零依赖响应式微内核rea:200行代码实现局部DOM自动更新

发布时间:2026/10/24 18:40:50
零依赖响应式微内核rea:200行代码实现局部DOM自动更新

我一直觉得,做前端最怕的不是需求复杂,而是明明只需要一处小动态,却被迫把整条工程链搬过来。前段时间在某个内部运营工具里,需要做一个轮询刷新的状态卡片区:十几个点位,每秒更新一次,UI结构很简单。可当时团队主流方案已经搭好一套重型的响应式体系,接入后光是首屏加载就要多拉几百KB的运行时。我决定单独抽一个零依赖的轻量级响应式微内核,代号就叫“rea”。“rea”不是什么新框架,它就是一个真正能开箱即用的小工具:用几行API,把普通对象变成可观测数据,数据变了,绑定的DOM自动更新。如果你正被“只想更新一块区域却要引入一堆依赖”这个问题困扰,或者想弄明白响应式背后那层薄薄的原理,这个项目应该能给你不少启发。

1. “rea”从哪里冒出来的:一次杀鸡用牛刀的取舍

1.1 那个让开发很烦的真实场景

当时的业务页面其实不复杂:顶部一排行状态卡片,每张卡片显示设备名称、在线状态、最近上报时间,下面还有几个大数字,表示今日处理量、失败量、平均耗时。数据由后端接口定时推送,前端只需要把新数值渲染到对应位置,同时给在线/离线状态切换一下底色和文案。

听起来非常简单,但一旦落到实现上,第一个冒出来的问题就是:“更新页面哪一块?”传统做法是拿一个定时器去调接口,拿到数据后直接操作DOM,改文字、改class。写起来倒也不难,可是状态一多,代码就变成了下面这种样子:document.getElementById('device-' + id + '-status')到处飞,每个更新项都要手动维护引用,稍不留神就会把节点ID写错,或者在节点还没创建时就执行了更新。

第二个问题更实际:如果布局调整,比如卡片从列表改成栅格,DOM结构一变,之前所有“按ID找节点”的逻辑全都要跟着返工。当时团队已经有了统一的重型响应式工程体系,可那是为整站级应用准备的,为了这十几个点位引入它,不管是构建配置、运行时体积,还是后续的团队上手成本,都有点得不偿失。于是“rea”就诞生了:一个只做状态响应、不干预DOM结构和渲染策略的小内核。

1.2 重型方案不是不好,而是配菜不对

先说清楚,我不是在否定重型响应式方案。在复杂项目里,组件树、虚拟节点、生命周期这些能力非常重要,很多场景非它不可。但同样是“数据变了页面要更新”这件事,不同量级的诉求对应的成本差是很大的。

我整理过一个对比逻辑:

  • 如果只是一个独立页面里的局部区域需要动态刷新,搬整站框架进来,等于为了热一碗饭点燃一个灶台。
  • 如果项目本身已经有了完整工程链路,再把一个小库插进去,路由、状态、样式方案都得跟着适配,折腾一圈可能比手写更慢。
  • 如果页面还涉及第三方嵌入、广告脚本、低端机兼容,零依赖的小方案反而更可控,因为它不跟你抢全局对象,也不会因为升级引发连锁问题。

“rea”的设计目标因此定得很明确:体积尽量小,API尽量少,不依赖其他包,只要浏览器原生支持基础能力就能跑。核心诉求就是一条——把“数据到DOM”的同步过程自动化,同时保持透明,让开发者知道每一处更新发生在哪。

1.3 “rea”想解决的三个核心问题

第一个是数据引用问题:我不想手动写“把A对象的值赋给B元素的textContent”,我想直接写“一旦A对象变化,自动执行我注册的函数”。第二个是局部更新问题:数据变了只重绘关联片段,而不是把整张卡片、整个列表都重新渲染。第三个是心智负担问题:我希望接口简单到让不熟悉原理的同事也能在5分钟内上手,而不是先看几十个概念。

