1. 从“手动同步”到“自动反应”:为什么我会写 Rea 这个内核
做前端时间一长,你会发现大部分复杂度的根源都指向同一个词:状态。页面里的用户信息、购物车、筛选条件、分页参数,哪一块数据都不是孤立的。A 区域改了用户名,B 区域的头像就得跟着换,C 区域可能还要重新算会员等级和推荐位。最原始的做法是写好多个更新函数,手动去调用;调多了以后代码就开始发散,今天改 A 忘了通知 B,明天 B 的数据错乱了还找不到原因。
我自己也经历过那个阶段。从最开始的“手动 set 数据 + 手动刷新页面”,到后来用事件订阅,谁变了就去 emit 一下,再到后来用全局 store,本质上都是在解决同一个问题:数据变化之后,依赖它的逻辑怎么才能自动、准确、高效地跟着变。这个思路有一个专门的叫法——响应式(Reactive)。
前几天我把多年前写的一个实验性项目重新整理了一遍,给它起了个特别简短的名字:Rea。它不算一个完整框架,严格说是一颗极简的响应式状态管理内核,核心能力就是让数据变成响应式对象,让依赖数据的副作用函数自动重新执行。写这个东西不是为了造轮子替代谁,而是想搞明白响应式背后那几个关键机制到底是怎么运转的。如果你是那种“用了一年框架但心里总觉得缺点什么”的开发者,或者你想自己从零搭一个轻量状态层,这篇内容应该能给你一些可以落地的参考。
Rea 的整个实现过程其实特别适合当成一颗小玩具来看待:核心源码量不大,但麻雀虽小五脏俱全。你会碰到依赖收集、依赖清理、惰性计算、调度器批量更新这些看起来高端的名词,但它们拆开之后都不复杂,也就是几个 Map、Set 再加上一个微任务队列的事。
2. 咖啡店的订单系统:理解响应式闭环的三个角色
在贴代码之前,我想先把响应式的核心模型讲清楚,因为很多版本的问题都出在概念没理顺。我习惯用一个咖啡店订单系统的例子来解释。
想象一家咖啡店,店员手里有一叠顾客订单,张三点了美式,李四点了拿铁。后台通知说牛奶涨价了,这时候店员不需要跑出去喊全店顾客,只需要找到点了拿铁的李四,告诉他价格变了。这个过程拆成三件事:
- 顾客点单这个动作,会被记录到订单簿上——这就叫依赖收集(Track);
- 有原料价格变化时,翻订单簿去找到受影响的人——这就叫依赖触发(Trigger);
- 店员通知顾客“你的饮品价格变了”——这个“通知后要执行的动作”,就是副作用(Effect)。
把角色平移过来:顾客就是一段需要读取数据的函数(比如渲染函数、计算函数),订单簿就是弱引用 Map 组成的依赖关系表,店员就是触发器,谁的数据变了就去找读了这个数据的人,并且叫他重新跑一遍。
这里有一个非常关键的设计点:为什么要用“依赖收集”,而不是简单的“数据变了就全部执行一遍”?因为全量执行虽然简单,但规模一大就会出现严重的性能浪费。页面里有十个区域读品牌名,但只有两个区域读会员等级,品牌名一变,你把十个区域全部重跑一遍,其实有八个是不需要动的。依赖收集能做到“精确通知”,这是响应式这个思路最有价值的地方。
Rea 要实现的,就是把这三件小事用代码完整地串起来:读取对象属性时记录“当前正在运行的函数”,修改对象属性时找到所有读过这个属性的函数并重新调用它。听起来简单,但真正实现的时候细节非常多,而且容易踩坑,下面我逐步展开。
3. 读写代理与依赖追踪:Rea 的核心实现细节
3.1 为什么选用 Proxy 而不是 defineProperty
要实现“读取时收集依赖、修改时触发依赖”,第一步是要能拦截对象属性的读写。老一代方案通常用 Object.defineProperty,逐属性改写 getter 和 setter。它的问题在于:新增属性需要额外处理,数组索引和 length 的变化天然被动,深度嵌套对象必须递归处理一遍才能生效。
Rea 直接选了 Proxy 做数据代理。Proxy 可以拦整个对象的读取、写入、删除、遍历等一组核心操作,而且用 Reflect 转发默认行为时能保留原对象的 this 指向和属性语义。一个关键区别是:defineProperty 在对象创建时就把属性“焊死”了,新增属性能不能响应要看有没有补做处理;Proxy 是活着的手,任何属性访问都会被感知,新增属性天然就能触发依赖追踪,不需要额外补丁。
在 Rea 里,reactive 函数接收一个普通对象,返回它的代理对象:
function reactive(obj) { return new Proxy(obj, { get(target, key, receiver) { const result = Reflect.get(target, key, receiver); track(target, key); return result; }, set(target, key, value, receiver) { const oldValue = target[key]; const result = Reflect.set(target, key, value, receiver); if (oldValue !== value) { trigger(target, key); } return result; } }); }这里的 track 和 trigger 就是响应式的左右手。get 里做依赖收集,set 里做变更通知。反射函数 Reflect 不是随便用的,如果你直接写 target[key],那么当对象的 getter 里夹带了 this 访问时,目标对象内部的 this 不会自动指向代理对象,很多奇怪的 this 绑定问题就是这么来的。用 Reflect 就能把 receiver 传进去,保持一致。
3.2 Track 与三层依赖表结构
track 的实现要用到一张多层嵌套的表。我先定义两个全局变量:
let activeEffect = null; const targetMap = new WeakMap();targetMap 是一个 WeakMap,目标对象作为键,它的值是一个 Map——属性名作为键,再往下一层是一个 Set——存放所有依赖这个属性的 effect 函数。为什么要三层?因为一个对象有多个属性,每个属性可能被多个副作用读取,读取的副作用数量用 Set 来去重。
真正的 track 函数长这样:
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); } deps.add(activeEffect); }先别急着往下看,这里有两个细节值得停下来想。第一,为什么用 WeakMap 而不是普通 Map?因为如果再用普通 Map 强引用目标对象,被代理的原对象就算没人用了也永远不会被垃圾回收,页面就会带着一堆废弃对象的内存。WeakMap 的键是弱引用,目标对象被销毁,对应整条依赖链也会被自动回收,这是内存安全的基础。第二,activeEffect 是一个全局变量,它表示“当前正在运行的那个副作用函数”。只有在一个副作用函数执行期间去读响应式数据,才会建立依赖关系;如果在某个普通函数里读数据、而当前并没有 effect 在运行,那么这个读取不需要被记录。
3.3 Trigger 与变更通知
trigger 的职责是:数据改了就找出所有读了这个数据的 effect,逐个重新执行。
function trigger(target, key) { const depsMap = targetMap.get(target); if (!depsMap) return; const deps = depsMap.get(key); if (deps) { const effects = [...deps]; effects.forEach((effect) => effect()); } }为什么要把 deps 展开成新数组再执行?如果某个 effect 在执行过程中又改变了同一个属性,那它可能把自己加进这个 Set,或者把自己删掉,直接在遍历 Set 的同时修改 Set,轻则跳过执行,重则死循环。拷贝一份快照先跑,能规避这类问题。
到此为止,一个最简响应式对象已经能工作了:你调用 reactive 包一层对象,再用 effect 包一个函数。函数运行时读到响应式对象的属性,依赖被记录;之后修改这个属性,函数就会自动重跑。但还差点关键的东西,也就是 effect 函数本身。
3.4 effect 函数的正确写法
一个最朴素的 effect 实现是这种,但我不建议你直接这么写:
function effect(fn) { const wrapped = () => { activeEffect = wrapped; fn(); activeEffect = null; }; wrapped(); return wrapped; }它的问题在于,如果 fn 内部嵌套执行了另一个 effect,内层的 effect 执行完会把 activeEffect 置成 null,外层 fn 后续再读数据就追踪不到了。正确做法是用一个执行栈来维护 currentEffect,而不是单变量覆盖:
const effectStack = []; let activeEffect = null; function effect(fn) { const wrapped = () => { cleanup(wrapped); activeEffect = wrapped; effectStack.push(wrapped); fn(); effectStack.pop(); activeEffect = effectStack[effectStack.length - 1] || null; }; wrapped(); return wrapped; }注意这里提前调用了 cleanup(wrapped),这就是下一章的核心。
4. 依赖清理与僵尸效应:内存安全比功能本身更关键
先说一个典型场景。一个响应式对象 person,有 name 和 age 两个字段。某个 effect 函数里写了一个条件分支,age 小于 18 时读 name,否则读 address。第一次运行 age=16,它读了 name,所以 name 的依赖 Set 里记录了它。后来 age 改成 20,效果重跑,这次读了 address。这个时候,理想状态是这个 effect 应该出现在 address 的依赖里,同时从 name 的依赖里移除。但如果不做清理,它依然留在 name 的依赖 Set 里,以后 name 无论改成什么,它都会被无意义地重新执行一遍——这就是“僵尸依赖”。
更危险的是长期累积。每次这个 effect 都把自己留在一个不该留的依赖集合里,时间一长,依赖关系会越来越混乱,不该触发的副作用频繁触发,性能崩坏是迟早的事。所以每次 effect 重跑之前,都必须把它从所有它曾经挂过的依赖 Set 中删除,然后重新收集。
实现方式其实只需要两样东西:存下当前 effect 挂过的所有依赖 Set,提供一个 cleanup 函数清空它们。
function cleanup(targetEffect) { for (const dep of targetEffect.deps) { dep.delete(targetEffect); } targetEffect.deps.length = 0; }然后在 track 里,把 activeEffect 加入 deps 的同时,也把 deps 记到 activeEffect.deps 里:
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); } deps.add(activeEffect); activeEffect.deps.push(deps); }注意这里 front 的一件会被用到吗?这里存在一个细节,如果 activeEffect.deps 里重复存同一个 Set,cleanup 时会导致同一个 Set 被 delete 两次,第一次删了元素,第二次就无从删起,不过 Set.delete 一个不存在的元素不会报错,只是净一个重复调用的开销。更好的做法是在 push 之前判断一下。这属于实现层级的取舍,不同框架的写法不一样,但核心思路基本相同。
我特别想强调的是:cleanup 不要省。第一次写 Rea 时,我一开始图省事略掉了清理逻辑,跑出来的效果是页面逻辑在数据变化后“偶尔正常、偶尔错乱”,最后排查半天才发现是曾经读到旧字段的副作用悄悄遗留在旧依赖列表里,导致本该立即触发的更新被拉长成了一次多余的重复执行。你如果要从零写自己的响应式库,第一次跑通之后,第一件事就是补上这一块,而不是等 bug 再现。
5. 计算属性与缓存失效:惰性求值让派生数据只算一次
写业务的时候,很多“数据”不是真实存在的原始变量,而是从原始变量派生出来的。比如购物车里每件商品的价格和数量都有了,总价就是一个派生值;列表和筛选条件都有了,过滤后的列表就是派生值。Rea 处理这类派生数据的工作叫计算属性(computed)。
计算属性有两个特征:第一,它能缓存结果,只有依赖变了才重新计算,没变的时候读它应该直接拿上次的结果,而不是每次读都算一遍;第二,它也是懒的,如果一个计算属性定义了但从来没人读,那么它连一次都不应该计算。
实现计算属性最优雅的位置是在 effect 机制上加一层控制:effect 需要支持两个额外参数——lazy(是否立即执行一次)和 scheduler(当依赖变化时,不直接重跑,而是执行一个自定义计划函数)。
下面是个简化版计算属性的结构:
function computed(getter) { let value; let dirty = true; const runner = effect(() => { value = getter(); dirty = false; }, { lazy: true, scheduler: () => { dirty = true; } }); return { get value() { if (dirty) { runner(); } return value; } }; }流程是这样的:computed 内部创建一个 lazy effect,创建时不立即执行;当外部第一次读取 computed.value 时,发现 dirty 为 true,于是执行 runner,跑一次 getter,得到结果缓存下来并标记 dirty 为 false。之后只要它的依赖没有变化,读多少次都直接拿缓存值。当依赖变化时,scheduler 不立刻触发重算,只是把 dirty 标回 true,等下一次被读取时再惰性计算。这一套组合拳下来,起到了精确、延迟、可缓存的效果。
用这套机制处理「总价」就非常直观:你把每件商品的单价和数量变成响应式数据,computed 里计算总价。任何商品数量变化,computed 只把自己标记为过期;哪怕用户连续改五次数量,只要中途没人读总价,真正计算只会在最后读取时发生一次。这就是惰性计算的价值,直接省掉无意义的重复计算。
在实际项目里,computed 用得越多,响应式依赖图就越像一棵有向树——根节点是原始状态,枝叶是各种派生数据。只要依赖清晰,它能让代码的可读性提升一个档次,因为业务公式可以用代码直接声明出来,而不是散落在各种更新逻辑里。
6. 调度器与批量更新:连续修改五次数据,只渲染一次
到了这里,核心响应式机制已经足够跑通了。但它还有个明显的效率问题:如果你在一个函数里连续修改五个响应式属性,那么每一个属性触发 trigger 时,都会同步执行一遍依赖它的 effect。很多 effect 都是渲染、计算这种重活,执行次数直接和修改次数挂钩,这显然不够聪明。
Rea 的做法是引入一个调度器(scheduler),把“同步执行 effect”改成“把 effect 放入一个批量任务队列,排队执行”。这个队列有两个原则:去重和异步。
- 去重:同一个 effect 在两个属性里都被触发了,它只需要进队列一次。用 Set 存任务就能轻松做到。
- 异步:把批量刷新安排在当前同步代码跑完之后。利用 Promise.resolve() 的微任务机制,就能实现这一点。
const queuedEffects = new Set(); let isFlushing = false; function schedule(effectFn) { queuedEffects.add(effectFn); if (!isFlushing) { isFlushing = true; Promise.resolve().then(flushQueue); } } function flushQueue() { queuedEffects.forEach((effectFn) => effectFn()); queuedEffects.clear(); isFlushing = false; }然后把 trigger 里的effects.forEach((effect) => effect())换成effects.forEach((effect) => schedule(effect))。这一步做下来,整个 Rea 就从“每改一个属性立刻更新”变成了“同步代码跑完后统一更新一次”。
这里分享一个实际体验。我最开始想用 setTimeout 来延迟 flushQueue,结果发现 setTimeout 会拖到下一个事件循环,界面可能先闪现旧数据再更新,视觉上会闪一下。改成 Promise 微任务之后,批量刷新发生在当前同步任务结束之后、浏览器重新渲染之前,时机刚刚好,不会闪烁。所以如果你要抄这个模式,调度器的载体建议优先选 Promise。
还有一点要注意:批量执行时的触发顺序。如果要保证内层 effect 先执行、外层 effect 依赖它结果时再执行,那么 flushQueue 最好是被触发顺序执行。简单方案是维护一个队列数组,按触发顺序先进先出;但用 Set 的遍历顺序实际上就是插入顺序,所以也能满足大部分场景。复杂场景如果出现嵌套 effect 或者 effect 之间有父子关系,建议给每个 effect 标记一个执行深度,按深度排序后再执行。
7. 边界情况提示:这些坑我在 Rea 里真实踩过
Rea 写完之后,我拿它试水了表单联动校验和一个小型数据看板,过程中踩过几个坑,值得单独拿出来说。
第一个坑是 effect 死循环。如果一个 effect 函数里既读取了依赖数据 A,又修改了数据 A,那么它的执行方案通常会导致执行结束后再次触发自身。Rea 能帮你收集依赖、触发更新,但不会帮你判断业务逻辑是不是死循环。踩进去最常见的形式是:你在效果里写“根据某个值计算新值,顺手把这个新值写回同一个源对象”,外部修改源对象触发效果,效果再改源对象,变成永动机。解决办法是:把写入动作挪到计算属性里,让 effect 只做有外部影响的动作(比如渲染、日志、发请求),或者手动增加递归保护标记。
第二个坑是对象嵌套。Proxies 虽然能拦截到嵌套对象的属性访问,但如果你读的属性值本身还是一个对象,读出来的对象通常不会自动代理。以 Rea 的简单实现为例,state.settings.theme读到的 settings 是原对象,theme 变化是触发不到依赖里的。所以成熟的做法是在 get 拦截时对嵌套对象也递归调用 reactive,但要注意缓存代理对象,避免每次 get 都创建新 Proxy,否则响应式对象的身份就变得不稳定。
第三个坑是数组的特殊处理。Rea 用 Proxy 拦截数组的 set 操作时,修改 arr[0] 会同时触发 length 的变化,如果你依赖了 length,那一次修改可能同时触发两个 key 的通知,这是正常的。真正的坑在于,如果你在 effect 里读 arr.length,在另一个地方又 arr.push 一个新元素,这个 push 会触发一次 length 的 set,从而触发 effect;但在 Rea 这种“属性触发”风格里,依赖的是 length 这个 key,而不是“数组内容变了”这个抽象概念。如果你的副作用逻辑依赖整个数组包含哪些元素,最好明确读一次数组本身(例如用arr[...])或者读 length,不要想当然以为 push 会自动触发所有依赖数组的 effect。
第四个坑是内存膨胀,尤其是在 graph 复杂的情况下。依赖清理既然已经是核心机制了,那么每个 effect 身上的 deps 数组就会频繁增删,如果目标对象特别多、effect 特别多,deps 数组的大量重建会带来额外内存分配。可以做一个微优化:缓存数组,或者用链表结构替代数组来减少 GC 压力。但作为教学内核,我觉得没到这一步之前,先保证正确性最重要。
8. 怎么把 Rea 继续推演下去:从内核到可用层
Rea 现在能做什么?它能做响应式状态管理、派生数据、延迟计算和批量渲染。但距离一个完整可用的前端状态方案还差几层:视图绑定、异步处理、模块拆分、跨组件通信。所以下一阶段我建议你给它接三样东西:
第一是视图绑定层。最朴素的做法是让渲染函数本身成为一个 effect。数据变了,渲染函数重新执行,再把输出的 Dom 节点替换进页面。这样就能看到一个微型的前端渲染循环。不需要任何框架,几行代码就能把数据和页面打通。
第二是模块化状态。Rea 的 reactive 是面向单对象的,你可以再包一层 store 结构:一个全局 WeakMap 里存多个命名空间,每个命名空间有自己的状态、getter 和 actions。这就是一个轻量级全局状态管理器的雏形。
第三是异步与竞态处理。effect 里如果发了请求,数据回来后你要再做一次判断,判断这次结果是不是发出请求之后的最新结果。否则请求乱序返回时,页面数据会覆盖成过期内容。这个问题的通用解法是给每个请求标记一个序列号,效果函数每次重跑时递增序列号,旧请求回来时发现序列号不等于最新就直接丢弃结果。
这几层做完之后,Rea 就不再是一颗只有内部机制的小玩具,而是一个可以支撑真实中型应用的状态骨架。整个过程里最值得学到的,其实不是那几百行代码,而是“如何把一种抽象机制拆成几个清晰的子问题,再逐个用最直白的数据结构解决”的思路。响应式并不是什么魔法,它只是把业务里“数据变了,界面要跟着变”这个直觉,翻译成了几个 Map、Set 和微任务之间的默契配合。