项目名叫 rea,三个小写字母,后面没有任何说明。我第一次拿到这个标题时,第一反应是:这多半是一个被随手砍短的框架名。把它展开,最合理的解释是 reactive runtime——也就是一个响应式前端运行时。它要回答的问题很具体:当项目只需要三五个交互组件时,有没有必要背上一整套重型框架,还是可以自己写一套几百行就能跑透的轻量方案。这篇文章围绕 rea 的实际开发过程,完整复盘我从需求拆解、技术选型、核心实现到踩坑记录的全过程。适合两类人:一类是想亲手写小型框架的前端学习者,另一类是被框架体积和配置复杂度反复折磨的工程负责人。
1. 标题只有三个字母,我是这么拆出需求的
1.1 拿到 rea 后,我先不急着写代码
很多人拿到一个项目名后第一件事就是开 IDE 建目录,这在我看是大忌。尤其是 rea 这种三字母缩写,连正文都没有,盲写代码等于没有图纸就浇混凝土。先把标题拆成能力词:rea 最像 reactive 和 runtime 的拼接,翻译过来就是“响应式运行时”。这个定位一旦确认,后面所有设计都围绕一件事展开——和数据变化绑定的页面渲染层。
潜在需求也顺出来了。标题这么短,大概率是某个内部工具或者学习性质的小项目,不是大型商业化产品。它需要的不是复杂生态,而是三个能力:响应式数据、组件化组织、轻量可嵌入。这三个词就是整篇代码的骨架,其他功能全部往后排。把标题翻译成问题清单之后,你会发现写代码的顺序其实很自然:先有 reactive,再有 render,最后有 mount。
1.2 三个真实使用场景反过来定义功能
需求不能凭空想,我习惯用使用场景倒推。之前帮人做内部系统时,最常遇到的就是“就差一个小组件”的情况。比如某个页面的表格上方要加一个实时筛选状态栏,不想为这个状态栏引一套完整框架,又不想手写一堆 addEventListener 和 innerHTML。这个场景直接指向 rea 的最小形态:一个组件、几行模板、自带响应式更新。
第二个场景是低代码配置页的渲染器。配置面板会把用户填写的字段映射成表单组件,字段一多,普通 DOM 操作就撑不住了。这里需要的是条件渲染、列表渲染和事件绑定。第三个场景是教学。我自己带过几个入门前端的朋友,他们看大框架源码像看天书,但如果用一个几百行的运行时把依赖收集、模板解析、DOM 更新串起来,理解会快很多。三个场景列完,功能清单就出来了:reactive 状态、模板字符串、指令绑定、生命周期函数,再加一个轻量更新队列。就这些,多了不要。
| 能力项 | 是否进入第一版 | 依据 |
|---|---|---|
| 响应式数据 | 必须 | 所有渲染行为都围绕它 |
| 模板字符串 | 必须 | 不想引入编译链,直接用字符串定义视图 |
| 事件绑定 | 必须 | 没有事件,组件就没有交互 |
| 条件/列表渲染 | 必须 | 低代码表单场景需要 |
| 生命周期 | 必须 | 组件卸载时不清理会内存泄漏 |
| 路由 | 砍掉 | 单页面组件场景用不到 |
| 服务端渲染 | 砍掉 | 第一版只做浏览器端 |
| 全局状态管理 | 砍掉 | 跨组件共享状态可用事件或者外部存储,没必要内置 |
1.3 我砍掉了哪些功能
确定“不做什么”比确定“做什么”更重要。rea 第一版只有大约一千五百行代码,如果我贪心把路由、状态管理、虚拟滚动都塞进去,项目就会膨胀到无法维护。路由完全可以由使用方自己判断,全局状态也只是一个外部对象加上响应式包装,没必要做成内建机制。还有服务端渲染,第一版的目标是浏览器端嵌入式组件,不能在这一点上分心。
我给自己定了一个硬性原则:任何功能如果两句话讲不清它解决什么场景,就不进入第一版。这个原则在后续开发里帮我挡掉了不少冲动。比如有人建议加一个动画系统,听起来很酷,但 rea 的核心价值是“数据变了视图跟着变”,动画属于锦上添花,放在后续版本里完全来得及。轻量不是代码行数少,而是边界清晰。这句话几乎决定了我后面所有设计决策。
2. 响应式数据:引擎盖下面那几十行
2.1 响应式的本质:读取时联系,写入时通知
一开始我准备把响应式系统做成一个独立小模块,算上注释大概一百行。它的核心逻辑可以用一句话描述:当你读取某个属性时,我把当前正在运行的渲染函数记到这本属性名下;当你修改这个属性时,我把这个名字下面所有渲染函数都跑一遍。听起来麻烦,但用代码实现起来反而很直观。
打个比方,这就像 Excel 里的单元格公式。你在 A1 里输入 5,A2 里输入 6,A3 里写 =A1+A2。A3 并不知道自己依赖了 A1 和 A2,但计算引擎在计算 A3 时一定会去读取 A1、A2,它就把这条依赖记录了下来。等 A1 改成 10,Excel 自动重算 A3,不需要你手动告诉它。响应式框架做的事情和这个一模一样,只不过把单元格换成了 JavaScript 对象属性,把重算公式换成了渲染函数。
2.2 一段能跑起来的响应式实现
我用 Proxy 而不是老式的属性重写方案,因为 Proxy 可以在对象初始化之后拦截新增属性。第一版代码是这样的:
const effectStack = []; function effect(fn) { const wrapped = () => { effectStack.push(wrapped); try { fn(); } finally { effectStack.pop(); } }; wrapped(); return wrapped; } function reactive(target) { const depsMap = new WeakMap(); return new Proxy(target, { get(obj, key) { const active = effectStack[effectStack.length - 1]; if (active) { let deps = depsMap.get(obj); if (!deps) { deps = new Set(); depsMap.set(obj, deps); } deps.add(active); } return Reflect.get(obj, key); }, set(obj, key, value) { const result = Reflect.set(obj, key, value); const deps = depsMap.get(obj); if (deps) { deps.forEach((fn) => fn()); } return result; }, }); }这段代码里最关键的是 effectStack。为什么要用一个栈而不是一个全局变量?因为组件会嵌套,父组件渲染时触发子组件渲染,子组件内部又去读取自己的响应式对象,如果只存一个 currentEffect,父子效应会互相覆盖。使用栈之后,每个 getter 都能拿到“当前正在执行的、最内层那个 effect”,依赖记录自然也就归位了。
2.3 细节决定成败:WeakMap 和分支依赖
上面代码里我特意用了 WeakMap 而不是普通 Map。原因很实际:当响应式对象被销毁后,WeakMap 里的 key 可以被垃圾回收,普通 Map 会一直持有引用,时间一长就会内存泄漏。这种细节在小型框架里看不出差别,跑一个长期单页应用立马见高下。另一个容易被忽略的问题是分支依赖。
看这个模板:<div r-if="visible">余额:{{ balance }}</div>。当 visible 为 false 时渲染函数不会读取 balance,依赖记录里只有 visible;当 visible 变成 true,渲染函数又读取了 balance,balance 也被记入依赖。问题是 effect 每次执行时不会自动清理之前的依赖,如果 visible 又变成 false,理论上 balance 的更新不应再触发这个渲染函数,但旧依赖还在,会导致多余的渲染。修复方法是每次 effect 执行之前把旧依赖清掉,执行过程中重新收集。
2.4 更新队列:不要每次赋值都刷新一次 DOM
初学者最容易犯的错是在 setter 里拿到 effect 就立刻调用。如果同一事件里连续修改三个属性,页面会被刷新三次,而且前两次读取的中间值根本不会显示出来。我在 rea 里加了一个简单的调度器,把所有待执行 effect 放进队列,用微任务统一执行。
const jobQueue = new Set(); let flushing = false; function queueJob(job) { jobQueue.add(job); if (!flushing) { flushing = true; Promise.resolve().then(() => { jobQueue.forEach((fn) => fn()); jobQueue.clear(); flushing = false; }); } }用 Set 而不是数组,是为了去重。同一个 effect 即使被三个属性同时触发,也只需要执行一次。这个设计和现代框架的调度思路是一致的,先用小步快跑保证正确性,再谈批量优化。这里我额外留了一个改进空间:把 effect 的更新标记成异步任务后,读取 DOM 内容可能会拿到旧值,所以对依赖 DOM 尺寸的组件要特别小心,实际项目里我基本不在渲染函数里读布局属性。
3. 模板解析与渲染链路:从字符串到 DOM 节点
3.1 模板语言第一版设计
rea 没有用虚拟 DOM,也没有先编译成抽象语法树。第一版模板就是一个普通的模板字符串,配合几个自定义指令来标记动态位置。指令设计得越简单,解析器写起来越轻松。我最终采用的是 r-text、r-if、r-for、r-on 四个指令,配合 JavaScript 表达式作为属性值。
r-text负责把状态值写入文本节点,r-if控制元素显隐,r-for循环生成列表,r-on:click绑定事件。组件长这样:
function Counter() { const state = reactive({ count: 0 }); return { state, template: ` <div> <span r-text="count"></span> <button r-on:click="increment">加一</button> </div> `, increment() { state.count += 1; }, }; }这个设计的好处是模板不需要额外编译,坏处是解析器要自己处理指令查找。我一开始天真地以为用正则匹配属性就够了,后来发现这是整个项目里最大的坑。
3.2 正则解析踩坑后,我换成了临时容器
第一版解析代码用/<[a-z][^>]*\br-text="([^"]+)"/g去匹配模板字符串里的指令。看起来没问题,但它遇到两种情况会直接翻车:一是属性值里包含>字符,二是字符串里出现<或>会导致正则提前截断。更麻烦的是自闭合标签和元素嵌套,正则匹配很容易把外层标签和内层标签搞混。我在一次测试模板里写了<input value="a > b">,解析结果直接乱套。
后来我换了一个思路:既然浏览器本身就是最好的 HTML 解析器,为什么不直接把模板字符串塞进临时容器,再用 DOM API 去遍历呢?这个改动让解析逻辑从一百行缩减到三十行,还顺手解决了浏览器特有标签的兼容问题。具体代码如下:
function parseTemplate(template) { const container = document.createElement('div'); container.innerHTML = template.trim(); return container.firstElementChild; }用 innerHTML 解析模板必须注意一个安全底线:如果模板里的内容可能掺入用户输入,不能直接塞进去。rea 内部对动态文本统一做了转义,把<、>、&替换成实体,保证任何用户输入都不会被当成新标签执行。这个规则从第一天起就没有放松过。
3.3 挂载时把状态和 DOM 绑定起来
拿到模板的根元素之后,需要把状态和 DOM 节点绑定。我在每次挂载时扫描整个子树,找到带指令的元素,逐个执行绑定逻辑。看起来原始,但对于一个微型运行时来说,可读性比花哨算法更重要。
function bind(root, state, ctx) { root.querySelectorAll('[r-text]').forEach((el) => { const key = el.getAttribute('r-text'); effect(() => { el.textContent = state[key]; }); }); root.querySelectorAll('[r-on\\:click]').forEach((el) => { const methodName = el.getAttribute('r-on:click'); el.addEventListener('click', () => { ctx[methodName](); }); }); }这里有一个作用域细节:事件处理函数不能直接通过state[methodName]调用,因为方法定义在组件实例对象上,不一定挂在响应式状态里。所以我额外传入了组件实例对象ctx,事件触发时在ctx上查找方法。这个方法环境第一次写反了,导致所有 click 都报 undefined,排查了很久才反应过来。
3.4 更新策略:能局部改就不全量重建
第一批功能实现后,我遇到过输入框丢焦点的经典问题。原因是每次状态更新都把整棵 DOM 树重建一遍,用户打一个字,输入框就被替换成新节点,焦点自然丢掉。要解决这个问题,必须让更新尽量局部化。rea 的做法是:r-text直接用节点替换文本内容,r-if只切换display属性,r-for在列表数据变化时才重建列表片段。这样大部分交互场景只改动一个文本节点或一种样式属性,与用户正在交互的输入框互不干扰。
列表渲染的简化版长这样:
function renderList(container, list, keyExpr) { const fragment = document.createDocumentFragment(); list.forEach((item) => { const el = buildItem(item); el.dataset.key = item[keyExpr]; fragment.appendChild(el); }); container.replaceChildren(fragment); }replaceChildren是一次性替换所有子节点,比循环 removeChild 性能好,也避免每次追加时触发布局抖动。这个 API 在支持度上完全够用,兼容问题几乎可以忽略。列表只要不改,就不需要重建;一旦改了,整体替换反而比细粒度 diff 更可靠。
4. 生命周期和列表渲染:补齐框架该有的“品格”
4.1 组件生命周期和清理 effect
一个框架如果没有生命周期,组件卸载后响应式依赖还会留在内存里。页面 A 被切走,理论上组件应该停止响应,但 effect 并没有自动停止,后续任何状态变更都会触发已经不存在的 DOM 操作,报错或者白耗性能。我在 rea 里加了一个 stopEffect 机制,为每个 effect 打上停止标记,停止后不再执行,也会从所有依赖集合中移除。
function stopEffect(fn) { fn.stopped = true; } function effect(fn) { const wrapped = () => { if (wrapped.stopped) return; effectStack.push(wrapped); try { fn(); } finally { effectStack.pop(); } }; wrapped(); return wrapped; }组件卸载时只要遍历一次自己的 effect 列表,全部调 stopEffect,就不会再有任何后台更新。实际开发里,我还会把组件挂载和卸载注册到生命周期对象里,让使用方在合适时机处理自定义清理,比如取消事件监听、清除定时器。一个响应式组件最重要的品格就是“不在你不需要它的时候打扰你”,stopEffect 就是这句话的代码表达。
4.2 列表渲染必须有 key,否则会出现幽灵复用
列表渲染第一版我只用了数字索引作为默认 key,数据一排序就出问题。举个例子:列表按名称倒序排列,某项的状态是“选中”,sort 之后旧的第 0 项和新第 0 项并不是同一个对象,但因为索引相同,DOM 节点被复用,选中状态就挂到了错误的那一行。这种情况在视觉上很隐蔽,用户看到的是勾选框自动移动,完全摸不着头脑。
解决办法是强制 list 节点带一个>