后来“rea”的API就长成这样:

const state = createRea({ deviceName: 'A-01', online: true }); effect(() => { document.querySelector('.device-name').textContent = state.deviceName; document.querySelector('.device-status').textContent = state.online ? '在线' : '离线'; });

页面里完全不需要写事件绑定、不需要手动派发更新回调。state名字变了,DOM文本自动变;状态变了,显示文案也自动跟着切换。这就是“rea”最核心的价值:把繁琐的同步细节藏在微内核里,开发者只需要描述“什么依赖了什么”。

2. 设计“rea”前,先把响应式原理吃透

2.1 发布订阅是底子,依赖收集才是灵魂

响应式的本质,说穿了就是发布订阅模式:一个数据对象变化时,通知所有关心它的人。但真正决定一个响应式系统好不好用的,不是通知本身,而是“怎么知道有哪些人该被通知”。这就是依赖收集要做的事。

我习惯用一个不太严谨但很好懂的类比:医院挂号系统。病人(DOM更新函数)去看病,不会直接冲进诊室,而是在护士台(依赖收集器)登记“我要看某个医生(某个数据字段)”。医生(数据字段)一旦开始叫号,护士台会翻出这份登记表,按号通知病人。它的巧妙之处在于:每次挂号是动态发生的,只有真正读过某个数据的函数,才会出现在那张表的对应位置。

在“rea”里,我设计了一个全局变量activeEffect来标记“当前正在执行的更新函数”。只要在effect执行过程中读取了某个对象属性,就把这个函数登记到那个属性的依赖表里。听起来简单,实际写出来后才意识到细节特别多:同一个effect读了十个字段,它就要出现在十张表里;不同的effect读了同一个字段,这个字段一变化,就要一次通知所有人。这个数据结构本质上是一个两级Map:对象Map套字段Map,再套一个函数集合。

2.2 数据代理用Proxy,不用defineProperty

老一代响应式方案喜欢用Object.defineProperty。它的问题是:只能拦截某个对象上已经存在的属性,新增一个原本没有的属性时,拦截器不会被触发。要响应式地处理新属性,只能额外写一堆set、delete的方法,非常绕。

“rea”的目标是零依赖,所以直接用现代环境里普遍支持的Proxy。Proxy拦截的是“对对象的操作”本身,不是针对某一个具体的key。无论你访问state.count,还是给state.extraInfo = {}赋值,甚至是delete state.item,我都能在代理层捕获到,然后走同一个订阅通知流程。

用Proxy还有一个意料之外的好处:对数组的操作天然友好。比如list.push(1),push过程会先读取length,再设置新下标,整个过程都能被拦截,不需要为数组方法写一堆patch。我在设计阶段对比过这两者的差异,列了一张表:

对比项Object.definePropertyProxy
拦截已有属性支持支持
拦截新增属性需要手动补充原生支持
数组方法支持需要重写原生操作可捕获
嵌套对象初始化时递归访问时懒递归
兼容旧环境更广需要较新运行环境

2.3 批量异步更新:微任务的正确用法

响应式系统最怕的问题之一,是同一个事件循环里改了十次数据,页面就更新十次。比如一个轮询接口返回了一整包数据,里面有二十个点位状态,每个点位都触发一次effect,如果同步执行,页面会在极短时间里被连续刷新二十次,浏览器来不及排版,肉眼可能不觉得,但性能损耗是实打实的。

“rea”的做法是:把更新动作放到微任务里统一调度。第一次数据变化时,先把相关effect函数加入更新队列,然后挂起一个微任务;后续同一帧里再改数据,只是继续往队列里塞函数,不会重复挂起微任务。微任务执行时,队列里的函数才依次执行,而且同一个函数如果被塞了多次,队列里只保留一次。

