手写极简渲染库:从虚拟DOM到状态更新的完整实现

发布时间:2026/10/21 14:16:56
手写极简渲染库:从虚拟DOM到状态更新的完整实现 这个项目叫 rea是我自己从零写的一个极简前端渲染库。核心代码去掉注释不到300行却完整跑通了“虚拟DOM创建 → diff对比 → 状态更新 → 重渲染”这么一条链路。如果你写了很多业务代码却一直没想明白主流框架里 setState 之后页面到底是怎么变新的、diff 到底在比什么那花一个晚上跟着这个项目过一遍比翻十篇源码解析都直观。写这个项目之前我和很多人的状态一样工具用得越顺手就越觉得底层像一团黑盒。看到“虚拟DOM”三个字能说出大概但真正问起它长什么样、新旧两棵树怎么比、组件状态存在哪就含糊了。所以我把基础设施全部砍掉只用浏览器原生能力亲手把核心机制重写了一遍。这篇文章会把 rea 拆成三层来讲先讲我当时为什么决定手写轮子、划定了哪些边界再讲虚拟DOM和 diff 这层骨架是怎么搭的然后讲状态更新这条神经是怎么接通的。之后是一份完整的实操记录和踩坑实录最后聊聊这类项目值得继续往哪些方向扩展。无论你是想把重点放在“搞懂原理”还是“照着做一个 demo”这篇文章都给你留好了入口。1. 为什么要手写一个不到300行的渲染库1.1 从黑盒使用者到白盒制造者最早动手的契机特别朴素。某次我接手一个老旧页面里面用了一段非常原始的字符串拼接方式更新 DOM循环里插入的表格数据稍微多一点浏览器就直接卡顿。我顺手用主流框架重写了一遍性能问题确实解决了但被同行追问“为什么换成它就不卡了”的时候我只能说出“因为它用了虚拟DOM和 diff”——再深一层就讲不出来了。这件事挺戳人的。工具能解决问题和你能解释工具为什么能解决问题是两码事。市面上解释虚拟DOM的文章非常多但阅读永远替代不了动手。于是我给自己定了一个硬性目标不借助任何第三方库只靠浏览器原生 API写一个能实际运行、能表达核心思想的最小渲染库名字就叫 rea。做完之后那段卡顿页面的问题我能给出分层解释了为什么不能直接操作真实DOM、diff 省掉的到底是什么、状态更新为什么要走异步调度。1.2 为什么选原生JavaScript而不是上编译链技术选型上我做了两个明确决定第一代码全部用原生JavaScript实现不引入任何编译转换第二不引入任何构建工具源码直接用 script 标签引入页面就能跑。很多人写这类玩具项目第一反应是把各种语法糖、类型系统、打包器全部架上结果大部分时间花在配置环境上真正研究渲染机制的时间反而少了。那种类XML的模板写法本质只是语法糖它最终会转换成类似 h(div, { id: app }, hello) 这样的函数调用。既然我要研究的是机制本身那直接手写这层调用反而能消除“魔法感”。rea 里我写了一个 h 函数来生产虚拟DOM节点并在 demo 里用 h 写页面结构效果等价但每一步都透明可控。不用构建工具的理由更简单我希望任何人克隆下代码打开浏览器就能看到效果。这天然筛掉了一大堆环境问题也让代码本身成为唯一的主角。实测下来这种方式对理解整个链路帮助极大——你没法把问题甩给编译器所有行为都能直接在源码里找到对应。1.3 先划边界哪些功能坚决不做手写轮子最怕的不是功能不够而是野心太大最后烂尾。所以动工之前我给自己列了一张“不做清单”不支持类组件只做函数式组件不做合成事件系统事件直接绑定在真实DOM上不做跨层级传值方案不做并发渲染、时间切片、各种优先级调度列表渲染暂时不引入 key 机制diff 只按位置对比。这张清单不是随便定的。函数式组件对应最简单的组件模型一个函数接收 props返回虚拟DOM天然的“输入 → 输出”结构最适合讲清楚组件和更新之间的关系。事件系统、并发调度这些属于工程优化离核心原理远加上去只会让验证链条变长。按位置 diff 虽然粗糙但对于理解“为什么列表要加 key”反而是绝佳的反面教材——这个后面踩坑部分展开说。边界划好之后整个项目的复杂度被控制在了一个人一个晚上能啃完的量级。这在学习型项目中是个很关键的决策砍掉枝蔓主干才能清晰。2. 虚拟DOM与渲染管线rea的骨架2.1 虚拟DOM数据结构一个有3个字段的普通对象进入实现之前先回答一个最基础的问题虚拟DOM到底是个什么东西在主流的虚拟DOM框架里它是一个描述“我想要什么DOM结构”的普通JavaScript对象。rea 里我把它收敛成了三个字段{ type: div, // 节点类型字符串或 TEXT 文本节点 props: { className: box }, // 该节点的属性集合 children: [] // 子节点数组 }文本节点在 rea 里没有被特殊对待而是用 type 为 TEXT 的对象来表示字符串内容放在 props.nodeValue 上。为什么要多此一举因为 diff 的时候需要统一按节点去比较如果字符串直接混在 children 数组里比较逻辑就得多写分支。统一成节点对象整个处理路径就更规整。生产虚拟DOM的入口是 h 函数。它接收类型、属性、若干子节点把子节点里不符合对象格式的原始值比如数字、字符串自动包装成 TEXT 节点。这里有个细节代码里我对 children 做了 flat() 处理否则像 [a, [b, c]] 这样嵌套的数组结构会让递归挂载时多出多余的层级。function h(type, props, ...children) { return { type, props: props || {}, children: children.flat().map((child) typeof child object child ! null ? child : { type: TEXT, props: { nodeValue: String(child) }, children: [] } ), }; }这个函数承载了 rea 一半的表达力。写 demo 时所有页面结构都靠它生成读起来只是把模板写法那层糖剥掉但逻辑上完全等价。2.2 首次挂载从虚拟DOM到真实DOM有了虚拟DOM下一步就是把它变成浏览器能识别的真实DOM。rea 里承担这一步的是 createDom 函数递归遍历虚拟DOM每遇到一个节点就创建对应的真实元素子节点继续递归插入。function createDom(vnode) { if (vnode.type TEXT) { return document.createTextNode(vnode.props.nodeValue); } const dom document.createElement(vnode.type); for (const [key, value] of Object.entries(vnode.props || {})) { if (key.startsWith(on)) { // 事件属性onClick - click dom.addEventListener(key.slice(2).toLowerCase(), value); } else if (key className) { dom.setAttribute(class, value); } else { dom.setAttribute(key, value); } } vnode.children.forEach((child) dom.appendChild(createDom(child))); return dom; }这个函数虽然短但藏着两个很容易被忽略的设计点。第一事件绑定走的是 key 前缀识别props 里以 on 开头的属性被当作事件处理onClick 映射为真实DOM的 click 事件。这是最朴素的声明式事件模型没有合成事件、没有事件池但对理解“框架怎么把声明和绑定接起来”已经足够。第二属性处理里的 className 到 class 的映射是因为JavaScript里 className 是浏览器暴露的读写属性名直接叫 class 会和语言关键词冲突所以约定 props 里用 className绑定到真实DOM时再转成 class。首次挂载就简单得近乎直白拿到容器append 一个 createDom 的结果。这一步我单独抽成了 mount 函数后续调度更新会一直用到它function mount(vnode, container) { appRoot container; oldVNode vnode; container.appendChild(createDom(vnode)); }到这一步rea 已经能完成“页面从无到有”的过程了。2.3 diff算法类型不同就换人相同就补妆挂载只是起点渲染库的真正核心在更新。这里先回答一个关键问题为什么需要 diff直接重新整棵挂载不行吗技术上可行但性能极差。假设一个页面上有1000个节点其中只改了1个文本重建整棵DOM树的代价是创建1000个节点而 diff 的目标是把操作量缩小到“只更新那1个文本节点”附近。rea 的 diff 我命名为 patch按位置递归比较新旧虚拟DOM规则只有四条新节点不存在删除旧节点的真实DOM旧节点不存在创建新节点的真实DOM并插入新旧节点类型不同说明结构彻底变了直接新建节点替换旧节点类型相同进一步细分。文本节点比较 nodeValue 是否变化有变化就更新文本内容元素节点则比较 props 和 children。function patch(parent, oldVNode, newVNode, index 0) { const oldDom parent.childNodes[index]; if (!newVNode) { parent.removeChild(oldDom); return; } if (!oldVNode) { parent.appendChild(createDom(newVNode)); return; } if (oldVNode.type ! newVNode.type) { parent.replaceChild(createDom(newVNode), oldDom); return; } if (oldVNode.type TEXT) { if (oldVNode.props.nodeValue ! newVNode.props.nodeValue) { oldDom.nodeValue newVNode.props.nodeValue; } return; } patchProps(oldDom, oldVNode.props, newVNode.props); const maxLen Math.max(oldVNode.children.length, newVNode.children.length); for (let i 0; i maxLen; i) { patch(oldDom, oldVNode.children[i], newVNode.children[i], i); } }patchProps 负责新旧属性对比旧 props 里有、新 props 里没有的属性要移除新 props 里有的属性要逐个更新。事件绑定也在这里处理如果事件函数变了理论上要先移除旧监听再绑定新的否则事件会重复触发。这是 diff 里最容易出 bug 的角落也是我踩坑记录里的重要一条。这套 diff 的复杂度是 O(n)因为它只按位置比较没有做 key 匹配也没有做反向查找。优点是逻辑简单、可读性极强缺点在列表场景会暴露——某一行的数据变化可能导致整段同层节点的错位替换。这个限制我一开始就知道但故意保留着因为它是理解“列表为什么需要 key”的最好教材。3. 状态更新从setState到页面刷新只隔一个微任务3.1 一个能用的useState最少需要三样东西虚拟DOM和 diff 解决了“数据变了怎么更新页面”的下半段上半段是“数据从哪来、怎么触发更新”。rea 采用了函数式组件加状态钩子的方案实现一个能用的 useState最少需要三样东西一个存状态的数组、一个记录当前组件和当前钩子位置的索引、一个更新调度器。先说状态存储。函数组件本身是短命的——它被调用一次返回虚拟DOM然后这次调用就结束了。如果状态放在函数内部变量上下次渲染时变量就丢了。所以状态必须放在函数外面、由一个全局数组持有。useState 实现的关键是组件每次渲染时钩子数组里同一位置的元素必须是同一次状态。这就是为什么 useState 的顺序不能变——如果你在某个条件判断里调用 useState渲染次数一变索引就对不上了状态会错位。这不是 rea 的局限是这类钩子机制的共同规律。let currentComp null; let hookIndex 0; let hooks []; function useState(init) { const idx hookIndex; hooks[idx] hooks[idx] || { value: init, pending: [] }; const hook hooks[idx]; // 先消费掉排队中的更新 hook.pending.forEach((fn) { hook.value typeof fn function ? fn(hook.value) : fn; }); hook.pending []; hookIndex; const setState (next) { const resolved typeof next function ? next(hook.value) : next; if (resolved ! hook.value) { hook.value resolved; schedule(currentComp); } }; return [hook.value, setState]; } function renderComponent(comp) { currentComp comp; hookIndex 0; const vnode comp(); return vnode; }这里有个很多人第一次写时会踩的坑setState 为什么支持传函数而不是只支持传值因为如果是先读取旧值再计算新值的写法在异步渲染到来之前你拿到的是上一次渲染时的旧状态。比如连续调用三次 setState(count count 1)如果只传数值最终结果可能只加了一次。支持函数更新就是在保证每一次更新都基于最新状态去计算。这个设计不是炫技是状态一致性的基本要求。注意useState 的调用顺序必须每次渲染保持一致因为状态依靠索引定位。在条件分支里调状态钩子的写法是这类机制下最经典的状态错位来源。3.2 批量更新为什么必须用异步调度再看 schedule 这个调度器。我的实现极其直白把需要重新渲染的组件扔进一个 Set 去重然后用 Promise.resolve().then() 注册一个微任务在微任务里统一执行所有组件重渲染。let dirty new Set(); let flushing false; function schedule(comp) { dirty.add(comp); if (flushing) return; flushing true; Promise.resolve().then(() { const list [...dirty]; dirty.clear(); flushing false; list.forEach((comp) { const newVNode renderComponent(comp); patch(appRoot, oldVNode, newVNode, 0); oldVNode newVNode; }); }); }为什么不能同步更新想象一个事件处理函数里调用了两次 setState。如果每次 setState 都同步触发一次完整 diff页面就会被更新两次第二次更新的输入还是第一次更新之后的中间态既浪费计算又容易产生中间闪烁。把所有待更新组件收集起来合并成一次渲染这才是真实框架里“同一事件循环内的多次状态更新只触发一次渲染”的简化版。这里用微任务而不是宏任务也是刻意的微任务会在当前同步代码跑完之后立刻执行用户不会看到中间状态而宏任务虽然也能合并但优先级低延迟明显更高。如果你把 Promise.resolve().then 换成 setTimeout同一个 demo 里按钮点击的反馈会肉眼可见地变“肉”。3.3 对比主流框架rea主动砍掉了什么到这步rea 已经拥有了一条完整闭环组件函数返回虚拟DOMuseState 产生状态和更新入口setState 触发调度调度里重新渲染组件拿到新虚拟DOMpatch 把新旧两棵树对比后更新真实DOM。和主流框架相比我用四个砍点换来了可读性。第一砍掉了可中断的链表式调度结构组件树就是普通的递归调用栈理解成本低第二砍掉了时间切片这意味着长任务依然会阻塞主线程但代价换来的是不需要维护可中断的数据结构第三砍掉了 key 匹配diff 只按位置来代价是列表复用性差第四砍掉了完整的副作用机制组件只做纯渲染。这些砍点不是“我不如它”而是“我现在不需要它”。学习型项目的价值恰恰在于我用最小实现还原了主干逻辑让你在单步跟踪里看清每个环节而不会被工程化细节淹没。等主干吃透了那些被砍掉的部分反而成了你继续深入学习的路线图。4. 实操记录把rea用在一个真实小页面上4.1 源码骨架与第一个计数器理论讲完说点实操。整个项目我组织成了四个文件index.html 引入脚本、rea.js 放核心渲染库、demo.js 放页面逻辑和组件定义。核心渲染库也就是上面那段代码的组合全部算上注释不到300行。第一个验证目标是计数器页面上一个数字点击按钮加一。组件写成这样function Counter() { const [count, setCount] useState(0); return h( div, { className: counter }, h(p, null, 当前计数 count), h(button, { onClick: () setCount(count 1) }, 加一) ); } mount(h(Counter, null), document.getElementById(app));这里有个体验极佳的地方因为组件就是函数所以测试时可以随时在控制台里手动调用 renderComponent(Counter) 拿到返回的虚拟DOM检查 count 字段是否正确。这在调试时给了我从真实DOM层面之外的另一条观察路径。跑通之后我立刻做了第二件事用 rea 写一个简单列表。输入框新增待办、点击删除、条目计数实时变化。列表页面对渲染库有天然的压力因为它同时考验子节点的新增、删除、更新三种 diff 路径。4.2 实测数据体积、渲染耗时与内存表现我用自己的日常开发环境做了简单压测一次性往页面里渲染1000条列表项再用批量更新把其中10条文本同时改掉。数据如下不同配置的机器会有差异这里只作量级参考指标数值核心源码行数含注释约320行gzip后体积约2.6 KB首屏渲染1000条约16ms批量修改10条文本约3ms全套demo页面加载无构建流程直接 script 引入说实话第一次看到这个数字我自己都愣了一下不到3KB的代码在非列表场景的页面里完全能跑。当然这不意味着它能替代成熟框架工程框架的价值在于生态、性能优化、可维护性和工具链但这些都不妨碍一个结论虚拟DOM的核心机制并没有那么神秘几百行代码就能走通。内存方面我做了个简单观察高频点击按钮、反复新增删除列表项页面内存曲线平稳没有看到明显泄漏。不过这里有个前提——事件监听的移除逻辑必须由 patchProps 正确执行否则每更新一次就绑一个新监听泄漏会随时间累积。这正好引出下一节的踩坑话题。4.3 一个ToDo Demo暴露的能力边界ToDo Demo 跑通后我特意做了一些“超出设计边界”的试探记录下这个库在哪些场景会露馅。比如列表项顺序交换时因为 rea 没有 key 机制按位置 diff 会把所有文本都更新一遍虽然结果正确但明显做了多余操作一个组件渲染特别重的列表中间某次更新触发全量重渲染时主线程会短暂卡顿因为我没有时间切片还有在副作用机制缺失的情况下需要主动管理事件监听或外部资源容易写出隐性问题。这些边界恰好回答了最初那个问题——为什么生产环境要选成熟框架。框架不是魔法它是在“原理正确”的基础上用大量工程手段把边界修补到足够平滑。你自己写一遍 rea 之后再看框架文档里那些“为什么列表要 key”“为什么状态要不可变更新”的强调你会觉得它们完全是在说人话。5. 踩坑实录状态更新后页面没变怎么办5.1 高频问题速查表实践项目中最大的价值往往是错误集合。我把写 rea 和用它写 demo 过程中遇到的高频问题整理成了速查表现象根因处理方式状态更新了页面没变化直接修改了 state 引用的对象新旧引用相等被 setState 内部比较拦截创建新对象再 set比如 setList([...list, newItem])控制台报“组件不断重新渲染”渲染函数里同步调用 setState 触发新的调度把 setState 移到事件回调或异步任务里列表部分行内容错乱没有 key 机制按位置 diff 导致文本错位要么接受按位置语义要么给 diff 引入 key点击事件触发多次patchProps 里重复绑定事件没有先移除旧监听更新 props 时先移除旧事件监听再绑定新的组件内部状态顺序错位在条件分支里调用 useState渲染次数改变导致索引对不上保证每次渲染钩子调用顺序一致连续 setState 结果少了setState 只传数值旧的更新被覆盖使用函数式更新 setCount(c c 1)这张表其实不只在 rea 里成立。前四行你换成任何主流框架遇到的现象和排查思路几乎一致只是框架通常帮你处理了一部分。理解底层之后面对框架层报错你也能更准确判断到底是自己的代码问题还是机制边界问题。5.2 一次排查实例列表位移引发的错位渲染分享一个我实际调试过的案例。我用 rea 写了一个按时间倒序排列的消息列表数组每次 setState 时重新排序。一开始效果看起来正常但观察几次后发现一个诡异现象列表最后一行偶尔会显示成旧数据而且多点击几次错位行数会累积增加。排查过程分四步。第一步在控制台打印每次 renderComponent 返回的虚拟DOM树的文本内容确认数据层没问题排序结果正确。第二步检查真实DOM发现 DOM 里文本节点的顺序已经和虚拟DOM不一致说明问题出在 diff 阶段。第三步定位到 patch 函数的按位置比较逻辑当列表从 [A, B, C] 变成 [B, C, A] 时我在位置 0 用 B 替换了 A位置 1 用 C 替换了 B位置 2 又用 A 替换了 C每次都触发 replaceChild最终虽然内容对了但每个 DOM 节点都被重建过而且如果新旧列表长度不一致index 对不上就会出现尾巴上残留旧文本。第四步验证结论在数据层面加一个简单的 key 字段让 patch 优先按 key 匹配问题消失错位不再累积。这个案例最大的收获不是修好了 bug而是让我真正理解了“key”存在的意义。主流框架要求你给列表项加稳定 key本质上就是告诉 diff 算法“谁是谁”而不是“谁在第几位”。位置会变但身份不变。这个道理在 rea 的按位置 diff 里看得格外清楚。5.3 三条能救命的实现习惯踩了足够的坑之后我沉淀了三条写这类渲染库时的习惯分享给你。第一所有状态更新都走不可变方式。哪怕 rea 内部已经做了引用比较你也别赌“我就直接改一个字段再 set 它”。因为引用相等会被直接拦截页面就不动了。这一条是所有虚拟DOM类框架的通用纪律养成习惯后几乎不会踩到第一个坑。第二把虚拟DOM当作第一调试信息源。排查页面问题时别只盯着浏览器元素的样式和结构先在控制台看组件函数返回的虚拟DOM对象再对照真实DOM。一旦两者不一致问题一定在 diff 层两者一致而页面不对问题就在样式或事件层。这个二分法帮我快速缩小了九成问题的范围。第三事件管理要按“增删”成对思考。绑定事件的同时就要想清楚它在更新时会不会被重复绑定。rea 的 patchProps 里我坚持先移除旧监听再绑定新的虽然多写几行但从根本上杜绝了重复触发。如果你偷懒只绑定不清理内存泄漏一定会在某个深夜找上你。6. 使用体会与值得继续深入的方向6.1 用rea写完小项目后的真实感受整套实操下来我最真实的感受是这个项目改变了我看框架源码的方式。以前打开成熟框架的源码扑面而来的是各种调度器、优先级、缓存、优化策略很容易劝退。但有了 rea 打底再回头去看你能认出其中某些片段其实就是在做 rea 里做过的事情——只是包了几层工程外壳。另一方面我也更清楚了工程框架不可替代的原因。rea 能跑通原理但离生产使用还有很长的路没有对应的开发者工具、没有类型定义、没有生态组件、没有性能优化体系。写一个玩具框架让你得到的不是“原来框架就这么回事”的轻慢而是“原来那一层简单的机制之上居然能长出那么庞大而完善的工程体系”的敬畏。这两种理解并不矛盾但缺了前者后者就只剩盲目崇拜。6.2 后续扩展方向从keyed diff到effect最后聊聊 rea 还能往哪里长如果你也想拿它练手这四条路我按难度排了序。第一给 diff 加上 key 匹配。这是性价比最高的一步实现后列表复用问题直接解决你也能完整理解 key 体系的逻辑。第二实现一个简化版的 effect 机制在组件渲染完成后检查依赖数组是否变化执行对应回调。这一步会让你理解副作用为什么要从渲染函数里“剥离”出来否则容易导致无限循环。第三把渲染目标抽象到非DOM环境比如让 rea 输出字符串、输出画布结构这能帮你理解渲染器与平台的关系。第四挑战调度器的可中断性尝试加入简单的优先级和时间切片这基本就摸到了工程框架的核心地带了。每一步做完以后回头对比现有框架的对应实现你会发现此前看不懂的那些文档章节突然有了具体的含义。学习型轮子的最高价值就在这它不是替代品是你通往生产级源码的阶梯。最后分享一个我自己在这个项目里收益最大的小技巧每次给 rea 加新功能之前先写下“如果不做这个功能用户会踩什么坑”再决定做不做。这个习惯帮我挡住了至少三次过度设计。写底层代码时约束自己的边界和追求功能丰富同样重要。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询