☰
从零实现极简响应式状态库:核心原理与工程实践
2026/10/21 14:10:48 网站建设 项目流程

前两天我得了个任务,需求方只发过来三个字母: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 缓存的 Map:

const 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 被置为 null,A 中在 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 传入一个 scheduler,effect 在依赖变化时不会立刻重跑,而是把新旧值取出来交给业务回调。

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 回调里需要拿到旧值,第一次不跑 getter,oldValue 就是 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 指向 queueJob:

effect(fn, { scheduler() { queueJob(this); }, });

我在实测中特意测过连续调用两次 addTodo 的情况。第一次修改数据时 effect 入队,第二次修改数据时发现同一个 effect 已经存在,不再重复入队。等到微任务执行时,回调只跑一次,页面只更新一次。这个“一次更新”的效果对渲染性能影响巨大,尤其当列表较长或者渲染函数较复杂时。

4. 进阶补丁:嵌套对象、数组、delete、内存回收

到这里,rea 的“玩具版”已经能应付不少场景了。但一旦把真实业务数据塞进去,马上会撞上几个边界问题:嵌套对象如何代理、数组操作触发多少次通知、删除属性怎么通知依赖、销毁组件后 effect 还在不在。这一章全部是实战中踩过的补丁点。

4.1 懒递归与 WeakMap 缓存

reactive 的 get 陷阱里有一段关键代码:如果读取到的值是一个对象,就继续把它代理成响应式对象。这种方式叫懒递归,而不是初始化时就把整个树递归代理一遍。懒递归的好处是性能好,而且永远不会代理那些根本没人读过的孤立节点。缺点是需要处理“同一个对象返回同一个代理”的引用一致性问题,否则state.list === state.list这种判断会莫名失效。

我用了两个 WeakMap:rawMap 存原始对象到代理的映射,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 的缩写,但那个能跟随数据自动更新的页面,才是真正让需求方闭嘴的东西。以后你再收到这种没头没尾的代号,也许可以试试我的做法:别慌,先把方向定死,再让代码自己说话。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询