这个思路就像是大家都在一个窗口排队,不管一天发多少张号,窗口只叫一次队,把手里所有号处理完。这样既保证了状态一致,又避免了重复渲染。调度器代码非常短,核心就是Promise.resolve().then(flushQueue),下一小节我会把完整实现贴出来。

3. 从零手写一个“rea”核心:不到200行搞定

3.1 核心API设计:createRea、effect、computed

动手之前,先把API和用法定下来。接口太多容易让用户懵,太少又不够用。我自己只留了四个入口:

createRea(initialState) // 创建响应式状态对象 effect(callback) // 注册副作用函数,依赖数据变化时自动执行 computed(callback) // 创建计算属性,依赖不变则不重复计算 dispose(callback) // 手动解除一个effect的订阅

为什么不做成observe()这种名字?因为它听上去像是在观察对象,但实际上我们关注的是“代码片段和数据之间的依赖关系”,effect更接近“这个函数会产生副作用”的语义。computed则负责派生状态,比如“列表是否为空”“汇总金额是多少”,它会让业务逻辑更清晰。

下面是一个最基础的使用示例:

const store = createRea({ price: 10, count: 2 }); const total = computed(() => store.price * store.count); effect(() => { document.querySelector('#total').textContent = '合计:' + total.value; }); store.count = 5; // 界面自动变成 50

3.2 依赖收集与派发更新:track和trigger

先定义核心数据结构。我用两个Map来保存依赖关系:第一个Map按对象分组,第二个Map在对象内部按字段名分组,最终存的是一个Set,避免同一个副作用被重复收集。

const targetMap = new WeakMap(); let activeEffect = null; function track(target, key) { if (!activeEffect) return; let depsMap = targetMap.get(target); if (!depsMap) { depsMap = new Map(); targetMap.set(target, depsMap); } let depSet = depsMap.get(key); if (!depSet) { depSet = new Set(); depsMap.set(key, depSet); } depSet.add(activeEffect); } function trigger(target, key) { const depsMap = targetMap.get(target); if (!depsMap) return; const depSet = depsMap.get(key); if (!depSet) return; depSet.forEach(effectFn => effectFn()); }

这里的细节在于WeakMap的用法。我不想让“rea”长期持有一个对象的引用,导致状态对象无法被垃圾回收。WeakMap的key是弱引用,只要外部不再使用这个对象,依赖表也会随之消失,这样就不容易内存泄漏。收集时用Set,天然去重,同一个effect重复读同一个key,只会被登记一次。

有了这两段基础函数,接下来就是创建响应式对象:

function createRea(initialState) { return new Proxy(initialState, { get(target, key, receiver) { track(target, key); return Reflect.get(target, key, receiver); }, set(target, key, value, receiver) { const result = Reflect.set(target, key, value, receiver); trigger(target, key); return result; } }); }

3.3 批量异步调度:先合并再执行

直接在trigger里同步执行effect函数,会导致一个字段变化触发多次连锁更新。我在“rea”里加了调度队列:

const jobQueue = new Set(); let flushing = false; function enqueue(effectFn) { jobQueue.add(effectFn); if (!flushing) { flushing = true; Promise.resolve().then(() => { jobQueue.forEach(fn => { if (fn.active) fn(); }); jobQueue.clear(); flushing = false; }); } }

每次数据变化,trigger不是直接调用effect,而是调enqueue(effectFn)。同一个函数在短时间内不管被触发几次,Set都会把它合并成一次。微任务执行的时候,所有函数集中跑一次。这里有一点值得注意:effect函数执行过程中可能又改了数据,导致新的effect进入队列,但此时flushing已经是true,不会重复开启微任务,等下一轮队列执行结束再统一清空。正因为有Set做去重,循环触发的风险也被降低了一部分,但真正的死循环还需要额外防护,我在第5节再展开。

3.4 实现thing effect:注册、标记和销毁

effect本身需要包一层,才能区分“哪个函数正在执行”。完整实现如下:

