主题
手写一个 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: 10API 与 Vue 3 的 reactive / effect 同名同语义。这就是响应式的全部外壳,下面填掉里面的坑。
二、第一版:能跑,但有洞
核心思路三句话:
Proxy拦截get——读取属性时,把「当前正在运行的函数」记为该属性的依赖;Proxy拦截set——修改属性时,把记下的依赖全部重跑;- 「当前正在运行的函数」用一个模块级变量
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/set | deleteProperty、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')参考来源
- Vue 官方文档:深入响应式系统 —— track/trigger 与 effect 概念的官方定义
- Vue 官方文档:渲染机制 —— 调度器与组件更新队列
- @vue/reactivity 源码 ——
effect.ts/dep.ts,本文 cleanup 与生产实现的对应关系 - Vue 3.4 发布说明 —— 响应式系统重写(版本计数 + 双向链表)的官方说明
作者:yanshaodong · 2026-10-18