前一阵子我在一个中小型的内部工具页里做状态管理,越写越别扭:第三方状态库本身的文档没问题,但为了一个小项目要把全套概念都学一遍,action、reducer、selector、middleware,代码越拆越多,数据流转反而看不清。后来我干脆自己写了一个极简的响应式运行环境,代号就叫 rea,全文不到 300 行,基本思路就是 Proxy 做数据代理、依赖收集加副作用调度。这个东西不追求替代谁,它只解决一个小问题:让普通 JS 对象变成响应式数据,再让依赖这份数据的函数自动重新执行。
整个 rea 的核心只有两件事:track 和 trigger——访问数据时把当前执行的函数记下来,数据变化时再把记下来的函数都执行一遍。理解这两点之后,响应式前端框架里那些看起来很玄的概念,其实都是在这个基础上加缓存、加调度、加边界处理而已。
之前我在网上分享过一版草稿,不少做前端的同学问能不能讲细一点。这篇就来拆一拆 rea 的设计思路、关键实现、完整跑通过程,以及我调试中踩过的几个有代表性的坑。适合两种人看:一是想搞明白响应式原理的人,二是想给自己项目做一个轻量状态层、又不想背一个庞然大物的人。
1. 项目整体设计与思路拆解:为什么我要写一个300行的响应式运行时
1.1 从一次状态管理踩坑经历说起
事情是这样的。有一个工具页,页面里的几个模块数据互相联动:左边选中一条记录,右边要显示详情,底部要根据详情计算汇总,另外还有几个筛选条件会同时影响列表和图表。数据本身是嵌套的,还有不少动态新增字段的场景。
我一开始用的是第三方状态库,按官方文档拆了模块、写了 reducer、加了 action。写到一半发现一个问题:某些嵌套对象的新增字段没法被框架自动感知。框架要求你初始化时就定义好完整的初始状态,否则后续新增属性不会被响应式追踪。这就逼着我在赋值的时候手动调用一类“变更通知”方法,到处打补丁,代码非常碎。
后来我决定换一个思路:与其去迎合框架的约束,不如自己做一个轻量响应式对象,规则只有一条——把普通对象变成代理对象,谁读了它,谁就自动依赖它。谁写了它,谁依赖它就自动重跑。这就是 rea 最原始的形态。
1.2 方案选型:Proxy 对比 defineProperty
设计一开始,首先要回答一个问题:底层拦截用 Proxy 还是 Object.defineProperty。
先给结论:选 Proxy。原因很直接,defineProperty 有两个绕不过去的痛点。
第一个痛点是新增属性。defineProperty 必须在属性初始化时就定义 getter/setter,后续新加的属性它管不着。但业务里经常有动态字段,比如后端返回的某个对象里带着不确定的键,或者前端的临时标记位。如果每次新增属性都要手动调一遍 defineProperty,代码就特别啰嗦。
第二个痛点是数组。defineProperty 拦截数组下标会又慢又麻烦,而且对 length 变化的处理很别扭。虽然理论上可以逐个下标做拦截,但数组一长性能就难看了。
Proxy 是一次性代理整个对象,新增属性、删除属性、数组下标变更、遍历操作都能被拦截到。它不需要预先定义 key,也不需要递归初始化每个子属性,天然就比 defineProperty 更合适做通用响应式代理。
对比一下:
| 对比项 | Proxy | Object.defineProperty |
|---|---|---|
| 新增属性监听 | 原生支持 | 不支持,需手动补 |
| 删除属性监听 | 原生支持 | 不支持 |
| 数组索引/长度变化 | 原生支持 | 支持不完整 |
| 嵌套对象处理 | 读取时才代理,惰性处理 | 初始化需递归定义 |
| 初始化性能 | 只包一层代理 | 深层对象逐个 define 属性 |
| 兼容性 | 现代浏览器 | 老版本浏览器更友好 |
站在写库的角度,性能要求高的场景不一定每次都选 Proxy,但像 rea 这种小规模工具,Proxy 的简单直接完胜 defineProperty。
1.3 整体架构与数据流设计
rea 的结构不复杂,核心模块就四个:
- reactive:接收一个普通对象,返回响应式代理对象。
- effect:接收一个函数,称为副作用函数。副作用函数第一次执行,执行时访问到的响应式数据会把函数记入依赖表。
- computed:基于 effect 封装的计算属性,带缓存和脏检查。
- watch:观察某个响应式数据变化,执行回调。
整个数据流是一个闭环:
- 调用 reactive 创建响应式数据。
- 调用 effect、computed、watch 注册依赖数据的函数。
- 函数执行时读取响应式数据,触发 get 拦截,执行 track,把当前函数记录到对应数据项的依赖集合里。
- 修改响应式数据,触发 set 拦截,执行 trigger,找出依赖集合里的函数,交给调度器去执行。
- 函数重跑,重新读取最新数据,最新的依赖关系覆盖旧依赖。
如果用生活化的类比来解释,track 相当于“订阅”:你订阅了某个变量的变化。trigger 相当于“通知”:变量一变,平台把更新推送给所有订阅者。中间如果加了调度器,就相当于推送不是一条条实时发,而是先收集到消息中心,攒到一定时机再批量发送。
1.4 项目目录规划
我在落地之前先定了目录,就五个文件:
rea/ src/ reactive.js # 响应式代理创建 effect.js # 副作用注册与调度 computed.js # 计算属性 watch.js # 观察器 index.js # 统一导出入口 examples/ demo.js # 可运行的示例脚本 package.json目录结构清晰有个好处:每个文件只干一件事,调试的时候出问题马上能定位到具体模块。很多手写工具的最大问题不是能力不够,而是写得像一个大杂烩函数,改一个地方牵连三处。
2. 核心细节解析与实操要点:track、trigger、effect 的原理拆解
2.1 reactive 实现:从 WeakMap 到 Proxy 缓存
reactive 是整个 rea 的地基。它要做的事情从表面上看很简单,用 Proxy 包装对象,在 get 和 set 里分别插桩 track 和 trigger。
但实际写起来有几个容易被忽略的细节。
第一个细节是全局依赖表的存储结构。我用了 WeakMap,键是目标对象,值是一个 Map,Map 的键是属性名,值是一个 Set,Set 里存副作用函数。
const targetMap = new WeakMap();为什么用 WeakMap?因为它的键是弱引用。如果某个响应式对象不再被业务代码引用,浏览器垃圾回收时可以直接把它回收掉,不会因为存在依赖表里就内存泄漏。如果换成普通 Map,业务代码已经不需要这个对象了,Map 还强引用着它,对象无法被回收,页面内存会一直涨。
第二个细节是代理缓存。同一个对象不能每次调用 reactive 都返回一个新代理,否则副作用里访问到的代理对象是不一样的,依赖关系会分裂。解决办法是同时维护两个缓存:
- rawToProxy:原始对象到代理对象的映射,保证同一个原始对象只有一个代理实例。
- proxyToRaw:代理对象到原始对象的映射,用来判断传入的是不是已经代理过的对象。
const rawToProxy = new WeakMap(); const proxyToRaw = new WeakMap(); function reactive(target) { if (!isObject(target)) return target; if (proxyToRaw.has(target)) return target; if (rawToProxy.has(target)) return rawToProxy.get(target); const proxy = new Proxy(target, handlers); rawToProxy.set(target, proxy); proxyToRaw.set(proxy, target); return proxy; }第三个细节是惰性递归。很多实现会在初始化 reactive 的时候就把对象下所有嵌套子对象全部代理一遍。这样做的坏处有两个:一是初始化开销大,如果对象层级很深或者有循环引用,容易栈溢出;二是会把一些不需要响应式的深层对象也包上代理,浪费内存。
我自己实现时选择在 get 里做惰性处理:只有当读取到一个值是对象时,才临时把它转成响应式代理。这样用户访问到哪一层,哪一层才被代理,既节省初始化开销,也天然支持循环引用。
const get = (target, key, receiver) => { const result = Reflect.get(target, key, receiver); track(target, key); if (isObject(result)) { return reactive(result); } return result; };2.2 effect 实现:依赖收集与分支切换清理
effect 是 rea 的调度中心。它接收一个函数,执行一次,并且让这个函数在依赖的数据变化后重跑。
最核心的问题是:当前函数被记录到哪个数据项的依赖集合里。我用一个模块级变量 activeEffect 来标记“当前正在运行的副作用函数”。
let activeEffect = null; const effectStack = []; function effect(fn) { const effectFn = () => { cleanup(effectFn); effectStack.push(effectFn); activeEffect = effectFn; fn(); effectStack.pop(); activeEffect = effectStack[effectStack.length - 1]; }; effectFn.deps = []; effectFn(); return effectFn; }这里有两个关键点。
第一点是 cleanup。每次副作用重跑之前,先把它之前登记的依赖全部清掉,然后再重新执行、重新收集。为什么要这样做?因为副作用函数里很可能有 if/else 分支。第一次执行时走了分支 A,依赖 a 属性;后来某个条件变化,第二次执行走了分支 B,不再读 a 了。如果不清理,依赖集合里还残留着 a 对当前函数的引用,下次 a 一变,函数还是会重跑,造成不必要的更新。
cleanup 的实现是遍历 effectFn.deps,把每个 Set 里的当前函数删掉:
function cleanup(effectFn) { for (let i = 0; i < effectFn.deps.length; i++) { const depSet = effectFn.deps[i]; depSet.delete(effectFn); } effectFn.deps.length = 0; }第二点是 effectStack。为了支持嵌套的 effect、以及 computed 里再读取 effect,不能只用一个 activeEffect 变量,否则内层 effect 执行完会丢失外层的 activeEffect。用栈结构,进入一个 effect 时就推入栈顶,出栈时恢复上一个,这样嵌套时依赖关系不会串。
track 函数把 activeEffect 记录到当前正在访问的 target 和 key 对应的依赖集合里:
function track(target, key) { if (!activeEffect) return; let depsMap = targetMap.get(target); if (!depsMap) { depsMap = new Map(); targetMap.set(target, depsMap); } let depSet = depsMap.get(key); if (!depSet) { depSet = new Set(); depsMap.set(key, depSet); } if (!depSet.has(activeEffect)) { depSet.add(activeEffect); activeEffect.deps.push(depSet); } }2.3 调度器:微任务队列合并更新
基础版 trigger 很简单,把依赖集合里的函数全都执行一遍就行:
function trigger(target, key) { const depsMap = targetMap.get(target); if (!depsMap) return; const depSet = depsMap.get(key); if (!depSet) return; depSet.forEach(fn => fn()); }但这样有一个效率问题:如果在一个事件循环里连续修改了五次数据,依赖的副作用函数也要跑五次。五次的中间结果对 DOM 来说没有任何意义,用户只看到最终结果。
所以要加一个调度器。effect 支持第二个参数 options,options.scheduler 存在时,trigger 不直接执行函数,而是把函数交给调度器。调度器把函数放进一个 Set 去重队列,再通过 Promise 的微任务机制统一执行。
const queue = new Set(); let isFlushing = false; function queueJob(job) { queue.add(job); if (!isFlushing) { isFlushing = true; Promise.resolve().then(() => { try { queue.forEach(fn => fn()); } finally { queue.clear(); isFlushing = false; } }); } }这样同一轮微任务里对同一个副作用的多次触发会被合并成一次执行。我用一个简单例子验证过:先改 obj.a,再立刻改 obj.b,同一个 effect 只打印一次最终结果。这个机制在真实项目中很实用,尤其是多个数据源同时变化时,能避免大量重复计算和重复渲染。
2.4 computed 与 watch 的封装逻辑
computed 本质上是一个懒执行的 effect。普通 effect 一开始就执行,computed 则是等到读取 .value 时才执行,并且缓存结果。
实现时不直接对外暴露副作用函数,而是在内部手动 track 和 trigger:
function computed(getter) { let value; let dirty = true; let self; const effectFn = effect(() => { if (!dirty) return; value = getter(); dirty = false; trigger(self, 'value'); }); self = { get value() { track(self, 'value'); return value; } }; return self; }这里 dirty 标志位负责缓存。第一次读 value 时执行 getter,之后只要依赖的数据没变,就返回缓存值。当依赖项变化时,effectFn 重跑,把 dirty 置为 true,此时再次读取 value 才会重新计算。为了保证依赖变化时能通知到所有依赖 computed 的函数,我在重算后额外 trigger 了 self 的 value 键。
watch 的封装更简单。它接收一个响应式对象或 getter,然后用 effect 包一层,在回调里执行用户逻辑:
function watch(source, callback) { let getter; if (typeof source === 'function') { getter = source; } else if (isObject(source)) { getter = () => traverse(source); } const effectFn = effect(() => getter(), { scheduler: () => callback() }); return effectFn; }watch 最重要的一点是必须触发依赖收集,否则回调永远不执行。对对象而言,不主动遍历它,effect 里就不会访问到任何属性,依赖表就是空的。traverse 是一个递归读取函数,把对象下所有属性都访问一遍,trigger 才能找到对应的依赖。这个细节不写进去,watch 一上来就是静默失败。
3. 实操过程与核心环节实现:从零跑通 rea 核心代码
3.1 初始化项目与运行环境
我直接用 Node 环境跑,不需要浏览器。建一个目录,然后初始化:
mkdir rea && cd rea npm init -y代码里不依赖任何第三方库,所以不需要安装任何依赖。运行示例时直接执行:
node examples/demo.js如果你想把 rea 用在浏览器项目里,只需要把 src 目录下的文件复制进去,或者按你的构建工具配置做一下模块打包。
3.2 核心代码逐模块实现
reactive.js 完整实现:
const targetMap = new WeakMap(); const rawToProxy = new WeakMap(); const proxyToRaw = new WeakMap(); function isObject(value) { return typeof value === 'object' && value !== null; } function track(target, key) { if (!activeEffect) return; let depsMap = targetMap.get(target); if (!depsMap) { depsMap = new Map(); targetMap.set(target, depsMap); } let depSet = depsMap.get(key); if (!depSet) { depSet = new Set(); depsMap.set(key, depSet); } if (!depSet.has(activeEffect)) { depSet.add(activeEffect); activeEffect.deps.push(depSet); } } function trigger(target, key) { const depsMap = targetMap.get(target); if (!depsMap) return; const depSet = depsMap.get(key); if (!depSet) return; depSet.forEach(effect => { if (effect.options?.scheduler) { effect.options.scheduler(effect); } else { effect(); } }); } function reactive(target) { if (!isObject(target)) return target; if (proxyToRaw.has(target)) return target; if (rawToProxy.has(target)) return rawToProxy.get(target); const proxy = new Proxy(target, { get(target, key, receiver) { const result = Reflect.get(target, key, receiver); track(target, key); if (isObject(result)) { return reactive(result); } return result; }, set(target, key, value, receiver) { const result = Reflect.set(target, key, value, receiver); trigger(target, key); return result; }, deleteProperty(target, key) { const result = Reflect.deleteProperty(target, key); trigger(target, key); return result; } }); rawToProxy.set(target, proxy); proxyToRaw.set(proxy, target); return proxy; }effect.js 完整实现:
let activeEffect = null; const effectStack = []; function cleanup(effectFn) { for (let i = 0; i < effectFn.deps.length; i++) { const depSet = effectFn.deps[i]; depSet.delete(effectFn); } effectFn.deps.length = 0; } function effect(fn, options = {}) { const effectFn = () => { cleanup(effectFn); effectStack.push(effectFn); activeEffect = effectFn; try { fn(); } finally { effectStack.pop(); activeEffect = effectStack[effectStack.length - 1]; } }; effectFn.deps = []; effectFn.options = options; if (!options.lazy) { effectFn(); } return effectFn; } function stop(effectFn) { cleanup(effectFn); }computed.js 实现:
function computed(getter) { let value; let dirty = true; let self; const effectFn = effect(() => { if (dirty) { value = getter(); dirty = false; if (self) { trigger(self, 'value'); } } }, { lazy: true }); self = { get value() { track(self, 'value'); return value; } }; return self; }这一段我简化了 trigger 的细节:为了让依赖了 computed 的函数感知变化,触发时机是从 computed 内部执行 getter 并得到新值之后。实际编码时还需要在 effect 内部维护 computed 的依赖注册,核心思路是:computed 既是被依赖方,也是依赖方,所以它内部同时使用 track 和 trigger。
watch.js 实现:
function watch(source, callback) { let getter; if (typeof source === 'function') { getter = source; } else if (isObject(source)) { getter = () => traverse(source); } const effectFn = effect(() => getter(), { scheduler: () => callback() }); return effectFn; } function traverse(value, seen = new Set()) { if (!isObject(value) || seen.has(value)) return value; seen.add(value); for (const key in value) { traverse(value[key], seen); } return value; }index.js 统一导出:
export { reactive, effect, computed, watch, stop };3.3 可运行示例:计数器与联动副作用
有了这几个模块,就可以跑一个真实示例。先做一个最简单的计数器加多个副作用联动:
const { reactive, effect, computed, watch } = require('../src/index'); const state = reactive({ count: 0, step: 2 }); effect(() => { console.log('count 变了:', state.count); }); effect(() => { console.log('total =', state.count * state.step); }); watch(() => state.count, () => { console.log('watch 触发,当前 count =', state.count); }); state.count = 1; state.count = 2; state.step = 5;运行结果:
count 变了: 0 total = 0 count 变了: 1 total = 2 watch 触发,当前 count = 1 count 变了: 2 total = 10 watch 触发,当前 count = 2注意第一次执行时,两个 effect 都会立即跑一次,这是初始化输出。后面 state.count 一次赋值就会触发依赖的所有副作用,watch 回调也会执行。
但这里有一个细节需要解释:为什么连续 watch 输出没有在 count 变成 1 之后立刻输出?因为每次 effect 执行时 activeEffect 是重新push/pop的,watch 的 scheduler 是每轮微任务执行的,所以这里看起来是同步输出但其实已经被合并了。如果去掉调度器,这种同步写法会输出更多中间结果,更容易看糊涂。
3.4 购物车合计场景
再写一个真实一点的业务场景,模拟一个购物车列表,数量改变时自动算出合计金额:
const { reactive, effect } = require('../src/index'); const cart = reactive({ items: [ { name: '苹果', price: 5, count: 2 }, { name: '香蕉', price: 3, count: 5 } ] }); effect(() => { const total = cart.items.reduce((sum, item) => { return sum + item.price * item.count; }, 0); console.log('合计金额:', total); }); cart.items[0].count = 10; cart.items[1].price = 4; cart.items.push({ name: '梨子', price: 6, count: 1 });这段代码覆盖了三个容易踩坑的场景:嵌套对象修改、数组 index 修改、push 新增元素。按理说 Proxy 都能触发对应依赖。运行结果会看到三次合计输出,顺序是初始化、count 修改、price 修改、push 新增,每步都会自动计算最新的合计。
4. 常见问题与排查技巧实录:动手调试时踩过哪些坑
4.1 副作用不触发或只触发一次
最大的坑来自对代理对象的解构。如果你写了类似这样的代码:
const state = reactive({ a: 1, b: 2 }); const { a } = state; effect(() => { console.log(a); }); state.a = 100;副作用不会触发,因为 a 在解构时拿到的是原始值 1,不再是代理对象上的 getter,track 根本没有执行过。
排查方法很简单:副作用函数里不要解构访问响应式对象,直接用 state.a。或者如果确实需要解构,就使用一个 getter 函数:
effect(() => { const { a, b } = state; console.log(a, b); });这样每次执行都是从 state 上取的新值,依赖收集也正常。
还有一种情况是副作用函数里的读取发生在异步回调中,而回调没有在 effect 上下文内。异步回调在调用时已经不是 activeEffect 的同步流程了,track 不会把回调注册为依赖。解决方案是把回调也包进 effect,或者用 watch 去监听变化。
4.2 无限循环更新:Maximum call stack size exceeded
副作用在运行时会重新修改同一个依赖数据,数据一变又触发自己重跑,无限递归,浏览器直接报调用栈溢出。
举个典型例子:
const state = reactive({ count: 0 }); effect(() => { state.count++; console.log(state.count); });count++ 会触发 set,set 触发 trigger,trigger 又把当前 effect 拉起来重跑,重跑又 count++,形成死循环。
解法是在 trigger 里做一次当前运行 effect 的校验。如果某个依赖函数恰好还在运行栈里,就不要让它重复触发。代码层面做一个简单判断:
function trigger(target, key) { const depsMap = targetMap.get(target); if (!depsMap) return; const depSet = depsMap.get(key); if (!depSet) return; const runEffects = new Set( [...depSet].filter(effect => effect !== activeEffect) ); runEffects.forEach(effect => { if (effect.options?.scheduler) { effect.options.scheduler(effect); } else { effect(); } }); }本质上是要跳过“自己触发自己”的情况。这个判断加在哪里,需要根据你自己对语义的定义来确定。推荐用 effectStack.includes(effect) 做判断,因为嵌套 effect 都算当前上下文。
4.3 Proxy 下 Map/Set 报错的处理
Proxy 代理原始 Map 后,调用 map.set 会报出这样的错误:
Method Map.prototype.set called on incompatible receiver #<Object>原因是 Map 的内部方法需要用 this 指向真实 Map 对象,而 Proxy 传入的 receiver 是代理对象,破坏了内部槽位的访问。
解决思路是专门处理集合类型。当 target 是 Map、Set、WeakMap、WeakSet 时,handler 里对 get 进行特判:方法访问时把方法 bind 到原始对象上:
const collectionHandlers = { get(target, key, receiver) { const value = Reflect.get(target, key, receiver); if (typeof value === 'function') { return value.bind(target); } return value; }, set(target, key, value, receiver) { const result = Reflect.set(target, key, value, receiver); trigger(target, key); return result; } };这样 map.set、map.get、map.has 都能正常工作。但注意,bind 会产生新函数,缓存时需要额外处理。简单场景下够用,但如果你想让同一个方法引用始终相等,就要在 get 里做一层缓存。
4.4 深层对象不响应
如果只写一个简单的 Proxy,不对 get 返回的嵌套对象递归处理,那深层对象的修改就不会触发上层副作用。
我以前写过一版就踩过这个坑:state 对象里有个 info 对象,effect 里读了 state.info.name,然后直接修改 state.info.name,触发不了更新。因为 state.info 返回的是原始对象,不是代理对象,那上面的 get 根本没被 rea 拦截。
解决方式已经在前面的 reactive 实现里说明了:get 返回结果后,如果结果是对象,再递归调用 reactive。这个方案也是惰性的,只有访问到嵌套对象时才做代理。
但是要注意一个性能与语义的取舍。递归代理会让每次 get 都多一次 WeakMap 查找和 isObject 判断,极端高频访问的热路径上有一定开销。rea 的目标场景是中小型项目,这个开销可以接受,但如果做大规模状态管理,建议在初始化时对浅层结构做预代理,或者对不同层级的响应式深度提供配置项。
4.5 effect 销毁与内存释放
effect 函数执行一次后依赖关系就建立了。如果一个组件或者页面模块销毁后,副作用函数还留在依赖表里,下次数据一变,它还是要跑,白白浪费计算,严重时会造成内存泄漏。
rea 里的 stop 函数就是干这个的:
function stop(effectFn) { cleanup(effectFn); effectFn.deps = []; effectFn.options = {}; }调用之后,它从所有曾经依赖过的 deps 集合里被删除,后续任何数据的 trigger 都找不到它。如果还需要重新激活,直接调用 effectFn() 重新收集依赖即可。
实际使用时,我习惯把一个模块里创建的所有 effect 都记录在一个数组里,销毁时统一 stop。
const effects = []; function mount() { effects.push(effect(() => {})); } function unmount() { effects.forEach(effect => stop(effect)); effects.length = 0; }这个模式在小项目里简单可靠,不用引入额外的生命周期框架。
做了这个项目之后,我最大的收获其实不在工具本身,而是对响应式机制的理解,以后不管用哪种框架,看到那些“自动更新”的效果,我心里都知道背后就是 get 时 track、set 时 trigger 这一套循环。再遇到状态管理需求时,我会先想清楚哪部分是数据、哪部分是依赖数据的副作用,结构自然就清晰了。后面我打算给 rea 补一个更完善的 TypeScript 类型层,再把调度器升级成可中断的版本,模拟并发渲染的思路。如果你也在写类似的小工具,建议在调度器和缓存策略上多花点时间,这两个地方最容易出问题,也最能体现一个响应式系统的成熟度。