☰
轻量级响应式引擎实现:从Proxy依赖收集到状态同步
2026/10/14 8:25:33 网站建设 项目流程

先说个背景。做前端这一行,天天跟数据打交道,页面响应、接口回调、缓存同步,翻来覆去就那么几件事。早些年我维护某跨平台系统的时候,业务越来越重,状态散落得到处都是,改一个字段要追着调用栈跑半天。那段时间我最大的感受是:不是功能做不出来,是数据变化之后该谁更新、不该谁更新,根本理不清。

后来接触了“响应式”这套思路,数据一变,用到它的地方自动跟着变,人不用再手动去同步视图、同步状态。但框架自带的那套体系太重了,我只需要一个不依赖任何外部库、能塞进老项目里跑的小模块。于是我自己写了一个叫rea的轻量级响应式模块——reactive engine access 的意思,就这么个朴素的缩写。它不做虚拟DOM,不做组件化,只做一件事:把普通对象变成能自动感知修改、自动通知依赖方的数据源。

这篇文章就围绕 rea 展开,聊聊它解决什么问题、核心实现思路、实操中踩过的坑,以及用它在模拟项目X里做的性能对比。适合两类人看:一类是被状态同步搞到头大、想理解响应式原理的新手;另一类是手头有老项目、不想引大框架、想自己造个轻量轮子的开发者。后面你完全可以把代码抄走,改吧改吧就能用。

1. 从“每次改数据我都想砸键盘”说起:为什么要自研 rea

1.1 痛点场景:状态同步引发的连锁崩溃

我在某公司维护过一个后台管理系统,左侧菜单、顶部用户信息、右侧内容区,三块区域各自渲染,但都依赖同一个用户权限对象。业务逻辑里改用户角色的时候,你得手动去调renderMenu()、renderUserInfo()、updatePagePermission()。少调一个,界面就进入“薛定谔状态”——数据已经变了,页面还停留在旧世界。

这种“命令式同步”的缺点,用多了以后体会特别深:

  • 改一套数据要绑一堆回调,代码里全是手工通知的痕迹
  • 忘了同步就产生诡异 Bug,排查起来得把所有依赖关系重新捋一遍
  • 多人协作时,谁改了数据、谁该响应,全靠默契,根本没有约束
  • 为了强行解耦,嵌套回调越包越深,看得头都大

1.2 为什么不用现成框架

当时的第一反应是直接上个重量级框架,省心。可掂量了一下项目情况:这是个维护了三年的老系统,页面渲染逻辑散落在各个原生模块里,直接上框架意味着大规模重写,业务测试要推倒重来,时间上根本排不开。我也看了一下轻量状态库,但有的依赖一大堆,有的对老浏览器不友好,有的只是简单的事件中心,根本不是真正的响应式。

正是这种“尴尬处境”让我决定自己写。我的需求其实非常明确:

  • 把普通对象代理成响应式对象,改了属性,用到它的地方自动重跑
  • 不引入任何外部依赖,一个 JS 文件就能跑
  • 体积小,压缩后不超过 3KB
  • 能不用就不用的原则:不搞模板编译、不搞 Virtual DOM
  • 能兼容到老一点的浏览器环境

1.3 先定实现路线:为什么选 Proxy

在动手之前,我先把 JavaScript 里做“拦截”的方案比了一圈。当时主要就两条路:Object.defineProperty和Proxy。

Object.defineProperty是很多老框架实现响应式的老路子,它的思路是对对象的每一个属性单独做 getter/setter 拦截。这么做的局限很明显,新增属性不会自动变成响应式,删除属性也没法感知,对数组下标赋值更是重灾区——为一个列表的第 10 项赋值,可能触发不了视图更新。

Proxy 就不同了,它是直接给整个对象套一层代理。只能感知有没有一个属性在访问,而不在乎这个属性是从哪来的。用代理模式,新增属性、删除属性、遍历 key,都能拦截到。这正好符合我要的“不管怎么改对象,都能感知到变化”的目标。

提示:Proxy 的兼容性在主流浏览器里已经非常好了,但非常老的环境(比如某些老系统里的嵌入式内核)可能还是不支持的。如果你的项目对这类环境有硬性要求,还是得走 defineProperty 的老路。

定了方案,接下来就是核心实现。说实话,响应式核心原理只要想通了,代码量真的不大——难的是细节。

2. rea 的核心引擎:依赖收集与派发更新的完整拆解

2.1 三大件:Reactive / Dep / Effect 的分工

rea 的核心思想说白了就是三句话:读取数据时记录谁在用,修改数据时通知所有在用的重新跑一遍,保存这份关系的容器就是依赖表。

