项目名叫 rea,起初只是我目录里一个空文件夹,里面躺着几个 JS 文件和一个 README。起因很简单,我在那个跨平台项目里被状态同步折磨到怀疑人生,花了一个晚上解决的问题,本质上就是"数据变了,界面没跟着变"。rea 这个名字就是 reactive 的前三个字母,它要做的事情也正好对应这个名字:当数据发生变化时,自动去更新所有依赖这些数据的地方。
文章不打算写成"从零实现某某框架"那种大而全的教程,我更想记录的是,一个普通的响应式系统从设计、编码,到落地真实场景的完整过程,包括代码背后的取舍,以及我亲手踩进去又爬出来的坑。目标是让你看完之后,能自己搭一个几十行、零依赖的状态管理模块,也能对市面上成熟框架的响应式原理建立起基本判断力。如果你是做前端、跨端或者正在学框架源码的开发者,这篇应该能帮你省下不少摸索时间。
1. 为什么会有"rea":从一个手忙脚乱的夜晚说起
1.1 一次典型的"状态不同步"事故
事情发生在模拟项目 X 的购物车模块上。用户从商品列表页把一件商品加进购物车之后,页面上至少三个地方要同步更新:右上角购物车角标的数字、购物车列表里的商品行、底部的结算金额合计。
听起来不难,对吧?我当时也是这么想的。于是我在每一个"加入购物车"的点击事件里,手动调用了一连串刷新函数:刷新角标、刷新列表、刷新合计。最初只有三处调用,倒也能跑。但随着需求变动,结算金额开始包含满减、优惠券、运费,刷新逻辑从一行变成了一段,角标又加了动画,列表还要滚动定位。每一次改动,我都要顺着事件流把所有相关调用点找出来改一遍。
那个晚上就是典型事故现场:用户把商品加到 5 件之后,角标显示了 5,列表也正常,但底部合计还是 3 件的价格。我排查了两个小时才意识到,新增的"运费重新计算"逻辑只挂在列表刷新里,合计刷新没执行。问题不在某个函数写错了,而在整个同步链条上,任何一个地方漏掉一个调用点,状态就会悄悄漂移。这种 bug 不报错、不崩溃,就是数据不对,最让人抓狂。
1.2 响应式的本质:把"手动通知"变成"自动广播"
痛到极点就想换个思路。"加入购物车"这个动作发生后,真正需要做的是让所有关心购物车数据的地方自动收到通知,而不是我去逐个喊它们。
这就是响应式最朴素的定义:数据被读取时,把"谁在读"登记下来;数据被修改时,把"所有在读的人"挨个喊一遍。它像一个广播系统,数据源是电台,页面里的角标、列表、合计是收音机。电台一旦发声,所有开着机的收音机都会响,不需要有人挨个去按按钮。
用生活里的场景可能更好理解。以前的做法是"你有一个快递到了,你手动给张三、李四、王五都打个电话",响应式则是"快递信息登记在驿站系统里,谁订阅了这个通知,系统就自动推给谁"。差别看起来很小,但当"打电话"的次数从每天 3 次变成 3000 次,手动方案必然会漏。
rea 要做的核心,就是这个"驿站系统":做一个对象包裹层,拦截对数据的读写,自动完成"登记依赖"和"触发更新"两件事。状态变化不再需要人肉通知,视图层只要声明过"我依赖这个字段",剩下的事全部交给 rea。
1.3 为什么不用成熟方案,要亲手写一个
写之前我当然也犹豫过:市面上成熟框架的响应式系统已经很完善,何必再造一个轮子?
回到当时的场景,模拟项目 X 的核心诉求有两个:一是状态逻辑要在多个端复用,我不想被迫绑定某一套视图层实现;二是完整框架的运行时对我这个模块来说太重,我只需要"数据变化通知依赖"这一层能力。rea 被设计成一套框架无关的纯 JS 模块,任何环境拿去都能用,这正好解决我的问题。
当然,如果是一个大团队做大型业务应用,我的建议仍然是直接用成熟方案,它们经过了海量场景验证,边界情况处理得比我这种自研版本好得多。rea 的定位很明确:轻量、可学习、可扩展。自研核心代码的最大价值,其实是让你在遇到框架警告或诡异行为时,脑子里能画出"它在哪一环出了问题",这种排查能力不是面试题能教会你的。
2. rea 的核心设计:代理、依赖、通知的三层拆解
2.1 三个关键概念,用收快递来理解
在动手写代码之前,我先捋清楚了 rea 需要三个基础部件,任何响应式系统都围绕这三个东西转。
第一是代理。你不能直接读写原始状态对象,因为直接读写没有机会做"登记"和"通知"。rea 给状态对象套一层代理层,所有 get 和 set 操作都先经过这层代理。这就好比驿站门口装了个扫码器,每个包裹进出都要扫一下,不扫就不知道谁拿走了什么。
第二是依赖。一个效果函数 effect,只要执行过程中读取了某个字段,就说明"这个函数依赖这个字段"。rea 会在读取时把该函数记录进这个字段对应的"依赖清单"里。依赖的粒度可以精确到字段级,也就是"计数字段"的依赖清单和"金额字段"的依赖清单互不干扰。
第三是通知。状态字段被写入新值时,rea 去查依赖清单,把依赖它的每个 effect 函数都重新执行一遍。这个过程叫 trigger,是响应式系统里最直接的"播报"动作。
我用一次收快递的类比把它们串起来:代理是驿站系统,你每次取件都要扫码;依赖是订阅记录,你登记过"这件到了请通知我";通知是系统给你发的取件短信。三条链路缺一不可,而代码层面的实现也不复杂,一个 WeakMap 加两个 Map 加一个 Set 就够。
2.2 为什么选 Proxy,而不是逐个监听属性
实现响应式拦截,传统方案是给对象每个属性重新定义访问器属性,但 rea 从一开始就决定用 Proxy。原因很具体:
- Proxy 拦截的是"对对象的整体操作",新增属性、删除属性天然能被感知,而逐个监听属性的话,新增属性需要额外重新遍历绑定,很容易漏。
- 数组场景差异更大。逐个监听属性对数组的 push、pop、length 变化处理起来极其别扭,通常要重写数组方法;Proxy 则可以统一拦截。
- Proxy 的初始化开销更小,不需要在创建时就遍历整个对象结构,这对深层嵌套对象也更友好。
我当时做过一个对比表格,直接在项目文档里,这里贴出来对你更有参考价值:
| 对比维度 | Proxy 方案 | 逐个属性监听方案 |
|---|---|---|
| 新增属性监听 | 默认支持,无需额外处理 | 需要手动重新遍历绑定 |
| 数组方法 | 索引和 length 变化可统一拦截 | 需要重写方法补丁 |
| 初始化性能 | 创建代理开销小 | 深层对象递归遍历成本高 |
| 兼容性 | 不支持非常古老的运行环境 | 老环境支持更好 |
如果你维护的代码还要兼容上古浏览器,那选择要慎重;但在 2024 年做新项目,Proxy 基本没有需要顾虑的地方。
2.3 一次数据修改背后,依赖收集的完整链路
依赖收集的完整结构,我习惯用四层描述:
第一层是 targetMap,用来记录"目标对象 → 它的依赖地图"的对应关系。key 是原始对象,value 是 depsMap。这里必须用 WeakMap,目的是让目标对象不再被引用时,对应的依赖记录也能被垃圾回收,避免内存泄漏。
第二层是 depsMap,记录"字段名 → 依赖集合"的对应关系。同一个对象的不同字段,各自拥有独立的小本本。
第三层是 dep,一个 Set 集合,存放所有依赖该字段的 effect 函数。用 Set 而不是数组,是为了保证同一个 effect 即使多次读取同一字段,也只被收集一次。
最后一层才是 activeEffect,它是全局变量,代表"当前正在执行的 effect 函数"。执行 effect 时,rea 把它设为全局激活状态;effect 内部读取字段,track 函数就把激活状态的 effect 塞进对应字段的 Set 里。effect 执行结束,activeEffect 重新置空。
整个链路用文字走一遍就是这样:用户执行 effect 函数 A,A 内部读取 state.count,track 发现 activeEffect 是 A,于是在 targetMap 中找到 state 的 depsMap,再找到 count 字段的 dep 集合,把 A 放进去。之后某处给 state.count 赋值 5,trigger 顺着同样的路径找到 dep 集合,把 A 拿出来重新执行。一次响应式更新就完成了。
3. 30分钟搭出rea核心:代码级拆解与落地
3.1 项目结构与零依赖原则
rea 的项目结构非常简单,我刻意保持零依赖,整个过程不需要 npm 安装任何包,一个浏览器加 Node.js 就能跑:
rea/ core.js # 依赖收集与触发的核心逻辑 reactive.js # Proxy 包装层 computed.js # 计算属性 test.js # 手动测试脚本core.js 负责 track 和 trigger,reactive.js 负责把普通对象变成响应式对象,computed.js 负责派生出跟随数据变化的计算值。文件虽少,每一行都有用途。零依赖带来的最大好处是:你可以无障碍地打开任意一个文件,纯读代码就能理解全貌,不用担心被繁重的依赖树淹没。
3.2 reactive:给普通对象装上监听器
reactive 的完整实现如下。这个文件是 rea 的门面,你在业务代码里接触最多的就是这个函数:
// rea/core.js const targetMap = new WeakMap(); let activeEffect = null; export function track(target, key) { if (!activeEffect) return; let depsMap = targetMap.get(target); if (!depsMap) { depsMap = new Map(); targetMap.set(target, depsMap); } let dep = depsMap.get(key); if (!dep) { dep = new Set(); depsMap.set(key, dep); } dep.add(activeEffect); activeEffect.deps.push(dep); } export function trigger(target, key) { const depsMap = targetMap.get(target); if (!depsMap) return; const dep = depsMap.get(key); if (!dep) return; const effects = [...dep]; effects.forEach(effect => { if (effect !== activeEffect) { effect.scheduler ? effect.scheduler() : effect(); } }); } export function createEffect(fn, options = {}) { const effect = () => { cleanup(effect); activeEffect = effect; const result = fn(); activeEffect = null; return result; }; effect.deps = []; effect.scheduler = options.scheduler; if (!options.lazy) { effect(); } return effect; } function cleanup(effect) { effect.deps.forEach(dep => dep.delete(effect)); effect.deps.length = 0; }// rea/reactive.js import { track, trigger } from './core.js'; export function reactive(target) { return new Proxy(target, { get(target, key, receiver) { const value = Reflect.get(target, key, receiver); track(target, key); return value; }, set(target, key, newValue, receiver) { const oldValue = Reflect.get(target, key, receiver); const result = Reflect.set(target, key, newValue, receiver); if (oldValue !== newValue) { trigger(target, key); } return result; } }); }set 拦截里做了一个浅比较,只有旧值和新值实际不同才会触发更新。这个细节很重要,否则重复给同一个字段赋相同值,也会白白触发一堆无意义的渲染更新,性能会在不知不觉中劣化。
get 拦截里没有特殊过滤所有 Symbol 类型的 key。如果你打算把 rea 用在更复杂的遍历场景,建议对内置 Symbol 做一次拦截跳过,避免像 Symbol.iterator 之类的访问也被登记成依赖,造成整个对象被不必要的宽泛依赖绑定。
3.3 effect:让函数自动跟随数据变化
effect 在 rea 里是最核心的消费方式,它的职责是"执行一次函数,并把函数内部读取过的所有字段自动登记为依赖"。
我刚才实现的 createEffect 里藏着两个不写代码就发现不了的坑。
第一个坑是依赖分支切换。假如 effect 内部有 if 分支语句,第一次执行时走了 A 分支,只读取了字段 x,那么依赖集合里只有 x。第二次执行时条件变了,走的是 B 分支,只读取了字段 y——如果不清除旧的依赖,下一次 x 变化时这个 effect 仍然会被触发,但它其实已经不需要 x 了。cleanup 函数就是用来解决这个问题的:每次 effect 执行前,先把上一次记录的所有依赖从这个 effect 身上移除,保证依赖集合永远只反映最近一次执行的真实读取结果。
第二个坑是 effect 触发自身时的死循环。trigger 里有个effect !== activeEffect的判断,防止一个 effect 在运行中修改了自己的依赖字段,又立即触发自己,造成无限递归。加了这个保护,至少不会在同一次同步更新中无限循环。
实际用起来,effect 会有两种模式。默认模式创建后立即执行一次,完成初始渲染;lazy 模式不会立即执行,而是等待后续读取或调用,这在 computed 里会用到。
3.4 computed:带缓存的计算属性
计算属性是我在购物车合计金额场景里用得最舒服的功能。如果每次渲染都要把数百件商品的价格手动累加一遍,性能上并不划算。computed 的本质是"惰性求值加缓存":第一次读取时执行一次计算,并把计算过程中读取的字段登记为依赖;依赖发生变化时,只把缓存标记为脏,等下一次真正读取才重新计算。
实现如下:
// rea/computed.js import { createEffect, track, trigger } from './core.js'; export function computed(getter) { let value; let dirty = true; const runner = createEffect(getter, { lazy: true, scheduler() { dirty = true; trigger(computedRef, 'value'); } }); const computedRef = { get value() { if (dirty) { value = runner(); dirty = false; } track(computedRef, 'value'); return value; } }; return computedRef; }dirty 是个布尔标志位:初始为 true,第一次读取时执行 runner 计算结果并置为 false;之后只要依赖字段没变化,每次读取都直接返回缓存值。当某个依赖字段被修改,scheduler 被调用,dirty 被重新置为 true,但不会立即重算。这种设计避免了"改了 A 字段但页面只用 B 字段时还白算一遍"的浪费,是非常划算的懒加载策略。
我在测试脚本里验证过:一个 computed 读取 1000 次,如果依赖没变,底层 getter 只会真正执行一次;依赖变了之后,再次读取才重新计算。这个表现对于高频渲染场景非常重要。
3.5 scheduler:把多次通知合并成一次更新
scheduler 是 rea 里我最后补上的机制,也是它从"能用"变得"好用"的关键一步。假设一个 effect 同时依赖了三个字段,某一轮业务逻辑连续修改了这三个字段,如果没有调度器,这个 effect 会被连续触发三次,意味着页面会被刷新三次。在 DOM 渲染环境里,这就是明显的卡顿来源。
scheduler 的思路是:不直接执行 effect,而是先把更新任务放进一个队列,在下一轮微任务里统一执行。我在这里用了一个极轻量的实现:
// rea/scheduler.js const queue = new Set(); let isFlushing = false; export function queueJob(effect) { queue.add(effect); if (!isFlushing) { isFlushing = true; Promise.resolve().then(() => { queue.forEach(fn => fn()); queue.clear(); isFlushing = false; }); } }使用方式很简单,在创建 effect 时把 scheduler 传给 createEffect:
createEffect(render, { scheduler() { queueJob(render); } });渲染函数不会立即执行,而是进入队列,等当前同步代码全部跑完,microtask 阶段再统一执行一次。这样无论业务里连续改了多少个字段,页面最多只刷新一次。实测在购物车场景里,一次性商品数量、金额、优惠信息全部更新时,渲染次数从原来的 4 次降到了 1 次,体感上顺滑很多。
4. 把rea用进真实场景:购物车状态改造实录
4.1 场景设定:一个让人头疼的多处同步问题
模拟项目 X 的购物车面板,是一个典型的多消费方场景。页面顶部有个角标,显示商品总件数;中间是购物车列表,每一行有商品名、单价、数量;底部有合计金额,还会展示优惠信息。之前的手动方案里,我在每个业务函数里都要更新这些地方,代码散布在十几个位置,注释写着"更新角标""更新列表""更新合计",毫无章法。
改造的目标很明确:建立一个响应式购物车状态对象,所有界面元素通过 effect 自动追着状态变化走。业务代码只需要修改状态对象上的数据,其他什么都不用管。
4.2 改造前:手动刷新的代码长什么样
改造前的代码大概长得像这样,我相信很多人看到会熟悉得心疼:
function addToCart(product) { cart.products.push(product); updateBadge(cart.products.length); renderCartList(cart.products); updateTotal(calculateTotal(cart.products)); updateDiscountInfo(cart.discount.total); }这个函数看起来不算太坏,问题是整个项目里有十几个这样的事件入口:修改数量、删除商品、清空购物车、切换优惠券……每一个都要老老实实把几行 refresh 调用抄一遍。少抄一行就是状态不同步,多抄一遍就是重复代码泛滥。
4.3 改造后:状态驱动下的自动更新
改造后的逻辑收敛成了两段。第一段是状态定义:
const cartState = reactive({ products: [], discount: { total: 0 }, get totalCount() { return this.products.reduce((sum, item) => sum + item.count, 0); }, get totalPrice() { return this.products.reduce((sum, item) => sum + item.price * item.count, 0); } });第二段是若干个 effect 声明式地绑定依赖:
effect(() => { updateBadge(cartState.totalCount); }); effect(() => { renderCartList(cartState.products); }); effect(() => { updateTotal(cartState.totalPrice - cartState.discount.total); });业务代码瞬间变成了一行:
function addToCart(product) { cartState.products.push(product); }加购动作本身只修改状态,角标、列表、合计会自动跟着变化。再也不会出现"角标变了但合计没变"的诡异局面,因为每个界面区域都只关心自己真正依赖的字段,依赖不到、就不更新,依赖到了、必然更新。
4.4 接入rea时的三个落地建议
第一个建议是:effect 的粒度要尽量细。我曾经写了一个大 effect,把角标、列表、合计全塞进去,确实也能跑,但任何字段变化都会导致整片区域重渲染,性能劣化严重。拆成三个独立的 effect,各自依赖各自关心的字段,才是正确姿势。
第二个建议是:不要用 reactive 包裹那些一次性配置对象。响应式是有开销的,不是所有数据都需要被追踪。像用户配置、静态字典这类写入后不会变化的数据,保持普通对象就好。
第三个建议是:避免在 computed 里产生副作用。computed 的函数应该只负责计算并返回结果,不要在计算过程里去写别的字段或调用外部接口。一旦这么做了,computed 的缓存策略会掩盖这些副作用的真实发生时机,排查问题时会非常混乱。副作用请放到 effect 里。
5. 实操中踩过的坑与排查技巧
5.1 死循环:effect 修改了自己的依赖
我第一次把 effect 写进项目,就踩到了死循环。当时有个 effect 依赖 cartState.discount.total,执行时又因为某种业务逻辑把它设置成了新值。于是一轮更新触发新的依赖,新的依赖又触发自己,页面直接卡死。
解决的核心思路是"变化检测加自触发保护"。reactive 的 set 拦截里必须有浅比较。trigger 里必须加effect !== activeEffect的判断。如果你之后要做异步的批量调度,还要小心队列里同一个 effect 被多次入队,优先用 Set 结构去重。这几个保护机制缺一不可,少了任何一个,复杂业务里一定会踩雷。
5.2 数组与 Map 的监听陷阱
数组是响应式系统里最容易被低估的坑。Proxy 虽然能拦截数组索引操作,但像 push、pop 这类方法调用,走的是内部特殊流程,某些实现会在中间触发不只一次的 get/set,如果不做处理,一个简单的 push 可能引发多次 trigger。
更麻烦的是 Map 和 Set。直接拿 reactive 包裹 Map,然后照常调用map.set(key, value)会报错,因为 Map 的方法内部依赖真实对象作为 this,而 Proxy 拦到的 this 是代理对象。解决办法是在 get 拦截器里,对读取到的函数类型值做一次bind(target, target),把内部 this 绑定回原始对象:
get(target, key, receiver) { track(target, key); const value = Reflect.get(target, key, receiver); if (typeof value === 'function') { return value.bind(target); } return value; }数组以及 Map 的边界情况相当多,我的建议是:如果你的业务要大量操作 Map 或 Set,先把边界测试写全,再优化性能,否则很容易在运行几小时后突然冒出一个 TypeError,排查成本极高。
5.3 effect 的清理与内存泄漏
这是 rea 从 demo 走向生产环境时,我补上的最严肃的一课。createEffect 里我维护了一个effect.deps数组,它记录了这个 effect 被哪些 dep 集合收集过。页面销毁时,如果只把 effect 函数丢掉,但 dep 集合里仍然保存着它的引用,那么下次对应字段更新时,已经销毁页面的回调依然会被触发,轻则白跑一遍,重则操作不存在的 DOM 节点导致报错。
清理函数很简单:
export function stop(effect) { cleanup(effect); }在组件销毁或模块卸载时调用stop(effect),把这个 effect 从所有 dep 集合里移除,之后 trigger 就找不到它了。如果你构建界面用的是自己写的 render 函数,这个步骤一定要挂在生命周期销毁钩子上,千万不能省。
5.4 常见问题速查
| 症状 | 原因 | 解决办法 |
|---|---|---|
| 页面卡死、调用栈溢出 | effect 内修改了自己的依赖字段 | 检查 trigger 是否加了自触发保护;检查业务逻辑是否合理 |
| 某个字段更新后页面没反应 | 依赖没被正确收集,或使用了 Object.assign 整体替换对象 | 改用逐字段赋值;或重新 reactive 新对象后再替换引用 |
| 对 reactive 包裹的 Map 调用 set 报错 | 方法内部 this 指向了代理对象 | get 拦截器里对函数做 bind(target) |
| 组件销毁后回调仍被触发 | effect 没有清理 | 销毁时调用 stop(effect) |
| 连续修改多个字段导致多次渲染 | 缺少调度器 | 为 effect 配置 scheduler,合并到微任务批次执行 |
6. 后续还能怎么玩:rea的扩展与边界
6.1 从单一状态到多模块store
rea 目前只解决了"单个状态对象"的响应式问题,真实项目里状态往往是分散多个模块的。你可以基于 rea 的 reactive 和 effect,做一个简单的 store 组合层,每个模块暴露自己的状态对象和操作函数,页面通过 effect 订阅多个模块。需要注意的是一定要在模块加载时统一注册、卸载时统一 stop,避免模块级泄漏。
6.2 中间件、持久化与调试能力
我记得写完调度器之后,觉得 rea 已经不是一个玩具了。你可以在 scheduler 这一步接入中间件,在更新前打日志、记录旧值新值、做快照,这就是时间旅行调试的基础。持久化也可以走同样的通道,状态变化后自动写 localStorage 或同步到后端,不用在业务代码里到处埋点。
但我也要泼一盆冷水:rea 离生产级仍有距离。它没有完整的类型推导保障,没有跨线程通信方案,针对复杂对象图的边界处理也还不够严苛。如果将来要用它支撑一个高并发、多端协同的项目,我更建议去深入理解成熟框架的响应式设计,而不是直接把 rea 硬扛上生产。rea 的价值更多是作为理解和扩展的原型,它能让你在遇到那些巨兽的警告信息时,不再是一脸茫然。
写到这里,我想起当初熬夜排查合计金额 bug 的自己。那个 bug 真正让我难受的,不是问题本身有多难,而是我明知道"每次都要手动同步"是错的,却拿不出更好的工具去替代它。后来花两个晚上把 rea 的核心写出来,再往后所有状态同步问题都变成了同一句话:改状态对象,页面自然跟着变。如果你也在类似的困惑里,我建议亲手把这套代码敲一遍,尤其是 effect 的清理和 scheduler 的合并逻辑,敲完你对响应式的理解会完全不一样。可能还是会有新的坑,但至少你会知道坑长在哪里了。