function effect(callback) { const effectFn = () => { if (!effectFn.active) return; try { activeEffect = effectFn; callback(); } finally { activeEffect = null; } }; effectFn.active = true; effectFn(); return effectFn; } function dispose(effectFn) { effectFn.active = false; }

第一次注册时,effectFn()会立刻执行一次,这样页面初始化时依赖表才能完成收集。这是关键:一个函数如果不先跑一遍,系统根本不知道它读了哪些数据,后面数据变化时自然也就不知道要通知谁。执行完立即执行回调,同时在finally里清空activeEffect,这样嵌套effect时也不会串台。

3.5 computed计算属性:懒计算与自动失效

计算属性本质是一个特殊的effect,它依赖若干状态,输出一个值。“rea”的做法是:先建一个内部对象保存值,然后把计算函数注册成effect,计算结果存进去,再让外部读取“内部对象”的字段。当依赖变化时,内部字段自动更新,外部读取时拿到的就是新值。

function computed(getter) { const box = createRea({ value: undefined }); effect(() => { box.value = getter(); }); return box; }

但在效率上,我还加了一个小的惰性优化:只有真正读取box.value时才去重新求值,否则依赖变了也没关系,先缓存住。实现思路是维护一个dirty标记,computed内部通过effect注册一个“惰性更新”回调,只把dirty置为true,然后在get中判断是否需要重新执行getter。这样在数据变化而没人读取计算值时,不会白算。

4. 在真实项目里的三种落地姿势

4.1 场景一:数据大屏的轮询状态卡

当时最迫切的就是这个场景。轮询接口每秒钟返回一批点位数据,页面上有二十多张卡片。如果用传统方案,每次要遍历卡片、判断新旧值、手动改DOM;用“rea”之后,只需要把数据塞进响应式store,然后让effect去绑节点。

const store = createRea({ points: [] }); function renderCards() { const container = document.querySelector('#card-list'); store.points.forEach(p => { const card = document.createElement('div'); card.className = 'card'; container.appendChild(card); effect(() => { card.querySelector('.status').textContent = p.online ? '在线' : '离线'; card.querySelector('.status').classList.toggle('offline', !p.online); }); }); } setInterval(async () => { const data = await fetchStatus(); store.points = data.items; }, 1000);

这里有个小细节:store.points被整体替换时,页面里新卡片会自动创建,但它并不是“ther”的渲染器,renderCards仍然需要自己处理创建和销毁。我不想把“rea”变成模板引擎,所以结点的增删还是由开发者的逻辑负责,effect只负责更新已有节点的内容。这样分工清晰,也避免库本身过度设计。

4.2 场景二:聊天消息区的增量渲染

另一个很适合的场景是消息流。后端推过来的消息不断追加到数组里,界面要做的就是往列表末尾补一条记录,同时保持滚动位置不跳。用“rea”写起来很直接:

const chat = createRea({ messages: [] }); effect(() => { const list = document.querySelector('#msg-list'); const last = chat.messages[chat.messages.length - 1]; if (!last || last.__rendered) return; const item = createMessageItem(last); list.appendChild(item); last.__rendered = true; list.scrollTop = list.scrollHeight; });

这个写法之所以简洁,是因为“rea”的依赖收集粒度到了字段级别。只要messages数组发生了push,effect就会重新执行,而数组末尾新增的那条消息如果没渲染过,就补上去。相比“整个列表重新渲染”,这种方式在大数据量下优势明显。

需要注意的是:我随手在消息对象上加了__rendered标记,这不算干净的做法,生产环境可以换成Set来记录已渲染ID,只是演示时求简单。

4.3 场景三:表单联动与禁用逻辑

只要是表单,就逃不开“A字段变了,B字段要禁用/隐藏/重置”这种联动。以前每次change事件都要写一堆if...else,现在可以声明成computed:

const form = createRea({ payMethod: 'bank', needInvoice: false }); const canUsePoints = computed(() => form.payMethod !== 'bank' && !form.needInvoice); effect(() => { document.querySelector('#use-points').disabled = !canUsePoints.value; }); form.payMethod = 'credit'; // 积分开关自动可用