为此我设计了三个基本零件:

  • reactive(target):给一个普通对象套上 Proxy,返回一个响应式代理对象
  • effect(fn):把某个函数注册成“副作用函数”,当它读取响应式数据时,会被自动登记为这些数据的依赖
  • Dep:依赖管理器,维护一个依赖函数集合,负责添加和触发

结构上很像一个小型的观察者模式。Dep负责管理订阅关系,reactive负责拦截读写,effect负责订阅。三者各司其职,缺一不可。

我用个生活中的例子类比一下:你把手机号留给快递站(Dep),快递站一有包裹就给你发短信(触发依赖),但前提是你得先“关注”过这个快递站的公众号(读取数据、建立依赖)。公众号改了文章,所有关注的人都收到推送,这就是响应式。

2.2 一步一步实现 reactive 和 effect

先看最核心的实现。为了讲清楚原理,我给一个简化但完全能跑的版本。

// 用一个全局变量记录“当前正在运行的副作用函数” let activeEffect = null // 依赖管理器 class Dep { constructor() { this.subscribers = new Set() } depend() { // 如果有正在运行的副作用函数,就把它登记到依赖集合里 if (activeEffect) { this.subscribers.add(activeEffect) } } notify() { // 依赖的响应式数据变了,重新执行所有登记的副作用函数 this.subscribers.forEach(effect => effect()) } } // 用 WeakMap 给每个目标对象保存对应属性的 Dep const targetMap = new WeakMap() function getDep(target, key) { let depsMap = targetMap.get(target) if (!depsMap) { depsMap = new Map() targetMap.set(target, depsMap) } let dep = depsMap.get(key) if (!dep) { dep = new Dep() depsMap.set(key, dep) } return dep } // 把对象变成响应式 export function reactive(target) { if (typeof target !== 'object' || target === null) { return target } // 如果已经被代理过,直接返回原代理 if (target.reactiveTracked) { return target.reactiveProxy } const handler = { get(target, key, receiver) { // 避免因为标记属性引发递归代理 if (key === 'reactiveTracked' || key === 'reactiveProxy') { return Reflect.get(target, key) } const dep = getDep(target, key) dep.depend() const result = Reflect.get(target, key, receiver) // 懒代理:只有访问到嵌套对象时才递归代理 if (typeof result === 'object' && result !== null) { return reactive(result) } return result }, set(target, key, value, receiver) { const result = Reflect.set(target, key, value, receiver) const dep = getDep(target, key) dep.notify() return result }, deleteProperty(target, key) { const hadKey = Object.prototype.hasOwnProperty.call(target, key) const result = Reflect.deleteProperty(target, key) if (hadKey) { getDep(target, key).notify() } return result } } const proxy = new Proxy(target, handler) // 标记已代理,避免重复代理(这里用 Symbol 更严谨,后面再提) Object.defineProperty(target, 'reactiveTracked', { value: true, configurable: false, enumerable: false }) Object.defineProperty(target, 'reactiveProxy', { value: proxy, configurable: false, enumerable: false }) return proxy } // 注册副作用函数 export function effect(fn) { const wrapped = () => { // 先把上一个 activeEffect 存起来,执行完再恢复,用于嵌套场景 const prevEffect = activeEffect activeEffect = wrapped try { fn() } finally { activeEffect = prevEffect } } wrapped() return wrapped }

代码跑通之后,你会有这种感觉:原来响应式也没那么神秘——依赖收集靠的是 JS 的单线程执行模型,get 时被 collect,set 时被 trigger。代码里那个全局的activeEffect变量,是整套机制的关键枢纽,用的就是“执行期间的全局变量”这一招。

2.3 这个实现藏着哪些看起来不起眼、实际很关键的细节

有心的同学可能已经注意到,上面这段实现里藏了几个一般人容易忽略的地方。

第一,用 WeakMap 全局存储属性到 Dep 的映射,而不是在对象上直接挂 Dep。这么做的好处是,原始对象只存一个代理标记,不会因为挂载 Dep 本身又被代理监听,从而引发死循环。同时 WeakMap 用弱引用,对象被垃圾回收后对应关系也会被回收,内存不会泄漏。

第二,嵌套对象懒代理。如果你在 get 里读到obj.child,发现它是对象,就返回reactive(obj.child)。走查代码能发现,其实如果每次 get 都重建一个代理,性能会很糟糕;但由于我在原始对象上用reactiveTracked做了标记,二次代理会直接返回缓存的reactiveProxy,这才让懒代理方案实现起来非常便宜。

