文章

手写简易响应式系统深度解析:Vue3 核心不到 200 行

Vue3 响应式核心只有 reactive(Proxy 拦截)、track(收集 effect)、trigger(通知 effect)三模块,再加 WeakMap 三层数据结构。 手写一遍能彻底理解依赖收集与触发更新的闭环,面试中能现场推导出最小可用响应式系统即为加分项。

手写简易响应式系统深度解析:Vue3 核心不到 200 行

一句话概括

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 行代码能写出完整系统,但每一行都在回答一个真实的生产问题。

本文由作者按照 CC BY 4.0 进行授权