普通视图

发现新文章,点击刷新页面。
昨天 — 2025年11月20日首页

深入 Vue3 响应式系统:手写 Computed 和 Watch 的奥秘

作者 云枫晖
2025年11月20日 13:36

在 Vue3 的响应式系统中,计算属性和监听器是我们日常开发中频繁使用的特性。但你知道它们背后的实现原理吗?本文将带你从零开始,手写实现 computed 和 watch,深入理解其设计思想和实现细节。

引言:为什么需要计算属性和监听器?

在Vue应用开发中,我们经常遇到这样的场景:

  • 派生状态:基于现有状态计算新的数据
  • 副作用处理:当特定数据变化时执行相应操作

Vue3提供了computedwatch来优雅解决这些问题。但仅仅会使用还不够,深入理解其底层原理能让我们在复杂场景下更加得心应手。

手写实现Computed

computed的核心特性包括:

  • 惰性计算:只有依赖的响应式数据变化时才重新计算
  • 值缓存:避免重复计算提升性能
  • 依赖追踪:自动收集依赖关系

computed函数接收一个参数,类型函数或者一个对象,对象包含getset方法,get方法是必须得。基本框架就出来了:

export function computed(getterOrOptions) {
  let getter;
  let setter = undefined;
  if (isFunction(getterOrOptions)) {
    getter = getterOrOptions
  } else {
    getter = getterOrOptions.get;
    setter = getterOrOptions.set;
  }
}

当你使用过computed函数时,你会发现会返回一个ComputedRefImpl类型的实例。代码就可以进一步写成下面的样子:

export class ComputedRefImpl {
  constructor(getter, setter) {
    this.getter = getter;
    this.setter = isFunction(setter) ? setter : undefined;
  }
}
export function computed(getterOrOptions) {
  /* 上述代码实现省略 */
  const cRef = new ComputedRefImpl(getter, setter);
  return cRef;
}

ComputedRefImpl的实现

ComputedRefImpl类中有几个主要的属性:

  • _value:缓存的计算结果
  • _v_isRef:表示这是一个ref对象,可以通过.value访问
  • effect 响应式副作用实例
  • _dirty 脏值标记,true表示需要重新计算
  • dep 依赖收集容器,存储依赖当前计算属性的副作用 在初始化的时候,将会创建一个ReactiveEffect实例,此类型在手写Reactive中实现了。
class ComputedRefImpl {
  effect = undefined; // 响应式副作用实例
  _value = undefined; // 缓存的计算结果
  __v_isRef = true; // 标识这是一个ref对象,可以通过.value访问
  _dirty = true; // 脏值标记,true表示需要重新计算
  dep = undefined; // 依赖收集容器,存储依赖当前计算属性的副作用

  /**
   * 构造函数
   * @param {Function} getter - 计算属性的getter函数
   * @param {Function} setter - 计算属性的setter函数
   */
  constructor(getter, setter) {
    this.getter = getter;
    this.setter = isFunction(setter) ? setter : () => {};

    // 创建响应式副作用实例,当依赖的数据变化时会触发调度器
    this.effect = new ReactiveEffect(getter, () => {
      // 调度器函数 后续处理
    });
  }
}

通过get valueset value手机依赖和触发依赖

class ComputedRefImpl {
  /* 上述代码实现省略 */
  /**
   * 计算属性的getter
   * 实现缓存机制和依赖收集
   */
  get value() {
    // 如果存在激活的副作用,则进行依赖收集
    if (activeEffect) {
      trackEffects(this.dep || (this.dep = new Set()));
    }

    // 如果是脏值,则重新计算并缓存结果
    if (this._dirty) {
      this._value = this.effect.run(); // 执行getter函数获取新值
      this._dirty = false; // 清除脏值标记
    }

    return this._value; // 返回缓存的值
  }

  /**
   * 计算属性的setter
   * @param {any} newValue - 新的值
   */
  set value(newValue) {
    // 如果有setter函数,则调用它
    if (this.setter) {
      this.setter(newValue);
    }
  }
}

当依赖值发生变化后,将触发副作用的调度器,触发计算属性的副作用更新。

constructor(getter, setter) {
  this.getter = getter;
  this.setter = isFunction(setter) ? setter : () => {};

  // 创建响应式副作用实例,当依赖的数据变化时会触发调度器
  this.effect = new ReactiveEffect(getter, () => {
    // 调度器函数:当依赖变化时执行
    this._dirty = true; // 标记为脏值,下次访问时需要重新计算
    triggerEffects(this.dep); // 触发依赖当前计算属性的副作用更新
  });
}

完整代码及用法示例

import { isFunction } from "./utils";
import {
  activeEffect,
  ReactiveEffect,
  trackEffects,
  triggerEffects,
} from "./effect";

/**
 * 计算属性实现类
 * 负责管理计算属性的getter、setter以及缓存机制
 */
class ComputedRefImpl {
  effect = undefined; // 响应式副作用实例
  _value = undefined; // 缓存的计算结果
  __v_isRef = true; // 标识这是一个ref对象,可以通过.value访问
  _dirty = true; // 脏值标记,true表示需要重新计算
  dep = undefined; // 依赖收集容器,存储依赖当前计算属性的副作用

  /**
   * 构造函数
   * @param {Function} getter - 计算属性的getter函数
   * @param {Function} setter - 计算属性的setter函数
   */
  constructor(getter, setter) {
    this.getter = getter;
    this.setter = isFunction(setter) ? setter : () => {};

    // 创建响应式副作用实例,当依赖的数据变化时会触发调度器
    this.effect = new ReactiveEffect(getter, () => {
      // 调度器函数:当依赖变化时执行
      this._dirty = true; // 标记为脏值,下次访问时需要重新计算
      triggerEffects(this.dep); // 触发依赖当前计算属性的副作用更新
    });
  }

  /**
   * 计算属性的getter
   * 实现缓存机制和依赖收集
   */
  get value() {
    // 如果存在激活的副作用,则进行依赖收集
    if (activeEffect) {
      trackEffects(this.dep || (this.dep = new Set()));
    }

    // 如果是脏值,则重新计算并缓存结果
    if (this._dirty) {
      this._value = this.effect.run(); // 执行getter函数获取新值
      this._dirty = false; // 清除脏值标记
    }

    return this._value; // 返回缓存的值
  }

  /**
   * 计算属性的setter
   * @param {any} newValue - 新的值
   */
  set value(newValue) {
    // 如果有setter函数,则调用它
    if (this.setter) {
      this.setter(newValue);
    }
  }
}

/**
 * 创建计算属性的工厂函数
 * @param {Function|Object} getterOrOptions - getter函数或包含get/set的对象
 * @returns {ComputedRefImpl} 计算属性引用实例
 */
export const computed = (getterOrOptions) => {
  let getter; // getter函数
  let setter = undefined; // setter函数

  // 根据参数类型确定getter和setter
  if (isFunction(getterOrOptions)) {
    // 如果参数是函数,则作为getter
    getter = getterOrOptions;
  } else {
    // 如果参数是对象,则分别获取get和set方法
    getter = getterOrOptions.get;
    setter = getterOrOptions.set;
  }

  // 创建并返回计算属性实例
  const cRef = new ComputedRefImpl(getter, setter);
  return cRef;
};

示例用法:

import { reactive, computed } from "./packages/index";
const state = reactive({
  firstName: "tom",
  lastName: "lee",
  friends: ["jacob", "james", "jimmy"],
});
const fullName = computed({
  get() {
    return state.firstName + " " + state.lastName;
  },
  set(newValue) {
    [state.firstName, state.lastName] = newValue.split(" ");
  },
});
effect(() => {
  app.innerHTML = `
    <div> Welcome ${fullName.value} !</div>
  `;
});
setTimeout(() => {
  fullName.value = "jacob him";
}, 1000);
setTimeout(() => {
  console.log(state.firstName, state.lastName); // firstName: jacob lastName: him 
}, 2000);

手写实现Watch和WatchEffect

watch函数接收三个参数:

  • source:要监听的数据源,可以是响应式对象或函数
  • cb:数据变化时执行的回调函数
  • options 配置选项:immediate:是否立即执行,deep:是否深度监听等
export function watch(source, cb, {immediate = false} = {}) {
 // 待后续实现
}

1. watch的实现

首先source是否可以接受多种监听的数据源:响应式对象、多个监听数据源的数组、函数。将不同方式统一起来。

export function watch(source, cb, { immediate = false } = {}) {
  let getter;
  if (isReactive(source)) {
    // 如果是响应式对象 则调用traverse
    getter = () => traverse(source);
  } else if (isFunction(source)) {
    // 如果是函数 则直接执行
    getter = source;
  } else if (isArray(source)) {
    // 处理数组类型的监听源
    getter = () =>
      source.map((s) => {
        if (isReactive(s)) {
          return traverse(s);
        } else if (isFunction(s)) {
          return s();
        }
      });
  }
}
/**
 * 遍历对象及其嵌套属性的函数
 * @param {any} source - 需要遍历的源数据
 * @param {Set} s - 用于记录已访问对象的集合,避免循环引用
 * @returns {any} 返回原始输入数据
 */
export function traverse(source, s = new Set()) {
  // 检查是否为对象类型,如果不是则直接返回
  if (!isObject(source)) {
    return source;
  }
  // 检测循环引用,如果对象已被访问过则直接返回
  if (s.has(source)) {
    return source;
  }
  // 将当前对象加入已访问集合
  s.add(source);
  // 递归遍历对象的所有属性
  for (const key in source) {
    traverse(source[key], s);
  }
  return source;
}

处理完souce参数后,创建一个ReactiveEffect实例,对监听源产生响应式的副作用。

export function watch(source, cb, { immediate = false } = {}) {
  /* 上述代码以实现省略 */
  let oldValue;
  // 定义副作用执行的任务函数
  const job = () => {
    let newValue = effect.run(); // 获取最新值
    cb(oldValue, newValue); // 触发回调
    oldValue = newValue; // 新值赋给旧值
  };

  // 创建响应式副作用实例
  const effect = new ReactiveEffect(getter, job);
  if (immediate) {
    job();
  } else {
    oldValue = effect.run();
  }
}

⚠️ 性能注意

traverse函数会递归遍历对象的所有嵌套属性,在大型数据结构上使用深度监听(deep: true)时会产生显著性能开销。建议:

  • 只在必要时使用深度监听
  • 尽量使用具体的属性路径而非整个对象
  • 考虑使用计算属性来派生需要监听的数据

2. watchEffect的实现

实现了watch函数后,watchEffect的实现就容易了。

// watchEffect.js
import { watch } from "./watch";
export function watchEffect(effect, options) {
  return watch(effect, null, options);
}
// watch.js
const job = () => {
  if (cb) {
    let newValue = effect.run(); // 获取最新值
    cb(oldValue, newValue); // 触发回调
    oldValue = newValue; // 新值赋给旧值
  } else {
    effect.run(); // 处理watchEffect
  }
};

用法示例

watch([() => state.lastName, () => state.firstName], (oldValue, newValue) => {
  console.log("oldValue: " + oldValue, "newValue: " + newValue);
});
setTimeout(() => {
  state.lastName = "jacob";
}, 1000);
setTimeout(() => {
  state.firstName = "james";
}, 1000);
/*
1秒钟后:oldValue: lee,tom newValue: jacob,tom
2秒钟后:oldValue: jacob,tom newValue: jacob,james
*/

总结

本文核心内容

通过手写实现Vue3的computedwatch,我们深入理解了:

  • 计算属性的惰性计算、值缓存和依赖追踪机制
  • 监听器的多数据源处理和深度监听原理
  • 响应式系统中副作用调度和依赖收集的完整流程

代码地址

📝 本文完整代码
[GitHub仓库链接] | [github.com/gardenia83/…]

下篇预告

在下一篇中,我们将继续深入Vue3响应式系统,手写实现:

《深入 Vue3 响应式系统:从ref到toRefs的完整实现》

  • refshallowRef的底层机制
  • toReftoRefs的响应式转换原理
  • 模板Ref和组件Ref的特殊处理
  • Ref自动解包的神秘面纱

敬请期待! 🚀


掌握底层原理,让我们的开发之路更加从容自信

MarsUI 引入项目的使用记录

作者 isixe
2025年11月20日 13:32

最近准备做数据大屏的项目,找了一些相关的UI控件,顺着 mars3d-vue-example 然后我就找到了它开源的 MarsUI 控件。但是这个控件只有源文件形式的,没有上传到 npm 库,所以我们就得手动引入了。

依赖安装

Mars3d 的开源模板项目 mars3d-vue-example 中,提供有一套完整的控件样板的源码文件,这些基础控件是在 Ant Design Vue 组件库的基础上进行编写的,Mard3d 主要封装了表单控件,所以所有控件依赖于 Ant Design Vue 组件库。

虽然在 mars3d-vue-example 中列出的相关依赖,但是这并不完全

image.png

实际需要的完整依赖还得补充 3 个,缺少了 lodash-es、dayjs 和 less 这三个依赖

  "dependencies": {
    "@icon-park/svg": "^1.4.2",
    "@turf/turf": "^7.2.0",
    "ant-design-vue": "^4.0.7",
    "consola": "^3.2.3",
    "echarts": "^5.4.3",
    "nprogress": "^0.2.0",
    "vite-plugin-style-import": "^2.0.0",
    "vue-color-kit": "^1.0.6"
    // 任意版本安装
    "vue": "^3.5.13",
    "lodash-es": "^4.17.21",
    "dayjs": "^1.11.19",
    "less": "^4.4.2",
  },

我们直接使用 pnpm 快速安装

npm install @icon-park/svg@^1.4.2 @turf/turf@^7.2.0 ant-design-vue@^4.0.7 consola@^3.2.3 echarts@^5.4.3 nprogress@^0.2.0 vite-plugin-style-import@^2.0.0 vue-color-kit@^1.0.6 vue lodash-es dayjs less

//or

yarn install @icon-park/svg@^1.4.2 @turf/turf@^7.2.0 ant-design-vue@^4.0.7 consola@^3.2.3 echarts@^5.4.3 nprogress@^0.2.0 vite-plugin-style-import@^2.0.0 vue-color-kit@^1.0.6 vue lodash-es dayjs less

//or

pnpm add @icon-park/svg@^1.4.2 @turf/turf@^7.2.0 ant-design-vue@^4.0.7 consola@^3.2.3 echarts@^5.4.3 nprogress@^0.2.0 vite-plugin-style-import@^2.0.0 vue-color-kit@^1.0.6 vue lodash-es dayjs less

组件引入

我们需要将 mars3d-vue-example 的项目文件拉取下来,然后把 components/mars-ui 这个文件夹整个复制到我们的项目中

image.png

然后在 main.js 中进行组件的批量注册

import MarsUIInstall from "@mars/components/mars-ui"

const app = createApp(Application)

MarsUIInstall(app)

配置 Antdv 和 引入 Less 样式文件

前面我们提到 MarsUI 是依赖于 Antdv,并且在组件中使用了 Less,所以我们需要在 vite.config.js 中增加下面的配置

import { createStyleImportPlugin, AndDesignVueResolve } from "vite-plugin-style-import"
import path from 'path';

export default defineConfig({
  css: {
    preprocessorOptions: {
      less: {
        javascriptEnabled: true,
        additionalData: `@import "${path.resolve(
          __dirname,
          "src/components/mars-ui/base.less"
        )}";`,
      },
    },
  },
  plugins: [
    vue(),
    createStyleImportPlugin({
      resolves: [AndDesignVueResolve()],
      libs: [
        {
          libraryName: "ant-design-vue",
          esModule: true,
          resolveStyle: (name) => {
            if (name === "auto-complete") {
              return `ant-design-vue/es/${name}/index`;
            }
            return `ant-design-vue/es/${name}/style/index`;
          },
        },
      ],
    }),
  ],
});

配置完成,重启一下项目我们就能在项目中按需导入 MarsUI 的控件了。

参考

基于 Vue2 封装大华 RTSP 回放视频组件(PlayerControl.js 实现)

作者 西愚wo
2025年11月20日 11:16

参考链接:基于 Vue3 封装大华 RTSP 回放视频组件(PlayerControl.js 实现)_vue playercontrol大华的使用-CSDN博客

官方教程: WEB无插件开发包使用说明-浙江大华技术股份有限公司

碰到的问题: 1、PlayerControl.js默认是在根目录使用,如果不是在根目录使用需要修改对应文件中的的路径(我的前缀是cockpit)不然会找不到对应的文件

image.png

image.png

2、我对接的大华的摄像头是H265格式的只能在canvas中展现出来,我这边的功能需要是对视频进行回放和参考链接类似,但是参考链接是能在video中展示因此不需要添加播放、暂停、音量开关、抓图、刷新、全屏功能,canvas中就需要手动添加

<template>
  <el-dialog :title="'文件预览'" class="prev-file-dialog" append-to-body :visible.sync="DialogVisible" :fullscreen="true">
    <template #title>
      <div class="vn-flex vn-flex-space-between vn-gap-8 vn-flex-y-center vn-fill-width">
        <span>{{ '文件预览' }}</span>
      </div>
    </template>
    <div class="preview-pdf">
      <canvas ref="canvasElement" :style="{ width: '100%', height: '100%' }"></canvas>
      <div class="operation">
        <div class="operation-left vn-flex vn-flex-y-center vn-gap-8">
          <div
            class="play icon vn-pointer"
            :class="canvasOperation.playState ? 'el-icon-video-pause' : 'el-icon-video-play'"
            @click="handlePlay"
          ></div>
          <div class="disconnect icon"></div>
          <button class="control-btn" @click.stop="toggleMute">
            <span v-if="!canvasOperation.muteState" class="icon-volume">🔊</span>
            <span v-else class="icon-muted">🔇</span>
          </button>

          <div class="timestamp vn-flex vn-flex-y-center vn-gap-4">
            <span class="first-time">{{ canvasOperation.firstTime }}</span>
            /
            <span class="total-time">{{ canvasOperation.totalTime }}</span>
            <el-input-number
              v-model="canvasOperation.backTime"
              size="mini"
              clearable
              type="number"
              :min="0"
              :max="canvasOperation.totalTime"
              :controls="false"
              class="number-input"
              :precision="0"
            ></el-input-number>
            <el-button size="mini" type="primary" @click="playerBack">跳转</el-button>
          </div>
        </div>
        <div class="operation-right vn-flex vn-flex-y-center vn-gap-12">
          <!-- 捕获截图 -->
          <div class="el-icon-crop icon" @click="handleCapture"></div>

          <!-- 刷新 -->
          <div class="el-icon-refresh-right icon" @click="handleRefresh"></div>
          <!-- 全屏按钮 -->
          <div class="el-icon-full-screen icon" @click.stop="toggleFullscreen"></div>
        </div>
      </div>
    </div>
  </el-dialog>
</template>

<script lang="ts">
import { Component, Vue, Ref, Prop, PropSync } from 'vue-property-decorator'

@Component({
  name: 'DaHuaVideoPreview',
  components: {}
})
export default class DaHuaVideoPreview extends Vue {
  @Ref() canvasElement!: any
  @PropSync('visible', { default: false }) DialogVisible!: boolean
  // 接收外部参数
  @Prop({
    default: () => {
      return {
        wsURL: 'ws://xxx.xxx.xxx.xxx:9527/rtspoverwebsocket',
        url: '',
        ip: 'xxx.xxx.xxx.xxx',
        port: '9527',
        channel: 1,
        username: 'admin',
        password: 'admin123',
        proto: 'Private3',
        subtype: 0,
        starttime: '2025_11_10_09_10_00',
        endtime: '2025_11_10_10_10_00',
        width: '100%',
        height: '220px'
      }
    }
  })
  props!: any

  player: any = null
  canvasOperation = this.initCanvasData()

  initCanvasData() {
    return {
      playState: false,
      muteState: false,
      isFullscreen: false,
      backTime: 1,
      totalTime: 0,
      firstTime: 0
    }
  }
  playerStop() {
    this.player?.close()
  }

  playerPause() {
    this.player?.pause()
  }

  playerContinue() {
    // this.player?.play()
    this.playerPlay()
  }

  playerCapture() {
    this.player?.capture('test')
  }

  playerPlay() {
    if (this.player) {
      this.player.stop()
      this.player.close()
      this.player = null
    }
    if (!window.PlayerControl) {
      console.error('❌ PlayerControl SDK 未加载,请在 index.html 中引入 /module/playerControl.js')
      return
    }

    this.closePlayer()

    var options = {
      wsURL: `ws://${this.props.ip}:${this.props.port}/rtspoverwebsocket`,
      rtspURL: this.buildRtspUrl(),
      username: this.props.username,
      password: this.props.password,
      h265AccelerationEnabled: true
    }
    this.player = new window.PlayerControl(options)
    let firstTime = 0
    this.player.on('WorkerReady', (rs: any) => {
      console.log('WorkerReady')
      this.player.connect()
    })

    this.player.on('Error', (rs: any) => {
      console.log('error')
      console.log(rs)
    })
    this.player.on('PlayStart', () => {
      console.log('PlayStart')
      this.canvasOperation.playState = true
    })

    this.player.on('UpdateCanvas', (res: any) => {
      if (firstTime === 0) {
        firstTime = res.timestamp //获取录像文件的第一帧的时间戳
      }
      this.canvasOperation.firstTime = res.timestamp - firstTime
    })
    this.player.on('GetTotalTime', (res: any) => {
      console.log(res, 'GetTotalTime')
      this.canvasOperation.totalTime = res || 0
    })

    this.player.on('FileOver', (res: any) => {
      console.log(res, 'FileOver')
      this.handleRefresh()
    })
    this.player.init(this.canvasElement, null)
    window.__player = this.player
  }

  mounted() {
    console.log(this.props, 'props')
    this.$nextTick(() => {
      this.playerContinue()
    })
  }

  beforeDestroy() {
    this.closePlayer()
  }

  playerBack() {
    this.player.playByTime(this.canvasOperation.backTime)
  }

  /** 拼接 RTSP URL 回放 */
  buildRtspUrl() {
    if (this.props?.url) return this.props?.url
    return `rtsp://${this.props.ip}:${this.props.port}/cam/playback?channel=${this.props.channel}&subtype=${this.props.subtype}&starttime=${this.props.starttime}&endtime=${this.props.endtime}`
  }

  closePlayer() {
    if (this.player) {
      try {
        this.player.close()
      } catch (e) {
        console.warn('旧播放器关闭异常:', e)
      }
      this.player = null
    }
  }

  toggleMute() {
    this.canvasOperation.muteState = !this.canvasOperation.muteState
    // 如果要关闭声音,将 val 参数设置为 0 即可。WEB SDK 播放时,默认音量是 0。需要声音时,必须调用该方法,并且参数大于 0
    this.player.setAudioVolume(Number(this.canvasOperation.muteState))
  }

  toggleFullscreen() {
    const videoWrapper = this.$el.querySelector('.preview-pdf') as HTMLElement

    if (!document.fullscreenElement) {
      // 进入全屏
      if (videoWrapper.requestFullscreen) {
        videoWrapper.requestFullscreen()
      }
      this.canvasOperation.isFullscreen = true
    } else {
      // 退出全屏
      if (document.exitFullscreen) {
        document.exitFullscreen()
      }
      this.canvasOperation.isFullscreen = false
    }
  }
  //
  handlePlay() {
    if (!this.canvasOperation.playState) {
      this.player.play()
    } else {
      this.player.pause()
    }
    this.canvasOperation.playState = !this.canvasOperation.playState
  }

  handleCapture() {
    this.player.capture(new Date().getTime())
  }

  handleRefresh() {
    this.canvasOperation = this.initCanvasData()
    this.playerPlay()
  }
}
</script>
<style lang="scss" scoped>
.preview-pdf {
  height: 100%;
  width: 100%;

  position: relative;

  .operation {
    width: 100%;
    height: 40px;
    position: absolute;
    bottom: 0;
    right: 0;
    z-index: 1;
    background-color: rgb(0, 0, 0, 0.5);

    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    padding: 0 16px;
    .play {
      color: #fff;
      font-size: 16px;
    }
    .icon {
      font-size: 20px;
      color: #fff;
      cursor: pointer;
    }
    .control-btn {
      background-color: transparent;
      span {
        font-size: 18px;
      }
    }
  }
  .timestamp {
    color: #fff;
    flex-shrink: 0;

    .first-time,
    .total-time {
      flex-shrink: 0;
    }
    .number-input {
      width: 100px;
    }
  }
}

.prev-file-dialog {
  width: 100vw;
  height: 100vh;
  ::v-deep {
    .el-dialog.is-fullscreen {
      height: 100%;
    }
    .el-dialog {
      min-width: 80vw;
      height: calc(70vh);
    }
    .el-dialog__body {
      max-height: 100%;
      min-height: 0;
      height: 100%;
      overflow: hidden;
      display: flex;
      justify-content: center;
      align-items: center;
      padding: 12px !important;
      background: #fff;
    }
  }
}
</style>

实现效果:

image.png

Vue3+TS设计模式实战:5个场景让代码优雅翻倍

作者 宇余
2025年11月20日 11:00

在Vue3+TypeScript开发中,写“能跑的代码”很容易,但写“优雅、可维护、可扩展”的代码却需要思考。设计模式不是银弹,但合理运用能帮我们解决重复出现的问题,让代码结构更清晰、逻辑更健壮。

本文结合5个真实业务场景,讲解单例模式、工厂模式、观察者模式、策略模式、组合模式在Vue3+TS中的实践,每个场景都附完整代码示例和优化思路。

场景1:全局状态管理 - 单例模式

场景痛点

项目中需要全局状态管理(如用户信息、主题配置),如果多次创建状态实例,会导致状态不一致,且浪费资源。

设计模式应用:单例模式

单例模式确保一个类只有一个实例,并提供一个全局访问点。Vue3的Pinia本质就是单例模式的实现,但我们可以自定义更灵活的单例逻辑。

代码实现


// stores/singletonUserStore.ts
import { reactive, toRefs } from 'vue'

// 定义用户状态接口
interface UserState {
  name: string
  token: string
  isLogin: boolean
}

class UserStore {
  private static instance: UserStore
  private state: UserState

  // 私有构造函数,防止外部new
  private constructor() {
    this.state = reactive({
      name: '',
      token: localStorage.getItem('token') || '',
      isLogin: !!localStorage.getItem('token')
    })
  }

