Vue 的三次重写:从 Angular 指令到 Proxy 响应式的演进史
第1章:Vue 的三次重写——从 Angular 指令到 Proxy 响应式的演进史
2013 年 7 月,Evan You 用了一个周末写出了 Vue 的第一个版本,最初叫 Seed.js,整个代码库不超过 1000 行。十年后,Vue 3 的源码超过 40 万行,服务于全球超过 300 万开发者。
本章核心问题:Vue 为什么要进行三次重写?每次重写解决了什么根本性矛盾?
读完本章你将理解:
- 每个 Vue 大版本背后的具体技术动机,不是营销说辞
Object.defineProperty的三个无法绕过的限制,以及 Proxy 如何解决- Vue 与 React 演进路线的异同,以及这种异同背后的设计哲学差异
Level 1 · 你需要知道的(1-3年经验)
1.1 一切始于一个 Angular 的"太重了"
2013 年,Evan You 在 Google Creative Lab 做前端开发,日常工作是使用 AngularJS 构建内部工具。AngularJS 的双向绑定和依赖注入确实强大,但对于简单的 UI 原型来说,整套框架太笨重了——学习曲线陡峭,启动配置繁琐,运行时体积庞大。
Evan 想要的是 Angular 的数据绑定功能,但剥掉其他所有东西。他用了一个周末,写出了第一个版本,最初叫 Seed.js,后来改名为 Vue.js。核心思路非常简单:一个轻量的数据-视图绑定库,不是框架,不需要学习"Angular 方式"才能用。
这是 Vue 的第一次定位:Angular 的数据绑定,去掉所有框架开销。
1.2 Vue 1.x:细粒度依赖追踪的初次尝试
Vue 1.x 发布于 2015 年,它建立在 Object.defineProperty 之上。核心思路是:把每个数据属性转换成 getter/setter,当属性被读取时记录"谁依赖了我",当属性被修改时通知所有依赖者更新。
这套机制叫做依赖追踪(dependency tracking),它让 Vue 知道精确更新哪个 DOM 节点,而不是重新渲染整棵树。
// Vue 1.x 内部的简化版响应式机制
function defineReactive(obj, key, val) {
const dep = new Dep(); // 收集依赖的容器
Object.defineProperty(obj, key, {
get() {
if (Dep.target) { // 有组件正在渲染
dep.depend(); // 记录这个组件依赖了这个属性
}
return val;
},
set(newVal) {
val = newVal;
dep.notify(); // 通知所有依赖者更新
}
});
}
Vue 1.x 的响应式是极细粒度的:每一个数据属性对应一组 Watcher,数据改变触发精确更新,没有虚拟 DOM,直接操作真实 DOM。在小型应用里,这套机制效率极高。
但它有一个根本性问题:Object.defineProperty 只能拦截已经存在的属性。如果你在 Vue 实例创建之后给 data 对象新增属性,Vue 根本不知道这件事发生了。
// Vue 1.x 中这样做不会触发更新
this.someObject.newProp = 'hello'; // 响应式系统看不见这个操作
Vue 1.x 通过暴露 $set 方法来绕开这个限制,但这是一个外科手术式的补丁,不是根本解决。
1.3 Vue 2.x:引入虚拟 DOM,但保留了同样的限制
Vue 2.0 发布于 2016 年。这次重写的最大变化是引入了虚拟 DOM(Virtual DOM),灵感来自 Snabbdom 库(作者 Simon Friis Vindum 的实现只有约 200 行)。
为什么需要虚拟 DOM?
Vue 1.x 的精细粒度依赖追踪在组件层面运作良好,但在大型应用中,当一个状态被数百个 Watcher 订阅时,管理这些 Watcher 本身的开销变得不可忽视。Vue 2 的解决方案是将粒度从"每个属性-每个 DOM 节点"提升到"每个属性-每个组件":数据变化时,重新运行组件的渲染函数,生成新的虚拟 DOM 树,再通过 diff 算法找出最小更新集合。
// Vue 2.x 的组件更新流程(简化)
// 1. 数据变化触发 Watcher
// 2. Watcher 调用组件的 _update 方法
// 3. _update 内部调用 render() 生成新 VNode
// 4. patch(oldVNode, newVNode) 更新真实 DOM
这次重写还引入了 render function:你可以不使用模板,而是直接写 JavaScript 函数来描述视图结构。这让 Vue 的能力边界大幅扩展,JSX 支持、高阶组件、更复杂的渲染逻辑都变得可能。
但 Vue 2 继承了 Vue 1 的同样限制,因为它仍然基于 Object.defineProperty:
限制一:无法检测属性新增
// 这不会触发视图更新
this.user.age = 18; // user 已存在,age 是新增的属性
// 必须用:
this.$set(this.user, 'age', 18);
限制二:无法检测属性删除
// 这不会触发视图更新
delete this.user.name;
// 必须用:
this.$delete(this.user, 'name');
限制三:无法检测数组索引赋值
// 这不会触发视图更新
this.list[0] = newItem;
// 必须用:
this.$set(this.list, 0, newItem);
// 或者:
this.list.splice(0, 1, newItem);
Vue 2 对数组的处理是一个有趣的工程折衷:它通过劫持数组的 7 个变异方法(push、pop、shift、unshift、splice、sort、reverse)来实现响应式,但直接的索引赋值和 length 修改仍然无法追踪。
这三个限制困扰了 Vue 2 开发者整整四年。文档里有专门一节列出"注意事项",Vue 相关的 Stack Overflow 问题有相当大比例都与这三个限制有关。
1.4 Vue 3.x:从头重写的五个动机
2018 年末,Evan You 在 Medium 发布了一篇文章,宣布 Vue 3 将从头重写。他给出了五个动机:
动机一:TypeScript 支持
Vue 2 是用 Flow(Facebook 的类型检查工具)写的,但 Flow 的类型推断能力远不及 TypeScript。更重要的是,Vue 2 的 this 上下文让 TypeScript 的类型推断几乎无法正常工作——你拿不到组件数据和方法的类型提示。
Vue 3 完全用 TypeScript 重写,API 设计时把类型推断作为第一优先级。
动机二:Composition API
当组件逻辑复杂时,Options API 会把同一个功能的代码分散在 data、methods、computed、watch 四个地方。Composition API 允许按功能来组织代码,而不是按选项类型。(详见第2章)
动机三:Proxy 响应式
Proxy 是 ES2015 原生支持的对象代理机制,它能拦截对象的所有基本操作,包括属性新增、属性删除、数组索引赋值。Object.defineProperty 的三个限制全部消失。
动机四:Tree-shakable 架构
Vue 2 的所有 API 都挂在 Vue 全局对象上(Vue.component、Vue.filter、Vue.mixin),无法被打包工具摇掉。Vue 3 改为命名导出,未使用的 API 不会打包进最终产物。
动机五:性能提升 基于编译时分析的 PatchFlag 系统:编译器在生成虚拟 DOM 时标记每个节点的动态部分,运行时 diff 只检查标记了动态的部分,跳过静态内容。
Vue 团队在 Chrome 的 js-framework-benchmark 上测量了 Vue 3 vs Vue 2 的性能差异:
- 初始化速度:提升 55%
- 更新速度:提升 133%
- 内存占用:减少 54%
1.5 Vue vs React:两条平行的演进轨迹
Vue 和 React 在差不多同一时期(2013年前后)诞生,但演进轨迹截然不同:
| 维度 | React 演进 | Vue 演进 |
|---|---|---|
| 状态管理 | Class 组件 state → Hooks useState | Options API data → Composition API ref/reactive |
| 逻辑复用 | HOC → Render Props → Hooks | Mixin → Composition API |
| 类型支持 | 逐步完善 → Hooks 后大幅改善 | Flow → TypeScript 完全重写 |
| 渲染优化 | 手动 memo/useMemo/useCallback | 编译时自动追踪依赖 |
| 哲学 | 函数式优先,UI = f(state) | 响应式优先,自动追踪变化 |
React Hooks(2019)和 Vue Composition API(2020)几乎同时解决了相同的问题:逻辑复用和代码组织。但解决方式根本不同:React Hooks 在每次渲染时重新执行,Vue 的 setup() 只执行一次。这个差异导致 React 需要 useCallback、useMemo、依赖数组等概念,Vue 完全不需要。
Level 2 · 它是怎么运行的(3-5年经验)
2.1 Object.defineProperty 的工作原理与根本限制
要理解 Vue 3 为什么需要 Proxy,需要先深入理解 Object.defineProperty 在做什么,以及它为什么无法解决那三个限制。
Object.defineProperty 是 ES5 引入的,用于在一个对象上定义新属性,或修改现有属性的特性。它操作的是属性描述符(Property Descriptor):
Object.defineProperty(obj, 'count', {
enumerable: true, // for...in 和 Object.keys() 能枚举到
configurable: true, // 可以被 delete,可以再次 defineProperty
writable: true, // 可以用赋值运算符修改
value: 0 // 属性的值
});
// 或者用 accessor descriptor(不能同时有 value/writable):
Object.defineProperty(obj, 'count', {
enumerable: true,
configurable: true,
get() { return this._count; },
set(val) { this._count = val; }
});
Vue 的响应式系统用的是 accessor descriptor(getter/setter)。当你在 Vue 2 中定义 data() { return { count: 0 } },Vue 内部遍历这个对象的所有属性,对每个属性调用 defineProperty,把它变成 getter/setter 对。
为什么无法检测属性新增?
defineProperty 只能操作已经存在于对象上的属性。当你写 this.user.newProp = 1 时,JavaScript 引擎执行的是一个普通的属性赋值操作——它不会触发任何已有的 getter/setter,因为 newProp 这个 accessor descriptor 根本不存在。Vue 没有机会拦截这个操作。
要拦截属性新增,你需要的是能拦截"对象上任何属性的赋值"的机制,而不是某个具体属性的赋值——这正是 Proxy 的 set trap 能做的事。
为什么无法检测属性删除?
delete obj.prop 是一个独立的操作符,不会触发 getter/setter。即使属性有 setter,delete 操作也绕过它直接从对象上移除属性描述符。没有任何方式在 defineProperty 层面拦截 delete。
为什么无法检测数组索引赋值?
这个最微妙。理论上,你可以对数组的每个索引调用 defineProperty:
const arr = [1, 2, 3];
Object.defineProperty(arr, '0', {
get() { return this._0; },
set(val) { this._0 = val; dep.notify(); }
});
Vue 2 实际上就是这样做的!对于已知长度的数组,Vue 2 会对每个索引调用 defineProperty。
问题在于:当你写 arr[100] = 'new' 时,100 这个索引是新增的,同样无法拦截。更严重的是,arr.length = 0 这样的操作会批量删除元素,也无法通过 getter/setter 拦截。
Vue 2 的解决方案是劫持数组的变异方法,但这只覆盖了 push/pop/shift/unshift/splice/sort/reverse 这 7 个方法,索引赋值和 length 修改依然是盲区。
2.2 Proxy 的工作原理
Proxy 是 ES2015 引入的,它允许创建一个对象的"代理",可以拦截并重新定义该对象的基本操作。
const handler = {
get(target, key, receiver) {
console.log(`读取属性: ${String(key)}`);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
console.log(`设置属性: ${String(key)} = ${value}`);
return Reflect.set(target, key, value, receiver);
}
};
const obj = {};
const proxy = new Proxy(obj, handler);
proxy.newProp = 1; // 触发 set trap: "设置属性: newProp = 1"
delete proxy.newProp; // 触发 deleteProperty trap(如果定义了的话)
Proxy 的 set trap 会在任何属性赋值时触发,无论这个属性是否已经存在。这直接解决了"无法检测属性新增"的问题。
Object.defineProperty 拦截层:
obj observer
┌──────────────────┐ ┌────────────────────────────────────────┐
│ .name [getter] │ │ defineProperty('name') 时注册 │
│ [setter] │◄──│ 只能拦截已知属性 │
│ .age [getter] │ │ │
│ [setter] │ │ 新增属性 obj.xxx = 1 → 无法拦截 ✗ │
│ .xxx ??? │ │ 删除属性 delete obj.yyy → 无法拦截 ✗ │
└──────────────────┘ └────────────────────────────────────────┘
Proxy 拦截层:
target handler
┌──────────────────┐ ┌────────────────────────────────────────┐
│ .name │ │ get trap:任何属性读取都经过这里 │
│ .age │ │ set trap:任何属性写入都经过这里 │
│ .xxx(新增) │◄─│ deleteProperty trap:delete 操作 │
│ .yyy │ │ has trap:in 操作符 │
└──────────────────┘ │ ownKeys trap:Object.keys() 等 │
└────────────────────────────────────────┘
2.3 Vue 3 响应式系统的架构变化
Vue 3 的响应式系统被完全提取成一个独立的包:@vue/reactivity。这个包可以独立使用,不依赖 Vue 的其他部分。
Vue 3 包结构(关键部分):
@vue/reactivity @vue/runtime-core @vue/runtime-dom
┌─────────────────┐ ┌──────────────────┐ ┌───────────────────┐
│ reactive() │ │ defineComponent() │ │ createApp() │
│ ref() │─────►│ computed() │──────►│ render (DOM) │
│ effect() │ │ watch/watchEffect │ │ patch (DOM diff) │
│ track/trigger │ │ 组件生命周期 │ │ nodeOps │
│ Proxy handlers │ │ VNode 系统 │ └───────────────────┘
└─────────────────┘ └──────────────────┘
Vue 3 的核心依赖追踪数据结构是一个三层嵌套的 Map:
WeakMap<target, Map<key, Set<ReactiveEffect>>>
targetMap (WeakMap)
│
├── target1 (depsMap: Map)
│ ├── "key1" → dep (Set<ReactiveEffect>)
│ │ ├── effect1
│ │ └── effect2
│ └── "key2" → dep (Set<ReactiveEffect>)
│ └── effect3
│
└── target2 (depsMap: Map)
└── "keyA" → dep (Set<ReactiveEffect>)
└── effect4
使用 WeakMap 的原因:当 target 对象没有其他引用时,GC 可以自动回收,不需要手动清理依赖图。
2.4 Vue 3 Composition API 的实际代码组织优势
来看一个真实案例:一个包含搜索、分页、排序功能的列表页面。
Options API 写法:逻辑分散在各个选项中:
// Vue 2 Options API
export default {
data() {
return {
// 搜索相关
searchQuery: '',
searchResults: [],
isSearching: false,
// 分页相关
currentPage: 1,
pageSize: 20,
total: 0,
// 排序相关
sortBy: 'name',
sortOrder: 'asc'
}
},
computed: {
// 搜索相关
hasResults() { return this.searchResults.length > 0 },
// 分页相关
totalPages() { return Math.ceil(this.total / this.pageSize) },
// 排序相关
sortIcon() { return this.sortOrder === 'asc' ? '↑' : '↓' }
},
methods: {
// 搜索相关
async search() { /* ... */ },
clearSearch() { /* ... */ },
// 分页相关
goToPage(page) { /* ... */ },
changePageSize(size) { /* ... */ },
// 排序相关
sort(field) { /* ... */ }
},
watch: {
searchQuery(val) { this.search(); }
}
}
Composition API 写法:每个功能封装成独立的 composable:
// useSearch.js
export function useSearch() {
const searchQuery = ref('');
const searchResults = ref([]);
const isSearching = ref(false);
const hasResults = computed(() => searchResults.value.length > 0);
async function search() { /* 完整逻辑 */ }
function clearSearch() { /* 完整逻辑 */ }
watch(searchQuery, search);
return { searchQuery, searchResults, isSearching, hasResults, search, clearSearch };
}
// usePagination.js
export function usePagination() { /* 完整分页逻辑 */ }
// useSorting.js
export function useSorting() { /* 完整排序逻辑 */ }
// 组件内
export default {
setup() {
const search = useSearch();
const pagination = usePagination();
const sorting = useSorting();
return { ...search, ...pagination, ...sorting };
}
}
Composition API 的优势不在于代码量减少,而在于同一功能的代码在物理上靠近,便于理解和迁移。
Level 3 · 设计文档与源码(资深开发者)
3.1 Vue 3 重写过程中的关键设计决策
Vue 3 的重写过程被记录在 GitHub 的多个 RFC(Request for Comments)中。最关键的几个:
RFC-0004(2019.01):Function-based Component API 这是 Composition API 的最初提案。引发了 GitHub 有史以来讨论最激烈的 RFC 之一(超过 1000 条评论)。最终保留了 Options API,Composition API 作为可选项并存。
RFC-0006(2019.06):Slots Unification Vue 3 统一了普通 slot 和 scoped slot 的语法,底层统一为函数。这个决策让 TypeScript 类型推断更容易实现。
RFC-0008(2019.10):Composition API(正式版)
在社区讨论六个月后,Composition API 以独立 API 的形式被正式接受,setup() 函数成为入口点。
3.2 Proxy handler 的源码实现
Vue 3 响应式系统的核心在 packages/reactivity/src/baseHandlers.ts:
// packages/reactivity/src/baseHandlers.ts
import { track, trigger } from './effect'
import { TrackOpTypes, TriggerOpTypes } from './operations'
import { reactive, readonly, isReactive } from './reactive'
// 普通可变对象的 handler
export const mutableHandlers: ProxyHandler<object> = {
get, // 读取属性时触发 track
set, // 写入属性时触发 trigger
deleteProperty, // delete 操作时触发 trigger
has, // in 操作符时触发 track
ownKeys // Object.keys() 等时触发 track
}
function createGetter(isReadonly = false, shallow = false) {
return function get(target: Target, key: string | symbol, receiver: object) {
// 特殊 key 处理
if (key === ReactiveFlags.IS_REACTIVE) {
return !isReadonly
} else if (key === ReactiveFlags.IS_READONLY) {
return isReadonly
} else if (
key === ReactiveFlags.RAW &&
receiver === (isReadonly ? readonly : reactive)(target)
) {
return target // toRaw() 实现原理
}
const targetIsArray = isArray(target)
if (!isReadonly && targetIsArray && hasOwn(arrayInstrumentations, key)) {
// 数组的特殊方法处理(includes, indexOf, lastIndexOf)
return Reflect.get(arrayInstrumentations, key, receiver)
}
const res = Reflect.get(target, key, receiver)
if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
return res // 内置 Symbol 不追踪
}
if (!isReadonly) {
track(target, TrackOpTypes.GET, key) // 关键:触发依赖收集
}
if (shallow) {
return res // shallowReactive 不递归代理
}
if (isRef(res)) {
// 嵌套 ref 自动解包(但数组中的 ref 不自动解包)
const shouldUnwrap = !targetIsArray || !isIntegerKey(key)
return shouldUnwrap ? res.value : res
}
if (isObject(res)) {
// 懒代理:访问时才对嵌套对象创建 Proxy
return isReadonly ? readonly(res) : reactive(res)
}
return res
}
}
注意这里的懒代理(lazy proxy)策略:嵌套对象不是在 reactive() 调用时就全部代理,而是在访问到时才创建代理。这避免了对深层嵌套对象的提前遍历,提升了初始化性能。
// set handler 的实现
function createSetter(shallow = false) {
return function set(
target: object,
key: string | symbol,
value: unknown,
receiver: object
): boolean {
let oldValue = (target as any)[key]
if (!shallow) {
// 处理嵌套的 ref
if (!isShallow(value) && !isReadonly(value)) {
oldValue = toRaw(oldValue)
value = toRaw(value)
}
// 如果旧值是 ref,新值不是 ref,自动更新 ref.value
if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
oldValue.value = value
return true
}
}
const hadKey =
isArray(target) && isIntegerKey(key)
? Number(key) < target.length
: hasOwn(target, key)
const result = Reflect.set(target, key, value, receiver)
// 只在 target 是 receiver 的原型链上最近的代理时触发
// 这避免了原型链上的重复触发
if (target === toRaw(receiver)) {
if (!hadKey) {
trigger(target, TriggerOpTypes.ADD, key, value) // 新增属性
} else if (hasChanged(value, oldValue)) {
trigger(target, TriggerOpTypes.SET, key, value, oldValue) // 修改属性
}
}
return result
}
}
这段代码中有一个精妙之处:通过 target === toRaw(receiver) 判断,防止当对象被继承时,父对象的 set 被触发两次。
3.3 Vue 3 的编译时优化:PatchFlag 系统
Vue 3 的编译器在分析模板时,会为动态节点打上标记(PatchFlag),运行时 diff 只检查有标记的部分:
// packages/shared/src/patchFlags.ts
export const enum PatchFlags {
TEXT = 1, // 动态文本内容
CLASS = 1 << 1, // 动态 class
STYLE = 1 << 2, // 动态 style
PROPS = 1 << 3, // 动态 prop(排除 class 和 style)
FULL_PROPS = 1 << 4, // 有动态 key 的 prop(需要完整 diff)
HYDRATE_EVENTS = 1 << 5, // SSR 水合时需要处理事件
STABLE_FRAGMENT = 1 << 6,
KEYED_FRAGMENT = 1 << 7,
UNKEYED_FRAGMENT = 1 << 8,
NEED_PATCH = 1 << 9,
DYNAMIC_SLOTS = 1 << 10,
DEV_ROOT_FRAGMENT = 1 << 11,
HOISTED = -1, // 静态提升的节点,永不 diff
BAIL = -2 // diff 时完全跳过优化
}
一个模板 <div :class="cls">{{ text }}</div> 会编译成:
// 编译后的 render function
createElementVNode("div", { class: _ctx.cls }, _ctx.text,
PatchFlags.CLASS | PatchFlags.TEXT // = 3
)
运行时收到 PatchFlag = 3 时,只检查 class 和 text,不检查其他 prop,跳过完整的属性比对。这就是 Vue 3 更新速度提升 133% 的核心来源。
3.4 @vue/reactivity 的独立性与生态影响
Vue 3 把响应式系统完全解耦,@vue/reactivity 可以在任何 JavaScript 环境中独立使用:
import { reactive, effect } from '@vue/reactivity';
const state = reactive({ count: 0 });
effect(() => {
console.log('count is:', state.count); // 立即打印
});
state.count++; // 触发 effect 重新执行,打印 "count is: 1"
这个设计影响深远:
- VueUse(Vue 社区最大的 composable 库)的底层就是
@vue/reactivity - Petite-vue(约 6KB 的轻量版 Vue)复用了相同的响应式核心
- @vue/reactivity-transform(实验性)让 ref 可以在编译时自动处理
.value
Level 4 · 边界与陷阱(全体适用)
陷阱1:reactive() 对基础类型无效
import { reactive } from 'vue';
// 错误用法
const count = reactive(0); // 返回值仍然是数字 0,不是响应式的
count++; // 这不会触发任何更新
// 正确用法
const count = ref(0);
count.value++;
// 为什么?
// reactive() 内部是 new Proxy(target, handlers)
// Proxy 只能代理对象,不能代理 number/string/boolean 等基础类型
// Vue 会打印警告:
// [Vue warn]: value cannot be made reactive: 0
根本原因:Proxy 的第一个参数必须是对象。typeof null === 'object' 所以 null 也不行,Vue 内部有专门检查。
陷阱2:解构 reactive 对象丢失响应性
import { reactive, watch } from 'vue';
const state = reactive({ count: 0, name: 'Vue' });
// 错误:解构后 count 是普通数字,不是响应式的
const { count } = state;
console.log(count); // 0
state.count = 10;
console.log(count); // 仍然是 0,没有跟随变化
// 正确方案1:不解构,直接用 state.count
// 正确方案2:用 toRefs
import { toRefs } from 'vue';
const { count } = toRefs(state);
console.log(count.value); // 0,count 现在是一个 ref,链接到 state.count
state.count = 10;
console.log(count.value); // 10,正确跟随
// 正确方案3:直接用 ref 而不是 reactive(推荐)
const count = ref(0);
const name = ref('Vue');
根本原因:const { count } = state 等价于 const count = state.count。这只是读取了当前值(触发一次 get trap,返回数字 0),并把这个数字赋给了 count 变量。数字是基础类型,没有响应性。后续 state.count 的变化不会影响 count 变量。
陷阱3:在 watchEffect 外访问响应式数据不触发追踪
import { reactive, watchEffect } from 'vue';
const state = reactive({ count: 0 });
// 错误理解:以为响应式对象被修改时会"主动通知"订阅者
// 实际上:必须先在 effect 中访问,才能建立追踪关系
// 这段代码不会在 state.count 变化时重新打印
let lastCount = state.count; // 在 effect 外读取,不建立追踪
state.count = 5;
console.log(lastCount); // 仍然是 0
// 正确:在 watchEffect 内访问
watchEffect(() => {
lastCount = state.count; // 在 effect 内读取,建立追踪
console.log('count changed:', lastCount);
});
state.count = 5; // 触发 watchEffect 重新执行
根本原因:响应式追踪是"拉取式"的,必须先有消费者(effect),在 effect 执行时读取数据,才能建立 target → key → effect 的依赖关系。没有 effect 上下文时,track() 函数会检查 activeEffect 是否存在,如果不存在则直接返回不做任何事。
陷阱4:reactive 嵌套时 toRaw 的必要性
import { reactive, toRaw } from 'vue';
const original = { nested: { value: 1 } };
const state = reactive(original);
// 由于懒代理,第一次访问时 state.nested 会变成 Proxy
console.log(state.nested === original.nested); // false!
// state.nested 是 original.nested 的 Proxy 版本
// 场景:需要把数据传给第三方库(不需要响应式)
thirdPartyLib.process(state); // 可能引起问题,传的是 Proxy
// 正确做法:用 toRaw 获取原始对象
thirdPartyLib.process(toRaw(state)); // 传原始对象
// 另一个常见陷阱:比较时
const rawState = toRaw(state);
console.log(rawState === original); // true,toRaw 返回的是原始对象
console.log(rawState.nested === original.nested); // true
根本原因:Vue 3 的懒代理策略使得每次通过 Proxy 访问嵌套对象,都会返回该嵌套对象的 Proxy 版本。这意味着在 Proxy 环境中看到的对象引用与原始对象引用不同。=== 比较会失败,如果你缓存了从 Proxy 中取到的子对象引用,它是一个 Proxy,传给不了解 Vue 的第三方代码可能导致意外行为。
陷阱5:Vue 3 的数组响应式与索引追踪
import { reactive, watchEffect } from 'vue';
const list = reactive([1, 2, 3]);
// 这些操作都是响应式的(Vue 3 不同于 Vue 2)
watchEffect(() => {
console.log(list[0]); // 追踪索引 0
});
list[0] = 99; // 触发更新!Vue 3 可以追踪索引赋值
// 但这个陷阱仍然存在:
watchEffect(() => {
console.log(list.length);
});
// 以下两种方式的追踪效果不同
list.push(4); // 触发 length 追踪(length 变化)
list[10] = 99; // 也会触发 length 追踪(稀疏数组,length 变为 11)
// 但:
const arr = reactive(['a', 'b', 'c']);
watchEffect(() => {
// 这里访问了 arr 的 length,但没有访问具体元素
console.log('length:', arr.length);
});
arr[0] = 'A'; // 不触发!因为只追踪了 length,没追踪索引 0
根本原因:Vue 3 追踪的是你实际访问的属性。如果你的 effect 只读取了 arr.length,它只会在 length 变化时重新执行。索引赋值在 length 不变时(原位替换),不会触发只追踪 length 的 effect。这是精确追踪的代价:你追踪什么,就更新什么,没有多余的更新,但也不会帮你追踪你没有访问的东西。
本章小结
-
Vue 的三次重写各有明确动机:Vue 1 是"Angular 精简版",Vue 2 引入虚拟 DOM 解决大规模更新问题,Vue 3 用 Proxy 解决了
Object.defineProperty的三个根本限制,同时用 TypeScript 重写提升开发体验。 -
Object.defineProperty的三个限制是结构性的,无法通过补丁解决:无法拦截属性新增、属性删除、数组索引赋值,$set/$delete是 API 层面的变通,不是技术层面的解决。 -
Proxy 解决的不只是那三个限制:Proxy 的 13 个 trap 覆盖了对象操作的所有基本行为,让 Vue 3 可以追踪
in操作符、Object.keys()、delete等之前完全无法响应的操作。 -
Vue 3 的性能提升来自两个正交的优化:运行时的 Proxy 响应式(精确追踪,无需 $set)+ 编译时的 PatchFlag 系统(静态分析减少 diff 工作量),两者叠加产生了初始化 +55%、更新 +133% 的效果。
-
Vue 与 React 的演进解决了相同的问题,但方式根本不同:Hooks 每次渲染重新执行(闭包陷阱、依赖数组是代价),setup() 只执行一次(没有闭包陷阱,没有依赖数组)。这不是优劣之分,是不同响应式模型的必然结果。