恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

手写响应式内核:从依赖收集到批量更新的原理与工程实践

  • 首页
  • 资讯中心
  • /
  • 手写响应式内核:从依赖收集到批量更新的原理与工程实践

相关资讯

Linux上搭建Hadoop伪分布式开发环境:从零到WordCount实战 2026/10/21 22:59:37
RAG知识库从零搭建:文档切块、向量检索与工程实践指南 2026/10/21 22:59:37
知识工作插件全攻略:从采集整理到输出检索的自动化闭环 2026/10/21 22:59:37

最新资讯

rea项目极简架构实战:响应式与实时系统的设计、实现与避坑指南
大数据人才需求分析毕设实践:从招聘数据到可视化系统
若依框架AI代码生成不稳定?用Agent Skill三层结构+五步法搞定
压缩空气储能与热网热惯性联合优化调度:热电联产系统建模与求解
电商智能运营体系从0到1:架构设计、落地步骤与避坑指南
从模糊标识符到可复用模块:以rea为例的工程实践

今日推荐

电商智能运营体系从0到1:架构设计、落地步骤与避坑指南
压缩空气储能与热网热惯性联合优化调度:热电联产系统建模与求解
若依框架AI代码生成不稳定?用Agent Skill三层结构+五步法搞定

本周热门

基于GMM的语音识别系统原理与MATLAB实现详解
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

本月精选

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)

手写响应式内核:从依赖收集到批量更新的原理与工程实践