  // 全局访问点
  public static getInstance(): UserStore {
    if (!UserStore.instance) {
      UserStore.instance = new UserStore()
    }
    return UserStore.instance
  }

  // 业务方法
  public login(token: string, name: string) {
    this.state.token = token
    this.state.name = name
    this.state.isLogin = true
    localStorage.setItem('token', token)
  }

  public logout() {
    this.state.token = ''
    this.state.name = ''
    this.state.isLogin = false
    localStorage.removeItem('token')
  }

  // 暴露响应式状态
  public getState() {
    return toRefs(this.state)
  }
}

// 导出单例实例
export const userStore = UserStore.getInstance()

优雅之处

  • 全局唯一实例,避免状态冲突

  • 封装性强,状态修改只能通过实例方法,避免直接篡改

  • 结合TS接口,类型提示完整,减少类型错误

场景2:动态组件渲染 - 工厂模式

场景痛点

表单页面需要根据不同字段类型(输入框、下拉框、日期选择器)渲染不同组件,如果用if-else判断,代码会臃肿且难以维护。

设计模式应用:工厂模式

工厂模式定义一个创建对象的接口,让子类决定实例化哪个类。在Vue中,我们可以创建“组件工厂”,根据类型动态返回对应组件。

代码实现


<template>
  <div class="form-container">
    <component 
      v-for="field in fields" 
      :key="field.id"
      :is="getFormComponent(field.type)"
      v-model="formData[field.key]"
      :label="field.label"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import InputComponent from './components/InputComponent.vue'
import SelectComponent from './components/SelectComponent.vue'
import DatePickerComponent from './components/DatePickerComponent.vue'

// 定义字段类型
type FieldType = 'input' | 'select' | 'date'

interface Field {
  id: string
  key: string
  label: string
  type: FieldType
  options?: { label: string; value: string }[]
}

// 组件工厂:根据类型返回组件
const getFormComponent = (type: FieldType) => {
  switch (type) {
    case 'input':
      return InputComponent
    case 'select':
      return SelectComponent
    case 'date':
      return DatePickerComponent
    default:
      throw new Error(`不支持的字段类型:${type}`)
  }
}

// 表单数据和字段配置
const formData = ref({
  username: '',
  gender: '',
  birthday: ''
})

const fields: Field[] = [
  { id: '1', key: 'username', label: '用户名', type: 'input' },
  { 
    id: '2', 
    key: 'gender', 
    label: '性别', 
    type: 'select',
    options: [{ label: '男', value: 'male' }, { label: '女', value: 'female' }]
  },
  { id: '3', key: 'birthday', label: '生日', type: 'date' }
]
</script>

优雅之处

  • 消除大量if-else,代码结构清晰

  • 新增组件类型只需修改工厂函数,符合开闭原则

  • 字段配置与组件渲染分离,便于维护

场景3:跨组件通信 - 观察者模式

场景痛点

非父子组件(如Header和Footer)需要通信(如主题切换),用Props/Emits太繁琐,用Pinia又没必要(仅单一事件通信)。

设计模式应用:观察者模式

观察者模式定义对象间的一对多依赖关系,当一个对象状态改变时,所有依赖它的对象都会收到通知。我们可以实现一个简单的事件总线。

代码实现


// utils/eventBus.ts
class EventBus {
  // 存储事件订阅者
  private events: Record<string, ((...args: any[]) => void)[]> = {}

  // 订阅事件
  on(eventName: string, callback: (...args: any[]) => void) {
    if (!this.events[eventName]) {
      this.events[eventName] = []
    }
    this.events[eventName].push(callback)
  }

  // 发布事件
  emit(eventName: string, ...args: any[]) {
    if (this.events[eventName]) {
      this.events[eventName].forEach(callback => callback(...args))
    }
  }

  // 取消订阅
  off(eventName: string, callback?: (...args: any[]) => void) {
    if (!this.events[eventName]) return

    if (callback) {
      this.events[eventName] = this.events[eventName].filter(cb => cb !== callback)
    } else {
      delete this.events[eventName]
    }
  }
}

// 导出单例事件总线
export const eventBus = new EventBus()

使用示例:


<!-- Header.vue -->
<script setup lang="ts">
import { eventBus } from '@/utils/eventBus'

const toggleTheme = () => {
  // 发布主题切换事件
  eventBus.emit('theme-change', 'dark')
}
</script>

<!-- Footer.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { eventBus } from '@/utils/eventBus'

const theme = ref('light')

const handleThemeChange = (newTheme: string) => {
  theme.value = newTheme
}

onMounted(() => {
  // 订阅主题切换事件
  eventBus.on('theme-change', handleThemeChange)
})

onUnmounted(() => {
  // 取消订阅,避免内存泄漏
  eventBus.off('theme-change', handleThemeChange)
})
</script>

优雅之处

  • 解耦组件,无需关注组件层级关系

  • 轻量级通信,比Pinia更适合简单场景

  • 支持订阅/取消订阅,避免内存泄漏

场景4:表单验证 - 策略模式

场景痛点

表单需要多种验证规则(必填、邮箱格式、密码强度),如果把验证逻辑写在一起,代码会混乱且难以复用。

设计模式应用:策略模式

策略模式定义一系列算法,把它们封装起来,并且使它们可相互替换。我们可以将不同验证规则封装为“策略”,动态选择使用。

代码实现


// utils/validator.ts
// 定义验证规则接口
interface ValidationRule {
  validate: (value: string) => boolean
  message: string
}

// 验证策略集合
const validationStrategies: Record<string, ValidationRule> = {
  // 必填验证
  required: {
    validate: (value) => value.trim() !== '',
    message: '此字段不能为空'
  },
  // 邮箱验证
  email: {
    validate: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
    message: '请输入正确的邮箱格式'
  },
  // 密码强度验证(至少8位,含字母和数字)
  password: {
    validate: (value) => /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/.test(value),
    message: '密码至少8位,包含字母和数字'
  }
}

// 验证器类
class Validator {
  private rules: Record<string, string[]> = {} // { field: [rule1, rule2] }

  // 添加验证规则
  addField(field: string, rules: string[]) {
    this.rules[field] = rules
  }

  // 执行验证
  validate(formData: Record<string, string>): Record<string, string> {
    const errors: Record<string, string> = {}

    Object.entries(this.rules).forEach(([field, rules]) => {
      const value = formData[field]
      for (const rule of rules) {
        const strategy = validationStrategies[rule]
        if (!strategy.validate(value)) {
          errors[field] = strategy.message
          break // 只要有一个规则不通过,就停止该字段验证
        }
      }
    })

    return errors
  }
}

export { Validator }

使用示例:


<script setup lang="ts">
import { ref } from 'vue'
import { Validator } from '@/utils/validator'

const formData = ref({
  email: '',
  password: ''
})

const errors = ref<Record<string, string>>({})

const handleSubmit = () => {
  // 创建验证器实例
  const validator = new Validator()
  // 添加验证规则
  validator.addField('email', ['required', 'email'])
  validator.addField('password', ['required', 'password'])
  // 执行验证
  const validateErrors = validator.validate(formData.value)
  
  if (Object.keys(validateErrors).length === 0) {
    // 验证通过,提交表单
    console.log('提交成功', formData.value)
  } else {
    errors.value = validateErrors
  }
}
</script>

优雅之处

  • 验证规则与业务逻辑分离,可复用性强

  • 新增规则只需扩展策略集合,符合开闭原则

  • 验证逻辑清晰,便于维护和测试

场景5:树形结构组件 - 组合模式

场景痛点

开发权限菜单、文件目录等树形组件时,需要处理单个节点和子节点的统一操作(如展开/折叠、勾选),递归逻辑复杂。

设计模式应用:组合模式

组合模式将对象组合成树形结构以表示“部分-整体”的层次结构,使得用户对单个对象和组合对象的使用具有一致性。

代码实现


// utils/treeNode.ts
// 定义节点接口
interface TreeNodeProps {
  id: string
  label: string
  children?: TreeNodeProps[]
  expanded?: boolean
  checked?: boolean
}

class TreeNode {
  public id: string
  public label: string
  public children: TreeNode[] = []
  public expanded: boolean
  public checked: boolean

  constructor(props: TreeNodeProps) {
    this.id = props.id
    this.label = props.label
    this.expanded = props.expanded ?? false
    this.checked = props.checked ?? false
    // 递归创建子节点
    if (props.children) {
      this.children = props.children.map(child => new TreeNode(child))
    }
  }

  // 展开/折叠节点
  toggleExpand() {
    this.expanded = !this.expanded
  }

  // 勾选节点(并联动子节点)
  toggleCheck() {
    this.checked = !this.checked
    this.children.forEach(child => {
      child.setChecked(this.checked)
    })
  }

  // 设置节点勾选状态
  setChecked(checked: boolean) {
    this.checked = checked
    this.children.forEach(child => {
      child.setChecked(checked)
    })
  }

  // 获取所有勾选的节点ID
  getCheckedIds(): string[] {
    const checkedIds: string[] = []
    if (this.checked) {
      checkedIds.push(this.id)
    }
    this.children.forEach(child => {
      checkedIds.push(...child.getCheckedIds())
    })
    return checkedIds
  }
}

export { TreeNode }

使用示例:


<template>
  <ul class="tree-list">
    <tree-node-item :node="treeRoot" />
  </ul>
</template>

<script setup lang="ts">
import { TreeNode } from '@/utils/treeNode'
import TreeNodeItem from './TreeNodeItem.vue'

// 初始化树形数据
const treeData = {
  id: 'root',
  label: '权限菜单',
  children: [
    {
      id: '1',
      label: '用户管理',
      children: [
        { id: '1-1', label: '查看用户' },
        { id: '1-2', label: '编辑用户' }
      ]
    },
    { id: '2', label: '角色管理' }
  ]
}

const treeRoot = new TreeNode(treeData)
</script>

<!-- TreeNodeItem.vue 递归组件 -->
<template>
  <li class="tree-node">
    <div @click="node.toggleExpand()" class="node-label">
      <span v-if="node.children.length">{{ node.expanded ? '▼' : '►' }}</span>
      <input type="checkbox" :checked="node.checked" @change="node.toggleCheck()">
      {{ node.label }}
    </div>
    <ul v-if="node.expanded && node.children.length" class="tree-children">
      <tree-node-item v-for="child in node.children" :key="child.id" :node="child" />
    </ul>
  </li>
</template>

<script setup lang="ts">
import { defineProps } from 'vue'
import { TreeNode } from '@/utils/treeNode'

defineProps<{
  node: TreeNode
}>()
</script>

优雅之处

  • 统一处理单个节点和子节点,无需区分“部分”和“整体”

  • 递归逻辑封装在TreeNode类中,组件只负责渲染

  • 树形操作(勾选、展开)职责单一,便于扩展

总结

设计模式不是“炫技”,而是解决问题的“方法论”。在Vue3+TS开发中:

  • 单例模式适合全局状态、工具类等唯一实例场景

  • 工厂模式适合动态创建组件、服务等场景

  • 观察者模式适合跨组件通信、事件监听场景

  • 策略模式适合表单验证、算法切换等场景

  • 组合模式适合树形结构、层级数据场景

合理运用这些模式,能让你的代码更优雅、更可维护。当然,设计模式也不是万能的,要根据实际业务场景选择合适的方案,避免过度设计。

.sync 修饰符 | vue前端知识

2025年11月20日 01:32

.sync 修饰符

在 Vue 2 中,.sync 修饰符是实现父子组件双向数据绑定的语法糖,特别适合用于弹窗这类需要子组件修改父组件状态的场景。

基本概念

.sync 修饰符,实际上就是将父组件的属性,通过 .sync 修饰符,映射到子组件的 props 属性中,并监听子组件的 props 属性的修改,将修改后的值,通过 $emit 事件发送给父组件,从而实现父子组件的数据同步。

本质是自动为你扩展了一个 v-on 监听器。

<!-- 使用 .sync 的写法 -->
<ChildComponent :visible.sync="dialogVisible" />

<!-- 等效于完整写法 -->
<ChildComponent
    :visible="dialogVisible"
    @update:visible="val => dialogVisible = val"
/>

举例

1. 引入并注册弹窗组件

import MyDialog from "./MyDialog.vue";

export default {
    components: {
        MyDialog,
    },
    data() {
        return {
            showDialog: false,
        };
    },
};

2. 注册点击事件并绑定弹窗组件

<template>
    <div class="parent">
        <button @click="showDialog = true">打开弹窗</button>

        <!-- 使用 .sync 修饰符 -->
        <MyDialog :visible.sync="showDialog" title="示例弹窗" />
    </div>
</template>

3. 子组件:弹窗逻辑

3.1 定义接收的 props
props: {
  visible: {
    type: Boolean,
    default: false
  },
  title: {
    type: String,
    default: '弹窗标题'
  }
},
3.2 使用 v-if 控制弹窗显示
<div class="dialog-overlay" v-if="visible">
    <div class="dialog-content">
        <div class="dialog-header">
            <h3>{{ title }}</h3>
            <button class="close-btn" @click="closeDialog">×</button>
        </div>
        <div class="dialog-body">
            <p>这是一个弹窗内容</p>
            <slot></slot>
            <!-- 支持插槽内容 -->
        </div>
        <div class="dialog-footer">
            <button @click="closeDialog">取消</button>
            <button @click="confirm">确定</button>
        </div>
    </div>
</div>
3.3 关闭弹窗逻辑
methods: {
  closeDialog() {
    // 关键:使用 update:visible 模式触发事件
    this.$emit('update:visible', false);
  },
  confirm() {
    console.log('确认操作');
    this.closeDialog();
  }
},
3.4 监听 visible 变化(可选)
watch: {
  // 监听 visible 变化,处理外部对弹窗的关闭
  visible(newVal) {
    if (!newVal) {
      // 弹窗关闭时的清理操作
    }
  }
}

详细代码

父组件(使用弹窗)
<template>
    <div class="parent">
        <button @click="showDialog = true">打开弹窗</button>

        <!-- 使用.sync修饰符 -->
        <MyDialog :visible.sync="showDialog" title="示例弹窗" />
    </div>
</template>

<script>
    import MyDialog from "./MyDialog.vue";

    export default {
        components: {
            MyDialog,
        },
        data() {
            return {
                showDialog: false,
            };
        },
    };
</script>
子组件弹窗(MyDialog.vue)
<template>
    <div class="dialog-overlay" v-if="visible">
        <div class="dialog-content">
            <div class="dialog-header">
                <h3>{{ title }}</h3>
                <button class="close-btn" @click="closeDialog">×</button>
            </div>
            <div class="dialog-body">
                <p>这是一个弹窗内容</p>
                <slot></slot>
                <!-- 支持插槽内容 -->
            </div>
            <div class="dialog-footer">
                <button @click="closeDialog">取消</button>
                <button @click="confirm">确定</button>
            </div>
        </div>
    </div>
</template>

<script>
    export default {
        props: {
            visible: {
                type: Boolean,
                default: false,
            },
            title: {
                type: String,
                default: "弹窗标题",
            },
        },
        methods: {
            closeDialog() {
                // 关键:使用update:visible模式触发事件
                this.$emit("update:visible", false);
            },
            confirm() {
                // 执行确认操作...
                console.log("确认操作");
                this.closeDialog();
            },
        },
        watch: {
            // 监听visible变化,处理外部对弹窗的关闭
            visible(newVal) {
                if (!newVal) {
                    // 弹窗关闭时的清理操作
                }
            },
        },
    };
</script>

<style scoped>
    // 样式
</style>

总结

  1. .sync 的作用

    • 实现父子组件双向绑定,简化代码。
    • 特别适合弹窗等需要频繁切换显示状态的场景。
  2. 核心逻辑

    • 父组件通过 .sync 将状态传递给子组件。
    • 子组件通过 $emit('update:visible', value) 修改父组件的状态。
  3. 可选功能

    • 如果需要在弹窗关闭时执行清理操作,可以使用 watch 监听 visible 的变化。
  4. 使用场景

    • 弹窗显示/隐藏控制

    • 表单编辑对话框

    • 确认对话框

    • 设置面板

    • 任何需要子组件修改父组件状态的场景

Vue高阶组件已过时?这3种新方案让你的代码更优雅

2025年11月20日 07:30

还记得那些年被高阶组件支配的恐惧吗?props命名冲突、组件嵌套过深、调试困难...每次修改组件都像在拆炸弹。如果你还在Vue 3中苦苦挣扎如何复用组件逻辑,今天这篇文章就是为你准备的。

我将带你彻底告别HOC的痛点,掌握3种更现代、更优雅的代码复用方案。这些方案都是基于Vue 3的Composition API,不仅解决了HOC的老问题,还能让你的代码更加清晰和可维护。

为什么说HOC在Vue 3中已经过时?

先来看一个典型的高阶组件例子。假设我们需要给多个组件添加用户登录状态检查:

// 传统的HOC实现
function withAuth(WrappedComponent) {
  return {
    name: `WithAuth${WrappedComponent.name}`,
    data() {
      return {
        isLoggedIn: false,
        userInfo: null
      }
    },
    async mounted() {
      // 检查登录状态
      const user = await checkLoginStatus()
      this.isLoggedIn = !!user
      this.userInfo = user
    },
    render() {
      // 传递所有props和事件
      return h(WrappedComponent, {
        ...this.$attrs,
        ...this.$props,
        isLoggedIn: this.isLoggedIn,
        userInfo: this.userInfo
      })
    }
  }
}

// 使用HOC
const UserProfileWithAuth = withAuth(UserProfile)

这个HOC看似解决了问题,但实际上带来了不少麻烦。首先是props冲突风险,如果被包裹的组件已经有isLoggedIn这个prop,就会产生命名冲突。其次是调试困难,在Vue Devtools中你会看到一堆WithAuth前缀的组件,很难追踪原始组件。

最重要的是,在Vue 3的Composition API时代,我们有更好的选择。

方案一:Composition函数 - 最推荐的替代方案

Composition API的核心思想就是逻辑复用,让我们看看如何用composable函数重构上面的认证逻辑:

// 使用Composition函数
import { ref, onMounted } from 'vue'
import { checkLoginStatus } from '@/api/auth'

// 将认证逻辑提取为独立的composable
export function useAuth() {
  const isLoggedIn = ref(false)
  const userInfo = ref(null)
  const loading = ref(true)

  const checkAuth = async () => {
    try {
      loading.value = true
      const user = await checkLoginStatus()
      isLoggedIn.value = !!user
      userInfo.value = user
    } catch (error) {
      console.error('认证检查失败:', error)
      isLoggedIn.value = false
      userInfo.value = null
    } finally {
      loading.value = false
    }
  }

  onMounted(() => {
    checkAuth()
  })

  return {
    isLoggedIn,
    userInfo,
    loading,
    checkAuth
  }
}

// 在组件中使用
import { useAuth } from '@/composables/useAuth'

export default {
  name: 'UserProfile',
  setup() {
    const { isLoggedIn, userInfo, loading } = useAuth()

    return {
      isLoggedIn,
      userInfo,
      loading
    }
  }
}

这种方式的优势很明显。逻辑完全独立,不会产生props冲突。在Devtools中调试时,你能清晰地看到原始组件和响应式数据。而且这个useAuth函数可以在任何组件中复用,不需要额外的组件嵌套。

方案二:渲染函数与插槽的完美结合

对于需要控制UI渲染的场景,我们可以结合渲染函数和插槽来实现更灵活的逻辑复用:

// 使用渲染函数和插槽
import { h } from 'vue'

export default {
  name: 'AuthWrapper',
  setup(props, { slots }) {
    const { isLoggedIn, userInfo, loading } = useAuth()

    return () => {
      if (loading.value) {
        // 加载状态显示加载UI
        return slots.loading ? slots.loading() : h('div', '加载中...')
      }

      if (!isLoggedIn.value) {
        // 未登录显示登录提示
        return slots.unauthorized ? slots.unauthorized() : h('div', '请先登录')
      }

      // 已登录状态渲染默认插槽,并传递用户数据
      return slots.default ? slots.default({
        user: userInfo.value
      }) : null
    }
  }
}

// 使用方式
<template>
  <AuthWrapper>
    <template #loading>
      <div class="skeleton-loader">正在检查登录状态...</div>
    </template>
    
    <template #unauthorized>
      <div class="login-prompt">
        <h3>需要登录</h3>
        <button @click="redirectToLogin">立即登录</button>
      </div>
    </template>
    
    <template #default="{ user }">
      <UserProfile :user="user" />
    </template>
  </AuthWrapper>
</template>

这种方式保留了组件的声明式特性,同时提供了完整的UI控制能力。你可以为不同状态提供不同的UI,而且组件结构在Devtools中保持清晰。

方案三:自定义指令处理DOM相关逻辑

对于需要直接操作DOM的逻辑复用,自定义指令是不错的选择:

// 权限控制指令
import { useAuth } from '@/composables/useAuth'

const authDirective = {
  mounted(el, binding) {
    const { isLoggedIn, userInfo } = useAuth()
    
    const { value: requiredRole } = binding
    
    // 如果没有登录,隐藏元素
    if (!isLoggedIn.value) {
      el.style.display = 'none'
      return
    }
    
    // 如果需要特定角色但用户没有权限,隐藏元素
    if (requiredRole && userInfo.value?.role !== requiredRole) {
      el.style.display = 'none'
    }
  },
  updated(el, binding) {
    // 权限变化时重新检查
    authDirective.mounted(el, binding)
  }
}

// 注册指令
app.directive('auth', authDirective)

// 在模板中使用
<template>
  <button v-auth>只有登录用户能看到这个按钮</button>
  <button v-auth="'admin'">只有管理员能看到这个按钮</button>
</template>

自定义指令特别适合处理这种与DOM操作相关的逻辑,代码简洁且易于理解。

实战对比:用户权限管理场景

让我们通过一个完整的用户权限管理例子,对比一下HOC和新方案的差异:

// 传统HOC方式 - 不推荐
function withUserRole(WrappedComponent, requiredRole) {
  return {
    data() {
      return {
        currentUser: null
      }
    },
    computed: {
      hasPermission() {
        return this.currentUser?.role === requiredRole
      }
    },
    render() {
      if (!this.hasPermission) {
        return h('div', '无权限访问')
      }
      return h(WrappedComponent, {
        ...this.$attrs,
        ...this.$props,
        user: this.currentUser
      })
    }
  }
}

// Composition函数方式 - 推荐
export function useUserPermission(requiredRole) {
  const { userInfo } = useAuth()
  const hasPermission = computed(() => {
    return userInfo.value?.role === requiredRole
  })
  
  return {
    hasPermission,
    user: userInfo
  }
}

// 在组件中使用
export default {
  setup() {
    const { hasPermission, user } = useUserPermission('admin')
    
    if (!hasPermission.value) {
      return () => h('div', '无权限访问')
    }
    
    return () => h(AdminPanel, { user })
  }
}

Composition方式不仅代码更简洁,而且类型推断更友好,测试也更容易。

迁移指南:从HOC平稳过渡

如果你有现有的HOC代码需要迁移,可以按照以下步骤进行:

首先,识别HOC的核心逻辑。比如上面的withAuth核心就是认证状态管理。

然后,将核心逻辑提取为Composition函数:

// 将HOC逻辑转换为composable
function withAuthHOC(WrappedComponent) {
  return {
    data() {
      return {
        isLoggedIn: false,
        userInfo: null
      }
    },
    async mounted() {
      const user = await checkLoginStatus()
      this.isLoggedIn = !!user
      this.userInfo = user
    },
    render() {
      return h(WrappedComponent, {
        ...this.$props,
        isLoggedIn: this.isLoggedIn,
        userInfo: this.userInfo
      })
    }
  }
}

// 转换为
export function useAuth() {
  const isLoggedIn = ref(false)
  const userInfo = ref(null)
  
  onMounted(async () => {
    const user = await checkLoginStatus()
    isLoggedIn.value = !!user
    userInfo.value = user
  })
  
  return { isLoggedIn, userInfo }
}

最后,逐步替换项目中的HOC使用,可以先从新组件开始采用新方案,再逐步重构旧组件。

选择合适方案的决策指南

面对不同的场景,该如何选择最合适的方案呢?

当你需要复用纯逻辑时,比如数据获取、状态管理,选择Composition函数。这是最灵活和可复用的方案。

当你需要复用包含UI的逻辑时,比如加载状态、空状态,选择渲染函数与插槽组合。这提供了最好的UI控制能力。

当你需要操作DOM时,比如权限控制隐藏、点击外部关闭,选择自定义指令。这是最符合Vue设计理念的方式。

记住一个原则:能用Composition函数解决的问题,就不要用组件包装。保持组件的纯粹性,让逻辑和UI分离。

拥抱Vue 3的新范式

通过今天的分享,相信你已经看到了Vue 3为逻辑复用带来的全新可能性。从HOC到Composition API,不仅仅是API的变化,更是开发思维的升级。

HOC代表的组件包装模式已经成为过去,而基于函数的组合模式正是未来。这种转变让我们的代码更加清晰、可测试、可维护。

下次当你想要复用逻辑时,不妨先想一想:这个需求真的需要包装组件吗?还是可以用一个简单的Composition函数来解决?

希望这些方案能够帮助你写出更优雅的Vue代码。如果你在迁移过程中遇到任何问题,欢迎在评论区分享你的经历和困惑。

昨天以前首页

Cloudflare 崩溃梗图

作者 冴羽
2025年11月19日 20:23

1. 新闻

昨天,Cloudflare 崩了。

随后,OpenAI、X、Spotify、AWS、Shopify 等大型网站也崩了。

据说全球 20% 的网站都受到波及,不知道你是否也被影响了?

2. 事故原因

整个事故持续了 5 个小时,根据 Cloudflare 的报告,最初公司怀疑是遭到了超大规模 DDoS 攻击,不过很快就发现了核心问题。

事故的根本原因是因为 Cloudflare 内部的一套用于识别和阻断恶意机器人流量的自动生成配置文件。

该配置文件在例行升级后规模意外变大,远超系统预期,撑爆了路由网络流量的软件限制,继而导致大量流量被标记为爬虫而被 Ban。

CEO 发布了道歉声明:

不过这也不是第一次发生这种大规模事故了。

一个月前,亚马逊 AWS 刚出现持续故障,超过一千个网站和在线应用数小时瘫痪。

今年 7 月,美国网络安全服务提供商 CrowdStrike 的一次软件升级错误则造成全球范围蓝屏事故,机场停航、银行受阻、医院手术延期,影响持续多日。

