Skip to content

Vue 系统学习文档 - 深度剖析篇 ​

补充 Vue 系统学习文档,深入解读 Vue 3 每个核心设计决策背后的"为什么"

包含:设计动机分析 | Vue 2 方案缺陷 | Vue 3 替代方案的设计哲学 | 取舍权衡


一、响应式系统:为什么从 Object.defineProperty 换成 Proxy? ​

1.1 Vue 2 的 Object.defineProperty 方案 ​

Vue 2 在初始化时,通过 Object.defineProperty 递归遍历 data 对象的所有属性,为每个属性添加 getter/setter。

核心代码逻辑:

javascript
// Vue 2 的 observe 函数(简化版)
function observe(value) {
  if (!isObject(value)) return;
  if (value.__ob__) return value.__ob__; // 避免重复观察

  // 核心:递归遍历所有属性
  Object.keys(value).forEach(key => defineReactive(value, key, value[key]));
}

function defineReactive(obj, key, val) {
  observe(val); // 递归:如果 val 是对象,继续深度遍历

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      dep.depend(); // 依赖收集
      return val;
    },
    set(newVal) {
      if (newVal === val) return;
      val = newVal;
      dep.notify(); // 触发更新
    }
  });
}

1.2 Vue 2 方案的三大缺陷 ​

缺陷一:无法检测属性的添加和删除 ​

为什么?

Object.defineProperty 是在初始化阶段对已有属性进行劫持。当你给对象添加一个全新属性时,Vue 2 根本没有对这个新属性设置 getter/setter,自然也无法追踪它的变化。

javascript
export default {
  data() {
    return { user: { name: "Alice" } };
  },
  created() {
    // Vue 2 初始化时只遍历了 "name",没设置 "age"
    this.user.age = 25;  // ❌ 不是响应式的!
  }
};

Vue 2 的弥补方案:

javascript
// 使用 Vue.set 手动添加响应式属性
this.$set(this.user, "age", 25);
// 或 import Vue from "vue"; Vue.set(this.user, "age", 25);

// 为什么需要 $set?
// $set 内部会判断目标是否是响应式对象,如果是,就手动调用 defineReactive
// 为新属性添加 getter/setter,然后通知更新
function set(target, key, val) {
  // ... 省略其他判断
  defineReactive(target, key, val); // 为新属性添加响应式
  target.__ob__.dep.notify(); // 手动触发更新
}

为什么这是一个问题?

在实际项目中,后端 API 返回的数据结构经常不确定,或者需要在运行时动态添加状态。每次新增属性都要用 $set,不仅繁琐而且容易遗漏。遗漏一个 $set 就意味着一个静默的 bug——页面不会更新,但没有任何报错提示。

缺陷二:无法检测数组索引和长度的变化 ​

为什么?

JavaScript 数组索引本质上是对象的属性。理论上 Object.defineProperty 可以拦截索引赋值,但 Vue 2 出于性能考虑没有这么做。因为一个数组可能有成千上万个元素,对每个索引进行劫持在大型列表场景下性能开销极大。

javascript
export default {
  data() {
    return { items: ["a", "b", "c"] };
  },
  methods: {
    replaceItem() {
      this.items[0] = "x"; // ❌ 不是响应式的!
    },
    correctReplace() {
      this.$set(this.items, 0, "x");  // ✅ Vue.set
      this.items.splice(0, 1, "x");   // ✅ splice(已重写)
      this.items = [...this.items];    // ✅ 替换整个数组
    }
  }
};

Vue 2 的弥补方案:重写 7 个数组方法

Vue 2 对数组的 7 个变异方法(push/pop/shift/unshift/splice/sort/reverse)进行了重写,在这些方法内部手动触发更新:

javascript
// Vue 2 数组方法重写(简化版)
const arrayProto = Array.prototype;
const arrayMethods = Object.create(arrayProto);

["push", "pop", "shift", "unshift", "splice", "sort", "reverse"].forEach(method => {
  const original = arrayProto[method];
  Object.defineProperty(arrayMethods, method, {
    value: function(...args) {
      const result = original.apply(this, args);

      // 关键:手动触发更新
      const ob = this.__ob__;
      let inserted;
      switch (method) {
        case "push": case "unshift": inserted = args; break;
        case "splice": inserted = args.slice(2); break;
      }
      if (inserted) ob.observeArray(inserted); // 新插入的元素也要响应式化
      ob.dep.notify(); // 手动通知更新

      return result;
    }
  });
});

缺陷三:递归遍历的性能问题 ​

问题本质:

Vue 2 在初始化时,需要深度递归遍历 data 中的所有属性。对于深层嵌套的对象,这个过程的性能开销是 O(n) 且需要一次性完成。如果你的 data 对象有 100 层嵌套、10,000 个属性,Vue 2 在 created 之前就要全部遍历一遍。

javascript
// Vue 2 初始化阶段
const data = {
  level1: {
    level2: {
      level3: {
        // ... 深层嵌套
      }
    }
  }
};
// 每个层级都要递归调用 defineReactive
// 嵌套越深、属性越多,初始化越慢

1.3 Vue 3 为什么选择 Proxy? ​

Proxy 的核心优势 ​

优势一:完整的拦截能力

Proxy 可以拦截 13 种不同的操作,包括属性读取、设置、删除、枚举等。最重要的是,它能拦截到原本不存在的属性操作。

javascript
// Vue 3 响应式核心(简化版)
function reactive(target) {
  if (!isObject(target)) return target;

  return new Proxy(target, {
    get(target, key, receiver) {
      // 拦截所有属性读取:不管属性是否存在
      track(target, key);
      const result = Reflect.get(target, key, receiver);
      // 惰性递归——只有访问到时才递归处理
      return isObject(result) ? reactive(result) : result;
    },
    set(target, key, value, receiver) {
      const oldValue = target[key];
      const hadKey = hasOwn(target, key);
      const result = Reflect.set(target, key, value, receiver);

      if (!hadKey) {
        // 新增属性——Proxy 能拦截到!
        trigger(target, "add", key);
      } else if (oldValue !== value) {
        // 修改属性
        trigger(target, "set", key);
      }
      return result;
    },
    deleteProperty(target, key) {
      const hadKey = hasOwn(target, key);
      const result = Reflect.deleteProperty(target, key);
      if (hadKey) {
        // 删除属性——Proxy 能拦截到!
        trigger(target, "delete", key);
      }
      return result;
    },
    has(target, key) {
      // 拦截 in 操作符
      track(target, "has", key);
      return Reflect.has(target, key);
    },
    ownKeys(target) {
      // 拦截 Object.keys/for...in
      track(target, "iterate");
      return Reflect.ownKeys(target);
    }
  });
}

优势二:惰性递归

Vue 3 的响应式是惰性的——只有当你访问一个属性的值时,如果它的值是对象,才会递归处理它。这意味着:

javascript
const data = reactive({
  level1: {
    level2: {
      deepValue: "非常深层"
    }
  }
});

// 此时仅 level1 被代理了,level2 和 deepValue 尚未被递归处理
// 只有当你访问 data.level1.level2 时,level2 才被 reactive 包裹
const v = data.level1.level2; // 在此刻才递归

这种惰性方式让 Vue 3 的初始化速度远快于 Vue 2,尤其是在 data 对象结构复杂的大型项目中。

优势三:数组不需要特殊处理

javascript
const arr = reactive([1, 2, 3]);
arr[0] = 100;     // ✅ 直接拦截到 set
arr[3] = 4;       // ✅ 新增索引,拦截到 set
arr.length = 2;   // ✅ 长度变化,拦截到 set

不再需要重写数组方法,不再需要 $set,不再需要 splice。这一切都是因为 Proxy 可以原生拦截数组的索引赋值和长度修改。

Proxy 的代价 ​

