项目名叫 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 里输入 5A2 里输入 6A3 里写 A1A2。A3 并不知道自己依赖了 A1 和 A2但计算引擎在计算 A3 时一定会去读取 A1、A2它就把这条依赖记录了下来。等 A1 改成 10Excel 自动重算 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-ifvisible余额{{ balance }}/div。当 visible 为 false 时渲染函数不会读取 balance依赖记录里只有 visible当 visible 变成 true渲染函数又读取了 balancebalance 也被记入依赖。问题是 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-textcount/span button r-on:clickincrement加一/button /div , increment() { state.count 1; }, }; }这个设计的好处是模板不需要额外编译坏处是解析器要自己处理指令查找。我一开始天真地以为用正则匹配属性就够了后来发现这是整个项目里最大的坑。3.2 正则解析踩坑后我换成了临时容器第一版解析代码用/[a-z][^]*\br-text([^])/g去匹配模板字符串里的指令。看起来没问题但它遇到两种情况会直接翻车一是属性值里包含字符二是字符串里出现或会导致正则提前截断。更麻烦的是自闭合标签和元素嵌套正则匹配很容易把外层标签和内层标签搞混。我在一次测试模板里写了input valuea 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 节点带一个>
阅读完成 · 觉得有帮助?