从零实现极简响应式状态库:核心原理与工程实践

📅 发布时间:2026/10/21 14:10:53
从零实现极简响应式状态库:核心原理与工程实践
前两天我得了个任务需求方只发过来三个字母rea。没有文档没有原型没有验收标准。干这一行久了都明白这种“简洁”往往不是信任你而是他自己也没想清楚。第一版沟通记录里我问了三遍“rea 想解决什么问题”对方回了句“就是觉得数据层重复代码太多了”。行方向这就有了——要的是一个数据层。再结合命名习惯rea 大概率是 reactive 的缩写。于是我把目标定为从零做一个极简响应式状态库让视图能跟着数据自动更新。这篇文章会把整个落地过程写清楚从接到含糊需求怎么拆解到核心的响应式原理和代码实现再到 computed、watch、批量调度这些“进阶件”最后用 todo 列表跑一遍真实业务验证。适合两类人看一是写过页面但没深究过数据驱动原理的前端开发二是想从零搭建自己的数据层、又不想一上来就啃大框架源码的学习者。1. 拿到“rea”之后我先做了三件事而不是写代码拿到一个含糊代号最容易犯的错就是马上开工。代码还没写人先被自己的“高效”感动了。可这种项目往往做着做着就发现方向偏了返工成本比一开始多花半天去澄清高得多。所以我给自己定了三件事第一验证 rea 这个缩写到底该是什么第二划定它明确不做的事第三跑通一个最小闭环用结果反向验证需求理解对不对。1.1 为什么我把它解读成 reactive而不是其他意思先说词源。“rea”三个字母在技术圈里最常见的展开就是 reactive因为很多开发者给目录、函数、状态切片起名时都习惯截取前缀reactive 截成 rea 非常自然。也有人会想到 REA 企业建模模型那是会计信息系统里描述业务资源的框架全称是 Resources、Events、Agents但那种场景下通常需要完整的场景说明不可能只丢一个代号过来。还有房地产经纪人缩写也是 REA显然和写代码没什么关系。我判断的核心条件其实是需求方那句话“数据层重复代码太多。”这往往意味着当前项目里有大量手动同步数据的逻辑表单改变后手动更新列表、接口返回后手动赋值给页面、多个模块之间互相通知。这类问题的通用解法就是封装一个响应式数据层让状态变化自动触发关联更新。浏览器里已经原生支持 Proxy实现成本又不高于是 rea 的方向基本确定做一套轻量的响应式状态管理。这里有个经验需求只有一个含糊代号时不要替需求方做“正确决定”而是找一个风险最低、能立刻跑通的方案去验证。rea 做成响应式数据层最坏情况也就是他们想要别的但这个数据层本身仍然可以在任何前端项目里当状态管理用不算浪费。1.2 给 rea 划定的功能边界我列了一份第一版功能清单精确到可以写代码的程度。要做的明确不做的reactive把对象变成响应式不做路由跳转effect自动追踪依赖并执行回调不做虚拟 DOM / 模板编译computed派生状态带缓存不做网络请求层watch监听某个值变化后回调不做跨标签页通信批量调度同一轮多次修改只触发一次更新不做持久化存储stop支持销毁 effect 防内存泄漏不做依赖注入容器边界这个东西很重要。很多人做技术原型死于“顺手加个功能”结果核心问题还没验证完外围代码堆了一大堆。rea 的核心问题只有一个数据变了依赖于它的函数怎么自动重新执行。其他功能都必须为这个问题服务优先级往后排。一旦想清楚这一点实现路径就非常清晰了一个依赖收集器一个响应式包装器一个调度器三个文件搞定。第一版目标也定得很朴素30 行核心代码让它能跑再用 100 行补齐工程化的边角。我始终相信一个能跑的最小闭环比一份完美的设计文档更有说服力。2. 最小核心Dep、reactive、effect 三件套很多人谈起响应式会背出一堆框架名词但真让他手写半天也写不出来。其实响应式最核心的机制只有一句话在读取数据时登记“谁在读”在修改数据时通知“登记过的人”。这句话拆开就是两个角色Dep 负责登记和通知effect 负责提供“谁在读”的上下文reactive 负责拦截读写操作。2.1 依赖收集到底在收集什么我用了最直白的方式实现 Dep。每一个被追踪的属性都对应一个 Dep 实例里面是一个 Set。Set 天然去重同一个 effect 重复订阅一个属性只会保留一次。activeEffect 是全局变量代表当前正在执行的 effect 回调。数据读取时activeEffect 如果存在就把它加进当前属性的订阅者列表数据写入时遍历订阅者列表逐个执行。let activeEffect null; class Dep { constructor() { this.subscribers new Set(); } track() { if (activeEffect) { this.subscribers.add(activeEffect); activeEffect.deps.push(this); } } trigger() { this.subscribers.forEach((sub) { if (sub.scheduler) sub.scheduler(); else sub.run(); }); } }这里有两个细节容易忽略。一是 track 里同时把 Dep 实例推入 activeEffect.deps这不是多此一举是为了后续 stop 时能从所有 Set 里把自己移除。二是 trigger 时优先执行 sub.scheduler 而不是 sub.run这是 computed、watch、批量调度能工作的基础。为什么用 Set 而不是数组因为 effect 在执行期间可能多次读取同一个属性比如循环里反复访问某个状态。数组会累积重复项导致一次修改触发多次回调Set 天然去重保证一个 effect 在一个属性上最多注册一次。2.2 Proxy 版的 reactive 和它的 get/set 陷阱有了 Dep接下来就是让读写操作自动落到 track 和 trigger 上。这里我直接选了 Proxy没选 defineProperty。原因很直接Proxy 能监听新增属性、删除属性、数组索引变化而 defineProperty 只能监听已存在的属性新增属性还得额外补 $set 之类的操作。现代浏览器对 Proxy 的支持也很稳定没有理由不用。const rawMap new WeakMap(); const proxyMap new WeakMap(); export function reactive(target) { if (typeof target ! object || target null) return target; if (proxyMap.has(target)) return target; if (rawMap.has(target)) return rawMap.get(target); const handler { get(proxyTarget, key, receiver) { const result Reflect.get(proxyTarget, key, receiver); if (activeEffect) { const dep getDep(proxyTarget, key); dep.track(); } if (typeof result object result ! null) { return reactive(result); } return result; }, set(proxyTarget, key, value, receiver) { const old proxyTarget[key]; const ok Reflect.set(proxyTarget, key, value, receiver); if (old ! value) { getDep(proxyTarget, key).trigger(); } return ok; }, deleteProperty(proxyTarget, key) { const hadKey Object.prototype.hasOwnProperty.call(proxyTarget, key); const ok Reflect.deleteProperty(proxyTarget, key); if (hadKey ok) { getDep(proxyTarget, key).trigger(); } return ok; }, }; const proxy new Proxy(target, handler); proxyMap.set(proxy, proxy); rawMap.set(target, proxy); return proxy; }getDep 实际上是一个按 target 和 key 缓存的 Mapconst depMap new WeakMap(); function getDep(target, key) { let deps depMap.get(target); if (!deps) { deps new Map(); depMap.set(target, deps); } let dep deps.get(key); if (!dep) { dep new Dep(); deps.set(key, dep); } return dep; }这里有一个反直觉的细节修改一个属性时如果新旧值相等我不会触发 trigger。这么做是为了避免不必要的重复执行。比如某个接口返回的数据和当前页面数据完全一致就不需要把依赖它的渲染函数全部重跑一遍。然后 effect 的实现也同样直白export function effect(fn, options {}) { const runner { deps: [], scheduler: options.scheduler, run: function () { cleanup(runner); effectStack.push(runner); activeEffect runner; const result fn(); effectStack.pop(); activeEffect effectStack[effectStack.length - 1]; return result; }, }; if (!options.lazy) runner.run(); return runner; }第一次跑通测试时我写了个很简单的计数器state.count 渲染到页面点击按钮 count 加一页面数字自动变。那一刻感觉还行但紧接着就发现了一个隐蔽问题。2.3 嵌套 effect 的坑activeEffect 被覆盖问题是这样的effect A 内部又创建了一个 effect B。B 执行完后activeEffect 被置为 nullA 中在 B 之后读取的响应式属性就再也收集不到依赖了。等那个属性变化时A 不会重新执行页面就出现了“改了数据但视图不动”的诡异现象。解决方式是用一个调用栈来维护 activeEffect而不是用单个变量。effect 执行前把自己压入栈顶执行完再弹出来activeEffect 始终指向栈顶元素。这样内层 effect 执行结束后外层 effect 仍然是活跃状态后续读取的属性也能被正确收录。const effectStack []; function cleanup(runner) { for (let i 0; i runner.deps.length; i) { runner.deps[i].subscribers.delete(runner); } runner.deps.length 0; }这个坑让我第一次意识到依赖收集是一场“递归退栈”的配合。内部有 effect、computed、watch 时activeEffect 不能靠单个全局变量硬撑。所有主流实现也会维护一个 effect 栈只是平时写业务代码根本碰不到这一层。真实项目中遇到“某些属性更新不触发”的灵异问题时先检查是不是嵌套 effect 覆盖了 activeEffect。3. 从能跑到好用computed、watch、异步批量调度当 reactive 和 effect 能正常跑通后rea 已经具备一个响应式系统的最小骨架。但业务不会只满足于“能跑”还需要三个高频能力派生状态、监听变化、合并多次更新。这三件事分别对应 computed、watch 和批量调度。3.1 computed把脏检查变成每次读取时重新求值computed 的本质是一个带缓存的 effect。它会追踪上游依赖但不会立即执行当依赖变化时只标记一个 dirty 标志真正的重新计算延迟到有人读取该 computed 时才发生。这种懒计算能省掉大量无意义的中间计算如果没人读重复计算就是浪费。computed 还必须有自己独立的 Dep因为下游 effect 依赖的是 computed 本身而不是 computed 内部依赖的那些属性。当上游数据变化时computed 要把“我变脏了”这件事传给下游订阅者下游才会重新读取并拿到新值。export function computed(getter) { let value; let dirty true; const dep new Dep(); const runner effect(getter, { lazy: true, scheduler() { dirty true; dep.trigger(); }, }); return { get value() { if (dirty) { value runner.run(); dirty false; } dep.track(); return value; }, }; }这里的顺序很关键先跑内部 effect 拿到新值再把当前活跃的下游 effect 收集进 dep。如果反了下游 effect 在读取时还没注册到 dep 上下一次上游变化时下游就收不到通知。我最初实现 computed 时只做了脏标记没加 dep结果页面里一用 computed首次渲染是好的之后数据更新视图纹丝不动。排查了半天才意识到上游只通知 computed 自己computed 却忘了通知它的下游用户。这个问题直接决定了 computed 是一个“更聪明的 effect”而不是一个简单的函数缓存。3.2 watch本质是“带回调的 effect”watch 的语义比 computed 更贴近业务我盯着某个数据源等它变化之后执行一段回调。实现上watch 就是给 effect 传入一个 schedulereffect 在依赖变化时不会立刻重跑而是把新旧值取出来交给业务回调。export function watch(source, callback) { let oldValue; const getter typeof source function ? source : () traverse(source); const runner effect(getter, { lazy: true, scheduler() { const newValue getter(); callback(newValue, oldValue); oldValue newValue; }, }); oldValue runner.run(); return () stop(runner); }为什么先要 runner.run() 一次因为 watch 回调里需要拿到旧值第一次不跑 getteroldValue 就是 undefined后续任何比较都会错乱。这个步骤也叫“初始化收集依赖”它让 watch 从创建那一刻就开始追踪目标的变化。traverse 则是当你传入一个响应式对象而不是函数时递归读取所有属性确保深层字段变化也能被追踪到。正常业务中 watch 一个对象里某个嵌套字段很常见不做 traverse 就会出现“改了内层字段watch 没反应”的尴尬。3.3 批量调度器把十次触发合并成一次这是 rea 里最有实际价值的一个设计。假设一次事件回调里连续修改了三个状态没有调度器时effect 会同步执行三次DOM 被反复更新三次。明明只需要最终结果中间过程全是在浪费性能。批量调度的思路很简单effect 的 scheduler 不直接执行回调而是把回调放进一个待执行队列用微任务异步清空队列队列里的同一个 effect 只保留一次。因为 Set 天然去重即便触发了十次最终也只是推入了一次回调。const pendingJobs new Set(); let flushing false; function queueJob(job) { pendingJobs.add(job); if (!flushing) { flushing true; Promise.resolve().then(() { flushing false; pendingJobs.forEach((job) job()); pendingJobs.clear(); }); } }使用上把 effect 的 scheduler 指向 queueJobeffect(fn, { scheduler() { queueJob(this); }, });我在实测中特意测过连续调用两次 addTodo 的情况。第一次修改数据时 effect 入队第二次修改数据时发现同一个 effect 已经存在不再重复入队。等到微任务执行时回调只跑一次页面只更新一次。这个“一次更新”的效果对渲染性能影响巨大尤其当列表较长或者渲染函数较复杂时。4. 进阶补丁嵌套对象、数组、delete、内存回收到这里rea 的“玩具版”已经能应付不少场景了。但一旦把真实业务数据塞进去马上会撞上几个边界问题嵌套对象如何代理、数组操作触发多少次通知、删除属性怎么通知依赖、销毁组件后 effect 还在不在。这一章全部是实战中踩过的补丁点。4.1 懒递归与 WeakMap 缓存reactive 的 get 陷阱里有一段关键代码如果读取到的值是一个对象就继续把它代理成响应式对象。这种方式叫懒递归而不是初始化时就把整个树递归代理一遍。懒递归的好处是性能好而且永远不会代理那些根本没人读过的孤立节点。缺点是需要处理“同一个对象返回同一个代理”的引用一致性问题否则state.list state.list这种判断会莫名失效。我用了两个 WeakMaprawMap 存原始对象到代理的映射proxyMap 存代理对象到自身的映射。传入的 target 如果是代理对象直接从 proxyMap 返回如果是原始对象从 rawMap 取代理都没有才新建代理并同时写入两个缓存。WeakMap 的作用是弱引用当原始对象不再被任何地方引用时WeakMap 里的键值对不会阻止垃圾回收。这是响应式系统能长期稳定运行的基础不用的对象就让它彻底死掉而不是被缓存占住内存。4.2 数组操作的几个“阴间”现象我对数组做了完整的实测先说结论Promise 的 Proxy 监听数组大部分情况下不会出现“改了内容视图不更新”的经典问题因为 push、pop、splice 这些方法在规范层面会经过 get、set、deleteProperty 陷阱。真正麻烦的是触发次数太频繁。一个 push 操作既会设置新索引的值又会修改 length每个操作都走一次 set 陷阱。如果你依赖了多个索引和 length理论上会收到多次 trigger。没有调度器时同一个 effect 可能被重复执行多次有了批量调度器后第二次入队会被 Set 去重最终只跑一次。所以我的经验是数组操作的正确性靠 Proxy性能靠批量调度器两者缺一不可。还有一个容易让人疑惑的场景effect 里只读state.list这个属性本身不读内部元素的任何字段那么对state.list.push(...)这种突变是不会触发更新的。因为依赖收集收的是“属性 key”list 的引用并没有变化。这在语义上是合理的但业务侧如果习惯了“整个数组都是响应式”的思维会觉得这是个坑。解决方法是让 effect 明确读取数组的 length 或某个索引让依赖收得更精确。4.3 让 effect 可以被销毁很多手写响应式系统会漏掉这一步。页面组件销毁后之前在 effect 里注册的依赖依然留在 Dep 的订阅者列表里数据一旦更新回调照常执行操作一个已经不存在的 DOM 节点轻则白白浪费性能重则因为闭包引用导致内存泄漏。要让 effect 可销毁就得让每个 effect 记住自己依赖了哪些 Dep销毁时从这些 Set 中把自己移除。这正是前面 cleanup 函数做的事遍历 runner.deps删除自己stop 就是简单地把 cleanup 暴露出去。export function stop(runner) { cleanup(runner); }组件卸载钩子里调用 stop(runner) 之后旧 effect 就从所有依赖记录的订阅者集合中消失了。之后再怎么改数据也不会触发已经销毁的回调。现实中很多数据流问题排查到最后都发现是“组件明明卸载了状态一更新却还在报错”根本原因就是没做销毁这步。5. 实战验证用 todo 列表和筛选统计跑通完整业务原理讲再多不如一个真实场景来得直接。我用 rea 做了一个 todo 列表包含增删改、筛选、统计未完成数量。这个场景虽然小但把响应式系统的常用功能全用上了对象数组、computed 派生、多次修改合并渲染。5.1 最小渲染绑定effect DOM 操作我不打算引入虚拟 DOM 或者模板编译那是另一个话题。rea 这一层只需要把数据映射成 DOM 字符串就够了。渲染绑定就是一个简单的 effect读取响应式状态后修改 innerHTML。const state reactive({ todos: [ { id: 1, text: 写生存指南, done: false }, { id: 2, text: 交水电费, done: true }, ], filter: all, }); const filteredTodos computed(() { if (state.filter active) return state.todos.filter((t) !t.done); if (state.filter done) return state.todos.filter((t) t.done); return state.todos; }); const remainingCount computed(() { return state.todos.filter((t) !t.done).length; }); const listEl document.querySelector(#list); const countEl document.querySelector(#count); effect(() { listEl.innerHTML filteredTodos.value .map((t) li>function addTodo(text) { state.todos.push({ id: Date.now(), text, done: false }); } function toggleTodo(id) { const todo state.todos.find((t) t.id id); if (todo) todo.done !todo.done; } function deleteTodo(id) { state.todos state.todos.filter((t) t.id ! id); }我特意在渲染 effect 里加了一个计数器验证批量调度是否真的生效let renderCount 0; effect(() { renderCount; countEl.textContent remainingCount.value 项待完成; }); addTodo(写周报); addTodo(预约体检); console.log(renderCount); // 期望是 1而不是 2实测结果符合预期连续两次 addTodo页面只重新渲染一次。原因在于两次 push 分别触发了 trigger但 effect 的 scheduler 把它推入同一个微任务队列第二次入队时 Set 发现已存在自动跳过。这个数字从 2 变成 1看起来微不足道但放到一个真实应用里对应的就是用户输入过程中页面不会闪烁多次、滚动条不会反复跳动。切换筛选条件也验证过state.filter 一变filteredTodos 的 dirty 标记被置为 true下游渲染 effect 重新读取它时触发重新计算视图立刻切到对应列表。整个过程没有手动调用任何更新函数。5.3 真实 code review 里我会挑出的三个问题如果有人在生产环境提交了这样一个手写响应式数据层我会重点检查三个地方第一所有 effect 的销毁时机。很多人写完 reactive 和 effect 就不管了组件卸载时不调 stop页面一多订阅关系越积越厚。我会在代码审查时明确要求任何长期存活的 effect必须绑定一个生命周期清理点。第二computed 的纯度。computed 的 getter 里只能做派生计算不能有修改其他状态的副作用。如果某个开发者把数据请求也塞进 computed一旦依赖变化computed 会在任意读取时机重新触发请求时机完全不可控这是非常糟糕的实践。第三依赖的精度。我在实战时就发现渲染函数里一旦出现JSON.stringify(state)这种写法和不必要的整对象读取会把一堆根本无关的属性也收进订阅列表。一个不相干字段变化能带崩整个渲染。合理做法是只读取当前渲染真正用到的字段让 effect 的依赖面尽可能小。6. 把 rea 的原理放进主流框架里看写到这里你会发现rea 根本不稀奇甚至可以说它踩在所有数据驱动框架共同的原理上。能不能直接在生产环境用是另一回事但理解它的实现会让你读任何框架的源码时省掉一大半功夫。6.1 数据驱动视图框架的两条路线数据驱动视图的主流框架大体有两条路线。一条是细粒度依赖追踪数据变化后只有真正订阅了变化数据的 effect 会执行。这种方式的优点是很省不会轻易触发无关模块缺点是需要运行时依赖收集实现复杂度高。rea 走的就是这条路。另一条是“不可变状态 全量重渲染”修改状态时生成一份新数据框架比较新旧数据后决定哪些组件需要更新。这种方式对开发者心智负担更小因为状态是不可变快照比较逻辑简单但为了性能要靠 memo 等手段做局部更新本质上还是在做“谁是依赖者”的追踪只不过时机从运行时提前到了渲染阶段。两条路线的核心是同一个问题修改数据后系统怎么知道哪些视图需要更新rea 把细粒度追踪的核心逻辑抽出来了。你如果亲手写过这套东西再去读别的框架源码看到的就只是它的成熟度、边界处理和性能优化而不会再觉得它神秘。6.2 如果重写一遍我会换掉的三个设计做完 rea 之后我复盘过一遍至少三个设计我重写时会直接换掉。第一依赖收集的粒度。现在 dep 是基于“属性 key”的数组和嵌套对象会带来很多无谓触发。我会改成按“对象的某一层路径”收集或者在依赖侧维护一个更细的依赖匹配规则。这样既能精准触发又能减少 trigger 次数。第二computed 的失效传导。第一版 computed 只实现了自身缓存和 dirty 标记忘记把变更传给下游后来补了 follows。重写时我会一开始就把 computed 设计成“特殊的 effect 独立 Dep”而不是写完再补漏洞。教训是派生状态天然有两层订阅关系一层是上游到 computed一层是 computed 到下游少一层整个链路就是断的。第三数组触发次数。虽然批量调度把重复渲染合并了但 trigger 本身还是被调用了很多次。重写时我会在 trigger 阶段做更精细的判断比如对数组的变化统一合并成一次通知而不是依赖调度器去擦屁股。这也是后来我在真实项目里看到列表滚动卡顿之后的优化方向。这些设计问题并不是说 rea 不够好而是它到了“能用”和“好用”之间的那个临界点时必然要面对更复杂的工程问题。而这些问题恰好就是那些成熟框架之所以成熟的原因。最后再分享一点我在实际操作中的体会拿到一个含糊代号的项目最忌讳的就是把时间花在争论命名和猜需求上先把范围划小用最小闭环跑通一个可演示的结果比任何嘴上论证都有说服力。rea 这个名字可能到最后也没人记得它是 reactive 的缩写但那个能跟随数据自动更新的页面才是真正让需求方闭嘴的东西。以后你再收到这种没头没尾的代号也许可以试试我的做法别慌先把方向定死再让代码自己说话。