3. 梗图

每次这种大事故都会有不少梗图出现,这次也不少。

3.1. 第一天上班

苦了这位缩写为 SB 的老哥 😂

3.2. 真正的底座

原本你以为的 Cloudflare:

经过这次事故,实际的 Cloudflare:

3.3. 死循环

3.4. 按秒赔偿

3.5. 影响到我了

3.6. 影响惨了

3.7. 这是发动战争了?

3.8. 加速失败

3.9. mc 亦有记载

把 16MB 中文字体压到 400KB:我写了一个 Vite 字体子集插件

2025年11月19日 14:47

一个真实的前端项目里,我遇到一个很常见却又容易被忽视的问题:一套中文界面,为了保证视觉效果引入了整套中文字体文件,结果单字体就占了十几 MB,构建产物和安装包体积都被严重拖累。现有方案要么依赖运行时,要么在工程化集成上不太符合我的需求。于是我决定写一个专门面向 Vite 的字体子集化插件 @fe-fast/vite-plugin-font-subset:在构建阶段自动收集实际用到的字符集,生成子集化后的 woff2 字体,并无缝替换原有资源,不侵入业务代码。在这篇文章里,我会分享这个插件诞生的背景、设计目标、关键实现思路,以及在真实项目中带来的体积优化效果,希望能给同样被“中文字体体积”困扰的你一些参考。

一、项目背景:16MB 的中文字体,把包体积拖垮了

我日常主要做的一个项目,是基于 Vue3 + Vite + Pinia + Electron 的桌面应用(电力行业业务系统)。
这个项目有两个典型特点:

  • 中文界面 + 大量业务术语:几乎所有页面都是中文,且有不少专业名词
  • 离线/弱网场景:不仅要打成 Electron 安装包,还要支持在弱网环境下更新

随着功能越来越多,我开始频繁在构建日志里看到这样一段“刺眼”的内容:

  • src/SiYuanHeiTi 目录里有两份 SourceHanSansCN OTF 字体
  • 每一份大概 8MB+,加起来就是 16MB+ 的纯字体资源

哪怕我已经做了一些优化:

  • 图片用 vite-plugin-imagemin 压缩
  • 代码做了基础的拆包和懒加载

构建产物里字体资源仍然是绝对大头
简单说:用户只是打开一个中文界面,却要被迫下载完整一套 GBK 字库,这显然太浪费了。

二、降体积的几种思路,对比一下

在真正动手写插件之前,我先把可能的方案都过了一遍,权衡了一下利弊。

方案 1:换成系统字体 / 常见 Web 字体

  • 优势
    • 不需要额外的字体文件,体积几乎为 0
  • 劣势
    • 设计同学辛苦做的 UI 风格会被破坏
    • 跨平台(Windows/macOS)渲染效果不可控,特别是复杂表格、图形界面
  • 适用场景
    • 对视觉统一要求不高的后台系统、管理台
  • 实现难度:⭐

方案 2:直接引入现成的字体子集化工具 / 在线服务

  • 优势
    • 现有方案成熟,不用自己“造轮子”
  • 劣势
    • 有些是在线服务,不适合公司内网/离线场景
    • 一些工具只关注命令行,不关注 Vite 构建流程 的无缝集成
  • 适用场景
    • 纯 Web 项目、对 CI/CD 环境更自由的团队
  • 实现难度:⭐⭐⭐

方案 3:使用已有的 Vite 字体子集插件

我也尝试过社区已有的 vite-plugin-font-subset 等插件,但踩到了两个坑:

  1. ESM-only 与现有工程的兼容问题
    • 有的插件是纯 ESM 包,而我当时的构建链路里,

      vite.config.js 仍然是以 CJS 方式被 esbuild 处理

    • 直接 import 会在加载配置阶段就报:

      ESM file cannot be loaded by require

  2. “大量中文 + 特殊字符”场景需要更多可配置性
  • 优势
    • 理论上“开箱即用”,几行配置就能跑
  • 劣势
    • 在我的项目环境里,兼容性和可扩展性都有一些限制
  • 适用场景
    • Node / Vite 配置已经完全 ESM 化的新项目
  • 实现难度:⭐⭐

推荐选择 & 我的决策

  • 在综合权衡之后,我选择了:
    “在 Vite 插件体系内,写一个适配自己项目的字体子集化插件,并抽象成通用插件发布出来”

  • 于是就有了今天的这个包:
    **

    fe-fast/vite-plugin-font-subset**

三、我给插件定下的几个目标

在真正敲代码之前,我给这个插件定了几个很具体的目标:

  1. 零运行时开销

    • 所有工作都在 vite build 阶段完成
    • 运行时只加载子集后的 woff/woff2 文件
  2. 对现有项目“侵入感”足够低

    • 只需要在 vite.config 里增加一个插件配置
    • 不要求你改动业务代码里的 font-family 或静态资源引用方式
  3. 兼容我当前的工程形态

    • 支持 Electron + Vite 的场景
    • 避免“ESM-only 插件 + CJS 配置”这种加载失败问题
  4. 默认就能解决“中文大字体”问题

    • 在不配置任何参数的情况下,对于常规的中文页面,能直接减掉大部分无用字形

四、核心思路:从字符集到子集字体的流水线

具体实现细节线上可以看源码,这里更侧重讲清楚“思路”,方便大家自己扩展或实现类似插件。

整个插件的执行链路,大致可以拆成四步:

1. 收集可能会用到的字符集

  • 扫描构建产物(或者源码)里的:
    • 模板中的中文文案
    • 国际化文案 JSON
    • 常见 UI 组件中的静态字符
  • 做一些去重和过滤,得到一个 相对完整但不过度膨胀的字符集合

这里的关键是平衡:

  • 集合太小:生产环境会出现“口口口/小方块”
  • 集合太大:子集化收益会变差

2. 调用子集化引擎生成子集字体

  • 将“原始 OTF/TTF 字体文件 + 上面的字符集”交给子集工具
  • 输出一份或多份新的字体文件(优先 woff2)

在我的项目中,最终生成的结果类似构建日志中的这一行:

textSourceHanSansCN-Normal-xxxx.woff2    223.97 kBSourceHanSansCN-Medium-xxxx.woff2    224.79 kB

相比最初 两份 8MB+ 的 OTF 文件,体积已经被压到了大约十分之一左右。

3. 更新 CSS / 资源引用

  • 在原有的

    font-face 声明基础上,修改 src 指向子集化后的文件

  • 对于 Vite 生成的静态资源目录(如 dist/prsas_static),保持输出路径稳定,避免破坏现有引用

这一部分的目标是:对业务代码完全透明,你仍然可以这样写:

cssbody {  font-family: 'SourceHanSansCN', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;}

只是最终加载的资源,不再是原来那两个 8MB 的 OTF,而是几百 KB 的子集 woff2。

4. 和 Vite 构建流程集成

  • 通过 Vite 插件 API,在合适的生命周期(如 configResolvedgenerateBundle 等)
    • 拿到最终输出目录
    • 触发上面的子集化流水线
    • 将生成的文件写回到 Rollup 构建产物中

核心原则就是:不打破 Vite 原有工作流,只是“在尾部插一个子集化步骤”

五、在真实项目中的效果

以我这个 Electron + Vite 项目为例,启用

fe-fast/vite-plugin-font-subset 之后:

  • 原来两份 8MB+ 的 OTF 中文字体
  • 变成两份 两百多 KB 的 woff2 子集字体
  • 对比结果非常直观:
    • 安装包体积明显下降
    • 首次加载速度、增量更新速度都有肉眼可见的提升
    • 用户几乎感受不到视觉上的差异

配合 vite-plugin-imagemin 对 PNG 等图片资源的压缩,整体构建体验也变成了:

  • 构建时间长一点(多了字体子集化和图片压缩),但属于“可接受的离线计算”
  • 换来的是 更小的安装包、更快的首屏体验,尤其适合弱网和内网环境

六、如何使用这个插件

简单说一下使用方式(仅作示意,具体参数可以看 README):

bashnpm install -D @fe-fast/vite-plugin-font-subset

ts// vite.config.ts / vite.config.jsimport fontSubsetPlugin from '@fe-fast/vite-plugin-font-subset'export default defineConfig({  plugins: [    vue(),    fontSubsetPlugin({      // 一些可选配置,例如:      // fonts: [{ path: 'src/SiYuanHeiTi/SourceHanSansCN-Normal.otf', name: 'SourceHanSansCN' }],      // include: ['src/**/*.vue', 'src/**/*.ts'],      // ...    }),  ],})

做到这一点之后,剩下的事情就交给构建阶段处理即可。

七、过程中的几个坑 & 经验

在开发这个插件的过程中,也遇到了一些值得记录的坑:

  • ESM vs CJS 的兼容

    • 之前用其他字体插件时,遇到过 ESM file cannot be loaded by require 的报错
    • 这直接促使我在发布这个插件时,特别注意构建目标和导出形式,让它能更好地兼容现有工程
  • 字符集过于激进会导致“缺字”

    • 一开始我只统计了模板里的中文字符,结果线上发现某些动态内容会出现“口口口”
    • 最终方案是:适度保守 + 预留一部分常用汉字范围
  • 构建时间和体验的平衡

    • 字体子集化本身是一个“CPU 密集型”的过程
    • 在开发环境我默认关闭了子集化,仅在 vite build 时启用,保证日常开发体验

八、总结与展望

fe-fast/vite-plugin-font-subset 其实不是一个“炫技”的轮子,而是从真实业务需求里长出来的:

  • 它解决的是一个非常具体的问题:中文项目中,字体资源过大导致包体积和加载体验变差
  • 它也体现了我在做前端工程化时的一些偏好:
    • 用好现有工具链(Vite 插件体系)
    • 优先选择“构建时处理”,而不是在运行时增加复杂性
    • 遇到兼容性问题时,适当地“自己造一个更适合现有工程的轮子”

后续我还希望在这个插件上做几件事:

  • 更智能的字符集分析(结合路由拆分、按需子集)
  • 提供简单的可视化报告,让你一眼看到“字体减肥”前后的体积对比
  • 增强对多语言项目的支持

Quill 2.x 从 0 到 1 实战 - 为 AI+Quill 深度结合铺路

作者 humor
2025年11月19日 14:22

引言

在AIGC浪潮席卷各行各业的今天,为应用注入AI能力已从“锦上添花”变为“核心竞争力”。打造一个智能写作助手,深度融合AI与富文本编辑器,无疑是抢占下一代内容创作高地的关键一步。

而一切智能编辑的基石,在于一个稳定、强大且高度可定制的基础编辑器。本文将深度解析 ‌Quill 2.x——这个在现代Web开发中备受青睐的富文本编辑器解决方案。快来开始Quill2.x的教程吧!

本文将从概念解析到实战落地,补充核心原理、汉化方案和避坑指南,帮你真正吃透 Quill 2.x,看完就能直接应用到项目中。

一、Quill 核心概念:它到底是什么?

在动手之前,先搞懂 Quill 的核心定位,避免用错场景:

Quill 是一款「API 驱动的富文本编辑器」,核心设计理念是「让开发者能精准控制编辑行为」。它不同于传统编辑器(如 TinyMCE、CKEditor)的「配置式黑盒」,而是通过暴露清晰的 API 和内部状态,让开发者像操作 DOM 一样操作编辑器内容。

几个关键概念需要明确:

  • 容器(Container) :用于承载编辑器的DOM元素,Quill会接管该元素并渲染编辑区域
  • 模块(Modules) :编辑器的功能单元(如工具栏、代码块),2.x 中模块需显式注册。
  • 主题(Themes) :编辑器外观,官方提供 snow(带固定工具栏)和 bubble(悬浮工具栏)两种,支持自定义样式。
  • Delta:Quill 独创的内容描述格式(类似 JSON),用于表示内容本身和内容变化,是实现协同编辑、版本控制的核心。
  • 格式(Formats) :描述内容的样式属性(如加粗、颜色、链接),可通过 API 或工具栏触发,支持自定义扩展。

二、原理解析:Quill 是如何工作的?

理解底层原理,能帮你更灵活地解决问题。Quill 的核心工作流程可分为三部分:

1. 内容表示:Delta 格式

传统编辑器用 HTML 字符串描述内容,但 HTML 存在「同内容多表示」(如 <b> 和 <strong> 都表示加粗)、「难以 diff 对比」等问题。而 Delta 用极简的结构解决了这些问题:

Delta 本质是一个包含 ops 数组的对象,每个 op 由 insert(内容)和 attributes(样式)组成。例如:

// 表示「Hello 加粗文本」的 Delta
{
  ops: [
    { insert: '这是一段 ' },
    { insert: '加粗文本', attributes: { bold: true } }
  ]
}

image.png

  • 优势 1:唯一性 —— 同一内容只有一种 Delta 表示,避免歧义。
  • 优势 2:可合并 —— 两个 Delta 可通过算法合并(如用户 A 和用户 B 同时编辑的内容),是协同编辑的基础。
  • 优势 3:轻量性 —— 比 HTML 更简洁,传输和存储成本更低。

2. 渲染机制:2.x 版本的性能飞跃

Quill 1.x 直接操作 DOM 渲染内容,当内容量大时容易卡顿。2.x 重构了渲染逻辑,采用「虚拟 DOM 思想」优化:

  • 内部维护一份「文档模型(Document Model)」,作为内容的单一数据源。
  • 当内容变化,先更新文档模型,再通过「差异计算」只更新需要变化的 DOM 节点。
  • 减少 30% 以上的 DOM 操作,大幅提升大数据量场景(如万字长文)的流畅度。

3. 模块架构:功能的解耦与扩展

Quill 的所有功能都通过「模块」实现,核心模块包括:

  • toolbar:工具栏,控制格式按钮的显示和交互。
  • history:记录操作历史,支持撤销 / 重做。
  • table:2.x 原生支持的表格模块(1.x 需第三方扩展)。
  • clipboard:处理复制粘贴,自动过滤危险内容。

模块之间相互独立,开发者可按需注册,也能通过 Quill.register() 自定义模块,实现功能的灵活扩展。

三、快速入门:5 分钟搭建基础编辑器

安装依赖 -> 基础初始化 -> 核心API -> 预告

1. 安装依赖

bash

运行

# 核心包(2.x 版本)
pnpm add quill@2.x

# 表格模块(2.x 需单独安装,原生支持)
pnpm add @quilljs/table

2. 基础初始化

Step 1:HTML 容器

<div id="editor" style="height: 300px;"></div>

Step 2:引入并注册模块

import Quill from 'quill';
import 'quill/dist/quill.snow.css'; // 引入 snow 主题样式
import TableModule from '@quilljs/table'; // 表格模块

// 显式注册模块 
Quill.register('modules/table', TableModule);

Step 3:初始化配置 - 方案一