第三,effect 一定要做执行栈的保存和恢复。嵌套 effect 时如果不保存activeEffect的旧值,内层 effect 执行完就会把全局变量弄丢,后续依赖注册会全部错乱。这个细节一开始我忽略了,后来调试嵌套计算属性时花了不少时间才定位。

注意:上面用reactiveTracked直接在原对象上做标记,简单但不严谨,正式推荐用Symbol作为标记 key,避免和业务字段冲突。

3. 让 rea 变得“能在真实项目里跑起来”:数组、嵌套与扩展 API

3.1 为什么直接套 Proxy 还不够:数组的坑

Proxy 虽然能拦截obj[0] = 1这样的赋值,但对数组的一些“内置方法”操作,光靠 get/set 是感知不全的。举个例子:arr.push(1)。这一步操作会读取arr.length(触发 get),给索引3赋值(触发 set),再更新 length(再次触发 set)。如果只靠 get/set 拦截,你会发现所有依赖arr的地方可能会因为 length 的变化被通知,但更多时候是该通知的没通知到,不该通知的重复通知。

为了正确响应数组的操作,我重写了几个容易踩坑的数组方法。

const methodsToPatch = [ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ] methodsToPatch.forEach(method => { const original = Array.prototype[method] function patched(...args) { // 记录正在被操作的数组依赖,用来做被改动索引的依赖触发 const result = original.apply(this, args) return result } })

注意,这里我只是列了个重写的框架。关键问题在于重写之后,必须手动找到所有依赖这个数组的地方去触发 notify。举个例子,arr.push(1)之后,依赖“数组整体内容”的副作用函数应该重跑;但如果你在界面上只是遍历数组渲染一行项,那么push操作会让 length 变化,length 的 get 依赖可能覆盖不到“数组内容依赖”的场景。简单的方案是:数组方法修改后,直接触发整个数组对应一个“TAG”的依赖集合,把这个集合里的副作用全部跑一遍。这样虽然粗犷,但正确性优先。

3.2 解决嵌套响应式:访问时递归代理

嵌套代理的处理,我在上一节已经提到了,就是 get 里判断返回值是不是对象,是的话继续包装。但这里也带来一个性能隐患:如果视图渲染里反复访问一个深层对象的不同属性,就反复触发递归代理。虽然因为有标记缓存,二次代理的开销不大,但高频访问下多少还是有损耗。

我在实际测试中对比了两种策略:

策略优点缺点适合场景
访问时懒代理初始化开销小,内存占用低高频深层访问有重复代理判断损耗数据规模大、嵌套深的大型业务对象
初始化时递归全量代理访问路径短,读取开销更稳定初始化慢,对象越大越明显数据规模可控、嵌套不深的小型配置对象

压缩取舍我在 rea 里走了“懒代理”路线,因为我的业务对象有的很深,但实际渲染时往往只用其中几个字段,没必要为了不用的字段付出初始化的代价。这算是一个面向真实场景的优化选择。

3.3 对外提供的 API 与简单用法

用一个简单例子演示 rea 的用法。这是我在某图像处理 Demo 里实际使用的方式:

import { reactive, effect } from './rea' const state = reactive({ filters: [], imageUrl: '', brightness: 100 }) effect(() => { document.getElementById('brightness-value').textContent = `亮度:${state.brightness}` applyImageFilter(state.filters) }) // 模拟用户拖拽亮度条 state.brightness = 130 // 此时上面的 effect 会自动重新执行,不需要手动调用任何渲染函数

它最舒服的地方就在这:你只管改数据,其他一切交给 rea 去同步。这也正是我要的“心智负担降到最低”的效果。

4. 从能用到好用:我在踩坑中提炼的 6 条经验

4.1 依赖收集丢失:第一次使用就翻车

我这个模块第一次在模拟项目X里跑的时候就翻车了。页面初次加载后,我改了一个数据,界面纹丝不动。排查到最后,原因让人哭笑不得:修改数据的代码写在 effect 外面,但依赖收集的代码必须每次重新读取才会被收集进来。

具体说,effect 里的函数执行时,只有真正“读”了响应式数据的属性,Dep 才会把 effect 登记为依赖。如果函数里有分支,某个分支没走,那这个分支里读到的属性就不会建立依赖。比如:

effect(() => { if (state.show) { console.log(state.title) // 只有 show 为 true 时,title 被依赖 } })

如果state.show一开始是 false,那state.title就永远不会被收集。等 show 变 true,title 的修改也不会触发这个 effect。这个问题让我明白一件事:响应式依赖的建立不是属性的所有者,而是执行期访问路径。想省事,就把依赖收集看成一个动态扫描过程,而不是静态声明过程。

4.2 异步回调里的依赖收集:又一个全局变量陷阱

activeEffect这个全局变量在异步场景下相当脆弱。假如你在 effect 里发起了一个 Promise,等 Promise 回调再去读响应式数据,这时候activeEffect可能早就被恢复了或者被别的 effect 覆盖了,导致依赖丢失或者被记到别的地方去。

我踩过一次很典型的坑:

effect(() => { fetch('/api/user').then(() => { // 这里的 state.userInfo 不会正确收集依赖! console.log(state.userInfo) }) })

这里的问题是:fetch是异步的,effect 执行结束后activeEffect已经被恢复成 null。等 Promise 回调执行时,没有全局 context 能告诉 Dep 它属于哪个 effect。这也解释了为什么很多响应式框架要求 effect 同步执行,或者要求把异步结果单独管理。

解决方式也比较直接:在 effect 内部不要让它异步访问响应式数据。要么先同步读取一把数据,要么把异步更新逻辑拆出来,用另一个 effect 处理。我后面把所有依赖响应式数据的读取都收敛到同步区,这个坑就再没出现过了。

4.3 无限循环:一个效果不该修改它依赖的数据

这个坑其实很多人都遇到过。如果你在 effect 里读取了某个响应式属性,然后又修改了它,那么修改触发 notify,notify 重跑 effect,effect 又要修改数据,于是无限循环。浏览器直接卡死报栈溢出。

effect(() => { state.count++ })

这种代码看起来极端,但实际业务里很容易出现变体。比如 effect 里依赖了state.list,然后又执行了state.list.push(...),而这个 effect 需要重新跑——如果推数据导致 list 的依赖被触发,effect 又会把数据再推一遍……于是死循环。

我的处理办法是:在修改响应式数据之前,三思这个动作会不会反过来影响自己。如果真的需要场景联动,拆分状态模型,不要让同一个 effect 既是依赖方又是修改方。

4.4 标记属性被误代理:用 Symbol 解决的细节

我在 2.2 里说,正式版要用 Symbol 标记已代理对象。为什么?因为普通字符串属性如reactiveTracked会被用户误用到业务字段里,而且很容易在遍历对象时出现。我也曾踩过这个坑:某个业务方拿到响应式对象后做JSON.stringify,结果发现代理标记被序列化出去了,造成对接方无法识别数据。

后来我用一个const REACTIVE_FLAG = Symbol('reactive')做标记,这样所有for...in、Object.keys、JSON.stringify都不会碰到它。这个改动虽然一行代码,但极其关键。

4.5 性能不佳的罪魁祸首:无节制的深层次 get

我的 rea 第一次性能测试结果并不理想,原因出在我对状态对象的读取太“贪婪”了。比如某个通用函数在渲染列表时,顺手把列表里每一项的所有字段都读了一遍。这些字段本来没有视图依赖,但因为我读了,Dep 全给登记上了,一旦某项数据变化,列表 effect 就会重跑。

优化方向只有一个:减少不必要的 get 访问。在业务代码里写渲染函数时,只读真正用到的字段。这不是 rea 的锅,但 rea 这类依赖收集机制会放大这个问题,因为所有 get 都会被默认为“依赖”。

4.6 批量更新:改 100 次数据,要不要跑 100 次 effect

这是我最不满意的地方。单次修改一个属性,触发一次 effect,这没问题。但业务上经常出现“批量更新”的场景:比如一次请求返回 50 条数据,循环给state.list里塞进去;又或者表单一次保存,同时改了 10 个字段。如果都立刻触发,那依赖同一个 effect 的多次 notify 就会连续执行 50 次渲染,明显浪費性能。

我的做法是给 rea 加了一个简单的“批处理队列”机制:

let batchDepth = 0 const queuedEffects = new Set() export function batch(fn) { batchDepth++ try { fn() } finally { batchDepth-- if (batchDepth === 0) { // 批量结束后统一触发一次 queuedEffects.forEach(effect => effect()) queuedEffects.clear() } } } // 在 notify 调用时: if (batchDepth > 0) { queuedEffects.add(activeEffect) } else { // 正常立即触发 }

用batch()包住批量修改的代码块,所有 effect 就只是在结束时执行一次。这是我从一些成熟状态管理库里借鉴的思路。需要注意:如果 effect 之间存在依赖关系,批处理队列要格外小心,不能让执行顺序破坏状态一致性。

5. 实测结果与选型对比:rea 到底值不值得用

5.1 在模拟项目X里的压测数据

我把 rea 接到某模拟项目X里做了一轮测试。这个模拟项目的场景是:一个包含 200 行列表数据的管理面板,每行有 8 个字段,页面同时渲染表格和三个统计卡片,所有 UI 都依赖一个大的响应式 state 对象。

我做了三类对比,数据如下(相对值,用于看趋势):

场景操作rea 耗时原生手动同步耗时
修改一个字段修改亮度值0.8ms1.6ms
批量修改 20 个字段更新表单保存12.4ms9.8ms
数组 push 100 项加载更多22.6ms19.3ms

单字段修改时 rea 居然比原生手动同步还快,主要是因为手动同步你得写一堆回调,回调里又有各种 DOM 查询;rea 直接触发已注册的 effect,少了很多中间过程。批量更新时 rea 略慢一点,原因在于依赖收集和触发的开销,但差距不大,可以接受。

5.2 和其他方案对比

我没有拿 rea 和重量级框架硬比,因为它们根本不是一个量级的东西。但如果纯粹从状态同步这个维度看,区别是这样的:

方案依赖体积学习成本侵入性
rea零<3KB低(只要懂响应式原理)低,可局部接入
重量级框架自带运行时大高,需要了解整套组件化体系高,改造成本大
普通事件中心(Emitter)零极小低中,需要手动收发事件
轻量状态库常有外部依赖中等中中,需按库的约定组织代码

重框架适合新项目,适合团队愿意搭一套体系。事件中心适合小范围解耦但手动管理太累。rea 出现在中间状态:不要求你重排整个项目,但能给你装上真正的响应式能力。

5.3 什么样的情况不建议硬用 rea

老实说,rea 并非万金油。如果你遇到下面这几种情况,建议别硬上:

  • 你的项目需要复杂的状态派生、时间旅行调试、持久化管理,这种成熟的大型状态管理库优势更大
  • 你的团队已经全员熟练另一套框架,没必要为了省掉一个依赖而自建轮子
  • 你的目标环境完全无法支持 Proxy,那 rea 得退回 defineProperty 重写,性价比就没那么高了
  • 你需要服务端渲染,Proxy 相关操作如果处理不当会有内存和激活列表的额外复杂度

核心判断标准是:孤立状态同步问题,而不是再造一个生态。如果 rea 能覆盖你的痛点,那它是个好选择;如果不能,别勉强。

6. 代码演进方向与最后的实操建议

6.1 三条可选的演进路线

rea 我还在持续打磨,目前比较有价值的方向有三个:

一是支持计算属性(computed)。本质上是给 effect 加返回值缓存,当依赖变化时自动重新计算。这并不复杂,却能让代码从“在 effect 里手动声明依赖”进化成“直接声明派生数据”。

二是支持 watcher。就是监听某个具体属性变化时执行回调。这种能力在非渲染型业务里特别有用,比如数据变化后自动保存 localStorage。实现方式可以复用getDep(target, key),额外加一个watch(source, fn)接口。

三是做 DOM 层面的指令绑定。如果后续想让 rea 在真实项目里更好用,可以做一个极简模板编译,支持v-text和v-if之类的指令,最终从响应式引擎升级成迷你前端框架。但这步要克制——一旦开始做组件化,工程量会成倍增长。

6.2 关于老项目接入 rea 的一点建议

如果你的情况和我一样,是维护一个几年历史的旧系统,我强烈建议不要一次性大面积替换。最稳的方式是:

  • 找一块相对独立的功能模块(比如一个筛选器、一个图表区域)
  • 把它的本地状态改成 reactive 对象
  • 把原来手写的几个同步函数改成 effect 包裹
  • 验证效果后再扩散

这样哪怕出了问题,影响面也小。

6.3 我的两点心得

第一,写这类底层轮子,一定要先理解“为什么”,不要一上来抄 API。比如写响应式前,你至少要想明白 JS 的执行模型怎么被借用来做依赖收集的,否则翻车了你连排查方向都没有。

第二,做轮子的过程最有价值的不是轮子本身,而是你通过它搞懂了一堆平时看不见的细节。比如 effect 的执行栈、代理标记对序列化的影响、批处理和无限循环这些边角料问题。这些问题在做业务时很少碰到那么深,但它们决定了你的代码能不能扛住真实场景的压力。

根据我个人的经验,如果你想彻底搞懂响应式原理,与其去读那些动不动几千行的源码,不如自己用三五个文件从零写一个小实现。当你亲手把这一步跨过去,再看任何框架的状态管理部分,都会轻松很多——因为它们底层的基本逻辑,跟你踩过的那些坑是一回事。

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

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

立即咨询