Rea 是我最近给自己折腾的一个前端状态管理小项目起的代号,它没有英文全称,也不是某个公司里的正式项目,纯粹是我在代码仓库里随手敲下的三个字母。但这三个字母背后藏着一个完整的响应式系统:基于原生 JavaScript 的依赖收集、副作用调度、数据代理和视图同步。最初只是因为一个小需求,想在完全不打包、不引框架的静态页面里拥有类似 Vue 的数据驱动体验,结果越写越上头,最后把整个响应式链路从零到一实现了一遍。这个项目很适合两类人看:一类是已经在用 Vue、React 之类框架、但一直没搞懂数据为什么改了视图会自动更新的前端开发者;另一类是手里经常要维护轻量页面、又不愿意为 50 行交互逻辑引入 500KB 依赖的人。这篇文章我会把 Rea 的完整思路、核心代码、踩坑过程和工作原理全部摊开讲,尽量让零基础的同学也能看懂。
1. 项目背景与定位
1.1 小页面里的状态管理困局
先说清楚我为什么要写这个东西。前阵子接手了一个内部小工具页面,场景非常简单:一个输入框、一个按钮、一个列表、几条提示文案,所有交互加起来不超过三十行逻辑。如果用 Vue 那套工程化流程,我得先初始化项目、装依赖、配打包器,最后产出一个可能比页面本身还大的 bundle。如果不用框架,纯手写 DOM 操作,问题更大:列表要刷新、文案要联动、输入框要同步,每个状态变更都得手动找到对应的 DOM 节点改一遍,代码稍微多一点就乱成一锅粥。
很多团队遇到这种情况会选择引入一个单独的发布订阅库来做事件通知,但发布订阅只能解决“某个事件发生了,通知别人去处理”,它解决不了“哪个数据变了,哪些地方要更新”这个更底层的问题。数据与界面之间没有自动绑定,每次仍然要手动在事件回调里写大量重复的更新逻辑。我最终想要的是一个非常朴素的能力:我声明一个普通 JavaScript 对象作为数据源,然后在 HTML 里用指令把某个节点和某个属性绑定起来,之后无论我在代码哪里修改数据,视图都自动跟着变。这就是响应式状态管理最原始、最核心的诉求,也是 Rea 这个项目诞生的起点。
1.2 Rea 要做什么、不做什么
Rea 的目标非常克制,我只希望它做到三件事:第一,提供一个reactive()函数,把普通对象变成响应式对象;第二,提供一个effect()函数,用来注册副作用任务,比如“渲染列表”“更新文案”这种函数;第三,提供一套极简的模板指令,比如r-text和r-model,让 HTML 元素能直接绑定数据。全部代码不依赖任何第三方库,运行环境只需要一个支持 ES6 的浏览器,连打包器都不需要,直接用<script type="module">引入就行。
同时我也明确划了不做的事:不写虚拟 DOM,不做组件化,不做模板编译优化,不支持一键生成完整框架。Rea 就是一个几百行代码的教学级响应式引擎,它的价值在于把一个现代框架中最关键的原理剥出来,用最容易理解的方式呈现。如果你希望找一个生产级状态管理库来用,那大可不必自己造轮子;但如果你的页面真的很轻、逻辑真的很简单,并且你想知道“框架内部到底是怎么跑起来的”,那跟着我把它实现一遍,收获会非常大。
2. 响应式系统的核心原理拆解
2.1 数据驱动视图的本质
要理解 Rea 这类迷你响应式系统,先得建立一个关键认知:数据驱动视图的本质不是“数据变化时去更新视图”,而是“视图是数据的函数”。这句话怎么理解?你可以把渲染列表想象成一个数学函数:输入待办数组,输出一段完整的列表 HTML。只要这个函数建立起来,数据数组一旦变化,函数就应该重新执行,视图自然就更新了。难点在于,程序运行的时候根本不知道该重新执行哪些函数。
传统做法是命令式:某个按钮被点击,我在回调里手动清空列表、重新循环数据、拼接字符串、插入 DOM。代码长而琐碎。响应式做法是声明式:我声明“这个列表依赖todos数组”,“这段文案依赖remaining数字”,然后就撒手不管。系统内部自己维护了一张表格,记录着数据和函数之间的依赖关系。修改任何数据时,系统查这张表,把被依赖的函数重新执行一遍,视图就更新了。Rea 要写的,就是这张依赖表和触发机制。
2.2 依赖收集:从副作用函数到订阅关系
“依赖收集”这个名词听起来很玄,说穿了就是四个字:边走边记。当一个副作用函数执行时,它会读取某些数据。我们在读取数据的动作里安装一个“探测器”,发现“现在正有一个函数在读我”,就把这个函数记下来,记为“这个数据被这个函数依赖了”。这就是收集。
举个例子,一个渲染函数执行时读取了state.count,那么在读取count的那一瞬间,我们要做一件事:把当前正在执行的渲染函数放进一个和count关联的集合里。以后只要count被修改,我们就从集合里把所有函数拿出来重新执行。这里面的关键数据结构是三层嵌套关系:一个WeakMap以原始对象为键,值为一个Map;这个Map以对象属性名为键,值为一个Set;这个Set里存放的是所有依赖该属性的副作用函数。这个结构用一张表来理解就是:对象A的name属性,被函数1和函数2依赖;对象A的age属性,被函数2依赖。修改 age 时,只需要触发函数2;修改 name 时,触发函数1和函数2。
这里用WeakMap而不是普通Map是有讲究的。WeakMap的键是弱引用,当某个对象不再被业务代码引用时,它和它的依赖关系可以被垃圾回收自动清理,不会产生内存泄漏。这个是整个响应式系统稳定性的基石,很多新手自己写响应式库时会忽略这一点,页面切换后内存越涨越高,问题就出在这里。
2.3 为什么选 Proxy 而不是 Object.defineProperty
Rea 的数据代理层我毫不犹豫选择了 Proxy,这是 Vue 3 那套方案,也是现在实现响应式最合适的方式。为什么不用 Vue 2 时代的Object.defineProperty?我先说结果:Object.defineProperty只能拦截对象上已经存在的属性,你给对象新增一个属性、删除一个属性,它都感知不到。这就会导致一个很尴尬的场景:state.name有响应式,但state.age如果一开始不在对象上,后面再添加就不灵了。Vue 2 为此搞了一套$set和$delete辅助方法,但那是打补丁,不是根本解法。
Proxy 则完全不同,它代理的是整个对象,无论访问、修改、新增、删除、遍历,都会被拦截到。而且 Proxy 还天然支持数组的索引赋值和length变化。这能省太多事:我不需要为数组维护一堆特殊方法,push、pop、splice这些操作在引擎层面会触发索引和length的读写,只要我在 Proxy 的get和set陷阱里做好依赖收集和触发,它们就能被自动响应。当然 Proxy 也不是没有代价,后面我会聊几个它带来的坑,比如内置方法和this指向问题,但在权衡之下,Proxy 依然是现代 JavaScript 里实现响应式的最优解。
3. 从零实现 Rea 的关键环节
3.1 整体架构与文件规划
整个 Rea 源码我分了四个文件,各管一块:reactive.js负责用 Proxy 实现响应式对象;effect.js负责副作用函数的注册与调度;directive.js负责解析 HTML 上的指令并绑定 DOM;index.js作为入口,把三者串起来。这样一个初学者拿到源码也能顺着数据流走通:页面加载时index.js调用reactive()创建状态对象,然后遍历节点,解析r-text、r-model指令,每个指令都注册一个effect,这个effect内读取状态对象属性时触发 Proxy 的get,完成依赖收集;之后用户输入或代码逻辑修改数据时触发 Proxy 的set,找到对应的effect并执行,视图更新。
文件划分遵循一个原则:不准跨层调用。reactive.js不知道什么是 DOM,directive.js只管节点绑定,effect.js手里只有抽象的依赖图表和任务队列。这样的好处是任何一个模块都可以单独拿出来测试。我实测下来,这种分层结构也便于排查问题:视图不更新了,先怀疑effect有没有被正确触发;触发没有响应,再检查依赖收集是不是漏了;依赖收集正常但 DOM 没变,最后查看指令解析逻辑。按这个顺序排查,效率很高。
3.2 实现 reactive 核心模块
reactive()的核心代码我直接贴出来,这是我最满意的一段,因为它足够短。
const rawToProxy = new WeakMap() const proxyToRaw = new WeakMap() const bucket = new WeakMap() let activeEffect = null function isObject (val) { return val !== null && typeof val === 'object' } export function reactive (target) { if (!isObject(target)) return target if (proxyToRaw.has(target)) return target if (rawToProxy.has(target)) return rawToProxy.get(target) const proxy = new Proxy(target, { get (obj, key, receiver) { if (activeEffect) { track(obj, key) } const result = Reflect.get(obj, key, receiver) if (isObject(result)) return reactive(result) return result }, set (obj, key, value, receiver) { const hadKey = Object.prototype.hasOwnProperty.call(obj, key) const oldValue = obj[key] const success = Reflect.set(obj, key, value, receiver) if (!hadKey || !Object.is(oldValue, value)) { trigger(obj, key) } return success }, deleteProperty (obj, key) { const hadKey = Object.prototype.hasOwnProperty.call(obj, key) const success = Reflect.deleteProperty(obj, key) if (hadKey) trigger(obj, key) return success } }) rawToProxy.set(target, proxy) proxyToRaw.set(proxy, target) return proxy }这里有两个容易被忽略的细节。一是get里对嵌套对象的处理:如果读取的属性值本身是一个对象,就递归地调用reactive(),让深层嵌套数据也是响应式的。二是用Object.is()而不是===来比较新旧值,因为Object.is(NaN, NaN)为true,如果用===,对NaN的赋值会被当成“值没变”而不触发更新,这是一个很刁钻的边界问题。两个WeakMap则用来保存原始对象和代理对象之间的双向映射,保证同一个原始对象多次调用reactive()永远返回同一个代理,避免依赖收集出现重复。
3.3 实现 effect 副作用队列
effect()函数是整个响应式系统的发动机。它的作用可以描述成:把一个普通函数包装成副作用函数,并立即执行一次。关键是执行过程里要维护一个全局变量activeEffect,让依赖收集器知道“现在是哪个函数在读取数据”。
const effectStack = [] export function effect (fn) { const wrapped = function () { try { effectStack.push(wrapped) activeEffect = wrapped cleanup(wrapped) return fn() } finally { effectStack.pop() activeEffect = effectStack[effectStack.length - 1] } } wrapped() return wrapped }effectStack是用来处理嵌套effect的。比如一个渲染函数内部又有子组件的事件监听,或者一个 effect 里注册了另一个 effect,如果没有栈结构,activeEffect会在内层函数结束时丢失外层身份,导致依赖归属错误。cleanup函数稍微麻烦点,它要做的是在每次执行前,把上一次收集到该函数身上的依赖全部清空。为什么要清空?因为程序里的条件分支会让依赖关系动态变化。
举一个典型例子:某个渲染函数里写着state.a ? state.b : state.c。第一次执行时state.a为 true,函数只读取了a和b,依赖表里记住了这两项。之后state.a变成 false,按道理函数改为读取a和c,但如果不清空旧的依赖,state.b的依赖关系里还残留着这个函数,将来 b 一变,函数依然会被调用。大多数时候这没坏处,顶多多执行几次,但在某些场景下会引发幽灵依赖甚至死循环。所以cleanup必须在每次执行前清空这个函数挂载过的所有依赖集合。
3.4 实现依赖收集与批量调度
依赖收集函数track和触发函数trigger直接操作前面说的三级嵌套结构。track的职责很简单:如果activeEffect存在,就从bucket里逐层找到对应的Set,把这个 effect 加进去。trigger的职责则是在数据改变时拿出对应Set里的所有 effect,放到调度器里执行。
这里有一个至关重要的优化:不能同步地立刻执行所有 effect,必须做批量更新。比如一个函数里连续修改三个状态,或者一个渲染函数同时依赖十个属性,如果不加调度,修改一次数据就渲染一次,页面会有十次无效渲染。批量调度的思路是:把需要执行的 effect 先放进一个Set(利用 Set 天然去重的特性),然后在微任务里统一执行。我用的方案和 Vue 的 nextTick 思路一致。
const queue = new Set() let isFlushing = false function queueJob (job) { queue.add(job) if (isFlushing) return isFlushing = true Promise.resolve().then(() => { try { queue.forEach(fn => fn()) } finally { queue.clear() isFlushing = false } }) } function trigger (target, key) { const depsMap = bucket.get(target) if (!depsMap) return const effects = depsMap.get(key) if (!effects) return const toRun = new Set(effects) toRun.forEach(effectFn => queueJob(effectFn)) }用Promise.resolve().then把同步任务推迟到所有代码执行完的微任务阶段,这就是 Vue 那套 nextTick 调度的原理。同样是修改三个状态三次,三次trigger只是往queue里加了同一个 effect 三次,Set去重后只执行一次,渲染开销从三次降为一次。这个细节就是响应式框架性能好的关键原因之一,很多初学者实现时忽略了它,导致页面数据一多就频繁重渲染、卡顿明显。
3.5 模板指令与视图绑定
Rea 的视图绑定层只实现了两个核心指令,r-text用于把元素文本绑定到某个状态属性,r-model用于实现表单元素和状态的双向绑定。实现上,我直接用TreeWalker遍历 DOM 树,找到带指令的元素,逐个绑定。这种做法的好处是简单直观,完全不依赖虚拟 DOM,而且对静态页面非常友好。
export function mount (root, state) { const nodes = [] const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT) while (walker.nextNode()) nodes.push(walker.currentNode) nodes.forEach(el => { if (el.hasAttribute('r-text')) { const key = el.getAttribute('r-text') effect(() => { el.textContent = resolve(state, key) }) } if (el.hasAttribute('r-model')) { const key = el.getAttribute('r-model') effect(() => { if (document.activeElement !== el) { el.value = resolve(state, key) } }) el.addEventListener('input', () => { assign(state, key, el.value) }) } }) }resolve和assign处理的是简化版的路径解析,支持todos.length、user.name这类点路径。这样用户写r-text="todos.length"时,内部能自动解析到根状态对象的嵌套属性。r-model里那个document.activeElement !== el的判断是防止光标跳动:当用户正在输入时,如果某个 effect 又把值同步回输入框,光标会跳到末尾,非常影响体验。这就是为什么即使只是输入框自己绑定自己,也要做这个防御。双向绑定的本质其实就是一个单向绑定加一个事件监听,effect负责把状态推到输入框,input事件负责把输入框的值推回状态,两条线各管各的,中间不加这个判断就会出现光标问题。
4. 实操验证:用一个待办列表 Demo 跑通全流程
4.1 Demo 功能与页面结构
理论说了一堆,不落地验证等于白写。我准备了一个待办列表的 Demo,功能包括:输入框添加待办、点击待办切换完成状态、剩余未完成数量实时统计。页面结构非常简单,全部是静态 HTML 加 Rea 的指令。
<div id="app"> <input r-model="inputText" placeholder="输入待办事项" /> <button id="addBtn">添加</button> <ul id="list"></ul> <p r-text="remainingText"></p> </div>这里有一个值得注意的设计:remainingText这种派生状态我没有单独在数据对象里维护,而是用一个computed风格的函数计算出来。严格来说 Rea 没有完整的 computed 实现,但通过在mount里注册一个读取状态的effect,然后把这个 effect 的返回值往文本节点里写,就已经达到了计算属性的效果。核心逻辑还是依赖收集:计算remainingText的函数内部读取了todos数组,因此它会被自动收集到todos的依赖集合里,数组一变它就重新计算并更新文本。
4.2 绑定 Rea 的过程
整个 Demo 的入口代码如下:
import { reactive, effect, mount } from './rea/index.js' const state = reactive({ inputText: '', todos: [] }) effect(() => { const doneCount = state.todos.filter(item => item.done).length const total = state.todos.length state.remainingText = `剩余 ${total - doneCount} 项` }) effect(() => { const listEl = document.getElementById('list') listEl.innerHTML = '' state.todos.forEach((item, index) => { const li = document.createElement('li') li.textContent = item.text li.style.textDecoration = item.done ? 'line-through' : 'none' li.addEventListener('click', () => { item.done = !item.done }) listEl.appendChild(li) }) }) document.getElementById('addBtn').addEventListener('click', () => { if (!state.inputText.trim()) return state.todos.push({ text: state.inputText.trim(), done: false }) state.inputText = '' }) mount(document.getElementById('app'), state)这里用了一个嵌套响应式对象结构:todos数组里的每一项都是一个普通对象,因为get拦截器里会对对象值递归调用reactive(),所以item.done的修改也是响应式的,点击列表项切换完成状态会触发列表渲染函数重新执行。这就是嵌套响应的意义:如果只把最外层数组做成响应式,item本身是普通对象,点击切换done根本不会触发任何依赖。
4.3 渲染性能与内存释放注意事项
待办列表每次重新渲染都用了innerHTML = ''再重建节点的方式,这在列表规模小的时候没有任何问题。渲染函数执行时读到state.todos.length,所以新增一项会触发渲染;读到每一项的item.done,所以点击切换也会触发渲染。这里有个现象:循环里访问item.text、item.done时,每个item都会被reactive()处理,如果列表有一千项,就要创建一千个代理对象。虽然这些代理对象被WeakMap缓存,不会有重复创建的问题,但创建本身有开销,所以如果列表规模上万,还想沿用这种全量渲染策略,性能会明显下降,到时候就要考虑按 key 复用 DOM 节点。
内存释放方面有一个坑需要专门提一下:item.done所在的 item 对象如果从todos数组中移除,并且业务代码中不再持有它,那么它对应的响应式代理和依赖关系应该被回收。因为我们用的是WeakMap来保存原始对象到代理对象的映射,一旦原始对象没有其他强引用,整个映射项可以被垃圾回收,不会泄漏。但要注意,如果你在某个effect的闭包里长期持有 item 变量,那它永远不会被回收,这是业务代码的问题,框架层管不到。页面上那些r-model绑定的 input 事件监听器,也需要在页面销毁时手动移除,否则会造成 DOM 和状态对象之间互相引用。Rea 暂时没有提供unmount销毁函数,但在真实项目中这是必须补的,否则单页应用里反复创建销毁页面实例,监听器会越积越多。
5. 必踩的坑与排查实录
5.1 Proxy 代理后的 this 指向问题
第一次实现响应式代理时,我遇到一个诡异的 bug:数组push之后,页面上显示的数据顺序经常是乱的,而且某些情况下会触发非常多次渲染。排查半天才发现问题出在 Proxy 的get拦截里。数组调用push方法时,引擎会对数组对象做一系列内部操作,比如读取length、设置索引、设置length。这些操作本身是正常的,但问题在于:代理对象调用原始数组方法时,方法内部的this指到了代理对象上,而不是原始数组。
这意味着数组方法内部每次读取length或者索引时,都会触发代理的get陷阱。如果我在get里无脑收集依赖,那么一次push可能收集了十几个无意义的依赖关系;如果我在set里无脑触发更新,一次push可能触发了十几次列表渲染,虽然最终被调度器去重了,但依赖关系被无意义的操作塞满了。更严重的情况是,某些内置方法依赖不可代理的内部槽位,在代理上调用会直接抛错。
我的解决办法是:在get陷阱里对内置属性做一次白名单过滤,比如Symbol类型的键一律不收集,length这种内部频繁访问的键要根据场景决定是否收集。另外一个关键点是Reflect.get(obj, key, receiver)中的receiver参数:不能总是传代理对象,否则方法内部访问的响应式 hooks 会被触发。标准方案是用rawToProxy判断,在需要的时候把 receiver 换回原始对象,也就是所谓rawBind技术。这一步是整个 Proxy 响应式实现里最影响稳定性的地方,我在 Demo 简化版里没有做完整处理,但实际做生产级封装时这步不能省。
5.2 数组操作为什么总是不触发更新
很多初次写响应式系统的同学都会问一个问题:为什么我直接给数组索引赋值state.arr[0] = 'a',视图没有更新?原因要分两层看。
第一层,代理本身是能拦截到索引赋值的,set陷阱确实会被触发。第二层,依赖收集可能没到位。渲染函数里如果写的是state.arr.forEach(...),读取的是length属性和每一个索引的值。而直接给索引赋值时,引擎会先修改该索引,再修改length。如果修改的索引值恰好和旧值相等,我前面用了Object.is比较就过滤掉了。如果索引比当前length大,比如给arr[5]赋值,触发逻辑会去查找5这个 key 对应的依赖集合,但渲染函数从来没有读取过5,自然没有收集到这个属性,视图就不会更新。
处理方式有两个:一是在应用层约定,修改数组尽量用push、splice这类变更方法,而不是直接索引赋值;二是在响应式底层对数组的length变化做特殊联动,比如当新增索引时,除了触发该索引,还要强行走一遍length的触发逻辑。Vue 3 内部对数组做了非常细的区分,比如跟踪for...in遍历、用迭代器标记等。Rea 作为教学项目,我选择只用第一种约定,但我必须把这个坑写出来,因为这是实际开发中排查“数据变了视图不更新”最高频的原因。
5.3 对象新增属性与 Map/Set 的响应式支持
前面说过 Proxy 能拦截新增属性,所以state.newKey = 1不需要像Object.defineProperty那样专门设置。但有个细节要注意:新增属性时触发更新是写在set陷阱里的,判断条件是!hadKey。这个逻辑本身没问题,但如果你在新增属性之后又紧跟着读取该属性,依赖收集发生在get里,而get在属性不存在时也能返回undefined,所以不会报错,只是收集到的是一个空依赖。这个问题不致命,但会在依赖表里留下一条永远用不到的记录。
Map 和 Set 是另一个麻烦。直接new Proxy(new Map(), ...)是行不通的,因为 Map 和 Set 内部有不可代理的内部槽位,很多方法在代理对象上调用会抛异常。要支持它们,必须自己包一层:拦截get里的get、set、add、delete方法,在方法调用时手动收集和触发依赖。Rea 当前版本不提供对 Map/Set 的完整支持,这个选择是有意的。实际项目中如果确实要用 Map/Set 做响应式数据源,直接选用 Vue 的响应式库会比再造一次轮子划算得多。我的建议是:教学项目一定要把代理边界搞清楚,知道什么场景能做什么场景不能做,这比无脑堆功能更有价值。
5.4 常见问题速查表
我在调试过程中把典型问题整理成了表格,排查时按图索骥非常高效。
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 给对象新增属性后视图没更新 | 渲染函数没有读取过该属性,依赖未建立 | 在渲染函数里先访问该属性;确认新增属性逻辑在 set 陷阱中触发了 trigger |
| 直接修改数组索引视图不更新 | 依赖只建立在 length 和已有索引上,新索引未被收集 | 使用 splice 或 push 代替直接索引赋值;底层补充索引 add 的 length 联动 |
| 修改嵌套对象深层属性失败 | 嵌套对象未在 get 中递归转为响应式 | 确认 get 陷阱返回对象前调用了 reactive() |
| 输入框输入时光标乱跳 | r-model 的 effect 反向覆盖了用户输入 | 在写入输入框前判断 document.activeElement 是否等于该元素 |
| 多个状态同时变化导致多次渲染 | trigger 同步执行了所有 effect | 引入调度器,把 effect 放入微任务队列批量执行 |
| 多次调用 reactive 返回不同代理 | 缺少 rawToProxy / proxyToRaw 映射 | 用 WeakMap 建立双向引用,保证同一对象只有一个代理 |
| 引用类型修改后页面完全无响应 | 使用了 Object.defineProperty 思路,代理边界没覆盖 | 换用 Proxy;检查是否误用浅代理嵌套 |
排查时我建议遵循一个顺序:先复现最小案例,把依赖收集的日志打开,看看修改数据时trigger里有没有拿到 effect;拿不到,问题在收集阶段;拿到了但不执行,问题在调度;执行了但页面没变,问题在指令绑定或 DOM 更新逻辑。日志一开,问题立刻现形。
6. 我在 Rea 项目里积累的实操心得
Rea 这个项目写到中期,我最大的收获不是学会了某个 API,而是真正理解了“框架不是魔法,只是一堆非常具体的数据结构计算”。以前我用 Vue 的时候,看到ref、effect这些概念总觉得隔着一层纱;亲手把WeakMap、Set、微任务调度这些积木搭起来之后,很多文档里一笔带过的设计瞬间就通了。举个例子,“响应式数据”听起来很高端,本质上不过是在读写对象的必经之路上各加了一个回调点:读的时候登记使用者,写的时候通知使用者。仅此而已。
如果让我给想动手折腾框架原理的同学一点建议,我会说:千万不要一上来就想实现一个完整框架,先把最小闭环跑通。Rea 最初只用了五十行代码,目标只有一个:让某个文本节点跟随一个数字变化。这个闭环跑通之后,再逐步扩展数组、嵌套、调度、指令,每一步都有明确目标,不会迷失在抽象概念里。我也踩了不少坑,尤其是 Proxy 的receiver和数组特殊操作,这类问题不看源码很难体会它的微妙之处,所以我在这篇文章里特意用了大量篇幅讲这些边界场景。
最后分享一个小技巧:这种迷你响应式库特别适合用来做骨架屏或者活动页。因为它的包体只有几百行,不需要打包器,打开 HTML 就能跑,性能在小规模场景下完全不输大框架。后续我大概率还会给 Rea 补充完整unmount销毁流程和简单的computed缓存,但核心的响应式链路已经足够稳定。如果你也在维护轻量页面,或者想弄懂状态管理到底是怎么回事,照着我这个思路从零写一个几十行的响应式状态管理,一定比读十篇分析文章来得深刻。