手写简易响应式系统深度解析:Vue3 核心不到 200 行
Vue3 响应式核心只有 reactive(Proxy 拦截)、track(收集 effect)、trigger(通知 effect)三模块,再加 WeakMap 三层数据结构。 手写一遍能彻底理解依赖收集与触发更新的闭环,面试中能现场推导出最小可用响应式系统即为加分项。
一句话概括
Vue3 响应式系统的核心只有三个模块:用 Proxy 拦截对象的 get/set 操作,用 track 在 get 时收集当前正在运行的 effect 函数,用 trigger 在 set 时通知所有收集到的 effect 重新执行——再加上一个 WeakMap<target, Map<key, Set<effect>>> 的数据结构。
核心知识点
1. reactive:Proxy 拦截 + 递归响应
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const targetMap = new WeakMap() // target → Map(key → Set(effect))
let activeEffect = null
function reactive(obj) {
if (typeof obj !== 'object' || obj === null) return obj
return new Proxy(obj, {
get(target, key, receiver) {
track(target, key)
const res = Reflect.get(target, key, receiver)
// 惰性递归:访问到嵌套对象时才创建 Proxy
return typeof res === 'object' && res !== null ? reactive(res) : res
},
set(target, key, val, receiver) {
const old = Reflect.get(target, key, receiver)
const result = Reflect.set(target, key, val, receiver)
if (old !== val && !Object.is(old, val)) trigger(target, key)
return result
}
})
}
关键决策:
WeakMap而非Map:key 是弱引用,响应式对象被销毁时自动 GC,不泄漏内存- 惰性递归:不在一开始遍历所有嵌套属性,访问时才创建子对象 Proxy
Reflect而非直接读取:Reflect.get正确传递receiver,保证 Proxy 链中 getter 的 this 正确
2. track / trigger:依赖收集和触发
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function track(target, key) {
if (!activeEffect) return
let depsMap = targetMap.get(target)
if (!depsMap) targetMap.set(target, (depsMap = new Map()))
let deps = depsMap.get(key)
if (!deps) depsMap.set(key, (deps = new Set()))
deps.add(activeEffect)
// ⚠️ 关键:effect 也要记住自己属于哪些 dep,用于 cleanup
activeEffect.deps.push(deps)
}
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
for (const fn of [...(depsMap.get(key) || [])]) {
if (fn !== activeEffect) { // 防止自循环
fn.options?.scheduler ? fn.options.scheduler(fn) : fn()
}
}
}
3. effect:副作用注册 + 依赖清理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const effectStack = []
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn) // 每次执行前清除旧依赖
effectStack.push(effectFn)
activeEffect = effectFn
fn() // 执行时重新 track
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
effectFn.options = options
effectFn.deps = [] // 记录「我属于哪些 dep Set」
if (!options.lazy) effectFn() // 立即执行收集依赖
return effectFn
}
function cleanup(fn) {
for (const dep of fn.deps) dep.delete(fn)
fn.deps.length = 0
}
effect 栈:嵌套 effect(如组件嵌套渲染)时,内层执行完后必须弹出栈恢复到外层 activeEffect,否则 track 会收集到错误的 effect。
cleanup 的作用:解决分支切换问题:
1
2
3
4
5
6
const state = reactive({ ok: true, text: 'hello' })
effect(() => {
console.log(state.ok ? state.text : 'NOT') // 依赖 ok 和 text
})
// state.ok = false → 走 else 分支,不再依赖 text
// cleanup 确保 text 的 dep 中移除此 effect,修改 text 不会触发重执行
4. computed:惰性求值 + 缓存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function computed(getter) {
let value, dirty = true
const runner = effect(getter, {
lazy: true,
scheduler: () => { dirty = true; trigger(ref, 'value') } // 依赖变了→标记脏
})
const ref = {
get value() {
track(ref, 'value')
if (dirty) { value = runner(); dirty = false }
return value
}
}
return ref
}
const fullName = computed(() => state.firstName + ' ' + state.lastName)
// 不读取 .value 就不执行 getter
// 依赖不变时连续读取 .value 只计算一次
核心机制:computed 内部有一个 effect,依赖变化时 effect 的 scheduler 被调用→标记 dirty=true+通知 computed 的订阅者。但真正重新计算发生在下次读取 .value 时。
5. ref:包装基本类型为响应式对象
1
2
3
4
5
6
7
8
9
10
11
class RefImpl {
#value; #raw
constructor(v) { this.#raw = v; this.#value = toReactive(v) }
get value() { track(this, 'value'); return this.#value }
set value(v) {
if (Object.is(v, this.#raw)) return
this.#raw = v; this.#value = toReactive(v)
trigger(this, 'value')
}
}
const toReactive = v => (typeof v === 'object') ? reactive(v) : v
ref 内部是一个 { value: ... } 对象,通过 getter/setter 实现依赖追踪。如果值是对象,自动调用 reactive 包装。
其实你每天都在用
- Vue 模板渲染 —
<div>{ { count } }</div>编译后等价于effect(() => { div.textContent = count.value }),count 变→模板自动更新 - computed 缓存 —
const fullName = computed(() => first + last),只要 first/last 没变就不重新计算,即使被读了 100 次 - watch/watcheffect — 本质上就是 effect 的封装,侦听指定依赖变化
- React useSyncExternalStore — React 的响应式订阅机制和 Vue 的 track/trigger 本质上思路一致,只是用 subscribe 替代了 Proxy
- MobX —
observable+autorun和 Vue3 reactive + effect 是同构的设计
常见误解(FAQ)
❌ 误区:「Proxy 比 Object.defineProperty 快」
实际上单次 get/set 调用上 Proxy 比 defineProperty 略慢(约 2-5 倍)。但 Proxy 是拦截整个对象而不是逐个属性定义 getter/setter,当对象有大量属性时 Proxy 总体更高效。真正的优势是功能性——Proxy 能拦截数组索引变更、属性删除(delete)、in 运算符等 defineProperty 做不到的事。
❌ 误区:「Vue3 的所有数据都应该用 reactive 包装」
基本类型(数字、字符串)必须用 ref,因为 Proxy 只能代理对象。大列表(>10000 项)建议用 shallowRef 或直接普通数据,避免每个元素的依赖追踪开销。
❌ 误区:「effect(fn) 中 fn 里的所有 get 都会被追踪」
只有同步代码中发生的 get 才会被追踪。setTimeout(() => state.count, 0) 在异步回调中读取不会被 track,因为当前 activeEffect 已经被清空。
❌ 误区:「computed 的值会随依赖自动更新」
computed 是惰性求值——依赖变了只标记 dirty=true,真正计算发生在下一次读取 .value 时。如果依赖变了但你从来不读 .value,计算永远不执行。这和 effect 的 eager 执行完全相反。
一句话总结
手写 Vue3 响应式系统的本质是理解「谁读了谁、谁改了谁通知谁」这套发布订阅——Proxy 是房间的防盗门(入口拦截),WeakMap→Map→Set 是访客登记表(三层级依赖图),effect 是来访者(带着自己的名字来登记),track/trigger 是前台(登记和广播)。不到 200 行代码能写出完整系统,但每一行都在回答一个真实的生产问题。