Vue 3 选择 Proxy 并非没有代价:

  1. 兼容性:Proxy 无法被 polyfill。这意味着 Internet Explorer 完全不支持。Vue 3 直接放弃了 IE 支持——这是一个经过深思熟虑的决定:

    • 2020 年 IE 全球市场份额已低于 2%
    • 微软已宣布 2022 年停止支持 IE
    • 保留 IE 支持会拖累整个框架的发展
  2. 性能:Proxy 本身比普通属性访问慢,但在大多数实际场景中这个差异可以忽略不计。而且在 Vue 3 的整体优化(虚拟 DOM 重写、编译优化)下,整体性能反而显著提升。

  3. 无法直接访问原始对象:经过 Proxy 包装的对象和原始对象不相等。但 Vue 3 通过 ref 的 .value 和 reactive 的自动解包设计,让开发者几乎不需要关心这个问题。

1.4 总结对比 ​

维度Vue 2 (Object.defineProperty)Vue 3 (Proxy)
拦截粒度属性级别对象级别
新增属性不拦截,需 $set自动拦截
删除属性不拦截,需 $delete自动拦截
数组索引不拦截自动拦截
数组长度不拦截自动拦截
初始化性能O(属性总数),递归遍历O(1),惰性递归
内存占用每个属性一个闭包每个对象一个 Proxy
IE 兼容IE9+不支持 IE
代码复杂度需要 $set/$delete + 数组方法重写简洁,无特殊处理

结论:Vue 3 换用 Proxy 是一次"长痛不如短痛"的架构决策。短期看放弃了 IE 兼容性,但长期看大幅简化了框架的实现和开发者的使用体验。


1.5 深入:依赖收集与派发更新(track / trigger / effect) ​

前面讲了 Proxy 如何拦截读写,但拦截之后「怎么知道该通知谁」?这就是依赖收集(track)与派发更新(trigger)要解决的问题,也是理解 computed 缓存、watch 触发、组件精准重渲染的钥匙。

三层依赖结构:

text
targetMap: WeakMap<target, depsMap>
  └─ depsMap: Map<key, dep>
       └─ dep: Set<ReactiveEffect>
javascript
// 简化实现
const targetMap = new WeakMap();
let activeEffect = null;

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()));
  dep.add(activeEffect);
}

function trigger(target, key) {
  const dep = targetMap.get(target)?.get(key);
  if (dep) dep.forEach(effect => effect.run());
}

为什么最外层用 WeakMap? 因为它的键是响应式对象本身,而 WeakMap 对键是弱引用——响应式对象一旦没有其他引用就能被 GC,不会因为 targetMap 持有它而泄漏内存。这是响应式系统「不阻止对象回收」的关键设计。

effect 是副作用的最小执行单元。Vue 3 把组件渲染、computed、watch 统一包装成 ReactiveEffect:

javascript
class ReactiveEffect {
  constructor(fn) { this.fn = fn; }
  run() {
    const prev = activeEffect;
    activeEffect = this;          // 执行前标记「当前 effect」
    try { return this.fn(); }     // fn 读数据 → get → track → 收集依赖
    finally { activeEffect = prev; }
  }
}

完整链路:

text
首次渲染
  effect(组件渲染函数).run()
    -> 渲染函数读取 count.value
    -> get -> track(count, "value")
    -> dep = { 渲染 effect }

修改数据
  count.value = 2
    -> set -> trigger(count, "value")
    -> dep.forEach(effect => effect.run())   // 重新渲染

一个关键的设计决策:依赖收集发生在「读取」阶段,而不是「声明」阶段。 这带来三个重要性质:

  1. 精确到属性:只有真正被 effect 读过的数据才建立依赖,没读到的数据改了也不会触发这个 effect。这就是「改了哪个属性,只重跑依赖它的 effect」的由来。

  2. 依赖是动态的:这次渲染读了 a,下次渲染走另一条分支读了 b,依赖会自动跟着变。effect 每次 run 都会重新建立依赖(先清空旧的再收集新的),所以不需要手动管理依赖生命周期。

  3. 读取时机决定依赖:同一个 effect 里「先读后写」和「先写后读」会产生不同的依赖结果——因为写入会触发其他 effect,而读取才触发 track。理解这一点能避免很多「为什么 watch 没触发/触发了两次」的困惑。

二、虚拟 DOM:为什么全面重写? ​

2.1 Vue 2 虚拟 DOM 的问题 ​

Vue 2 使用基于 Snabbdom 的虚拟 DOM 实现,每次更新时无法精确知道哪些节点是静态的、哪些是动态的,因此需要对整个虚拟 DOM 树进行全量 diff。

javascript
// Vue 2 渲染函数
render(h) {
  return h("div", { class: "container" }, [
    h("h1", null, "静态标题"),                    // 永远不变的静态节点
    h("p", null, this.dynamicText),               // 只有文本会变的节点
    h("div", { class: { active: this.isActive } }, // 只有 class 会变的节点
      this.items.map(item => h("span", null, item.name))
    )
  ]);
}

// Vue 2 的 diff 过程(每次更新都跑一遍):
// 1. 比较 div.container
// 2. 比较 h1 → 发现没变化
// 3. 比较 p → 发现文本变了,更新
// 4. 比较 div → 发现 class 变了
// 5. 遍历 items → 每个 span 都要比较
// 问题:h1 明明永远不会变,但每次更新还是要比较它

2.2 Vue 3 的核心编译优化 ​

Vue 3 意识到:有很多信息在编译时就已经知道了,不需要等到运行时再去判断。因此,Vue 3 的编译器在编译阶段就标记出动态节点,生成带有优化信息的渲染函数。

优化一:PatchFlag(补丁标记) ​

Vue 3 的编译器会分析模板,为每个动态绑定生成一个位掩码标记,告诉运行时这个节点具体哪些部分是动态的:

vue
<template>
  <div>
    <span>静态文本</span>      <!-- 没有 PatchFlag,完全跳过 -->
    <span :class="cls">文字</span>  <!-- PatchFlag = 2 (CLASS) -->
    <span :id="id">{{ text }}</span> <!-- PatchFlag = 9 (TEXT + PROPS) -->
  </div>
</template>

编译后的渲染函数:

javascript
// Vue 3 编译结果(简化)
import { createElementVNode as _createVNode, toDisplayString as _toDisplayString } from "vue";

// 静态节点提升到函数外部,只创建一次!
const _hoisted_1 = _createVNode("span", null, "静态文本", -1 /* HOISTED */);

function render(_ctx, _cache) {
  return _createVNode("div", null, [
    _hoisted_1,                                                    // 完全不用比较
    _createVNode("span", { class: _ctx.cls }, "文字", 2 /* CLASS */), // 只需要比较 class
    _createVNode("span", { id: _ctx.id }, _toDisplayString(_ctx.text), 9 /* TEXT + PROPS */)
    // 只需要比较 text 内容 + id 属性
  ]);
}

PatchFlag 使用位运算进行比较,效率极高:

javascript
// PatchFlag 定义(位掩码)
const PatchFlags = {
  TEXT: 1,            // 动态文本内容
  CLASS: 2,           // 动态 class
  STYLE: 4,           // 动态 style
  PROPS: 8,           // 动态属性(class/style 除外)
  FULL_PROPS: 16,     // 属性名(key)也是动态的
  NEED_HYDRATION: 32, // 需要处理事件监听等 props hydration
  NEED_PATCH: 512,    // 仅需非 props 的 patch(ref、指令钩子等)
};

// 运行时比较一个只有 TEXT 变化的节点:
if (patchFlag & PatchFlags.TEXT) {  // 位运算,O(1) 判断
  patchText(oldVNode, newVNode);
}
if (patchFlag & PatchFlags.CLASS) {
  patchClass(oldVNode, newVNode);
}
// 没有标记的维度根本不比较!

为什么位掩码?

因为一个节点可能有多种动态类型(例如同时有动态文本和动态 class),位掩码可以用一个数字表示多种标记的组合。在运行时只需要一次位运算就能判断是否需要处理某个维度,性能极高。

优化二:静态提升 ​

Vue 3 将模板中完全不依赖动态数据的静态节点提升到渲染函数外部,这样它们在每次更新时被复用,不会重新创建:

