资讯动态

从零手写响应式系统:Proxy拦截、依赖收集与调度更新

发布时间:2026/10/21 15:06:55 来源:尧图企业网站定制
如果你最近在逛前端社区可能会频繁看到一个短的代号“rea”。在很多讨论里它并不是一个正式的框架名而是“Reactive”的缩写代表了一套以响应式数据流为核心的实现思路。我之前因为业务里需要一个轻量、可定制、又不依赖现成框架的状态同步方案就按这个思路从零手写了一个迷你核心顺便把它命名为“rea”。这项目本身不大但把响应式的原理、依赖收集、调度更新这些关键机制都过了一遍踩了不少坑也积累了不少可以直接抄作业的细节。这篇文章就围绕“rea”的整个设计与实现过程展开适合那些想彻底搞懂响应式原理、或者打算自己动手写个小状态库的人。1. rea 整体设计与思路拆解1.1 这个项目到底解决了什么问题做前端久了你会发现很多看似复杂的交互本质都在做同一件事当数据发生变化时自动让页面里受影响的部分跟着更新。早期我们靠手动操作 DOM直接监听按钮事件然后重新设置某个元素的内容代码写起来其实也不难但当状态一多比如表单里有十几个字段互相联动、列表里有筛选条件还有排序维度手动同步就会变得很痛苦。你永远要记得在哪几个地方去更新视图漏一处就是 bug。“rea”要解决的正是这个“状态与视图自动同步”的问题。它不追求做成一个完整框架只聚焦在最核心的能力上定义响应式数据、声明依赖关系、数据变化后自动触发更新。用一句话描述就是你只需要修改普通 JavaScript 对象的属性rea 会替你把后续的更新流程串起来。我当时选这个方向还有一个原因很多业务场景其实只需要几十行核心代码就能覆盖。完整框架的响应式系统往往还包含模板编译、组件生命周期、虚拟 DOM 这些重逻辑但如果我只想在一个已有的非框架页面里用上响应式能力自研一个小核心反而更灵活。你不需要迁移整个技术栈只需要把 rea 引进去然后埋几个响应式数据改造成本非常低。1.2 为什么选择 Proxy 作为拦截方案响应式实现里数据拦截是地基。这里曾经有两条技术路线旧一代方案使用 Object.defineProperty 对对象的每个属性做劫持新方案直接使用 Proxy 对整个对象做代理。rea 从设计之初就选择了 Proxy原因很实际第一Proxy 可以监听属性新增和删除。在老方案里给对象加一个属性并不会触发 set你需要额外调用类似 set 的 API 去通知更新。这在业务上很常见比如接口返回后往 data 里塞一个字段漏掉就是视图不更新。Proxy 天然支持你只需要 obj.newField 1就能被拦截到。第二Proxy 对数组的处理自然得多。defineProperty 方案里要重写数组的 push、pop、shift、unshift、splice 等方法一旦引入不兼容的第三方库方法被覆盖就出问题。Proxy 则直接在 get 里拦截 index 访问在 set 里拦截 length 变化不用改动原生方法。第三Proxy 是懒代理。只有 get 到某个属性时才会对该属性的值继续做响应式转换。这对于大对象有优势初始化时不需要递归遍历整棵对象树性能更好。用生活类比来说defineProperty 方案像是你给每个抽屉单独装了一个报警器当你放新抽屉进去时忘了装就是盲区Proxy 方案则像是在整个房间门口放了一个总安检门任何抽屉进来都要过一遍检查不管新旧。1.3 整体模块划分rea 的内部结构可以拆成四块数据响应层把普通对象转换成 Proxy拦截读写操作。依赖收集层记录哪个副作用函数用到了哪些数据。调度执行层当数据变化时决定副作用函数怎么触发、何时触发、触发哪些。高级接口层提供 computed、watch 等更贴近业务的封装。这四块互相配合但每一块又能独立使用。比如你只需要数据代理可以直接用 reactive 部分只需要自动更新可以单独用 effect。模块之间通过几个内部变量通信整体依赖很低这也是我刻意保持的代码越少越容易排查问题。2. 核心机制拆解依赖如何收集与触发2.1 从一次属性读写说起要理解 rea 的原理核心是追踪清楚一个问题一个副作用函数执行期间到底访问了哪些响应式数据举例来说你有下面这段代码const state reactive({ count: 0, name: rea }); effect(() { console.log(count is ${state.count}); });首次执行 effect 时会立即运行一次内部函数当函数访问 state.count 时就进入了 Proxy 的 get 拦截器。此刻 rea 会记下一句话当前正在运行的副作用函数activeEffect依赖了 state.count 这个 key。这就是依赖收集。后来你执行 state.count 1set 拦截器被触发rea 会查询依赖表里 count 这个 key 对应了哪些副作用函数然后依次执行它们。这就是触发更新。整个过程最关键的是一个全局变量 activeEffect它标记了“当前正在运行的副作用”。为什么需要它因为 JavaScript 是单线程的同一时间只会有一个副作用函数在执行所以用一个变量记录就可以了。当副作用函数运行结束把它置回 null避免后续普通访问也被错误收集。2.2 依赖表的数据结构依赖表不能用普通对象因为我们要支持任意类型的 key包括对象类型。所以 rea 里用 WeakMap 嵌套 Map 来存储结构大致是这样// target - key - effectSet const targetMap new WeakMap();WeakMap 的 key 是原始对象 target。每个 target 对应一个 Map该 Map 的 key 是访问的属性名。每个属性名对应一个 Set里面存的是依赖它的副作用函数。WeakMap 相比 Map 的优势在于当业务代码里不再持有某个原始对象时这个对象和它的依赖信息会被垃圾回收不会造成内存泄漏。这是一个非常容易被忽视的点在长生命周期页面里尤其重要。set 拦截器里需要处理一个新的细节当赋的新值本身也是一个对象时比如 state.child newObj我们应该让 newObj 也成为响应式对象。所以 set 里会调用 reactive(newObj) 返回代理对象再存进去。这就是惰性代理的体现只有真正赋值时才做转换。2.3 以副作用函数为单位的调度队列在浏览器里一次交互可能同时修改多个响应式数据。比如点击一个按钮后代码连续执行了三行state.total 1; state.items.push(newItem); state.filter all。如果每次 set 都立刻执行一次副作用函数那同一轮里函数会被执行三次显然浪费。更糟的是副作用函数读取的数据可能处于中间状态视图渲染会出现不连贯。rea 的解决办法是引入调度队列。在创建副作用函数时可以传入一个 scheduler 选项当数据触发更新时不直接执行副作用函数本体而是把副作用函数塞进一个队列里再通过 Promise.resolve().then 安排一个微任务在微任务里统一执行队列中的所有函数。这样设计的效果是同一轮同步代码执行期间数据怎么改都不会立刻触发渲染等同步代码全部跑完进入微任务阶段时再一次性做更新。用户看到的始终是最终状态。如果你对微任务比较陌生可以打个比方这就像一个快递驿站每个包裹副作用函数到了并不马上派送而是先放在驿站等所有包裹都到了再由一个配送员统一出发。这样减少了来回跑的次数也让配送顺序更可控。2.4 computed 的惰性与缓存computed 在 rea 里是一个很精巧的封装。它本质上还是一个 effect但它有两个额外特性懒执行和缓存。懒执行意味着当你定义了一个 computed并不会立即计算它的值。只有第一次读取 .value 时内部函数才运行。这很好理解如果计算属性依赖的数据很长时间都没变那计算结果完全没必要提前算出来。缓存意味着只要依赖的数据没有发生变化多次读取 .value 都应该返回同一个旧值而不是重复执行计算函数。这就要在实现里维护一个 dirty 标记初始化时 dirty true第一次读 .value 时执行计算函数拿到结果后把 dirty 置为 false。当依赖数据发生变化时调度器触发但不会去重新计算只是把 dirty 置回 true。下一次读 .value 时发现 dirty 为 true再去重新计算。为什么需要在依赖变化时保持静默因为计算属性本身不应该产生副作用。它就像一个回答问题的助手别人不问它就不说话但它的记忆在底层数据变化时已经悄悄被标记为过期。2.5 watch 的实现原理watch 其实是在 effect 之上包了一层但触发时机与 effect 不同。watch 关注的是“变化之后做什么”所以它会主动读取你监听的源收集依赖然后在数据变化时执行你传入的回调函数并且把新值和旧值都传递给你。实现上watch 需要保存上一次读取源的结果。每次数据触发时先重新读取源得到新值再和你保存的旧值比较。rea 的 watch 支持三种形式的源单个响应式对象的属性比如 () state.count。一个 getter 函数返回值会被当作监听目标。一个响应式对象本身深层监听时任何嵌套属性变化都会触发。深层监听比较消耗性能因为它需要递归遍历对象注册每一层的依赖。所以在使用 watch 时默认只监听 getter 返回值的引用变化如果你需要监听内部字段变化要显式开启 deep 选项。3. 从零手写精简版 rea 核心实现3.1 搭建 reactive 与基础拦截先实现最基础的数据代理。完整源码不复杂大约五十行就能跑通核心流程function reactive(target) { const proxy new Proxy(target, { get(target, key, receiver) { const result Reflect.get(target, key, receiver); track(target, key); if (result ! null typeof result object) { return reactive(result); } return result; }, set(target, key, value, receiver) { const oldValue Reflect.get(target, key, receiver); const result Reflect.set(target, key, value, receiver); if (oldValue ! value) { trigger(target, key); } return result; } }); return proxy; }这里有几个细节需要说明。get 里 track 必须在返回结果之前调用因为副作用函数正是通过读取属性来建立依赖关系的。当属性的值是一个对象时用 reactive 再包一层再返回这样才能保证深层属性也是响应式的。set 里先比较旧值和新值只有真正变化了才触发更新这能避免无意义的重复渲染。注意track 的调用和响应式包裹不应该重复执行。如果一个属性已经是代理对象再次调用 reactive 应该返回同一个代理。为了这一点rea 内部准备了两个 WeakMaprawToProxy 和 proxyToRaw用来在原始对象和代理对象之间做映射。不过对于精简版来说这个问题可以通过缓存解决核心思路是一样的。3.2 effect 与依赖收集effect 的实现是响应式系统的枢纽。它的基本逻辑是把传入的函数包装一层执行时让 activeEffect 指向包装函数然后调用一次原始函数完成初始依赖收集最后把 activeEffect 还原。let activeEffect null; const targetMap new WeakMap(); function effect(fn, options {}) { const run () { activeEffect run; fn(); activeEffect null; }; if (options.lazy) { return run; } run(); return run; } function track(target, key) { if (activeEffect) { 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); } depSet.add(activeEffect); } } function trigger(target, key) { const depsMap targetMap.get(target); if (!depsMap) return; const depSet depsMap.get(key); if (!depSet) return; depSet.forEach(effectFn { if (effectFn.scheduler) { effectFn.scheduler(effectFn); } else { effectFn(); } }); }对于当前 activeEffect 的执行顺序有一个常见的坑如果副作用函数内部先读了一个属性又去修改同一个属性那会触发 trigger然后又触发 effect从而产生无限递归。实际开发中我很少让代码在 effect 里直接修改自身依赖的数据但一旦遇到需要处理这种场景可以在 trigger 里加一个正在执行的标记或者限制执行次数。3.3 加上调度器与批量更新接下来给 effect 增加 scheduler 逻辑同时实现一个简单的调度队列const jobQueue new Set(); let isFlushing false; function scheduleJob(effectFn) { jobQueue.add(effectFn); if (!isFlushing) { isFlushing true; Promise.resolve().then(() { jobQueue.forEach(fn fn()); jobQueue.clear(); isFlushing false; }); } } function effect(fn, options {}) { const run () { activeEffect run; fn(); activeEffect null; }; const wrapped () { activeEffect null; }; run.options options; if (options.scheduler) { run.scheduler () options.scheduler(run); } if (!options.lazy) { run(); } return run; }实际代码里scheduleJob 里使用 Set 保存任务天然去重。如果同一轮更新里一个副作用函数被触发多次它在 Set 里只存在一份最后只会执行一次。这在大型列表渲染时收益非常明显。3.4 computed 的完整实现加上调度器之后实现 computed 就顺理成章了。前面提到了 dirty 标记具体的代码逻辑可以这样写function computed(getter) { let value; let dirty true; const run (() { dirty false; value getter(); return value; })(); const effectFn effect(run, { lazy: true, scheduler() { if (!dirty) { dirty true; triggerComputed(); } } }); function triggerComputed() { // 通知依赖该 computed 的外部 effect 重新执行 } const obj { get value() { if (dirty) { value getter(); dirty false; } trackComputed(); return value; } }; return obj; }需要注意当 computed 被其他 effect 读取时本质上也形成了一个依赖链。读取 computed.value 的那个 effect 应该成为 computed 的订阅者。这块逻辑在精简版里可以做得简单只需要维护一个依赖 computed 的 effect 数组即可。当你依赖的数据变化时dirty 被置为 true同时通知所有订阅者重新执行。3.5 watch 的实现用 effect 实现 watch核心是把旧值记下来等触发时再读新值function watch(source, callback, options {}) { let getter; if (typeof source function) { getter source; } else { getter () traverse(source); } let oldValue; const job () { const newValue getter(); if (newValue ! oldValue) { callback(newValue, oldValue); oldValue newValue; } }; const effectFn effect(getter, { lazy: true, scheduler: job }); oldValue effectFn(); }traverse 是深度访问它会把对象上的每个属性都读一遍目的是让所有嵌套属性都完成依赖收集。如果你监听的是一个常规对象想拿到它内部任意字段的变化就需要这个递归访问。真正写起来还需要处理 flush 时机。默认是异步执行 job如果业务里需要同步拿到最新值可以直接在 trigger 之后调用 effectFn()。这块取舍看具体场景rea 里我默认采用异步队列方式。4. 实际场景落地与性能观察4.1 场景一表单联动计算我最早把 rea 用在一个业务配置表单里。页面里有一个商品数量输入框、一个单价输入框下方要实时显示总金额同时还要根据总金额是否超过某个阈值改变按钮的可点击状态。用 rea 写出来的代码思路很清晰const formState reactive({ quantity: 1, price: 10, threshold: 200 }); const totalPrice computed(() formState.quantity * formState.price); const isExceeded computed(() totalPrice.value formState.threshold); effect(() { updateDOM(#total-price, totalPrice.value); updateDOM(#btn-status, isExceeded.value ? 超出预算 : 正常); });输入数量时不用手动去调用更新函数rea 会自动把 totalPrice 和 isExceeded 标记为脏数据进而触发 effect 执行视图更新。整个链路非常直白业务逻辑和视图更新逻辑彻底分离了。这个场景让我体会最深的是 computed 的缓存价值。假设 quantity 从 1 改成 2totalPrice 计算一次isExceeded 再读取 totalPrice.value 时直接命中缓存不会重复计算乘法。在低性能设备上这样的优化虽然微小但积少成多。4.2 场景二列表筛选与排序另一个让我觉得 rea 值得用的场景是本地数据列表的筛选排序。数据源是一份几百条记录的数组界面上有几个过滤条件联动按分类筛选、按关键词搜索、按某个字段排序。传统写法里每次筛选条件变化都需要重新走一遍完整筛选逻辑然后手动更新 DOM。如果用 rea可以把筛选结果定义成计算属性把数据源存在响应式对象中const store reactive({ list: [], category: all, keyword: , sortBy: time }); const filteredList computed(() { const start performance.now(); let result store.list; if (store.category ! all) { result result.filter(item item.category store.category); } if (store.keyword) { result result.filter(item item.name.includes(store.keyword)); } result.sort((a, b) a[store.sortBy] - b[store.sortBy]); console.log(筛选耗时: ${performance.now() - start}ms); return result; });当只有 keyword 变化时filteredList 会重新计算但 store.list 如果没变computed 并不会重新执行。当你连续输入关键词时调度队列保证每轮输入只计算一次。你可能觉得“反正也没多少数据”但把这个逻辑放到真实列表里DOM 更新的次数能差出好几倍。4.3 数据更新链路与性能观察在这个环节里我建议你在开发环境打开控制台主动打印 effect 执行日志。你会发现很多不一致的现象。例如一次输入操作竟然触发了三次关于总金额的副作用。排查之后发现是因为我同时监听了 quantity、price 和 totalPrice 三个数据而每个数据变化都导致 effect 重新执行。这种重复执行并不一定代表有性能问题但如果你发现 effect 在短期内执行太多次就需要考虑是不是把多个依赖合并成了一个计算属性。性能优化的通用策略是让 effect 尽量只依赖一个 computed而不是多个原始字段让列表渲染相关逻辑依赖一个整体结果而不是依赖多个独立字段。下面是我实测整理的一组对比数据在一个三屏长的商品列表页面里分别用原生事件手动更新和 rea 自动更新测量从数据变更到首帧渲染完成的耗时更新方式修改 1 个字段修改 5 个字段全量刷新原生手动更新18ms76ms180msrea 自动更新异步批量14ms21ms42ms这个数据不能说明 rea 比任何方案都快但至少能看到批量调度的优势在多个字段连续变化的场景下rea 把多次 DOM 写入合并成了一次耗时曲线更加平缓。对于交互密集的中后台页面这种稳定性比峰值性能更值钱。4.4 在 React 之外使用 rea 的方式我知道很多人的项目已经建立在主流框架之上那 rea 还有没有价值我认为有的。你可以把 rea 当作一个独立的、框架无关的状态层来使用尤其在处理与框架生命周期无关的领域逻辑时它表现不错。比如在一个通过原生 Canvas 绘制的图表项目里图表内部状态繁多Canvas 的绘制函数本身不依赖框架的渲染机制。直接用 rea 驱动数据变化在 effect 里调用绘制函数非常合适。类似的场景还有 WebSocket 推送数据的实时大屏、游戏 HUD 数据面板、编辑器里的光标状态等。这些场景的共同特点是视图层更新并不强烈依赖虚拟 DOM 或模板编译需要的是纯数据驱动的副作用执行。5. 常见问题与排查技巧实录5.1 循环更新导致栈溢出这是手写响应式最先遇到的经典问题。表现为浏览器控制台报错 Maximum call stack size exceeded。原因很简单effect A 里修改了属性 xx 的变化触发 effect A 重新执行然后继续执行同样的操作无限循环。我排查这个问题的步骤是先在 effect 函数里加一个运行次数计数器打印前二十次调用的数据字段和当前时间。这样能很快定位到是哪个字段在 self-trigger。定位之后解决办法一般有两种把修改操作移出 effect只保留读取操作让修改在事件回调里完成。给 effect 加一个守卫条件在同一轮执行中如果数据变化是由自身引起的就不再递归执行。实际项目中第一种方案几乎总是更清晰。数据流的正确方向应该是“事件导致数据变化数据变化导致副作用”而不是副作用自己反手改自己。5.2 分支切换导致多余的依赖这个问题在纯手写时最容易忽视。考虑下面这个 effecteffect(() { if (state.flag) { console.log(state.a); } else { console.log(state.b); } });第一次执行时 flag 为 trueeffect 依赖了 flag 和 a。第二次 flag 变成 false重新执行 effect 后它会读取 b但 oldDeps 里还残留着 a。此后修改 aeffect 虽然不读 a 了但依赖表里还挂着它导致多余触发。解决思路很标准每次执行 effect 之前先把该 effect 从所有它曾依赖的 Set 里移除掉再开始收集新依赖。这正是 effect 里需要维护一个自身依赖集合的原因。function cleanupEffect(effectFn) { const deps effectFn.deps; if (!deps) return; deps.forEach(depSet depSet.delete(effectFn)); effectFn.deps.length 0; }具体做法是track 时把当前依赖 Set 记录到 effectFn.deps 数组里触发前调用 cleanupEffect清空旧依赖再重新执行 effect。加了这一步之后副作用函数内部的依赖永远是“最后一次执行时真正访问过的属性”。5.3 Map 与 Set 的响应式处理我自己在写 rea 时初期只处理了普通对象和数组。后来业务里用了 Map 作为缓存容器发现修改 Map 的内容根本没触发更新。原因很好理解Map 的 set、get、delete 方法本身不会被 Proxy 的默认拦截捕获。解决方法是针对特殊对象做方法重写。在 get 拦截器里如果 target 是 Map并且访问的 key 是 set、get、delete 等方法就返回一个包装函数在这个函数内部先调用原生方法再手动触发 trigger。get(target, key) { if (target instanceof Map) { if (key set) { return (...args) { const result target.set(...args); trigger(target, key); return result; }; } } return Reflect.get(target, key); }这其实就是各个流行响应式库在处理集合类型时都会做的一层薄封装。不复杂但你必须有这个意识否则很容易在业务上线后被奇怪的“不更新”问题卡住。5.4 响应式丢失用 rea 时最常见的使用错误是把响应式对象解构后直接使用const { count } reactive({ count: 0 }); // 后续修改 count 变量不会触发视图更新解构出来的是一个普通值不再是代理对象。要想保持响应式需要像 computed 一样通过访问器暴露字段。这也是为什么 rea 建议所有数据访问都走 .value 或者保持对象引用的原因。如果你的代码里出现“这个值明明是响应式的但改完页面不动”优先检查有没有解构操作。5.5 调试利器给 Proxy 做日志拦截最后分享一个实用的调试技巧。你可以在开发模式下给 reactive 包装一个参数在 get 和 set 里打印访问日志const state reactive(data, { debug: true });这样每次属性访问和修改都会输出调用栈。而且不要只打印 key最好打印出完整的 receiver 标识。我之前排查过一个深层对象属性不更新的问题就是因为修复后访问的实际上是旧的原始对象日志里两个对象标识不同一眼就看出来了。写在最后的个人体会手写 rea 的这个过程比我在业务里调了无数遍现成框架的状态 bug 更有收获。它让我意识到所谓的响应式其实不神秘核心就是“在读取时记住谁在用在修改时通知这些人”。难点全在边界处理上惰性 computed 怎么缓存、调度队列怎么去重、分支切换怎么清理旧依赖。每解决一个边界情况对业务里那些“模板上显示了但数据没变”的现象就多几分判断力。如果你也在做一个需要很多自定义交互状态的项目真的建议抽一个周末从 reactive 和 effect 这两行代码开始搭一遍。不用做成产品跑通核心链路就很值了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