
1. 为什么一个被框架黑盒折磨的开发者要手搓一个 ReaRea 这个名字听起来很随意它确实就是某天我在工位上随手敲出来的三个字母。那阵子我在维护一个内部工具的数据面板页面里的表格组件在切换筛选条件后状态和界面老是错位某个输入框的内容跑到另一行里明明选中了一项下拉框却显示成前一项。我习惯性地打开调试工具想顺着调用栈往下追结果栈里全是某个大型视图框架内部的调度帧一层套一层业务代码被埋在最底下断点根本落不到我自己的逻辑上。那个晚上我给自己下了一个定义与其继续猜黑盒不如亲手写一个能一行行看完的最小渲染引擎。这个决定出来之后我给项目定了三条硬指标。第一总代码量控制在 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 idboxhello/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 与 keyRea 在不做 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 变成spanRea 直接创建新 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 更新成 CB 的 DOM 更新成 AC 的 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 divempty/div; } const [count, setCount] useState(0); const [name, setName] useState(); return div{count} {name}/div; }第一次渲染时show为 false组件直接 returnuseState一个都没执行到hooks 数组为空。第二次渲染时show变成 true组件开始执行后面两个 useStatehookCursor从 0 开始第一个 useState 拿到的是空数组里的新 slot第二个也是新 slot看起来没问题。但如果你反过来第一次渲染 show 为 true两个 useState 都执行了然后 show 变成 false组件直接 return再变回 true 时第一个 useState 拿到的还是老位置的值第二个也是。表面没问题。真正出问题的场景是条件语句改变 hook 之间顺序。比如 show 为 true 时执行了 useState A、useState Bshow 变为 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 c1)后又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 内部处理说明classNamebtndom.setAttribute(class, btn)直接赋给 class 属性style{{color:red}}逐条写入dom.style.color对象形式不做字符串拆分stylecolor: reddom.style.cssText ...字符串形式原样设置disabled{true}dom.disabled true布尔属性直接赋值htmlForinputdom.htmlFor inputlabel 的 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] typeli key2 props update: { title: 买菜 } [Rea][diff] typeli key3 type changed: li - div [Rea][mount] create input key3 [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最后才碰事件和属性归一化。每加一层就离看穿框架更近一步。