Reactivity源码原理
如果只把 Vue 3 的响应式理解成“用了 Proxy”,这个答案是不够的。Proxy 只是入口,真正的核心是:
- 谁在读取数据
- 把这个读取关系记到哪里
- 数据变化后怎么只通知相关副作用
所以从源码角度看,Reactivity 的主线不是 Proxy,而是:
track 收集依赖,trigger 触发依赖,effect 组织副作用执行。
0. 面试速答(30 秒版 TL;DR)
- Vue 3 响应式核心包是
@vue/reactivity,核心链路是:代理拦截 -> 依赖收集 -> 依赖触发 -> 调度执行。 - 依赖关系通常可抽象成:
WeakMap<Target, Map<Key, Dep>>。 effect表示“依赖这个数据的一段副作用逻辑”,组件 render、本地副作用、computed底层都依赖它。reactive负责把对象接入依赖系统,ref负责把单值包成可追踪对象,computed负责懒执行与缓存。- Vue 3 的优势不只是
Proxy,还包括:精细依赖、cleanup、scheduler、集合类型特殊处理。
先记主链路:effect 执行 -> 读取属性 -> track 建依赖;数据变更后走 trigger -> 调度 effect。
1. 先立住心智模型:响应式系统到底在解决什么?
最本质的问题只有一句:
当一段逻辑依赖了某个数据,数据变化时,能不能只重新执行这段逻辑?
比如:
effect(() => {
console.log(state.count)
})
系统要做到两件事:
- 第一次执行时,知道这段 effect 读了
state.count - 以后
state.count变化时,只重新触发这段 effect
所以响应式系统的本质不是“自动刷新页面”,而是“自动维护读取关系”。
2. 源码里最关键的数据结构是什么?
面试里一定要把这句说出来:
WeakMap<Target, Map<Key, Dep>>
可以拆成三层理解:
WeakMap的 key 是目标对象targetMap的 key 是目标对象上的属性keyDep是依赖这个属性的一组 effects
例如:
const state = reactive({ count: 0, name: 'vue' })
可能会形成这样的依赖图:
state.count->{effectA, effectB}state.name->{effectC}
这意味着:
- 改
count不会误触发只依赖name的逻辑 - 响应式是按“对象 + 属性”精细追踪的
3. effect 是响应式系统的执行单元
effect(fn) 可以理解成:
- 先把
fn包装成一个响应式副作用对象 - 执行时把它设成“当前激活副作用”
- 这样后续所有属性读取都知道该把依赖记到谁身上
伪代码可理解为:
let activeEffect
function effect(fn) {
const reactiveEffect = () => {
activeEffect = reactiveEffect
fn()
activeEffect = undefined
}
reactiveEffect()
return reactiveEffect
}
真实实现比这复杂很多,还会处理:
- effect 嵌套
- cleanup
- scheduler
- 停止监听
stop - 避免递归自触发
但主线不变:执行 effect 时暴露 activeEffect,供依赖收集使用。
4. track 到底做了什么?
当你访问响应式对象属性时,会进入代理的 get 拦截。
例如:
const state = reactive({ count: 0 })
effect(() => {
console.log(state.count)
})
执行 state.count 时,流程大致是:
- 代理
get被触发 - 调用
track(target, key) - 发现当前存在
activeEffect - 在
targetMap中找到target对应的依赖表 - 在依赖表里找到
key对应的dep - 把
activeEffect放进dep
伪代码:
function track(target, key) {
if (!activeEffect) return
let depsMap = targetMap.get(target)
if (!depsMap) {
depsMap = new Map()
targetMap.set(target, depsMap)
}
let dep = depsMap.get(key)
if (!dep) {
dep = new Set()
depsMap.set(key, dep)
}
dep.add(activeEffect)
}
重点不是背代码,而是理解:
track的目标是建立“哪个属性被哪个 effect 用过”的关系
5. trigger 为什么能做到精准更新?
写入响应式属性时,会进入代理的 set、deleteProperty,或者集合类型的专门处理逻辑。
例如:
state.count++
流程通常是:
- 判断这次是新增、修改还是删除
- 根据
target + key找到对应dep - 把相关 effects 收集出来
- 如果 effect 定义了
scheduler,交给它调度 - 否则直接执行
伪代码:
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects?.forEach((effect) => {
if (effect.scheduler) {
effect.scheduler()
} else {
effect()
}
})
}
这里的关键是:
- 不是“对象一变,全量通知”
- 而是 按具体 key 取出对应 effects
6. 为什么需要 effect 栈和 cleanup?
这是很多人回答不出来,但很容易被追问的点。
6.1 effect 栈
因为 effect 可能嵌套执行。
例如外层 effect 中又触发了内层 effect,如果只用一个全局变量保存当前 effect,就可能把依赖记错对象。
所以需要:
- 入栈:当前 effect 开始执行
- 出栈:当前 effect 结束执行
activeEffect永远指向栈顶
6.2 cleanup
因为 effect 每次重新执行后,依赖集合可能变化。
例如:
effect(() => {
console.log(flag ? state.a : state.b)
})
第一次 flag = true 时依赖了 a,后来 flag = false 后应该依赖 b。如果不清理旧依赖,就会出现:
- 改
a还会误触发 effect - 依赖集合越来越脏
所以 effect 在重新执行前,会把自己从旧 dep 中移除,再重新收集。
7. reactive 的本质不是 Proxy,而是“带协议的 Proxy”
reactive 返回的是代理对象,但关键不是“用了代理”,而是代理里藏着响应式协议。
最核心的 get / set 行为可以概括为:
new Proxy(target, {
get(target, key, receiver) {
track(target, key)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver)
trigger(target, key)
return result
},
})
真实源码还会继续处理:
- 是否只读
readonly - 是否浅层
shallowReactive - 嵌套对象是否继续转响应式
- 数组方法是否需要特殊拦截
Map/Set/WeakMap/WeakSet的读写与遍历
所以源码难点不在语法,而在“边界条件非常多”。
8. ref 为什么也能接进同一套系统?
ref 没有直接把值暴露出去,而是包成了一个带 value 访问器的对象。
你可以把它想成:
class RefImpl {
get value() {
track(this, 'value')
return this._value
}
set value(nextValue) {
this._value = nextValue
trigger(this, 'value')
}
}
所以 ref 和 reactive 虽然入口不同,但最终都能落回:
- 读取时
track - 写入时
trigger
换句话说,ref 不是另一套系统,而是同一套依赖协议的另一种包装形态。
9. computed 为什么既能缓存,又能自动更新?
computed 底层也是 effect,只不过是 lazy effect。
它的关键点是:
- 不会一创建就立即重新计算到底
- 第一次读
.value时才执行 getter - 依赖变了后,不是立刻重算,而是先把自己标记为 dirty
- 下次再读
.value时才重新计算
所以 computed 的本质是:
- effect 提供依赖追踪能力
- dirty 标记提供缓存失效能力
这也是它和普通方法最大的区别。
10. 数组、Map、Set 为什么更难?
因为这类结构的“变化”不只是一条普通属性写入。
10.1 数组
数组会涉及:
length变化- 按索引赋值
push/pop/splice- 遍历依赖
所以源码会特别处理:
- 改某个索引是否会影响
length - 改
length是否要通知超出长度的索引依赖
10.2 Map / Set
集合类型的关键不是单个 key 读取,而是:
getsetadddeleteclearsizeforEach- 迭代器遍历
其中“遍历相关副作用”并不绑定某个普通属性名,所以 Vue 会引入类似“迭代专用 key”的内部标记,用来表示:
- 依赖的是结构变化
- 不是依赖某个单独字段
面试里可以直接总结为:
- 普通对象按属性追踪
- 集合类型还要额外追踪结构性变化和遍历依赖
11. watch 属于 Reactivity 吗?
严格说:
watch的能力建立在响应式 effect 之上- 但它的实现不完全属于
@vue/reactivity的最底层核心 - 它更像是基于响应式系统封装出来的高级 API
所以回答边界时可以这样说:
@vue/reactivity负责底层依赖追踪与触发watch、组件更新这些能力是在这套底层之上继续封装
12. 高频面试题怎么答
12.1 Vue 3 响应式为什么比 Vue 2 更强?
标准答法:
- Vue 2 主要基于
Object.defineProperty,对新增属性、删除属性、数组下标和集合类型支持有限。 - Vue 3 基于
Proxy,拦截能力更完整,能天然覆盖更多操作。 - 但真正升级不只是
Proxy,还包括更精细的依赖结构、scheduler、cleanup 和集合类型处理。
12.2 为什么 computed 默认有缓存?
因为它底层是懒执行 effect,并且依赖变更时只先标记 dirty,不会立刻重算;再次读取时才重新求值。
12.3 为什么解构 reactive 会丢响应式?
因为解构后拿到的是当前值,不再经过原代理对象的 get / set,依赖收集链断了。
13. 常见误区
- 误区 1:Vue 3 响应式核心就是
Proxy不对。Proxy只是拦截手段,真正核心是targetMap + effect + track/trigger。 - 误区 2:依赖收集是按对象维度,不分属性
不对。普通对象通常是按
target + key精细追踪。 - 误区 3:effect 重跑不用清理旧依赖 不清理就会出现脏依赖和误触发。
- 误区 4:
ref和reactive是两套完全独立系统 实际它们都接在同一套依赖协议上。
14. 速记要点
- 一句话:Reactivity 本质上是在维护“属性 -> 副作用”的映射关系。
- 一个结构:
WeakMap<Target, Map<Key, Dep>>。 - 两条主线:
track收集,trigger触发。 - 三个高频 API:
reactive接对象,ref接单值,computed做懒计算缓存。 - 一个高频追问点:为什么需要 cleanup 和 scheduler。