“rea”这个名字大概率会让不少开发者觉得眼熟——毕竟三个字母,看着像某个知名框架的缩写,又像是拼写错误。其实,这是我给自己写的一个前端组件骨架起的代号,全称是 Reactive Element Architecture,反应式元素架构。项目动机特别朴素:我厌倦了每开一个新页面,都要把状态管理、模板渲染、事件绑定这些重复逻辑重新撸一遍。所以就用业余时间搭了一个轻量级框架,并在两个实际项目里跑了一段时间,期间踩了不少坑,也沉淀了一些经验。
如果你正在纠结“要不要自己造一个轮子”,或者好奇现代前端框架摘掉脚手架和语法糖之后,底层到底还剩下些什么,这篇复盘应该能给你一些真实参考。我不会把它包装成什么高深架构,只讲一个代号“rea”的项目,从设计、实现到落地后的经验教训。
1. 项目起源与需求拆解
1.1 项目代号 rea 到底指什么
先解释一下命名。React Element Architecture?不是。Reactive Element Architecture,强调的是“反应式”和“元素”两个关键词。在方案设计初期,我给自己定了三条硬约束:不引第三方框架、尽量不写跳来跳去的装饰器、必须在普通浏览器环境直接跑通。
所以 rea 不是要跟大厂框架叫板,它就是想做一件小事:让我能用声明式的方式描述一个界面元素,然后框架负责把数据变化同步到 DOM。这个目标听起来简单,但真正动手后才发现,光是“数据一变页面就跟着变”这一条,就牵扯到依赖收集、调度更新、DOM diff 等多个环节。一个项目代号从一个随手的缩写变成一份可运行的代码,中间隔着一连串权衡。
1.2 动手前先明确了三个核心问题
我没有直接写框架代码,而是先把要解决的问题列了出来。
第一个是重复代码多。过去写页面组件,总要手动创建 DOM 节点、绑定事件、更新状态、清理副作用。一个稍微复杂点的表单,光这两个步骤就容易写两三百行。我想要的是一套模板语法,让 DOM 的创建和更新由框架代劳,业务代码只管数据模型。
第二个是组件之间状态同步困难。老项目里多个模块要共享同一个数据源,比如用户信息、筛选条件,通常的做法是全局变量或者事件通知。全局变量容易造成命名冲突,事件通知又难排查顺序问题。我希望能有一个中心化的状态容器,组件可以自动订阅自己关心的数据片段,而不是每次刷新全量数据。
第三个是渲染性能不可控。早期尝试过“数据一变,就重新生成整个 HTML 再替换”的做法,页面大了以后能明显感觉到卡顿。我希望 rea 能做到最小化的 DOM 更新——哪个数据变了,就只更新影响了的那一小块 DOM,而不是整块页面推倒重来。
1.3 为什么没有直接使用现成框架
有人可能会问,市面上成熟的方案那么多,为什么还要自己写?我的回答分三方面。
第一,理解底层机制。工作里用框架写业务是一回事,真正理解框架内部的响应式原理是另一回事。自己写一遍,比看十遍原理文章印象深刻。比如“依赖收集”这个词,我读了不少文章,真正实现时才发现,怎么精准地知道当前正在用哪个 state,远比想象中复杂。
第二,定制性强。我只想要一个核心运行时,不需要路由、不需要国际化、不需要构建时插件体系。用现成框架,这些能力模块大多推着一起走,即使不用也占体积。自研可以按需裁剪,最终打包出来只有十几 KB。
第三,体积可控。业务项目里很多页面是嵌入第三方应用的,JS 体积太大会拖慢首次加载。我的目标是核心代码压缩前不超过 20 行核心逻辑,整个运行时打包后不超过 8 KB。
但这并不是说鼓励大家都去造轮子,我的建议仍然是业务场景优先用成熟方案,学原理可以用小项目练手。就像自己修一下自行车没问题,但如果你每天要骑车通勤,我还是建议去专业车店上一条好轮胎。
2. 整体架构与核心设计
2.1 架构分层:视图层、状态层、运行时
rea 整体分成三层,即使后来代码量增加了,这个分层也没大改过。
| 层级 | 主要职责 | 关键模块 |
|---|---|---|
| 视图层 | 模板解析、DOM 更新、事件绑定 | 模板编译器、渲染函数 |
| 状态层 | 响应式数据存储、依赖收集、变更通知 | reactive store、依赖表 |
| 运行时 | 把数据与视图关联、调度更新 | mount、update、批处理队列 |
为什么一定要分层?因为每一层变化频率不同。视图层经常要为不同业务写新模板;状态层侧重稳定,数据结构尽量不变;运行时则是最后一道关口,更新调度的策略直接影响性能。分层之后,我可以单独优化运行时的调度算法,不至于把整个项目搅乱。
2.2 数据流设计:单向数据流加响应式依赖
我在 rea 里用的是单向数据流:状态变化产生新状态,新状态驱动视图更新。组件内部可以修改状态,但修改动作必须通过统一的方法来触发,不能在组件外部随意改掉内部状态。这样做的最大好处是数据流向可预测,出问题时能顺着数据流找源头。
数据流中关键的一环是依赖收集。每个渲染函数执行时,会读取一堆响应式属性。rea 需要记录“这个渲染函数依赖哪些属性”,等那些属性变化时,才有目的地重新执行渲染函数。这个机制有点像一个餐厅服务员,你在桌前点菜(读取数据),服务员记录是哪桌点的是哪道菜(依赖收集),后厨出菜(数据变化)时只往对应桌子送,不用广播给全场。
下面这段代码,是 rea 里响应式状态的核心,简单但已经具备了最基本的依赖收集和触发逻辑。
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 deps = depsMap.get(key); if (!deps) { deps = new Set(); depsMap.set(key, deps); } deps.add(activeEffect); } function trigger(target, key) { const depsMap = targetMap.get(target); if (!depsMap) return; const deps = depsMap.get(key); if (!deps) return; [...deps].forEach((effect) => effect()); }这里用activeEffect标记当前正在执行的渲染函数。当渲染函数读取某个属性时,track就把当前函数记录到对应属性的依赖集合里。当属性被重新赋值时,trigger会取出依赖集合中的函数并一一调用。这听起来简单,但真跑起来你会发现很多细节,后面第四部分会讲踩过的坑。
2.3 最小可行功能图:模板、绑定、插槽
我在项目启动时给 rea 划定了边界,只做五件基础事:
- 模板渲染:可以读取响应式数据并输出到页面上。
- 文本插值:双大括号语法,例如
{{ user.name }}。 - 事件绑定:类似
@click="handleClick"的写法。 - 条件渲染:只渲染满足条件的节点。
- 插槽机制:允许把外部传入的内容渲染到组件内部。
不做的事也很多:不做虚拟 DOM diff、不做复杂动画、不做组件的异步加载。这些功能不是没必要,而是为了先让核心链路跑通。等到后续真的需要了,再一个一个补。
3. 实操过程:从零构建 rea 的关键模块
3.1 初始化项目与开发环境
rea 的工程结构我不想搞得太复杂,只用了两个目录,src放核心运行时,examples放测试页面。初始化命令很简单:
mkdir rea && cd rea npm init -y mkdir src examples touch src/index.js src/compiler.js src/reactivity.js开发依赖我只装了esbuild用来打包测试页,没有引入任何其他运行时依赖。为什么不装全功能构建工具?因为我想保持核心代码跟运行时框架一样干净,任何额外依赖都可能影响后续的体积分析。esbuild 够快,也支持 tree shaking,能满足初期开发需求。
3.2 用几十行代码实现响应式核心
响应式系统是整个项目的地基。我前面已经给了track和trigger的基础代码,现在补上创建响应式对象的入口。这里用Proxy实现,因为它能拦截属性的读取与赋值,也方便我后续做深层响应式的扩展。
function reactive(target) { const proxy = new Proxy(target, { get(current, key, receiver) { const result = Reflect.get(current, key, receiver); track(current, key); return result; }, set(current, key, value, receiver) { const oldValue = Reflect.get(current, key, receiver); if (oldValue !== value) { Reflect.set(current, key, value, receiver); trigger(current, key); } return true; } }); return proxy; }这里有个问题容易忽略:track是在get里执行的,也就是说只要渲染函数读了这个属性,就会把这个属性标记为依赖。但如果渲染函数读的是user.name这样一个嵌套对象,那么track记录的只会是user这个对象本身,并不会记录name属性。所以要实现真正的深层响应式,需要递归地对嵌套对象调用reactive。这里先不展开,后面处理方式和潜在性能问题会说。
为了让activeEffect在每次渲染时指向正确的函数,我定义了一个辅助函数:
let activeEffect = null; function effect(fn) { activeEffect = fn; fn(); activeEffect = null; }这样写只支持同步执行,一旦 fn 内部有异步操作,依赖收集就会失效。实际项目中我改成了一种基于栈的结构,可以嵌套使用,这里先理解思想。
3.3 模板编译到渲染函数
模板是用户跟框架接触的第一层。rea 的模板编译器做法很直接:把 HTML 模板字符串解析成一段可执行的 JavaScript 渲染函数。渲染函数每次执行返回一个新的 DOM 片段,框架再把它插入页面中。
我用一个轻量级的解析方式:正则替换文本插值,把{{ user.name }}转换成_ctx.user.name,然后通过new Function构造渲染函数。这是简化版,只处理了插值场景。
export function compile(template) { const code = template .replace(/{{(.*?)}}/g, '${(ctx.$data$1)}') .replace(/\$\{ctx\.\$data/, '${ctx.$data'); // 这里省略了真正解析器的细节 return new Function('ctx', ` const container = document.createElement('template'); container.innerHTML = \`${template.replace(/{{(.*?)}}/g, '\${ctx.$data$1}')}\`; return container.content.firstElementChild; `); }上面这段代码在真实项目中是不能直接用的,因为innerHTML拼接出来的节点上没有绑定更新逻辑。但它的价值在于展示一个核心思想:模板编译最终结果不是字符串拼接,而是一个可以访问响应式数据的函数。有了这个函数,就能在数据变化时准确找到对应节点并更新。
3.4 把状态与视图关联起来
mount 函数是 rea 的入口。它的职责是创建响应式数据对象,编译组件模板,随后建立渲染函数和数据之间的依赖关系。
export function mount(component, container) { const state = reactive(component.data()); const view = compile(component.template); const render = () => { const element = view(state); container.innerHTML = ''; container.appendChild(element); }; const update = effect(render); return { state, update }; }我来解释为什么用effect(render)。当render执行时,读取了state里的属性,响应式系统自动收集依赖。当属性变化时,effect会让render再次执行,重新生成 DOM。这样做的缺点也很明显:每次更新都是整块替换,没有 diff,导致输入框里的文本也会在更新时丢失。这个问题的优化方案我放到后面说,因为这是所有初版框架都会撞上的坎。
3.5 打包与体积控制
rea 用 esbuild 打包代码,命令很简单:
npx esbuild src/index.js --bundle --minify --format=esm --outfile=dist/rea.js打完包后我会统计一下各部分体积占比。在我的实验版本里,响应式系统占了约 4 KB,模板编译模块大约 2.5 KB,运行时更新逻辑约 1.5 KB,整体下来不到 8 KB。这个体积在框架里算相当轻了,主要原因是没做虚拟 DOM、没有复杂调度器。
体积控制这件事,真正依赖的是模块边界清晰和少依赖。只要核心模块不引用任何额外库,打包工具就能放心摇掉没用的分支。我建议你在构建自己的项目时,也要养成统计体积的习惯,别等到线上白屏了才发现打包产物大得离谱。
4. 常见问题与排查技巧实录
4.1 响应式更新失效:为什么数据改了页面没变
这是我在测试 rea 时遇到的第一个大坑。写了一个数组渲染的例子,点击按钮往数组里 push 一项,页面死活不更新。排查后发现问题出在Proxy对数组方法的拦截上。
Array.prototype.push会触发set拦截,但同时也会修改length属性。我的trigger逻辑只触发了length的依赖,没有触发数组索引的依赖,而模板渲染时读取的是数组索引,所以视图根本没收到通知。解决办法是要么同时对索引属性发送更新信号,要么在渲染时不直接依赖索引,而是依赖一个 version 计数器,数据一变就递增。
第二种做法更简单。我在 store 上挂了一个__version__,每次数据变化时递增。渲染函数里读取这个版本号,依赖关系就会绑定到它上面。
const count = reactive({ value: 0 }); function addItem() { count.__version__++; count.value++; }这属于一种巧妙的“寄主”策略,但不是什么优雅解法。更通用的方案是重写数组方法,让 push、splice 这类操作主动触发通知。如果你在实现自己的框架,建议一开始就特别注意数组方法与 Proxy 的交互。
4.2 异步任务里的依赖收集失效
第二个常见问题跟effect的同步执行有关。我在业务中使用了一个异步请求:
effect(async () => { const users = await fetchUsers(); state.users = users; render(); });因为effect内部执行到await fetchUsers()时,响应式读取发生在 await 之后,而这时候activeEffect已经被清成了 null,导致模板里的{{ users.length }}依赖收集不到。
解决思路是把依赖收集与函数执行位置脱钩:不从全局抓正在执行的函数,而是给渲染函数显式绑定自己的依赖上下文。我最终采用了一个调度器,把渲染函数包装成任务对象,任务对象里保存自己的依赖集合和一个已执行的标记。
如果你的项目也遇到这种诡异的不更新问题,先检查一下数据读取是否和渲染函数处在同一个同步执行栈里。只要有一个 await,原来的依赖链就断了。
4.3 性能问题:整块替换导致输入框失焦
最让我头疼的是输入框体验。用前面mount里的全量替换方案,每次输入一个字符,整个容器 innerHTML 重新赋值,输入框不仅内容丢失,还直接失去焦点,根本没法用。
为了解决这个问题,我开始认真研究 DOM 更新策略。最朴素的做法是给每个插值节点加上标记,数据变化时只更新对应文本节点的nodeValue,而不是重建整个节点树。我在渲染函数里维护了一个映射:key -> [依赖属性, DOM节点],属性变化时定位到节点,只更新它的文本。
const textMap = new Map(); function updateText(key, value) { if (textMap.has(key)) { textMap.get(key).nodeValue = value; } }改造后,输入框内容不会再被重置,因为文本节点更新不会触碰输入框节点。这个教训让我明白:框架的更新粒度,决定了它的体验上限。全量替换永远只能作为 debug 兜底,不能用来做正式渲染。
4.4 事件绑定的内存泄漏
事件绑定也是重灾区。初次实现时,我在每个组件 mount 时用 addEventListener 绑定事件,组件卸载时却只清空了容器 innerHTML。从 React 时代转过来的经验告诉我,这会造成内存泄漏:旧的监听器还留在元素上,元素又被 GC 回收,但闭包引用的数据没法释放。
最后的方案是给每个绑定函数挂一个 token,卸载时统一通过 token 清除:
function offAll(el, token) { el.querySelectorAll('*').forEach((node) => { if (node.__reaSource) { node.removeEventListener(node.__reaSource.type, node.__reaSource.listener); delete node.__reaSource; } }); }如果你自己写了类似框架,记得实现与 mount 对称的 unmount,别只清理 DOM 不清理监听器。监听器泄漏在单页应用里尤其隐蔽,可能几小时内存占用就翻一倍。
5. 项目落地后的沉淀
5.1 从实验到业务项目的关键一步
rea 跑通响应式和文本更新之后,我在两个场景里做了试点:一个是内部数据可视化大屏的筛选栏,另一个是交接班表单系统。前者要求筛选控件之间联动,后者要求表单字段根据类型动态渲染。
两个项目用下来,rea 的短板也很清楚:缺少成熟的调试工具、生态和 TypeScript 类型支持。但它有一个非常显著的优点:核心代码全部在掌握之中,遇到渲染问题可以直接打断点调试,不用像在大型框架里那样,一层层框架源码带着走。这个其实正是自研框架最大的红利——你写的每一行代码,你都知道它是怎么来的。
5.2 三条被实际验证的生活小技巧
第一,先把核心链路跑通再谈优化。我最初的目标只是“模板能渲染 + 数据能驱动更新”。等这条链路跑通后,才去优化数组更新和输入框性能。如果一开始就追求虚拟 DOM diff、异步调度器,大概率项目会卡在设计阶段坚持不下去。
第二,保持核心文件零依赖,尤其是运行时文件。依赖越少,Debug 时越不用考虑第三方库的干扰。我在 rea 的src/reactivity.js里没有 import 任何东西,这让后续性能和逻辑排查变得异常顺畅。
第三,给每个模块写一个最小用例。我建了三个测试页面,分别测文本插值、事件绑定、列表渲染。每改一次渲染逻辑,就翻一遍这三个页面,看有没有回归。这种方式比自动化测试更轻,但对早期项目足够有效。
这些经验也延伸到了我日常的开发中。现在不管用什么框架,我都会下意识地关注依赖追踪机制和更新策略,看到奇怪的白屏或卡顿,也有更明确的排查方向。
5.3 rea 后续可能的长成方向
虽然项目很小,但我已经给它留了可扩展的接口。比较有可能加上的功能是更完善的调度器,把同步的刷新改成批次合并,这样在连续修改多个数据时只触发一次渲染;再就是支持类似 key 的列表复用,避免整个列表重排时丢失滚动位置。这些其实都是现有框架已经解决的问题,我再去写一遍,更多是为了理解它们背后的权衡。
如果你也有类似的项目,我建议可以沿着“模板语法 -> 响应式系统 -> 生命周期 -> 异步调优”这个顺序迭代。每完成一个阶段,自然就知道下一个问题在哪。
一些最后想说的体己话
写 rea 这个项目的日子,没有想象中那么热血沸腾,反而是大量枯燥的定位问题和扫文档的过程。但等它真正跑起来的那个晚上,我在浏览器里看到点击按钮后页面上的数字自己蹦了一下,那种满足感跟完成一个业务需求完全不一样。因为这次我知道,从旁边那个小小的{{ counter }}语法,到 DOM 上那个变化的数字,每一步都是自己铺出来的。
如果你也想练手,别一上来就盯着“打造一个媲美某某的框架”的目标,也不要在踩到 Proxy 数组拦截这种深坑时急着放弃。先把最基础的响应式绑定做出来,再考虑周围的事情。这个过程里培养起来的底层直觉,会在你回归日常业务开发时持续回放,让每一个简单的 bug 都有了更清晰的解释。