最近有个朋友问我,天天用框架写页面,有没有想过框架底层到底是怎么把代码变成界面的。我被问得愣住,发现自己除了会写组件、会调接口,对内部机制只能说个大概。于是决定做一个实验项目:用最简代码手写一个前端渲染引擎,项目代号就叫 rea,全称是 Rendering Engine Architecture。这篇文章记录我从空目录开始,到能跑通一个 Todo 应用的全过程,包括架构设计、核心实现、踩过的坑和性能优化思路。如果你也想深入理解组件化框架的渲染原理,或者正在准备框架原理类的面试,这篇文章应该能给你一些不一样的视角。
1. 为什么要手写一个 rea:不是造轮子,是拆轮子
1.1 一个面试问题逼出来的实验项目
事情起源于一次模拟面试。对方问“你了解前端框架的渲染流程吗”,我当时脑海里瞬间冒出的是路由、状态管理、生命周期这些应用层概念,结果对方追问虚拟节点、diff、提交阶段时,我发现自己答得越来越虚。那种感觉很不好受——用了几年框架,居然连核心链路都描述不完整。
后来我复盘了一下,发现问题出在“用得太顺手”上。框架把所有复杂细节封装好了,我只管写模板和逻辑,底层到底怎么运行,长期处于黑盒状态。为了彻底把这层窗户纸捅破,我决定自己动手写一个微型渲染引擎,要求不高,但必须完整:能把组件描述转成虚拟节点,能把虚拟节点挂载成真实 DOM,状态变化后能重新渲染并更新界面。
项目代号就叫 rea。为什么叫这个?因为它的核心就是“Rendering Engine Architecture”,听起来挺唬人,实际上代码量控制得很小。我的目标是只写 1200 到 1500 行,并且不依赖任何上层框架,纯原生 JavaScript 实现。这个过程不追求替代任何生产级框架,而是原原本本地把渲染链路走通。
1.2 rea 的核心设计目标
动手之前,我给 rea 定了三个硬性目标。
第一,代码量必须可控。人一多代码就容易飘,一个学习项目如果膨胀到上万行,那就没有意义了。rea 的核心代码我只允许自己在 1500 行以内完成,逼自己用最简洁的方式表达核心机制。
第二,必须支持函数组件和状态更新。这是现代组件化框架的基础能力,如果只做一个静态的模板渲染器,那根本不叫引擎。要支持类似“定义一个函数,函数返回界面描述,当内部状态变化时界面自动更新”这样的开发模式。
第三,必须能跑通一个真实的小应用。我选了一个带输入框的 Todo 列表作为验收标准,它涵盖了列表渲染、事件绑定、输入更新、删除代办等常见场景,任何一个环节卡住都说明实现有问题。
事实证明,这三个目标帮了大忙。rea 没有落入“什么都想做”的泥潭,而是始终聚焦在最核心的渲染流程上。越到后面我越清楚,真正复杂的东西不是挂载、不是属性更新,而是“状态变化后如何高效地找出需要更新的部分”。
2. rea 的总体架构与渲染流水线
2.1 从组件描述到虚拟节点:JSX 不是魔法
在开始写代码之前,得先想清楚一个问题:用户写的组件代码是怎么变成可执行内容的?
如果你用过 JSX,你会感觉这是在 JavaScript 里直接写 HTML。但实际上,这块内容在编译阶段已经被转换成了函数调用。也就是说,<div id="app">hello</div>并不是浏览器原生认识的东西,而是被编译成了类似createElement('div', { id: 'app' }, 'hello')这样的代码。
rea 不打算依赖编译工具链,所以我直接在项目里手动调用自己的createElement函数。这个函数接收三个参数:元素类型、属性对象、子节点。它的返回值是一个普通的 JavaScript 对象,常被称为虚拟节点,也就是 vnode。虚拟节点并不真实存在于浏览器中,它只是对“我想在页面上呈现什么东西”的结构化描述。
这么绕一圈有什么好处?因为真实 DOM 操作是昂贵的。每次访问和修改 DOM 都可能引起浏览器重排重绘,如果频繁操作,页面性能会急剧下降。虚拟节点只是一层普通对象,创建和比较它的成本远低于真实 DOM。我们把“要变成什么样”先描述清楚,再一次性交给渲染器去更新真实 DOM,这样能大幅减少实际操作次数。
2.2 虚拟节点的数据结构设计
rea 中一个标准的虚拟节点结构被我设计成下面这样:
| 字段 | 类型 | 说明 |
|---|---|---|
| type | string / function | 元素类型,如 div、span,或者组件函数 |
| props | object | 该元素上的属性,如 id、class、onClick |
| children | array | 子节点数组,每个元素同样是一个虚拟节点 |
| key | string / number | 列表渲染时用于标识节点的唯一性 |
type 是整个虚拟节点的核心。如果 type 是字符串,说明这是一个原生 DOM 元素;如果 type 是函数,说明这是一个组件,渲染器需要先执行这个函数拿到更底层的虚拟节点,再继续处理。这种设计给了组件化能力最大的自由度,组件本质上就是虚拟节点的一层包装函数。
props 上除了常见属性,事件监听也会统一挂在里面。比如onClick这类以 on 开头的属性,在渲染时我会单独识别并绑定事件。props 的对比也是更新时最重要的环节,这一点后面会详细讲。
children 一定是数组,哪怕是单个文本节点,我也会统一包装成数组。这样遍历和对比的时候就不用关心边界情况。文本节点在 rea 里会被特殊处理,type 为字符串TEXT,真实值存在 props.nodeValue 里。
2.3 渲染器的三段式流程:render、patch、commit
整个渲染器的工作流程,我把它拆成三个阶段。
第一个阶段是 render,它的输入是一个组件定义和挂载点,输出是根虚拟节点树。这个过程会递归执行所有组件函数,直到把所有函数类型的节点都展开成原生 DOM 描述。第二个阶段是 patch,输入是旧的虚拟节点树和新的虚拟节点树,输出是一系列补丁操作。说白了就是比较两棵树之间的差异,得出“哪些地方需要移除、哪些地方需要新建、哪些地方只需要改属性”。第三个阶段是 commit,把补丁操作真实地应用到 DOM 上。
为什么要拆成三个阶段?这是非常关键的设计决策。如果我们边对比边操作 DOM,就会出现一种尴尬情况:对比到一半,前面已经改了真实 DOM,后面又发现需要回滚,或者由于 DOM 状态变化导致后续对比结果不可信。把“计算差异”和“应用差异”分开,保证了渲染过程是可控、可追踪的。
rea 的首次渲染会走完这三个阶段。后续更新时,render 阶段会被跳过,直接拿新旧虚拟节点树进入 patch 阶段。这就像一个装修队干活:先看图纸,再列清单,最后动手施工。三个阶段各有各的职责,代码结构也会清晰很多。
3. 核心实现:从 0 到 1 跑通 rea
3.1 实现 createElement:把配置变成节点
rea 的第一个函数是裸写出来的创建虚拟节点的工具。它做的事很简单,收拢参数,规范结构。
function createElement(type, props, ...children) { const flatChildren = children .flat() .map(child => child instanceof Object ? child : createTextElement(child) ); return { type, props: props || {}, children: flatChildren, key: props?.key ?? null }; } function createTextElement(text) { return { type: 'TEXT', props: { nodeValue: String(text), children: [] }, children: [], key: null }; }这段代码有两个容易被忽略的细节。
第一,children使用了展开运算符和flat()处理。因为在写调用代码时,可能出现嵌套数组的情况,例如createElement('div', null, list.map(item => createElement('span', null, item))),map 返回的是一个数组,如果不扁平化,后续遍历子节点时要多做很多边界判断。第二,纯字符串和数字子节点会被统一转成TEXT类型的虚拟节点,这样可以保证整棵虚拟节点树中每个节点都具备type、props、children、key这四个字段,后续处理逻辑可以统一。
3.2 实现 render:完成首次挂载
有了虚拟节点之后,第一个大任务是把它真实地渲染到页面上。render 函数负责的就是这个。
function render(vnode, container) { const dom = createDom(vnode); container.appendChild(dom); return dom; } function createDom(vnode) { let dom; if (vnode.type === 'TEXT') { dom = document.createTextNode(vnode.props.nodeValue); } else if (typeof vnode.type === 'string') { dom = document.createElement(vnode.type); updateProps(dom, vnode.props); } vnode.children.forEach(child => { dom.appendChild(createDom(child)); }); return dom; }createDom 是递归入口,遇到原生元素就创建 DOM 元素并设置属性,遇到文本节点就创建文本节点,然后继续遍历子节点。updateProps 里除了赋值常规属性,还要单独处理事件。
function updateProps(dom, nextProps) { for (const [key, value] of Object.entries(nextProps || {})) { if (key.startsWith('on')) { const eventType = key.slice(2).toLowerCase(); dom.removeEventListener(eventType, dom.__handlers?.[key]); dom.addEventListener(eventType, value); dom.__handlers = dom.__handlers || {}; dom.__handlers[key] = value; } else if (key === 'style') { Object.assign(dom.style, value); } else { dom.setAttribute(key, value); } } }这里处理事件时我踩了第一个坑:如果不先删除旧的事件监听,每次重新设置属性都会叠加一个新的监听函数,最终导致一个事件触发多次。上面的实现方式虽然简单,但已经规避了这个问题,用__handlers在 DOM 上记录旧监听器,赋值前先移除。
3.3 实现更新调度:从 setState 到重新渲染
rea 要支持组件状态,最朴素的实现是给每个组件实例配一个状态数组和一个更新函数。我参照常见的 useState 思路,用闭包保存状态,并把状态更新函数和渲染器关联起来。
let currentComponent = null; let hookIndex = 0; function useState(initialValue) { const component = currentComponent; const index = hookIndex; if (!component.hooks[index]) { component.hooks[index] = { value: initialValue }; } const setState = (newValue) => { const nextValue = typeof newValue === 'function' ? newValue(component.hooks[index].value) : newValue; if (Object.is(component.hooks[index].value, nextValue)) return; component.hooks[index].value = nextValue; scheduleRender(component); }; hookIndex++; return [component.hooks[index].value, setState]; }这个实现避开了复杂的优先级调度,走的是同步更新路线。每次 setState,组件节点会进入一个待更新队列,rea 在微任务中统一执行重新渲染。这样连续多次 setState 会被合并成一次渲染,这一点对性能非常关键。
我自己在实现时特意加了Object.is比较,这是为了符合主流框架里“状态没变就不重新渲染”的预期。如果没有这个判断,哪怕你设置了相同值,也会触发整棵树更新,白白浪费性能。
3.4 实现 diff 算法:最朴素的同级比较
更新渲染的核心在于 diff。rea 没有采用复杂的前端框架级调度策略,而是做了最朴素的双层比较:先比节点类型,再比属性。
function diff(oldNode, newNode, container, dom) { if (!newNode) { container.removeChild(dom); return null; } if (!oldNode) { return createDom(newNode); } if (oldNode.type !== newNode.type) { const newDom = createDom(newNode); container.replaceChild(newDom, dom); return newDom; } if (oldNode.type === 'TEXT' && oldNode.props.nodeValue !== newNode.props.nodeValue) { dom.nodeValue = newNode.props.nodeValue; } else if (typeof oldNode.type === 'string') { diffProps(dom, oldNode.props, newNode.props); } const oldChildren = oldNode.children; const newChildren = newNode.children; const maxLength = Math.max(oldChildren.length, newChildren.length); for (let i = 0; i < maxLength; i++) { diff( oldChildren[i], newChildren[i], dom, dom.childNodes[i] ); } return dom; }先判断节点是否存在,再判断类型是否相同。类型不同说明整个节点要替换,直接建新 DOM 替换旧 DOM,不递归子节点。如果类型相同,则继续比较属性和子节点。diffProps 会遍历新旧属性,删掉不存在的新属性,更新已存在的属性,并增加新增属性。
这版 diff 算法有一个显著特点:它假设同一下标位置的节点是同一个节点,没有做 key 匹配。后面我会说明为什么这个假设在某些场景下会引来麻烦。
4. 踩坑实录:调试 rea 时遇到的问题
4.1 没有 key 导致列表状态错乱
rea 第一版跑 Todo 应用时,出现了非常诡异的现象。第一项是“写代码”,第二项是“写文档”。删除“写文档”之后,界面上 “写代码”确实没了,但输入框里的内容却跑到了另一项上。直觉告诉我,这是 diff 算法在列表场景下的缺陷。
原因是我的 diff 是按下标比较的。原来列表是 A、B 两个节点,删除 B 后新列表是 A。理论上 A 没变,只需要删除 B 的 DOM。但如果没有 key 的辅助,diff 会把“新的 A”与“旧的 A”比较,这没问题。真正的问题出在列表项带有内部状态时,比如每个 Todo 组件的输入框有自己的值。如果删除的是中间项,所有后续项的 DOM 不会被重新创建,但组件内的状态可能还停留在旧位置上,就会出现状态错位。
后来我给 diff 加了一层 key 判断:同级比较时,如果新旧节点的 key 都存在且不相同,就只做“移动位置”操作,而不是简单复用下标对应的 DOM。这个改动让列表渲染稳定了很多。
4.2 事件监听的重复绑定问题
第一次实现 updateProps 时,我天真地直接遍历新属性并 addEventListener,结果连续点击按钮时,事件处理函数被执行了两次。排查后发现是每次刷新都会重新绑定,旧监听器没有被移除。
解决办法就是用新监听器替换旧监听器,本质上是先 remove 再 add。如果你也打算手写类似玩意,建议一开始就把__handlers记录在 DOM 对象上,而不是放在闭包里。因为 DOM 对象可以和虚拟节点一一对应,闭包反而容易忘记清理。监听器泄漏这个问题非常隐蔽,任务管理器里看不出个所以然,但页面长时间运行后内存会一路往上飙,最终卡顿到怀疑人生。
4.3 不可变数据的重要性:为什么我的组件不更新
还有一个让我排查了两小时的坑。我在某个组件里写了一个toggleTodo方法,直接修改了数组中的某个对象属性,然后调用 setState 传入修改后的数组。结果界面完全没有反应。控制台打印数组,值确实变了,组件就是不刷新。
问题出在我在 setState 里加了浅比较。比较的时候用Object.is判断新旧状态是否相等。直接修改数组元素不会改变数组引用,所以Object.is(oldArr, newArr)返回了 true,渲染被直接跳过了。
原因清楚了:状态更新时必须创建新的数组或对象引用。哪怕只是改一个对象的属性,也应该返回一个新的数组。这个经验很有代表性,几乎所有人第一次写这类引擎时都会踩到。所以 rea 的源码里会在 README 加一条显而易见的提醒:不要原地修改状态。
4.4 effect 清理与内存泄漏
还差一个容易被忽略的细节:副作用函数。rea 里我实现了一个极简的 useEffect 版本,它接收一个回调函数,渲染完成后执行。如果回调函数返回一个清理函数,在组件下一次渲染之前必须先执行清理。
如果忽略了清理这一步,比如在 useEffect 里绑定了全局事件监听、创建了定时器,组件卸载后这些资源会一直挂在浏览器里。rea 的处理方式是:在删除组件对应的 DOM 之前,先把组件实例上记录的清理函数全部执行一遍,再移除节点。这个逻辑写起来就几行,但少写了就是内存泄漏的根源。
5. 对 rea 做基础性能优化:少做事,才能快
5.1 减少不必要的虚拟节点创建
rea 处于早期版本时,每次 setState 都会把整个组件树重新展开一遍。比如一个列表组件,任何一项的状态变一下,整个列表的所有虚拟节点都要重新创建一遍。这样的好处是实现简单,坏处是当节点数量大的时候,diff 的开销会变得不可忽略。
要优化,第一步是减少“无效工作”。我引入了记忆化机制:当组件的 props 没有变化时,直接复用上次生成的虚拟节点树。具体做法还是浅比较,props 的所有字段值都相同,就用旧的虚拟节点。这在日常场景中能省下不少创建对象的开销。
另一个优化点是延迟展开:组件函数不一定要在创建虚拟节点时立刻执行。rea 可以先把函数节点保留在虚拟树中,等 diff 到它时才去执行组件函数,取到它真正的子虚拟节点。这样没变化的分支连展开都不会发生。
5.2 批量合并状态更新
rea 有一个同步调度器:每次 setState 把组件放进一个队列,然后用 Promise.resolve().then 触发一次统一渲染。
这意味着同一个任务里连续调用了三次 setState,也只会产生一次最终渲染,而不是三次渲染。用户永远不会看到中间状态,因为中间状态根本不会被绘制。
这个 trick 非常实用,也很容易实现。不过要注意一个边界:如果新状态依赖旧状态,例如需要把计数器累加三次,直接传原始值只能看到最后一次结果。解决方法是让 setState 支持传入函数,拿到上一次状态再计算新状态。这一点我的实现里已经包含了,但是刚开始漏掉的时候,测试用例怎么跑都是错的。
章节到这里,rea 已经从一个空洞的代号变成了一个能跑、能更新、有基本 diff、有状态管理的迷你前端引擎。虽然它的性能和工业级框架相距甚远,但作为学习项目,恰恰是因为简单,所有关键路径都可以一眼看到底。
6. 一点后续打算:rea 还能往哪里走
rea 当前版本只做到了同步渲染,没有时间切片,没有优先级调度,也没有暂停恢复的能力。如果继续做下去,我计划把虚拟 DOM 树改成链表结构,让渲染过程可以被中断。这个方向就是静态的架构升级,直接增加“可恢复性”。
另一个方向是支持 Portal,让某个组件的内容可以渲染到页面其他位置,而不是严格跟着组件树走。这个用到的地方不多,但对理解“渲染结果和组件树解耦”特别有帮助。
最后想说的是,rea 这个项目给我最大的收获不是学会了怎么写渲染器,而是明白了框架里的每一个设计背后都有其存在的理由。比如 key 机制、不可变数据、批量更新、生命周期清理,这些看似零散的知识点,在全栈实现一遍之后才能真正串起来。如果你也想试试,建议从最朴素的版本开始,千万别一上来就追求 Fiber 级别的调度。写个能跑的、简单的,再一步步加优化,过程中的体验会完全不同。