首页 / 资讯中心 / 文章详情

手写迷你响应式框架Rea:Proxy依赖追踪与精准DOM更新

手写迷你响应式框架Rea:Proxy依赖追踪与精准DOM更新 ★ FEATURED ARTICLE
Rea这个名字最早只是我在某次技术分享上随手打在幻灯片角落里的一张占位图名称。没想到后来同事问得多了它反而成了我那个内部小工具的正式代号。这个工具本身也很有意思——它不是一个完整的前端框架而是我基于细粒度响应式依赖追踪写的一套轻量渲染引擎。如果你也做过那种“一个状态变了整棵界面树却跟着抖三抖”的页面大概能理解我为什么会花两三个周末去折腾它。这篇文章不讲虚的就讲清楚几件事Rea到底解决了什么问题、它的依赖追踪机制长什么样、我如何用一个真实的Demo把它跑通以及在我自己项目和几个模拟场景里踩过的坑。文章讨论的对象是一个代号为Rea的微型框架全文基于我曾经实现的方案与常见实践补充不绑定任何真实项目。适合对前端响应式原理感兴趣、想在中小型页面里用更轻方案的朋友也适合那些想自己动手写一个迷你响应式框架的人参考。1. 为什么我会写一个叫Rea的框架1.1 从一次线上问题说起全量重渲染的成本事情要从一次内部系统的表格页面说起。那个页面里有一列开关点一下开关某个元素的状态会被改变。按照当时所用框架的常规逻辑每次状态变化都会触发当前组件树的全量重算再对新旧两棵虚拟节点树做一次Diff。问题在于那个页面里塞了几百个排序控件和筛选条件状态一变整块区域都要重新渲染一遍。线上用户反馈说“点开关的时候卡了一下”一查性能面板单次点击的脚本执行时间超过了120毫秒。120毫秒是什么概念人眼能感知到的卡顿阈值大约在100毫秒左右。也就是说用户每次点击都能感受到一次清晰的顿挫。我当时第一反应不是去调这个框架的优化选项而是问了一个问题一个布尔值变了为什么要把整个表格、所有筛选器、所有排序状态全部重新算一遍实际需要变化的其实只有那个开关附近的几行界面。如果系统能精确到“哪个数据变了就只更新读取过它的那一个节点”性能问题就不存在了。Rea的雏形就是冲着这个“只更新该更新的地方”去的。1.2 Rea的设计目标与取舍在动手前我给自己定了三条硬规矩第一体积必须小压缩后控制在10KB以内不引入任何第三方依赖第二API必须少到让人在五分钟内能上手不搞复杂生命周期和多人协作规范第三不使用虚拟DOM不做跨端渲染也不做编译期的模板魔改纯运行时干活。很多人一听“不用虚拟DOM”就觉得落伍但Rea的适用场景本来就不是大型复杂应用而是管理后台里的局部交互、仪表盘上的实时数据面板、机房监控这类界面。这类界面共同特点是信息密度高、局部刷新频繁、但整体结构相对固定。在这样场景下虚拟DOM带来的可维护性收益并不明显反而是全量Diff的时间开销和内存峰值更容易让人头疼。我最终的取舍是用一个Proxy作为拦截层在数据读取时收集“谁用了这个数据”在数据写入时精确通知调用方去更新对应的DOM节点。这种做法本质上更像是一个可运行的最小化响应式系统而不是一个完整的框架。Rea的取舍清单可以概括成一张表维度选择理由渲染方式直接操作真实DOM避免Diff开销数据拦截Proxy支持新增属性与数组操作更新调度微任务队列批量合并防止高频写入抖动模板语法渲染函数加指令字符串简化编译器降低上手成本生命周期挂载前后两个钩子尽量减少心智负担适用规模中小型局部页面大项目仍然建议主流框架这张表就是我写Rea时最核心的思考过程。后面所有的代码和踩坑记录基本都是在这个设计框架下发生的。2. Rea最核心的机制代理拦截与依赖追踪2.1 从“赋值即更新”到“谁在读就通知谁”Rea最底层的逻辑可以用两句话概括读的时候收集依赖写的时候触发更新。这两句话听起来简单但真正的难点在于“依赖”到底怎么收集。我把一个普通对象变成一个能感知读写的数据对象靠的是JavaScript自带的Proxy。Proxy能在对象的任何属性访问和赋值操作上插一脚而且不需要像老一代方案那样提前遍历对象、对每个属性单独重定义。只要把原生对象包一层代理后续新增的字段也天然纳入监控。依赖收集的流程是这样的我先设置一个当前正在执行的“渲染函数”的全局标记当渲染函数执行时一旦访问到代理对象的某个属性代理的get钩子就会把“当前渲染函数”和“这个属性”的映射关系记下来。之后如果有人修改这个属性代理的set钩子就去查这张映射表挨个通知当初读取过它的渲染函数去执行。这套思路和很多主流响应式框架的底层是相通的区别在于Rea不做组件树的虚拟比较每个渲染函数通常只精确对应一个很小的模板片断。为了让你看得更直观我把最精简的依赖收集代码写出来。这段代码没有错误处理、没有边界判断但它包含了整个Rea核心机制的百分之八十// 全局标记当前正在运行的渲染函数 let currentEffect null // 存储每个对象属性对应的依赖集合 const depMap new WeakMap() function getDeps(target, key) { let map depMap.get(target) if (!map) { map new Map() depMap.set(target, map) } let deps map.get(key) if (!deps) { deps new Set() map.set(key, deps) } return deps } function reactive(data) { return new Proxy(data, { get(target, key) { // 读取时收集依赖 if (currentEffect) { getDeps(target, key).add(currentEffect) } return Reflect.get(target, key) }, set(target, key, value) { const result Reflect.set(target, key, value) // 写入时触发依赖 const deps getDeps(target, key) deps.forEach((effectFn) effectFn()) return result } }) } function effect(fn) { const run () { currentEffect run fn() currentEffect null } run() }注意到这里我用currentEffect这个全局变量来标记“当前正在执行的副作用函数”而不是把函数作为参数传进get里。这样做的好处是无论嵌套多少层调用只要是在副作用执行期间发起的属性读取都会被当前副作用记录下来。代价是全局变量状态在遇到异步代码时会丢失这个坑后面还会讲到。2.2 一次点击只更新一个text节点在纯数据层之外Rea把“渲染函数”和“DOM节点”做了绑定。比如我希望页面上某个数字能实时变化就写一个非常小的渲染函数让它读取响应式数据并把结果赋值给对应节点的textContent。为了说明效果我给实际场景做了个简化版本。状态里有一个数字count页面上有三处分别显示这个数字的不同倍率。常规框架的做法是重新渲染整个组件Rea的做法是给三处分别注册三个互不影响的effect函数import { reactive, effect } from ./rea.js const state reactive({ count: 1 }) // 区域一直接显示原始值 effect(() { document.querySelector(#areaA).textContent String(state.count) }) // 区域二显示两倍 effect(() { document.querySelector(#areaB).textContent String(state.count * 2) }) // 区域三显示十倍 effect(() { document.querySelector(#areaC).textContent String(state.count * 10) }) // 点击一次按钮count从1变成2 button.addEventListener(click, () { state.count })点击按钮后代理的set钩子会遍历存储在这个属性上的三个副作用函数分别执行。因为每个副作用只处理自己对应的那个节点所以这次状态变更总共只有三次直接文本替换中间不存在任何树Diff也没有任何多余DOM回收。我在实际项目中用同样思路处理过一个在线状态列表几百个在线终端每隔几秒上报一次心跳其中只有个别的在线状态会变化。用Rea后每轮更新消耗的脚本执行时间从之前框架方案的几十毫秒降到一两毫秒以内而且界面响应和滚动跟手程度明显提升。2.3 为什么我用Proxy而不是defineProperty可能有朋友会问既然早期版本的Vue是用Object.defineProperty实现响应式为什么Rea一上来就锁定Proxy答案很简单因为defineProperty的天然局限太多。它只能拦截已经存在的属性所以在监听对象新增属性时要么手动补一遍要么用替代方法覆盖对于数组它也无法拦截通过索引赋值和修改length的操作必须靠重写数组方法救场。Proxy则可以从根上解决这两大痛点。我做的对比测试也很能说明问题。用defineProperty方式给数组的某个索引赋值如果不重写数组方法或调用Splice页面上根本不会得到更新通知。而Proxy的set钩子会在任何属性赋值时触发包括数字键名。当然Proxy也有代价兼容性要求浏览器的原生实现无法在不支持Proxy的旧环境里进行完美的降级。我在Rea里对旧环境的处理方式是直接抛出明确错误让使用方在构建时通过浏览器版本提示来决定是否引入。相比在代码层级里绕圈子这反而是更清晰的做法。3. 调度器与批量更新多次写入一次渲染3.1 同步通知带来的抖动问题上一节的依赖追踪代码完成了一个响应的闭环但它离可用还有一段距离。最现实的问题就是“同步触发太急”。假设用户在一个输入框里连续输入十个字符响应式数据的value属性会被连续修改十次。如果用同步通知每次修改都会立刻把所有读取过value的副作用函数执行一遍渲染函数会把整段HTML重新解析再把内容写进DOM。这不是性能问题而是体验问题。最终用户只关心输入结束后界面处于正确状态谁在乎中间结果是否错误地闪动了十次。我见过很多初学响应式原理的人栽在这一步调试数据的时候发现每敲一个字母页面上的某个统计数字就会疯狂跳变然后卡顿。这其实不是依赖收集写错了而是缺少“调度合并”。3.2 用微任务把更新合并到同一批Rea的调度方案可以概括为三步先标记依赖函数为“已加入待更新队列”然后用微任务安排一次统一的刷新最后在刷新阶段把队列里的函数去重执行。这个思路类似于把十次连续呼喊的人先拦在休息区等他们全部喊完、安静下来之后再统一开一次门放他们进去。我选了微任务而不是宏任务的setTimeout是因为微任务会在当前脚本执行结束后、浏览器绘制新帧之前运行。如果在一轮事件循环里对状态做了20次修改这20次修改对应的所有依赖刷新任务会被压缩成一次DOM写入而且这次写入发生在浏览器真正绘制之前不会让你看到任何中间状态。调度器的核心代码大致是let isFlushing false const pendingEffects new Set() function scheduleEffect(effectFn) { pendingEffects.add(effectFn) if (!isFlushing) { isFlushing true Promise.resolve().then(() { isFlushing false const effects Array.from(pendingEffects) pendingEffects.clear() effects.forEach((fn) fn()) }) } }我把它接在代理的set钩子后面每次属性写入不直接执行依赖而是把依赖函数丢给调度器。这样带来的另一个隐性收益是如果一个副作用函数本身又修改了状态它触发的后续依赖也会被收集到同一批更新里不会出现渲染一半又突然中断的撕裂感。3.3 从输入框校验看批量更新的真实收益调度器有没有用不能只用感觉说话。我在模拟项目里做过一次对照实验。场景是一个“商品套装快捷配置”页面用户填写套餐价格时界面需要同时更新三个内容折扣后价格、区间提示、历史价格对比图。在无调度版本里输入一次价格三个区域依次各自渲染中间还会来回读取其他状态在有调度的版本里整个输入过程结束后只执行一轮批量刷新。实测数据是同一台测试机型上连续快速输入20个字符无调度版本触发了60次小渲染脚本耗时累计约80毫秒有调度版本只触发一次批量刷新总耗时约4毫秒。这个差异在输入没有明显卡顿但如果你同时开了性能录制去逐帧看就会发现无调度版本在部分帧里掉到了40帧以下而有调度版本稳定保持60帧。调度器除了合并更新以外还需要处理一个细节清空队列和重新入队的顺序。如果依赖函数在批量刷新过程中又修改了同一个属性新的调度任务应该被排进下一轮微任务循环否则会在同一轮里出现递归循环。我在Rea里用一个isFlushing标志来隔离这两种情况确保当前刷新过程产生的副作用永远不会插入本轮的已清理队列里。4. 跑通第一个Rea应用从零搭建到事件绑定4.1 最小环境搭建与模板结构Rea没有自己的命令行工具也没有脚手架。我故意让它保持这种状态——像这种级别的微型框架与其接一堆打包器插件不如直接扔进浏览器里用原生ES模块加载。目录结构只需要一个HTML文件、一个模块文件和一个入口脚本。我把Rea的核心代码拆在一个叫rea.js的模块里普通使用者只需要关心怎么导入reactive和effect两个函数。这里给一个最原始但能完整跑起来的示例。这个示例模拟了一个设备监控面板顶部显示设备在线状态中间有一个大数字展示当前温度底部是一个可以增加温度的按钮。!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleRea Demo/title /head body div idapp span idonlineStatus加载中.../span span idtemperatureValue--/span button idwarmButton升温/button /div script typemodule import { reactive, effect } from ./rea.js const state reactive({ online: true, temperature: 18 }) effect(() { document.querySelector(#onlineStatus).textContent state.online ? 在线 : 离线 }) effect(() { const temp state.temperature document.querySelector(#temperatureValue).textContent temp °C }) document.querySelector(#warmButton).addEventListener(click, () { state.temperature }) /script /body /html这个Demo虽然简单但它把前面讲的依赖追踪和批量更新全部串起来了点击按钮时温度递增onlineStatus那个effect因为没有读取过temperature所以完全不会被触发只有读取过温度的那个effect会被调度并执行一次。4.2 渲染函数与DOM绑定的约定有朋友肯定要问这种手写querySelector再手动赋值的写法和直接操作DOM有什么区别从形式上来说没有区别这是事实。Rea本来就不打算让你写一个外观上很像Vue模板或JSX的代码。它给大家提供的核心抽象是“数据的任何变化都会精确地自动反映到某个DOM节点上”至于这个节点的定位方式完全开放给使用者。我在实际项目里推荐过两种封装方式效果都还不错。第一种是写一个小助手函数把渲染函数映射到节点选择器function bindEffect(selector, fn) { effect(() { const el document.querySelector(selector) if (el) { el.textContent fn() } }) } bindEffect(#onlineStatus, () state.online ? 在线 : 离线) bindEffect(#temperatureValue, () state.temperature °C)第二种是直接让渲染函数返回一个DOM节点在组件初始化时挂载一次后续更新只改动这个节点的内容。这两种方式都不需要模板编译器理解成本低得多。不要觉得这样low。我写的所有业务里用这种极简绑定方式写出来的代码三个月后再回去看依然能一眼读懂每个副作用的输出对象是谁。4.3 事件绑定与“只有挂载前后”的生命周期Rea没有created、mounted、updated、destroyed那种完整生命周期整个库只提供两个挂载钩子。我认为小型响应式工具最不该做的就是把生命周期概念复杂化。太多框架把“什么时候拉数据、什么时候销毁监听、什么时候同步外部状态”这些业务问题伪装成了框架概念逼着使用者先去学一套名词。Rea的挂载钩子分别是onMounted和onBeforeUnmount。它们的作用非常直接前者在根页面节点插入DOM后执行用于发起异步请求或启动定时器后者在对应区域被卸载前执行用于清理定时器和取消订阅。举个例子我在模拟项目里做一个设备心跳提示挂载时开启一个setInterval每秒读取一次模拟数据源并更新状态卸载时清掉定时器。这段逻辑只用两个钩子就能表达清楚import { reactive, effect, onMounted, onBeforeUnmount } from ./rea.js const state reactive({ heartbeat: 0 }) onMounted(() { state.timer setInterval(() { // 模拟心跳变化 state.heartbeat (state.heartbeat 1) % 60 }, 1000) }) onBeforeUnmount(() { clearInterval(state.timer) }) effect(() { document.querySelector(#heartbeat).textContent String(state.heartbeat) })这里有个小细节我在响应式对象上直接挂了一个定时器ID这个动作能成功正是因为Proxy支持动态新增属性。如果用老式的defineProperty拦截新增的timer属性根本不会被任何getter和setter覆盖你会在调试时一脸疑惑为什么清理失败。5. 踩坑实录响应式失效的三种情况5.1 解构赋值把依赖追踪弄丢了Rea用起来不像主流框架那么“粘手”自由度高的代价就是很容易在一些看似自然的写法上踩坑。最常见的坑就是解构。有人习惯了从对象里直接取字段于是写出了下面这样的代码const { temperature } state effect(() { // 这里的temperature是普通值跟state.temperature没有任何关系 console.log(temperature) }) state.temperature 20 // 不会触发effect执行原因很简单state.temperature在解构那一刻被读取了一次返回的是原始数值18之后temperature就是一份与代理对象完全无关的拷贝。无论后续状态如何变化这个effect里永远读的都是旧值。我排查过的一个模拟场景里团队用这种写法把温度面板写成了“死页面”界面永远显示初始值但状态明明已经变了。解构这个操作不是在“偷懒”它相当于主动切断了这个数据字段和Dep之间的连接线。解决办法也很统一在副作用函数内部进行解构只要解构发生在effect执行期间读取的是代理对象那么这次读取就会正常注册依赖effect(() { const { temperature } state document.querySelector(#temperatureValue).textContent temperature °C })在副作用函数里解构每次函数执行都会重新从代理上读取最新值同时把自身注册进依赖完整保留响应能力。5.2 数组和length属性表面上变了实际上没人理响应式对象的数组操作是重灾区。Rea虽然通过Proxy解决了“索引赋值可以触发set”的问题但真实场景里很多人用的是哪些操作push、pop、splice、sort以及直接修改数组的length。Proxy可以对push这类方法调用进行拦截吗可以。因为length和索引都是属性操作push执行时实际上做了两次写操作一次写入索引值一次修改length。所以只要代理了数组push天然能触发对应索引的set。我实测下来这个路径是通的。真正的坑在于“直接赋值给索引”和“直接修改length”时依赖收集不完全。假设页面上有两个effect一个读取了数组的map结果一个读取了arr.length那么当arr[2] 100发生的时候读取length的effect反而不会被触发——因为赋值给索引并不会主动触发length的依赖。我第一次遇到还是在某个列表合并场景里两个列表拼接后我用arr.length newArr.length截断多余部分结果依赖length渲染的“剩余条数”区域纹丝不动。调试了很久之后我才意识到必须要在set钩子里主动补发length的通知set(target, key, value) { const oldLength target.length const result Reflect.set(target, key, value) if (target.length ! oldLength) { // 长度变化时额外触发length的依赖 getDeps(target, length).forEach((fn) scheduleEffect(fn)) } scheduleEffect(getDeps(target, key)) return result }我这版伪代码没有加上所有防御逻辑但你要记住的核心经验是在Proxy的set钩子里索引写入后手动检查一下数组长度是否变化。如果变化了就把length对应的依赖也批量调度一次否则就会出现“数组内容明明变了但依赖长度的区域不动”的灵异事件。5.3 组件卸载后的异步回调Rea的响应式系统是全局的副作用函数在注册之后不会自动知道自己的宿主DOM节点已经被移除了。如果某个异步回调在组件卸载后才触发状态更新再执行effect里的DOM操作就会对着一个不存在的节点做赋值浏览器不会报错但会产生无效写入。更麻烦的是一种旧版本里的行为卸载后执行的effect会重新注册依赖导致整个数据对象的依赖表里残留一堆死节点引用。数据量小的时候看不出问题数据量大的时候就会出现新节点不更新、旧节点反而不断变化的错乱现象。我的解决方案是给每个副作用函数加一个disposed标志。在卸载钩子onBeforeUnmount里统一遍历当前作用域下的副作用把它们全部置为disposed调度器在批量刷新时也会跳过这些已失效函数。为了给使用者一份经验Rea还支撑了一个约定所有发起异步读取的代码块都应该用onBeforeUnmount做一个取消操作。例如模拟设备上报的定时器需要在组件卸载时清除。如果你把这个约定严格遵守那么卸载后的回调根本就不会再去改状态那点残留问题也自然不存在。6. 实战后我对Rea的重新认识6.1 用对比数据重新审视框架选型Rea在我手头的几个内部页面里跑得很顺但我不打算吹嘘它能替代主流框架。为了让自己冷静下来我把Rea和目前常见的两个主流方案在同一个模拟数据面板上做了对比。这个面板有三块区域设备在线数量、温度曲线、告警列表。三种实现方式分别是Rea原生写法、某组合式API框架写法、某函数式组件框架写法。结果如下表对比维度Rea组合式API框架函数式组件框架核心代码体积约8KB约30KB起步约40KB起步更新粒度精确到单个节点精确到组件级别组件级协调全量刷新次数每次状态变更只刷新关联节点组件级重渲染虚拟DOM协调学习成本两个函数加一个钩子较多概念JSX与协调机制生态成熟度无生态非常成熟非常成熟适合场景局部面板、嵌入式控件中大型应用中大型应用我刻意没有在这个表格里放“性能”这一类因为数据量不同、写法不同、优化水平不同性能数据非常容易失真。我更看重的是更新粒度差异带来的代码心智负担差异。在日常业务里如果你面对的是那种“几十个组件中只有一两个会频繁变化”的页面Rea这种细粒度方案反而能让调试变得更简单一个effect对应一个节点变量名就是节点语义不需要通过组件树层层追踪数据流。6.2 边界在哪里Rea不适合哪些项目前面强调适用场景这里必须反向说清楚Rea的边界。第一个不适合的场景是超大规模状态应用比如千人同时编辑的复杂富文本编辑器。这类应用状态多、操作密集、协作关系复杂Rea的轻量API很难支撑起像样的状态架构模式。第二个不适合的场景是需要大量服务端渲染或SEO友好的内容站。Rea没有服务端渲染能力也没有从HTML模板到运行时DOM的完整编译流程放在内容站点上只会给自己找麻烦。第三个不适合的场景是团队协作的大型前端工程。不是说Rea会写坏而是它缺少规范和约束不同人写出来的绑定风格五花八门。团队越多这种自由反而成为维护负担。我在实践过程中发现Rea最适合的形态是“个人项目里的局部交互模块”而不是整个团队的底层基础设施。6.3 如果继续做下去我会往哪里扩展Rea目前没有模板语法但有明显的扩展空间。假如我后续继续打磨最优先考虑的是加一个简单的HTML模板字符串解析器支持把那些经常重复的节点结构提取成可复用的模板片断。第二优先的是提供TypeScript类型推导让reactive代理的对象在类型层面保留原始字段提示。如果非要加一个用于大型页面的功能我会做一个可作用域隔离的依赖图允许开发者显式创建多个互不干扰的Rea实例每个实例拥有自己独立的依赖表与调度队列。这样多个团队在同一页面上用Rea时不会因为全局currentEffect的冲突而互相干扰。最后再分享一点个人经验。我在实际使用Rea的过程中发现微任务调度器在处理极端高频操作时偶尔会变成新一轮微任务的连续堆积导致一帧内执行时间过长。后来我用一个简单的“最长连续待更新数量”阈值来做强制降级如果当前待处理的effect数量超过某一阈值就立刻同步执行并清空队列防止无限排队。这种看似简陋的策略反而比花哨的调度算法稳定得多。所以如果你也想借鉴Rea的思路去打磨自己的迷你框架我的建议很直接从依赖收集开始用Proxy拦截读写再加一个微任务调度器顺便把数组的length通知补上。这些东西写起来不会超过三百行但足以帮你把响应式原理吃透。而Rea给我最大的收获并不是那几千行代码而是让我学会在选型时先问自己我到底需要多大的框架来支撑多大范围的变化。
阅读完成 · 觉得有帮助?
咨询建站