const quill = new Quill('#editor', {
  theme: 'snow', // 选择主题
  modules: {
    toolbar: { 
        container: [
            // 每个数组是一个分组,里边每个项是一个工具栏最小配置单元
            ['bold', 'italic', 'underline', 'strike'], // 基本格式
            ['blockquote', 'code-block'], // 块引用和代码块 
            [{ 'header': 1 }, { 'header': 2 }], // 标题级别
            [{ 'list': 'ordered'}, { 'list': 'bullet' }], // 有序列表和无序列表 
            [{ 'script': 'sub'}, { 'script': 'super' }], // 上标和下标 
            [{ 'indent': '-1'}, { 'indent': '+1' }], // 缩进
            [{ 'direction': 'rtl' }], // 文本方向
            [{ 'size': ['small', false, 'large', 'huge'] }], // 字体大小
            [{ 'header': [1, 2, 3, 4, 5, 6, false] }], // 标题级别(完整) 
            [{ 'color': [] }, { 'background': [] }], // 颜色选择 
            [{ 'font': [] }], // 字体选择 
            [{ 'align': [] }], // 对齐方式
            ['link', 'image', 'video'], // 链接和媒体 
            ['clean'] // 清除格式 ], 
             // 方式2:使用选择器配置 // container: '#toolbar',
             // 方式3:使用自定义工具栏HTML 
             // container: document.getElementById('custom-toolbar') }
  },
  placeholder: '请输入内容...'
});

Step 3:初始化配置 - 方案2

const quill = new Quill('#editor', {
  theme: 'snow', // 选择主题
  modules: {
    toolbar: { 
       // 使用选择器配置(或者document.getElementById('custom-toolbar'))
        container: '#toolbar',
       }
  },
  placeholder: '请输入内容...'
});
.custom-toolbar {
    display: flex;
    flex-wrap: wrap;
    gap: 5px;
    align-items: center;
}
.custom-toolbar .ql-formats {
    margin-right: 15px;
    display: flex;
    align-items: center;
}
.custom-toolbar button {
    border: 1px solid #ddd;
    border-radius: 5px;
    padding: 5px 10px;
    background: white;
    cursor: pointer;
    transition: all 0.3s ease;
}
.custom-toolbar button:hover {
    background: #e9ecef;
    border-color: #adb5bd;
}
.custom-toolbar select {
    border: 1px solid #ddd;
    border-radius: 5px;
    padding: 5px;
    background: white;
}
        
<div id="custom-toolbar" class="toolbar-container">
    <div class="custom-toolbar">
        <!-- 字体和大小 -->
        <span class="ql-formats">
            <select class="ql-font"></select>
            <select class="ql-size"></select>
        </span>

        <!-- 文本格式 -->
        <span class="ql-formats">
            <button class="ql-bold" title="粗体"></button>
            <button class="ql-italic" title="斜体"></button>
            <button class="ql-underline" title="下划线"></button>
            <button class="ql-strike" title="删除线"></button>
        </span>

        <!-- 颜色 -->
        <span class="ql-formats">
            <select class="ql-color" title="文字颜色"></select>
            <select class="ql-background" title="背景颜色"></select>
        </span>

        ....
    </div>
</div>

3. 核心 API:内容操作

// 获取 Delta 内容(推荐存储)
const delta = quill.getContents();

// 获取 HTML 内容(用于展示)
const html = quill.root.innerHTML;

// 设置内容(支持 Delta 或纯文本)
quill.setContents([{ insert: 'Hello Quill\n', attributes: { bold: true } }]);

// 插入内容(在光标位置)
const range = quill.getSelection(); // 获取光标位置
quill.insertEmbed(range.index, 'image', 'https://example.com/img.png');

// 标记文案为黄色 -- 预告:下一篇文章我们会通过AI查找文档错误,然后用这个API标记错误内容
quill.formatText(
    startIndex, // 索引
    endIndex, // 索引
    {
      background: "yellow"
    },
    Quill.sources.SILENT
);

// 获取选区格式
quill.getFormat(index, 1)

// 指定位置追加内容 -- 需要保持格式  (预告:下一篇我们会用这个功能将AI扩写的内容追加到指定位置)
const formats = instance.value.getFormat(
  range.index + range.length - 1,
  1
);
quill.insertText(index, '追加内容', formats, Quill.sources.USER);

预告

  1. 下一篇文章我们会通过AI查找文档错误,然后用formatText标记错误内容
  2. 下一篇我们会用insertText将AI扩写的内容追加到指定位置
  3. 更多内容见下一篇文章

四、核心功能实战:从汉化到媒体处理

汉化 -> 增加工具栏-图片上传 -> 自定义quill格式 -> 自定义quill属性格式

1. 汉化:让编辑器「说中文」

Quill 默认提示为英文(如工具栏按钮的 tooltip),需手动汉化:

scss为例

标题汉化

.editor-wrapper {
  :deep(.ql-toolbar) {
    .ql-picker.ql-header {
      width: 70px;

      .ql-picker-label::before,
      .ql-picker-item::before {
        content: "正文";
      }

      @for $i from 1 through 6 {
        .ql-picker-label[data-value="#{$i}"]::before,
        .ql-picker-item[data-value="#{$i}"]::before {
          content: "标题#{$i}";
        }
      }
    }
  }
}

字体汉化

```字体汉化
.editor-wrapper {
  :deep(.ql-toolbar) {
    .ql-picker.ql-font {
      .ql-picker-item,
      .ql-picker-label {
        &[data-value="SimSun"]::before {
          content: "宋体";
          font-family: "SimSun" !important;
        }

        &[data-value="SimHei"]::before {
          content: "黑体";
          font-family: "SimHei" !important;
        }

        &[data-value="KaiTi"]::before {
          content: "楷体";
          font-family: "KaiTi" !important;
        }
 

        &[data-value="FangSong_GB2312"]::before {
          content: "仿宋_GB2312";
          font-family: "FangSong_GB2312", FangSong !important;
          width: 80px;
          overflow: hidden;
          white-space: nowrap;
          text-overflow: ellipsis;
          line-height: 24px;
        }

        
      }
    }
  }

  :deep(.ql-editor) {
    font-family: "SimSun", "SimHei", "KaiTi", "FangSong", "Times New Roman",
      sans-serif !important;
  }
}

汉化思路一致,不一一列出,有需要可随时私我

2. 图片上传:从本地到服务器

默认图片按钮只能输入 URL,需重写逻辑实现本地上传:

const toolbarOptions = {
  container: ['image'],
  handlers: {
    image: function() {
      const input = document.createElement('input');
      input.type = 'file';
      input.accept = 'image/*';
      
      input.onchange = (e) => {
        const file = e.target.files[0];
        if (!file) return;
        
        // 上传到服务器(替换为你的接口)
        const formData = new FormData();
        formData.append('file', file);
        
        fetch('/api/upload', { method: 'POST', body: formData })
          .then(res => res.json())
          .then(data => {
            // 插入图片到编辑器
            const range = quill.getSelection();
            quill.insertEmbed(range.index, 'image', data.url);
          });
      };
      
      input.click(); // 触发文件选择
    }
  }
};

3. 自定义规则:字体规则

注册字体 -> 工具栏配置 -> css适配

注册字体

import Quill from "quill";

export const useFontHook = () => {
  // // 注册自定义字体
  const Font: Record<string, any> = Quill.import("attributors/style/font");
  Font.whitelist = [
    "FangSong_GB2312",
    "KaiTi_GB2312",
    "FZXBSJW-GB1-0",
    "FangSong",
    "SimSun",
    "SimHei",
    "KaiTi",
    "Times New Roman"
  ]; // 字体名称需与 CSS 定义一致
  Quill.register(Font, true);

  return {
    Font
  };
};

工具栏配置

const { Font } = useFontHook();
... 
toolbar: {
    container: [
        [
            { size: SizeStyle.whitelist }, // 这里是自定义size
            {
              font: Font.whitelist
            }
          ], // custom dropdown
        ]
}

css适配

同汉化部分

.editor-wrapper {
  :deep(.ql-toolbar) {
    .ql-picker.ql-font {
      .ql-picker-item,
      .ql-picker-label {
        &[data-value="SimSun"]::before {
          content: "宋体";
          font-family: "SimSun" !important;
        }

        &[data-value="SimHei"]::before {
          content: "黑体";
          font-family: "SimHei" !important;
        }

        &[data-value="KaiTi"]::before {
          content: "楷体";
          font-family: "KaiTi" !important;
        }
        ...
      }
    }
  }

  :deep(.ql-editor) {
    font-family: "SimSun", "SimHei", "KaiTi", "FangSong", "Times New Roman",
      sans-serif !important;
  }
}

4. 自定义属性格式 -- 以margin,值为em为例

Quill工具栏是没有边距效果的(有text-indent,场景不一样),需要自行写格式

import Quill from "quill";
const Parchment = Quill.import("parchment");

const whitelist = ["2em", "4em", "6em", "8em"];

export function useMarginHook() {
  class MarginAttributor extends Parchment.StyleAttributor {
    constructor(styleName, key) {
      super(styleName, key, {
        scope: Parchment.Scope.BLOCK,
        whitelist
      });
    }

    add(node, value) {
      // 直接验证传递的字符串是否在白名单中
      if (!this.whitelist.includes(value)) return false;
      return super.add(node, value);
    }
  }

  Quill.register(
    {
      "formats/custom-margin-left": new MarginAttributor(
        "custom-margin-left",
        "margin-left"
      ),
      "formats/custom-margin-right": new MarginAttributor(
        "custom-margin-right",
        "margin-right"
      )
    },
    true
  );
}


// 工具栏配置
toolbar: [
  [{ 'custom-margin-left': ['2em', '4em', '6em', '8em'] }], 
  [{ 'custom-margin-right': ['2em', '4em', '6em', '8em'] }] 
]

五、事件与扩展:深度控制编辑器

1. 事件监听:响应编辑行为

// 内容变化时触发(用于自动保存 或者 统计字数等)
quill.on('text-change', (delta, oldDelta, source) => {
  if (source === 'user') { // 仅处理用户操作
    console.log('内容变化:', delta);
  }
});

// 光标/选择范围变化时触发(用于显示格式提示)
quill.on('selection-change', (range, oldRange, source) => {
  if (range && range.length > 0) {
    const text = quill.getText(range.index, range.length);
    console.log('选中文本:', text);
  }
});

2. 自定义格式:添加「高亮」功能

// 注册自定义格式
Quill.register({
  'formats/highlight': class Highlight {
    // 从 DOM 中读取格式
    static formats(domNode) {
      return domNode.style.backgroundColor === 'yellow' ? 'yellow' : false;
    }
    
    // 应用格式到 DOM
    apply(domNode, value) {
      domNode.style.backgroundColor = value === 'yellow' ? 'yellow' : '';
    }
  }
});

// 工具栏添加高亮按钮
const toolbarOptions = [
  [{ 'highlight': 'yellow' }]
];

// 初始化编辑器
const quill = new Quill('#editor', {
  modules: { toolbar: toolbarOptions },
  // ...其他配置
});

3. 自定义 module - 导出文件

增加工具栏、激活配置、module配置

 toolbar: {
    container: [
        'exportFile'
    ],
    // 激活handlers -- 必须手动激活 - 重要!!!
    handlers: {
      exportFile: true
    }
 },
 // exportFile插件的配置
  exportFile: {
      apiMethod: ({ htmlContent }) => {
          const html = getFileTemplate(htmlContent);
          downloadDocx({
              html
          });
      }
  }

模块注册与实现

useExportFilePlugin()

import Quill from "quill";

interface QuillIcons {
  [key: string]: string;
  exportFile?: string;
}

// 修改icon
const icons = Quill.import("ui/icons") as QuillIcons;
const uploadSVG =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path fill="currentColor" d="M160 832h704a32 32 0 1 1 0 64H160a32 32 0 1 1 0-64m384-253.696 236.288-236.352 45.248 45.248L508.8 704 192 387.2l45.248-45.248L480 584.704V128h64z"></path></svg>';
icons.exportFile = uploadSVG;

interface IApiMethodParams {
  htmlContent: string;
}

// 定义类型
interface ExportFilePluginOptions {
  apiMethod: (params: IApiMethodParams) => Promise<Blob>;
}

 

export const useExportFilePlugin = () => {
 

  class ExportFilePlugin {
    private quill: any;
    private toolbar: any;
    private apiMethod: (params: IApiMethodParams) => Promise<Blob>;

    constructor(quill: any, options: ExportFilePluginOptions) {
      this.quill = quill;
      this.toolbar = quill.getModule("toolbar");

      if (!options?.apiMethod) {
        throw new Error("导出module必须传入apiMethod");
      }

      this.apiMethod = options.apiMethod;

      // 添加工具栏 
      this.toolbar.addHandler("exportFile", this.handleExportClick.bind(this));
    }

    private async handleExportClick() {
      try {
        const htmlContent = this.quill.root.innerHTML;

        if (htmlContent.trim?.() === "<p><br></p>") {
          console.log("内容不能为空");
          return;
        }

        // 使用配置的API方法
        return this.apiMethod({ htmlContent });
      } catch (error) {
        console.error("导出失败:", error);
        return Promise.reject({
          error
        });
      }
    }
  }

  Quill.register("modules/exportFile", ExportFilePlugin);
};

自定义module或规则原理类似,很多,不一一列出,有需要可随时私我

六、避坑指南:这些问题要注意

1. 样式冲突:编辑器样式被全局 CSS 覆盖

问题:项目中的全局样式(如 p { margin: 20px })会影响编辑器内部的段落样式,导致排版错乱。

解决:用 CSS 隔离编辑器样式,通过父级类名限制作用域:

css

/* 给编辑器容器添加类名 quill-container */
.quill-container .ql-editor p {
  margin: 8px 0; /* 覆盖全局样式 */
}
.quill-container .ql-editor ul {
  padding-left: 20px;
}

2. 图片上传:跨域问题导致插入失败

问题:上传图片到第三方服务器时,因跨域限制导致 fetch 请求失败。

解决

  • 后端接口添加 CORS 头(Access-Control-Allow-Origin: *)。
  • 若无法修改后端,通过本地服务端代理转发请求:
// 前端请求本地代理接口
fetch('/proxy/upload', { method: 'POST', body: formData })
// 本地服务端将 /proxy/upload 转发到第三方服务器

3. 自定义模块:配置后不生效

问题:如“导出模块”配置后,工具栏按钮无响应。

核心原因:2.x版本中,自定义工具栏按钮需在handlers中手动激活。

解决方案:在toolbar配置中添加handlers激活项: 解决


modules: {
  toolbar: {
    container: ['exportFile'], // 自定义按钮
    // 必须手动激活,否则按钮点击无响应
    handlers: { exportFile: true } 
  },
  exportFile: { /* 模块配置 */ }
}

4. 获取选中文本 得到的结果多样性

代码 instance.value.getSelection(true)

问题 调用getText()时,返回结果可能为null、空对象或空字符串,导致后续操作报错

原因 光标未在编辑器内、用户未选中内容等场景会返回不同结果。

解决方案 封装工具函数处理边界情况:

/**
 * 获取选中文本 -- 只在真正有选中内容时候返回,否则返回''
 * @param focus是否聚焦 - true则能获取选中内容;false则代表光标不在富文本,会返回'' (非用户触发行为除外)
 * @returns obj code:-1代表没有选中  -2代表不在编辑器里 其他情况是有选中文本
 */
function getSelectionText(focus = true) {
  const range = instance.value.getSelection(focus);
  if (range) {
    if (range.length == 0) {
      console.log("用户没有选中任何内容");
      return {
        code: -1,
        text: "",
        range: {}
      };
    } else {
      const text = instance.value.getText(range.index, range.length);
      return {
        code: 1,
        text,
        range
      };
    }
  } else {
    console.log("用户光标不在富文本编辑器里");
    return {
      code: -2,
      text: "",
      range: {}
    };
  }
}

5. vue、react报错 Cannot read properties of null (reading 'offsetTop')

问题 在Vue3/React项目中,初始化Quill后控制台报上述错误 原因 框架响应式系统干扰Quill内部DOM计算逻辑 解决方案

  1. 用非响应式变量存储
  2. markRaw包裹quill实例 instance.value = markRaw(new Quill('#editor'))

七 汉化效果

工具栏和下拉内容均为中文

image.png

总结与后续预告

Quill 2.x 凭借「API 驱动」「Delta 格式」「模块化设计」三大特性,成为富文本编辑器的优质选择。本文从概念解析(是什么)、原理剖析(怎么工作)到实战落地(如何使用),再到避坑指南(常见问题),覆盖了 90% 的实用场景,掌握这些内容后,你可以轻松实现博客编辑器、在线文档、评论系统等功能

下一篇预告:《AI智能写作实战:让Quill编辑器“听话”起来》

我们将深度融合AIQuill2,实现三大核心功能:

  1. AI自动生成文档,填充到富文本编辑器
  2. AI自动检测内容错误并标记(formatText API)
  3. AI根据上下文扩写内容(insertText API)
  4. ...

资源获取

本文涉及的完整代码(含Vue3、汉化、自定义格式、自定义模块)已整理完毕,点赞+收藏+评论@我,即可私发资源包!

Vue组件开发避坑指南:循环引用、更新控制与模板替代

2025年11月18日 07:37

你是不是曾经在开发Vue组件时遇到过这样的困扰?组件之间相互引用导致无限循环,页面更新不受控制白白消耗性能,或者在某些特殊场景下标准模板无法满足需求。这些问题看似棘手,但只要掌握了正确的方法,就能轻松应对。

今天我就来分享Vue组件开发中三个边界情况的处理技巧,这些都是我在实际项目中踩过坑后总结出的宝贵经验。读完本文,你将能够优雅地解决组件循环引用问题,精准控制组件更新时机,并在需要时灵活运用模板替代方案。

组件循环引用的智慧解法

先来说说循环引用这个让人头疼的问题。想象一下,你正在构建一个文件管理器,文件夹组件需要包含子文件夹,而子文件夹本质上也是文件夹组件。这就产生了组件自己引用自己的情况。

在实际项目中,我遇到过这样的场景:

// 错误示范:这会导致循环引用问题
components: {
  Folder: () => import('./Folder.vue')
}

那么正确的做法是什么呢?Vue提供了异步组件的方式来打破这个循环:

// 方案一:使用异步组件
export default {
  name: 'Folder',
  components: {
    Folder: () => import('./Folder.vue')
  }
}

但有时候我们可能需要更明确的控制,这时候可以用条件渲染:

// 方案二:条件渲染避免循环
<template>
  <div>
    <p>{{ folder.name }}</p>
    <div v-if="hasSubfolders">
      <Folder
        v-for="subfolder in folder.children"
        :key="subfolder.id"
        :folder="subfolder"
      />
    </div>
  </div>
</template>

<script>
export default {
  name: 'Folder',
  props: ['folder'],
  computed: {
    hasSubfolders() {
      return this.folder.children && this.folder.children.length > 0
    }
  }
}
</script>

还有一种情况是组件之间的相互引用,比如Article组件引用Comment组件,而Comment组件又需要引用Article组件。这时候我们可以使用beforeCreate钩子来延迟组件的注册:

// 方案三:在beforeCreate中注册组件
export default {
  name: 'Article',
  beforeCreate() {
    this.$options.components.Comment = require('./Comment.vue').default
  }
}

这些方法都能有效解决循环引用问题,关键是要根据具体场景选择最适合的方案。

精准控制组件更新的实战技巧

接下来聊聊组件更新控制。在复杂应用中,不必要的组件更新会严重影响性能。我曾经优化过一个数据大屏项目,通过精准控制更新,让页面性能提升了3倍以上。

首先来看看最常用的key属性技巧:

// 使用key强制重新渲染
<template>
  <div>
    <ExpensiveComponent :key="componentKey" />
    <button @click="refreshComponent">刷新组件</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      componentKey: 0
    }
  },
  methods: {
    refreshComponent() {
      this.componentKey += 1
    }
  }
}
</script>

但有时候我们并不需要完全重新渲染组件,只是希望跳过某些更新。这时候v-once就派上用场了:

// 使用v-once避免重复渲染静态内容
<template>
  <div>
    <header v-once>
      <h1>{{ title }}</h1>
      <p>{{ subtitle }}</p>
    </header>
    <main>
      <!-- 动态内容 -->
    </main>
  </div>
</template>

对于更复杂的更新控制,我们可以使用计算属性的缓存特性:

// 利用计算属性优化渲染
export default {
  data() {
    return {
      items: [],
      filter: ''
    }
  },
  computed: {
    filteredItems() {
      // 只有items或filter变化时才会重新计算
      return this.items.filter(item => 
        item.name.includes(this.filter)
      )
    }
  }
}

在某些极端情况下,我们可能需要手动控制更新流程。这时候可以使用nextTick:

// 手动控制更新时机
export default {
  methods: {
    async updateData() {
      this.loading = true
      
      // 先更新loading状态
      await this.$nextTick()
      
      try {
        const newData = await fetchData()
        this.items = newData
      } finally {
        this.loading = false
      }
    }
  }
}

记住,更新的控制要恰到好处,过度优化反而会让代码变得复杂难维护。

模板替代方案的创造性应用

最后我们来探讨模板替代方案。虽然Vue的单文件组件很好用,但在某些场景下,我们可能需要更灵活的模板处理方式。

首先是最基础的动态组件:

// 动态组件使用
<template>
  <div>
    <component :is="currentComponent" :props="componentProps" />
  </div>
</template>

<script>
export default {
  data() {
    return {
      currentComponent: 'ComponentA',
      componentProps: { /* ... */ }
    }
  }
}
</script>

但动态组件有时候不够灵活,这时候渲染函数就派上用场了:

// 使用渲染函数创建动态内容
export default {
  props: ['type', 'content'],
  render(h) {
    const tag = this.type === 'header' ? 'h1' : 'p'
    
    return h(tag, {
      class: {
        'text-primary': this.type === 'header',
        'text-content': this.type === 'paragraph'
      }
    }, this.content)
  }
}

渲染函数虽然强大,但写起来比较繁琐。这时候JSX就是一个很好的折中方案:

// 使用JSX编写灵活组件
export default {
  props: ['items', 'layout'],
  render() {
    return (
      <div class={this.layout}>
        {this.items.map(item => (
          <div class="item" key={item.id}>
            <h3>{item.title}</h3>
            <p>{item.description}</p>
          </div>
        ))}
      </div>
    )
  }
}

对于需要完全自定义渲染逻辑的场景,我们可以使用作用域插槽:

// 使用作用域插槽提供最大灵活性
<template>
  <DataFetcher :url="apiUrl" v-slot="{ data, loading }">
    <div v-if="loading">加载中...</div>
    <div v-else>
      <slot :data="data"></slot>
    </div>
  </DataFetcher>
</template>

甚至我们可以组合使用这些技术,创建出真正强大的抽象:

// 组合使用多种模板技术
export default {
  render(h) {
    // 根据条件选择不同的渲染策略
    if (this.useScopedSlot) {
      return this.$scopedSlots.default({
        data: this.internalData
      })
    } else if (this.useJSX) {
      return this.renderJSX(h)
    } else {
      return this.renderTemplate(h)
    }
  },
  methods: {
    renderJSX(h) {
      // JSX渲染逻辑
    },
    renderTemplate(h) {
      // 传统渲染函数逻辑
    }
  }
}

实战案例:构建灵活的数据表格组件

现在让我们把这些技巧综合运用到一个实际案例中。假设我们要构建一个高度灵活的数据表格组件,它需要处理各种边界情况。

// 灵活的数据表格组件
export default {
  name: 'SmartTable',
  props: {
    data: Array,
    columns: Array,
    keyField: {
      type: String,
      default: 'id'
    }
  },
  data() {
    return {
      sortKey: '',
      sortOrder: 'asc'
    }
  },
  computed: {
    sortedData() {
      if (!this.sortKey) return this.data
      
      return [...this.data].sort((a, b) => {
        const aVal = a[this.sortKey]
        const bVal = b[this.sortKey]
        
        if (this.sortOrder === 'asc') {
          return aVal < bVal ? -1 : 1
        } else {
          return aVal > bVal ? -1 : 1
        }
      })
    }
  },
  methods: {
    handleSort(key) {
      if (this.sortKey === key) {
        this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'
      } else {
        this.sortKey = key
        this.sortOrder = 'asc'
      }
    }
  },
  render(h) {
    // 处理空数据情况
    if (!this.data || this.data.length === 0) {
      return h('div', { class: 'empty-state' }, '暂无数据')
    }
    
    // 使用JSX渲染表格
    return (
      <div class="smart-table">
        <table>
          <thead>
            <tr>
              {this.columns.map(col => (
                <th 
                  key={col.key}
                  onClick={() => this.handleSort(col.key)}
                  class={{ sortable: col.sortable }}
                >
                  {col.title}
                  {this.sortKey === col.key && (
                    <span class={`sort-icon ${this.sortOrder}`} />
                  )}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {this.sortedData.map(item => (
              <tr key={item[this.keyField]}>
                {this.columns.map(col => (
                  <td key={col.key}>
                    {col.render 
                      ? col.render(h, item)
                      : item[col.key]
                    }
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    )
  }
}

这个组件展示了如何综合运用我们之前讨论的各种技巧:使用计算属性优化性能,用渲染函数提供灵活性,处理边界情况,以及提供扩展点让使用者自定义渲染逻辑。

总结与思考

通过今天的分享,我们深入探讨了Vue组件开发中的三个关键边界情况:循环引用、更新控制和模板替代。这些技巧虽然针对的是边界情况,但在实际项目中却经常能发挥关键作用。

处理循环引用时,我们要理解组件注册的时机和方式,通过异步加载、条件渲染等技巧打破循环。控制组件更新时,要善用Vue的响应式系统特性,在必要的时候进行精准控制。而模板替代方案则为我们提供了突破模板限制的能力,让组件设计更加灵活。

这些解决方案背后体现的是一个重要的开发理念:理解框架的工作原理,在框架的约束下找到创造性的解决方案。只有这样,我们才能写出既优雅又实用的代码。

你在Vue组件开发中还遇到过哪些棘手的边界情况?又是如何解决的呢?欢迎在评论区分享你的经验和见解,让我们共同进步!

🎮 从 NES 到现代 Web —— 像素风组件库 Pixel UI React 版本,欢迎大家一起参与这个项目

2025年11月19日 10:51

还记得几个月前,掘金作者 @猫闷台817 的 Vue3 版上线了,地址是这个:github.com/maomentai81… 。现在我与他合作的 React 版也来了,github 地址:github.com/maomentai81…


image.png

1️⃣ 项目初衷

红白机、GameBoy 游戏,那种块状像素 UI 总让人心驰神往。现在虽然是现代 Web 时代,但像素风 UI 的美学依然令人着迷。

现有的 8-bit 风组件库 NES.css 是一个 CSS 框架, 它只需要 CSS,不依赖于任何 JavaScript, 核心绘制逻辑都是基于 box-shadow 实现, 但在不同浏览器环境, 浏览器缩放时,box-shadow 的浮点偏移值经过缩放后无法精准对齐物理像素网格,导致渲染出现间隙。子像素定位、像素舍入误差和 box-shadow 本身不适合精细拼接渲染等原因造成了一些困扰

QQ_1747188289846.png

NES.css

于是就有了基于 Vue 3 / React + TypeScript + UnoCSS + CSS Houdini 打造的一款组件库 —— 让像素风重回前端视野

Vue3 项目地址👇
📦 pixel-ui

React 项目地址👇
📦 pixel-ui-react

组件库首页👇
📎 Home


2️⃣ 技术选型

技术栈 用途
React19 + Hooks 组件化开发核心
TypeScript 类型系统增强开发体验
UnoCSS 原子化 CSS,灵活配置样式类
CSS Houdini 自定义 Paint Worklet 渲染像素边框
Dumi 组件展示 + 文档系统
Vitest 测试组件逻辑与渲染
pnpm + Monorepo 高效构建与多包管理

3️⃣ 项目目前进度

已迁移上线组件:

  • ✅ Button 按钮
  • ✅ Icon 图标
  • ✅ Overlay 遮罩层
  • ✅ Text 文本
  • ✅ ConfigProvider 全局配置
  • ✅ Input 输入框
  • ✅ Popconfirm 气泡确认框
  • ✅ Tooltip 文字提示

已发布 npm,可供下载。

更过规划见 vue 版:juejin.cn/post/750384…


4️⃣ 效果预览

image.png

image.png

image.png

欢迎大家:

  • 🌟 点个 Star
  • 🐛 提 Issue,有什么好的点子和想法都可以提
  • 🤝 提 PR,增强完善功能
  • 📢 分享给像素控朋友!

项目地址:github.com/maomentai81…

Vue.js 项目静态资源 OSS 部署完整实现指南

作者 浮游本尊
2025年11月19日 09:38

目录

  1. 背景与需求
  2. 技术方案设计
  3. Vue CLI 配置详解
  4. OSS 工具安装与配置
  5. 上传脚本编写
  6. 部署流程
  7. 注意事项与最佳实践
  8. 问题排查

背景与需求

业务场景

在大型前端项目的生产环境部署中,静态资源(JS、CSS、字体文件等)通常占据较大的体积。将这些资源部署到对象存储服务(OSS)并通过 CDN 加速,可以带来以下优势:

  1. 减轻服务器压力:静态资源不再占用应用服务器的带宽和存储
  2. 提升访问速度:CDN 边缘节点就近分发,降低延迟
  3. 降低服务器成本:减少服务器带宽和存储需求
  4. 提高可用性:CDN 的高可用性保障

需求分析

我们的项目需要实现以下功能:

  • 环境区分:测试环境使用本地资源,生产环境使用 OSS CDN
  • 选择性上传:只上传 static/css/static/fonts/static/js/ 目录
  • 路径处理favicon.icostyles/ 目录不上传 OSS,使用相对路径
  • 自动化部署:通过脚本自动化完成上传流程
  • 版本管理:通过文件 hash 实现缓存控制

技术方案设计

架构设计

┌─────────────────┐
│  本地开发环境    │  → 相对路径 (/)
└─────────────────┘

┌─────────────────┐
│  测试环境构建    │  → 相对路径 (/) → 直接部署
└─────────────────┘

┌─────────────────┐
│  生产环境构建    │  → CDN 路径 (https://cdn.example.com/)
└─────────────────┘
         │
         ├─→ static/css/   → 上传 OSS
         ├─→ static/fonts/ → 上传 OSS
         ├─→ static/js/    → 上传 OSS
         ├─→ favicon.ico   → 不上传(相对路径)
         └─→ styles/       → 不上传(相对路径)

技术选型

  • 构建工具:Vue CLI(基于 Webpack)
  • OSS 工具:ossutil(阿里云官方命令行工具)
  • 脚本语言:Bash Shell
  • 路径处理:Webpack 插件 + 自定义 HTML 修改插件

Vue CLI 配置详解

1. publicPath 动态配置

publicPath 决定了构建后资源的引用路径。我们需要根据环境变量动态设置:

module.exports = {
  publicPath: process.env.CDN_URL || 
    (process.env.NODE_ENV === 'development' ? '/' : // 本地开发环境
     process.env.VUE_APP_PATH_TYPE === 'gray' ? '/' : // 测试环境使用相对路径
     'https://js-cdn.m.cc/'), // 生产环境使用 CDN
  outputDir: 'dist',
  assetsDir: 'static',
  filenameHashing: true,
}

关键点说明

  • assetsDir: 'static':所有资源输出到 static/ 目录
  • filenameHashing: true:启用文件名 hash,实现缓存控制
  • 环境判断逻辑:
    • 开发环境:始终使用相对路径
    • 测试环境(VUE_APP_PATH_TYPE === 'gray'):使用相对路径
    • 生产环境:使用 CDN 路径

2. 输出文件名配置

为了确保文件路径的一致性,需要显式设置输出文件名:

configureWebpack: config => {
  // 统一使用 static/js/ 路径,与 assetsDir 保持一致
  config.output.filename = 'static/js/[name].[hash:8].js'
  config.output.chunkFilename = 'static/js/[name].[hash:8].js'
}

为什么需要显式设置

虽然 assetsDir: 'static' 会自动处理路径,但显式设置可以:

  • 确保所有环境路径一致
  • 避免不同 Webpack 版本的差异
  • 便于后续维护和理解

3. HTML 路径修改插件

生产环境需要特殊处理:静态资源使用 CDN,但 favicon.icostyles/ 使用相对路径。

实现原理

在 Webpack 的 emit 阶段(生成文件后,写入磁盘前),通过自定义插件修改 HTML 内容:

chainWebpack: config => {
  // 只在生产环境(非测试环境)运行
  if (process.env.NODE_ENV === 'production' && process.env.VUE_APP_PATH_TYPE !== 'gray') {
    class ModifyHtmlPlugin {
      apply(compiler) {
        compiler.hooks.emit.tapAsync('ModifyHtmlPlugin', (compilation, callback) => {
          Object.keys(compilation.assets).forEach(filename => {
            if (filename.endsWith('.html')) {
              let html = compilation.assets[filename].source()
              
              // 1. 先处理 BASE_URL 变量(如果还没被 Vue CLI 替换)
              html = html.replace(/href="<%= BASE_URL %>favicon\.ico"/g, 'href="/favicon.ico"')
              html = html.replace(/href="<%= BASE_URL %>styles\/([^"]+)"/g, 'href="/styles/$1"')
              
              // 2. 替换其他 BASE_URL 变量为 CDN 路径
              const baseUrl = process.env.CDN_URL || 'https://js-cdn.m.cc/'
              html = html.replace(/<%= BASE_URL %>/g, baseUrl)
              
              // 3. 最后处理已经被替换的 CDN 路径(将 favicon 和 styles 改回相对路径)
              html = html.replace(/href="https:\/\/js-cdn\.modb\.cc\/favicon\.ico"/g, 'href="/favicon.ico"')
              html = html.replace(/href="https:\/\/js-cdn\.modb\.cc\/styles\/([^"]+)"/g, 'href="/styles/$1"')
              
              compilation.assets[filename] = {
                source: () => html,
                size: () => html.length
              }
            }
          })
          callback()
        })
      }
    }
    config.plugin('modify-html').use(ModifyHtmlPlugin)
  }
}

处理顺序的重要性

  1. 先处理 BASE_URL 变量(模板阶段)
  2. 替换为 CDN 路径
  3. 最后将特定文件改回相对路径

这样可以确保所有路径都被正确处理。

4. 代码分割优化

为了提升加载性能,我们配置了代码分割:

config.optimization.splitChunks = {
  chunks: 'all',
  minSize: 20000,
  maxAsyncRequests: 20,
  maxInitialRequests: 15,
  cacheGroups: {
    // Vue 核心库单独拆分
    vueCore: {
      name: 'chunk-vue-core',
      test: /[\\/]node_modules[\\/](vue|vue-router|vuex|vue-i18n)[\\/]/,
      priority: 40,
      chunks: 'initial',
      enforce: true
    },
    // 大型 UI 库单独拆分
    elementPlus: {
      name: 'chunk-element-plus',
      test: /[\\/]node_modules[\\/]element-plus[\\/]/,
      priority: 35,
      chunks: 'initial',
      enforce: true
    },
    // 其他配置...
  }
}

优势

  • 大型库单独拆分,便于缓存
  • 按需加载的库设置为 async,减少初始加载体积
  • 公共代码提取,避免重复打包

OSS 工具安装与配置

1. 确定服务器架构

首先需要确定服务器的 CPU 架构,以选择正确的 ossutil 版本:

uname -m
# 输出示例:
# x86_64  → 使用 ossutil64
# aarch64 → 使用 ossutilarm64

2. 下载 ossutil

根据架构下载对应版本(以阿里云 OSS 为例):

# 创建目录
mkdir -p /root/oss
cd /root/oss

# 下载对应版本(示例:ARM64 架构)
wget https://gosspublic.alicdn.com/ossutil/1.7.13/ossutilarm64

# 或者使用 curl
curl -o ossutilarm64 https://gosspublic.alicdn.com/ossutil/1.7.13/ossutilarm64

# 添加执行权限
chmod +x ossutilarm64

# 验证安装
/root/oss/ossutilarm64 --version

3. 配置 OSS 凭证

ossutil 支持两种配置方式:

方式一:命令行配置(临时)

/root/oss/ossutilarm64 config -e <endpoint> \
  -i <accessKeyId> \
  -k <accessKeySecret>

方式二:配置文件(推荐)

创建配置文件 ~/.ossutilconfig

cat > /root/.ossutilconfig << 'EOF'
[Credentials]
language=EN
endpoint=oss-cn-hangzhou.aliyuncs.com
accessKeyID=YOUR_ACCESS_KEY_ID
accessKeySecret=YOUR_ACCESS_KEY_SECRET
EOF

# 设置文件权限(安全考虑)
chmod 600 /root/.ossutilconfig

配置说明

  • language=EN:使用英文输出,避免编码问题
  • endpoint:OSS 区域节点地址
  • accessKeyIDaccessKeySecret:OSS 访问凭证

4. 测试连接

# 测试连接
/root/oss/ossutilarm64 ls oss://your-bucket-name/

# 如果成功,会显示 OSS 上的文件列表

上传脚本编写

脚本结构设计

上传脚本需要包含以下功能:

  1. 环境检查:验证构建输出是否存在
  2. 连接测试:确保 OSS 连接正常
  3. 清理旧文件:删除 OSS 上的旧版本文件
  4. 上传新文件:上传指定的目录
  5. 验证上传:确认上传成功

完整脚本示例

#!/bin/bash
set -e  # 遇到错误立即退出

echo "Starting OSS upload for production environment..."

# ========== 1. OSS 配置 ==========
# 如果使用配置文件,可以跳过此步骤
# 如果需要动态配置,使用以下命令:
# /root/oss/ossutilarm64 config -e <endpoint> \
#   -i <accessKeyId> \
#   -k <accessKeySecret>

# ========== 2. 测试 OSS 连接 ==========
echo "Testing OSS connection..."
if /root/oss/ossutilarm64 ls oss://your-bucket-name/ 2>/dev/null > /dev/null; then
    echo "OSS connection test passed"
else
    echo "Warning: OSS connection test failed, but continuing..."
fi

# ========== 3. 设置构建路径 ==========
DIST_PATH="/u01/dist"

# ========== 4. 检查构建输出 ==========
if [ ! -d "$DIST_PATH" ]; then
    echo "Error: $DIST_PATH not found"
    exit 1
fi

echo "Build path: $DIST_PATH"

# 检查 static 目录
if [ ! -d "$DIST_PATH/static" ]; then
    echo "Error: $DIST_PATH/static not found"
    exit 1
fi

# 检查关键文件
if [ ! -f "$DIST_PATH/index.html" ]; then
    echo "Error: $DIST_PATH/index.html not found"
    exit 1
fi

if [ ! -f "$DIST_PATH/favicon.ico" ]; then
    echo "Warning: $DIST_PATH/favicon.ico not found"
fi

if [ ! -d "$DIST_PATH/styles" ]; then
    echo "Warning: $DIST_PATH/styles directory not found"
fi

# ========== 5. 清理 OSS 上的旧文件 ==========
echo ""
echo "Cleaning old files from OSS..."

# 删除 OSS 上的 css 目录(使用 -f 参数避免交互式提示)
echo "   Deleting old CSS files..."
/root/oss/ossutilarm64 rm -r oss://your-bucket-name/static/css/ -f 2>/dev/null || echo "   (CSS directory may not exist, skipping)"

# 删除 OSS 上的 fonts 目录
echo "   Deleting old fonts files..."
/root/oss/ossutilarm64 rm -r oss://your-bucket-name/static/fonts/ -f 2>/dev/null || echo "   (Fonts directory may not exist, skipping)"

# 删除 OSS 上的 js 目录
echo "   Deleting old JS files..."
/root/oss/ossutilarm64 rm -r oss://your-bucket-name/static/js/ -f 2>/dev/null || echo "   (JS directory may not exist, skipping)"

echo "Old files cleaned (if any existed)"
echo ""

# ========== 6. 上传新文件 ==========

# 上传 static/css/ 目录
if [ -d "$DIST_PATH/static/css" ]; then
    echo "Uploading static/css/ directory..."
    /root/oss/ossutilarm64 cp -r $DIST_PATH/static/css/ oss://your-bucket-name/static/css/ -f
    if [ $? -eq 0 ]; then
        echo "CSS directory uploaded successfully"
    else
        echo "Failed to upload CSS directory"
        exit 1
    fi
else
    echo "Warning: $DIST_PATH/static/css not found, skipping..."
fi

# 上传 static/fonts/ 目录
if [ -d "$DIST_PATH/static/fonts" ]; then
    echo "Uploading static/fonts/ directory..."
    /root/oss/ossutilarm64 cp -r $DIST_PATH/static/fonts/ oss://your-bucket-name/static/fonts/ -f
    if [ $? -eq 0 ]; then
        echo "Fonts directory uploaded successfully"
    else
        echo "Failed to upload fonts directory"
        exit 1
    fi
else
    echo "Warning: $DIST_PATH/static/fonts not found, skipping..."
fi

# 上传 static/js/ 目录
if [ -d "$DIST_PATH/static/js" ]; then
    echo "Uploading static/js/ directory..."
    /root/oss/ossutilarm64 cp -r $DIST_PATH/static/js/ oss://your-bucket-name/static/js/ -f
    if [ $? -eq 0 ]; then
        echo "JS directory uploaded successfully"
    else
        echo "Failed to upload JS directory"
        exit 1
    fi
else
    echo "Error: $DIST_PATH/static/js not found (required)"
    exit 1
fi

# ========== 7. 验证上传 ==========
echo ""
echo "Verifying upload..."

# 统计上传的文件数量
JS_COUNT=$(/root/oss/ossutilarm64 ls oss://your-bucket-name/static/js/ 2>/dev/null | wc -l)
CSS_COUNT=$(/root/oss/ossutilarm64 ls oss://your-bucket-name/static/css/ 2>/dev/null | wc -l)
FONTS_COUNT=$(/root/oss/ossutilarm64 ls oss://your-bucket-name/static/fonts/ 2>/dev/null | wc -l)

echo ""
echo "Upload completed!"
echo "Upload statistics:"
echo "   JS files: $JS_COUNT"
echo "   CSS files: $CSS_COUNT"
echo "   Fonts files: $FONTS_COUNT"

# 检查关键文件是否存在
echo ""
echo "Checking key files..."

JS_CHECK=$(/root/oss/ossutilarm64 ls oss://your-bucket-name/static/js/app.*.js 2>/dev/null | head -1 | wc -l)
CSS_CHECK=$(/root/oss/ossutilarm64 ls oss://your-bucket-name/static/css/app.*.css 2>/dev/null | head -1 | wc -l)

if [ "$JS_CHECK" -gt 0 ] && [ "$CSS_CHECK" -gt 0 ]; then
    echo "Key files app.js and app.css found in OSS"
    echo "Upload verification passed!"
else
    echo "Warning: Some key files may be missing"
fi

echo ""
echo "OSS upload process completed!"

脚本关键点说明

1. set -e 的作用

set -e

遇到任何错误立即退出,避免继续执行可能导致的问题。

2. 使用 -f 参数

ossutilarm64 rm -r oss://bucket/path/ -f
ossutilarm64 cp -r local/path/ oss://bucket/path/ -f

-f 参数的作用:

  • rm -r -f:强制删除,不询问确认
  • cp -r -f:强制覆盖,不询问确认

为什么重要:在自动化脚本中,交互式提示会导致脚本挂起。

3. 错误处理

command 2>/dev/null || echo "fallback message"
  • 2>/dev/null:隐藏错误输出
  • ||:如果命令失败,执行后续命令
  • 适用于首次运行(目录可能不存在)的场景

4. 变量命名注意事项

避免使用可能被系统识别的变量名:

# ❌ 不推荐(可能被某些系统识别为系统变量)
BUILD_PATH="/u01/dist"

# ✅ 推荐
DIST_PATH="/u01/dist"

5. 文件计数验证

JS_COUNT=$(ossutilarm64 ls oss://bucket/static/js/ 2>/dev/null | wc -l)

使用 wc -l 统计文件数量,用于验证上传是否成功。


部署流程

测试环境部署

测试环境使用相对路径,不需要上传 OSS:

# 1. 构建项目
npm run gray

# 2. 打包
tar -czf dist.gz dist/

# 3. 上传到服务器并解压
scp dist.gz user@server:/path/to/deploy/
ssh user@server "cd /path/to/deploy && tar -xzf dist.gz"

生产环境部署

生产环境需要上传资源到 OSS:

# 1. 构建项目
npm run build

# 2. 打包
tar -czf dist.gz dist/

# 3. 上传到服务器
scp dist.gz user@server:/u01/
scp upload-to-oss-production.sh user@server:/u01/

# 4. 在服务器上执行部署脚本
ssh user@server << 'EOF'
cd /u01
rm -rf dist && tar -zxvf ./dist.gz
chmod +x upload-to-oss-production.sh
./upload-to-oss-production.sh
EOF

完整部署脚本示例

可以将解压和上传合并到一个脚本中:

#!/bin/bash
set -e

# 解压构建文件
cd /u01
rm -rf dist && tar -zxvf ./dist.gz

# 执行 OSS 上传脚本
./upload-to-oss-production.sh

# 部署到 Web 服务器(Nginx 示例)
# cp -r dist/* /usr/share/nginx/html/
# systemctl reload nginx

注意事项与最佳实践

1. 安全性

凭证管理

  • 不要在脚本中硬编码凭证
  • 使用环境变量或配置文件
  • 设置配置文件权限为 600
chmod 600 ~/.ossutilconfig

最小权限原则

为 OSS 访问密钥设置最小必要权限:

  • 只允许上传到指定目录
  • 禁止删除其他目录的文件

2. 性能优化

并行上传

对于大量文件,可以考虑并行上传:

# 使用后台任务并行上传
/root/oss/ossutilarm64 cp -r $DIST_PATH/static/css/ oss://bucket/static/css/ -f &
/root/oss/ossutilarm64 cp -r $DIST_PATH/static/fonts/ oss://bucket/static/fonts/ -f &
/root/oss/ossutilarm64 cp -r $DIST_PATH/static/js/ oss://bucket/static/js/ -f &

# 等待所有任务完成
wait

增量上传

如果文件很多,可以使用 --update 参数进行增量上传:

ossutilarm64 cp -r local/path/ oss://bucket/path/ --update

但需要注意:如果文件名包含 hash,每次构建都是新文件,增量上传意义不大。

3. 缓存策略

HTTP 缓存头

在 OSS 控制台或通过 API 设置缓存头:

Cache-Control: public, max-age=31536000

对于带 hash 的文件名,可以设置长期缓存。

CDN 缓存

如果使用 CDN,需要配置:

  • 缓存规则:根据文件类型和路径
  • 缓存刷新:更新后及时刷新 CDN 缓存

4. 监控与日志

上传日志

记录上传过程的关键信息:

LOG_FILE="/var/log/oss-upload.log"
echo "$(date): Starting OSS upload" >> $LOG_FILE
# ... 上传过程 ...
echo "$(date): OSS upload completed" >> $LOG_FILE

监控告警

  • 监控上传失败率
  • 监控 OSS 存储使用量
  • 监控 CDN 流量

5. 回滚策略

保留历史版本

在删除旧文件前,可以先备份:

# 备份当前版本
BACKUP_DIR="backup/$(date +%Y%m%d_%H%M%S)"
/root/oss/ossutilarm64 cp -r oss://bucket/static/ oss://bucket/$BACKUP_DIR/ -f

快速回滚

如果需要回滚,可以从备份恢复:

/root/oss/ossutilarm64 cp -r oss://bucket/$BACKUP_DIR/static/ oss://bucket/static/ -f

问题排查

1. 路径问题

问题:HTML 中的路径不正确

症状:浏览器控制台显示 404 错误

排查步骤

  1. 检查 dist/index.html 中的路径
  2. 确认 publicPath 配置是否正确
  3. 验证 ModifyHtmlPlugin 是否正常运行

解决方案

# 检查构建后的 HTML
cat dist/index.html | grep -E '(href|src)='

# 检查环境变量
echo $NODE_ENV
echo $VUE_APP_PATH_TYPE

2. OSS 连接问题

问题:ossutil 连接失败

症状ossutil: No such file or directory 或连接超时

排查步骤

  1. 检查 ossutil 是否安装
  2. 检查网络连接
  3. 验证凭证是否正确

解决方案

# 检查 ossutil 是否存在
ls -la /root/oss/ossutilarm64

# 测试网络连接
ping oss-cn-hangzhou.aliyuncs.com

# 测试 OSS 连接
/root/oss/ossutilarm64 ls oss://bucket-name/

3. 上传失败

问题:上传过程中断或失败

症状:脚本执行失败,部分文件未上传

排查步骤

  1. 检查磁盘空间
  2. 检查网络稳定性
  3. 查看 OSS 控制台的错误日志

解决方案

# 检查磁盘空间
df -h

# 检查网络
curl -I https://oss-cn-hangzhou.aliyuncs.com

# 重试上传
./upload-to-oss-production.sh

4. 文件权限问题

问题:ossutil 无执行权限

症状Permission denied

解决方案

chmod +x /root/oss/ossutilarm64

5. 脚本语法问题

问题:脚本执行报语法错误

症状syntax error: unexpected EOF

常见原因

  1. 括号不匹配
  2. 引号未闭合
  3. 变量名包含特殊字符

解决方案

# 检查脚本语法
bash -n upload-to-oss-production.sh

# 使用 shellcheck 检查(如果已安装)
shellcheck upload-to-oss-production.sh

6. 环境变量问题

问题:构建时路径不正确

症状:测试环境使用了 CDN 路径,或生产环境使用了相对路径

排查步骤

# 检查 package.json 中的脚本
cat package.json | grep -A 5 '"scripts"'

# 确认环境变量设置
# 测试环境应该设置 VUE_APP_PATH_TYPE=gray
# 生产环境不应该设置此变量

总结

本文详细介绍了 Vue.js 项目将静态资源部署到 OSS 的完整实现过程,包括:

  1. 配置层面:通过 Vue CLI 配置实现不同环境的路径处理
  2. 工具层面:OSS 命令行工具的安装和配置
  3. 脚本层面:自动化上传脚本的编写
  4. 部署层面:完整的部署流程

关键要点

  • 环境区分:测试环境使用相对路径,生产环境使用 CDN
  • 选择性上传:只上传必要的静态资源目录
  • 路径处理:通过自定义插件处理特殊文件的路径
  • 自动化:通过脚本实现一键部署
  • 安全性:妥善管理 OSS 凭证
  • 可维护性:清晰的代码结构和完善的错误处理

扩展建议

  1. CI/CD 集成:将上传脚本集成到 CI/CD 流程中
  2. 监控告警:添加监控和告警机制
  3. 多环境支持:支持多个生产环境的配置
  4. 版本管理:实现更完善的版本管理和回滚机制

希望本文能帮助您成功实现静态资源的 OSS 部署!

Vue表单组件进阶:打造属于你的自定义v-model

2025年11月19日 07:32

从基础到精通:掌握组件数据流的核心

每次写表单组件,你是不是还在用 props 传值、$emit 触发事件的老套路?面对复杂表单需求时,代码就像一团乱麻,维护起来让人头疼不已。今天我要带你彻底掌握自定义 v-model 的奥秘,让你的表单组件既优雅又强大。

读完本文,你将学会如何为任何组件实现自定义的 v-model,理解 Vue 3 中 v-model 的进化,并掌握在实际项目中的最佳实践。准备好了吗?让我们开始这段精彩的组件开发之旅!

重新认识 v-model:不只是语法糖

在深入自定义之前,我们先来回顾一下 v-model 的本质。很多人以为 v-model 是 Vue 的魔法,其实它只是一个语法糖。

让我们看一个基础示例:

// 原生 input 的 v-model 等价于:
<input 
  :value="searchText" 
  @input="searchText = $event.target.value"
>

// 这就是 v-model 的真相!

在 Vue 3 中,v-model 迎来了重大升级。现在你可以在同一个组件上使用多个 v-model,这让我们的表单组件开发更加灵活。

自定义 v-model 的核心原理

自定义 v-model 的核心就是实现一个协议:组件内部管理自己的状态,同时在状态变化时通知父组件。

在 Vue 3 中,这变得异常简单。我们来看看如何为一个自定义输入框实现 v-model:

// CustomInput.vue
<template>
  <div class="custom-input">
    <input
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
      class="input-field"
    >
  </div>
</template>

<script setup>
// 定义 props - 默认的 modelValue
defineProps({
  modelValue: {
    type: String,
    default: ''
  }
})

// 定义 emits - 必须声明 update:modelValue
defineEmits(['update:modelValue'])
</script>

使用这个组件时,我们可以这样写:

<template>
  <CustomInput v-model="username" />
</template>

看到这里你可能要问:为什么是 modelValueupdate:modelValue?这就是 Vue 3 的约定。默认情况下,v-model 使用 modelValue 作为 prop,update:modelValue 作为事件。

实战:打造一个功能丰富的搜索框

让我们来实战一个更复杂的例子——一个带有清空按钮和搜索图标的搜索框组件。

// SearchInput.vue
<template>
  <div class="search-input-wrapper">
    <div class="search-icon">🔍</div>
    <input
      :value="modelValue"
      @input="handleInput"
      @keyup.enter="handleSearch"
      :placeholder="placeholder"
      class="search-input"
    />
    <button 
      v-if="modelValue" 
      @click="clearInput"
      class="clear-button"
    >
      ×
    </button>
  </div>
</template>

<script setup>
// 接收 modelValue 和 placeholder
const props = defineProps({
  modelValue: {
    type: String,
    default: ''
  },
  placeholder: {
    type: String,
    default: '请输入搜索内容...'
  }
})

// 定义可触发的事件
const emit = defineEmits(['update:modelValue', 'search'])

// 处理输入事件
const handleInput = (event) => {
  emit('update:modelValue', event.target.value)
}

// 处理清空操作
const clearInput = () => {
  emit('update:modelValue', '')
}

// 处理搜索事件(按回车时)
const handleSearch = () => {
  emit('search', props.modelValue)
}
</script>

<style scoped>
.search-input-wrapper {
  position: relative;
  display: inline-flex;
  align-items: center;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  padding: 8px 12px;
}

.search-icon {
  margin-right: 8px;
  color: #909399;
}

.search-input {
  border: none;
  outline: none;
  flex: 1;
  font-size: 14px;
}

.clear-button {
  background: none;
  border: none;
  font-size: 18px;
  cursor: pointer;
  color: #c0c4cc;
  margin-left: 8px;
}

.clear-button:hover {
  color: #909399;
}
</style>

使用这个搜索框组件:

<template>
  <div>
    <SearchInput 
      v-model="searchText"
      placeholder="搜索用户..."
      @search="handleSearch"
    />
    <p>当前搜索词:{{ searchText }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const searchText = ref('')

const handleSearch = (value) => {
  console.log('执行搜索:', value)
  // 这里可以调用 API 进行搜索
}
</script>

进阶技巧:多个 v-model 绑定

Vue 3 最令人兴奋的特性之一就是支持多个 v-model。这在处理复杂表单时特别有用,比如一个用户信息编辑组件:

// UserForm.vue
<template>
  <div class="user-form">
    <div class="form-group">
      <label>姓名:</label>
      <input
        :value="name"
        @input="$emit('update:name', $event.target.value)"
      >
    </div>
    
    <div class="form-group">
      <label>邮箱:</label>
      <input
        :value="email"
        @input="$emit('update:email', $event.target.value)"
        type="email"
      >
    </div>
    
    <div class="form-group">
      <label>年龄:</label>
      <input
        :value="age"
        @input="$emit('update:age', $event.target.value)"
        type="number"
      >
    </div>
  </div>
</template>

<script setup>
defineProps({
  name: String,
  email: String,
  age: Number
})

defineEmits(['update:name', 'update:email', 'update:age'])
</script>

使用这个多 v-model 组件:

<template>
  <UserForm
    v-model:name="userInfo.name"
    v-model:email="userInfo.email"
    v-model:age="userInfo.age"
  />
</template>

<script setup>
import { reactive } from 'vue'

const userInfo = reactive({
  name: '',
  email: '',
  age: null
})
</script>

处理复杂数据类型

有时候我们需要传递的不是简单的字符串,而是对象或数组。这时候自定义 v-model 同样能胜任:

// ColorPicker.vue
<template>
  <div class="color-picker">
    <div 
      v-for="color in colors" 
      :key="color"
      :class="['color-option', { active: isSelected(color) }]"
      :style="{ backgroundColor: color }"
      @click="selectColor(color)"
    ></div>
  </div>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  modelValue: {
    type: [String, Array],
    default: ''
  },
  multiple: {
    type: Boolean,
    default: false
  },
  colors: {
    type: Array,
    default: () => ['#ff4757', '#2ed573', '#1e90ff', '#ffa502', '#747d8c']
  }
})

const emit = defineEmits(['update:modelValue'])

// 处理颜色选择
const selectColor = (color) => {
  if (props.multiple) {
    const currentSelection = Array.isArray(props.modelValue) 
      ? [...props.modelValue] 
      : []
    
    const index = currentSelection.indexOf(color)
    if (index > -1) {
      currentSelection.splice(index, 1)
    } else {
      currentSelection.push(color)
    }
    
    emit('update:modelValue', currentSelection)
  } else {
    emit('update:modelValue', color)
  }
}

// 检查颜色是否被选中
const isSelected = (color) => {
  if (props.multiple) {
    return Array.isArray(props.modelValue) && props.modelValue.includes(color)
  }
  return props.modelValue === color
}
</script>

<style scoped>
.color-picker {
  display: flex;
  gap: 8px;
}

.color-option {
  width: 30px;
  height: 30px;
  border-radius: 50%;
  cursor: pointer;
  border: 2px solid transparent;
  transition: all 0.3s ease;
}

.color-option.active {
  border-color: #333;
  transform: scale(1.1);
}

.color-option:hover {
  transform: scale(1.05);
}
</style>

使用这个颜色选择器:

<template>
  <div>
    <!-- 单选模式 -->
    <ColorPicker v-model="selectedColor" />
    <p>选中的颜色:{{ selectedColor }}</p>
    
    <!-- 多选模式 -->
    <ColorPicker 
      v-model="selectedColors" 
      :multiple="true" 
    />
    <p>选中的颜色:{{ selectedColors }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const selectedColor = ref('#1e90ff')
const selectedColors = ref(['#ff4757', '#2ed573'])
</script>

性能优化与最佳实践

在实现自定义 v-model 时,我们还需要注意一些性能问题和最佳实践:

// 优化版本的表单组件
<template>
  <input
    :value="modelValue"
    @input="handleInput"
    v-bind="$attrs"
  >
</template>

<script setup>
import { watch, toRef } from 'vue'

const props = defineProps({
  modelValue: [String, Number],
  // 添加防抖功能
  debounce: {
    type: Number,
    default: 0
  }
})

const emit = defineEmits(['update:modelValue'])

let timeoutId = null

// 使用 toRef 确保响应性
const modelValueRef = toRef(props, 'modelValue')

// 监听外部对 modelValue 的更改
watch(modelValueRef, (newValue) => {
  // 这里可以执行一些副作用
  console.log('值发生变化:', newValue)
})

const handleInput = (event) => {
  const value = event.target.value
  
  // 防抖处理
  if (props.debounce > 0) {
    clearTimeout(timeoutId)
    timeoutId = setTimeout(() => {
      emit('update:modelValue', value)
    }, props.debounce)
  } else {
    emit('update:modelValue', value)
  }
}

// 组件卸载时清理定时器
import { onUnmounted } from 'vue'
onUnmounted(() => {
  clearTimeout(timeoutId)
})
</script>

常见问题与解决方案

在实际开发中,你可能会遇到这些问题:

问题1:为什么我的 v-model 不工作? 检查两点:是否正确定义了 update:modelValue 事件,以及是否在 emits 中声明了这个事件。

问题2:如何处理复杂的验证逻辑? 可以在组件内部实现验证,也可以通过额外的 prop 传递验证规则:

// 带有验证的表单组件
<template>
  <div class="validated-input">
    <input
      :value="modelValue"
      @input="handleInput"
      :class="{ error: hasError }"
    >
    <div v-if="hasError" class="error-message">
      {{ errorMessage }}
    </div>
  </div>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  modelValue: String,
  rules: {
    type: Array,
    default: () => []
  }
})

const emit = defineEmits(['update:modelValue', 'validation'])

// 计算验证状态
const validationResult = computed(() => {
  if (!props.rules.length) return { valid: true }
  
  for (const rule of props.rules) {
    const result = rule(props.modelValue)
    if (result !== true) {
      return { valid: false, message: result }
    }
  }
  
  return { valid: true }
})

const hasError = computed(() => !validationResult.value.valid)
const errorMessage = computed(() => validationResult.value.message)

const handleInput = (event) => {
  const value = event.target.value
  emit('update:modelValue', value)
  emit('validation', validationResult.value)
}
</script>

拥抱 Composition API 的强大能力

使用 Composition API,我们可以创建更加灵活的可复用逻辑:

// useVModel.js - 自定义 v-model 的 composable
import { computed } from 'vue'

export function useVModel(props, emit, name = 'modelValue') {
  return computed({
    get() {
      return props[name]
    },
    set(value) {
      emit(`update:${name}`, value)
    }
  })
}

在组件中使用:

// 使用 composable 的组件
<template>
  <input v-model="valueProxy">
</template>

<script setup>
import { useVModel } from './useVModel'

const props = defineProps({
  modelValue: String
})

const emit = defineEmits(['update:modelValue'])

// 使用 composable
const valueProxy = useVModel(props, emit)
</script>

总结与思考

通过今天的学习,我们深入掌握了 Vue 自定义 v-model 的方方面面。从基础原理到高级用法,从简单输入框到复杂表单组件,你现在应该能够自信地为任何场景创建自定义 v-model 组件了。

记住,自定义 v-model 的核心价值在于提供一致的用户体验。无论是在简单还是复杂的场景中,它都能让我们的组件使用起来更加直观和便捷。

现在,回顾一下你的项目,有哪些表单组件可以重构为自定义 v-model 的形式?这种重构会为你的代码库带来怎样的改善?欢迎在评论区分享你的想法和实践经验!

技术的进步永无止境,但掌握核心原理让我们能够从容应对各种变化。希望今天的分享能为你的 Vue 开发之路带来新的启发和思考。

Uniapp如何下载图片到本地相册

2025年11月18日 17:29

uniapp如何下载图片到本地相册?

在一些特定的情况下面,我们需要在uniapp开发的小程序中将图片保存到本地相册,比如壁纸小程序其他小程序,这个使用情况还是十分普遍的,现在我来分享一下我的实现方法和思路

实现方案

文档地址: saveImageToPhotosAlbum:uniapp.dcloud.net.cn/api/media/i… getImageInfo: uniapp.dcloud.net.cn/api/media/i…

我们通过查找uniapp官方文档可以发现里面有一个uni.saveImageToPhotosAlbum({})保存图片到系统相册的API接口可以让我们使用,其中最关键的就是filePath参数,这个参数不支持网络地址就是我们服务器返回的图片地址不可以使用

所以我们需要使用另外一个API接口来帮助我们生成一个临时的filePath路径这个api就是uni.getImageInfo 使用这个api就可以将我们接口返回的网络地址生成一个临时的file地址,这样我就可以配合uni.saveImageToPhotosAlbum({})来实现图片保存到本地相册的功能了

实例代码:

uni.getImageInfo({
src: currentInfo.value.picurl,
success: (res) => {
uni.saveImageToPhotosAlbum({
filePath: res.path,
success: (fileRes) => {
console.log(fileRes, "图片");
}
})
}
})

需要注意的问题

我们在小程序实现下载功能的时候我们还需要到小程序的配置当中将downloadFile合法域名行一个配置不然是无法完成下载的,同时还需要在小程序后台配置中进行一个授权不然也是无法实现保存到微信相册的。

1.downloadFile合法域名配置步骤

进入到小程序配置信息页面 点击开发管理 同时在显示中的页面里面下滑到服务器域名位置 可以看见downloadFile合法域名的位置信息 点击右上角修改 然后将我们的downloadFile域名添加就可以了

image.png

image.png

2. 进行授权配置

进入小程序后台设置 鼠标移入到头像上 根据你的小程序信息进行一个填写和备案 后面找到服务内容声明用户隐私保护指引 进入到里面根据信息提示就可以完成下载图片并且保存到手机本地相册的功能了!

Vue3 响应式原理:从零实现 Reactive

作者 云枫晖
2025年11月18日 16:32

前言

还记得第一次使用 Vue 时的那种惊艳吗?数据变了,视图自动更新,就像魔法一样!但作为一名有追求的前端开发者,我们不能只停留在"会用"的层面,更要深入理解背后的原理。

今天,我将带你从零实现一个 Vue3 的响应式系统,手写代码不到 200 行,却能覆盖核心原理。读完本文,你将彻底明白:

  • 🤔 为什么 Vue3 放弃 Object.defineProperty 选择 Proxy?
  • 🔥 依赖收集和触发更新的精妙设计
  • 🎯 数组方法的重写背后隐藏的智慧
  • 💡 Vue3 响应式相比 Vue2 的性能优势

什么是响应式?

简单来说,响应式是当数据变化时,自动执行依赖数据的代码

const state = reactive({ count: 0 });
effect(() => {
  console.log(`count值变化:${state.count}`);
});
state.count++; // count值变化:1
state.count++; // count值变化:2

vue2和vue3响应式区别

特性 vue2(Object.defineProperty) vue3(proxy)
对象新增属性 $set api实现响应式 直接支持
对象删除属性 $delete api 实现响应式 直接支持
数组拦截 改写数组原型方法 原生支持,重新包装
性能 递归遍历所有属性 懒代理,访问时才代理

综上所述Proxy的优势非常的明显,这就是Vue3选择重构响应式系统的根本原因。

手写实现:从零构建响应式

1. 项目结构

├── reactive.js           // reactive 核心
├── effect.js             // 副作用管理
├── baseHandler.js        // Proxy 处理器
├── arrayInstrumentations.js // 数组方法重写
├── utils.js              // 工具函数
└── index.js              // 入口文件

响应式入口

我们先从reactive函数着手,使用过vue3应该对reactive并不陌生。此函数接收一个对象,然后返回一个代理对象。

// reactive.js
export function reactive(target) {
  // 判断target是否一个对象
  if (!isObject(target)) {
    return target;
  }
  const proxy = new Proxy(target, {
    get(target, key, receiver) {
      // 后续收集依赖
    },
    set(target, key, value, receiver) {
      const oldValue = target[key];
      if (oldValue != value) {
        // 后续触发更新
      }
    }
  })
  return proxy;
}

目前已经搭建了reactive函数的框架,但是目前还有些问题:

  1. 同一个对象代理多次,会返回不同的代理对象,这样性能上带来不必要的开销。
const originalObj = { name: 'Vue', version: 3 }; // 第一次调用 reactive 
const proxy1 = reactive(originalObj); // 第二次调用 reactive(传入同一个对象)
const proxy2 = reactive(originalObj); // 验证两个代理是同一个实例
console.log(proxy1 === proxy2); // false

可以通过缓存代理对象解决此类问题,采用WeakMap来缓存代理对象,keytarget,value为代理对象。

// 缓存代理对象,避免重复代理
const reactiveMap = new WeakMap();
export function reactive(target) {
  // 判断target是否一个对象
  if (!isObject(target)) {
    return target;
  }
  // 将target是否已经代理过,如果代理则返回缓存的代理对象。
  const existsProxy = reactiveMap.get(target);
  if (existsProxy) {
    return existsProxy;
  }
  const proxy = new Proxy(target, {
    /* 此处暂时省略 */
  })
  // 缓存代理对象
  reactiveMap.set(target, proxy);
  return proxy;
}

💡 提示
在上述代码中之所以采用WeakMap主要考虑key是一个对象并且WeakMap可以当target不再引用时会自动清理。

  1. 当已经被reactive处理后,再次调用reactive时,又被代理。
const originalObj = { count: 1 }; // 第一次创建响应式对象 
const proxy1 = reactive(originalObj);
const proxy2 = reactive(proxy1); // 将代理对象再次代理

Vue3的源码中通过__v_isReactive标记来判断:

export const ReactiveFlags = {
  IS_REACTIVE: "__v_isReactive",
};
export function reactive(target) {
  // 判断target是否一个对象
  if (!isObject(target)) {
    return target;
  }
  // 避免重复代理
  if (target[ReactiveFlags.IS_REACTIVE]) {
    return target;
  }
  // 将target是否已经代理过,如果代理则返回缓存的代理对象。
  const existsProxy = reactiveMap.get(target);
  if (existsProxy) {
    return existsProxy;
  }
  const proxy = new Proxy(target, {
    get(target, key, receiver) {
      if (key === ReactiveFlags.IS_REACTIVE) {
        return true;
      }
    },
    /* 此处暂时省略 */
  })
  // 缓存代理对象
  reactiveProxy.set(target, proxy);
  return proxy;
}
  • 当第一次调用reactive时,检查target中是否已经存在__v_isReactive标记,正常情况下是undefined,返回一个Proxy代理对象。
  • 如果将返回的Proxy代理对象,再次调用reactive函数,再次检查__v_isReactive是否存在,将会进入Proxy代理对象的get方法中,进入判断返回true。从而达到无论将相同代理对象调用多少次reactive都不会产生多层代理对象嵌套。

Vue3getset包裹的对象是抽离到一个单独的文件baseHandlers中的,我们也进行相同调整:

// baseHandlers.js
import { ReactiveFlags } from "./reactive"; 
export const mutableHandlers = {
  get(target, key, receiver) {
    // 1. 响应式标识判断(Vue3 源码标准逻辑)
    if (key === ReactiveFlags.IS_REACTIVE) {
      return true;
    }
    /* 后续实现依赖收集 */
  },

  set(target, key, value, receiver) {
    const oldValue = target[key];
    if (oldValue !== value) {
     // 后续触发更新
    }
  },
};
// reactive.js
import { mutableHandlers } from "./baseHandler.js";
export const ReactiveFlags = {
  IS_REACTIVE: "__v_isReactive",
};
export function reactive(target) {
  // 判断target是否一个对象
  if (!isObject(target)) {
    return target;
  }
  // 避免重复代理
  if (target[ReactiveFlags.IS_REACTIVE]) {
    return target;
  }
  // 将target是否已经代理过,如果代理则返回缓存的代理对象。
  const existsProxy = reactiveMap.get(target);
  if (existsProxy) {
    return existsProxy;
  }
  const proxy = new Proxy(target, mutableHandlers)
  // 缓存代理对象
  reactiveProxy.set(target, proxy);
  return proxy;
}

副作用管理

Vue3中提供了一个effect函数,接收一个函数,提供给用户获取数据渲染视图,数据变化后再次调用该函数更新视图。effect具体实现如下:

// 当前响应器
export let activeEffect;
// 清理依赖
export function cleanupEffect(effect) {
  effect.deps.forEach((dep) => {
    dep.delete(effect);
  });
  effect.deps.length = 0;
}
class ReactiveEffect {
  active = true; // 是否激活状态
  deps = []; // 依赖集合数组
  parent = undefined; // 父级effect 处理嵌套effect
  constructor(fn, scheduler) {
    this.fn = fn; // 用户提供的函数
    this.scheduler = scheduler // 调度器(用于computed、watch)
  }
  run() {
    if (!this.active) {
      return this.fn();
    }
    try {
      // 建立effect的父子关系 确保依赖收集的准确性
      this.parent = activeEffect;
      activeEffect = this;
      // 清除旧依赖 避免不必要的更新
      cleanupEffect(this);
      return this.fn();
    } finally {
      // 恢复父级effect
      activeEffect = this.parent;
      this.parent = undefined;
    }
  }
}
export function effect(fn, options = {}) {
  const e = new ReactiveEffect(fn, options.scheduler);
  e.run();
  // 给到用户自行控制响应
  const runner = e.run.bind(e); // 确保this的指向
  runner.effect = e;
  return runner;
}
// 收集依赖函数
export function track(target, key) {}
// 触发依赖
export function trigger(target, key) {}

实现收集依赖

const state = reactive({ name: 'jim '});
effect(() => {
  document.getElementById('app').innerHTML = `${state.name}`;
})

当调用effect函数时,将会执行用户提供的函数逻辑,如上述代码执行state.name时将会进入代理对象的get方法,该方法中进行依赖收集。即调用track函数。

// baseHandler.js
import { isObject } from "./utils";
import { ReactiveFlags, reactive } from "./reactive";
import { track } from "./effect";
export const mutableHandlers = {
  get(target, key, receiver) {
    // 响应式标识判断(Vue3 源码标准逻辑)
    if (key === ReactiveFlags.IS_REACTIVE) {
      return true;
    }
    // 收集依赖(所有属性访问都需要追踪)
    track(target, key);
    // 执行原生 get 操作 
    const result = Reflect.get(target, key, receiver);
    // 深层响应式:嵌套对象/数组自动转为响应式(Vue3 懒代理特性)
    if (result && isObject(result)) {
      return reactive(result);
    }

    return result;
  },
  /* set方法在此省略 */
};

effect.jstrack函数中实现依赖收集

// 当前响应器
export let activeEffect;
export const targetMap = new WeakMap(); //  收集依赖
export function track(target, key) {
  if (!activeEffect) return;
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()));
  }
  let dep = depsMap.get(key);
  if (!dep) {
    depsMap.set(key, (dep = new Set())); // Vue3内部是一个Dep类
  }
  trackEffects(dep);
}
export function trackEffects(dep) {
  let shouldTrack = !dep.has(activeEffect);
  if (shouldTrack) {
    dep.add(activeEffect);
    activeEffect.deps.push(dep); // 双向记录
  }
}

收集完毕后的依赖关系结构:

WeakMap {
  target1: Map {
    key1: Set[effect1, effect2],
    key2: Set[effect3]
  },
  target2: Map { ... }
}

实现触发依赖

当用户对数据进行了修改时,需要根据收集的依赖自动对应执行effect的用户函数。

state.name = 'tom'

baseHandle.js中调用trigger函数。该函数实现具体的触发依赖

// baseHandle.js
import { isObject } from "./utils";
import { ReactiveFlags, reactive } from "./reactive";
import { track, trigger } from "./effect";
export const mutableHandlers = {
  /* get方法实现省略 */
  set(target, key, value, receiver) {
    const oldValue = target[key];
    const success = Reflect.set(target, key, value, receiver);
    // 7. 只有值变化且是自身属性时,才触发更新(避免原型链干扰)
    if (success && oldValue !== value) {
      trigger(target, key); // 触发依赖
    }
    return success;
  },
};

effect.js中实现trigger函数的实现

// 触发依赖
export function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  let dep = depsMap.get(key);
  if (dep) triggerEffects(dep);
}
export function triggerEffects(dep) {
  const effects = [...dep]; // 避免在遍历 Set 过程中修改 Set 本身导致的迭代器异常问题
  effects.forEach((effect) => {
    // 避免无限递归:当前正在执行的effect不再次触发
    if (effect != activeEffect) {
      if (!effect.scheduler) {
        effect.run();
      } else {
        effect.scheduler();
      }
    }
  });
}

对数组响应式处理

Vue3源码中单独一个文件arrayInstrumentations对数组的方法重新包装了一下。我的处理与源码有点不同毕竟是简易版本,但是原理都是一样的

// arrayInstrumentations.js
import { reactive } from "./reactive";
import { trigger } from "./effect";
import { isArray } from "./utils";

// 需要特殊处理的数组修改方法(Vue3 源码中也是用 Set 存储)
export const arrayInstrumentations = new Set([
  "push",
  "pop",
  "shift",
  "unshift",
  "splice",
  "sort",
  "reverse",
]);

/**
 * 包装数组修改方法,添加响应式能力
 * @param {string} method - 数组方法名
 * @returns 包装后的函数
 */
function createArrayMethod(method) {
  // 获取原生数组方法
  const originalMethod = Array.prototype[method];

  return function (...args) {
    // 1. 执行原生数组方法(保证原有功能不变)
    const result = originalMethod.apply(this, args);

    // 2. 处理新增元素的响应式转换(push/unshift/splice 可能添加新元素)
    let inserted;
    switch (method) {
      case "push":
      case "unshift":
        inserted = args; // 这两个方法的参数就是新增元素
        break;
      case "splice":
        inserted = args.slice(2); // splice 第三个参数及以后是新增元素
        break;
    }
    // 新增元素转为响应式(递归处理对象/数组)
    if (inserted) {
      inserted.forEach((item) => {
        if (typeof item === "object" && item !== null) {
          reactive(item);
        }
      });
    }

    // 3. 触发依赖更新(Vue3 源码中会触发 length 和对应索引的更新)
    trigger(this, "length");
    return result;
  };
}

// 生成所有包装后的数组方法(键:方法名,值:包装函数)
export const arrayMethods = Object.create(null);
arrayInstrumentations.forEach((method) => {
  arrayMethods[method] = createArrayMethod(method);
});

/**
 * 判断是否是需要拦截的数组方法
 * @param {unknown} target - 目标对象
 * @param {string} key - 属性名/方法名
 * @returns boolean
 */
export function isArrayInstrumentation(target, key) {
  return isArray(target) && arrayInstrumentations.has(key);
}

然后在baseHandler中添加数组情况下的逻辑

// baseHandler.js
import { isObject } from "./utils";
import { ReactiveFlags, reactive } from "./reactive";
import { track, trigger } from "./effect";
// 引入抽离的数组工具
import { isArrayInstrumentation, arrayMethods } from "./arrayInstrumentations";

export const mutableHandlers = {
  get(target, key, receiver) {
    // 1. 响应式标识判断(Vue3 源码标准逻辑)
    if (key === ReactiveFlags.IS_REACTIVE) {
      return true;
    }
    // 2. 收集依赖(所有属性访问都需要追踪)
    track(target, key);
    // 3. 执行原生 get 操作
    const result = Reflect.get(target, key, receiver);
    // 4. 数组方法拦截:如果是需要处理的数组方法,返回包装后的函数
    if (isArrayInstrumentation(target, key)) {
      // 绑定 this 为目标数组,确保原生方法执行时上下文正确
      return arrayMethods[key].bind(target);
    }
    // 5. 深层响应式:嵌套对象/数组自动转为响应式(Vue3 懒代理特性)
    if (result && isObject(result)) {
      return reactive(result);
    }

    return result;
  },

  set(target, key, value, receiver) {
    const oldValue = target[key];
    const isArrayTarget = Array.isArray(target);
    // 6. 执行原生 set 操作
    const success = Reflect.set(target, key, value, receiver);
    // 7. 只有值变化且是自身属性时,才触发更新(避免原型链干扰)
    if (success && oldValue !== value) {
      // 数组索引设置:触发对应索引和 length 更新(Vue3 源码逻辑)
      if (isArrayTarget && key !== "length") {
        const index = Number(key);
        if (index >= 0 && index < target.length) {
          trigger(target, key); // 触发索引更新
          trigger(target, "length"); // 触发长度更新
          return success;
        }
      }
      // 普通对象/数组 length 设置:触发对应 key 更新
      trigger(target, key);
    }
    return success;
  },
};

完整代码使用示例

import { reactive, effect } from "./packages/index";
const state = reactive({
  name: "vue",
  version: "3.4.5",
  author: "vue team",
  friends: ["jake", "james"],
});
effect(() => {
  app.innerHTML = `
    <div> Welcome ${state.name} !</div>
    <div> ${state.friends} </div>
  `;
});
setTimeout(() => {
  state.name = "vue3"; 
  state.friends.push("jimmy");
}, 1000);
// 一开始显示:
//    Welcome vue 
//    'jake,james'
// 1秒钟后:
//    Welcome vue3
//    'jake,james,jimmy'

总结

通过这 200 行代码,我们实现了一个完整的 Vue3 响应式系统核心:

  • 响应式代理: 基于 Proxy 的懒代理机制
  • 依赖收集: 精准的 effect 追踪
  • 批量更新: 避免重复执行的调度机制
  • 数组处理: 重写数组方法保持响应性
  • 嵌套支持: 自动的深层响应式转换

完整代码和资源 本文所有代码已开源,包含详细注释和测试用例:

GitHub 仓库:github.com/gardenia83/…

这为我们理解 Vue3 的响应式原理提供了坚实的基础,也为学习更高级的特性如 computed、watch 等打下了基础。

wangEditor5在vue中自定义菜单栏--格式刷,上传图片,视频功能

2025年11月18日 14:43

一、安装相关插件

npm install @wangeditor/editor
npm install @wangeditor/editor-for-vue

二、官方关键文档

  1. ButtonMenu:www.wangeditor.com/v5/developm…
  2. 注册菜单到wangEditor:自定义扩展新功能 | wangEditor
  3. insertKeys自定义功能的keys:www.wangeditor.com/v5/toolbar-…
  4. 自定义上传图片视频功能:菜单配置 | wangEditor
  5. 源码地址:GitHub - wangeditor-team/wangEditor: wangEditor —— 开源 Web 富文本编辑器

三、初始化编辑器(wangEdit.vue) 

<template>
  <div style="border: 1px solid #ccc">
    <Toolbar
      style="border-bottom: 1px solid #ccc"
      :editor="editor"
      :defaultConfig="toolbarConfig"
      :mode="mode"
    />
    <Editor
      style="height: 500px; overflow-y: hidden"
      v-model="html"
      :defaultConfig="editorConfig"
      :mode="mode"
      @onCreated="onCreated"
      @onChange="onChange"
    />
  </div>
</template>

<script>
// import Location from "@/utils/location";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import { Boot, IModuleConf, DomEditor } from "@wangeditor/editor";
import { getToken } from "@/utils/auth";
import MyPainter from "./geshi";
const menu1Conf = {
  key: "geshi", // 定义 menu key :要保证唯一、不重复(重要)
  factory() {
    return new MyPainter(); // 把 `YourMenuClass` 替换为你菜单的 class
  },
};
const module = {
  // JS 语法
  menus: [menu1Conf],

  // 其他功能,下文讲解...
};
Boot.registerModule(module);
export default {
  components: { Editor, Toolbar },
  props: {
    relationKey: {
      type: String,
      default: "",
    },
  },
  created() {
    console.log(this.editorConfig.MENU_CONF.uploadImage.meta.activityKey);
  },
  data() {
    return {
      // 富文本实例
      editor: null,
      // 富文本正文内容
      html: "",
      // 编辑器模式
      mode: "default", // or 'simple'
      // 工具栏配置
      toolbarConfig: {
        //新增菜单
        insertKeys: {
          index: 32,
          keys: ["geshi"],
        },
        //去掉网络上传图片和视频
        excludeKeys: ["insertImage", "insertVideo"],
      },
      // 编辑栏配置
      editorConfig: {
        placeholder: "请输入相关内容......",
        // 菜单配置
        MENU_CONF: {
          // ===================
          // 上传图片配置
          // ===================
          uploadImage: {
            // 文件名称
            fieldName: "contentAttachImage",
            server: Location.serverPath + "/editor-upload/upload-image",
            headers: {
              Authorization: "Bearer " + getToken(),
            },
            meta: {
              activityKey: this.relationKey,
            },
            // 单个文件的最大体积限制,默认为 20M
            maxFileSize: 20 * 1024 * 1024,
            // 最多可上传几个文件,默认为 100
            maxNumberOfFiles: 10,
            // 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 []
            allowedFileTypes: ["image/*"],
            // 跨域是否传递 cookie ,默认为 false
            withCredentials: true,
            // 超时时间,默认为 10 秒
            timeout: 5 * 1000,
            // 自定义插入图片操作
            customInsert: (res, insertFn) => {
              if (res.errno == -1) {
                this.$message.error("上传失败!");
                return;
              }
              insertFn(Location.serverPath + res.data.url, "", "");
              this.$message.success("上传成功!");
            },
          },
          // =====================
          // 上传视频配置
          // =====================
          uploadVideo: {
            // 文件名称
            fieldName: "contentAttachVideo",
            server: Location.serverPath + "/editor-upload/upload-video",
            headers: {
              Authorization: "Bearer " + getToken(),
            },
            meta: {
              activityKey: this.relationKey,
            },
            // 单个文件的最大体积限制,默认为 60M
            maxFileSize: 60 * 1024 * 1024,
            // 最多可上传几个文件,默认为 100
            maxNumberOfFiles: 3,
            // 选择文件时的类型限制,默认为 ['video/*'] 。如不想限制,则设置为 []
            allowedFileTypes: ["video/*"],
            // 跨域是否传递 cookie ,默认为 false
            withCredentials: true,
            // 超时时间,默认为 10 秒
            timeout: 15 * 1000,
            // 自定义插入图片操作
            customInsert: (res, insertFn) => {
              if (res.errno == -1) {
                this.$message.error("上传失败!");
                return;
              }
              insertFn(Location.serverPath + res.data.url, "", "");
              this.$message.success("上传成功!");
            },
          },
        },
      },

      // ===== data field end =====
    };
  },
  methods: {
    // =============== Editor 事件相关 ================
    // 1. 创建 Editor 实例对象
    onCreated(editor) {
      this.editor = Object.seal(editor); // 一定要用 Object.seal() ,否则会报错
      this.$nextTick(() => {
        const toolbar = DomEditor.getToolbar(this.editor);
        const curToolbarConfig = toolbar.getConfig();
        console.log("【 curToolbarConfig 】-39", curToolbarConfig);
      });
    },
    // 2. 失去焦点事件
    onChange(editor) {
      this.$emit("change", this.html);
    },

    // =============== Editor操作API相关 ==============
    insertText(insertContent) {
      const editor = this.editor; // 获取 editor 实例
      if (editor == null) {
        return;
      }
      // 执行Editor的API插入
      editor.insertText(insertContent);
    },

    // =============== 组件交互相关 ==================
    // closeEditorBeforeComponent() {
    //   this.$emit("returnEditorContent", this.html);
    // },
    closeContent(){
        this.html=''
    },
    // ========== methods end ===============
  },
  mounted() {
    // ========== mounted end ===============
  },
  beforeDestroy() {
    const editor = this.editor;
    if (editor == null) {
      return;
    }
    editor.destroy();
    console.log("销毁编辑器!");
  },
};
</script>
<style lang="scss" scoped>
// 对默认的p标签进行穿透
::v-deep .editorStyle .w-e-text-container [data-slate-editor] p  {
  margin: 0 !important;
}
</style>
<style src="@wangeditor/editor/dist/css/style.css"></style>
自定义上传图片接口
 uploadImage: {
                        // 文件名称
                        fieldName: "contentAttachImage",
                        // server: '/api/v1/public/uploadFile',
                        headers: {
                            Authorization: "Bearer " + getToken(),
                        },
                        meta: {
                            activityKey: this.relationKey,
                        },
                        // 单个文件的最大体积限制,默认为 20M
                        maxFileSize: 20 * 1024 * 1024,
                        // 最多可上传几个文件,默认为 100
                        maxNumberOfFiles: 10,
                        // 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 []
                        allowedFileTypes: ["image/*"],
                        // 跨域是否传递 cookie ,默认为 false
                        withCredentials: true,
                        // 超时时间,默认为 10 秒
                        timeout: 5 * 1000,
                         这里设置
                        customUpload: async (file, insertFn) => {
                            console.log(file, "file");
                            let formData = new FormData()
                            const sub = "order";
                            formData.append('file', file)
                            formData.append("sub", sub);
                            formData.append("type", "1");
                            let res = await getUploadImg(formData)
                            insertFn(res.data.full_path, '', '');
                        },
                        customInsert: (res, insertFn) => {
                            if (res.errno == -1) {
                                this.$message.error("上传失败!");
                                return;
                            }
                            // insertFn(res.data.url, "", "");
                            this.$message.success("上传成功!");
                        },
                    },

四、格式刷功能类js文件

import {
  SlateEditor,
  SlateText,
  SlateElement,
  SlateTransforms,
  DomEditor,
  //   Boot,
} from "@wangeditor/editor";
// Boot.registerMenu(menu1Conf);
import { Editor } from "slate";
export default class MyPainter {
  constructor() {
    this.title = "格式刷"; // 自定义菜单标题
    // 这里是设置格式刷的样式图片跟svg都可以,但是注意要图片大小要小一点,因为要应用到鼠标手势上
    this.iconSvg = ``;
    this.tag = "button"; //注入的菜单类型
    this.savedMarks = null; //保存的样式
    this.domId = null; //这个可要可不要
    this.editor = null; //编辑器示例
    this.parentStyle = null; //储存父节点样式
    this.mark = "";
    this.marksNeedToRemove = []; // 增加 mark 的同时,需要移除哪些 mark (互斥,不能共存的)
  }
  clickHandler(e) {
    console.log(e, "e"); //无效
  }
  //添加或者移除鼠标事件
  addorRemove = (type) => {
    const dom = document.body;
    if (type === "add") {
      dom.addEventListener("mousedown", this.changeMouseDown);
      dom.addEventListener("mouseup", this.changeMouseup);
    } else {
      //赋值完需要做的清理工作
      this.savedMarks = undefined;
      dom.removeEventListener("mousedown", this.changeMouseDown);
      dom.removeEventListener("mouseup", this.changeMouseup);
      document.querySelector("#w-e-textarea-1").style.cursor = "auto";
    }
  };

  //处理重复键名值不同的情况
  handlerRepeatandNotStyle = (styles) => {
    const addStyles = styles[0];
    const notVal = [];
    for (const style of styles) {
      for (const key in style) {
        const value = style[key];
        if (!addStyles.hasOwnProperty(key)) {
          addStyles[key] = value;
        } else {
          if (addStyles[key] !== value) {
            notVal.push(key);
          }
        }
      }
    }
    for (const key of notVal) {
      delete addStyles[key];
    }
    return addStyles;
  };

  // 获取当前选中范围的父级节点
  getSelectionParentEle = (type, func) => {
    if (this.editor) {
      const parentEntry = SlateEditor.nodes(this.editor, {
        match: (node) => SlateElement.isElement(node),
      });
      let styles = [];
      for (const [node] of parentEntry) {
        styles.push(this.editor.toDOMNode(node).style); //将node对应的DOM对应的style对象加入到数组
      }
      styles = styles.map((item) => {
        //处理不为空的style
        const newItem = {};
        for (const key in item) {
          const val = item[key];
          if (val !== "") {
            newItem[key] = val;
          }
        }
        return newItem;
      });
      type === "get"
        ? func(type, this.handlerRepeatandNotStyle(styles))
        : func(type);
    }
  };

  //获取或者设置父级样式
  getorSetparentStyle = (type, style) => {
    if (type === "get") {
      this.parentStyle = style; //这里是个样式对象 例如{textAlign:'center'}
    } else {
      SlateTransforms.setNodes(
        this.editor,
        { ...this.parentStyle },
        {
          mode: "highest", // 针对最高层级的节点
        }
      );
    }
  };

  //这里是将svg转换为Base64格式
  addmouseStyle = () => {
    const icon = ``; // 这里是给鼠标手势添加图标
    // 将字符串编码为Base64格式
    const base64String = btoa(icon);
    // 生成数据URI
    const dataUri = `data:image/svg+xml;base64,${base64String}`;
    // 将数据URI应用于鼠标图标
    document.querySelector(
      "#w-e-textarea-1"
    ).style.cursor = `url('${dataUri}'), auto`;
  };
  changeMouseDown = () => {}; //鼠标落下

  changeMouseup = () => {
    //鼠标抬起
    if (this.editor) {
      const editor = this.editor;
      const selectTxt = editor.getSelectionText(); //获取文本是否为null
      if (this.savedMarks && selectTxt) {
        //先改变父节点样式
        this.getSelectionParentEle("set", this.getorSetparentStyle);
        // 获取所有 text node
        const nodeEntries = SlateEditor.nodes(editor, {
          //nodeEntries返回的是一个迭代器对象
          match: (n) => SlateText.isText(n), //这里是筛选一个节点是否是 text
          universal: true, //当universal为 true 时,Editor.nodes会遍历整个文档,包括根节点和所有子节点,以匹配满足条件的节点。当universal为 false 时,Editor.nodes只会在当前节点的直接子节点中进行匹配。
        });
        // 先清除选中节点的样式
        for (const node of nodeEntries) {
          const n = node[0]; //{text:xxxx}
          const keys = Object.keys(n);
          keys.forEach((key) => {
            if (key === "text") {
              // 保留 text 属性
              return;
            }
            // 其他属性,全部清除
            SlateEditor.removeMark(editor, key);
          });
        }
        // 再赋值新样式
        for (const key in this.savedMarks) {
          if (Object.hasOwnProperty.call(this.savedMarks, key)) {
            const value = this.savedMarks[key];
            editor.addMark(key, value);
          }
        }
        this.addorRemove("remove");
      }
    }
  };

  getValue(editor) {
    // return "MyPainter"; // 标识格式刷菜单
    const mark = this.mark;
    console.log(mark, "mark");
    const curMarks = Editor.marks(editor);
    // 当 curMarks 存在时,说明用户手动设置,以 curMarks 为准
    if (curMarks) {
      return curMarks[mark];
    } else {
      const [match] = Editor.nodes(editor, {
        // @ts-ignore
        match: (n) => n[mark] === true,
      });
      return !!match;
    }
  }

  isActive(editor, val) {
    const isMark = this.getValue(editor);
    return !!isMark;
    //  return !!DomEditor.getSelectedNodeByType(editor, "geshi");
    // return false;
  }

  isDisabled(editor) {
    //是否禁用
    return false;
  }
  exec(editor, value) {
    //当菜单点击后触发
    // console.log(!this.isActive());
    console.log(value, "value");
    this.editor = editor;
    this.domId = editor.id.split("-")[1]
      ? `w-e-textarea-${editor.id.split("-")[1]}`
      : undefined;
    if (this.isDisabled(editor)) return;
    const { mark, marksNeedToRemove } = this;
    if (value) {
      // 已,则取消
      editor.removeMark(mark);
    } else {
      // 没有,则执行
      editor.addMark(mark, true);
      this.savedMarks = SlateEditor.marks(editor); // 获取当前选中文本的样式
      this.getSelectionParentEle("get", this.getorSetparentStyle); //获取父节点样式并赋值
    //   this.addmouseStyle(); //点击之后给鼠标添加样式
      this.addorRemove("add"); //处理添加和移除事件函数
      // 移除互斥、不能共存的 marks
      if (marksNeedToRemove) {
        marksNeedToRemove.forEach((m) => editor.removeMark(m));
      }
    }
    if (
      editor.isEmpty() ||
      editor.getHtml() == "<p><br></p>" ||
      editor.getSelectionText() == ""
    )
      return; //这里是对没有选中或者没内容做的处理
  }
}

五、页面应用组件

 <el-form-item label="内容">
 <WangEdit v-model="form.content" ref="wangEdit"  @change="change"></WangEdit>
  </el-form-item>


// js
const WangEdit = () => import("@/views/compoments/WangEdit.vue");
export default {
  name: "Notice",
  components: {
    WangEdit,
  },
    data(){
    return{
          form:{
         }
    }
    },

 methods: {
     change(val) {
            console.log(val,'aa');
            this.form.content=val
        },
     // 取消按钮
    cancel() {
      this.open = false;
      this.form={};
      this.$refs.wangEdit.closeContent();
    },
}

转载:wangEditor5在vue中自定义菜单栏--格式刷,上传图片,视频功能_vue.js_liang04273-Vue

npm scripts的高级玩法:pre、post和--,你真的会用吗?

作者 ErpanOmer
2025年11月18日 11:39

image.png

我们每天的开发,可能都是从一个npm run dev开始的。npm scripts对我们来说,天天用它,但很少去思考它。

不信,你看看你项目里的package.json,是不是长这样👇:

"scripts": {
  "dev": "vite",
  "build": "rm -rf dist && tsc && vite build", // 嘿,眼熟吗?
  "lint": "eslint .",
  "lint:fix": "eslint . --fix",
  "test": "vitest",
  "test:watch": "vitest --watch",
  "preview": "vite preview"
}

这能用吗?当然能用。

但这专业吗?在我看来,未必!

一个好的scripts,应该是原子化的、跨平台的。而上面这个,一个build命令就不行,而且rm -rf在Windows上还得装特定环境才能跑🤷‍♂️。

今天,我就来聊聊,如何用prepost--,把你的脚本,升级成专业的脚本。


prepost:命令的生命周期钩子

prepost,是npm内置的一种钩子机制。

它的规则很简单:

  • 当你执行npm run xyz时,npm自动先去找,有没有一个叫prexyz的脚本,有就先执行它。
  • xyz执行成功后,npm自动再去找,有没有一个叫postxyz的脚本,有就最后再执行它。

这个自动的特性,就是神一般的存在。

我们来改造那个前面👆提到的build脚本。

业余写法 (用&&手动编排)

"scripts": {
  "clean": "rimraf dist", // rimraf 解决跨平台删除问题
  "lint": "eslint .",
  "build:tsc": "tsc",
  "build:vite": "vite build",
  "build": "npm run clean && npm run lint && npm run build:tsc && npm run build:vite"
}

你的build脚本,它必须记住所有的前置步骤。如果哪天你想在build前,再加一个test,你还得去修改build的定义。这违反了单一职责

专业写法 (用pre自动触发)

"scripts": {
  "clean": "rimraf dist",
  "lint": "eslint .",
  "test": "vitest run",
  "build:tsc": "tsc",
  "build:vite": "vite build",

  // build的前置钩子
  "prebuild": "npm run clean && npm run lint && npm run test", 
  
  // build的核心命令
  "build": "npm run build:tsc && npm run build:vite",
  
  // build的后置钩子
  "postbuild": "echo 'Build complete! Check /dist folder.'"
}

看到区别了吗?

现在,当我只想构建时,我依然执行npm run build。

npm会自动帮我执行prebuild(清理、Lint、测试)👉 然后执行build(编译、打包)👉 最后执行postbuild(打印日志)。

我的build脚本,只关心构建这件事。而prebuild脚本,只关心前置检查这件事

这就是单一职责和关注点分离。

你甚至可以利用这个特性,搞点骚操作😁:

"scripts": {
  // 当你执行npm start时,它会自动先执行npm run build
  "prestart": "npm run build", 
  "start": "node dist/server.js"
}

-- (双短线):脚本参数

--是我最爱的一个特性。它是一个参数分隔符

它的作用是:告诉npm,我的npm参数到此为止了,后面所有的东西,都原封不动地,传给我要执行的那个底层命令。”

我们来看开头👆那个脚本:

"scripts": {
  "test": "vitest",
  "test:watch": "vitest --watch"
}

为了一个--watch参数,你复制了一个几乎一模一样的脚本。如果明天你还想要--coverage呢?再加一个test:coverage?这叫垃圾代码💩

专业写法 (用--动态传参)

"scripts": {
  "test": "vitest"
}

就这一行,够了。

等等,那我怎么跑watch和coverage?

答案,就是用--🤷‍♂️:

# 1. 只跑一次
$ npm run test -- --run
# 实际执行: vitest --run

# 2. 跑watch模式
$ npm run test -- --watch
# 实际执行: vitest --watch

# 3. 跑覆盖率
$ npm run test -- --coverage
# 实际执行: vitest --coverage

# 4. 跑某个特定文件
$ npm run test -- src/my-component.test.ts
# 实际执行: vitest src/my-component.test.ts

--就像一个参数隧道 ,它把你在命令行里,跟在--后面的所有参数,原封不动地扔给了vitest命令。


一个专业的CI/CD脚本

好了,我们把pre/post--结合起来,看看一个专业的package.json是长什么样子👇。

"scripts": {
  // 1. Lint
  "lint": "eslint .",
  "lint:fix": "eslint . --fix",

  // 2. Test
  "test": "vitest",
  "pretest": "npm run lint", // 在test前,必须先lint

  // 3. Build
  "build": "tsc && vite build",
  "prebuild": "npm run test -- --run", // 在build前,必须先test通过
  
  // 4. Publish (发布的前置钩子)
  // prepublishOnly 是一个npm内置的、比prepublish更安全的钩子
  // 它只在 npm publish 时执行,而在 npm install 时不执行
  "prepublishOnly": "npm run build" // 在发布前,必须先build
}

看看我们构建了怎样一条自动化脚本:

  1. 你兴高采烈地敲下npm publish,准备发布。
  2. npm一看,有个prepublishOnly,于是它先去执行npm run build
  3. npm一看,build有个prebuild,于是它又先去执行npm run test -- --run
  4. npm一看,test有个pretest,于是它又双叒叕先去执行npm run lint

最终的执行流是:Lint -> Test -> Build -> Publish

这些脚本,被pre钩子,自动地、强制地串联了起来。你作为开发者,根本没有机会犯错。你不可能发布一个连Lint都没过或者测试未通过的包😁。


npm scripts,它不是一个简单的脚本快捷方式。它是一个工作流(Workflow)的定义

prepost,定义了你工作流的执行顺序依赖,保证了代码检查等功能,而--是确保你工作流中的脚本参数

现在,马上去打开你项目的package.json,看看它,是专业的,还是业余的呢?🤣

😱一行代码引发的血案:展开运算符(...)竟让图表功能直接崩了!

2025年11月18日 11:27

前言:一个看似简单的 bug

Hello~大家好。我是秋天的一阵风

最近在负责开发我司的一个图表功能时,遇到了一个令人困惑的问题。用户反馈在特定操作下会出现 Maximum call stack size exceeded 错误,但这个问题只在特定条件下出现:选择少量参数正常,但添加大量参数后就会崩溃。

经过深入调试,我发现问题的根源竟然是一行看似无害的代码

const rightYMinMax = [Math.min(...rightYData), Math.max(...rightYData)];

rightYData 数组包含 189,544 个元素时,这行代码导致了堆栈溢出。

这个Bug让我不禁开始思考:在 JavaScript 开发中,简洁的代码一定是最好的代码吗?

为了彻底搞清楚问题本质并找到最优解决方案,我在家中编写了完整的复现案例,通过对比不同实现方式的性能表现,总结出一套可落地的优化方案。

一、问题本质:为什么展开运算会导致栈溢出?

要解决问题,首先要理解问题的根源。在 JavaScript 中,Math.min()Math.max() 是全局函数,它们接收的是可变参数列表(而非数组),而展开运算符(...) 的作用是将数组元素「拆解」成一个个独立参数传递给函数。

1. 调用栈的限制

JavaScript 引擎对函数调用栈的深度有严格限制,不同浏览器和环境下的限制略有差异(通常在 1 万 - 10 万级别的参数数量)。当我们使用 Math.min(...largeArray) 时,相当于执行:

Math.min(1.23, 4.56, 7.89, ..., 999.99); // 参数数量 = 数组长度

当参数数量超过引擎的调用栈阈值时,就会触发「栈溢出」错误。在我们的项目中,

这个阈值大约是 18 万个参数 —— 这也是为什么少量参数正常,18 万 + 参数崩溃的核心原因。

2. 代码层面的隐藏风险

很多开发者喜欢用展开运算符处理数组,因为代码简洁直观。但这种写法在「小数据量」场景下看似无害,一旦数据量增长(比如图表数据、列表数据),就会瞬间暴露风险。更隐蔽的是,这种问题在开发环境中很难复现(开发环境数据量小),往往要等到生产环境才会爆发。

二、复现案例:三种方案的性能对比

为了验证不同实现方式的稳定性和性能,我编写了完整的测试代码(基于 Vue3 + TypeScript),通过「展开运算符」「循环遍历」「Reduce 方法」三种方案,在 10 万、50 万、100 万级数据量下进行对比测试。

1. 核心测试代码

<script setup lang="ts">
import { ref, onMounted } from 'vue';

// 测试数据大小(10万、50万、100万)
const testSizes = ref([100000, 500000, 1000000]);

// 定义测试结果类型
interface TestResult {
  method: string;
  success: boolean;
  result: number[] | null;
  time: number;
  error: string | null;
}
interface TestData {
  size: number;
  tests: TestResult[];
}
const results = ref<TestData[]>([]);

// 生成随机测试数据
const generateTestData = (size: number) => {
  return Array.from({ length: size }, () => Math.random() * 1000);
};

// 方案1:原始方法(展开运算符,会栈溢出)
const getMinMaxWithSpread = (data: number[]): TestResult => {
  try {
    const start = performance.now();
    const result = [Math.min(...data), Math.max(...data)]; // 风险代码
    const end = performance.now();
    return {
      method: '展开运算符',
      success: true,
      result,
      time: end - start,
      error: null
    };
  } catch (error: any) {
    return {
      method: '展开运算符',
      success: false,
      result: null,
      time: 0,
      error: error.message
    };
  }
};

// 方案2:优化方案(循环遍历)
const getMinMaxWithLoop = (data: number[]): TestResult => {
  try {
    const start = performance.now();
    let min = data[0];
    let max = data[0];
    for (let i = 1; i < data.length; i++) {
      if (data[i] < min) min = data[i];
      if (data[i] > max) max = data[i];
    }
    const end = performance.now();
    return {
      method: '循环遍历',
      success: true,
      result: [min, max],
      time: end - start,
      error: null
    };
  } catch (error: any) {
    return {
      method: '循环遍历',
      success: false,
      result: null,
      time: 0,
      error: error.message
    };
  }
};

// 方案3:优化方案(Reduce方法)
const getMinMaxWithReduce = (data: number[]): TestResult => {
  try {
    const start = performance.now();
    const result = data.reduce(
      (acc, curr) => [Math.min(acc[0], curr), Math.max(acc[1], curr)],
      [data[0], data[0]]
    );
    const end = performance.now();
    return {
      method: 'Reduce方法',
      success: true,
      result,
      time: end - start,
      error: null
    };
  } catch (error: any) {
    return {
      method: 'Reduce方法',
      success: false,
      result: null,
      time: 0,
      error: error.message
    };
  }
};

// 执行测试
const runTests = () => {
  console.log('### 开始测试栈溢出问题...');
  results.value = [];
  
  testSizes.value.forEach(size => {
    console.log(`### 测试数据大小: ${size.toLocaleString()}`);
    const testData = generateTestData(size);
    
    // 执行三种方案的测试
    const testResult = {
      size,
      tests: [
        getMinMaxWithSpread(testData),
        getMinMaxWithLoop(testData),
        getMinMaxWithReduce(testData)
      ]
    };
    
    results.value.push(testResult);
    
    // 打印控制台结果
    testResult.tests.forEach(test => {
      if (test.success && test.result) {
        console.log(`### ${test.method}: 成功 - 耗时 ${test.time.toFixed(2)}ms - 结果: [${test.result[0].toFixed(2)}, ${test.result[1].toFixed(2)}]`);
      } else {
        console.log(`### ${test.method}: 失败 - ${test.error}`);
      }
    });
    console.log('### ---');
  });
};

