Skip to content

手写一个 mini 响应式系统 ​

Vue 深度系列第 1 篇。不读源码、不背八股,用 100 行左右的代码把 Vue 3 响应式的核心机制从零实现一遍——实现完你会发现「响应式」三个字背后只有两件事:收集依赖和触发更新。

一、目标与效果 ​

先看我们要实现什么。用 reactive 包一个对象,改动它,依赖它的函数自动重跑:

javascript
import { reactive, effect } from './mini-reactivity.mjs'

const state = reactive({ count: 0, doubled: 0 })

effect(() => {
  state.doubled = state.count * 2
  console.log(`doubled is now: ${state.doubled}`)
})

state.count = 5
// 输出: doubled is now: 10

API 与 Vue 3 的 reactive / effect 同名同语义。这就是响应式的全部外壳,下面填掉里面的坑。

二、第一版:能跑,但有洞 ​

核心思路三句话:

  1. Proxy 拦截 get——读取属性时,把「当前正在运行的函数」记为该属性的依赖;
  2. Proxy 拦截 set——修改属性时,把记下的依赖全部重跑;
  3. 「当前正在运行的函数」用一个模块级变量 activeEffect 标记。
javascript
// mini-reactivity.mjs — 第一版
let activeEffect = null

// WeakMap<target, Map<key, Set<effect>>>:按「对象 → 属性 → 依赖集合」三层组织
const targetMap = new WeakMap()

export function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      track(target, key)
      return Reflect.get(target, key)
    },
    set(target, key, value) {
      Reflect.set(target, key, value)
      trigger(target, key)
      return true
    }
  })
}

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)
}

function trigger(target, key) {
  targetMap.get(target)?.get(key)?.forEach((fn) => fn())
}

export function effect(fn) {
  const run = () => {
    activeEffect = run
    fn()
  }
  run()
  activeEffect = null
  return run
}

第一版能通过上面的 count 例子。但它在真实场景的第一个分支语句上就会出错。

三、踩坑 1:分支切换留下脏依赖 ​

javascript
const state = reactive({ ok: true, text: 'hello' })

effect(() => {
  // ok 为 true 时才会读 text
  console.log(state.ok ? state.text : 'no text')
})

state.ok = false // 之后 text 的变化与这个 effect 无关了
state.text = 'changed' // 但第一版仍然会触发 effect 重跑——脏依赖

原因:第一次执行时 ok 和 text 都被 track;当 ok 变为 false,effect 重跑后不再读取 text,但旧的依赖关系还挂在 text 上。Vue 官方文档把这个问题称为「分支切换导致的依赖遗留」。

解法是每次执行前先清空自己,再重新收集——依赖永远反映「上一次执行实际读了什么」:

javascript
export function effect(fn) {
  const run = () => {
    // 先把自己从所有已注册的依赖集合中摘除
    for (const deps of run.deps) deps.delete(run)
    run.deps.clear()

    activeEffect = run
    fn()
    activeEffect = null
  }
  run.deps = new Set() // 记录「我被收集进了哪些依赖集合」
  run()
  return run
}

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)
  activeEffect.deps.add(deps) // 双向登记,cleanup 时用
}

这是整个实现里最容易漏的一块,也是面试题「为什么 effect 要 cleanup」的正确答案。Vue 源码里的 cleanupEffect 做的就是这件事。

四、踩坑 2:嵌套对象不深 ​

reactive({ a: { b: 1 } }) 改 state.a.b 不会触发任何更新——因为读取 state.a 时,get 返回的是原始子对象,后续对 .b 的读写绕过了 Proxy。

修复:get 返回值如果是对象,递归包一层:

javascript
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      track(target, key)
      const value = Reflect.get(target, key)
      // 深响应:子对象也变成响应式(Vue 3 就是懒代理:访问到才包装)
      return value !== null && typeof value === 'object' ? reactive(value) : value
    },
    set(target, key, value) {
      Reflect.set(target, key, value)
      trigger(target, key)
      return true
    }
  })
}

注意这叫懒代理(访问时才包装),Vue 3 采用的正是这个策略,而不是初始化时递归遍历整个对象——对大对象来说初始化成本天差地别。

五、踩坑 3:Reflect.get 的 receiver ​

如果响应式对象有继承的 getter:

javascript
const parent = {
  get name() {
    return this.child // 这里的 this 必须指向 proxy,否则 track 不生效
  }
}
const state = reactive(Object.create(parent))

getter 里的 this 指向谁,决定了后续属性读取是否被拦截。正确姿势是把 Proxy 透传过去:

javascript
get(target, key, receiver) {
  track(target, key)
  return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
  Reflect.set(target, key, value, receiver)
  trigger(target, key)
  return true
}

Reflect.get/set 的第三个参数把 this 绑定为 receiver(即 Proxy 本身)。没有这一步,继承链上的响应式会静默失效——不报错、只是不更新,属于典型的「排查半天发现是 this 指向」类问题。

六、与 Vue 3 真实实现的差距 ​

写完这 100 行,你知道的是「骨架」;生产实现(@vue/reactivity 包)在这之上还有多层工程化差异,列出来避免「我懂响应式了」的错觉:

差异点mini 版Vue 3 生产实现
触发时机同步逐个重跑调度器批处理:同一轮微任务里去重、合并(这就是为什么连续 count++ 100 次组件只重渲染一次)
依赖存储Set<effect> 逐个遍历3.4 起用版本计数 + 双向链表,避免每次触发全量遍历与高频增删开销
ref无基础类型无法被 Proxy 拦截,所以需要 .value 的包装对象
computed无effect + 缓存标志 + 脏检查的衍生实现
其他拦截面仅 get/setdeleteProperty、has(配合 v-if 的 in 判断)、ownKeys(配合 v-for)
边界处理无数组索引/length、Object.freeze 跳过、markRaw、避免响应式对象被 Proxy 套娃

其中调度器是体感差距最大的一个:mini 版每次赋值立即重跑所有依赖,生产版把重跑任务推入队列、按组件粒度去重后统一在微任务中 flush。理解了这一点,你就明白了 nextTick 存在的原因——它就是排在这个 flush 之后的回调时机。

七、自己动手验证 ​

把下面的测试保存为 test.mjs,与 mini-reactivity.mjs(合并本文二、三、四、五节代码)放同一目录:

javascript
// test.mjs — node test.mjs 运行,全部输出 ok 即通过
import assert from 'node:assert'
import { reactive, effect } from './mini-reactivity.mjs'

// 1. 基础响应
{
  const state = reactive({ count: 0 })
  let n = 0
  effect(() => (n = state.count))
  state.count = 1
  assert.strictEqual(n, 1)
}

// 2. 分支切换后脏依赖被清除
{
  const state = reactive({ ok: true, text: 'a' })
  let runs = 0
  effect(() => {
    runs++
    // eslint-disable-next-line no-unused-expressions
    state.ok ? state.text : ''
  })
  state.ok = false
  state.text = 'b'
  assert.strictEqual(runs, 2)
}

// 3. 深层嵌套响应
{
  const state = reactive({ a: { b: 1 } })
  let n = 0
  effect(() => (n = state.a.b))
  state.a.b = 2
  assert.strictEqual(n, 2)
}

console.log('all tests passed')

参考来源 ​


作者:yanshaodong · 2026-10-18