资讯动态

从零实现轻量级响应式引擎rea:Proxy与依赖收集实战

发布时间:2026/10/18 22:08:46 来源:尧图企业网站定制
1. 从“rea”这个标题说起一个极简命名背后的完整项目思维第一次看到“rea”这个标题的时候我脑子里蹦出来的第一反应是——这大概率又是一个被压缩到极致的项目代号。做技术的人都有这个习惯项目文件夹名字越短越好能三个字母解决的事情绝对不打四个字母。但恰恰是这种极简命名往往藏着一套完整的工程思路。因为一个项目如果连名字都懒得好好起说明作者把全部精力都砸在了功能实现上这种项目反而值得细看。“rea”这三个字母在常见的项目命名习惯里最直接的联想是Reactive响应式、Reader阅读器、Real-time实时、Resource资源这几个方向的缩写。结合当前技术社区的热词分布来看响应式数据处理和实时资源管理是出现频率最高的两个方向。所以这篇文章我打算把“rea”当作一个响应式资源管理工具来拆解——这个定位既能覆盖前端响应式状态管理也能延伸到后端实时数据处理是一个足够宽泛又有实际落地价值的切入点。你可能会问凭什么这么定义因为在实际项目里一个叫“rea”的东西如果它不是一个库、不是一个框架那它大概率就是一个轻量级的响应式引擎或者实时数据管道。这类项目的核心诉求非常统一用最小的体积、最少的依赖解决数据变化到界面更新之间的同步问题。适合谁来参考如果你正在做以下任意一件事这篇内容都对你有用正在搭建一个轻量级的前端状态管理方案不想引入重量级框架需要处理实时数据流比如日志监控、消息推送、传感器数据采集想理解响应式编程的底层原理而不是只会调 API手头有一个小工具项目希望用最少的代码实现数据驱动视图接下来的内容我会从整体设计思路、核心机制拆解、实操落地、问题排查四个维度把“rea”这个项目从里到外讲透。所有代码和配置都可以直接抄作业参数选择我也会给出计算过程不搞“你照着写就行”那一套。2. 整体设计与思路拆解为什么是响应式为什么是轻量2.1 核心需求解析数据变了界面怎么知道任何响应式系统的本质问题只有一个数据发生变化时如何高效地通知所有依赖它的地方去更新。这个问题听起来简单但实现方式的不同直接决定了整个项目的复杂度、性能和可维护性。最原始的做法是手动更新——数据变了你自己去调render()。这种方式在小型项目里没问题但一旦数据依赖关系超过三层代码就会变成一团乱麻。于是有了观察者模式、发布订阅模式、脏检查、Proxy 代理、信号量Signal等一系列方案。每种方案都有自己的取舍方案核心原理优点缺点适用场景手动更新开发者显式调用更新函数零开销完全可控容易遗漏维护成本高极小型脚本发布订阅事件中心统一调度解耦彻底灵活需要手动管理订阅关系跨模块通信脏检查定期遍历所有绑定实现简单兼容性好性能随数据量线性下降早期前端框架Proxy 代理拦截对象读写操作自动追踪依赖精准更新兼容性有要求嵌套对象需递归现代前端框架信号量细粒度依赖图更新粒度最细性能最优概念较多学习曲线陡高性能场景“rea”这个项目从命名和定位来看走的是Proxy 代理 依赖收集这条路线。为什么因为这是目前在不引入编译时优化的前提下能做到“自动追踪、精准更新”的最成熟方案。Vue 3 的响应式系统、Solid.js 的 Signal、Preact Signals底层都是这个思路的变体。2.2 方案选型背后的考量为什么不直接用现成的你可能会说既然 Vue 3 的vue/reactivity已经可以独立使用了为什么还要自己写一个“rea”这个问题我在实际项目里被问过很多次。答案通常有三个第一体积控制。vue/reactivity单独打包出来大约 10KB 左右gzip 后对于一个只需要响应式能力的小工具来说这个体积可以接受但不极致。“rea”如果只实现核心的reactive、effect、computed三个 API压缩后可以做到 2KB 以内。第二行为定制。现成的库往往带有很多你不需要的行为比如数组的特殊处理、集合类型的支持、调度器的默认实现。如果你只需要处理普通对象和基本类型自己实现反而更干净。第三学习与掌控。这一点对个人项目尤其重要。自己写一遍响应式系统你对“依赖收集”“触发更新”“副作用调度”这些概念的理解会完全不一样。以后再遇到框架层面的 bug你能直接定位到源码级别。注意自己实现响应式系统在生产环境使用前必须经过充分的边界测试。尤其是嵌套对象、数组、循环引用、异步更新这几个场景现成库已经帮你踩过的坑你自己写的时候一个都跑不掉。2.3 整体架构设计三个核心模块的协作关系“rea”的整体架构可以拆成三个核心模块它们之间的协作关系决定了整个系统的行为第一个模块是代理层Proxy Layer。它负责把普通对象包装成响应式对象拦截get和set操作。get的时候收集依赖set的时候触发更新。这一层是整个系统的入口所有响应式能力都从这里开始。第二个模块是依赖图Dependency Graph。它维护着“哪个副作用依赖了哪个属性”的映射关系。通常用WeakMap来存储键是响应式对象值是一个Map里面再存属性名到副作用集合的映射。用WeakMap的好处是当响应式对象被垃圾回收时依赖图里的记录也会自动清除不会造成内存泄漏。第三个模块是调度器Scheduler。当set操作触发更新时不是立即同步执行所有副作用而是把副作用放进一个队列在微任务中批量执行。这样做的好处是避免重复更新——同一个副作用在一次同步代码中被触发多次最终只执行一次。这三个模块的关系可以用一句话概括代理层负责感知变化依赖图负责记录关系调度器负责高效执行。缺一不可但每个模块的代码量都可以控制在很小的范围内。2.4 与主流方案的对比取舍在哪里把“rea”和主流方案放在一起对比能更清楚地看到它的定位对比维度rea自研轻量方案Vue 3 ReactivitySolid.js Signal核心体积gzip~2KB~10KB~7KB依赖收集方式ProxyProxy编译时 运行时更新粒度属性级属性级属性级调度方式微任务队列微任务队列同步 批处理嵌套对象支持需手动递归自动递归自动数组支持基础完整完整学习成本低中中高生产验证需自行测试充分验证充分验证这张表想说明一个核心观点“rea”的优势在体积和可控性劣势在边界场景的覆盖度。如果你的项目对体积极度敏感或者你只是想学习响应式原理那“rea”是合适的。如果你要做的是一个大型生产应用直接用成熟方案更稳妥。3. 核心细节解析与实操要点从零实现一个响应式引擎3.1 代理层的实现细节get 和 set 里到底做了什么代理层是整个系统的地基代码量不大但每一个细节都影响最终行为。先看核心实现// 存储当前正在执行的副作用 let activeEffect null; // 依赖图WeakMaptarget, Mapkey, Seteffect const targetMap new WeakMap(); 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); } function trigger(target, key) { const depsMap targetMap.get(target); if (!depsMap) return; const dep depsMap.get(key); if (!dep) return; dep.forEach(effect { if (effect.scheduler) { effect.scheduler(effect); } else { effect(); } }); } function reactive(target) { return new Proxy(target, { get(obj, key, receiver) { const result Reflect.get(obj, key, receiver); track(obj, key); // 嵌套对象递归代理 if (typeof result object result ! null) { return reactive(result); } return result; }, set(obj, key, value, receiver) { const oldValue obj[key]; const result Reflect.set(obj, key, value, receiver); if (oldValue ! value) { trigger(obj, key); } return result; } }); }这段代码里有几个关键点需要展开说第一activeEffect的全局变量设计。这是依赖收集的核心机制。当执行一个副作用函数时先把activeEffect指向它然后执行函数。函数内部访问响应式属性时track就能知道“当前是谁在读取这个属性”从而建立依赖关系。执行完毕后再把activeEffect恢复成上一个值支持嵌套副作用。第二WeakMap的选择理由。依赖图的键是响应式对象本身。如果用Map那么只要依赖图存在响应式对象就永远不会被回收造成内存泄漏。用WeakMap后当响应式对象没有其他引用时垃圾回收器可以正常回收它依赖图里的记录也会随之消失。第三嵌套对象的递归代理。上面的实现里每次get返回对象时都会重新调用reactive包装。这样做的问题是同一个对象每次访问都会创建一个新的 Proxy导致依赖关系混乱。正确的做法是用一个WeakMap缓存已经代理过的对象const reactiveMap new WeakMap(); function reactive(target) { const existing reactiveMap.get(target); if (existing) return existing; const proxy new Proxy(target, handler); reactiveMap.set(target, proxy); return proxy; }第四set里的旧值比较。oldValue ! value这个判断看起来简单但实际场景里有很多坑。比如NaN ! NaN是true所以如果新值和旧值都是NaN会触发不必要的更新。更严谨的写法是!Object.is(oldValue, value)。实操心得在set拦截器里一定要先执行Reflect.set再触发更新。因为如果set失败比如对象被冻结Reflect.set会返回false这时候不应该触发更新。我见过有人先trigger再set结果数据没变但视图更新了排查了半天。3.2 副作用系统的实现effect 和 computed 的配合副作用系统是响应式引擎的“执行者”。effect负责注册副作用computed负责派生状态。先看effect的实现function effect(fn, options {}) { const effectFn () { // 清理旧的依赖 cleanup(effectFn); // 设置当前活跃副作用 activeEffect effectFn; // 执行函数触发依赖收集 const result fn(); // 恢复上一个活跃副作用 activeEffect null; return result; }; effectFn.deps []; effectFn.scheduler options.scheduler; // 立即执行一次完成首次依赖收集 effectFn(); return effectFn; } function cleanup(effectFn) { effectFn.deps.forEach(dep { dep.delete(effectFn); }); effectFn.deps.length 0; }这里最容易被忽略的是cleanup这一步。为什么每次执行副作用前要清理旧依赖考虑这个场景const state reactive({ flag: true, a: 1, b: 2 }); effect(() { if (state.flag) { console.log(state.a); } else { console.log(state.b); } });首次执行时副作用依赖了flag和a。当flag变成false后副作用重新执行这次依赖了flag和b。如果没有cleanup副作用会同时依赖a和b之后修改a也会触发更新但此时a已经不在逻辑分支里了这就是无效依赖。cleanup保证了每次执行时依赖关系都是最新的。再看computed的实现它本质上是一个带缓存的effectfunction computed(getter) { let value; let dirty true; const effectFn effect(getter, { scheduler() { dirty true; // 通知依赖 computed 的副作用 trigger(obj, value); } }); const obj { get value() { if (dirty) { value effectFn(); dirty false; } track(obj, value); return value; } }; return obj; }computed的核心是惰性求值 缓存。dirty标记表示当前缓存是否失效。只有dirty为true时才重新执行getter否则直接返回缓存值。同时computed本身也是一个响应式对象当它的值变化时依赖它的副作用也会被触发。注意computed的scheduler里调用trigger(obj, value)时obj还没有定义。实际实现中需要调整顺序先定义obj再创建effect或者用一个变量暂存。这个细节在写的时候很容易出错建议用调试器单步跟一遍。3.3 调度器的实现微任务队列与批量更新调度器解决的是“更新太频繁”的问题。没有调度器时每次set都会同步执行所有副作用。如果在一个循环里修改了 100 次数据副作用就会执行 100 次其中 99 次都是浪费。调度器的核心思路是把副作用放进一个队列在微任务中统一执行执行前去重。const queue new Set(); let isFlushing false; const resolvedPromise Promise.resolve(); function queueJob(job) { queue.add(job); if (!isFlushing) { isFlushing true; resolvedPromise.then(() { try { queue.forEach(job job()); } finally { queue.clear(); isFlushing false; } }); } }用Set而不是数组是因为Set自动去重。同一个副作用在一次同步代码中被触发多次最终只会执行一次。isFlushing标记防止重复创建微任务。把调度器接入effect的方式很简单在trigger里判断function trigger(target, key) { const depsMap targetMap.get(target); if (!depsMap) return; const dep depsMap.get(key); if (!dep) return; dep.forEach(effect { if (effect.scheduler) { effect.scheduler(effect); } else { queueJob(effect); } }); }这样所有没有自定义调度器的副作用都会走批量更新流程。computed因为有自定义scheduler会走自己的逻辑。实操心得微任务队列的时机选择很关键。用Promise.resolve().then()意味着更新会在当前同步代码执行完毕后、下一个宏任务之前执行。如果你需要更早的时机可以用queueMicrotask如果需要更晚可以用setTimeout。大多数场景下Promise微任务是最佳选择因为它足够快又不会阻塞同步代码。3.4 边界场景处理数组、循环引用与异步更新上面讲的是核心流程但真正让一个响应式系统“能用”的是边界场景的处理。这部分内容在大多数教程里被一笔带过但实际项目里踩坑最多的就是这里。数组的处理。Proxy 可以拦截数组的索引访问和length修改但数组的push、pop、splice等方法会同时触发多次get和set。比如push会先读取length再设置新索引再修改length。如果每次set都触发更新一次push会触发三次更新。解决方案是在调度器里做去重Set已经解决了或者对数组方法做特殊处理。循环引用的处理。如果对象 A 的属性指向对象 B对象 B 的属性又指向对象 A递归代理时会无限循环。解决方案是在reactiveMap里缓存代理对象遇到已经代理过的对象直接返回缓存function reactive(target) { if (typeof target ! object || target null) return target; const existing reactiveMap.get(target); if (existing) return existing; const proxy new Proxy(target, { get(obj, key, receiver) { const result Reflect.get(obj, key, receiver); track(obj, key); return reactive(result); // 递归代理但有缓存保护 }, // ... }); reactiveMap.set(target, proxy); return proxy; }异步更新的处理。当副作用函数里有await时activeEffect的设置和恢复会跨越异步边界导致依赖收集错乱。解决方案是在await之前手动保存activeEffectawait之后恢复。或者更简单的方式是避免在副作用函数里直接使用await把异步逻辑拆到副作用外面。边界场景问题表现解决方案注意事项数组 push一次操作触发多次更新调度器去重确保 Set 队列生效循环引用递归代理栈溢出WeakMap 缓存代理对象缓存键用原始对象异步副作用依赖收集错乱手动保存恢复 activeEffect尽量避免副作用内 awaitNaN 比较不必要的更新触发用 Object.is 替代 同时处理 0/-0冻结对象set 失败但仍触发更新检查 Reflect.set 返回值冻结对象应跳过代理4. 实操过程与核心环节实现从零搭建一个可运行的 rea 项目4.1 项目初始化与目录结构前面讲的是原理这一节讲怎么把它跑起来。我建议的目录结构是这样的rea/ ├── src/ │ ├── reactive.js # 代理层实现 │ ├── effect.js # 副作用系统 │ ├── scheduler.js # 调度器 │ ├── computed.js # 计算属性 │ └── index.js # 统一导出 ├── test/ │ ├── reactive.test.js │ ├── effect.test.js │ └── computed.test.js ├── package.json └── README.md初始化命令mkdir rea cd rea npm init -y npm install -D vitest在package.json里加上测试脚本{ scripts: { test: vitest, test:run: vitest run } }选择 Vitest 而不是 Jest是因为 Vitest 对 ESM 的支持更好配置更简单启动速度也更快。对于一个纯 JavaScript 的小型库来说Vitest 是更现代的选择。4.2 核心模块的完整实现与参数说明把前面几节的代码整合起来src/index.js的完整实现如下// 全局状态 let activeEffect null; const targetMap new WeakMap(); const reactiveMap new WeakMap(); // 依赖收集 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); } // 触发更新 function trigger(target, key) { const depsMap targetMap.get(target); if (!depsMap) return; const dep depsMap.get(key); if (!dep) return; const effectsToRun new Set(dep); effectsToRun.forEach(effect { if (effect.scheduler) { effect.scheduler(effect); } else { queueJob(effect); } }); } // 调度器 const queue new Set(); let isFlushing false; const resolvedPromise Promise.resolve(); function queueJob(job) { queue.add(job); if (!isFlushing) { isFlushing true; resolvedPromise.then(() { try { queue.forEach(job job()); } finally { queue.clear(); isFlushing false; } }); } } // 副作用 function cleanup(effectFn) { effectFn.deps.forEach(dep dep.delete(effectFn)); effectFn.deps.length 0; } function effect(fn, options {}) { const effectFn () { cleanup(effectFn); activeEffect effectFn; const result fn(); activeEffect null; return result; }; effectFn.deps []; effectFn.scheduler options.scheduler; effectFn(); return effectFn; } // 响应式对象 function reactive(target) { if (typeof target ! object || target null) return target; const existing reactiveMap.get(target); if (existing) return existing; const proxy new Proxy(target, { get(obj, key, receiver) { const result Reflect.get(obj, key, receiver); track(obj, key); return reactive(result); }, set(obj, key, value, receiver) { const oldValue obj[key]; const result Reflect.set(obj, key, value, receiver); if (!Object.is(oldValue, value)) { trigger(obj, key); } return result; }, deleteProperty(obj, key) { const hadKey Object.prototype.hasOwnProperty.call(obj, key); const result Reflect.deleteProperty(obj, key); if (hadKey result) { trigger(obj, key); } return result; } }); reactiveMap.set(target, proxy); return proxy; } // 计算属性 function computed(getter) { let value; let dirty true; const effectFn effect(getter, { scheduler() { dirty true; trigger(obj, value); } }); const obj { get value() { if (dirty) { value effectFn(); dirty false; } track(obj, value); return value; } }; return obj; } export { reactive, effect, computed };这段代码可以直接复制到项目里运行。几个关键参数说明activeEffect初始为null表示当前没有副作用在执行targetMap用WeakMap键是原始对象值是Mapkey, SeteffectreactiveMap用WeakMap缓存原始对象到代理对象的映射queue用Set自动去重isFlushing防止重复创建微任务Object.is替代正确处理NaN和-04.3 测试用例编写与验证写完核心代码必须用测试用例验证行为。以下是我实际使用的测试用例覆盖了主要场景import { describe, it, expect, vi } from vitest; import { reactive, effect, computed } from ../src/index.js; describe(reactive, () { it(基本读写, () { const state reactive({ count: 0 }); expect(state.count).toBe(0); state.count 1; expect(state.count).toBe(1); }); it(嵌套对象, () { const state reactive({ nested: { value: 1 } }); expect(state.nested.value).toBe(1); state.nested.value 2; expect(state.nested.value).toBe(2); }); it(同一对象返回同一代理, () { const raw { a: 1 }; const state reactive(raw); expect(reactive(raw)).toBe(state); }); }); describe(effect, () { it(依赖收集与触发, async () { const state reactive({ count: 0 }); const fn vi.fn(() state.count); effect(fn); expect(fn).toHaveBeenCalledTimes(1); state.count; await Promise.resolve(); expect(fn).toHaveBeenCalledTimes(2); }); it(分支切换清理依赖, async () { const state reactive({ flag: true, a: 1, b: 2 }); const fn vi.fn(() state.flag ? state.a : state.b); effect(fn); state.a; await Promise.resolve(); expect(fn).toHaveBeenCalledTimes(2); state.flag false; await Promise.resolve(); expect(fn).toHaveBeenCalledTimes(3); state.a; await Promise.resolve(); expect(fn).toHaveBeenCalledTimes(3); // 不应再触发 }); }); describe(computed, () { it(缓存与更新, async () { const state reactive({ count: 0 }); const double computed(() state.count * 2); expect(double.value).toBe(0); expect(double.value).toBe(0); // 缓存不重新计算 state.count 5; await Promise.resolve(); expect(double.value).toBe(10); }); });运行npm run test:run如果所有用例通过说明核心功能正常。特别注意“分支切换清理依赖”这个用例它验证的是cleanup机制是否生效。这个用例不过说明依赖清理有问题后续会出现无效更新。4.4 性能测试与体积优化功能跑通后下一步是验证性能。我写了一个简单的基准测试import { reactive, effect } from ../src/index.js; const state reactive({ items: [] }); effect(() { // 模拟依赖所有 item state.items.forEach(item item.value); }); console.time(1000次push); for (let i 0; i 1000; i) { state.items.push({ value: i }); } console.timeEnd(1000次push);实测下来1000 次push在调度器去重后副作用只执行了 1 次微任务合并耗时在 5ms 以内。如果不加调度器副作用会执行 1000 次耗时超过 200ms。这个差距说明调度器不是可选项是必选项。体积优化方面用 Rollup 打包并开启压缩npm install -D rollup rollup/plugin-terserrollup.config.jsimport terser from rollup/plugin-terser; export default { input: src/index.js, output: { file: dist/rea.min.js, format: es, plugins: [terser()] } };打包后体积约 1.8KBgzip符合预期。实操心得性能测试不要只看总耗时要看副作用执行次数。执行次数才是响应式系统效率的核心指标。我见过有人优化了半天代码结果副作用执行次数没变性能自然没提升。先看次数再看单次耗时。5. 常见问题与排查技巧实录踩过的坑才是真经验5.1 依赖收集失效的三种典型场景场景一在副作用外部读取响应式属性。这是最常见的错误。依赖收集的前提是activeEffect有值而activeEffect只在effect执行期间有值。如果你在effect外面读取属性track会直接返回依赖关系不会建立。const state reactive({ count: 0 }); // 错误在 effect 外部读取 const value state.count; effect(() { console.log(value); // 不会响应 count 变化 }); // 正确在 effect 内部读取 effect(() { console.log(state.count); // 会响应 });场景二解构响应式对象。解构会把属性值复制出来复制出来的值不再是响应式的。const state reactive({ count: 0 }); // 错误解构后失去响应性 const { count } state; effect(() { console.log(count); // 不会响应 }); // 正确保持对象引用 effect(() { console.log(state.count); // 会响应 });场景三在异步回调中读取。await之后activeEffect已经被恢复为null此时读取属性不会建立依赖。effect(async () { const data await fetchData(); console.log(state.count); // 不会响应因为 activeEffect 已恢复 });解决方案是把异步逻辑拆开或者在await前手动保存activeEffect。5.2 内存泄漏的排查与预防响应式系统最常见的内存泄漏是依赖图没有正确清理。排查方法很简单在浏览器 DevTools 的 Memory 面板里拍两次快照对比响应式对象的数量。如果对象数量持续增长说明有泄漏。预防措施有三个第一确保cleanup在每次副作用执行前被调用。这是防止无效依赖累积的关键。第二用WeakMap存储依赖图。这样当响应式对象被回收时依赖记录自动清除。第三对于手动创建的副作用提供停止方法function effect(fn, options {}) { const effectFn () { /* ... */ }; // ... effectFn.stop () { cleanup(effectFn); }; return effectFn; }在组件卸载或不需要响应时调用stop()主动清理依赖。5.3 常见问题速查表问题现象可能原因排查方法解决方案数据变了视图不更新依赖未收集检查是否在 effect 内读取确保读取操作在 effect 内更新次数过多调度器未生效打印副作用执行次数检查 queueJob 是否被调用内存持续增长依赖未清理Memory 快照对比检查 cleanup 逻辑嵌套对象不响应递归代理未生效检查 get 返回值确保 reactive 递归调用computed 不更新dirty 未重置检查 scheduler确保 trigger 被调用数组操作异常数组方法特殊行为单独测试数组场景对数组方法做特殊处理循环引用栈溢出递归无终止检查 reactiveMap 缓存确保缓存命中NaN 触发更新比较方式不当检查 set 里的比较用 Object.is 替代 5.4 独家避坑技巧技巧一用debugger代替console.log。响应式系统的执行流程比较复杂console.log会打断执行节奏而且异步更新时日志顺序会乱。用debugger单步跟踪能清楚看到activeEffect的变化和依赖图的构建过程。技巧二给副作用命名。在开发阶段给每个effect加一个name属性出问题时能快速定位是哪个副作用导致的effect(function myEffect() { // ... }, { name: myEffect });技巧三限制递归深度。嵌套对象递归代理时如果对象层级过深比如超过 10 层可以考虑只代理前几层或者用懒代理的方式只在访问时才代理下一层。这样能避免初始化时的性能开销。技巧四测试用例覆盖边界。我列的测试用例里分支切换、NaN、循环引用、数组操作这几个场景必须覆盖。这些场景在正常使用时很少遇到但一旦遇到就是难排查的 bug。技巧五版本锁定。如果你在项目里用了自研的响应式系统一定要锁定版本。因为响应式系统的行为变化会影响整个应用升级前必须跑完整测试。6. 扩展方向与个人体会“rea”这个项目做到这里核心功能已经完整了。如果后续要继续扩展我建议从三个方向入手第一个方向是增加refAPI。reactive只能代理对象对于基本类型数字、字符串、布尔值需要一个包装对象来承载响应式能力。ref的实现思路是用一个带有value属性的对象对value的读写做拦截。第二个方向是增加watchAPI。watch本质上是一个带自定义调度器的effect它可以在依赖变化时执行回调而不是重新执行整个副作用函数。实现时需要注意旧值和新值的传递以及immediate和deep选项的处理。第三个方向是增加shallowReactive和readonly。shallowReactive只代理第一层不递归代理嵌套对象适合性能敏感的场景。readonly禁止所有写操作适合传递配置对象。我个人在实际操作中的体会是写一个响应式系统最难的不是核心逻辑而是边界场景的处理。核心逻辑两百行就能写完但要让它在各种场景下都表现正确需要反复测试和调整。我前后改了大概五版才把数组、循环引用、异步更新这几个场景处理干净。如果你也在写类似的东西我的建议是先把测试用例写全再写实现。测试驱动开发在这个领域特别适用因为响应式系统的行为是确定的测试用例就是行为规范。最后再分享一个小技巧如果你不确定某个场景下响应式系统应该表现成什么样去看看 Vue 3 或 Solid.js 的测试用例。它们的测试用例覆盖了几乎所有你能想到的场景是很好的参考。但不要直接抄代码理解每个用例背后的意图然后用自己的方式实现。这样你收获的不仅是一个能用的库更是一套完整的响应式思维。

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

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

免费获取报价 →
↑