// 页面挂载时执行测试
onMounted(() => {
  runTests();
});
</script>

<template>
  <div style="padding: 20px; font-family: Arial, sans-serif;">
    <h1>Math.min/max 栈溢出问题测试</h1>
    
    <div style="margin: 20px 0;">
      <h2>问题描述</h2>
      <p>当数组元素数量过大时,使用展开运算符 <code>Math.min(...array)</code> 会导致栈溢出错误。</p>
      <p>原因:展开运算符会将所有数组元素作为参数传递给函数,超出JavaScript引擎的调用栈限制。</p>
    </div>
    
    <div style="margin: 20px 0;">
      <button @click="runTests" style="padding: 10px 20px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;">
        重新运行测试
      </button>
    </div>
    
    <div style="margin: 20px 0;">
      <h2>测试结果</h2>
      <div v-for="result in results" :key="result.size" style="margin: 15px 0; padding: 15px; border: 1px solid #ddd; border-radius: 4px;">
        <h3>数据大小: {{ result.size.toLocaleString() }} 个元素</h3>
        <div v-for="test in result.tests" :key="test.method" style="margin: 10px 0; padding: 10px; background: #f8f9fa; border-radius: 4px;">
          <div style="display: flex; justify-content: space-between; align-items: center;">
            <strong>{{ test.method }}</strong>
            <span :style="{ color: test.success ? 'green' : 'red' }">
              {{ test.success ? '✓ 成功' : '✗ 失败' }}
            </span>
          </div>
          <div v-if="test.success" style="margin-top: 5px;">
            <div>耗时: {{ test.time.toFixed(2) }}ms</div>
            <div v-if="test.result">结果: [{{ test.result[0].toFixed(2) }}, {{ test.result[1].toFixed(2) }}]</div>
          </div>
          <div v-else style="margin-top: 5px; color: red;">
            错误: {{ test.error }}
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
image.png