javascript
// 未提升时的写法(Vue 2 风格):
function render() {
  return createVNode("div", null, [
    createVNode("span", null, "永远不变"), // 每次 render 都重新创建
    createVNode("span", null, dynamicText)
  ]);
}

// Vue 3 静态提升后:
const _hoisted_1 = createVNode("span", null, "永远不变"); // 只创建一次!

function render() {
  return createVNode("div", null, [
    _hoisted_1, // 直接复用,不需要重新创建也无需比较
    createVNode("span", null, dynamicText, 1 /* TEXT */)
  ]);
}

对于大段的静态内容(如纯展示性的 HTML 块),静态提升的收益非常明显。

优化三:Block Tree(区块树) ​

Vue 3 将模板的根节点和所有含 v-if、v-for 的节点视为"动态区块"(Block)。每个 Block 会追踪其内部的所有动态子节点,形成一个扁平的动态节点数组:

vue
<template>
  <div>                          <!-- Block Root -->
    <span>静态</span>
    <span>{{ name }}</span>      <!-- 动态节点 #1 -->
    <div v-if="show">            <!-- Block: 新的动态区块 -->
      <p>{{ title }}</p>         <!-- 动态节点 #2 -->
      <p>{{ content }}</p>       <!-- 动态节点 #3 -->
    </div>
  </div>
</template>

在更新时,Vue 3 只需要遍历这个扁平的动态节点数组([节点1, 节点2, 节点3]),而不需要递归地比较整棵树。当虚拟 DOM 树很深时,这种优化的效果非常显著。

优化四:事件缓存 ​

Vue 3 默认缓存内联事件处理函数:

vue
<button @click="count++">点击</button>
javascript
// Vue 2:每次 render 都创建新函数
render(h) {
  return h("button", { on: { click: () => this.count++ } });
}

// Vue 3:利用闭包缓存,只创建一次
function render(_ctx, _cache) {
  return _createVNode("button", {
    onClick: _cache[0] || (_cache[0] = ($event) => _ctx.count++)
  });
}

2.3 Vue 3 虚拟 DOM 总结 ​

优化Vue 2Vue 3效果
diff 策略全量递归比较PatchFlag 定向 diff跳过静态节点比较
静态节点每次重新创建提升到函数外部减少创建和 GC 开销
树遍历递归整棵树Block Tree 扁平化跳过层级遍历
事件函数每次重新创建闭包缓存减少内存分配

核心思路:Vue 2 的虚拟 DOM 是"运行时的黑盒"——编译器仅生成渲染函数,所有的优化决策都在运行时做。Vue 3 则是"编译时 + 运行时协作"——编译器尽可能多地分析模板信息,将优化注入到渲染函数中。


2.4 深入:keyed diff 的具体过程(双端对比 + 最长递增子序列) ​

上一节讲的是「编译期」如何缩小 diff 范围,这一节讲「运行期」真正比较新旧子节点列表时做了什么。当 v-for 的节点带 key 时,Vue 3 的 patchKeyedChildren 分五步:

text
旧: [a, b, c, d, e, f]
新: [a, b, e, c, d, g, f]
         ↑ i=2  (两端同步后剩下的中间区间)
text
第一步 sync from start:头指针 i 向后,key+type 相同就复用 patch,直到遇到不同
        a、b 相同 → 跳过
第二步 sync from end:尾指针 e1/e2 向前,同样复用 patch
        f 相同 → 跳过
第三步 common + mount:旧节点全部比对完、新节点还有剩余 → 剩余的全是新增,挂载
第四步 common + unmount:新节点比对完、旧节点还有剩余 → 剩余的全是删除,卸载
第五步 unknown sequence:两端对齐后剩下的中间「未知区间」
        旧中间: [c, d, e]
        新中间: [e, c, d, g]

第五步是核心,处理「顺序被打乱」的情况:

text
1. 遍历新中间节点,建立 key -> newIndex 映射(keyToNewIndexMap)
2. 遍历旧中间节点,用 key 去映射表里找:
   - 找不到 → 该旧节点被删了,卸载
   - 找到了 → 复用真实 DOM,递归 patch,记录它在新列表里的位置
3. 得到 newIndexToOldIndexMap(新位置 -> 旧位置),值 0 表示「新节点,需挂载」
4. 对 newIndexToOldIndexMap 求「最长递增子序列」(LIS)
5. 从尾到头遍历:
   - 在 LIS 里的 → 位置已正确,不动
   - 不在 LIS 里的 → 移动 DOM(insertBefore 到正确位置)
   - 值为 0 的 → 挂载新节点

为什么要求最长递增子序列? LIS 代表「旧列表中已经保持正确相对顺序、不需要移动」的最长节点集合。找到它之后,只需移动其余少数节点,就能用最少的 DOM 移动次数完成重排。这是 Vue 3 keyed diff 相比 Vue 2 纯双端 diff 在复杂乱序场景下的主要改进——Vue 2 的 updateChildren 只靠双端指针两两交换,遇到大规模乱序时移动次数可能更多。

key 的决定性作用:diff 判断两个节点「是不是同一个」靠 key + type 都相等。没有 key(或用 index 当 key)时,Vue 退化为按位置复用,列表一旦插入/删除,复用错误的 DOM 会导致输入框内容、组件内部状态串位——这是「为什么 key 必须稳定唯一」的底层原因,也是 keyed diff 存在的前提。

三、API 设计:为什么从 Options API 到 Composition API? ​

3.1 Options API 的设计背景 ​

Vue 2 的 Options API 在 2016 年设计时,主要考虑的是降低入门门槛和代码可读性。将不同功能的代码按"选项类型"(data/methods/computed/watch)组织,对于简单组件来说非常直观。

javascript
export default {
  data() { return { count: 0 }; },
  computed: { double() { return this.count * 2; } },
  methods: { increment() { this.count++; } },
  watch: { count(val) { console.log(val); } }
};

3.2 Options API 在大型项目中的三大问题 ​

问题一:逻辑关注点分散 ​

当组件变得复杂时,同一个逻辑关注点的代码被强制分散在不同的选项中:

javascript
// 一个"用户管理"功能的代码分散在 4 个选项中
export default {
  data() {
    return {
      // 用户功能
      userProfile: {}, userList: [], searchQuery: "",
      // 通知功能
      notifications: [], unreadCount: 0,
      // 权限功能
      permissions: [], roles: []
    };
  },
  computed: {
    // 用户功能
    filteredUsers() { /* ... */ },
    // 通知功能
    hasUnread() { /* ... */ },
    // 权限功能
    isAdmin() { /* ... */ }
  },
  watch: {
    // 用户功能
    searchQuery() { this.fetchUsers(); },
    // 通知功能
    notifications() { /* ... */ }
  },
  methods: {
    // 用户功能
    fetchUsers() { /* ... */ },
    // 通知功能
    markAsRead() { /* ... */ },
    // 权限功能
    checkPermission() { /* ... */ }
  },
  mounted() {
    this.fetchUsers();
    this.fetchNotifications();
  }
};

当你需要理解"用户搜索"功能的完整逻辑时,你需要在 data/computed/watch/methods/mounted 之间来回跳转。在大型组件中(500+ 行),这个问题会严重拖累开发效率。

问题二:Mixins 的逻辑复用缺陷 ​

Vue 2 用 Mixins 实现逻辑复用,但 Mixins 有三个致命问题:

命名冲突:

javascript
// mixin1.js
export const mixin1 = {
  data() { return { name: "来自 mixin1" }; },
  methods: { log() { console.log("mixin1"); } }
};

// mixin2.js
export const mixin2 = {
  data() { return { name: "来自 mixin2" }; }, // 冲突!
  methods: { log() { console.log("mixin2"); } } // 冲突!
};

// 组件中
export default {
  mixins: [mixin1, mixin2],
  mounted() {
    console.log(this.name); // 覆盖规则复杂,难以预测
    this.log(); // 到底调用哪个?
  }
};

来源不明确:

