资讯动态

从零手写响应式库 rea:核心原理、代码实现与踩坑复盘

发布时间:2026/10/21 4:57:36 来源:尧图企业网站定制
第一次见到 rea 这个名字是一个周末的深夜。当时我正在重构一个内部项目的列表页页面逻辑不复杂却因为状态同步问题改得身心俱疲。我需要的只是一个能自动把数据变化反馈到视图上的轻量方案而不是把整包框架引进来。于是自己动手写了一个响应式绑定库代号就叫 rea。这篇文章会把 rea 从设计到落地过程中最核心的思考、代码实现和踩过的坑都复盘一遍适合那些已经有前端基础、想深入理解响应式原理的朋友也适合准备自己封装一个小工具库的开发者参考。它不是那种“三分钟速成”的教程而是把一个库从零到一的全过程讲清楚希望能帮你在面对类似需求时少走弯路。1. rea 是什么以及它解决的真实痛点1.1 一个小页面为什么会让我想自研状态层先说背景。我维护的那个项目是一个老旧的运营后台技术栈几乎算是“原生 JS 手动渲染”。列表页面需要同时维护筛选条件、分页信息、选中状态、表格数据以及弹窗里的临时数据。一开始我图简单用一组全局变量存状态每次操作后用render()方法手动更新 DOM。问题很快就暴露了状态分散在各个地方一个筛选条件变了可能需要同时修改三四个变量然后手动调用几次不同的渲染函数如果漏了一次界面上就会出现过期的数据。最要命的是页面里还有很多像“当前选中行是否在可视区”这样的派生状态每次都要临时计算代码越写越乱。这种场景下大型状态管理框架虽然能用但对这种不带构建体系的旧页面来说引入成本太高先是模块系统然后是依赖转换最后可能还要处理各种兼容问题。那时候我想要的其实特别简单一个数据对象修改它的属性界面能自动跟着变化最好还能顺手计算派生值避免到处写重复的手算逻辑。这就是 rea 最初的雏形——不是要取代谁而是为了解决一个具体、细碎但普遍的前端痛点状态和视图的手动同步。1.2 哪些场景适合 rea哪些场景请绕道经过一段时间的实践后我总结出 rea 的适用边界。它特别适合中小型页面、工具型应用、老旧项目渐进式改造以及那些不想引入完整框架的轻交互场景。在这些场景里rea 能在不改变你原来的代码风格的前提下用一个相对小的 API 面积解决响应式更新的问题。我后来在几个活动页和原型项目里也用了它开发速度确实快了不少。但如果你正在做一个超大单页面应用内部有复杂的跨模块通信、嵌套路由、服务端状态缓存那么 rea 这种轻量方案就不够用了。它本身没有设计路由、状态持久化、跨标签页同步这些能力强行堆上去会让代码结构变得很别扭。还有一个不适合的典型场景你的团队已经深度依赖某个框架并且大量使用了框架自带的响应式体系这时候再引入一套新的响应式模型只会带来不必要的学习成本和心智负担。工具的价值在于匹配场景而不是越全能越好。认清边界比拼命堆功能更重要。2. rea 的整体设计与核心原理2.1 三个核心角色Observer、Dep、Watcher如果要用一句话说清楚 rea 的原理那就是“数据变化时找到所有依赖它的地方逐一通知更新”。这句话听起来简单真正落地需要三类角色协作。Observer 负责把普通对象变成响应式对象。我的实现里Observer 对对象的每个属性做递归劫持利用Object.defineProperty或者Proxy拦截属性的读取和写入。读取时会把当前正在运行的依赖收集起来写入时则触发通知。Dep 是依赖收集器。每个响应式属性都对应有一个 Dep或者按属性路径注册一个 Dep。它内部维护一个订阅者列表也就是 Watcher 的集合。当属性被读取时Dep 把当前 Watcher 添加进去当属性被写入时Dep 依次更新所有 Watcher。Watcher 是真正的响应执行单元。它抽象出“谁依赖了这个数据”这个概念。在 rea 里Watcher 可能是渲染函数、计算属性、侦听器甚至是某个定时器的回调。Watcher 执行时会标记自己为“当前正在运行”从而让 Observer 可以正确地把依赖收集到 Dep 中。这三者的协作构成了 rea 最基本的响应式闭环。这也是市面上很多响应式框架的理论底座。理解了这个模型再去看 rea 的代码就会觉得一切都是顺理成章的。2.2 依赖收集到底在收集什么我第一次设计 rea 时最大的困惑是“依赖收集”到底是在收集什么。后来我理解到核心是收集“函数执行环境”。举个例子我有一个计算函数total price * count它读取了price和count。当price变化时我实际上需要重新执行这个函数以便拿到新的total。所以price对应的 Dep 里应该存储一个 Watcher这个 Watcher 包裹着这个计算函数。当price被修改时Dep 就把这个 Watcher 拿出来执行一遍。如果用全局变量window.__reaTarget来标记当前正在执行的 Watcher那么依赖收集的过程就是当函数第一次执行时它访问了priceprice的 getter 发现window.__reaTarget存在就把它添加进自己的订阅列表。访问count时同理。这样一次函数执行完成后所有被读取过的属性都会记住这个函数对应的 Watcher。这里有一个细节非常关键依赖的收集一定发生在函数首次执行阶段。如果函数里有分支比如if (cond)而某个属性只在另一个分支里被读取那么首次执行时该属性的 getter 没被触发就不会被收集。后面即使那个属性变化这个函数也不会重新执行。这不是 bug而是“依赖收集”的正常规则——你只有读到了才知道自己依赖它。这让我第一次真正理解了响应式系统的物理边界。2.3 派发更新时的批量调度策略如果一次修改连续改变多个属性比如user.name 张三; user.age 30;而页面渲染依赖了这两个属性那么最理想的行为是只触发一次视图更新而不是执行两次渲染。所以在派发更新时rea 不会立即调用 Watcher 的更新方法而是把更新动作放入一个任务队列然后通过微任务统一调度。具体来说每个 Watcher 都有一个update()方法它会把自身标记为入队。如果入队前已经在队列里就忽略重复入队。入队完成后通过 Promise 异步执行 flush。flush 时统一调用每个 Watcher 的run()方法真正执行渲染或计算。这样即使你连续修改了多个相关属性一个 Watcher 也只会被真正执行一次。这种批量更新的思路有点像把消息通知合并成一次发布。它能显著减少不必要的重复计算尤其在渲染逻辑复杂的页面时性能提升非常明显。但也带来了新问题你拿到的渲染中间态可能不是每次修改后的即时结果而是一次合并后的最终状态。理解这一点对于排查旧值、新值的问题很重要我后面会专门讲。3. 从零手写一个 rea 的最小可运行版本3.1 搭建最小项目结构动手之前我先定义了 rea 的 API 面。为了保持简单我选择做三个对外接口observe(target)把对象变成响应式effect(fn)让一个函数自动追踪依赖computed(fn)返回一个带缓存的计算属性结果。这三个接口已经能覆盖我大部分业务需求。项目结构上我没有引入任何构建工具直接用一个rea.js文件使用 ES6 的Proxy实现响应式代理避免处理Object.defineProperty对新增属性的兼容性问题。文件内部划分四个模块Dep 类、Watcher 类、observe 函数、调度器。目录结构非常简单rea/ rea.js demo.html README.mddemo.html 用来做手动验证rea.js 是核心实现。这种单文件结构很适合在小项目里直接引入不需要打包器。如果你要发布成 npm 包后续可以再加 rollup 或者 esbuild 做压缩输出但最开始的时期保持简单比工程化更重要。3.2 实现响应式数据代理我选择用Proxy而不是Object.defineProperty核心原因是 Proxy 能拦截未知属性的读写天然支持新增属性而且不用预先遍历对象属性。下面是一段核心代码展示了如何把普通对象变成响应式对象const targetMap new WeakMap(); function observe(target) { if (target null || typeof target ! object) return target; if (targetMap.has(target)) return targetMap.get(target); const proxy new Proxy(target, { get(obj, key, receiver) { const value Reflect.get(obj, key, receiver); if (typeof key string) { track(obj, key); if (value ! null typeof value object) { return observe(value); } } return value; }, set(obj, key, value, receiver) { const oldValue Reflect.get(obj, key); const result Reflect.set(obj, key, value, receiver); if (oldValue ! value) { trigger(obj, key); } return result; } }); targetMap.set(target, proxy); return proxy; }这个实现里有一个细节访问嵌套对象时我会递归调用observe返回嵌套代理这样可以保证深层属性也是响应式的。这里有一个潜在的性能问题每次属性访问都创建一个新代理不会。因为observe内部用了targetMap做缓存同一个原始对象只代理一次所以递归访问的性能在绝大多数场景是可控的。3.3 实现 effect 与依赖收集有了响应式对象下一步是让函数能自动追踪依赖。实现的核心是使用一个全局变量来标记当前正在运行的 Watcher。let activeEffect null; const effectStack []; function effect(fn) { const watcher new Watcher(fn); watcher.run(); return watcher; } class Watcher { constructor(fn) { this.fn fn; this.deps new Set(); } run() { try { effectStack.push(this); activeEffect this; this.fn(); } finally { effectStack.pop(); activeEffect effectStack[effectStack.length - 1] || null; } } }当 effect 中的函数读取响应式属性时track函数会执行收集逻辑const depMap new WeakMap(); // 原始对象 - key - Dep function track(obj, key) { if (!activeEffect) return; let depsMap depMap.get(obj); if (!depsMap) { depsMap new Map(); depMap.set(obj, depsMap); } let dep depsMap.get(key); if (!dep) { dep new Set(); depsMap.set(key, dep); } dep.add(activeEffect); activeEffect.deps.add(dep); }这里的 Dep 直接用Set保存 Watcher 实例其主要作用就是“集合”而已。真正重要的是依赖的收集时机和跟踪对象的维度我用“原始对象 key”作为 Dep 的唯一标识这样当对象的某个 key 被写入时就可以精确地找到对应的 Watcher 集合。这里需要特别注意track里使用的obj必须是原始对象而不是代理对象。所以在observe的 getter 里我用的是Reflect.get(obj, key, receiver)中第一个参数obj也就是原始对象这样track才能正确命中depMap。这个细节一旦写错就会出现“数据变了但永远不触发更新”的诡异问题。3.4 派发更新与批量调度Watcher 被收集后写操作触发更新时调用trigger并配合调度器实现批量更新。let queue []; let flushing false; function queueWatcher(watcher) { if (flushing) { if (!watcher.id queue.indexOf(watcher) -1) { queue.push(watcher); } } else { if (queue.indexOf(watcher) -1) { queue.push(watcher); } } if (!flushing) { flushing true; Promise.resolve().then(() { const copy queue.slice(0); queue.length 0; flushing false; copy.forEach(w w.run()); }); } } function trigger(obj, key) { const depsMap depMap.get(obj); if (!depsMap) return; const dep depsMap.get(key); if (dep) { dep.forEach(watcher queueWatcher(watcher)); } }这里queueWatcher里最重要的一段逻辑是“去重”。同一个 Watcher 在同一个 tick 内只允许入队一次。因为修改多个数据属性时它们可能把同一个 Watcher 触发多次去重后能避免重复执行渲染函数。需要注意的是批量调度在异步任务中执行如果你在修改数据后立刻读取视图或者立刻检查某个 computed 的值拿到的是更新前的旧值。这不是设计缺陷而是所有响应式系统都会面对的时间差问题。正确做法是使用nextTick之类的机制在队列 flush 之后再读取结果。3.5 computed 与 effect 的扩展有了 effect 和响应式代理computed 的实现就顺理成章了。一个 computed 就是带缓存的 effect它只在依赖变化时重新计算并且计算后把结果缓存住多次读取不会重复执行函数。我实现 computed 时的思路是这样computed 内部创建一个 WatcherWatcher 在run()时计算值并保存到this.value。同时给 Watcher 加上dirty标志依赖变化时把dirty设为 true读取时如果dirty为 true 再重新计算否则直接返回缓存值。这样保证 computed 不会在每次访问时都重复计算性能很好。function computed(fn) { const watcher new Watcher(fn); watcher.dirty true; deli: watcher 被收集时依赖变化不会立即执行 fn而是等别人读取它 const result { get value() { if (watcher.dirty) { watcher.run(); watcher.dirty false; } return watcher.value; } }; return result; }这里有个坑Watcher 运行后它依赖的 Dep 中存的是 Watcher 实例但 computed 的依赖变化时Watcher 并不会主动重新执行。所以要让 Dep 通知时把 Watcher 的dirty设为 true。我是在 Watcher 里加了一个update()方法默认情况下update()直接入队重新执行而用于 computed 的 Watcher其update()方法只把dirty设为 true 并调用父级依赖的更新。这一点可以根据实际需求微调但核心思想就是“延迟计算、按需缓存”。4. rea 在真实业务场景中的接入经验4.1 与原生 DOM 操作混用时的注意事项rea 最大的优势是你能在已有项目中逐步引入不用一次性推倒重写。但它和手写 DOM 操作混用时有一些常见的冲突点。我踩过的最典型的一个坑是我使用 rea 管理数据一个合作伙伴的模块里却用innerHTML直接重写了某个容器。这个容器原本是由 rea 渲染出来的重写后 rea 的数据和视图就脱节了——后续响应式数据一变视图刷新函数仍然会尝试重绘这个容器但容器里的内容已经不是我预期的结构导致渲染结果错乱。我的解决原则是用 rea 管理的区域尽量不要直接用原生方法去改它的 DOM 子树。如果确实需要和外部组件协作就给外部组件划分一个独立容器让 rea 只负责它自己渲染的那部分。如果你希望 rea 渲染结果也能被外部操作接管那么最好在外部操作后手动调用一次依赖数据的trigger来同步视图。但在项目里我建议还是通过“数据中转”来维护一致性这样代码更纯粹排查问题也更容易。4.2 性能优化什么时候需要手动拆分更新rea 的批量更新可以减少重复渲染但如果你在一个很大的响应式对象里频繁更新其中一个字段每次更新依然会触发所有依赖这个字段的 Watcher。这在大型表格或长列表场景里会成为瓶颈。我实际遇到的需求是一个表格组件它监听一个巨大的表格数据对象。我最初把整个表格数据对象作为单一响应式对象每行数据都是其属性。结果修改一行中的某个单元格时视图更新函数会因为依赖了整个对象而重绘整张表格。后来我把每行数据拆成独立的响应式对象让每一行的渲染函数只收集自己这一行的依赖。这样修改某一行的一列时只会更新那一个单元格对应的 Watcher性能提升非常明显。这个优化思路其实很通用不要把“所有数据”都放在同一个响应式对象里要让响应式粒度贴近你的视图粒度。如果你的页面有多个相对独立的板块比如筛选区、列表区、详情区那就把它们的 state 分开管理而不是塞进一个大对象。响应式粒度越细无谓的渲染就越少。4.3 在现有工程化项目中的按需引入方式如果你要在一个使用 webpack 或 vite 的工程里引入 rea并不需要把整个库全量打包。由于 rea 是单文件、无依赖的库可以直接在package.json中声明一个对./src/rea.js的本地引用。更合理的做法是把 rea 的核心代码提取为一个独立 package然后通过 npm link 或 file: 协议引用。当然如果你只是想在老项目里快速试一下也可以直接script src./rea.js/script引入然后挂载到全局。rea 整体只有几百行代码不会带来体积上的压力。但要注意的是如果页面本身使用了 ES6 语法而你还没有 babel 转译你可能需要把 rea.js 转换成 ES5 后再引入或者使用对应的 IIFE 版本。我在实践中的习惯是把 rea 作为独立的本地模块维护也会为它写一个简单的api.md文档记录每个 API 的输入输出和边界条件。这个小习惯帮我节省了大量回溯时间尤其是在项目持续迭代之后。5. 常见问题与排查实录5.1 循环依赖导致栈溢出我最早把 rea 的 watch 逻辑做得比较激进一个 Watcher 执行时如果它依赖的某个属性也间接依赖同一个 Watcher就可能形成循环。典型场景是effectA读取了 A 和 BeffectB读取了 C而 C 的 getter 里又读取了 A。执行effectA时会触发 C 的读取而 C 的 getter 又读取 A最后在 A 的 getter 里发现当前运行的 Watcher 还是effectA本身再把effectA加进 A 的依赖集合。这样effectA和 C 之间形成了环更新时互相触发最终栈溢出。排查这类问题的最好方法是在track函数里做一次判断如果当前 Dep 已经包含 activeEffect就不再重复添加。但更根本的解法是在业务层尽量避免形成这种循环依赖。如果确实不可避免就要调整数据结构让派生数据之间是单向依赖关系而不是互相依赖。我后来给 rea 增加了依赖树的打印方法用日志定位到底是哪些属性形成了环大大提高了排查效率。5.2 数据更新了但视图不刷新这是响应式系统最常见的疑难杂症。我遇到过的原因有好几类这里分享最常见的几个。第一你读取属性时使用的是代理对象还是原始对象。如果你不小心用原始对象参与计算那么原始对象没有 getter 拦截就不会触发 track。解决方法是统一使用observe返回的代理对象读取数据。第二你写入的属性根本不是响应式属性。如果你在observe之后给对象添加了一个全新的 key但你的 observe 使用的是Object.defineProperty而不是Proxy这个新 key 就不会被劫持。虽然 rea 默认用 Proxy但如果你在某个版本里为了兼容性用了 defineProperty就会踩这个坑。建议如果不要求老浏览器直接放弃 defineProperty。第三Watcher 的依赖没有被正确重新收集。比如你的渲染函数里有条件分支首次执行时某些属性没有读取后来数据变化导致分支切换但那些属性不在依赖集合里。这时候需要强制刷新一次依赖或者确保依赖收集的覆盖范围足够广。这也是我后来把模板编译阶段的所有可能分支都生成一次访问的原因之一。5.3 批量更新导致的旧值问题由于 rea 的更新是异步批量处理的有时你在修改数据后立即读取依赖该数据的其他属性会拿到更新前的旧值。这不是 bug而是异步特性带来的时间差。比如const state observe({ count: 0 }); effect(() { console.log(count is, state.count); }); state.count 1; console.log(state.count); // 这里的 count 会是多少如果是立即读取state.count已经变成 1因为代理对象的 set 是同步的。但effect的视图更新函数是异步执行的所以日志输出顺序可能不是你直觉里的顺序。如果业务里确实需要在一次修改后立即读取更新后的 DOM 或 computed 结果我建议在 rea 里提供一个类似nextTick()的方法把后续操作排在 flush 之后执行。我的实现里直接用了Promise.resolve().then(...)所以只要在你的代码之后用await nextTick()即可。5.4 内存泄漏排查响应式系统的内存泄漏多数都发生在没有正确清除 Watcher。比如你在一个弹窗组件里创建了一个 effect弹窗关闭后这个 effect 仍然存在并且还被页面上的某些响应式依赖持有导致无谓的运算。更麻烦的是如果这些依赖链中还引用着 DOM 节点最终会导致整个弹窗 DOM 无法被回收。我的解决方案是为 Watcher 提供dispose()方法它会遍历并清空所有已绑定的 Dep 集合从每个 Dep 里移除当前 Watcher。同时我要求所有创建 effect 的地方在组件销毁时必须调用dispose()。实践下来这个要求在早期经常被忽略。后来我封装了一个生命周期管理工具统一监听页面卸载或组件销毁事件来调用清理逻辑。这套机制做好之后我在一个连续操作 2 小时的活动页上测过内存占用增长曲线变得平稳没有再出现明显的泄漏。5.5 实际操作中的 debug 技巧最后分享几个我在排查 rea 问题时经常用的 debug 方法。第一在track和trigger里加上条件打印输出当前操作的对象 key、Watcher 编号能直观看到依赖收集和派发更新的链路。第二为每个 Watcher 起一个名字或者给自定义fn绑定一个name在日志里能认出是哪一个渲染任务。第三我会用performance.mark和performance.measure记录每次 flush 的耗时快速定位哪些 Watcher 拖慢了渲染。这些方法不复杂但确实能大幅提升排查效率。如果你也打算自己写一个类似的响应式工具我的建议是不要一开始就贪大求全先把observe、effect、computed这三个接口做到稳定再慢慢扩展 watch、数组方法、模板编译等能力。rea 这个项目到目前也没有做很多花哨功能但它在多个真实页面里稳定运行了很长一段时间。我个人的体会是一个库能不能撑起业务不在于它有多全而在于它把你最需要的那几个能力做透。遇到不合适的场景不妨勇敢地砍掉遇到使用的纠结就回头想想“数据变化后谁需要知道”这个根本问题。保持简单就能走远。

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

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

免费获取报价 →
↑