2. 测试结果分析(Chrome 浏览器环境)

通过实际运行测试代码,我得到了以下关键结果(数据为多次测试平均值):

数据量 展开运算符 循环遍历 Reduce 方法
10 万元素 成功(1.6ms) 成功(0.2ms) 成功(1.4ms)
50 万元素 失败(栈溢出错误) 成功(0.4ms) 成功(4.4ms)
100 万元素 失败(栈溢出错误) 成功(0.6ms) 成功(23.9ms)

从结果中可以得出两个核心结论:

  1. 稳定性:展开运算符在数据量超过 10 万后就会触发栈溢出,而循环遍历和 Reduce 方法在 100 万级数据下仍能稳定运行;

  2. 性能:循环遍历的性能最优(耗时最短),Reduce 方法略逊于循环(函数调用有额外开销),展开运算符在小数据量下表现尚可,但稳定性极差。

三、解决方案:从修复到预防

针对「Math.min/max 处理大数据数组」的问题,我们可以从「即时修复」和「长期预防」两个层面制定方案。

方案 1:循环遍历(性能最优)

适用于对性能要求高的场景(如大数据图表、实时计算),代码如下:

const getMinMax = (data: number[]): [number, number] => {
  if (data.length === 0) throw new Error('数组不能为空');
  let min = data[0];
  let max = data[0];
  for (let i = 1; i < data.length; i++) {
    min = Math.min(min, data[i]);
    max = Math.max(max, data[i]);
  }
  return [min, max];
};const getMinMax = (data: number[]): [number, number] => {
  if (data.length === 0) throw new Error('数组不能为空');
  let min = data[0];
  let max = data[0];
  for (let i = 1; i < data.length; i++) {
    min = Math.min(min, data[i]);
    max = Math.max(max, data[i]);
  }
  return [min, max];
};

