
第一次看到项目代号rea时你会想到什么老前端可能会猜它是某个框架的缩写做过业务建模的人可能想起资源-事件-代理那套体系而更多人只是觉得它像某个单词被截掉了一半。我手头维护的一个内部小工具代号就叫rea。它不是一个界面框架也不是完整脚手架而是一个体积不到 1KB、靠三四个方法撑起整个页面状态管理的轻量级响应式引擎。它解决的核心问题很朴素一块数据变了所有依赖它的地方自动跟着变。如果你在写小应用、做组件库或者好奇前端框架底层怎么监听数据变化这篇从设计选型、核心实现到踩坑清单的完整记录应该能直接照着用。整个项目代码不长但里面的思路和坑值得我单独写一篇文章来讲。1. 项目定位与设计思路1.1 为什么需要一个叫 rea 的轻量级响应式引擎先说说我为什么做它。手头有个纯前端的小应用页面结构不复杂但跨组件共享的状态不少用户偏好、筛选条件、弹层开关大概十来个状态需要在不同页面片段之间联动。按常规思路引入一套完整的状态管理库是最省事的但实测下来有两个问题一是引入体积和初始化代码对这个小项目来说有点重样板代码比业务代码还长二是那一整套全局 store 的设计理念对小范围内的数据联动来说相当于杀鸡用牛刀很多概念根本用不上。另一个方案是自己写事件总线EventBus但事件总线最大的缺点是“触发”和“响应”之间是割裂的。我在组件 A 里 emit 一个事件组件 B 里 on 同一个事件事件名一旦拼错、漏传参数或者忘记取消监听排查起来非常难受。状态越多事件名越多最后脏监听也不知道从哪来。我需要的东西其实很简单一个响应式的数据容器。谁引用了某个字段这个字段一变引用它的地方就自动收到通知。这不复杂但它应该是数据驱动而不是事件驱动。于是我把这个一直想做但没时间完善的引擎从抽屉里翻出来重新捋了一遍需求它就是rea。注意选择响应式数据容器而不是事件总线核心区别在于“依赖关系是自动记录的”。事件总线需要开发者自己维护事件名和参数结构响应式引擎则把这些隐藏在了数据读取动作背后。1.2 设计目标与边界明确“不做什么”在实际写代码之前我给rea定了几条非常明确的边界避免做歪只做响应式数据联动不做路由、不做组件、不做持久化之类的附加功能核心代码要尽量短压缩后争取控制在 1KB 左右不依赖任何第三方库方便在任何前端技术栈里直接使用API 的数量控制在个位数以内最好能让使用者 5 分钟看完文档就上手。这几条边界后来被证明非常值钱。因为在开发过程中每次冒出新想法时我都会先问一句“这和响应式联动有关系吗”没有关系就砍掉。很多开源工具之所以越用越累往往不是文档不行而是边界模糊什么都能干最终使用成本反而被抬高。rea刻意做小反而让它成为一个很容易被解释清楚、很容易被信任的工具。至于命名其实没有太多讲究。文件名叫reactive-engine太长了写代码时顺手缩写成rea后来 README 也就沿用了。名字一旦固定连语义都会被重构——你说它取的是 reactive 前三个字母也好是 realtime 引擎的首尾也好都不影响它代表一个能用的东西。1.3 技术路线为什么选择 Proxy 而不是 defineProperty这是整个项目最核心的一个选型决定。要做数据变化监听在老浏览器时代通常是Object.defineProperty——很多知名前端框架都曾用它来做数据劫持。这个方案的思路是先声明一个对象然后对其中每一个已经存在的属性逐个执行defineProperty把属性变成带get/set的访问器属性。如果用defineProperty实现rea立刻会碰到三个硬伤。第一新增属性不会触发监听。对象一开始没声明过的属性后面直接赋值时不会走到预先定义的set管道里。这意味着使用者在运行时动态加一个新字段时监听逻辑直接失效还没有任何提示。第二数组的原生操作难以全面拦截。push、pop、shift、splice这些方法本质上不是单纯赋值需要额外重写这些方法才能拿到变化维护成本很高。第三初始化时就必须递归遍历整个对象。为了后续能监听到深层属性的变化创建时就要把嵌套对象的每一层都做完劫持对于大量数据来说这是个不小的负担。把这三个问题和Proxy的特性对照之后就非常清晰了。Proxy是直接在对象外层包一层拦截器任何属性访问、赋值、删除、枚举等操作都会先经过它不需要预先声明属性新增字段天然支持对数组也不需要特殊重写方法——因为push里的赋值操作都会体现在set/deleteProperty里只要感知到length和下标变化就能推断出来。嵌套对象可以用懒代理的方式访问到哪一层才代理哪一层初始化成本极低。实际写代码之前我还测过两者的基准性能在属性数量很多且深嵌套的场景下Proxy的懒代理模式明显更好在极端频繁但浅层修改的场景下defineProperty有时反而略好但差距几乎可以忽略。这个选择对我的项目来说没有悬念Proxy。2. 核心概念与原理拆解2.1 响应式核心模型一次数据变化的完整旅程在动手写代码之前必须把响应式引擎的底层模型讲清楚。这个模型可以浓缩成两句话读取数据时登记依赖修改数据时触发更新。怎么理解你可以把它想成微信公众号订阅机制。某个用户关注了一个账号账号发文章后所有关注者都会收到推送。在响应式系统里被关注的是数据字段关注者则是那些“读取过这个字段”的函数。读取动作相当于点击了“关注”赋值动作相当于“发文章”。一次完整的数据变化旅程是这样的某个函数执行了store.state.user.name这个读取动作触发 Proxy 的get拦截器get拦截器把这个函数登记为user.name字段的依赖者代码随后执行了store.set({ user: { name: B } })赋值动作触发set拦截器set拦截器找到所有登记在user.name字段上的依赖者依次执行依赖者函数重新运行拿到最新值完成界面或业务逻辑的更新。这个旅程看起来简单真正复杂的在于两个细节依赖是怎么精确登记的以及多次修改要不要合并通知。这也是rea这个引擎里最值得展开的部分。2.2 依赖收集的两种实现全局通知与精确路径第一版rea的依赖收集用的是最简单粗暴的全局通知维护一个 Set里面放着所有 watch 回调任何字段变化都通知所有人。在小项目里这招完全够用字段少、页面简单多跑几次回调也没什么性能压力。代码还特别短适合拿来起步。但一旦状态树大起来全局通知的问题就出现了。比如我改了只跟菜单栏相关的某个字段页面主体里所有 watch 回调都被无差别执行了一遍。如果这些回调都做了一些耗性能的操作比如过滤长列表、重新计算汇总就会产生无意义的工作。第二版才做了精确到字段的依赖收集思路是这样维护一个Mapkey 是字段名value 是一个 Set里面存着所有依赖这个字段的回调函数。在get拦截器里执行登记动作在set拦截器里根据 key 找到对应的 Set只通知那一批回调。这里有一个关键问题watch的回调声明的是(state) state.user.name这是一个函数引擎怎么知道它读取了哪个字段办法是借助一个全局变量activeEffect。当使用者调用watch时先把回调函数放进activeEffect再立即执行一次这个函数。执行过程中只要触发了任何 get拦截器就能通过activeEffect知道“现在正在登记的是谁”从而把它加到对应 key 的依赖集合里。这个手法就是行业里常说的“依赖收集”。提示依赖收集的关键在于“先执行一次回调让它在 get 过程中自报家门”。这也是为什么watch的第一个参数是 selector 函数而不是字符串路径——字符串路径没法把“当前正在执行的回调”和“被读取的字段”绑定起来。2.3 API 设计实战createStore / set / watch 的调用约定rea的对外 API 只有三个这很反直觉但确实够用。看一个实际使用例子import { createStore } from rea; const store createStore({ user: { name: A, login: false }, count: 0, }); // 监听某个值的变化返回值可以取消监听 const unlisten store.watch((state) state.user.name, (newValue, oldValue) { console.log(用户名从 ${oldValue} 变为 ${newValue}); }); // 更新数据 store.set({ user: { name: B } });三个方法的设计各有讲究。createStore(initialState)接收初始状态对象内部用 Proxy 包裹后放到store.state上。set(patch)接收的是一个“局部补丁”而不是一个全新的 state这样调用方不需要感知整个状态树的形状也方便做浅合并。watch(selector, callback)第一个参数是选择函数第二个才是回调watch 返回的unlisten函数用来取消监听。为什么用 selector 函数而不是传字符串路径因为字符串路径在代码重构时容易失效而且它无法在“读取的瞬间”把当前回调注册为依赖。selector 函数的优势在于它是可执行的执行过程就会自动触发依赖收集。这就让数据字段和监听回调之间天然地绑定在了一起。2.4 批量更新调度避免一次操作触发十次渲染rea里还有一个必须处理的细节批量调度。换句话说如果一次业务逻辑里连续改了五个字段难道要让依赖它们的回调执行五次吗这既有性能浪费也可能让中间态把界面搞乱。解决思路是引入调度队列。set拦截器里不直接执行依赖回调而是把回调放进一个待执行队列然后用微任务Promise.resolve().then去“清空”这个队列。微任务的特点是在当前同步代码执行完之后才运行这恰好能合并同一轮同步操作中产生的所有更新。还是用例子说话一次函数里连续调用了三次store.set按直觉应该触发三次 watch。实际上第一次 set 时调度器开始安排微任务后面两次 set 发现微任务已经被安排过就直接跳过排队。真正执行时把所有收集到的回调统一触发一遍监听回调里的新值就是最终态中间过程完全不影响外部。这就是响应式系统经常强调的“异步批量更新”。3. 实操过程与核心环节实现3.1 初始化项目从 package.json 开始rea的目标是轻量、零依赖所以项目结构不需要复杂的构建链。完整目录结构如下rea/ ├── src/ │ └── index.js ├── test/ │ └── rea.test.js ├── package.json └── README.mdpackage.json也很朴素{ name: your-scope/rea, version: 1.0.0, type: module, main: ./src/index.js, exports: { .: ./src/index.js } }我没有打包成 UMD 格式也没有压缩产物而是直接把源码作为入口。理由很简单现代环境基本都支持原生 ES Module引入额外的构建只是为了生成一个“传统格式”反而给使用者增加了理解负担。对于个人维护的轻量工具库来说直接给源码是最真诚的做法使用者能看到全部逻辑也更容易调试。3.2 核心引擎实现用 Proxy 让依赖收集跑起来核心代码放在src/index.js完整实现大概是下面这样let activeEffect null; let microtaskQueued false; const queuedEffects new Set(); function scheduleRun(effect) { queuedEffects.add(effect); if (microtaskQueued) return; microtaskQueued true; Promise.resolve().then(() { microtaskQueued false; for (const fn of queuedEffects) fn(); queuedEffects.clear(); }); } export function createStore(initialState {}) { const raw initialState; const targets new WeakMap(); const deps new Map(); function getDep(key) { if (!deps.has(key)) deps.set(key, new Set()); return deps.get(key); } function track(target, key) { if (!activeEffect) return; getDep(key).add(activeEffect); } function trigger(target, key) { const effects getDep(key); if (effects.size 0) return; for (const effect of effects) scheduleRun(effect); } const handler { get(target, key) { track(target, key); const value target[key]; if (value typeof value object !targets.has(value)) { targets.set(value, new Proxy(value, handler)); } return targets.get(value) || value; }, set(target, key, value) { const oldValue target[key]; if (oldValue ! value) { target[key] value; trigger(target, key); } return true; }, }; const state new Proxy(raw, handler); function set(patch) { for (const key of Object.keys(patch)) { state[key] patch[key]; } } function watch(selector, onChange) { let oldValue; const effect () { const value selector(state); if (value ! oldValue) { onChange(value, oldValue); oldValue value; } }; activeEffect effect; effect(); activeEffect null; return () { for (const effects of deps.values()) { effects.delete(effect); } }; } return { state, set, watch }; }这段代码的核心有三个地方。第一get拦截器里track登记依赖同时判断当前读取到的值是不是对象如果是对象就懒代理一层。第二set拦截器里先对比新旧值没有变化就不触发任何东西有变化才trigger。第三watch函数内部把回调放到activeEffect后立即执行一次这一步是整个依赖收集能够成立的基石。对比新旧值这一步特别值得提。响应式系统里最常见的性能问题之一就是“赋值没变却也触发更新”。有了oldValue ! value这个判断就能屏蔽掉大部分无效更新。注意这里用的是!它会区分1和1也会区分null和undefined。在数据联动场景里尽量让值类型稳定能少踩很多坑。3.3 测试验证用 Node 自带测试模块写单测测试不需要引入任何测试框架直接用 Node 内置的node:test模块就行。最核心的用例是验证“修改数据后监听函数被调用并且拿到新值和旧值”。import test from node:test; import assert from node:assert/promises; import { createStore } from ../src/index.js; test(watch 能收到数据变化, async () { const store createStore({ count: 0 }); const changes []; store.watch((state) state.count, (newValue, oldValue) { changes.push([newValue, oldValue]); }); store.set({ count: 1 }); store.set({ count: 2 }); store.set({ count: 2 }); // 等待微任务调度执行完 await new Promise((resolve) setTimeout(resolve, 0)); assert.deepEqual(changes, [[2, 0]]); });这个用例同时验证了三件事监听回调确实被调用了连续三次 set 因为批量调度合并成了一次通知值没有变化的那次 set 被新旧值判断过滤掉了。一个用例能覆盖这么多行为说明核心逻辑的耦合度其实还挺健康。更多测试用例还可以覆盖嵌套对象懒代理、watch 取消监听后不再触发、数组 push 时不崩溃、多个 watcher 互不影响等。测试代码本身也是很好的使用文档把每个用例名写得直白一点后面回来看时一目了然。3.4 发布到内部源让 rea 真正可用代码和测试都通过后发布流程其实很简单。因为在内部 npm 源上发布不需要处理公包繁琐的命名冲突问题npm publish --dry-run先跑一遍 dry-run检查包内容里会不会混入多余文件。确认没问题之后再正式发布npm publishyour-scope这种 scope 前缀可以替换成你自己的私有 scope这样发布在内部源上团队其他人安装时也只需要:npm install your-scope/rea发布时要注意package.json的files字段如果写了[src]发布包里就只包含源码目录README 和 LICENSE 会被自动带进去。这样做的好处是包体积最小化使用者安装后看到的就是几行源码出问题直接打开读连调试都省了。4. 常见问题与排查技巧实录4.1 深层对象的懒代理坑保存的引用是旧代理懒代理省了初始化开销但也带来了一个新的陷阱。某个组件里提前取出了state.user变量并保存下来之后你重新给state.user赋值再把新旧值一起作为参数传给一个函数。在这个函数里修改旧引用上的属性可能不会触发任何更新。原因在于state.user第一次被读取时懒代理生成的是一个当时包裹旧对象的 proxy 实例。之后state.user被赋值为新对象再读到的state.user就是另一个 proxy 实例了。旧引用指向的依然是旧对象修改它的属性自然不会进入新代理的拦截器。解决办法是统一经由store.state访问尽量不要长期保存中间层对象的引用。如果实在避免不了就重新从state取一次再修改。排查时如果发现“明明改了数据却没触发更新”优先确认修改的是不是最新代理对象。4.2 数组方法触发的多重更新与值对比数组是 Proxy 响应式里最常见的坑。比如执行一次state.list.push(item)会触发至少两次set一次是下标赋值一次是length变化。如果这两种变化都已登记了依赖watch 回调就可能被执行多次。我的处理思路是在set拦截器里不去特殊处理数组方法而是靠“新旧值对比”去过滤。第一次 push 后数组长度变了回调里拿到的新值确实和旧值不同触发一次随后的length变化再次触发时回调里 selector 计算出来的值如果没再变就会被value ! oldValue挡下来。这个逻辑让数组场景回归了正常一次 push 最终只产生一次有效回调。如果希望进一步减少数组监听粒度可以只监听state.list.length这种聚合值而不是监听整个数组对象。比如用watch((state) state.list.length, ...)后续只在下标变化导致长度改变时触发一次避免了每次元素替换都重复通知整个列表。4.3 监听器泄漏组件卸载后回调还在执行第一批使用者反馈过“页面反复切换几次后watch 回调越执行越多”的问题。这个现象几乎都是“忘记取消监听”导致的。组件在挂载时调用了store.watch但卸载时没有调用watch返回的unlisten函数回调就一直留在依赖集合里。每次重新挂载又新增一个累积到最后一次数据变化触发十几次回调界面开始卡顿。解决方向有两个一个是在组件卸载生命周期里显式调用取消函数另一个是对整个 store 层面做“分组订阅”让同一组 watcher 可以一次性取消。rea选择的是前者因为 API 更小语义更清楚。用框架的自动清理机制时也要小心有些框架的 effect 清理是异步执行的在清理还没运行前组件又重挂载了同样会出现短暂重复。排查监听器泄漏有一个实用技巧在代码里给每个 watch 回调加一个调试标记字段然后在回调入口打印标记和当前依赖集合大小。如果发现同一个标记反复出现多次基本可以断定是漏了取消订阅。4.4 排查工具与调试技巧响应式引擎的调试比普通事件总线难一点因为触发链路是隐式的。数据读取时登记依赖数据修改时触发更新看起来像魔法。但一旦出现“没更新”或“更新了太多回”的问题就很棘手。我的经验是准备两个排查工具。第一张是在 Proxy 的get和set拦截器里加一行环境变量控制的条件日志开启后每次拦截都打印 key 和当前值。这个日志能直接看出读取和赋值的顺序判断依赖到底有没有登记成功。第二张是给代理对象加一个自定义的Symbol.toStringTag属性这样在控制台打印代理时能一眼分辨出它是哪个 store 的状态对象raw[Symbol.toStringTag] REA-STORE;之后在 Console 里看到[object REA-STORE]就能确认当前打印的是rea的代理对象而不是某个普通业务对象。看起来是个很小的细节但在我排查问题时省了很多时间。另外还有一个小建议把内部批量调度的触发时机暴露出来。通常微任务队列不太好观察可以在调度器里临时用queueMicrotask并打印一条堆栈信息帮助定位“倒底是哪一次 set 把 watch 触发的”。4.5 常见问题速查表问题现象可能原因处理方式修改对象属性后没有触发更新保存的是旧代理引用统一经由 store.state 访问不缓存中间层引用watch 回调执行了很多次数组方法同时改下标和 length利用新旧值对比过滤或只监听聚合值回调越积越多页面变卡组件卸载时没有取消订阅调用 watch 返回的 unlisten 函数一次 set 触发多次渲染没有批量调度用微任务合并同步代码中的多次更新Proxy 代理对象在控制台输出混乱无法区分多个 store 对象设置 Symbol.toStringTag 标记5. 写在最后的个人经验用它做了大半年的项目之后我最大的感受是一个响应式引擎的复杂度不在那几十行核心代码里而在 API 边界和异常处理上。rea砍掉了很多我不需要的功能可越到后面越发现它的价值不在于代码量小而在于用的时候不需要频繁翻文档出问题时能直接读源码甚至能在调试器里单步走进拦截器看明白数据是怎么流转的。最后再分享一个小技巧给rea这样的工具保持“小而专”的路线尽量别为了满足奇怪的需求去加“顺手功能”。每多一个 API使用者要多花一分认知成本。如果某个扩展需求可以放在业务层解决就宁可放在业务层解决让引擎保持纯粹。这样它才配得上那个看起来像临时拼出来的三字母代号——简单、直接、不需要解释太多。