1. 为什么一个被框架黑盒折磨的开发者要手搓一个 Rea
Rea 这个名字听起来很随意,它确实就是某天我在工位上随手敲出来的三个字母。那阵子我在维护一个内部工具的数据面板,页面里的表格组件在切换筛选条件后,状态和界面老是错位:某个输入框的内容跑到另一行里,明明选中了一项,下拉框却显示成前一项。我习惯性地打开调试工具想顺着调用栈往下追,结果栈里全是某个大型视图框架内部的调度帧,一层套一层,业务代码被埋在最底下,断点根本落不到我自己的逻辑上。那个晚上我给自己下了一个定义:与其继续猜黑盒,不如亲手写一个能一行行看完的最小渲染引擎。
这个决定出来之后,我给项目定了三条硬指标。第一,总代码量控制在 700 行以内,JSX 的解析交给编译插件,运行时只保留必要部分;第二,任何一次状态更新都必须能用浏览器调试器从入口单步走完,不能出现"跳进 library 里出不来"的情况;第三,跑起来之后要能套进我自己的小项目,而不是一个只存在于测试页里的玩具。于是就有了代号 Rea,后来要跟同事解释的时候,我把它拆成了 Render Everything Again,也算是自嘲:把已经烂熟于心的渲染逻辑,再亲手渲染一遍。
1.1 触发点:当调试器开始变成"考古工具"
那段时间我遇到的最大问题不是功能不会写,而是出了问题之后没办法定位。组件状态对不上,我第一反应是打断点看 props 从哪一层开始不对。结果断点打进去了,看到的不是我写的组件函数,而是框架内部的更新协调器在排队、调度、复用。我理解这样做是为了性能和并发能力,但站在排查问题的角度,这种抽象带来了明显的成本:状态更新的因果关系被拆散到多个阶段里,你很难回答"这次渲染到底是由哪个 setState 触发的"。
我当时还专门列了一张对照表,记录了那些让我头疼的症状:
| 症状 | 框架内部原因 | Rea 的应对 |
|---|---|---|
| 调用栈全是内部调度帧 | 为并发更新做了大量任务调度 | 不做优先级,递归更新一条路走完 |
| setState 后界面延迟更新,难以单步跟踪 | 异步批处理加多阶段渲染 | 每次状态变更后排队一次重渲染,流程线性 |
| 一个 props 变化会牵扯多个组件生命周期 | Fiber 链表上的多阶段遍历 | 只比较新旧 vnode,沿着组件树递归 diff |
| 组件的渲染顺序和调用顺序不一致 | 渲染器会暂停、恢复、重放 | Rea 同步执行组件函数,顺序即调用顺序 |
这张表做完之后,目标就很清晰了:我要的不是一个功能对标大框架的引擎,而是一个"能让我在 10 分钟之内把一次点击到界面更新的完整路径讲清楚"的引擎。它不需要处理并发、优先级、可打断渲染,它只需要把最核心的那条链路做得透明、直接、可调试。
1.2 Rea 的边界:不做什么比做什么更重要
手写渲染引擎最忌讳的就是什么都想加。我在动手前先划了红线,把"坚决不做"写在 README 第一行。下面这张表是项目初期就定下来的:
| 做 | 不做 |
|---|---|
| JSX 到虚拟节点的编译转换 | 自研 JSX 解析器 |
| 函数组件的执行与递归渲染 | Fiber 架构、可中断渲染 |
| 元素属性和事件的更新 | 合成事件系统、事件优先级 |
| useState 的最小实现 | useEffect、useMemo、useReducer 等副作用调度 |
| 带 key 的子节点 diff | 并发更新、时间切片、服务器端渲染 |
这个边界设定帮了大忙。很多人在写迷你框架时喜欢把 hooks 全家桶都搬一遍,结果光是一个 useEffect 的依赖比较和清理时机就能写出两三百行,最后自己都看不懂。Rea 在最开始时只有Rea.createElement、Rea.render、Rea.useState三个对外 API,剩下的全是内部辅助函数。状态管理只保留 useState,是因为它有完整的"声明状态—修改状态—触发渲染"闭环,足够讲清模型,又不会被副作用机制带偏。
如果你也想复现这个项目,我建议你同样先写一个"不做清单"贴到项目根目录,否则很容易在某个深夜开始手写 useEffect 的清理逻辑,然后彻底迷失。
2. 编译与运行时分工:Rea 如何把 JSX 变成可执行的节点树
很多人一提到"从零实现渲染引擎"就以为要写一个 JSX 解析器,这其实是个误区。JSX 的本质是语法糖,它最终会被编译成普通的函数调用。你把编译这层交给 Babel 插件之后,运行时只需要处理标准的 JavaScript 对象,难度一下子降了下来。Rea 的整个编译链路就是把<App />变成Rea.createElement(App, null),把<div id="box">hello</div>变成Rea.createElement("div", { id: "box" }, "hello")。
2.1 用 Babel 编译,而不是自己写解析器
Rea 在开发环境中使用 Babel 做 JSX 转换,配置核心就一段:
{ "plugins": [ [ "@babel/plugin-transform-react-jsx", { "runtime": "classic", "pragma": "Rea.createElement" } ] ] }这里的pragma是关键词,它告诉 Babel:把 JSX 编译成对Rea.createElement的调用,而不是默认的某个第三方框架名称。设置成classic运行时意味着不做自动导入,所有转换结果都显式走我们自己定义的函数。
这样分工之后,编译阶段就完全从项目里剥离了。Babel 负责把尖括号语法翻译成函数调用,Rea 的运行时只接收一个普通对象,不关心源码长什么样。这个设计让我少写了一个完整的解析器——那是另一个项目量级的工程,没必要在这里重复投入。
有一个细节值得注意:如果你用 Vite 或 Webpack 这类打包工具,JSX 编译插件的加载顺序会影响结果。Rea 项目里我特意用了一个最小化的 Babel 配置,不预设preset-env,只注册这一个插件,避免编译产物被转译器再加工一层,导致调试时看到的代码和源码差异过大。
2.2 createElement 返回的虚拟节点到底长什么样
createElement是 Rea 的核心入口,它的职责不是创建 DOM,而是创建一个普通对象来描述"这里有个什么东西"。这个对象我称为 vnode,结构非常简单:
export function createElement(type, props, ...children) { return { type, props: props || {}, children: flatten(children) }; } function flatten(children) { const result = []; for (const child of children) { if (Array.isArray(child)) { result.push(...flatten(child)); } else if (child !== null && child !== undefined && child !== false) { result.push(child); } } return result.length === 1 ? result[0] : result; }type字段可以是字符串,比如"div"、"span",也可以是函数组件本身。props是组件或元素的属性集合,children是子节点。为什么要有flatten这层处理?因为 JSX 里经常出现{items.map(item => <li>{item}</li>)},map 返回值是一个数组,如果直接把数组塞进 children,后面递归遍历时就要多做一层判断。Rea 在 createElement 阶段就把它拍平,并且过滤掉null、undefined、false这些"不渲染"的节点。
但过滤时不能简单用Boolean过滤,因为数字0是一个合法文本节点。页面里渲染{count}时如果 count 是 0,过滤后界面就什么都不显示了,这是个很隐蔽的 bug。Rea 的 flatten 只排除空值,保留数字原样输出。
2.3 函数组件与普通标签的分流:type 字段是关键
有了 vnode 之后,渲染器第一件事就是判断type的类型。这个判断决定了完全不同的两条路径:
function mount(vnode) { if (typeof vnode === "string" || typeof vnode === "number") { return document.createTextNode(String(vnode)); } if (typeof vnode.type === "function") { const output = vnode.type(vnode.props); return mount(output); } const dom = document.createElement(vnode.type); updateProps(dom, {}, vnode.props); for (const child of [].concat(vnode.children || [])) { dom.appendChild(mount(child)); } return dom; }当type是字符串时,直接创建真实 DOM 元素;当type是函数时,Rea 会调用这个函数,把它的返回值当成新的 vnode,再继续递归挂载。函数组件在这里扮演的是"中间层"角色:它不直接产生 DOM,只产生一段节点描述。
这段递归逻辑是整个 Rea 的地基。在组件内部调用另一个组件、循环渲染列表,最终都会被递归归约到字符串数字文本节点或原生标签节点。理解了这段代码之后,再去看"为什么函数组件必须有返回值""为什么函数组件里被打断后状态会乱"这些问题,都会顺理成章。
3. 挂载、diff 与 key:Rea 在不做 Fiber 的前提下能省多少事
渲染引擎的核心工作量其实不在首次挂载,而在更新。首次挂载只需要把 vnode 线性地变成 DOM,更新却要回答一个问题:新旧两棵 vnode 树长得不一样,我怎么用最少的 DOM 操作让界面变成新的样子。Rea 的策略非常朴素:同类型就更新,不同类型就换掉,然后递归比较子节点。这个策略牺牲了部分性能上限,换来了极高的可读性。
3.1 首次挂载:递归地把 vnode 变成真实 DOM
挂载阶段的代码上面已经给出来了,需要补一个关键约定:每个 vnode 在挂载后都要把自己对应的真实 DOM 记下来。Rea 里我用的字段叫__dom,挂在 vnode 对象上。这样 diff 阶段才能知道"这个 vnode 对应的页面元素在哪里"。
这个字段虽然丑,但非常实用。大框架会用 Fiber 节点单独维护一棵指针树,Rea 选择直接复用 vnode 对象,省掉一层额外的数据结构。代价是 vnode 对象不再纯净,带上了运行时痕迹,但在这种小引擎里完全能接受。
3.2 diff 的大判断:同类型就更新,不同类型就换掉
Rea 的更新入口是update(prevVNode, nextVNode),核心逻辑分成三个分支:
function update(prev, next) { if (!prev || prev.type !== next.type) { const newDom = mount(next); prev.__dom.replaceWith(newDom); return next; } if (typeof next.type === "string") { updateProps(prev.__dom, prev.props, next.props); diffChildren(prev, next); prev.props = next.props; prev.children = next.children; return prev; } if (typeof next.type === "function") { const nextOutput = runComponent(next.type, next.props); next.__dom = prev.__dom; const childVNode = update(prev.__child || null, nextOutput); next.__child = childVNode; next.__dom = childVNode.__dom; next.props = next.props; next.children = nextOutput.children; return next; } return next; }第一个分支处理类型变化。比如旧的 vnode 是"div",新的 vnode 变成"span",Rea 直接创建新 DOM,替换旧 DOM。第二个分支处理原生元素,先更新属性,再递归 diff 子节点。第三个分支处理函数组件,Rea 会带着新的 props 重新执行组件函数,拿到全新的子 vnode,然后继续递归对比。
这里有一个理解难点:函数组件本身没有 DOM,它的__dom是从它返回的子 vnode 那里继承过来的。所以在 Rea 里,组件的__dom其实指向它内部某个实际 DOM 元素。这让整个 diff 逻辑能统一通过__dom操作真实节点,不需要区分"组件节点"和"元素节点"的身份。
3.3 没有 key 的 diff 是怎样"看起来能用但隐患很大"的
diff 子节点是里面最容易出错的部分。Rea 最初的实现非常简单,就是按下标一对一对齐比较:
function diffChildren(prev, next) { const prevChildren = [].concat(prev.children || []); const nextChildren = [].concat(next.children || []); const maxLen = Math.max(prevChildren.length, nextChildren.length); for (let i = 0; i < maxLen; i++) { update(prevChildren[i], nextChildren[i]); } }这个实现跑几个静态页面完全没问题,但一旦涉及列表排序就露馅。举个例子,旧的子节点是[A, B, C],新的子节点是[C, A, B]。按下标比较,Rea 会把 A 的 DOM 更新成 C,B 的 DOM 更新成 A,C 的 DOM 更新成 B,最终界面显示确实正确。但问题在于:原本属于 C 的那个输入框组件实例,它的内部状态还留在 A 的位置上。如果 C 下面挂着一个输入框,用户在里面输入了"你好",排序之后"你好"会跑到另一项下面。
这就是为什么需要key。加上 key 之后,diff 的逻辑变成先按 key 找到匹配的旧节点,再决定是复用、更新还是移动。Rea 的 keyed diff 不需要实现完整的复杂度优化,只要做到"同一 key 的节点尽量复用"就能解决状态错位:
function diffChildren(prev, next, parentDom) { const oldChildren = new Map(); prev.children.forEach(child => { const key = child.props && child.props.key != null ? child.props.key : null; oldChildren.set(key, child); }); next.children.forEach(newChild => { const key = newChild.props && newChild.props.key != null ? newChild.props.key : null; const oldChild = oldChildren.get(key); if (oldChild && oldChild.type === newChild.type) { const returned = update(oldChild, newChild); oldChildren.delete(key); } else { const dom = mount(newChild); parentDom.appendChild(dom); } }); }注意这种实现还比较粗糙,它只处理了新增和更新,没有做真正的插入位置调整和旧节点删除。但这已经足够让人理解 key 的核心价值了。我在实际使用时始终坚持:只要列表项对应真实数据,就强制要求传key,值用数据的唯一 id,而不是数组下标。这个习惯在 Rea 里养成之后,回到业务项目里也让我少踩了很多坑。
4. 一个不到 60 行的 useState,藏着整个状态管理的最小心智模型
功能组件本身是纯函数,它不能记住状态。那状态存在哪?Rea 的答案很直接:存在一个模块级的临时变量里,组件每次渲染时按执行顺序往里面取。因为 Rea 是同步渲染且不打断的,这个"全局变量"方案完全够用,还非常透明。
4.1 hook 状态放在哪:挂到"当前正在渲染的组件"上
Rea 的 useState 实现分两部分。第一部分是模块级的 hook 容器,第二部分是组件执行前的"重置"动作。先看代码:
let currentHooks = []; let hookCursor = 0; function beginHooks() { currentHooks = []; hookCursor = 0; } export function useState(initial) { const index = hookCursor++; if (currentHooks.length === index) { currentHooks.push({ value: typeof initial === "function" ? initial() : initial }); } const hook = currentHooks[index]; const setState = (action) => { const next = typeof action === "function" ? action(hook.value) : action; if (Object.is(next, hook.value)) return; hook.value = next; scheduleRender(); }; return [hook.value, setState]; } export function runComponent(Fn, props) { beginHooks(); const output = Fn(props); return output; }每次组件函数执行前,beginHooks会清空 hooks 数组并把游标归零。组件每调用一次useState,游标就加一,如果这个位置还没有存过值,就用初始值填充;如果已经存过了,说明是后续渲染,直接取旧值。setState做的事情很简单:计算新值,做一次浅比较,有变化就更新 hook 里的值,然后排一个渲染任务。
这套机制能成立的唯一前提是:组件函数必须是同步执行完毕的,中间不能被其他组件的 rendering 插队。Rea 不做并发渲染,所以全局变量之间不会互相污染。这个心智模型对理解大框架的 hook 规则极其有帮助——你会明白那些"必须放在顶层""不能放在条件语句里"的规则,不是因为框架霸道,而是因为它们背后依赖的正是线性索引。
4.2 为什么 hook 必须保证调用顺序稳定
用一个反例来说明。假如有一个组件这么写:
function BadComponent({ show }) { if (!show) { return <div>empty</div>; } const [count, setCount] = useState(0); const [name, setName] = useState(""); return <div>{count} {name}</div>; }第一次渲染时show为 false,组件直接 return,useState一个都没执行到,hooks 数组为空。第二次渲染时show变成 true,组件开始执行后面两个 useState,hookCursor从 0 开始,第一个 useState 拿到的是空数组里的新 slot,第二个也是新 slot,看起来没问题。但如果你反过来:第一次渲染 show 为 true,两个 useState 都执行了;然后 show 变成 false,组件直接 return;再变回 true 时,第一个 useState 拿到的还是老位置的值,第二个也是。表面没问题。
真正出问题的场景是条件语句改变 hook 之间顺序。比如 show 为 true 时执行了 useState A、useState B,show 变为 false 后只留下一个 useState A 分支,再变回 true 时 B 拿到的是之前 A 所在 slot 里的旧值。Rea 没有 lint 插件去报警告,它只会安静地给你一个错误的状态。这让我深刻明白了:函数组件不是真的"每次重新执行",它是在用旧的 hook 数据做锚点,锚点顺序变了,状态自然全乱。
4.3 setState 调度:用微任务合并一次重渲染
Rea 的scheduleRender也值得单独拎出来说。我一开始直接写成同步调用:setState 里立刻重新渲染整棵树。这个写法在简单例子里没问题,但如果你在组件函数里调用了 setState,它就会在工作区里"一边遍历一边修改数据源",直接无限循环。所以我改成了微任务排队:
let dirty = false; let rootVNode = null; let rootContainer = null; function scheduleRender(callback) { if (dirty) return; dirty = true; Promise.resolve().then(() => { dirty = false; render(rootVNode, rootContainer); }); }这段代码还有一个额外好处:同一个宏任务里连续调用两次 setState,微任务只会执行一次渲染。比如点击按钮时setCount(c => c+1)后又setName("x"),Rea 会合并成一次重渲染,因为第二次调用进来时 dirty 已经是 true 了。
需要注意的是,这里的状态值在 setState 调用时就已经更新到 hook 里了,所以微任务执行渲染时拿到的一定是最新值。这个方案没有大框架那种"事件处理器结束后统一 flush"的精确批量控制,但对理解"状态更新与渲染触发是两件事"这个核心概念已经足够了。
5. 事件绑定与文本节点:这些细节不处理好,Demo 会漏成筛子
等挂载、diff、useState 都跑通之后,Rea 看起来能用了,但真正让我在 Todo 项目里翻车的,反而是一些特别小的细节:事件监听器没解绑、文本节点被反复重建、class 属性写法和 DOM API 对不上。这些东西不值得写进架构文档,但不处理好,你的 demo 一旦跟用户交互,问题立刻冒出来。
5.1 从 props 里识别事件并绑定
Rea 的属性更新函数里需要区分普通属性和事件属性。我的约定是:props 里以on开头的键,都视为事件绑定,这里用原生事件监听器,不搞合成事件那套:
function updateProps(dom, oldProps, newProps) { const mergedProps = { ...oldProps, ...newProps }; for (const key of Object.keys(mergedProps)) { if (key.startsWith("on")) { const eventName = key.slice(2).toLowerCase(); const oldHandler = dom.__handlers?.[eventName]; const newHandler = newProps[key]; if (oldHandler) { dom.removeEventListener(eventName, oldHandler); delete dom.__handlers[eventName]; } if (newHandler) { dom.addEventListener(eventName, newHandler); (dom.__handlers ||= {})[eventName] = newHandler; } } else if (key !== "children") { dom[key] = newProps[key]; } } }这中间有一个非常常见的坑:如果你在更新属性时直接调用addEventListener而不先removeEventListener,那每次组件重渲染都会往同一个 DOM 节点上叠加一个事件监听器。假设一个按钮的 onClick 箭头函数每次渲染都是新引用,渲染十次,点击一次就会触发十次。这个 bug 的表现非常像"事件被发送到了别的组件",排查起来很痛苦。Rea 在 dom 上挂一个__handlers对象来记录当前监听器,更新时先解绑再绑定,从根本上杜绝了叠加。
5.2 文本节点更新:nodeValue 替换而不是重建
diff 文本节点时,最省事的写法是直接创建一个新的文本节点替换旧的。但这里有个细节:如果把文本框或输入框相邻的文本节点整体替换掉,浏览器可能会出现光标跳动、文本选择范围丢失的问题,而且每次替换都会产生一个新的 DOM 对象,积少成多后内存压力也不小。
Rea 的做法是优先复用文本节点:
function diffText(prevDom, nextText) { if (prevDom.nodeType === Node.TEXT_NODE) { prevDom.nodeValue = String(nextText); return prevDom; } const newDom = document.createTextNode(String(nextText)); prevDom.replaceWith(newDom); return newDom; }只有当旧节点不是文本节点时,才选择重建。这个判断看起来简单,但它避免了大量无意义的 DOM 替换。很多迷你框架的教程在这块写得很随意,实际跑一个有输入框的页面就会露馅。
5.3 class、style 这些"脏"属性的归一化
属性处理还有一个绕不开的归一化问题。JSX 里写className,但在 DOM API 里元素使用的是class;而 vnode 的 props 里可能出现class、className、style三种形式。Rea 在 updateProps 里做了统一映射:
| JSX 写法 | updateProps 内部处理 | 说明 |
|---|---|---|
className="btn" | dom.setAttribute("class", "btn") | 直接赋给 class 属性 |
style={{color:"red"}} | 逐条写入dom.style.color | 对象形式,不做字符串拆分 |
style="color: red" | dom.style.cssText = ... | 字符串形式原样设置 |
disabled={true} | dom.disabled = true | 布尔属性直接赋值 |
htmlFor="input" | dom.htmlFor = "input" | label 的 for 属性用 htmlFor |
这套归一化不需要做得很全面,但至少要保证最常见的 className 和 style 不会出错。我在最初版本里直接写了dom[key] = newProps[key],导致className变成了 DOM 上的自定义属性而不是 class,界面样式全丢。这个教训很便宜,但提醒我:渲染引擎里每一个"看起来理所当然"的属性操作,背后都有浏览器 API 的脾气。
6. 用 Rea 重做一遍 Todo 之后,我复盘出的三个实际教训
把 Todo 应用完整迁移到 Rea 上,是我给这个引擎设定的验收标准。迁移过程比我想象的顺,但也暴露了三个值得写下来的问题。它们不全是引擎本身的 bug,更多是"当你只能依赖最小运行时"时,你会被迫把很多抽象问题想得更清楚。
6.1 状态不在"看起来对应的位置",而在"函数执行顺序对应的位置"
Todo 列表里每一项都有一个删除按钮和输入框。列表排序是我测试的重点:把数组按完成时间重排之后,如果每项的 key 用数组下标,那排序后输入框里的文字会跟着"新的下标位置"走,而不是跟着"原来那条数据"走。这个现象在 Rea 里被解释得特别透明:diff 是根据 key 匹配旧 vnode 的,key 错了,旧 vnode 就匹配错了,对应 DOM 上记录的输入框内容自然还是旧的内容。
解决方式是在创建列表时就用唯一业务 id 作为 key。这个道理我在业务项目里听别人讲过很多次,但真正在一个自己写的引擎里复现一遍之后,才明白它不是一个"优化建议",而是一条硬性的正确性前提。Rea 甚至可以在 diff 日志里直接看到:key 相同但 type 不同时走了重建分支,key 相同但 props 变化时走了更新分支。这种可视化的因果链,比任何文档都有效。
6.2 小引擎也要有自己的调试设施
Rea 没有大框架那套成熟的调试工具,所以我只能自己加一个最小的 trace 开关。在 update 函数的关键分支里打了几个 console.log,输出长这样:
[Rea][diff] type=li key="2" props update: { title: "买菜" } [Rea][diff] type=li key="3" type changed: li -> div [Rea][mount] create <input> key="3" [Rea][update] useState[0] value: 0 -> 1这些日志看起来简陋,但在排查"为什么输入框状态错位"时帮了大忙。你只需要打开控制台,从 setState 的那一行日志开始往下看,就能知道哪一步创建的 DOM 被复用了,哪一步因为 key 不匹配触发了重建。后来我又给 trace 加了过滤规则,只输出某个 key 开头的节点,调试效率提升明显。
这给我的启示是:任何引擎,哪怕再小,都需要提供"可观测性"的出口。对 Rea 来说,trace 日志就是它的调试工具。你在复刻类似项目时,如果一开始就预留这个 trace 开关,会比事后补插 console.log 舒服得多。
6.3 手搓渲染引擎,最大的价值不是替代,而是拆掉一层黑盒
Rea 从头到尾没有被用于生产环境的打算,它也不会去替代我熟悉的成熟框架。它最大的意义在于,把"框架为什么要这样设计"这个问题变成了可以亲手验证的假设。我在改了几天代码之后,逐渐理解了大框架里很多约束的来源:为什么 hook 不能写在条件里,为什么 key 要用业务 id,为什么大量 props 更新时要批量处理,为什么事件绑定要注意解绑时机。
这些知识如果只是看文档,很容易看完就忘。但当你用一个 700 行的引擎把它们重新实现一遍时,每一个约束都变成了"不这样做就会出错"的自然结论。Rea 这个项目最后还被我扩展出了一个小功能:可插拔的 diff 记录器,在控制台里按组件树层级缩进打印更新路径。如果你也想做类似的迷你渲染引擎,我建议可以从 Rea 这条路径开始:先跑通挂载和 diff,再补 useState,最后才碰事件和属性归一化。每加一层,就离"看穿框架"更近一步。