javascript
export default {
  mixins: [userMixin, permissionMixin, analyticsMixin, uiMixin],
  mounted() {
    // this 上突然出现一堆属性,不知道来自哪个 mixin
    this.fetchUser();        // userMixin?
    this.checkPermission();  // permissionMixin?
    this.trackEvent();       // analyticsMixin?
    this.showModal();        // uiMixin?
  }
};

不支持传递参数:

Mixins 是静态合并的,无法在组件层级传参配置,导致代码不够灵活:

javascript
// 如果想配置 mixin 的行为,只能通过这种方式:
export default {
  mixins: [searchMixin],
  data() {
    return { searchEndpoint: "/api/users" }; // 靠约定覆盖
  }
};

更隐蔽的问题:Mixin 会制造组件的隐式 API。

例如一个列表页 mixin:

javascript
// mixins/listPage.js
export const listPageMixin = {
  data() {
    return {
      loading: false,
      list: [],
      page: 1,
      pageSize: 10
    };
  },
  created() {
    this.fetchList();
  },
  methods: {
    async fetchList() {
      this.loading = true;
      try {
        const res = await this.requestApi({
          page: this.page,
          pageSize: this.pageSize
        });
        this.list = res.list;
      } finally {
        this.loading = false;
      }
    }
  }
};

这个 mixin 表面上只暴露了 fetchList,实际上它隐式要求宿主组件必须提供:

  • requestApi 方法。
  • 兼容 { page, pageSize } 的接口参数。
  • 兼容 { list } 的响应结构。
  • 不和 loading、list、page、pageSize 命名冲突。
  • 能接受 created 阶段自动发起请求。

这些依赖没有写在函数参数里,也没有写在组件 props 里,而是散落在 this 上。项目越大,这种“靠约定连接”的逻辑越难维护。

Mixin 在 Vue 2 时代流行,是因为 Options API 里缺少一个天然的“按功能复用逻辑”的位置。Composition API 出现后,Vue 官方推荐从 Mixin 转向 Composable,本质上就是把这些隐式 API 变成显式参数和显式返回值。

3.3 Composition API 的设计哲学 ​

Composition API 的核心思路是:按功能逻辑组织代码,而不是按选项类型。

javascript
// 同一个功能的代码集中在一起
function useUserManagement() {
  const userProfile = ref({});
  const searchQuery = ref("");
  const filteredUsers = computed(() => { /* ... */ });

  async function fetchUsers() { /* ... */ }
  watch(searchQuery, fetchUsers);

  return { userProfile, searchQuery, filteredUsers, fetchUsers };
}

function useNotification() {
  const notifications = ref([]);
  const unreadCount = computed(() => notifications.value.filter(n => !n.read).length);
  async function fetchNotifications() { /* ... */ }

  return { notifications, unreadCount, fetchNotifications };
}

// 组件中
export default {
  setup() {
    const { userProfile, filteredUsers, fetchUsers } = useUserManagement();
    const { notifications, unreadCount } = useNotification();

    return { userProfile, filteredUsers, fetchUsers, notifications, unreadCount };
  }
};

设计动机逐条分析:

  1. 为什么不用 class? React 社区已经证明了 class 在热更新、this 绑定、mixin 等方面的问题。Composition API 选择了纯函数的方式。

  2. 为什么叫 Composition(组合)? 核心思想是"组合优于继承"。不是通过继承层次来复用代码,而是通过组合小型函数来构建复杂逻辑。

  3. 为什么引入 ref/reactive 而不是保留 this.xxx? this 在 JavaScript 中行为复杂(动态作用域、bind/call/apply 影响),函数式方式避免了 this 带来的心智负担。

3.4 为什么 Vue 2.7 向后移植了 Composition API? ​

这是一个重要的信号:Vue 团队并不认为 Composition API 是 Vue 3 的专属特性,而是 Vue 生态中更好的代码组织方式。因此在 Vue 2.7 中,Composition API 被内置到了 Vue 核心(此前 Vue 2.6 才需要 @vue/composition-api 插件),让暂时无法升级到 Vue 3 的项目也能直接使用。

但这并不意味着在 Vue 2 中使用 Composition API 就能获得 Vue 3 的所有好处——Vue 2 底层的响应式系统仍然是 Object.defineProperty,Proxy 带来的惰性递归和完整拦截能力是 Vue 2 无法通过补丁获得的。 ​

3.5 为什么 ref 需要 .value,而 reactive 不需要? ​

这是 Vue 3 被问得最多的问题之一。答案由 JavaScript 的能力边界决定,不是 API 设计者的任性。

根本原因:基本类型(number/string/boolean)无法被 Proxy 代理——Proxy 只能代理对象,new Proxy(0, ...) 会直接报错。所以响应式基本类型必须「包一层」:用对象包住值,通过对 .value 的 getter/setter 拦截读写:

javascript
class RefImpl {
  constructor(value) {
    this._value = toReactive(value);   // 对象会再转成 reactive
    this.__v_isRef = true;
  }
  get value() { trackRefValue(this); return this._value; }
  set value(v) {
    if (hasChanged(v, this._value)) {
      this._value = toReactive(v);
      triggerRefValue(this);
    }
  }
}

而 reactive 直接用 Proxy 代理对象本身,读写对象属性时天然走 get/set 陷阱,不需要额外的 .value。

.value 带来的三个次生设计:

  1. 自动解包(unwrap):模板里写 {{ count }} 而不是 {{ count.value }},是因为编译器对 setup 返回的 ref 做了自动解包;reactive({ n: ref(1) }) 里访问 .n 也会自动解包。这让 ref 在「使用侧」几乎无感,.value 只在「赋值侧」出现。

  2. reactive 会解包嵌套 ref:reactive 对象里嵌套的 ref 会被自动解包,所以你无法在 reactive 里「整体替换」一个 ref。这也是为什么需要「整体替换」的数据推荐用 ref 而非 reactive 的原因之一。

  3. 为什么不让 ref 彻底去掉 .value? Vue 团队确实尝试过——实验性的 Reactivity Transform($ref 语法糖)让 count = 2 这类普通赋值也能触发响应式,但该提案在 3.3 被标记弃用、3.4 从核心移除。否决的核心理由是:丢失 .value 会让人看不清「哪一行在追踪依赖、哪一行在触发更新」,且「变量赋值能触发副作用」扭曲了 JavaScript 语义,在大代码库和跨 SFC 场景下心智负担反而更重。

💡 一句话总结:.value 是用「显式的对象属性访问」换「无需编译器魔法即可实现的响应式赋值」。这是权衡的结果,不是疏漏。

四、状态管理:为什么从 Vuex 到 Pinia? ​

4.1 Vuex 的核心设计回顾 ​

Vuex 的设计受 Flux(Facebook 提出的单向数据流架构)和 Redux 的深刻影响,引入了严格的单向数据流:

组件 → dispatch → Action → commit → Mutation → 修改 State → 渲染更新

这个设计的初衷是让状态变化可预测、可追踪。每当你看到 state 变化了,可以通过 DevTools 回溯到是哪个 mutation 导致的。

4.2 Mutation 为什么是多余的抽象? ​

Vuex 中的 Mutation 是同步函数,作用是修改 state。但问题在于:

javascript
// Vuex 中修改 state 的"标准流程"
// 步骤1:定义 mutation
mutations: {
  SET_USER(state, user) {
    state.user = user;  // 唯一能修改 state 的地方
  }
}

// 步骤2:定义 action(可异步)
actions: {
  async fetchUser({ commit }, id) {
    const user = await api.getUser(id);
    commit("SET_USER", user);  // commit mutation
  }
}

// 步骤3:组件中 dispatch action
this.$store.dispatch("fetchUser", 1);

为什么 Pinia 不需要 mutation?

Pinia 的核心洞察是:在 Vue 3 的响应式系统中,state 本身就是响应式的,修改 state 就是修改响应式数据,框架可以自动追踪变化。不需要 mutation 这个"中间层"来显式声明"我要修改 state 了"。