这种写法的好处是把“状态之间的推导关系”从事件回调里抽离出来,以后改逻辑时不用去翻几十个监听器,只改computed里的判定就行。对于需要频繁调整型业务规则的项目,这个模式特别友好。

5. 从“能用”到“好用”:踩过的坑和补救方案

5.1 深层对象改值不更新,哪一步漏了?

最初的“rea”只在createRea第一层做了代理,结果出现一个经典问题:store.user.name = '新名字'时,effect死活不执行。原因是嵌套对象store.user返回的是原始对象,没有经过代理,后续对name的set根本不会走trigger。

解决方案是懒递归:get时如果取到的是一个对象,就再次对它调用createRea,然后返回代理对象。同时要用WeakMap缓存已经代理过的对象,防止每次get都生成一个新代理,那样不仅浪费内存,还会导致依赖关系失联。

const rawToProxy = new WeakMap(); function createRea(target) { if (rawToProxy.has(target)) return rawToProxy.get(target); const proxy = new Proxy(target, { get(obj, key, receiver) { track(obj, key); const value = Reflect.get(obj, key, receiver); if (value && typeof value === 'object') { return createRea(value); } return value; }, set(obj, key, value, receiver) { const result = Reflect.set(obj, key, value, receiver); trigger(obj, key); return result; } }); rawToProxy.set(target, proxy); return proxy; }

5.2 动态DOM销毁后,effect还在偷偷执行

这是第二个坑:列表项被移除后,它对应的effect仍然在依赖表里,数据一变化就会执行,回调里访问已卸载的DOM就会报错,或者做一些毫无意义的计算。

因此每个列表项在创建effect时都要保留返回值,离开时调用dispose。我更建议在内部用WeakMap记录节点与effect的关联,节点被垃圾回收后,effect自动失活。不过在实际项目中,列表项移除时手动调dispose最可靠,不然依赖表里还是会残留一个active的引用。

5.3 effect里修改了自己依赖的数据,无限循环

写业务的人很容易写出这种代码:

effect(() => { store.timer = Date.now(); // 不断修改自身依赖 });

这个effect读取了store.timer,又给它赋值,赋值触发trigger,又把effect塞进队列,执行时又赋值……如果不加防护,页面会一直空转。我在“rea”里设置了一个最大嵌套层级,当同一effect在一轮调度中被连续执行超过指定次数时,就抛出一条警告,提醒开发者检查逻辑。这个阈值默认是100,不影响正常业务,但足够暴露循环问题。

5.4 什么场景不要硬上“rea”

也说说边界。“rea”只是响应式状态的微内核,它不解决组件复用、虚拟DOM diff、路由、样式隔离这几个问题。如果你的页面里有很多动态增删的复杂结构,需要完整的渲染生命周期,或者多个模块之间要共享状态并遵循一套清晰的数据流规范,那还是上成熟框架更稳。

我的判断标准很简单:当“要更新的局部区域”小于页面面积的20%,并且交互逻辑集中在少数几个状态上时,“rea”是顺手的选择;当整个页面都充斥着动态交互,每一个区域都需要更新时,直接用框架是更省心的做法。

结语:一点点个人体会

踩过几次坑之后,我最大的体会是:响应式并不神秘,核心就是Proxy加一个依赖表,再加一个批量调度队列。把这三个东西想明白,很多框架里的概念也都能顺藤摸瓜理解下来。最后再分享一个小技巧:在调试“rea”项目时,可以在effect第一行临时打印new Error().stack,这样能快速定位到“这次更新是谁触发的”,尤其是遇到隐藏的循环依赖时,这个办法比逐行断点快得多。后续我还在考虑给它加上一个可选的调试面板,把依赖图和更新次数可视化出来,如果你们用了这些代码有任何新发现,欢迎一起交流。