10.1 原理深入:响应式系统的实现细节


10.1 原理深入:响应式系统的实现细节

本节摘要:把第 4 章的架构图再放大:信号由值、订阅集合与传播标记构成;Memo 是带状态机的计算节点;Effect 的执行走批量队列并带循环防护;Owner 树决定销毁语义。本节用一段伪代码实现最小 Signal,逐环节对照到 Solid 的真实行为,最后交代服务端渲染时这张图如何重建。

深夜读源码的人分两种:一种想抄实现,一种想验证心智模型。本节服务后者——全册反复使用的"订阅、投放、清理"三个词,在实现层各对应一段不长不短的代码。理解到这一层,第 2 章所有"形似神不似"的结论都能自证,不再需要背。

最小 Signal:三环节五十行

先用伪代码造一个能跑的最小信号系统:

let activeSubscriber = null; // 当前正在执行的响应式计算(追踪指针) function createSignal(initial) { let value = initial; const subscribers = new Set(); // 环节一:订阅集合 const read = () => { if (activeSubscriber) subscribers.add(activeSubscriber); // 读取即登记 return value; }; const write = next => { if (next === value) return; // 环节二:相等判断短路 value = next; for (const sub of [...subscribers]) sub.mark(); // 环节三:标记传播 }; return [read, write]; } function createEffect(fn) { const node = { run() { activeSubscriber = this; // 打开追踪指针 try { fn(); } finally { activeSubscriber = null; } }, mark() { queueMicrotask(() => this.run()); }, // 进调度队列 }; node.run(); // 首次执行,建立订阅 }

对照真实行为逐环节验证。环节一,订阅登记:activeSubscriber 就是 4.2 说的追踪指针,Effect 与 Memo 执行时它指向自己,执行中读到的每个信号都把 Effect 记进订阅集合——"读取现场自动登记"的机制本体,一个 Set 加一个全局指针而已。环节二,相等判断:写入口的短路一行,就是 2.1 讲的"源头拦截"。环节三,标记传播:真实实现比这里的"直接重跑"精细——它先做标记不执行,等本轮同步代码结束统一冲刷,这就是 2.2 的批量调度;循环防护也挂在这里,同一轮里发现节点被标记第二次还没执行,就判定为循环依赖并抛错,而不是让浏览器卡死。

Memo 的状态机

Memo 比 Effect 多一个职责:缓存判断。它不需要每次被读都重算,所以内部维护一个状态值:干净(值有效)、待查(上游有变但值可能没变)、脏(确定要重算)。上游信号变化时 Memo 被标为待查;真正被下游读取或调度命中时,先看自己依赖的上游有没有实际变化——没有变化就转回干净,下游无需传播。这个状态机是"依赖不变必不重算"承诺(2.4)的实现位置,也是 Solid 在基准测试里局部更新桶接近原生的直接原因:变化撞上待查状态的 Memo 就地熄火,波及面不再扩大。

图:Owner 树与销毁——两棵树的分工

图:Owner 树与销毁——两棵树的分工

服务端渲染时,图怎么重建

服务端没有持续运行的必要:渲染一遍、吐出 HTML、丢弃运行时。Solid 的服务端渲染因此是"建图、读值、序列化、拆图"的单程流程;客户端注水时按同一份组件代码重新建图,序列化的初始数据直接灌进信号——两端跑的是同一张图的两次实例化,这解释了 6.2 讲的"同构无分类"。注水之后,图进入与纯客户端完全相同的常驻状态,服务端那次的图已经拆掉了。理解这一点,服务端渲染的诸多限制(Effect 不执行、生命周期简化)都不再需要特例记忆——单程流程里它们根本没有执行时机。

💡 验证心智模型的练习:拿本节伪代码,给 2.2 的防抖搜索场景手工推演一次"输入三个字符"的完整时序——几次登记、几次短路、几次冲刷。推得通,响应式内核就毕业了。

把伪代码推进到可用的最小库

10.1 开头的五十行能跑但不完整,补上批处理与清理,就是信号系统的心脏全貌:

let inBatch = false, pending = new Set(); function flush() { for (const node of pending) node.run(); pending.clear(); inBatch = false; } function schedule(node) { pending.add(node); // 去重的待办集合 if (!inBatch) { inBatch = true; queueMicrotask(flush); } } // batch:同一轮内的所有写入共用一次冲刷 function batch(fn) { const prev = inBatch; inBatch = true; try { fn(); } finally { if (!prev) queueMicrotask(flush); else inBatch = prev; } }

两个细节值得咀嚼。待办集合天然去重——同一轮里信号改五次,订阅者只重跑一次,这是"批量调度"四字的真实含金量;微任务冲刷保证了同步代码读状态的一致性:一批写入期间,任何同步读取都看到一致的中间态,不会读到改了一半的世界。对照 React:这里的冲刷点是微任务边界,React 的冲刷点在渲染调度——位置不同,"批量"的动机完全相同。

问题:为什么 Solid 的 Signal 不用 Proxy 实现?

Proxy 是 Vue 选择的路径:拦截对象属性访问来收集依赖,开发者写 state.count 就完成登记。Solid 选了显式函数调用 count(),换来三样东西:实现更薄(不需要代理层与目标对象的对应管理)、语义更直(函数调用即登记,没有隐式魔法)、兼容性更稳(Proxy 的陷阱场景——原生对象、相等性——全部绕开)。代价是书写上多一对括号。两条路线没有对错,但括号这个视觉锚点让依赖关系在代码里"看得见",这与本册反复强调的可推理性一脉相承。

本节要点回顾

  • 信号三环节:读取登记、写入短路、标记传播,全部行为由此派生。
  • Memo 状态机:待查状态让变化波及面就地熄火,是基准成绩的机制来源。
  • 两棵树各管一事:组件树管装配,Owner 树管归属与回收;销毁拆的是后者的枝。
  • SSR 是图的两次实例化:服务端单程拆图,客户端注水重建。

下一章把四个框架摆上同一张矩阵,全册对照在此收拢。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U