资讯动态

手写轻量响应式运行时:signal、依赖收集与批量更新

发布时间:2026/10/18 6:52:55 来源:尧图企业网站定制
rea最初是我们项目组内部随手起的代号谁也没想到它后来长成了一个真正可用的轻量响应式运行时。起因是一款老后台系统里越来越失控的联动逻辑筛选条件一变表格、统计卡、图表、详情区多个模块都要跟着更新代码里全是散落的事件和状态同步改一个字段能牵连十几个函数调用。我当时就想能不能只写一个几百行的内核把数据变了、依赖它的地方自动更新这个能力做出来。rea就是这么来的。这篇内容适合谁看两类人。一类是被项目里复杂状态联动折磨、想找一个轻量替代方案的前端开发者另一类是单纯想理解响应式原理、想知道依赖收集和批量调度到底怎么回事的人。看完你不仅能复现rea的核心实现还能绕开我在真实项目里踩过的几个坑。1. rea的诞生背景一次状态同步地狱的觉醒1.1 那个让我下定决心动手的业务场景具体业务场景是这样的后台有一个订单筛选区顶部有时间范围、渠道、状态、关键词四个筛选项。按道理这四个条件变化后要联动刷新订单表格、汇总统计条、渠道分布图和最近异常订单列表。问题在于这些模块散落在不同的组件层级里。一开始的做法很朴素表单值变化时触发一个全局事件每个模块自己监听事件然后重新取数。随着筛选条件从两个增加到四个事件链越来越复杂。比如渠道变化时不仅表格要刷新状态筛选项的可选项也要跟着变化统计条的指标名要变化甚至异常订单列表的告警级别阈值都会变。我算过一笔账一个筛选条件最多能牵扯到八个模块的更新。每个模块都有自己的 loading、空态、缓存逻辑。任何一次条件变更都要按特定顺序触发布局里各模块的更新顺序错了界面就会出现先渲染旧数据再闪一下新数据的抖动。1.2 为什么现成方案没解决我手头的问题当时我们项目里已经有了一套组件化方案但它解决的是UI怎么组织的问题没有解决业务状态怎么流动的问题。我又看了看当时主流的全局状态管理库功能很全但引入成本不小需要定义一堆 action、reducer、中间件还有规范化数据结构的额外要求。对于整个系统来说可能是合理的但对我手上这一个内部模块来说两三天的改造量只为了理顺四五个联动关系总觉得不太划算。另一个考虑是旧代码兼容性。老项目里大量逻辑直接操作DOM或者组件实例不是标准的数据驱动写法。全量改造风险高、回归测试工作量大。我当时真正需要的是一个薄薄的响应式数据层——把现有业务数据包装成可观察对象让分散的模块订阅它们关心的字段数据一变对应的地方就精确更新而不是把整个大对象重新 diff 一遍。1.3 rea的目标边界能做什么不做什么所以rea的定位一开始就很明确。它要做的是最小响应式运行时提供 signal响应式数据单元、effect依赖副作用、batch批量更新可能再加一个 computed 派生值就足够了。它不做什么不做虚拟DOM不做组件生命周期不做跨组件通讯协议。UI部分仍然用项目里现成的渲染方式rea只负责把数据变化正确、及时、按顺序地通知给对应的更新函数。这听起来很简单但真把它实现到稳定里面有不少值得记录的设计决策。2. 拆开rea运行时的三层结构信号、依赖图与调度器2.1 数据层用 createSignal 包住状态rea 最核心的数据单元是 signal。它和一个普通变量的区别在于读取 signal 时正在运行的 effect 会被自动登记为依赖写入 signal 时所有登记过的 effect 会被标记为待执行。初次实现时我用了最简单的 getter/setter 方案function createSignal(value) { const subscribers new Set(); let internalValue value; return { get value() { if (activeEffect !subscribers.has(activeEffect)) { subscribers.add(activeEffect); } return internalValue; }, set value(newValue) { if (Object.is(internalValue, newValue)) return; internalValue newValue; for (const effect of [...subscribers]) { scheduleEffect(effect); } }, }; }这里有一个很关键的设计同一 effect 对同一 signal 的重复依赖只保留一份用 Set 天然去重。如果不去重一个 effect 里三次读取同一个 signal触发更新时就会三次执行这是新手写响应式最容易踩的点。2.2 依赖层effect 与依赖集合的生命周期signal 只是数据容器真正发挥作用靠的是 effect。effect 是一个包装过的函数执行时会把自己设为全局变量然后运行函数体函数体里所有 signal 的读取都会把当前 effect 记录进各自的依赖集合。effect 的设计难点在于依赖是动态变化的不能只收集一次。比如一个 effect 里有 if 分支条件不同依赖的 signal 就不同。如果依赖收集后不再更新就会出现在旧条件下依赖过的 signal 已经跟 effect 没关系了但更新时仍然触发 effect 的幽灵依赖问题。我的处理方式是每次 effect 运行前先清理旧依赖运行后生成新依赖集合const effectStack []; let activeEffect null; function createEffect(fn) { const effect function () { // 清理旧依赖 for (const dep of effect.deps) { dep.delete(effect); } effect.deps.clear(); // 执行函数体收集新依赖 activeEffect effect; try { fn(); } finally { activeEffect null; } }; effect.deps new Set(); effect.execute effect; effect(); return effect; }这套清理-重收集机制和浏览器垃圾回收里的标记-清除思路很像。每次 effect 执行都是先清空旧标记再重新建立引用关系确保依赖关系永远反映最新一次运行的实际情况。2.3 调度层微任务批量更新的设计如果每次 signal 赋值都立刻触发所有 effect会产生两个问题一是连续多次赋值导致 effect 反复执行白白浪费性能二是多个 effect 之间如果互相读取状态执行顺序会变得不可预测。rea 的调度器用微任务做了一次合并const pendingEffects new Set(); function scheduleEffect(effect) { pendingEffects.add(effect); if (!isFlushing) { isFlushing true; Promise.resolve().then(() { for (const e of [...pendingEffects]) { e(); } pendingEffects.clear(); isFlushing false; }); } }这个设计的收益很直接在同一轮事件循环里哪怕一个 signal 被赋值十次关联 effect 也只会在微任务阶段执行一次。比如用户快速切换筛选条件时表格不会闪烁十次而是等当前同步代码跑完后统一刷新一次。需要注意的是用微任务而不是 requestAnimationFrame是为了让更新结果在同一次事件循环结束前就稳定下来避免某些代码在同步阶段读取旧值造成前后不一致。3. rea的业务接入方式一个筛选联动场景从命令式改造成声明式3.1 rea 对外暴露的最小 API 集合rea 最终对外只保留了四个核心 API用一张表可以完整说明API作用典型使用场景createSignal(value)创建响应式数据单元返回 { value }存筛选条件、表单值、接口返回数据createEffect(fn)注册副作用自动收集依赖数据变更时重新执行监听数据变化后更新视图、请求接口createComputed(fn)创建派生值惰性计算、缓存结果依赖变化时自动失效根据筛选条件推导最终请求参数batch(fn)同步块内所有变更合并为一次通知多字段同时变化时避免重复刷新这样的 API 设计借鉴了函数式思想signal 是数据流effect 是订阅者computed 是纯派生batch 是合并递交。组合起来就是一个很优雅的发布订阅循环。3.2 实操改造一个筛选联动模块以筛选联动为例改造分三步走。第一步把筛选条件和不稳定的接口状态变成 signalconst filters createSignal({ timeRange: [2026-01-01, 2026-01-31], channel: , status: , keyword: , }); const tableLoading createSignal(false); const tableData createSignal([]); const stats createSignal({});第二步用 computed 派生真正发给后端的请求参数。这一步把用户界面上的条件和请求层面的参数解耦将来就算加一个排序字段也只是在 computed 里加一行const requestParams createComputed(() { const f filters.value; return { start: f.timeRange[0], end: f.timeRange[1], channel: f.channel || undefined, status: f.status || undefined, keyword: f.keyword.trim() || undefined, }; });第三步建几个 effect 分别处理表格刷新、统计条刷新、状态选项联动createEffect(async () { const params requestParams.value; tableLoading.value true; try { const data await fetchOrderList(params); tableData.value data; } finally { tableLoading.value false; } });改造完以后的代码模式非常直观定义数据源派生参数订阅变化更新视图。原来分散在十几个回调里的赋值和调用全部收敛成了声明式的依赖关系。后续新增一个筛选维度只要在 filters 里加字段、在 requestParams 里补映射其他模块的 effect 会自动感知。3.3 与既有技术栈共存的兼容方案我只用了很轻的适配层就把 rea 接进了老代码。最通用的方式是让 rea 充当发布订阅总线老的命令式代码可以通过 signal 暴露数据新的声明式代码通过 effect 订阅它。举例来说老代码里有一个由组件内部维护的当前选中行状态我改造时把行状态从组件 state 迁移到 signal 上再把组件里的赋值操作改成selectedRow.value row而详情面板的渲染逻辑用一个 effect 订阅selectedRow.value。这样完全不动组件树结构只在数据层加了衔接点。如果你要接入的是 React 风格的项目思路也一样signal 放在组件外组件里用 effect 把 signal 变化同步到本地渲染状态。反正关键就是数据流从 signal 走视图层只做单向渲染。4. 压测与排障rea在真实环境里踩过的四个坑4.1 坑一effect 内部改写状态导致无限循环第一次把 rea 放到真实页面就跑出过页面卡死。原因很典型某个 effect 里在更新一个状态这个状态又恰好是这个 effect 自身的依赖于是 effect 执行完立刻被再次调度循环往复。问题出在调度器缺少执行中状态的标记。我的修复方案是给每个 effect 增加一个 running 标志function scheduleEffect(effect) { if (effect.running) return; // 正在执行则不重复入队 pendingEffects.add(effect); // ... }同时定下一条约定effect 函数里不应该修改自己的直接依赖。如果确实需要根据某个状态决定要不要更新另一个状态正确做法是把变更放到 batch 里或者拆成两个 effect让数据流动方向保持单向。这个坑几乎是所有响应式系统都会遇到的区别只是框架帮你兜底还是你自己兜底。4.2 坑二嵌套对象更新丢失响应性signal 包装出来的数据如果是嵌套对象signal.value newObject这种方式能触发更新但signal.value.nested.count这种深层修改就完全感知不到。因为 getter 只在读取第一层 value 时收集依赖深层属性的读写根本不会经过 signal 的拦截。我试过两个方案。第一个是用递归 Proxy 实现深度响应式每次深层读取都能自动建立依赖第二个更省事约定嵌套数据整体不可变更新时整体替换。最终我选了后者因为业务场景里数据更新频率不算高整体替换配合 computed 缓存完全够用而且不可变更新让数据变更可追踪、可回放排查问题容易得多。如果你是给数据密集型应用设计响应式层深度 Proxy 方案会更合适如果是普通业务表单、列表联动整体替换才是性价比最高的做法。4.3 坑三异步 effect 与组件卸载的生命周期错位一个 effect 里发了异步请求返回时页面组件已经被卸载这时候再去更新 signal虽然不会直接报错但会造成无效渲染和潜在的内存泄漏。更隐蔽的问题是异步期间用户又切换了条件第一次请求的返回结果可能覆盖掉第二次请求的新数据形成竞态。在 rea 层我做了两件事。第一effect 支持返回清理函数createEffect(() { let cancelled false; fetchList(params).then((data) { if (!cancelled) list.value data; }); return () { cancelled true; }; });第二增加一个过期请求丢弃策略每次新请求发起时给上一次请求打上过期的标记数据返回后检查标记再决定是否写入 signal。这两个机制组合把那类页面跳转后还在更新列表的毛病彻底解决了。4.4 坑四动态依赖收集产生的伪依赖与重复执行动态依赖设计的代价是如果 effect 里读取 signal 的条件隔一次变一次effect 的依赖集合就在两种状态之间反复横跳产生每次执行都收集新依赖形成抖动的现象。比如说一个 effect 里先读a.value根据结果决定是否读b.value那么 b 的依赖时有时无导致 b 更新时 effect 有时触发有时不触发行为很不稳定。我的解决思路是稳定依赖的边界。对于明显属于函数执行流程硬依赖的 signal我会把它提到 effect 开头无条件读取一次对于条件性读取则尽量抽成 computed由 computed 做派生effect 只订阅最终结果。这样依赖关系更稳定effect 的重复执行次数也明显下降。5. rea的进阶思考与最终的取舍答案5.1 为什么 rea 没有引入虚拟 DOM很多人会问响应式运行时和虚拟 DOM 不是配套的吗rea 没做原因很简单虚拟 DOM 解决的是声明式描述 UI 后如何高效执行 UI 差异更新的问题而 rea 想做的是数据变化后如何跳过无关 DOM 操作、直接更新目标区域。两者服务的目标不完全一致。在 rea 的模型里effect 本身就是最精确的更新器。它知道哪些数据变了也知道哪些视图依赖这些数据所以直接调用对应的 DOM 更新函数即可根本不需要 diff 整个组件树。这就像你要换一盏坏掉的灯正确的做法是直接走到那盏灯下换掉而不是把整个房间的电路都断开重拉一遍。这套模型的好处是性能上限很高坏处是放弃了跨端复用和声明式 UI 的表达能力。对于绑定在浏览器里的业务数据联动场景这个取舍是对的。5.2 派生值、批量控制与未来方向如果还有下一步我会先补两个能力。第一个是让 computed 变成 lazy 的——现在的实现里只有在 effect 读取时才计算如果业务里出现大量重复读取 computed 场景可以加一层缓存失效标记避免重复计算。第二个是提供手动控制的 batch 时机让极少数需要同步拿到最新值的场景能绕过微任务延迟。但说实话rea 的价值现在已经充分体现了。运行时实现不到三百行没有引入任何依赖让那个后台系统的联动代码量缩减了约四成而且排查问题时只需要顺着 signal 的依赖关系走一条链路能直接看到数据从哪来、到哪里去。5.3 落地后的效果与个人建议最后分享一点个人体会。写这类内部工具时最值得投入的不是功能复杂度而是边界测试。我在接入真实项目前写了几十个用例专门覆盖 effect 执行次数、异步竞态、依赖清理、循环触发这类边界场景。后来大部分集成阶段的诡异 bug都能在前端跑这些用例时一眼定位出来。如果你也想做类似的响应式运行时建议从不调度、不批量、同步执行依赖的最小版本起步先跑通数据链路再加微任务批量、再加 computed、再加异步清理。一步一步来每个阶段都能测反而比一次性实现完整方案更稳。rea 这个名字大概就是这个过程的注脚——一个不起眼的代号最后变成了一个能扛业务的小骨架。

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

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

免费获取报价 →
↑