方案 2:Reduce 方法(代码简洁)

适用于代码风格偏向函数式编程的场景,代码如下:

const getMinMax = (data: number[]): [number, number] => {
  if (data.length === 0) throw new Error('数组不能为空');
  return data.reduce(
    (acc, curr) => [Math.min(acc[0], curr), Math.max(acc[1], curr)],
    [data[0], data[0]]
  );
};

方案 3:分批处理(超大数据量)

当数据量达到「千万级」时,即使循环遍历也可能有内存压力,此时可以分批处理:

const getMinMaxBatch = (data: number[], batchSize = 100000): [number, number] => {
  if (data.length === 0) throw new Error('数组不能为空');
  let min = data[0];
  let max = data[0];
  
  // 分批处理
  for (let i = 0; i < data.length; i += batchSize) {
    const batch = data.slice(i, i + batchSize);
    for (const num of batch) {
      min = Math.min(min, num);
      max = Math.max(max, num);
    }
  }
  
  return [min, max];
};

方案 4:长期预防:建立编码规范

为了避免类似问题再次发生,我们还可以考虑在团队中建立以下编码规范:

  1. 禁止用展开运算符处理未知大小的数组:如果数组长度可能超过 1 万,坚决不用 Math.min(...array) 或 Math.max(...array);
  1. 优先选择循环遍历处理大数据:在性能敏感场景(如数据可视化、列表筛选),优先使用 for 循环而非 Reduce 或其他函数式方法;
  1. 添加数据量校验:对输入数组的长度进行限制,超过阈值时给出警告或分批处理;
  1. 单元测试覆盖边界场景:在单元测试中加入「大数据量」场景(如 10 万、100 万元素),提前暴露问题。

四、总结:跳出「简洁代码」的陷阱

这个看似简单的栈溢出问题,给我们带来了三个深刻的启示:

  1. 简洁不等于优质:很多开发者追求「一行代码解决问题」,但忽略了代码的稳定性和性能。在 JavaScript 中,展开运算符、eval、with 等语法虽然简洁,但往往隐藏着风险;
  1. 关注数据量变化:前端开发不再是「小数据时代」,随着图表、大数据列表、实时数据流的普及,我们必须在代码设计阶段就考虑「大数据场景」;
  1. 重视边界测试:开发环境中的「小数据测试」无法覆盖生产环境的「大数据场景」,必须通过边界测试(如最大数据量、空数据、异常数据)验证代码稳定性。

最后,用一句话总结:优秀的前端工程师,不仅要写出「能运行」的代码,更要写出「稳定、高效、可扩展」的代码。这个栈溢出问题,正是我们从「会写代码」到「写好代码」的一次重要成长。

Vue 3 defineModel 完全指南

作者 hongliangsam
2025年11月18日 09:35

Vue 3 defineModel 完全指南

目录

  1. 概述
  2. 基础概念
  3. 基本用法
  4. 高级特性
  5. TypeScript 支持
  6. 实战案例
  7. 最佳实践
  8. 常见问题
  9. 与传统方式的对比
  10. 总结

概述

defineModel 是 Vue 3.3 引入、3.4 稳定的一个编译器宏,用于简化组件的双向数据绑定实现。它让开发者能够更轻松地创建支持 v-model 的组件,减少了样板代码,提高了开发效率。

为什么需要 defineModel?

在 Vue 3.3 之前,实现一个支持 v-model 的组件需要:

// 传统方式 - 需要定义 props 和 emits
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])

const value = computed({
  get: () => props.modelValue,
  set: (value) => emit('update:modelValue', value)
})

而使用 defineModel 后:

// defineModel 方式 - 简洁明了
const modelValue = defineModel()

主要优势

  • 简化代码:减少重复的样板代码
  • 类型安全:更好的 TypeScript 支持
  • 修饰符支持:内置对 v-model 修饰符的支持
  • 性能优化:编译时优化,运行时开销更小

基础概念

什么是编译器宏?

defineModel 是一个编译器宏,这意味着它会在构建阶段被 Vue 编译器处理,转换为相应的运行时代码。编译器宏只在 <script setup> 中使用,不需要导入。

双向数据绑定原理

Vue 的 v-model 本质上是语法糖:

<!-- 父组件使用 -->
<ChildComponent v-model="count" />

<!-- 等价于 -->
<ChildComponent
  :model-value="count"
  @update:model-value="newValue => count = newValue"
/>

defineModel 自动处理这种 prop 和 emit 的配对关系。


基本用法

1. 简单的模型绑定

<!-- ChildComponent.vue -->
<script setup>
const modelValue = defineModel()
</script>

<template>
  <input
    v-model="modelValue"
    placeholder="输入内容..."
  />
</template>
<!-- ParentComponent.vue -->
<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'

const message = ref('Hello World')
</script>

<template>
  <ChildComponent v-model="message" />
  <p>当前值: {{ message }}</p>
</template>

2. 命名模型

<!-- 自定义模型名称 -->
<script setup>
// 声明名为 'title' 的模型
const title = defineModel('title')

// 声明名为 'count' 的模型
const count = defineModel('count')
</script>

<template>
  <input v-model="title" placeholder="标题" />
  <input v-model="count" type="number" placeholder="数量" />
</template>
<!-- 父组件中使用 -->
<script setup>
import { ref } from 'vue'
import CustomModelComponent from './CustomModelComponent.vue'

const postTitle = ref('')
const postCount = ref(1)
</script>

<template>
  <CustomModelComponent
    v-model:title="postTitle"
    v-model:count="postCount"
  />
  <p>标题: {{ postTitle }}</p>
  <p>数量: {{ postCount }}</p>
</template>

3. 设置默认值和类型

<script setup>
// 带类型和默认值的模型
const name = defineModel({
  type: String,
  default: '匿名用户'
})

const age = defineModel({
  type: Number,
  default: 0
})

const isActive = defineModel({
  type: Boolean,
  default: false
})
</script>

<template>
  <div>
    <label>姓名: <input v-model="name" /></label>
    <label>年龄: <input v-model="age" type="number" /></label>
    <label>
      <input v-model="isActive" type="checkbox" />
      激活状态
    </label>
  </div>
</template>

高级特性

1. 模型修饰符

defineModel 内置对 v-model 修饰符的支持:

<!-- TextInput.vue -->
<script setup>
// 获取模型值和修饰符对象
const [modelValue, modelModifiers] = defineModel()