发布时间:2026/10/21 22:59:37
手写响应式内核:从依赖收集到批量更新的原理与工程实践 rea 这名字听起来像个半成品其实就是我手头这个小工具集的代号。reactive 前三个字母做着做着就懒得换名字了。它要干的事情很明确把响应式数据绑定、依赖收集、自动派发更新这一整套能力从那些翻来覆去写的业务代码里抽出来做成一个无依赖、可插拔、能随时拷进任何前端项目的轻量内核。大多数时候我们写页面离不开框架自带的响应式系统。但总有几个场景会让人难受想在框架之外保持一份全局状态、想写原生页面又不想为一个小联动引入整套重型状态库、或者干脆想把响应式原理彻底吃透然后自己定制一套。rea 就是冲着这些场景去的。这篇东西不吹不黑把我从设计到实现再到踩坑的全过程都摊开讲适合两类人看一类是想搞懂状态管理底层到底怎么转的开发者另一类是项目中实在不想被状态同步脏活反复折腾的实操党。1. 项目定位与设计思路拆解1.1 为什么我受不了手动同步状态以前写业务页面最烦的就是“数据变了视图要跟着变”这件事。尤其那种多组件共享同一份数据的情况接口回来了要刷新列表、搜索框输入要过滤数据、用户勾选要联动表单一套操作下来全是setState、onChange、手动调刷新函数代码里到处是“拉数据进行页面同步”的重复动作。一两个页面还能忍十几个页面下来人就是人肉状态同步机。更要命的是同步逻辑散落在各处排查 bug 的时候根本不知道哪里漏了一次同步。我那时候就在想能不能把“数据变了我自动去更新该更新的地方”这层逻辑彻底从业务里剥离出来。后端每次写“观察者模式”当八股文前端其实也一样只是很少有人手写一遍。1.2 rea 的边界做什么不做什么rea 不是一个框架也不是要替代任何框架的状态管理方案。它是一个“最小响应式内核”只解决一件事把传统对象变成响应式对象让读取时自动记录依赖修改时自动触发副作用。就这么个内核往上是业务逻辑往左往右都可以接框架但内核本身保持干净。具体来说核心 API 只有四个reactive创建响应式对象effect注册副作用函数computed做派生值batch做批量更新。不做什么也很重要不做路由不做持久化不绑定任何运行时不强行推广不可变数据。边界划清楚代码才不会越写越臃肿。很多状态库之所以重是因为把调度、序列化、调试工具、时间旅行全塞进来了rea 的选择是只保留响应式的最小闭环其他能力都留给使用者基于它自己扩展。1.3 自研而不是直接引入现成方案动手之前我当然比较过市面上的方案还做了个权衡方案优势不适合我的点重型状态管理库类 Redux 思路生态成熟、调试工具体验好模板代码多小项目里心智负担重类 MobX 思路的方案响应式模型贴合直觉嵌套数据友好依赖不小装饰器历史包袱重定制内部逻辑成本高现成框架自带的响应式模块性能经过验证和框架配合默契与运行时耦合脱离框架就废了学习成本还主要绑在框架概念上自研 rea透明、无依赖、可裁剪、原理完全掌握需要自己保证边界 case 的正确性我最后选自研核心原因就是透明。当我想给响应式内核加一个“防抖调度器”或者“持久化中间件”时只有自己完全掌握每一行代码才能改得痛快。用别人的库遇到不符合预期的情况就得去翻源码翻了源码还不一定改得了这种挫败感做过二次开发的人都懂。这就是 rea 的定位逻辑不是它比所有方案都强而是它在我最需要的维度上刚好最合适。2. 响应式系统的核心原理2.1 响应式的三个基石如果一个响应式系统只有一句话描述那就是读取时收集谁在用我修改时通知这些人去更新。这句话拆开就是三个基石依赖收集、派发更新、调度执行。依赖收集解决“谁依赖了什么”的问题。假设一个购物车对象总价是数量和单价的乘积总价的值就依赖数量和单价这两个字段。系统里其实并没有一个地方预先存着“总价依赖数量”这种关系表它是在总价计算代码执行到读取数量那一条语句时动态把当前正在执行的副作用函数登记到数量字段名下的。派发更新解决“谁变了要通知谁”的问题。等数量这个字段真正被赋值时系统会翻出依赖表里挂在数量名下的所有副作用函数挨个通知它们重新执行。闭包、对象引用、运行时动态注册这些机制在普通业务代码里我们不太注意但响应式系统就是把它们拧成了一套自动化的机制。调度执行解决“通知了也不能立刻盲目执行”的问题。一次交互里可能同时改十个字段如果每改一个字段就重新渲染一次页面浪费是小事性能抖动才是大事。调度器把需要更新的副作用收进队列等一个微任务批次再统一执行这是响应式系统能保持流畅的关键一环。2.2 用 Proxy 做数据代理为什么不用 defineProperty实现字段级拦截过去的标准答案是Object.defineProperty这也是老牌响应式框架早期走的路子。但 defineProperty 有两个硬伤新增属性不触发拦截数组索引操作要额外 hack。前者意味着数据结构的 key 必须提前声明好后者意味着数组方法都要包一层重写运行时的坑多到让人头皮发麻。rea 的实现是直接上 Proxy因为现代浏览器环境已经非常普及了。Proxy 的拦截是面向对象整体操作的不管这个 key 之前存不存在也不管是点属性还是下标赋值都能被get和set捕获。代码上干净很多语义上也更贴近“对象变化”这个抽象概念。get拦截里除了返回真实值还会调用依赖收集函数把当前正在执行的 effect 登记到当前 key 的依赖集合里。set拦截除了写入新值还会调用派发更新函数找到该 key 对应的所有依赖并触发它们。Delete 操作同理也要触发一次更新否则删了一个属性之前引用它的界面就永远停留在旧状态了。export function reactive(target) { if (target null || typeof target ! object) { return target; } if (target.__isReactive) { return target; } return new Proxy(target, { get(target, key, receiver) { const result Reflect.get(target, key, receiver); track(target, key); if (typeof result object result ! null) { 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; }, deleteProperty(target, key) { const hadKey key in target; const result Reflect.deleteProperty(target, key); if (hadKey) { trigger(target, key); } return result; } }); }有一点要注意reactive函数在get里做了递归代理也就是说深层次的嵌套对象也是懒加载代理的。第一次访问到state.user.profile时profile才被包上 Proxy而不是在创建state时就把整棵树全代理一遍。这个懒代理策略对性能非常重要尤其是数据体量大、但实际访问路径很有限的时候能省下不少初始化开销。2.3 依赖关系表的结构WeakMap 嵌套 Map 嵌套 Set响应式系统的核心数据结构我建议直接背下来一个 WeakMap 存对象到它的“键依赖映射表”的对应关系这个映射表本身是 MapMap 的每个 value 是一个存副作用函数的 Set。const targetMap new WeakMap();为什么要用 WeakMap因为 key 是原始对象本身如果这个对象不再被业务使用了WeakMap 不持有强引用垃圾回收可以把它连同依赖关系一起收走不会造成内存泄漏。如果换成普通 Map所有被代理过的对象即使不用了也会一直躺在内存里跑长驻页面迟早出问题。let activeEffect null; const effectStack []; function track(target, key) { if (!activeEffect) return; let depsMap targetMap.get(target); if (!depsMap) { depsMap new Map(); targetMap.set(target, depsMap); } let deps depsMap.get(key); if (!deps) { deps new Set(); depsMap.set(key, deps); } if (!deps.has(activeEffect)) { deps.add(activeEffect); activeEffect.deps.push(deps); } } function trigger(target, key) { const depsMap targetMap.get(target); if (!depsMap) return; const deps depsMap.get(key); if (!deps) return; const runEffects Array.from(deps); runEffects.forEach(effectFn { if (effectFn.scheduler) { effectFn.scheduler(); } else { effectFn.run(); } }); }这里有几个细节值得解释。activeEffect是全局变量保存“当前正在执行的副作用函数”。当执行一个 effect 时把它压进栈顶并且赋给activeEffect这样在它内部读取响应式属性时track就能知道该把依赖记到谁头上。Array.from(deps)这种拍快照的做法是必要的。如果在触发更新的过程中某个副作用函数又导致了新的依赖增删边遍历边改 Set 就会出各种莫名其妙的错。先拷贝一份当前依赖快照再挨个执行是响应式系统里一个不多但极其重要的防御动作。3. 从 0 到 1 实现核心模块3.1 项目结构与模块划分rea 的源码目录不长核心文件就四个但每个文件职责都划分得很清楚rea/ ├── src/ │ ├── reactive.js // 数据代理提供 reactive 函数 │ ├── effect.js // 副作用注册与调度提供 effect 函数 │ ├── computed.js // 派生值提供 computed 函数 │ └── index.js // 统一导出入口 └── test/ └── core.test.js // 核心行为测试模块划分的原则很简单reactive 只负责代理和依赖收集的入口effect 负责副作用函数的生命周期和批量调度computed 建立在 effect 之上形成层次依赖。不搞循环依赖后续维护的人看代码时不用在文件之间跳来跳去猜逻辑。3.2 实现 effect副作用函数的管理与调度effect是整个系统的执行单元。它接收一个普通函数包装成“可追踪的副作用函数”并立刻执行一次让该函数内部的响应式读取都能被记录。const queue new Set(); let flushing false; function flushQueue() { flushing false; const effects Array.from(queue); queue.clear(); effects.forEach(effectFn { effectFn.dirty false; effectFn.run(); }); } export function effect(fn, options {}) { const effectFn () { cleanup(effectFn); effectStack.push(effectFn); activeEffect effectFn; try { return fn(); } finally { effectStack.pop(); activeEffect effectStack[effectStack.length - 1]; } }; effectFn.deps []; effectFn.dirty true; effectFn.scheduler options.scheduler || null; effectFn.run () { if (effectFn.dirty) { effectFn.dirty false; effectFn(); } }; if (!options.lazy) { effectFn.run(); } return effectFn; } function cleanup(effectFn) { effectFn.deps.forEach(dep dep.delete(effectFn)); effectFn.deps.length 0; }几个关键点分开说。第一cleanup。每次 effect 重新执行前必须先把自己从所有依赖集合里清掉。因为函数体是动态的上次执行可能读了字段 A这次执行条件变了只读字段 B如果旧依赖不清掉A 字段以后每次修改都会无意义地触发这个副作用。这属于“先清后建”的策略。第二dirty标志。它表示这个副作用当前是否需要真正执行。如果同一个副作用被连续加入调度队列多次flushQueue只会在第一次执行时看它执行完就清了脏标记后几次重复入队就只更新标志位不再重复执行。这就是去重。第三scheduler选项。如果不提供 schedulertrigger直接调用effectFn.run()同步执行如果提供 scheduler比如传入一个“把副作用加入队列等会儿再跑”的函数运行时就走批量调度的路。这个设计把同步更新和异步批量更新都统一到了一套 API 里。批量更新效果很直观。比如一次事件里连续改了三个字段普通实现会触发三次视图渲染而 rea 的默认批量策略里副作用第一次入队时就触发微任务刷队列后续重复入队只更新标志位等刷队列的时候统一执行一次完整的更新。我把这个场景打印日志对比过同一批三次赋值渲染次数从三次降成一次复杂场景下这个差距就是性能质变。3.3 实现 computed惰性求值与缓存computed就是一个带缓存的惰性 effect。它内部建立一个懒执行副作用外部通过.value访问计算结果。export function computed(getter) { let value; let dirty true; const effectFn effect(getter, { lazy: true, scheduler() { if (!dirty) { dirty true; trigger(computedObj, value); } } }); const computedObj { get value() { if (dirty) { value effectFn.run(); dirty false; } track(computedObj, value); return value; } }; return computedObj; }这里最难理解的是trigger(computedObj, value)这行。当 getter 内部的依赖字段发生变化时scheduler 先标记 dirty然后把自己当成一个依赖源通知所有依赖computedObj.value的外部 effect 重新执行。这样外部 effect 在读取.value时先触发track把自己登记到 computedObj 名下再发现 dirty 标记重新执行 getter 拿到新值。整个链路是源字段变 - computed 标记脏并通知外部 - 外部 effect 重新执行 - 读取.value时重新计算。缓存的价值在复杂派生逻辑里特别明显。假如有一个表格行数一万总价是把每一行的金额加起来没有缓存时任何一行变动都会触发所有读取总价的地方重新完整求和。有缓存之后一行变动只标记一次 dirty下次读取时才重算一次而且只要有缓存命中哪怕被十个 effect 同时读也只会算一遍。3.4 实现 batch一批修改只响应一次批量更新本质上就是利用前面 effect 里那个queue。我暴露一个batch函数保证在一组同步操作结束后的同一个微任务里刷新队列。实现不算复杂重点是清楚“入队的是副作用不是修改动作”。let batchDepth 0; export function batch(fn) { batchDepth; try { fn(); } finally { batchDepth--; if (batchDepth 0) { flushQueue(); } } }这里的batchDepth支持嵌套。如果batch里又调了batch内层调用不会提前触发刷新只有最外层的 batch 结束后才统一 flush。这样写业务逻辑时可以放心地多层封装不用担心中间层把批量语义弄坏。4. 在实际业务里落地4.1 场景搜索框防抖联动列表光有原理不行我拿一个最常见的业务场景来演示搜索框输入过滤一个本地的数据列表同时展示过滤结果条数。不用框架直接跑在原生 DOM 上。import { reactive, computed, effect, batch } from rea; const state reactive({ keyword: , list: [苹果, 香蕉, 西瓜, 草莓, 蓝莓, 葡萄] }); const filteredList computed(() { return state.list.filter(item item.includes(state.keyword)); }); const totalText document.querySelector(#total); const listBox document.querySelector(#list); effect(() { const items filteredList.value; totalText.textContent 共 ${items.length} 条结果; listBox.innerHTML items.map(item li${item}/li).join(); }); document.querySelector(#search).addEventListener(input, e { batch(() { state.keyword e.target.value.trim(); }); });这段代码里有个很有趣的点effect内只写一次更新逻辑它自己就知道要同时更新两个 DOM 节点因为它在执行期间读取了filteredList.value而该值又被 computed 关联到了 keyword。后续我加需求比如还要更新隐藏字段、还要控制空状态节点只需要在同一个 effect 里追加 DOM 操作依赖收集会自动覆盖新增的读取项不用额外注册。业务里常见的搜索防抖其实也能接入。我给 effect 提供scheduler把副作用函数放进一个防抖队列这样每次输入触发 reactive 更新时真正的 DOM 刷新会等输入停顿后再执行。想要更激进的防抖还可以在 scheduler 里做超时合并这个就是扩展能力了。4.2 与主流框架配合时的注意点rea 的响应式内核不依赖框架所以接入 React、Vue 或者在普通 JS 里用本质都是一样的让框架的渲染函数成为 effect让框架的组件状态接收响应式数据。但实际接入中有一个很容易踩的坑框架的渲染函数并不是任何时候都能安全地放进响应式 system。拿 React 举例如果在组件函数体直接调用effect注册更新React 的渲染流程是受它自己调度器控制的强行在响应式副作用里同步操作虚拟 DOM会造成双调度冲突。我的建议是跨框架使用时不要试图让响应式系统接管框架的渲染而是只在需要“外部副作用”的地方使用它比如更新原生 DOM 节点、写 localStorage、推送消息事件、控制第三方非响应式组件。让框架管组件内部状态让 rea 管跨组件和跨渲染树的状态同步各司其职。一个我实际采用的模式是先用 rea 保存一份全局业务状态然后框架组件通过订阅函数把这份状态同步到自身的 state 中。React 里可以借一个微不足道的自增 hook 做强制刷新入口状态变更时用batch批量更新几个组件的刷新自然就自动合流了。5. 常见问题与排查实录5.1 响应式丢失问题从对象上解构出来的字段修改后不触发更新这是我被问得最多的问题。比如const keyword state.keyword然后直接改keyword响应式系统当然不会知道因为解构得到的只是一个基础类型值和源对象之间早就断了联系。解决办法有两个。第一能不解构就不解构尽量通过源对象属性去访问。第二确实需要解构出响应式能力时把源对象本身变成响应式再解构整个对象的引用型属性或者给 getter 字段提供独立的 ref 容器。这也是为什么很多响应式库会推出toRefs之类的 API本质就是把“基础值跟随源对象变化”这个能力显式包装出来。5.2 effect 内出现死循环在一个副作用里直接修改自己读取的源字段会造成无限触发。比如effect(() { state.count state.count 1 })读取时把 effect 注册到自己名下赋值时又触发自己执行一执行又开始读取、又赋值直接爆栈。判断依据很简单副作用函数体里是否存在“既读又写同一字段”的情况。如果确实有这个需求应当通过条件判断避免无限循环或者把这个“读写同一个响应式字段”的动作放到另一个隔离的 effect 里让它们互相之间不共享同一个追踪目标。5.3 数组索引赋值和 Map 方法更新数组的响应式处理一直是状态库的雷区。arr[0] xxx这种索引赋值Proxy 能捕获到但如果数组之前的 length 是 3现在给 index 5 赋值数组长度发生变化依赖“length”的方要也应当被通知。我的实现里在 set 拦截中增加了对 index 与 length 的特殊处理确保新增索引时足够触发 length 相关依赖。Map 和 Set 这类集合类型的处理比普通对象更麻烦因为map.set(key, value)和数组 push 一样是通过内部方法操作不走对象的 get/set。我的解决方案是在 reactive 包装 Map 时把set、delete、clear等原型方法重定向到代理对象上执行并在执行前后做 track/trigger。这个看起来加方法就完事实际上隐藏坑不少比如size属性是 getter还必须保证 new Map 传入的迭代器能正确解包。5.4 问题速查表现象根因解决方案解构后的变量修改不更新基础值断开了与源对象的联系尽量操作源对象属性或使用 ref/toRefs 风格容器effect 陷入死循环副作用内又读又写同一个源字段拆分副作用逻辑增加条件判断或隔离读写批量修改触发多次渲染未提供调度器或未使用 batch给 effect 配 scheduler 或把修改包进 batch 里computed 的值更新滞后dirty 标记没及时联动依赖变更时先标脏再 trigger 上层 effect深层对象顶层访问就报错直接把 null 或 undefined 当对象代理先检查非空再访问get 返回值判空后才能递归代理把这张表贴项目文档里等于给后来使用者省了半天排查时间。5.5 性能实测与优化记录我自己拿一个真实的表格页面做过对比500 行数据、每行 6 个可编辑单元格使用 rea 之后编辑单元格时不会再出现整页字段闪烁。原因很纯粹响应式系统只触发依赖了“编辑字段”的 UI 更新其他不相关单元格的渲染逻辑根本不会执行。而以前每编辑一个格整页都要重新遍历一遍全部行做校验和渲染卡顿感就来自这里。还有一个性能优化点尽量让 effect 粒度细。在 rea 里一个 effect 就是一个最小更新单元。把大页面上关联性不强的状态拆成多个 effect每个 effect 只管自己那一小块 DOM数据更新时开销就按需计算。粗粒度的副作用写起来省事但每次修改都会被放大执行所以代码里注释明确提醒“细粒度优先”。最后说一个实时开发时需要留意的点Proxy 的特性决定了浏览器开发者工具在控制台直接打印响应式对象时展开后的属性读取也可能触发track导致调试时出现一些“幽灵依赖”。我自己的习惯是在调试工具里打印通过toRaw转换后的原始对象或者在访问对象前临时关掉activeEffect这样能避免误报的依赖记录干扰排查。rea 后续的扩展方向我也琢磨过。一个是持久化中间件把响应式对象序列化到 localStorage并在外部存储变化时反向更新状态另一个是跨标签页同步通过 BroadcastChannel 监听数据变更让多标签页共享同一份响应式状态。这两个模块目前都还在实验阶段但基础的响应式内核已经跑得很稳往上加功能只是时间问题。实际用下来我对“先清后建”加“调度队列”这套设计的体会是响应式系统其实不难难的是把所有边界情况想清楚以后代码还能维持简单。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号