从零实现轻量级响应式核心rea:依赖收集与批量更新

发布时间:2026/10/22 16:34:31
从零实现轻量级响应式核心rea:依赖收集与批量更新 1. 从“rea”这个标题说起一个极简命名背后的完整项目拆解第一次看到“rea”这个标题的时候我脑子里蹦出来的第一反应是——这大概率是个缩写而且极有可能是一个内部项目代号。在技术圈混久了你会发现越是短到只有三个字母的项目名背后往往藏着越完整的一套东西。因为命名的人已经默认了“懂的人自然懂”不需要靠名字来解释自己。这种命名习惯在工具链、框架、脚手架这类项目里特别常见比如很多团队内部会把自己的构建工具叫“bld”、把配置中心叫“cfg”都是同一个逻辑。那“rea”最可能是什么结合我这些年接触过的项目类型它最合理的展开方向是Reactive / Reader / Render / Resource / Runtime / Realtime这一类的词根。而如果非要我押一个最可能的我会押Reactive或者Reader。原因很简单这两个方向是过去几年里个人开发者和小团队最愿意动手造轮子的领域。一个是响应式状态管理一个是内容读取与解析都属于“看起来简单、做起来全是坑”的典型场景特别适合用一个短名字来包装。所以这篇博文我不打算把它当成一个“某某框架官方文档”来写而是把它当成一个从零到一搭建一个名为 rea 的轻量级核心模块的完整项目复盘。它可能是一个响应式数据层也可能是一个统一读取层但无论具体形态如何它要解决的问题是共通的把复杂的东西收进一个极简的接口后面让调用方只关心“我要什么”而不是“你怎么做到的”。这篇文章适合谁看如果你是一个已经写过一些代码、但每次搭新项目都要重新造一遍轮子的开发者那这篇内容对你会有直接帮助。如果你是对“一个项目从命名到落地到底要经历什么”感兴趣的技术爱好者也能从里面看到很多真实决策的痕迹。我会尽量把每一步的“为什么”讲清楚而不是只丢一堆代码让你抄。因为抄代码谁都会但知道为什么这么写才是真正能带走的东西。2. 整体设计与思路拆解为什么是这样一个结构2.1 核心定位不做大而全只做一件事任何以三个字母命名的项目第一件要想清楚的事就是边界。我见过太多项目死在“什么都想做”上。一个叫 rea 的东西如果它既想管状态、又想管路由、还想管请求那它最后大概率会变成一个四不像。所以我在设计之初就定了一个原则rea 只负责它名字所代表的那一件事其他全部交给生态。假设我们把 rea 定位成一个响应式核心那它的职责就只有一个把普通数据变成可被追踪、可被订阅的数据。至于怎么渲染、怎么发请求、怎么持久化那是上层的事。这个定位带来的直接好处是核心代码量可以控制在很小的范围内测试覆盖率高出问题的概率低。而且因为职责单一它跟任何框架都能配合不会绑死在某一个技术栈上。这里有个很关键的取舍要不要提供开箱即用的完整方案很多开发者会倾向于“我全都给你配好”因为这样对新手友好。但代价是灵活性下降而且一旦某个内置方案不满足需求用户就得跟框架本身较劲。我最终选择的是“核心极简 可选插件”的路线。核心只提供最基础的响应式能力其他能力通过独立的包来扩展。这样既保证了核心的稳定又给了用户按需组合的自由。2.2 技术选型为什么不用现成的方案每次我说要自己写一个响应式核心总会有人问市面上已经有那么多成熟方案了为什么还要自己造这个问题问得特别好我的回答通常分两层。第一层是学习价值。如果你从来没有亲手实现过一个依赖收集和触发更新的机制那你对“响应式”这三个字的理解永远停留在使用层面。一旦遇到更新不触发、或者触发次数过多的问题你只能靠猜。而自己写一遍之后你会清楚地知道每一个更新是怎么被调度出去的排查问题时心里有底。第二层是可控性。现成方案为了覆盖尽可能多的场景往往会引入很多你根本用不到的抽象层。这些抽象层在大多数时候是无害的但在性能敏感或者需要深度定制的场景下就会变成负担。自己写的核心每一行代码你都知道它为什么在那里删掉任何一行你都能说清楚会失去什么。这种掌控感是直接用别人的东西换不来的。当然我不是说所有项目都应该自己造轮子。如果你的目标只是快速交付一个业务系统那用成熟方案绝对是更明智的选择。但如果你是在做一个需要长期维护、并且对行为有精确要求的基础设施那自己掌控核心逻辑就是值得的。2.3 架构分层三层结构的具体划分最终落地的架构我分成了三层这个划分方式参考了很多同类项目的经验也结合了我自己踩过的坑。最底层是数据层负责存储原始值和依赖关系。这一层不关心任何跟界面相关的东西它只做两件事记录谁读了哪个数据以及在数据变化时通知那些读过它的人。听起来很简单但实现起来要考虑的细节非常多比如嵌套依赖怎么处理、循环引用怎么避免、批量更新怎么合并。中间层是调度层负责决定什么时候执行更新。这一层是很多人容易忽略的但恰恰是最影响体验的部分。如果每次数据变化都同步触发更新那在一个循环里改十次数据就会触发十次更新性能直接崩掉。所以调度层要做的事情就是把这些更新收集起来在合适的时机一次性执行。这个“合适的时机”在不同环境下是不一样的浏览器里可能是微任务服务端可能是下一个事件循环。最上层是接口层也就是暴露给用户使用的 API。这一层要尽量薄薄到用户看一眼就知道怎么用。我最终只暴露了三个核心函数一个用来创建响应式数据一个用来创建派生数据一个用来监听变化。其他的全部通过组合来实现。接口越少用户的心智负担越小出错的可能性也越低。3. 核心细节解析与实操要点那些文档里不会写的坑3.1 依赖收集怎么知道谁读了什么依赖收集是整个响应式系统的地基如果这一步做错了后面全是问题。它的核心思路其实很朴素在读取数据的时候把当前的执行上下文记录下来。这个“执行上下文”通常被称为“副作用函数”它代表了一段需要在数据变化时重新运行的逻辑。实现上我们需要一个全局变量来保存当前正在执行的副作用函数。每次进入一个副作用函数时把这个全局变量指向它执行完之后再恢复成之前的值。这样在副作用函数内部读取任何响应式数据时我们都能知道“是哪个函数在读”。然后把这个函数和这个数据关联起来存到一个映射表里。这里有个特别容易踩的坑嵌套副作用。比如在一个副作用函数里又创建了另一个副作用函数这时候全局变量会被覆盖导致依赖关系错乱。解决办法是用一个栈来保存上下文进入时压栈退出时弹栈。这个细节在很多简易实现里是被忽略的但一旦你的项目稍微复杂一点就一定会遇到。还有一个坑是条件分支导致的依赖丢失。假设一个副作用函数里有一个 if 判断第一次执行时走了 true 分支收集了 A 数据的依赖第二次执行时因为某个条件变化走了 false 分支这时候如果不清除之前的依赖A 数据变化时仍然会触发这个副作用但它其实已经不需要 A 了。正确的做法是在每次重新执行副作用之前先清除它之前收集的所有依赖然后重新收集。这个清除和重新收集的过程是保证依赖关系准确的关键。3.2 触发更新怎么避免无谓的重跑收集完依赖之后下一步就是数据变化时触发对应的副作用重新执行。听起来很简单但这里有几个必须处理的细节。第一个是值比较。如果新值和旧值相同那就不应该触发更新。这个判断看起来多余但在实际项目里非常有用。比如你有一个定时器每隔一秒把某个值设成同样的内容如果没有这个判断界面就会每秒重渲染一次完全是浪费。比较的时候要注意对于对象类型我们比较的是引用而不是内容。也就是说如果你每次都创建一个新对象即使内容一样也会触发更新。这是很多新手会困惑的地方解决办法要么是复用对象要么是在业务层面做更精细的控制。第二个是批量更新。在一个同步代码块里连续修改多个数据理想情况下应该只触发一次更新。实现方式是把需要执行的副作用先放进一个队列然后在当前同步代码执行完之后统一执行。这个“执行完之后”的时机在浏览器里可以用微任务来实现在 Node 环境里可以用 process.nextTick 或者 setImmediate。选择哪个时机是有讲究的微任务会在当前宏任务结束前执行时机更早但如果在微任务里又修改了数据可能会形成循环宏任务时机更晚但更安全。我最终选择的是微任务因为它更符合大多数人的直觉。第三个是避免无限循环。如果一个副作用函数在执行过程中修改了它自己依赖的数据就会导致自己再次被触发形成死循环。这种情况在实现层面很难完全避免但可以通过一些策略来缓解比如在副作用执行期间暂停依赖收集或者设置一个最大递归深度。我在实际项目里的做法是在开发环境下检测到循环时给出明确的警告而不是直接崩溃这样用户能更快定位问题。3.3 派生数据怎么处理计算属性派生数据也就是常说的计算属性是响应式系统里最常用的功能之一。它的核心诉求是基于已有的响应式数据计算出一个新值并且这个新值也是响应式的。听起来简单但实现上有一个关键决策是每次读取时重新计算还是缓存计算结果。如果每次读取都重新计算那实现非常简单但性能会很差。如果缓存那就需要知道什么时候缓存失效。我的做法是采用惰性求值加缓存的策略第一次读取时执行计算函数并缓存结果同时记录这个计算函数依赖了哪些数据当依赖的数据变化时把缓存标记为失效但不立即重新计算下次读取时如果发现缓存失效再重新计算。这个策略的好处是如果计算属性被多个地方使用但依赖数据变化后没有人读取它那就不会浪费计算资源。坏处是实现复杂度上升需要维护一个“脏标记”。而且这里还有一个容易忽略的点计算属性本身也可能被其他副作用依赖。也就是说当计算属性的值变化时依赖它的副作用也应该被触发。这就要求计算属性在重新计算后如果值确实变了要通知它的订阅者。这个链条如果处理不好就会出现“计算属性更新了但界面没更新”的问题。3.4 内存管理怎么避免越用越卡响应式系统如果处理不好内存用久了就会越来越卡最后甚至崩溃。最常见的问题是副作用没有被正确清理。比如在一个组件里创建了副作用组件销毁时如果没有取消订阅那这个副作用就会一直存在于依赖表里数据变化时仍然会被触发但它引用的组件已经不存在了轻则浪费性能重则报错。解决办法是提供一个显式的清理机制。每次创建副作用时返回一个清理函数调用这个函数就会把该副作用从所有依赖表中移除。同时在框架层面组件的生命周期结束时自动调用这个清理函数。这个机制看起来简单但需要保证在所有路径上都被正确调用包括异常退出的情况。另一个内存问题是依赖表本身无限增长。如果每次创建副作用都往依赖表里加东西但从来不删除那这个表就会越来越大。所以清理机制不仅要移除副作用对数据的引用还要移除数据对副作用的引用双向都要清干净。我见过一些实现只清了一边结果内存还是泄漏。这个细节在测试的时候不容易发现但上线跑一段时间就会暴露出来。4. 实操过程与核心环节实现从零把 rea 跑起来4.1 环境准备与项目初始化动手之前先把环境理清楚。我用的是一套很常见的组合Node 运行时加上一个包管理工具编辑器随意但建议开启类型检查因为响应式系统里类型错误是很常见的 bug 来源。项目结构我习惯分成三个目录src放源码test放测试examples放一些可以直接运行的示例。这样做的目的是让每个部分职责清晰后面加东西的时候不会乱。初始化的时候有几个配置项值得注意。一个是模块格式我建议同时输出多种格式这样无论是直接在浏览器里用还是被其他构建工具引用都不会有问题。另一个是类型声明文件的生成这个一定要开不然用 TypeScript 的同事会很难受。还有就是测试框架的选择我倾向于用最轻量的那个因为响应式核心的测试主要是逻辑测试不需要太重的浏览器环境。依赖方面核心模块我坚持零运行时依赖。所有用到的工具都放在开发依赖里这样最终产出的包体积可以控制到极小。这一点在库开发里很重要因为你的用户可能对体积很敏感多一个依赖就多一份负担。4.2 核心模块的代码实现先实现最基础的响应式数据创建。思路是用一个代理来拦截读取和设置操作。读取的时候收集依赖设置的时候触发更新。这里要注意的是代理只能拦截一层如果数据是嵌套的对象需要在读取到嵌套对象时递归地把它也变成响应式的。这个递归转换的时机很关键不能一开始就把整个对象树全部转换那样性能太差应该是在真正读取到某个嵌套属性时才转换也就是所谓的惰性转换。function createReactive(target) { const cache new WeakMap(); return new Proxy(target, { get(obj, key, receiver) { const result Reflect.get(obj, key, receiver); track(obj, key); if (typeof result object result ! null) { if (!cache.has(result)) { cache.set(result, createReactive(result)); } return cache.get(result); } return result; }, set(obj, key, value, receiver) { const oldValue obj[key]; const result Reflect.set(obj, key, value, receiver); if (oldValue ! value) { trigger(obj, key); } return result; } }); }上面这段代码是整个系统的入口。track负责收集依赖trigger负责触发更新。用 WeakMap 做缓存是为了避免重复代理同一个对象同时不阻止垃圾回收。这里有个细节set里比较新旧值的时候如果值是对象比较的是引用。这意味着如果你把一个对象的内容改了但引用没变是不会触发更新的。这是符合预期的行为因为响应式系统追踪的是引用变化不是内容变化。接下来是副作用函数的实现。核心逻辑是执行传入的函数在执行前把自己设为当前副作用执行后恢复。同时要在执行前清除旧的依赖执行后如果依赖有变化要能重新收集。let activeEffect null; const effectStack []; function createEffect(fn) { const effect function() { if (effectStack.includes(effect)) return; try { effectStack.push(effect); activeEffect effect; cleanup(effect); return fn(); } finally { effectStack.pop(); activeEffect effectStack[effectStack.length - 1] || null; } }; effect.deps []; effect(); return effect; }这段代码里effectStack解决了嵌套副作用的问题cleanup解决了条件分支导致的依赖残留问题。effect.deps记录了该副作用依赖了哪些数据的哪些属性清理的时候遍历这个数组逐个删除。这个设计虽然简单但非常有效我用了很久都没遇到问题。4.3 调度器的实现与时机选择调度器负责把触发的更新收集起来在合适的时机批量执行。我实现的是一个基于微任务的调度器核心是一个队列和一个标志位。const queue new Set(); let isFlushing false; function queueJob(job) { queue.add(job); if (!isFlushing) { isFlushing true; Promise.resolve().then(flushJobs); } } function flushJobs() { try { queue.forEach(job job()); } finally { queue.clear(); isFlushing false; } }用 Set 而不是数组是为了自动去重同一个副作用在一次批量更新里只会执行一次。isFlushing标志位保证在刷新过程中新加入的任务会在下一轮处理而不是无限循环。finally块保证即使某个任务抛错队列也能被正确清空不会卡死后续更新。这里有个值得讨论的点为什么用微任务而不是宏任务微任务的执行时机是在当前同步代码结束之后、浏览器渲染之前这意味着数据变化后界面能尽快更新用户感知到的延迟更小。而宏任务会等到下一次事件循环中间可能插入渲染导致界面闪烁。当然微任务也有风险如果副作用里又触发了新的微任务可能会形成无限循环。我在实际项目里的做法是加一个最大刷新次数限制超过就报错避免页面卡死。4.4 计算属性的完整实现计算属性的实现要同时用到前面几个模块的能力。它本身是一个副作用但又不完全是因为它需要缓存结果。function createComputed(getter) { let value; let dirty true; const effect createEffect(() { dirty true; value getter(); }); return { get value() { if (dirty) { value getter(); dirty false; } track(this, value); return value; } }; }这段代码的逻辑是计算属性内部维护一个脏标记依赖变化时把标记设为 true但不立即重新计算。只有真正被读取时才检查标记如果脏了就重新算。同时计算属性本身也要被追踪这样依赖它的副作用才能在它变化时被触发。这里有个微妙的地方计算属性的 getter 执行时会收集它依赖的数据而计算属性的 value 被读取时会收集依赖它的副作用。两个方向的依赖都要处理好缺一不可。4.5 测试与验证方法写完核心代码之后一定要有测试来验证行为是否符合预期。我通常会从几个维度来测基础读写是否正常、依赖收集是否准确、批量更新是否生效、嵌套副作用是否正常、计算属性缓存是否有效、内存是否泄漏。测试内存泄漏有个简单的方法创建大量副作用然后清理掉观察内存占用是否回落。如果一直增长说明有引用没被释放。这个测试在 CI 里跑一次就能发现很多隐藏问题。另外我还会写一些边界测试比如在副作用里修改自身依赖的数据、在计算属性里读取另一个计算属性、在清理函数里再次触发更新等等。这些边界情况在实际项目里出现的概率不低提前测到比上线后才发现要好得多。5. 常见问题与排查技巧实录5.1 更新不触发从依赖收集链路倒查更新不触发是最常见的问题排查的时候我习惯从后往前查。先确认数据是不是真的变了如果数据没变那本来就不该触发。然后确认读取数据的地方是不是在副作用函数内部如果是在副作用外部读的那依赖根本没被收集。再确认副作用函数是不是被正确创建了有时候是因为创建方式不对导致它没有被纳入追踪体系。还有一个隐蔽的情况是代理丢失。如果你把响应式对象解构了或者把它存到了另一个普通对象里再读那读到的就是原始值不会触发依赖收集。解决办法是始终保持对代理对象的引用不要解构。如果确实需要解构可以用专门的工具函数来保持响应性。5.2 更新次数过多定位重复触发的源头更新次数过多通常有两个原因一是依赖收集不准确导致一个副作用依赖了它其实不需要的数据二是触发逻辑没有做去重同一个副作用被多次加入队列。排查第一个原因可以在副作用执行时打印它收集到的依赖列表看看有没有多余的。排查第二个原因可以检查队列的实现是不是用了 Set 或者做了去重判断。另外如果副作用里创建了新的副作用也可能导致连锁触发这种情况需要检查副作用的创建时机是否合理。5.3 内存持续增长引用泄漏的排查路径内存问题排查起来最麻烦因为它不会立刻表现出来。我的做法是先用工具抓取内存快照对比不同时间点的对象数量找出持续增长的那一类对象。然后顺着引用链往上找看是谁在持有它们。常见的原因有几个副作用清理函数没有被调用、依赖表里的引用没有被删除、闭包持有了不该持有的对象。针对第一种检查组件销毁逻辑是否完整针对第二种检查清理逻辑是否双向都清了针对第三种检查副作用函数里是否引用了外部的大对象如果引用了但不需要长期持有应该在清理时置空。5.4 常见问题速查表问题现象可能原因排查方法解决思路数据变了界面不动依赖未收集检查读取是否在副作用内确保在副作用内读取更新触发多次队列未去重打印队列内容使用 Set 去重内存持续增长引用未释放内存快照对比完善清理逻辑计算属性不更新脏标记未设置检查依赖变化通知确保依赖变化时置脏嵌套副作用错乱上下文被覆盖检查全局变量管理使用栈保存上下文循环更新卡死副作用修改自身依赖打印执行日志加最大深度限制5.5 几个我踩过的坑和对应的经验第一个坑是在副作用里使用异步操作。如果你在副作用函数里写了 await那 await 之后的代码执行时全局的当前副作用已经变了导致依赖收集错乱。解决办法是把异步逻辑拆出来或者在 await 之后重新建立上下文。这个问题在刚接触响应式的时候特别容易犯因为写起来很自然但行为完全不对。第二个坑是在计算属性里做副作用操作。计算属性的 getter 应该是纯函数只做计算不做修改。如果你在里面改了别的数据会导致依赖关系混乱甚至死循环。我见过有人在计算属性里发请求这是绝对要避免的。计算属性只负责算不负责做。第三个坑是过度使用深度监听。深度监听会递归遍历整个对象性能开销很大。如果只是需要知道对象引用有没有变用浅监听就够了。只有在确实需要知道内部属性变化时才用深度监听而且要尽量缩小监听范围。这个优化在数据量大的时候效果非常明显。第四个坑是忽略服务端渲染场景。在服务端没有浏览器环境微任务的实现方式可能不一样而且服务端渲染是一次性的不需要长期保持响应式。如果直接把浏览器端的实现搬到服务端可能会出现内存泄漏或者更新不触发的问题。解决办法是在服务端使用简化版的实现只做一次性的数据计算不做持续的依赖追踪。6. 后续扩展与个人体会rea 这个核心跑通之后能扩展的方向其实很多。最直接的是加一个渲染层把响应式数据和界面绑定起来。再往上可以加路由、加状态管理、加表单处理。但我的建议是每加一层都要想清楚它是不是真的必要能不能用现有的东西组合出来。因为每加一层维护成本就上升一截出问题的概率也大一分。另一个扩展方向是性能优化。比如把依赖收集的数据结构从对象换成 Map在大数据量下会有明显提升。再比如引入调度优先级让重要的更新先执行。这些优化在核心稳定之后再考虑不要一开始就过度设计。我个人在实际操作中的体会是写这种基础模块最忌讳的就是“想太多”。一开始不要想着覆盖所有场景先把最核心的路径跑通然后根据实际遇到的问题逐步完善。我见过太多项目因为一开始设计得太复杂最后连自己都维护不动。反而是那些从最小可用版本开始、不断迭代的项目最后活了下来。最后分享一个小技巧在开发阶段可以给每个副作用加一个名字这样在调试的时候能直接看出是哪个副作用出了问题。生产环境可以把这个名字去掉不影响性能。这个习惯帮我省了很多排查时间尤其是在副作用数量多的时候有名字和没名字的排查效率差好几倍。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询