// 监听修饰符
watch(modelModifiers, (newModifiers) => {
  console.log('当前修饰符:', newModifiers)
})

// 使用修饰符处理数据
const processedValue = computed({
  get: () => modelValue.value,
  set: (value) => {
    if (modelModifiers.trim) {
      value = value.trim()
    }
    if (modelModifiers.upper) {
      value = value.toUpperCase()
    }
    modelValue.value = value
  }
})
</script>

<template>
  <input v-model="processedValue" />
  <p>修饰符: {{ JSON.stringify(modelModifiers) }}</p>
</template>
<!-- 使用修饰符 -->
<script setup>
import TextInput from './TextInput.vue'
const text = ref('')
</script>

<template>
  <TextInput v-model.trim.upper="text" />
  <p>处理后的值: {{ text }}</p>
</template>

2. 自定义修饰符

<!-- NumberInput.vue -->
<script setup>
const [modelValue, modelModifiers] = defineModel({
  // 自定义 getter/setter
  get(value) {
    // 处理自定义修饰符
    if (modelModifiers.currency) {
      return new Intl.NumberFormat('zh-CN', {
        style: 'currency',
        currency: 'CNY'
      }).format(value)
    }
    if (modelModifiers.percentage) {
      return `${value}%`
    }
    return value
  },
  set(value) {
    // 清理格式化后的值
    if (modelModifiers.currency) {
      return parseFloat(value.replace(/[¥,]/g, ''))
    }
    if (modelModifiers.percentage) {
      return parseFloat(value.replace('%', ''))
    }
    return parseFloat(value) || 0
  }
})
</script>

<template>
  <input v-model="modelValue" />
</template>

3. 复杂对象模型

<!-- UserProfile.vue -->
<script setup>
// 复杂对象的模型绑定
const user = defineModel({
  type: Object,
  default: () => ({
    name: '',
    email: '',
    age: 0,
    avatar: ''
  })
})

// 计算属性验证
const isFormValid = computed(() => {
  return user.value.name &&
         user.value.email &&
         user.value.email.includes('@') &&
         user.value.age > 0
})

// 方法
const resetForm = () => {
  user.value = {
    name: '',
    email: '',
    age: 0,
    avatar: ''
  }
}
</script>

<template>
  <div class="user-profile">
    <div class="form-group">
      <label>姓名:</label>
      <input v-model="user.name" />
    </div>

    <div class="form-group">
      <label>邮箱:</label>
      <input v-model="user.email" type="email" />
    </div>

    <div class="form-group">
      <label>年龄:</label>
      <input v-model="user.age" type="number" />
    </div>

    <div class="form-group">
      <label>头像URL:</label>
      <input v-model="user.avatar" />
    </div>

    <button @click="resetForm">重置</button>
    <p v-if="!isFormValid" class="error">
      请填写完整的用户信息
    </p>
  </div>
</template>

<style scoped>
.user-profile {
  max-width: 400px;
  margin: 0 auto;
}

.form-group {
  margin-bottom: 1rem;
}

.form-group label {
  display: block;
  margin-bottom: 0.5rem;
}

.form-group input {
  width: 100%;
  padding: 0.5rem;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.error {
  color: red;
}
</style>

TypeScript 支持

1. 基本类型注解

<script setup lang="ts">
// 基本类型注解
const text = defineModel<string>()
const number = defineModel<number>()
const boolean = defineModel<boolean>()

// 数组类型
const items = defineModel<string[]>({ default: () => [] })

// 对象类型
interface User {
  id: number
  name: string
  email: string
}

const user = defineModel<User>({
  type: Object as PropType<User>,
  required: true
})
</script>

2. 复杂类型定义

<script setup lang="ts">
import type { PropType } from 'vue'

interface FormField {
  id: string
  label: string
  type: 'text' | 'email' | 'number' | 'textarea'
  value: string | number
  required?: boolean
  placeholder?: string
  validation?: {
    min?: number
    max?: number
    pattern?: string
  }
}

const formFields = defineModel<FormField[]>({
  type: Array as PropType<FormField[]>,
  default: () => [],
  // 自定义验证函数
  validator: (value: FormField[]) => {
    return Array.isArray(value) &&
           value.every(field => field.id && field.label && field.type)
  }
})

// 获取修饰符的类型
const [modelValue, modelModifiers] = defineModel<string, {
  trim?: boolean
  uppercase?: boolean
  required?: boolean
}>()

// 类型安全的方法
const updateField = (index: number, value: string) => {
  if (formFields.value[index]) {
    formFields.value[index].value = value
  }
}
</script>

3. 泛型组件

<script setup lang="ts">
// 泛型模型组件
interface GenericModelProps<T> {
  value: T
  options?: T[]
}

// 使用泛型
const modelValue = defineModel<T>({
  type: [String, Number, Object, Array] as PropType<T>,
  required: true
})

// 类型推断
type ModelType = typeof modelValue extends { value: infer T } ? T : never
</script>

实战案例

案例1:自定义输入组件库

<!-- BaseInput.vue -->
<script setup lang="ts">
interface Props {
  type?: 'text' | 'email' | 'password' | 'number' | 'tel'
  placeholder?: string
  disabled?: boolean
  readonly?: boolean
  maxlength?: number
  minlength?: number
}

// 支持多种修饰符
const [modelValue, modelModifiers] = defineModel<string, {
  trim?: boolean
  uppercase?: boolean
  lowercase?: boolean
  number?: boolean
}>()

const props = withDefaults(defineProps<Props>(), {
  type: 'text',
  placeholder: '',
  disabled: false,
  readonly: false
})

const emit = defineEmits<{
  focus: [event: FocusEvent]
  blur: [event: FocusEvent]
  input: [event: Event]
  change: [event: Event]
}>()

// 处理修饰符
const processedValue = computed({
  get: () => {
    let value = modelValue.value || ''

    if (modelModifiers.uppercase) {
      value = value.toUpperCase()
    }
    if (modelModifiers.lowercase) {
      value = value.toLowerCase()
    }

    return value
  },
  set: (value: string) => {
    if (modelModifiers.trim) {
      value = value.trim()
    }
    if (modelModifiers.number) {
      value = value.replace(/[^\d.-]/g, '')
    }
    modelValue.value = value
  }
})

// 事件处理
const handleInput = (event: Event) => {
  const target = event.target as HTMLInputElement
  processedValue.value = target.value
  emit('input', event)
}

const handleChange = (event: Event) => {
  const target = event.target as HTMLInputElement
  processedValue.value = target.value
  emit('change', event)
}
</script>

<template>
  <div class="base-input">
    <input
      :type="type"
      :value="processedValue"
      :placeholder="placeholder"
      :disabled="disabled"
      :readonly="readonly"
      :maxlength="maxlength"
      :minlength="minlength"
      @input="handleInput"
      @change="handleChange"
      @focus="$emit('focus', $event)"
      @blur="$emit('blur', $event)"
      class="input-field"
    />
  </div>
</template>

<style scoped>
.base-input {
  position: relative;
  display: inline-block;
}

.input-field {
  width: 100%;
  padding: 8px 12px;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  font-size: 14px;
  transition: border-color 0.2s;
  box-sizing: border-box;
}

.input-field:focus {
  outline: none;
  border-color: #409eff;
}

.input-field:disabled {
  background-color: #f5f7fa;
  cursor: not-allowed;
}

.input-field:readonly {
  background-color: #f5f7fa;
}
</style>

案例2:可编辑表格组件

<!-- EditableTable.vue -->
<script setup lang="ts">
interface TableColumn {
  key: string
  title: string
  width?: string
  type?: 'text' | 'number' | 'select'
  options?: Array<{ label: string; value: any }>
  required?: boolean
}

interface TableRow {
  [key: string]: any
}

interface Props {
  columns: TableColumn[]
  data: TableRow[]
  editable?: boolean
  addable?: boolean
  deletable?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  editable: true,
  addable: true,
  deletable: true
})

// 双向绑定表格数据
const tableData = defineModel<TableRow[]>('data', {
  type: Array as PropType<TableRow[]>,
  required: true
})

// 计算属性
const hasData = computed(() => tableData.value && tableData.value.length > 0)

// 方法
const addRow = () => {
  const newRow: TableRow = {}
  props.columns.forEach(col => {
    newRow[col.key] = col.type === 'number' ? 0 : ''
  })
  tableData.value = [...tableData.value, newRow]
}

const deleteRow = (index: number) => {
  tableData.value = tableData.value.filter((_, i) => i !== index)
}

const updateCell = (rowIndex: number, columnKey: string, value: any) => {
  const newData = [...tableData.value]
  newData[rowIndex][columnKey] = value
  tableData.value = newData
}

// 验证
const validateRow = (row: TableRow): boolean => {
  return props.columns.every(col => {
    if (col.required && (!row[col.key] || row[col.key] === '')) {
      return false
    }
    return true
  })
}

const validateTable = (): boolean => {
  return tableData.value.every(row => validateRow(row))
}
</script>

<template>
  <div class="editable-table">
    <table class="table">
      <thead>
        <tr>
          <th v-for="column in columns" :key="column.key" :style="{ width: column.width }">
            {{ column.title }}
            <span v-if="column.required" class="required">*</span>
          </th>
          <th v-if="deletable" class="action-column">操作</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="(row, rowIndex) in tableData" :key="rowIndex" :class="{ 'invalid-row': !validateRow(row) }">
          <td v-for="column in columns" :key="column.key">
            <template v-if="editable">
              <input
                v-if="column.type === 'text' || !column.type"
                v-model="row[column.key]"
                @input="updateCell(rowIndex, column.key, ($event.target as HTMLInputElement).value)"
                type="text"
                class="cell-input"
              />
              <input
                v-else-if="column.type === 'number'"
                v-model.number="row[column.key]"
                @input="updateCell(rowIndex, column.key, ($event.target as HTMLInputElement).value)"
                type="number"
                class="cell-input"
              />
              <select
                v-else-if="column.type === 'select'"
                v-model="row[column.key]"
                @change="updateCell(rowIndex, column.key, ($event.target as HTMLSelectElement).value)"
                class="cell-select"
              >
                <option v-for="option in column.options" :key="option.value" :value="option.value">
                  {{ option.label }}
                </option>
              </select>
            </template>
            <template v-else>
              {{ row[column.key] }}
            </template>
          </td>
          <td v-if="deletable" class="action-column">
            <button @click="deleteRow(rowIndex)" class="delete-btn">删除</button>
          </td>
        </tr>
      </tbody>
    </table>

    <div v-if="addable" class="table-actions">
      <button @click="addRow" class="add-btn">添加行</button>
    </div>

    <div v-if="!validateTable()" class="validation-error">
      请填写所有必填字段
    </div>
  </div>
</template>

<style scoped>
.editable-table {
  width: 100%;
}

.table {
  width: 100%;
  border-collapse: collapse;
  margin-bottom: 1rem;
}

.table th,
.table td {
  border: 1px solid #ebeef5;
  padding: 8px 12px;
  text-align: left;
}

.table th {
  background-color: #f5f7fa;
  font-weight: 600;
}

.required {
  color: #f56c6c;
}

.cell-input,
.cell-select {
  width: 100%;
  padding: 4px 8px;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  font-size: 14px;
}

.cell-input:focus,
.cell-select:focus {
  outline: none;
  border-color: #409eff;
}

.invalid-row {
  background-color: #fef0f0;
}

.action-column {
  width: 100px;
  text-align: center;
}

.delete-btn {
  padding: 4px 8px;
  background-color: #f56c6c;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.delete-btn:hover {
  background-color: #f78989;
}

.table-actions {
  margin-bottom: 1rem;
}

.add-btn {
  padding: 8px 16px;
  background-color: #409eff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.add-btn:hover {
  background-color: #66b1ff;
}

.validation-error {
  color: #f56c6c;
  font-size: 14px;
  margin-top: 8px;
}
</style>

案例3:搜索筛选组件

<!-- SearchFilter.vue -->
<script setup lang="ts">
interface FilterOption {
  label: string
  value: any
  type?: 'text' | 'select' | 'date' | 'number'
  options?: Array<{ label: string; value: any }>
}

interface SearchFilterProps {
  filters: FilterOption[]
  placeholder?: string
  debounceTime?: number
}

const props = withDefaults(defineProps<SearchFilterProps>(), {
  placeholder: '搜索...',
  debounceTime: 300
})

// 搜索关键词模型
const searchKeyword = defineModel<string>('keyword', {
  default: ''
})

// 筛选条件模型
const filterValues = defineModel<Record<string, any>>('filters', {
  default: () => ({})
})

// 搜索状态
const isSearching = ref(false)

// 防抖搜索
const debouncedSearch = useDebounceFn(() => {
  isSearching.value = true
  setTimeout(() => {
    isSearching.value = false
  }, 500)
}, props.debounceTime)

// 监听搜索关键词变化
watch(searchKeyword, () => {
  debouncedSearch()
})

// 监听筛选条件变化
watch(filterValues, () => {
  debouncedSearch()
}, { deep: true })

// 重置搜索
const resetFilters = () => {
  searchKeyword.value = ''
  Object.keys(filterValues.value).forEach(key => {
    filterValues.value[key] = ''
  })
}

// 活跃筛选数量
const activeFilterCount = computed(() => {
  return Object.values(filterValues.value).filter(value =>
    value !== '' && value !== null && value !== undefined
  ).length + (searchKeyword.value ? 1 : 0)
})
</script>

<template>
  <div class="search-filter">
    <!-- 搜索输入框 -->
    <div class="search-input-wrapper">
      <div class="search-icon">
        <svg viewBox="0 0 24 24" width="16" height="16">
          <path fill="currentColor" d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
        </svg>
      </div>
      <input
        v-model="searchKeyword"
        type="text"
        :placeholder="placeholder"
        class="search-input"
      />
      <div v-if="searchKeyword" class="clear-icon" @click="searchKeyword = ''">
        <svg viewBox="0 0 24 24" width="16" height="16">
          <path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
        </svg>
      </div>
    </div>

    <!-- 筛选条件 -->
    <div v-if="filters.length > 0" class="filters-wrapper">
      <div v-for="filter in filters" :key="filter.label" class="filter-item">
        <label class="filter-label">{{ filter.label }}:</label>

        <template v-if="filter.type === 'select'">
          <select v-model="filterValues[filter.label]" class="filter-select">
            <option value="">全部</option>
            <option
              v-for="option in filter.options"
              :key="option.value"
              :value="option.value"
            >
              {{ option.label }}
            </option>
          </select>
        </template>

        <template v-else-if="filter.type === 'date'">
          <input
            v-model="filterValues[filter.label]"
            type="date"
            class="filter-input"
          />
        </template>

        <template v-else-if="filter.type === 'number'">
          <input
            v-model.number="filterValues[filter.label]"
            type="number"
            class="filter-input"
          />
        </template>

        <template v-else>
          <input
            v-model="filterValues[filter.label]"
            type="text"
            :placeholder="filter.label"
            class="filter-input"
          />
        </template>
      </div>
    </div>

    <!-- 操作按钮 -->
    <div class="filter-actions">
      <div v-if="activeFilterCount > 0" class="active-filters">
        已选择 {{ activeFilterCount }} 个筛选条件
      </div>
      <button
        v-if="searchKeyword || activeFilterCount > 0"
        @click="resetFilters"
        class="reset-btn"
      >
        重置
      </button>
    </div>

    <!-- 搜索状态指示器 -->
    <div v-if="isSearching" class="searching-indicator">
      搜索中...
    </div>
  </div>
</template>

<style scoped>
.search-filter {
  background-color: #f8f9fa;
  padding: 16px;
  border-radius: 8px;
  margin-bottom: 16px;
}

.search-input-wrapper {
  position: relative;
  margin-bottom: 12px;
}

.search-icon,
.clear-icon {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  color: #6c757d;
}

.search-icon {
  left: 12px;
}

.clear-icon {
  right: 12px;
  cursor: pointer;
}

.clear-icon:hover {
  color: #495057;
}

.search-input {
  width: 100%;
  padding: 8px 40px;
  border: 1px solid #ced4da;
  border-radius: 4px;
  font-size: 14px;
}

.search-input:focus {
  outline: none;
  border-color: #007bff;
  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}

.filters-wrapper {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 12px;
  margin-bottom: 12px;
}

.filter-item {
  display: flex;
  flex-direction: column;
}

.filter-label {
  font-size: 12px;
  color: #6c757d;
  margin-bottom: 4px;
}

.filter-input,
.filter-select {
  padding: 6px 8px;
  border: 1px solid #ced4da;
  border-radius: 4px;
  font-size: 14px;
}

.filter-input:focus,
.filter-select:focus {
  outline: none;
  border-color: #007bff;
  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}

.filter-actions {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.active-filters {
  font-size: 12px;
  color: #007bff;
}

.reset-btn {
  padding: 6px 12px;
  background-color: #6c757d;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 12px;
  cursor: pointer;
}

.reset-btn:hover {
  background-color: #5a6268;
}

.searching-indicator {
  margin-top: 8px;
  font-size: 12px;
  color: #007bff;
  text-align: center;
}
</style>

最佳实践

1. 组件设计原则

<!-- ✅ 好的实践:单一职责 -->
<script setup>
// 每个组件只负责一个主要功能
const value = defineModel<string>()

// 简单的数据处理逻辑
const processedValue = computed({
  get: () => value.value?.trim() || '',
  set: (val) => value.value = val.trim()
})
</script>

<!-- ❌ 避免的实践:过度复杂 -->
<script setup>
// 避免在同一个组件中定义太多模型
const model1 = defineModel('model1')
const model2 = defineModel('model2')
const model3 = defineModel('model3')
// ... 太多模型会导致组件难以维护
</script>

2. 类型安全

<!-- ✅ 推荐的做法:完整的类型定义 -->
<script setup lang="ts">
interface UserForm {
  name: string
  email: string
  age: number
  preferences: {
    theme: 'light' | 'dark'
    notifications: boolean
  }
}

const userForm = defineModel<UserForm>({
  type: Object as PropType<UserForm>,
  required: true,
  validator: (value: UserForm) => {
    return value.name &&
           value.email.includes('@') &&
           value.age > 0
  }
})
</script>

<!-- ❌ 不推荐的做法:缺少类型约束 -->
<script setup>
// 缺少类型定义,容易出现运行时错误
const data = defineModel() // 类型为 unknown
</script>

3. 默认值处理

<!-- ✅ 推荐的做法:合理的默认值 -->
<script setup>
// 对于复杂对象,使用函数返回默认值
const user = defineModel({
  type: Object,
  default: () => ({
    name: '',
    email: '',
    age: 0
  })
})

// 对于数组,使用空数组作为默认值
const items = defineModel({
  type: Array,
  default: () => []
})
</script>

<!-- ❌ 避免的做法:可变引用的默认值 -->
<script setup>
// 可能导致多个组件实例共享同一个对象引用
const user = defineModel({
  type: Object,
  default: { name: '', email: '' } // ❌ 错误:共享引用
})
</script>

4. 验证和错误处理

<script setup>
const email = defineModel<string>({
  type: String,
  required: true,
  validator: (value: string) => {
    // 自定义验证逻辑
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    return emailRegex.test(value)
  }
})

// 错误状态管理
const errorMessage = computed(() => {
  if (email.value && !validator(email.value)) {
    return '请输入有效的邮箱地址'
  }
  return ''
})

// 实时验证
const isValid = computed(() => {
  return !errorMessage.value
})
</script>

<template>
  <div class="form-group">
    <label>邮箱:</label>
    <input v-model="email" type="email" />
    <div v-if="errorMessage" class="error-message">
      {{ errorMessage }}
    </div>
  </div>
</template>

5. 性能优化

<script setup>
// ✅ 使用计算属性进行复杂的数据处理
const [modelValue, modelModifiers] = defineModel<string>()

const processedValue = computed({
  get: () => {
    let value = modelValue.value || ''

    // 复杂的格式化逻辑
    if (modelModifiers.currency) {
      value = Number(value).toLocaleString('zh-CN', {
        style: 'currency',
        currency: 'CNY'
      })
    }

    return value
  },
  set: (value: string) => {
    // 处理用户输入
    if (modelModifiers.currency) {
      value = value.replace(/[¥,]/g, '')
    }

    modelValue.value = value
  }
})

// ❌ 避免在模板中进行复杂计算
<template>
  <!-- 不推荐:在模板中进行复杂计算 -->
  <input :value="formatCurrency(modelValue)" @input="handleInput" />

  <!-- 推荐:使用计算属性 -->
  <input v-model="processedValue" />
</template>
</script>

常见问题

1. 为什么 defineModel 不工作?

问题:组件中使用 defineModel 但没有响应式效果

原因

  • Vue 版本低于 3.3
  • 没有在 <script setup> 中使用
  • 父组件没有正确绑定 v-model

解决方案

<!-- 确保使用 Vue 3.3+ -->
<script setup>
// ✅ 正确使用
const modelValue = defineModel()
</script>

<!-- 父组件正确绑定 -->
<ChildComponent v-model="data" />
<!-- 而不是 -->
<ChildComponent :model-value="data" /> <!-- ❌ 缺少双向绑定 -->

2. 如何处理默认值的同步问题?

问题:子组件设置了默认值,但父组件没有提供值时出现同步问题

解决方案

<script setup>
// ✅ 推荐做法:使用本地状态管理默认值
const props = defineProps({
  modelValue: String
})

const emit = defineEmits(['update:modelValue'])

// 使用计算属性处理默认值
const internalValue = computed({
  get: () => props.modelValue || '默认值',
  set: (value) => emit('update:modelValue', value)
})

// 或者使用 defineModel 的 default 选项
const modelValue = defineModel({
  type: String,
  default: '默认值'
})
</script>

3. TypeScript 类型推断失败

问题:TypeScript 无法正确推断 defineModel 的类型

解决方案

<script setup lang="ts">
// ✅ 显式类型注解
const text = defineModel<string>()

interface FormData {
  name: string
  email: string
}

const formData = defineModel<FormData>({
  type: Object as PropType<FormData>
})

// ✅ 使用泛型约束
const model = defineModel<T extends string | number ? T : string>({
  type: [String, Number] as PropType<T>
})
</script>

4. 修饰符不生效

问题:自定义修饰符没有按预期工作

解决方案

<script setup>
// ✅ 正确使用修饰符
const [modelValue, modelModifiers] = defineModel({
  // 在 set 函数中处理修饰符
  set(value) {
    if (modelModifiers.trim) {
      return value.trim()
    }
    return value
  }
})
</script>

<!-- 使用时添加修饰符 -->
<ChildComponent v-model.trim="data" />

5. 如何避免无限更新循环?

问题:在 getter 和 setter 中设置值时导致无限循环

解决方案

<script setup>
const [modelValue, modelModifiers] = defineModel()

// ✅ 避免在 getter 中修改原值
const processedValue = computed({
  get: () => {
    // 只读取,不修改
    let value = modelValue.value || ''

    if (modelModifiers.uppercase) {
      return value.toUpperCase()
    }

    return value
  },
  set: (value) => {
    // 在 setter 中进行实际修改
    let finalValue = value

    if (modelModifiers.trim) {
      finalValue = value.trim()
    }

    // 直接设置,避免触发 getter
    modelValue.value = finalValue
  }
})
</script>

与传统方式的对比

1. 代码量对比

<!-- ❌ 传统方式:需要大量样板代码 -->
<script setup>
const props = defineProps({
  modelValue: String,
  title: String,
  count: Number
})

const emit = defineEmits([
  'update:modelValue',
  'update:title',
  'update:count'
])

const value = computed({
  get: () => props.modelValue,
  set: (value) => emit('update:modelValue', value)
})

const titleValue = computed({
  get: () => props.title,
  set: (value) => emit('update:title', value)
})

const countValue = computed({
  get: () => props.count,
  set: (value) => emit('update:count', value)
})
</script>

<!-- ✅ defineModel 方式:简洁明了 -->
<script setup>
const modelValue = defineModel()
const title = defineModel('title')
const count = defineModel('count')
</script>

2. 修饰符支持对比

<!-- ❌ 传统方式:需要手动处理修饰符 -->
<script setup>
const props = defineProps({
  modelValue: String,
  modelModifiers: {
    type: Object,
    default: () => ({})
  }
})

const emit = defineEmits(['update:modelValue'])

const value = computed({
  get: () => props.modelValue,
  set: (value) => {
    if (props.modelModifiers.trim) {
      value = value.trim()
    }
    emit('update:modelValue', value)
  }
})
</script>

<!-- ✅ defineModel 方式:内置修饰符支持 -->
<script setup>
const [modelValue, modelModifiers] = defineModel({
  set(value) {
    if (modelModifiers.trim) {
      return value.trim()
    }
    return value
  }
})
</script>

3. TypeScript 支持对比

<!-- ❌ 传统方式:类型推断不够直观 -->
<script setup lang="ts">
interface Props {
  modelValue?: string
}

const props = defineProps<Props>()
const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

const value = computed<string>({
  get: () => props.modelValue || '',
  set: (value) => emit('update:modelValue', value)
})
</script>

<!-- ✅ defineModel 方式:类型推断更直接 -->
<script setup lang="ts">
const value = defineModel<string>({
  default: ''
})
</script>

总结

defineModel 是 Vue 3 生态系统中的一个重要改进,它显著简化了组件双向数据绑定的实现。通过本文档的学习,我们了解到:

核心优势

  1. 简化开发:大幅减少样板代码,提高开发效率
  2. 类型安全:优秀的 TypeScript 支持,减少运行时错误
  3. 性能优化:编译时优化,运行时开销更小
  4. 功能丰富:内置修饰符支持和复杂的配置选项

适用场景

  • 表单组件:输入框、选择器、日期选择器等
  • 数据展示组件:需要编辑功能的数据表格
  • 配置面板:各种设置和配置界面
  • 搜索筛选:复杂的搜索和筛选组件

学习建议

  1. 从基础开始:先掌握基本的 v-model 绑定
  2. 逐步深入:学习修饰符和高级配置
  3. 实践项目:在实际项目中应用 defineModel
  4. 对比学习:了解传统方式的差异,更好地理解 defineModel 的优势

注意事项

  • 确保 Vue 版本为 3.3 或更高
  • 必须在 <script setup> 中使用
  • 注意默认值的处理方式
  • 合理使用 TypeScript 类型约束

defineModel 代表了 Vue.js 持续改进组件开发体验的努力,掌握这一特性将帮助开发者构建更简洁、更可维护的 Vue 应用程序。

❌
❌