javascript
// Pinia:直接修改
const store = useUserStore();
store.user = newUser;  // 直接修改,不需要 commit
// 框架自动追踪这次修改,DevTools 仍然可以记录

// 或者在 action 中修改
actions: {
  async fetchUser(id) {
    this.user = await api.getUser(id); // 直接修改 this.state
  }
}

Vuex 为什么需要 mutation?

Vuex 设计时有一个关键考量:DevTools 的追踪。但在 Vue 3 中,Proxy 拦截了所有 set 操作,框架本身就能知道 state 何时被修改。mutation 作为一个"用来告诉框架我在修改 state"的机制变得多余——框架自己就能检测到。

Pinia 的 $patch 方法:

Pinia 提供了 $patch 来替代 mutation 的批量更新功能:

javascript
// 同时修改多个属性,只触发一次更新
store.$patch({
  name: "Alice",
  age: 25
});

// 函数形式:用于复杂逻辑
store.$patch((state) => {
  state.items.push({ id: 1 });
  state.total = state.items.length;
});

4.3 Vuex 的 TypeScript 困境 ​

这是 Vuex 被 Pinia 取代的第二个核心原因。

Vuex 中的类型问题:

typescript
// 类型注解繁琐且容易断裂
const store = useStore();

// store.state.count 的类型是 any,需要手动断言
const count: number = (store.state as any).count;

// commit 和 dispatch 没有类型检查
store.commit("INCREMENT"); // 如果拼写为 "INCREMNT",没有编译错误
store.commit("increment"); // 大小写错误也没有类型提示
store.dispatch("nonExistentAction"); // 编绎通过,运行时才崩溃

Pinia 中的类型推导:

typescript
// 定义 store 时类型自动推导
export const useCounterStore = defineStore("counter", () => {
  const count = ref(0); // 自动推导为 Ref<number>
  const name = ref("counter"); // 自动推导为 Ref<string>
  return { count, name };
});

// 使用 store 时类型完整
const store = useCounterStore();
store.count; // 类型:number(自动推导)
store.name;  // 类型:string(自动推导)
store.nonExistent; // 编译错误:类型上不存在该属性

为什么 Vuex 做不到?

Vuex 的设计没有基于 TypeScript 考虑。它基于普通的 JavaScript 对象(state、getters、mutations、actions),这些对象的类型推断在 Vue 2 的时代非常困难。Pinia 则完全用 TypeScript 编写,并利用了 Vue 3 的 ref 和 reactive 的类型系统。

4.4 Vuex 的模块化 vs Pinia 的独立 Store ​

Vuex 模块化的问题:

javascript
// Vuex 模块化
const store = new Vuex.Store({
  modules: {
    cart: {
      namespaced: true, // 需要手动开启命名空间
      state: { items: [] },
      getters: { total: state => state.items.length }
    },
    user: {
      namespaced: true,
      state: { profile: null },
      mutations: { SET_PROFILE(state, profile) { state.profile = profile; } }
    }
  }
});

// 访问时需要加上命名空间路径
store.state.cart.items;       // 知道是 cart 模块的 items
store.getters["cart/total"];  // 字符串路径,无类型检查
store.commit("user/SET_PROFILE", data); // 字符串拼接名称

Pinia 的独立 Store:

javascript
// 每个 store 就是一个独立的模块
// stores/cart.js
export const useCartStore = defineStore("cart", () => {
  const items = ref([]);
  const total = computed(() => items.value.length);
  return { items, total };
});

// stores/user.js
export const useUserStore = defineStore("user", () => {
  const profile = ref(null);
  function setProfile(data) { profile.value = data; }
  return { profile, setProfile };
});

// 组件中直接引用
const cartStore = useCartStore();
const userStore = useUserStore();
cartStore.items;  // 类型完整,来源明确
userStore.setProfile(data); // 直接调用方法

为什么 Pinia 不需要 namespaced?

因为在 Pinia 中,每个 store 都是一个独立的 defineStore 调用,它们天然就是隔离的。你通过 useXxxStore() 获取 store,它的所有属性、方法都清晰可见。不需 namespaced,不需字符串拼接。


五、构建工具:为什么从 Webpack 到 Vite? ​

5.1 Webpack 的架构瓶颈 ​

Webpack 的架构基于静态分析 + 打包:

启动流程:
1. 从入口文件开始
2. 递归解析所有依赖模块(即使代码还没执行)
3. 转换模块(loader 处理)
4. 打包、优化、生成 bundle
5. 启动 DevServer

核心问题:所有操作在请求之前就完成了
javascript
// Webpack 开发模式:
// entry.js → 解析 → app.js → 解析 → utils.js → 解析 → ...
// 即使你只修改了一行代码,Webpack 也需要重新打包整个模块图

// 大项目 Webpack 冷启动时间:
// 1万模块:约 30-60 秒
// 5万模块:约 2-5 分钟
// 热更新:大项目中 2-10 秒

Webpack 慢的本质原因:

  1. JavaScript 实现的打包器:Webpack 自身的代码是用 JavaScript 写的,在大型项目中模块解析和代码转换本身就很慢。

  2. 项目越大越慢:因为所有模块在启动时就要被打包,这是 O(n) 的操作,n 是模块数量。

  3. 热更新也需要模块编译:每次修改文件,Webpack 需要重新编译被修改的模块及其依赖链,生成新的 chunk。在大型项目中这个耗时可能达到秒级。

5.2 Vite 为什么快:原生 ESM + esbuild ​

Vite 开发模式的三大加速引擎:

引擎一:利用浏览器原生 ESM

javascript
// Vite 开发模式:
// 入口 index.html
<script type="module" src="/src/main.ts"></script>

// 浏览器请求 main.ts → Vite 服务器 → 返回转换后的 ESM
// 浏览器请求 App.vue → Vite 服务器 → 编译为 ESM → 返回
// 浏览器请求 HMR 模块 → Vite 只处理被修改的那个文件

// 核心差异:Vite 不打包!让浏览器自己处理模块导入
// 浏览器需要什么模块,Vite 就处理什么模块

引擎二:esbuild 预构建依赖

javascript
// Vite 用 esbuild(Go 编写)预构建第三方依赖
// Go 编译为原生二进制,速度比 JavaScript 打包器快 10-100 倍

// npm install lodash-es
// Webpack:每次构建都需要解析 lodash 的数百个内部文件
// Vite:用 esbuild 将 lodash 数百个文件合并为单个 ESM bundle

// 性能对比(预构建):
// Webpack(JS):解析 node_modules → 15-30 秒
// Vite(esbuild):预构建 → 0.5-2 秒

引擎三:按需编译

javascript
// Webpack:启动时编译所有模块
// 你的项目有 1000 个文件 → 启动时就编译 1000 个文件

// Vite:只编译浏览器请求的文件
// 首页只需要 20 个文件 → 只编译 20 个文件
// 跳转到 /admin 页面 → 才编译 admin 的模块

// 这就是为什么项目越大,Vite 的优势越明显

5.3 Vite 的代价 ​

Vite 的快速开发体验并非没有代价:

  1. 生产构建用 Rollup 而不是 esbuild:esbuild 的代码分割和 CSS 处理不如 Rollup 成熟,所以 Vite 的开发和生产使用两套工具链。

  2. 不支持 CommonJS:如果某个依赖只提供 CJS 格式,Vite 需要在预构建时将其转为 ESM。大多数现代库已经支持 ESM,但仍有少数旧库需要处理。

  3. 兼容性要求:需要浏览器支持 <script type="module">,不支持 IE。

5.4 为什么 Vue CLI 被 create-vue 取代? ​

Vue CLI 是基于 Webpack 的重量级工具,提供了配置(vue.config.js)、插件系统(@vue/cli-plugin-xxx)、UI 界面等。但问题在于:

  1. 复杂的抽象层:Vue CLI 在 Webpack 之上又封装了一层配置,当需要自定义 Webpack 配置时,开发者需要学习 Vue CLI 的插件体系和 Webpack 的双重知识。

  2. 升级困难:依赖 @vue/cli-service 的版本,升级 Vue 时需要同时升级 CLI,且插件之间有复杂的版本兼容关系。

  3. 生态耦合:CLI 插件和 Vue 版本之间强耦合,导致 Vue 3 发布后 CLI 插件的适配进展缓慢。

