外观
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 并非没有代价:
兼容性:Proxy 无法被 polyfill。这意味着 Internet Explorer 完全不支持。Vue 3 直接放弃了 IE 支持——这是一个经过深思熟虑的决定:
- 2020 年 IE 全球市场份额已低于 2%
- 微软已宣布 2022 年停止支持 IE
- 保留 IE 支持会拖累整个框架的发展
性能:Proxy 本身比普通属性访问慢,但在大多数实际场景中这个差异可以忽略不计。而且在 Vue 3 的整体优化(虚拟 DOM 重写、编译优化)下,整体性能反而显著提升。
无法直接访问原始对象:经过 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()) // 重新渲染一个关键的设计决策:依赖收集发生在「读取」阶段,而不是「声明」阶段。 这带来三个重要性质:
精确到属性:只有真正被 effect 读过的数据才建立依赖,没读到的数据改了也不会触发这个 effect。这就是「改了哪个属性,只重跑依赖它的 effect」的由来。
依赖是动态的:这次渲染读了
a,下次渲染走另一条分支读了b,依赖会自动跟着变。effect 每次 run 都会重新建立依赖(先清空旧的再收集新的),所以不需要手动管理依赖生命周期。读取时机决定依赖:同一个 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 2 | Vue 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 };
}
};设计动机逐条分析:
为什么不用 class? React 社区已经证明了 class 在热更新、this 绑定、mixin 等方面的问题。Composition API 选择了纯函数的方式。
为什么叫 Composition(组合)? 核心思想是"组合优于继承"。不是通过继承层次来复用代码,而是通过组合小型函数来构建复杂逻辑。
为什么引入 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 带来的三个次生设计:
自动解包(unwrap):模板里写
{{ count }}而不是{{ count.value }},是因为编译器对 setup 返回的 ref 做了自动解包;reactive({ n: ref(1) })里访问.n也会自动解包。这让 ref 在「使用侧」几乎无感,.value只在「赋值侧」出现。reactive 会解包嵌套 ref:reactive 对象里嵌套的 ref 会被自动解包,所以你无法在 reactive 里「整体替换」一个 ref。这也是为什么需要「整体替换」的数据推荐用 ref 而非 reactive 的原因之一。
为什么不让 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 慢的本质原因:
JavaScript 实现的打包器:Webpack 自身的代码是用 JavaScript 写的,在大型项目中模块解析和代码转换本身就很慢。
项目越大越慢:因为所有模块在启动时就要被打包,这是 O(n) 的操作,n 是模块数量。
热更新也需要模块编译:每次修改文件,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 的快速开发体验并非没有代价:
生产构建用 Rollup 而不是 esbuild:esbuild 的代码分割和 CSS 处理不如 Rollup 成熟,所以 Vite 的开发和生产使用两套工具链。
不支持 CommonJS:如果某个依赖只提供 CJS 格式,Vite 需要在预构建时将其转为 ESM。大多数现代库已经支持 ESM,但仍有少数旧库需要处理。
兼容性要求:需要浏览器支持
<script type="module">,不支持 IE。
5.4 为什么 Vue CLI 被 create-vue 取代?
Vue CLI 是基于 Webpack 的重量级工具,提供了配置(vue.config.js)、插件系统(@vue/cli-plugin-xxx)、UI 界面等。但问题在于:
复杂的抽象层:Vue CLI 在 Webpack 之上又封装了一层配置,当需要自定义 Webpack 配置时,开发者需要学习 Vue CLI 的插件体系和 Webpack 的双重知识。
升级困难:依赖 @vue/cli-service 的版本,升级 Vue 时需要同时升级 CLI,且插件之间有复杂的版本兼容关系。
生态耦合: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>为什么问题很大?
slot 和 slot-scope 是属性而非指令:它们是 HTML 属性(attribute)而非 Vue 指令(directive),这导致它们在语法上与 Vue 的其他功能(v-bind、v-on、v-for)不一致。
作用域插槽语法不统一:
slot-scope可以作用于普通元素和<template>上,但slot在 Vue 2.6 之前只能作用于元素上,不能单独在<template>上使用。限制想象力:当插槽语法只是属性时,开发者不太容易想到它可以动态变化。
阶段二(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 为什么是问题?
和原生 HTML 冲突:原生
<input>的 value 是字符串,但自定义组件可能期望 value 是数字、对象、数组。当你在一个组件上使用v-model传一个对象时,value这个 prop 名已经不能表达它的含义了。和组件内部属性冲突:组件内部可能恰好需要另一个
valueprop 用于不同目的,但value已经被 v-model 占用了。限制每个组件只能有一个 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" />设计决策分析:
为什么是 modelValue 而不是 model-value? Vue 的 prop 命名规范是 camelCase,因为 props 是通过 JavaScript 访问的,遵循 JS 变量命名规范。在模板中使用 kebab-case,在 JS 中使用 camelCase。
为什么是 update:modelValue 事件名?
:分隔符的设计使得 Vue 可以解析出"更新"(update)和"目标属性"(modelValue)两部分。这个语义对于支持多 v-model 非常关键。为什么不保留 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 在大型项目中的问题
- 没有类型安全:事件名是字符串,无法在编译时检查拼写错误。
- 内存泄漏:组件销毁时如果没有调用
$off,回调函数不会被 GC 回收。 - 难以调试:事件的触发和监听是跨组件的,当出现一个 bug 时,你需要在全局搜索事件字符串来定位问题。
- 紧耦合:触发方和监听方通过事件名耦合,当需求变更改事件名时,所有相关组件都要修改。
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>移除原因:
不兼容 Composition API:filters 定义在 Options API 的
filters选项中,在 Composition API 的setup()中没有对应位置。用户可以定义全局函数在模板中使用,但那时 filters 就不是"filters"了。和管道符
|的冲突:filters 使用|符号,这在 JavaScript 中是位或运算符。在模板编译时,Vue 2 需要特殊处理|符号来区分 filter 调用和位运算。这种语法的"重载"增加了编译器的复杂性。性能问题:filter 函数每次渲染时都要重新计算,没有缓存机制。这与 computed 形成了对比。
可以通过普通函数和 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 是什么? -->移除原因:
keyCode 已被 W3C 弃用:
KeyboardEvent.keyCode属性在 Web 标准中已被标记为 deprecated(弃用)。现代浏览器推荐使用KeyboardEvent.key(返回按键名)替代。可读性差:
@keyup.13可能需要注释来说明 13 是哪个键。而@keyup.enter一目了然。跨浏览器不一致: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。
为什么改名?
更准确的语义:"销毁"(destroy)暗示实例被永久删除,而"卸载"(unmount)更准确地描述了操作——组件从 DOM 中移除,但实例可能被缓存(如
KeepAlive)。和
mount对应:Vue 有mount,那么对应的反操作应该是unmount,而不是destroy。mounted/unmounted、beforeMount/beforeUnmount形成清晰的对偶关系。和自定义元素规范一致: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 一次 DOM11.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' 里读。