第103课:Vue 3 响应式原理——reactive、ref、Proxy、依赖收集与触发
Vue 3 最核心的变革之一是响应式系统从 Object.defineProperty 迁移到了 Proxy。这不仅仅是底层 API 的替换——它彻底解决了 Vue 2 响应式系统的两大局限:无法检测对象属性的添加和删除,以及无法直接拦截数组的索引修改和 length 变更。Vue 3 的响应式系统通过 Proxy 实现了对任意嵌套对象的完整代理,结合 Reflect 进行默认行为转发,并通过依赖收集(track) 和派发更新(trigger) 构建了一套精确、高效的订阅-发布机制。理解这套机制的原理,是掌握 Vue 3 Composition API、自定义响应式逻辑以及 Pinia 状态管理底层的基础。本节课将自底向上,从手动实现一个迷你响应式系统开始,逐步剖析 Vue 3 源码中 reactive、ref、computed 和 watch 的实现原理。
1. Vue 2 响应式的局限与 Proxy 的解决
1.1 Object.defineProperty 的局限
Vue 2 通过 Object.defineProperty 递归地将对象的每个属性转换为 getter/setter。在 getter 中收集依赖(谁读取了这个属性),在 setter 中触发更新(重新渲染组件)。这种“属性级拦截”存在三个著名缺陷:
- 无法检测属性的添加和删除:由于 getter/setter 在对象初始化时一次性绑定,后续添加的新属性没有 getter/setter,因此 Vue 2 无法追踪它们。
Vue.set(target, key, value)和Vue.delete(target, key)这两个全局 API 正是为了弥补这一缺陷而生。 - 无法直接拦截数组的索引赋值和
length修改:arr[0] = newValue和arr.length = 0不会触发 setter。Vue 2 通过重写数组的 7 个变异方法(push、pop、shift、unshift、splice、sort、reverse)来间接实现响应式,但直接通过索引修改仍无效。 - 性能开销:初始化时需要递归遍历对象的所有嵌套属性,对深层大对象初始化缓慢。
1.2 Proxy 如何解决这些问题
Proxy 是 ES6 引入的元编程特性。它创建一个对象的代理,可以拦截对该对象的13 种基本操作——包括属性读取(get)、属性设置(set)、属性删除(deleteProperty)、属性枚举(ownKeys)等。由于 Proxy 是对象级的拦截(而非属性级),它天然解决了 Vue 2 的三大痛点:
- 拦截属性添加和删除:
set和deleteProperty陷阱可捕获所有属性变更,无论该属性是否预先存在。 - 拦截数组的索引修改和
length变更:数组也是对象,Proxy 的set陷阱对数组索引同样有效。 - 惰性代理:只有在访问嵌套对象时才递归地创建其代理,而非初始化时一次性递归。
结合 Reflect API——它提供了与 Proxy 陷阱一一对应的默认行为方法,并正确传递 receiver(确保 getter/setter 中的 this 指向代理对象)——Vue 3 构建了一个比 Vue 2 更强大、更简洁、更高效的响应式系统。
2. 手写一个迷你响应式系统
理解原理的最佳方式是亲手实现。下面我们将自底向上构建一个支持 reactive、effect 和依赖自动收集/派发的迷你响应式引擎。完整代码约 80 行,但涵盖了 Vue 3 响应式系统的核心骨架。
2.1 目标效果
我们希望实现以下 API:
1 | const state = reactive({ count: 0, message: 'hello' }); |
2.2 第一步:reactive——用 Proxy 创建响应式对象
1 | // 存储原始对象到代理对象的映射(用于避免重复代理) |
关键设计:
reactiveMap使用WeakMap存储原始对象到代理的映射。WeakMap对键是弱引用,当原始对象不再被其他代码引用时,垃圾回收器可以自动回收该映射条目,防止内存泄漏。get陷阱中的惰性深度代理:只有当访问到嵌套对象时才调用reactive(result)创建子代理,而不是在初始化时递归遍历所有属性。这极大地提升了包含深层大对象的初始化性能。set陷阱中使用Reflect.set并传入receiver——如果target[key]本身是一个 getter/setter,receiver确保this指向代理对象而非原始对象,从而保证 getter 内部对this的访问也能被代理拦截。deleteProperty陷阱是 Vue 2 无法做到的——它使得delete state.count能正常触发响应式更新。
2.3 第二步:track 与 trigger——依赖收集与派发更新
使用一个全局的 WeakMap 作为依赖存储桶,结构为:
1 | WeakMap<target, Map<key, Set<effectFn>>> |
WeakMap的键是原始对象(被代理的目标),值是另一个Map。- 内层
Map的键是属性名(key),值是一个 **Set**,存储所有依赖于该属性的effect函数。
1 | // 全局依赖存储 |
工作流程解析:
- 当
effect(() => { console.log(state.count); })被调用时,activeEffect被设置为该匿名函数,并立即执行一次。 - 在执行过程中,读取
state.count触发了proxy的get陷阱。 get陷阱中调用track(target, 'count')。track检测到activeEffect不为null,将该effect函数添加到bucket[target]['count']的 Set 中。- 执行结束后,
activeEffect重置为null。 - 当
state.count = 1时,触发proxy的set陷阱。 set陷阱中调用trigger(target, 'count')。trigger从bucket中取出依赖于count的所有effect函数并逐一执行。
2.4 完整可运行的最小响应式系统
1 | // --- 依赖存储 --- |
3. ref:基本类型的响应式包装
reactive 只能代理对象类型。对于基本类型(string、number、boolean),Vue 3 提供了 ref 函数。ref 内部创建了一个带有 value 属性的对象,并对这个对象使用 reactive(或直接通过 getter/setter)实现响应式。
3.1 简化版 ref 实现
1 | function ref(rawValue) { |
使用差异:在 setup 或 <script setup> 中,ref 创建的变量需要通过 .value 访问和修改。但在模板中,Vue 会自动“解包”(unwrap)ref,因此可以直接使用 {{ count }} 而非 {{ count.value }}。这一便利是由模板编译器在编译阶段自动处理的。
3.2 ref 与 reactive 的关系
当 ref 接收一个对象作为参数时,它内部会调用 reactive 来代理该对象:
1 | function ref(rawValue) { |
选择建议:
- 包装基本类型(数字、字符串、布尔值)使用
ref。 - 包装对象/数组使用
reactive。若使用ref包装对象,需要使用.value访问属性,稍显冗余(模板中会自动解包,但在 setup 脚本中仍需.value)。 - 在 Composition API 中,如果希望将对象的所有属性保持为响应式独立引用(可分别解构且保持响应性),可以使用
reactive配合toRefs。
4. Vue 3 响应式 API 全景
Vue 3 暴露了以下响应式核心 API,它们都建立在上述原理之上:
| API | 用途 | 实现基础 |
|---|---|---|
reactive() |
创建对象的深层响应式代理。 | Proxy |
ref() |
包装基本类型或对象为响应式引用。 | getter/setter 或 reactive |
shallowReactive() |
仅对对象的第一层属性做响应式代理,深层属性不处理。 | Proxy(不递归) |
shallowRef() |
仅 .value 本身是响应式的,内部值不做深层处理。 |
getter/setter |
readonly() |
创建对象的只读代理(深度),修改会触发警告。 | Proxy |
shallowReadonly() |
仅第一层只读。 | Proxy |
toRaw() |
返回 reactive 或 readonly 代理的原始对象。 |
reactiveMap 查找 |
markRaw() |
标记对象永不转为代理。 | 添加 __v_skip 标记 |
toRef() |
从 reactive 对象的某个属性创建一个 ref(保持响应式连接)。 |
getter/setter |
toRefs() |
将 reactive 对象的所有属性转换为独立的 ref。 |
toRef 循环 |
isReactive() |
检查对象是否由 reactive 创建。 |
检查 __v_isReactive |
isRef() |
检查值是否是一个 ref 对象。 |
检查 __v_isRef |
5. computed 的原理
computed 创建一个惰性求值的、可缓存的计算属性。它的核心是:仅在依赖的响应式数据发生变化时,才重新计算值。如果依赖未变,多次访问 computed 返回的是缓存值。
1 | function computed(getter) { |
Vue 3 的 computed 在内部是一个 ComputedRefImpl 类。它维护一个 _dirty 标志,当依赖的响应式数据变化时,trigger 会将 _dirty 设为 true,下次访问 computed.value 时才会重新计算。如果依赖未变,直接返回缓存值。此外,computed 本身也是响应式的——当你在 effect 中读取 computed.value 时,effect 会订阅这个 computed,一旦 computed 的值变化,effect 也会重新执行。
6. watch 的原理
watch 用于监听一个或多个响应式数据源的变化,并执行副作用回调。它基于 effect 实现,但有以下增强:
- 可指定监听源:可以是
ref、reactive对象、getter 函数、或它们的数组。 - 可获得旧值和新值:回调接收
(newValue, oldValue)。 - **可配置
immediate和flush**:immediate在创建时立即执行回调;flush控制回调执行时机('pre'组件渲染前、'post'渲染后、'sync'同步)。
1 | function watch(source, callback, options = {}) { |
7. 常见陷阱与最佳实践
7.1 解构 reactive 对象会丢失响应性
1 | const state = reactive({ count: 0, name: 'Alice' }); |
7.2 替换整个 reactive 对象会丢失响应性
1 | let state = reactive({ count: 0 }); |
7.3 watch 的 deep 选项与性能
watch(() => state, callback) 默认是浅层监听。如果 state 是一个 reactive 对象,需要使用 { deep: true } 或直接传递 () => state(Vue 会因对象被 reactive 而自动转为深层)。深层监听会递归遍历所有嵌套属性,对大型深层对象会造成性能开销。在可能的情况下,尽量监听具体的属性:watch(() => state.user.name, callback)。
7.4 避免在模板中直接使用 reactive 对象的解构
在 <script setup> 中,如果希望模板能够直接使用解构后的变量而保持响应性,应使用 reactive + toRefs,或直接使用多个 ref。这是 Composition API 最常见的编码模式之一。
课后练习
一、概念自测(选择题 / 填空题)
(单选) Vue 3 的响应式系统基于什么 ES6 特性实现?
A.Object.defineProperty
B.Proxy
C.Symbol
D.Generator(单选) 以下哪个操作在 Vue 2 中无法被响应式系统自动检测,但在 Vue 3 中可以?
A. 修改已存在的属性值。
B. 通过数组的push方法添加元素。
C. 删除对象的某个属性(delete obj.key)。
D. 读取对象的嵌套属性。(填空) 在 Vue 3 中,要将基本类型值转换为响应式引用,应使用
______函数。(多选) 以下哪些关于 Vue 3 响应式系统的描述是正确的?
A.reactive内部使用WeakMap缓存已创建的代理,避免重复代理。
B.ref创建的对象在<script setup>中不需要.value访问。
C.computed采用惰性求值,仅在依赖变化时才重新计算。
D.watch默认开启深层监听(deep: true)。
二、AI 编程任务:编写面向 AI 的提示词
场景:你需要实现一个简化的 Vue 3 风格响应式工具库,包含以下三个函数:
reactive(obj):使用Proxy创建深层响应式代理,支持get、set、deleteProperty陷阱。effect(fn):注册副作用函数,自动收集依赖并在依赖变化时重新执行。computed(fn):创建一个计算属性,惰性求值,仅在依赖变化时重新计算。
要求实现基础的依赖收集(track)和派发更新(trigger)机制,使用WeakMap存储依赖关系。代码需要包含使用示例(创建reactive对象,注册effect和computed,验证行为)。
任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成完整的 JavaScript 响应式库代码。提示词中需明确指定 Proxy 陷阱的类型、WeakMap 的结构以及 computed 的惰性求值实现。
三、Agent 模式下的提示词示例
你是一个资深前端开发 Agent。请创建一个简化版 Vue 3 响应式系统模块。需要创建文件
src/reactivity.js,导出reactive、effect和computed三个函数。
reactive(target):对target使用Proxy,get调用track,set检测新旧值是否不同后调用trigger,deleteProperty成功后调用trigger。get中对嵌套对象调用reactive实现惰性深度代理。使用WeakMap缓存已代理对象(reactiveMap)。track(target, key):将activeEffect存入全局WeakMap<target, Map<key, Set<effect>>>结构。trigger(target, key):从存储中取出对应Set并遍历执行所有effect。effect(fn):设置activeEffect = fn,执行fn(),最后重置activeEffect = null。computed(fn):返回{ value }对象,内部维护_dirty标志和_value缓存。使用effect监听fn内部的依赖变化,变化时标记_dirty = true。get value()中若_dirty则重新执行fn并缓存,同时track该computed的'value'属性。- 文件末尾添加测试代码(使用
console.log验证):创建reactive对象,注册effect打印变化,创建computed属性并验证惰性求值和缓存。- 所有函数添加 JSDoc 注释。完成后输出完整文件内容。
四、面试真题与参考答案
题目(字节跳动前端面试题):
请详细解释 Vue 3 响应式系统的实现原理。为什么 Vue 3 弃用了
Object.defineProperty而转向Proxy?Proxy解决了哪些 Vue 2 无法解决或需要特殊 API 才能解决的问题?请结合依赖收集(track)和派发更新(trigger)的流程,说明当修改一个深层嵌套的对象属性时,响应式系统是如何工作的。
参考答案:
Vue 3 响应式系统基于 Proxy 实现。reactive 函数创建一个对象的 Proxy,拦截 get、set、deleteProperty 等操作。在 get 陷阱中调用 track 收集当前活跃的 effect 函数;在 set 或 deleteProperty 陷阱中调用 trigger 派发更新,重新执行所有依赖该属性的 effect。依赖关系存储在一个 WeakMap<target, Map<key, Set<effectFn>>> 的三层结构中。
弃用 Object.defineProperty 转向 Proxy 的原因:Proxy 是对象级代理,可以拦截属性的添加(set)、删除(deleteProperty),以及数组的索引赋值和 length 修改——这些是 Object.defineProperty 的属性级拦截无法处理的。Vue 2 需要通过 Vue.set/Vue.delete 和重写数组方法等 workaround 来弥补,增加了 API 复杂度和开发者的心智负担。此外,Proxy 支持惰性深度代理——仅在访问嵌套对象时才递归创建代理,避免了初始化时一次性递归遍历的性能开销。
修改深层嵌套对象属性(如 state.a.b.c = 1)的工作流程:首先通过 proxy.a 读取 a,get 陷阱发现 a 是对象,返回 reactive(a) 的代理。接着 proxy.a.b 读取 b,同理返回 b 的代理。最终 proxy.a.b.c = 1 触发 b 代理的 set 陷阱,检测到 c 的值变化,调用 trigger(b, 'c')。trigger 从全局 WeakMap 中取出依赖于 b.c 的 effect 集合并执行。每一层的代理独立工作,依赖收集也精确到具体属性,因此修改深层属性只会触发依赖该具体属性的 effect,而非父级对象的所有 effect。这保证了更新粒度的高效性。
课后练习答案
一、概念自测答案
B
- 解析:Vue 3 响应式系统使用
Proxy实现。Vue 2 使用Object.defineProperty。
- 解析:Vue 3 响应式系统使用
C
- 解析:Vue 3 通过
Proxy的deleteProperty陷阱可以拦截属性删除,Vue 2 需要Vue.delete手动处理。A、B、D 在 Vue 2 中均已被支持。
- 解析:Vue 3 通过
ref- 解析:
ref用于将基本类型值包装为响应式对象(通过.value访问)。
- 解析:
A、C
- 解析:A 正确,
reactive使用WeakMap缓存代理避免重复。B 错误,ref在<script setup>中仍需.value,仅在<template>中自动解包。C 正确。D 错误,watch默认是浅层监听,深度监听需显式设置{ deep: true }或传递reactive对象作为 source。
- 解析:A 正确,
二、AI 编程任务参考答案(提示词示例)
示例提示词:
“请实现一个简化版 Vue 3 响应式系统。要求:
reactive(obj):使用Proxy,拦截get(调用 track,惰性深度代理)、set(新旧值比较,调用 trigger)、deleteProperty(调用 trigger)。WeakMap 缓存。effect(fn):收集依赖,变化时重新执行。computed(fn):惰性求值,带缓存和脏标志。track/trigger使用 WeakMap<target, Map<key, Set>> 结构。 - 文件末尾提供测试代码,验证 count 和 computed 行为。
- 输出完整 JavaScript 文件。”