create-vue 采用了完全不同的思路:

  • 极简:只做两件事——创建项目结构和安装依赖
  • 零配置:没有额外的抽象层,Vite 的配置即项目的配置
  • 可选的 feature:TypeScript/JSX/Pinia/Router 等都可以通过参数选择

六、组件复用:为什么从 Mixin 到 Composables? ​

6.1 Vue 2 Mixin 的深层次问题 ​

除了前面提到的命名冲突和来源不明确外,Mixin 还有一个更隐蔽的问题:选项合并策略复杂。

javascript
const mixin1 = {
  data() { return { value: 1 } },
  created() { console.log("mixin1 created"); }
};

const mixin2 = {
  data() { return { value: 2 } },
  created() { console.log("mixin2 created"); }
};

export default {
  mixins: [mixin1, mixin2],
  data() { return { value: 3 } },
  created() { console.log("component created"); }
};

// 输出顺序:
// mixin1 created → mixin2 created → component created
// this.value 最终 = 3(组件覆盖 mixin)
// 但对于数组类型的选项(如生命周期钩子),所有函数都会执行

// 合并规则总结:
// data:组件覆盖 mixin,后面的 mixin 覆盖前面的
// 生命周期钩子:全部执行,mixin 先执行
// methods/computed:组件覆盖 mixin
// watch:全部执行

这种复杂的合并规则导致当你使用 3 个以上 mixin 时,很难预测代码的执行顺序和行为。

6.1.1 Vue Mixin 合并不是简单对象合并 ​

Vue Mixin 的本质是“组件选项合并”。它不是 JavaScript 中的 Object.assign(component, mixin),而是 Vue 根据不同选项类型执行不同策略。

选项类型合并行为风险
data每个 data 函数都会执行,然后返回值合并同名字段覆盖,且多个来源都能修改同一份状态
created / mounted 等生命周期合并成数组,mixin 先执行,组件后执行接口请求、埋点、初始化顺序容易失控
methods合并成对象,同名方法组件优先覆盖是静默的,不容易发现
computed合并成对象,同名计算属性组件优先依赖关系不清楚
watch合并成数组,所有 watcher 都执行同一个字段被多个 watcher 监听,副作用叠加
components / directives合并成对象,组件局部定义优先命名冲突时排查成本高

一个典型问题是生命周期重复请求:

javascript
const analyticsMixin = {
  mounted() {
    this.trackPageView();
  }
};

const permissionMixin = {
  mounted() {
    this.checkPermission();
  }
};

export default {
  mixins: [analyticsMixin, permissionMixin],
  mounted() {
    this.fetchDetail();
  }
};

组件挂载时实际会执行:

text
analyticsMixin.mounted
permissionMixin.mounted
component.mounted

如果其中任何一个 mixin 又依赖另一个 mixin 的初始化结果,就会出现顺序耦合。顺序耦合不是语法错误,通常只有线上某个状态异常时才暴露。

6.1.2 全局 Mixin 的放大效应 ​

全局 Mixin 更危险,因为它会影响应用里的每个组件。

javascript
Vue.mixin({
  created() {
    if (this.$options.pageCode) {
      this.reportPage(this.$options.pageCode);
    }
  }
});

这类写法常见于老项目埋点、权限、国际化、页面标题处理。问题在于:

  • 所有组件创建时都会多执行一段逻辑。
  • 组件库里的内部组件也可能被影响。
  • 这段逻辑和业务组件没有显式 import 关系。
  • 测试单个组件时,也可能被全局 mixin 干扰。

更稳的方案是插件提供明确 API:

javascript
// plugins/analytics.js
export function installAnalytics(app, analytics) {
  app.config.globalProperties.$track = analytics.track;
}

或者在 Vue 3 中提供 composable:

javascript
export function useAnalytics() {
  function trackPage(pageCode) {
    // ...
  }

  return { trackPage };
}

这样组件是否使用埋点能力是显式的,而不是被全局注入。

6.1.3 从 Mixin 迁移到 Composable 的步骤 ​

迁移时不要一上来把所有 mixin 全删掉。更稳的方式是按风险拆解。

第一步,列出 mixin 的输入和输出:

text
输入:
- 依赖 this.requestApi
- 依赖 this.userId
- 依赖路由 this.$route.query

输出:
- loading
- list
- page
- fetchList()

第二步,把隐式依赖改成函数参数:

javascript
export function useListPage({ requestApi, getQuery }) {
  const loading = ref(false);
  const list = ref([]);
  const page = ref(1);

  async function fetchList() {
    loading.value = true;
    try {
      const query = getQuery();
      const res = await requestApi({ page: page.value, ...query });
      list.value = res.list;
    } finally {
      loading.value = false;
    }
  }

  return {
    loading,
    list,
    page,
    fetchList
  };
}

第三步,在组件中显式组装:

javascript
const route = useRoute();

const {
  loading,
  list,
  page,
  fetchList
} = useListPage({
  requestApi: getUserList,
  getQuery: () => route.query
});

迁移后的收益:

  • 依赖从 this.xxx 变成函数参数。
  • 输出从“自动挂到组件实例上”变成显式 return。
  • 不再依赖 Vue 的选项合并策略。
  • 单元测试可以直接测试 useListPage。
  • TypeScript 能推导参数和返回值。

6.2 Composables 如何解决这些问题 ​

Composables 的解决思路非常直接——根本没有合并。每个 Composable 就是一个独立的函数,你可以在组件中显式地调用它们:

javascript
// composables/useMouseTracker.js
import { ref, onMounted, onUnmounted } from "vue";

export function useMouseTracker() {
  const x = ref(0);
  const y = ref(0);

  function update(e) {
    x.value = e.pageX;
    y.value = e.pageY;
  }

  onMounted(() => window.addEventListener("mousemove", update));
  onUnmounted(() => window.removeEventListener("mousemove", update));

  return { x, y };
}

// 组件
import { useMouseTracker } from "@/composables/useMouseTracker";
import { useCounter } from "@/composables/useCounter";

// 显式解构,来源一目了然
const { x, y } = useMouseTracker();
const { count, increment } = useCounter();

// 即使重名也只需重命名
const { count: counter1 } = useCounter(0);
const { count: counter2 } = useCounter(10);

七、插槽语法:为什么从 slot/slot-scope 到 v-slot 指令? ​

7.1 Vue 2 的插槽语法问题 ​

Vue 2 的插槽语法经历了两个阶段:

阶段一(Vue 2.0 - 2.5):不同功能不同语法

html
<!-- 具名插槽 -->
<Child>
  <div slot="header">头部</div>
  <div>默认内容</div>
</Child>

<!-- 作用域插槽 -->
<Child>
  <div slot-scope="{ item }">
    {{ item.name }}
  </div>
</Child>

<!-- 同时使用具名和作用域 -->
<Child>
  <div slot="item" slot-scope="{ item, index }">
    {{ index }}: {{ item.name }}
  </div>
</Child>

为什么问题很大?

  1. slot 和 slot-scope 是属性而非指令:它们是 HTML 属性(attribute)而非 Vue 指令(directive),这导致它们在语法上与 Vue 的其他功能(v-bind、v-on、v-for)不一致。

  2. 作用域插槽语法不统一:slot-scope 可以作用于普通元素和 <template> 上,但 slot 在 Vue 2.6 之前只能作用于元素上,不能单独在 <template> 上使用。

  3. 限制想象力:当插槽语法只是属性时,开发者不太容易想到它可以动态变化。

阶段二(Vue 2.6+):统一为 v-slot

Vue 2.6 引入了 v-slot 指令统一了插槽语法,但在 2.x 中旧的 slot 和 slot-scope 仍然被支持(以便向下兼容)。这导致 Vue 2 的代码中可能同时存在三种插槽语法。

7.2 Vue 3 为什么要彻底移除旧语法? ​

原因一:指令化的插槽语法更强大

html
<!-- 动态插槽名 -->
<Child>
  <template #[dynamicSlotName]="{ item }">
    {{ item.name }}
  </template>
</Child>

当插槽是属性时,slot="header" 没法动态化——你不能写 :[slotName]="header",因为属性名表达式(动态参数)是 Vue 2.6 才加入的编译功能。而 v-slot:header 是指令参数,天然支持动态参数 v-slot:[slotName]。

原因二:简化编译器和类型系统

支持两套语法(slot + slot-scope 和 v-slot)意味着编译器要写两套解析逻辑,类型系统要为两套语法提供定义。Vue 3 决定"长痛不如短痛",彻底移除旧的属性式插槽语法,只保留 v-slot(及其缩写 #)。

原因三:slot-scope 的实现复杂性

slot-scope 的实现需要创建一个独立的函数作用域来捕获作用域数据,而 v-slot 的实现更简洁,与 Vue 3 的编译器架构更契合。


八、v-model:为什么 prop/event 名称从 value/input 改为 modelValue/update:modelValue? ​

8.1 Vue 2 v-model 的设计及问题 ​

html
<!-- Vue 2 v-model 本质 -->
<ChildComponent v-model="value" />
<!-- 等价于 -->
<ChildComponent :value="value" @input="value = $event" />

value 和 input 为什么是问题?

  1. 和原生 HTML 冲突:原生 <input> 的 value 是字符串,但自定义组件可能期望 value 是数字、对象、数组。当你在一个组件上使用 v-model 传一个对象时,value 这个 prop 名已经不能表达它的含义了。

  2. 和组件内部属性冲突:组件内部可能恰好需要另一个 value prop 用于不同目的,但 value 已经被 v-model 占用了。

  3. 限制每个组件只能有一个 v-model:因为 value 和 input 是固定的,你无法同时绑定 v-model:name 和 v-model:email。

html
<!-- Vue 2 中想要"多个 v-model"的变通方案 -->
<ChildComponent
  :name="name"
  :email="email"
  @update:name="name = $event"
  @update:email="email = $event"
/>
<!-- 完全无法使用 v-model 语法糖 -->

8.2 Vue 3 的修正方案 ​

Vue 3 将默认 prop 名从 value 改为 modelValue,将默认事件从 input 改为 update:modelValue。

html
<!-- Vue 3 v-model 本质 -->
<ChildComponent v-model="value" />
<!-- 等价于 -->
<ChildComponent :modelValue="value" @update:modelValue="value = $event" />

设计决策分析:

  1. 为什么是 modelValue 而不是 model-value? Vue 的 prop 命名规范是 camelCase,因为 props 是通过 JavaScript 访问的,遵循 JS 变量命名规范。在模板中使用 kebab-case,在 JS 中使用 camelCase。

  2. 为什么是 update:modelValue 事件名? : 分隔符的设计使得 Vue 可以解析出"更新"(update)和"目标属性"(modelValue)两部分。这个语义对于支持多 v-model 非常关键。

  3. 为什么不保留 input 事件? 原生 <input> 的 input 事件和 Vue 组件的 v-model 语义不匹配。原生 input 事件在每次输入时触发,但 v-model 的更新时机可能是 debounce 后的、也可能是自定义的(lazy/number/trim 修饰符)。

多 v-model 的实现:

html
<UserForm
  v-model:name="name"    <!-- prop: name, event: update:name -->
  v-model:email="email"  <!-- prop: email, event: update:email -->
/>
javascript
// 子组件中
export default {
  props: { name: String, email: String },
  emits: ["update:name", "update:email"],
  methods: {
    updateName(val) { this.$emit("update:name", val); },
    updateEmail(val) { this.$emit("update:email", val); }
  }
};

每个 v-model:xxx 绑定都使用 xxx 作为 prop 名,update:xxx 作为事件名。这是一套通用的绑定协议,不再硬编码 value 和 input。


九、移除的特性:为什么不再支持? ​

9.1 为什么移除 Event Bus($on / $off / $once)? ​

Vue 2 中 Event Bus 的"标准"写法:

javascript
// event-bus.js
import Vue from "vue";
export const EventBus = new Vue();

// 组件 A
import { EventBus } from "@/event-bus";
EventBus.$emit("user-logged-in", userData);

// 组件 B
import { EventBus } from "@/event-bus";
EventBus.$on("user-logged-in", (data) => { /* ... */ });

移除原因一:$on/$off/$once 是 Vue 实例方法,但在 Vue 3 中没有"全局 Vue 实例"

Vue 3 用 createApp 替代了 new Vue()。在 Vue 2 中,你可以 new Vue() 创建一个"空白"的 Vue 实例,利用它的 $emit/$on 机制作为事件总线。Vue 3 中不再有全局的构造函数模式,$on/$off 方法也就不再有存在的场景。

移除原因二:Event Bus 在大型项目中的问题

  1. 没有类型安全:事件名是字符串,无法在编译时检查拼写错误。
  2. 内存泄漏:组件销毁时如果没有调用 $off,回调函数不会被 GC 回收。
  3. 难以调试:事件的触发和监听是跨组件的,当出现一个 bug 时,你需要在全局搜索事件字符串来定位问题。
  4. 紧耦合:触发方和监听方通过事件名耦合,当需求变更改事件名时,所有相关组件都要修改。

Vue 3 的推荐替代方案:

javascript
// 方案一:Provide/Inject(推荐)
// 祖先组件
provide("eventBus", {
  userLoggedIn: (data) => { /* ... */ }
});

// 方案二:mitt(第三方库,只有 200 字节)
import mitt from "mitt";
const emitter = mitt();
emitter.on("event", handler);
emitter.emit("event", data);

// 方案三:Pinia 全局状态
const store = useGlobalStore();
store.userLoggedIn(data);

9.2 为什么移除 Filters? ​

Vue 2 中的 filters:

html
<template>
  <div>{{ price | currency("¥") }}</div>
  <div>{{ date | formatDate("YYYY-MM-DD") }}</div>
</template>

<script>
export default {
  filters: {
    currency(value, symbol = "¥") {
      return `${symbol}${Number(value).toFixed(2)}`;
    },
    formatDate(value, format) {
      return dayjs(value).format(format);
    }
  }
};
</script>

移除原因:

  1. 不兼容 Composition API:filters 定义在 Options API 的 filters 选项中,在 Composition API 的 setup() 中没有对应位置。用户可以定义全局函数在模板中使用,但那时 filters 就不是"filters"了。

  2. 和管道符 | 的冲突:filters 使用 | 符号,这在 JavaScript 中是位或运算符。在模板编译时,Vue 2 需要特殊处理 | 符号来区分 filter 调用和位运算。这种语法的"重载"增加了编译器的复杂性。

  3. 性能问题:filter 函数每次渲染时都要重新计算,没有缓存机制。这与 computed 形成了对比。

  4. 可以通过普通函数和 computed 完全替代:

vue
<script setup>
// Vue 3 替代方案:全局函数或 computed
const currency = (value) => `¥${Number(value).toFixed(2)}`;
const price = ref(99.99);

// 或使用 computed
const formattedPrice = computed(() => currency(price.value));
</script>

<template>
  <div>{{ currency(price) }}</div>
  <div>{{ formattedPrice }}</div>
</template>

9.3 为什么移除 .sync 修饰符? ​

Vue 2 中:

html
<ChildComponent :title.sync="title" />
<!-- 等价于 -->
<ChildComponent :title="title" @update:title="title = $event" />

移除原因:Vue 3 的 v-model 扩展了功能,支持多个 v-model 绑定,覆盖了 .sync 的所有使用场景。.sync 和 v-model 的功能是重叠的,保留两套功能相似但语法不同的机制没有意义。

Vue 3 中直接用 v-model 替代:

html
<!-- Vue 2 中用 .sync -->
<Child :title.sync="title" :visible.sync="visible" />

<!-- Vue 3 中用多 v-model -->
<Child v-model:title="title" v-model:visible="visible" />

.sync 的语法(:title.sync)本身也有问题:它看起来像是一个属性修饰符,但实际上它创建的是一个完整的双向绑定。v-model:title 的语义更加清晰——"这是一个叫 title 的双向绑定"。

9.4 为什么移除 keyCode 事件修饰符? ​

Vue 2 中:

html
<input @keyup.13="submit">    <!-- 13 = Enter 键 -->
<input @keyup.enter="submit">
<input @keyup.113="zoomIn">   <!-- F2 键,113 是什么? -->

移除原因:

  1. keyCode 已被 W3C 弃用:KeyboardEvent.keyCode 属性在 Web 标准中已被标记为 deprecated(弃用)。现代浏览器推荐使用 KeyboardEvent.key(返回按键名)替代。

  2. 可读性差:@keyup.13 可能需要注释来说明 13 是哪个键。而 @keyup.enter 一目了然。

  3. 跨浏览器不一致:keyCode 在不同操作系统和键盘布局下不一致。例如,某些键盘上 ; 键的 keyCode 在 Windows 和 Mac 上不同。

Vue 3 中只支持按键名字符串:

html
<input @keyup.enter="submit">
<input @keyup.escape="cancel">
<input @keyup.ctrl.enter="submitWithCtrl">

十、对其他 Vue 3 变化的深度分析 ​

10.1 为什么支持 Fragment(多根节点)? ​

Vue 2 要求组件模板必须有单个根元素,这个限制本质上来自 Vue 2 的虚拟 DOM 实现——每个组件实例的 $el(挂载的 DOM 元素)必须是单个节点。

Vue 3 引入了 Fragment 虚拟节点类型,可以表示一个"虚拟片段",它包含多个子节点但不生成自己的 DOM 元素。这样组件可以返回多个根节点,$el 的概念也随之变化。

javascript
// Vue 3 的 Fragment 实现
// 不创建额外的 DOM 元素,直接渲染子节点列表

// 渲染函数返回数组
render() {
  return [
    h("header", null, "标题"),
    h("main", null, "内容"),
    h("footer", null, "底部")
  ];
}

实际收益:

  • 移除无意义的包裹层(减少 DOM 层级)
  • 更好的语义化 HTML(不需要为了 Vue 加一个多余 div)
  • 改善 CSS 布局(Flex/Grid 子元素不再需要被额外容器包裹)

10.2 为什么支持 Teleport? ​

在 Vue 2 中,如果你想渲染一个全局模态框或通知,通常会遇到 CSS 层级和溢出问题:

html
<template>
  <div class="parent" style="overflow: hidden; position: relative;">
    <div class="modal" style="z-index: 9999;">
      模态框的内容
    </div>
  </div>
</template>

问题:overflow: hidden 的父元素会裁剪模态框,position: relative 影响 z-index 的层级上下文。在 Vue 2 中,你只能手动使用 document.body.appendChild 或使用第三方库解决这个问题。

Teleport 直接解决了这个框架层面的 DOM 位置需求:

vue
<template>
  <Teleport to="body">
    <div class="modal">
      <!-- 这个元素在 DOM 中会渲染到 <body> 下 -->
    </div>
  </Teleport>
</template>

这不是一个新功能,而是一个被很多开发者反复实现的共同需求的标准化。

10.3 新的生命周期命名 ​

Vue 2 中销毁阶段的生命周期是 beforeDestroy 和 destroyed。Vue 3 改为 beforeUnmount 和 unmounted。

为什么改名?

  1. 更准确的语义:"销毁"(destroy)暗示实例被永久删除,而"卸载"(unmount)更准确地描述了操作——组件从 DOM 中移除,但实例可能被缓存(如 KeepAlive)。

  2. 和 mount 对应:Vue 有 mount,那么对应的反操作应该是 unmount,而不是 destroy。mounted / unmounted、beforeMount / beforeUnmount 形成清晰的对偶关系。

  3. 和自定义元素规范一致:Web Components 规范中使用的是 connectedCallback / disconnectedCallback,"卸载"的语义更接近 "disconnected"。


十一、更新调度:为什么 Vue 的 DOM 更新是异步的? ​

11.1 同步更新会带来什么问题 ​

假设 Vue 每次数据变化都立即同步更新 DOM:

javascript
state.count++;   // 同步 patch 一次
state.count++;   // 同步 patch 一次
state.count++;   // 同步 patch 一次

三次修改会触发三次完整渲染,而用户只关心最终结果。更糟的是,如果一段逻辑里同时改了 10 个不同组件的状态,就会产生 10 次渲染,其中大部分是浪费。Vue 的答案是:把同一个 tick 内的所有状态变更收集起来,合并成一次 DOM 更新。这就是「异步更新」的由来。

11.2 更新队列 + 微任务刷新的实现 ​

Vue 3 的调度器维护一个更新队列,把去重后的任务安排在微任务里统一执行:

javascript
const queue = [];                 // 待执行的组件更新任务
let isFlushPending = false;
let currentFlushPromise = null;
const resolvedPromise = Promise.resolve();

function queueJob(job) {
  if (!queue.includes(job)) queue.push(job);  // 去重:同一任务只入队一次
  queueFlush();
}

function queueFlush() {
  if (isFlushPending) return;
  isFlushPending = true;
  currentFlushPromise = resolvedPromise.then(flushJobs); // 微任务
}

function flushJobs() {
  isFlushPending = false;
  queue.forEach(job => job());    // 真正 patch DOM
  queue.length = 0;
}

执行时序:

text
count.value++            // 同步:trigger → 组件更新 job 入队(去重)
count.value++            // 同步:同一 job 已存在,不再重复入队
count.value++            // 同步:同上
// ... 当前同步代码全部执行完
// 微任务 flushJobs 执行:只 patch 一次 DOM

11.3 nextTick 的定位 ​

nextTick 就是「挂到这个刷新 promise 上」的工具:

javascript
function nextTick(fn) {
  const p = currentFlushPromise || resolvedPromise;
  return fn ? p.then(fn) : p;
}
javascript
count.value++;
await nextTick();
// 到这里 DOM 一定已经更新

因为 nextTick 复用的就是本次刷新的 currentFlushPromise,所以它保证回调在「本次合并更新之后」执行,而不是在某个随机时间点。

⚠️ 为什么是微任务而不是 setTimeout?

  • 微任务在当前宏任务结束前立刻执行,延迟最小。
  • 同步代码里的所有修改会被合并进同一次微任务刷新,不会出现「改一次刷一次」。
  • 若换成宏任务,两次同步修改之间可能插入一次浏览器渲染,产生中间态闪烁。

11.4 watch 的执行时机:flush 选项 ​

watch 的回调默认在组件更新之前(flush: 'pre')执行,这是有意设计——这样 watch 里读到的数据已是最新,但 DOM 还没变:

javascript
watch(source, cb)                     // 默认 pre:渲染前执行
watch(source, cb, { flush: 'post' })  // post:DOM 更新后执行(类似 nextTick)
watch(source, cb, { flush: 'sync' })  // sync:每次变化同步执行(慎用)
  • flush: 'pre':适合在渲染前根据新值准备派生数据。
  • flush: 'post':适合需要操作「更新后的 DOM」的场景,等价于在回调里包了一层 nextTick。
  • flush: 'sync':每次赋值立刻执行,绕过了合并机制,频繁触发会退化成「改一次跑一次」,仅在确实需要同步时序时用。

💡 理解了「队列 + 微任务 + nextTick + flush」这套调度模型,就能解释 Vue 里最经典的疑问:「为什么我改了数据,console.log 读 DOM 还是旧值?」——因为数据变了,但 DOM 更新被推迟到了微任务;要么 await nextTick(),要么在 onUpdated / flush: 'post' 里读。

基于 VitePress 构建的个人学习文档知识库