
做前端这些年如果要我选一个最值得拿出来单独拆开讲的底层概念排第一的应该就是 VNode也就是虚拟节点。我们平时写模板、写渲染函数最后都是靠一套 VNode 结构和相应的处理逻辑把界面应该长什么样翻译成真实 DOM 要变成什么样。很多同学觉得 VNode 不就是一个描述 UI 的普通对象吗没什么好看的但真到了手写渲染器、排查列表更新错乱、优化页面卡顿的时候VNode 设计得合不合理直接决定你是省心还是天天救火。这篇文章我打算顺着一条完整的链路聊先讲为什么非要有虚拟节点再拆一个 VNode 内部到底有哪些字段然后看模板和渲染函数怎么生成它接着走一遍从 VNode 到真实 DOM 的挂载和更新流程最后聊几个设计取舍和我实际踩过的坑。适合刚入门、想从会用模板往前走一步的同学也适合工作了一两年、开始啃框架源码但被虚拟 DOM 一堆概念绕晕的人。我尽量用真实项目里遇到的例子来讲不搬官方文档那套。1. 为什么我们需要 VNode先算清这账1.1 手写 DOM 操作时代的那笔烂账早几年做前端尤其是不用任何框架的时候我们都是直接操作真实 DOM 的。页面上要加一行数据就得自己document.createElement、appendChild要更新表格里的数字得先querySelector找到那个节点再改textContent要监听事件还得记得在节点销毁时把事件解绑否则轻则内存泄漏重则事件重叠触发。这套写法最大的问题不是麻烦而是状态和界面的一致性完全靠人肉保证。比如一个订单列表页既有列表本身又有底部的总金额统计还有顶部未读数量角标——同一个数据源散落在三四个地方。你改了数组得记得手动刷新列表、重算金额、更新角标漏掉一个界面就错了。这种代码跑起来没什么技术含量但维护起来非常痛苦改一个需求要沿着 DOM 操作点反复排查。1.2 把界面变成一份可以计算的数据VNode 的核心思路其实一句话把界面描述从真实 DOM 对象改成普通 JavaScript 对象。一个 div 不再是一坨带几十个方法和内部状态的浏览器节点而是一个轻量的数据对象const vnode { type: div, props: { class: card }, children: [ { type: h3, props: null, children: 订单总览 }, { type: span, props: null, children: 128 } ] }这份对象是纯数据可以被创建、比较、序列化也可以被不同的渲染端消化。你可以把它理解成装修图纸图纸上画好了哪个位置放柜子、水电位在哪你不需要为了看新方案真的把墙砸了再砌。改图纸的成本接近于零改动真实墙体的成本则高得多。真实 DOM 的创建、插入、属性更新都会引发样式计算、布局、绘制来回折腾很贵VNode 就是个普通对象改它几乎不花钱。一旦 UI 变成数据很多问题就自然解开了。状态变了就重新生成一份新的 VNode 树新旧两棵树做比较找出差异再精确地落到真实 DOM 上。开发者只需要声明数据长什么样界面就长什么样至于怎么更新、更新哪些节点由框架内部完成。1.3 数据驱动的更新闭环是怎么转起来的日常开发里我们的代码路径基本是这样一圈循环状态变化用户点击、接口返回、定时器触发重新执行渲染逻辑生成一棵新的 VNode 树新树和旧树做同级对比diff只把差异部分同步到真实 DOM组件实例记录新的 VNode等待下一次变化这个闭环里VNode 是绝对核心的中间载体。没有它框架就不知道哪些地方变了只能粗暴地整个页面重建有了它框架才能做到精准更新。当然生成树和对比树本身也有成本所以 VNode 结构的设计、diff 算法的策略直接决定了这套机制的上限。2. 一个 VNode 的内部结构字段逐项拆解2.1 核心字段类型、属性、子节点先看一个典型的元素节点 VNode 会包含哪些东西。我不会照搬某个框架的结构而是按要做成这件事最少需要哪些字段来推字段用途说明type节点类型元素节点存标签名文本节点存特殊标记组件节点存组件定义props属性集合把 class、style、事件、普通属性统一放一个对象里children子节点可能是数组、字符串文本、对象单个 VNode或插槽函数key列表复用标识兄弟节点间唯一diff 时用来判断是否同一个人shapeFlag形态标记用位标记快速知道节点类型和 children 形态el真实 DOM 引用节点挂载后回填后续更新、移动都靠它type和props好理解我重点讲后面几个。children是最容易出幺蛾子的字段因为它的形态实在太多。你写divhello/divchildren 是文本写divspan1/spanspan2/span/divchildren 就是数组写Compsome content/Compchildren 还可能是插槽函数。设计 VNode 时如果不把这些形态收拢后面挂载和 diff 的代码会写得非常难看。2.2 为什么文本节点也要单独存在很多人会忽略文本节点觉得直接往父节点里塞字符串不就行了。但真实框架里文本通常也会被包装成一个 VNode原因是统一性。渲染器处理节点时希望面对的是一个又一个 VNode而不是这里可能是 VNode那里可能是字符串的混沌状态。文本节点一般长这样const textVNode { type: Symbol(text), // 用特殊标记区分 props: null, children: 你好虚拟节点 }挂载它的时候直接document.createTextNode(text)注释节点也是一样的套路给个独立类型存注释内容。这样做的好处是挂载流程走到任何一层代码都只需要判断少数几个类型然后走各自的分支逻辑干净。2.3 组件节点和 Fragment不产生真实 DOM 的节点元素节点和文本节点最终总会对应到一个真实 DOM但组件节点不一样。组件节点存的是组件定义对象或函数它本身不创建真实 DOM而是负责创建组件实例。挂载组件时框架先实例化然后调用该组件的渲染逻辑去生成子树 VNode再挂载子树。你可以理解成组件节点是分包负责人自己不干活但负责调度真正的工人。Fragment 节点更特殊它连这个概念都不应该出现在真实 DOM 里。比如某个组件要返回多个根节点我们不希望为了包裹它们而额外加一层 divFragment 就是为了这种情况设计的它有子节点数组但没有标签挂载时直接把 children 平铺插到父容器里。这种结构性但不产生实体的节点是 VNode 设计里非常典型的取舍。2.4 用位标记代替一堆布尔判断如果让我说 VNode 设计里最容易被忽略却又最有价值的一个细节我会选shapeFlag。想象一下如果没有它渲染器判断一个节点是什么类型、children 是什么样子就得写一串if (vnode.type div || vnode.type span ...)或者比较字符串。在渲染热点路径上这种判断越多性能越差。于是就有了位运算这套玩法。给每种形态分配一个独立的二进制位然后用或运算把这些标记组合在一个数字里const ShapeFlags { ELEMENT: 1 0, // 普通元素节点 TEXT: 1 1, // 文本节点 COMPONENT: 1 2, // 组件节点 CHILDREN_ARRAY: 1 3, // children 是数组 CHILDREN_TEXT: 1 4, // children 是字符串文本 CHILDREN_SLOTS: 1 5 // children 是插槽 }创建节点时把对应的标记用|组合起来比如一个元素节点且 children 是数组就是ShapeFlags.ELEMENT | ShapeFlags.CHILDREN_ARRAY这个值是一个整数。判断时用一下就行if (vnode.shapeFlag ShapeFlags.CHILDREN_ARRAY) { // children 是数组 }位运算在 JS 里是来到底层的按位操作比拆对象、比字符串比较都快而且一个数字可以承载多个信息省内存。这就像行李箱上贴标签一个箱子可能同时贴着易碎重物需要加急一个标签位代表一件事翻查的时候扫一眼就知道。实际做渲染器的时候我强烈建议一开始就用 shapeFlag 这套后面加新节点类型也只是新增一个位不会把判断逻辑写成一团乱麻。3. 从模板到 VNodeh 函数和编译链路3.1 h 函数开发者和 VNode 之间的最短路径就算有模板编译器渲染函数也需要一个直接创建 VNode 的入口这个入口一般就是h函数。它的签名约定非常直观h(type, props, children)。但真实场景里参数会偷懒比如只想创建一个没有 props 的节点第二个参数可能直接就是 children所以h内部要做参数重定向function h(type, propsOrChildren, child) { const length arguments.length if (length 2) { // 第二个参数可能是 props也可能是数组/文本形式的 children if (isObject(propsOrChildren) !isArray(propsOrChildren)) { // 也许是单个 VNode也许是 props 对象继续细分 if ((propsOrChildren as any).type ! undefined) { return createVNode(type, null, [propsOrChildren]) } return createVNode(type, propsOrChildren) } // 数组或者字符串则当成 children return createVNode(type, null, propsOrChildren) } return createVNode(type, propsOrChildren, child) }这里面的判断顺序是有讲究的。性能压力主要是别把数组误判成 prop所以在自己写 h 函数时要优先排除数组和 VNode 对象再进入 props 分支。我在项目里见过有人图方便把所有参数都强转一遍再传结果每个节点创建都多出几次无谓的函数调用列表一长肉眼可见地掉帧。3.2 children 的三种形态和收拢策略h函数处理完参数之后createVNode里还会做一次 children 规范化。总的来说children 最终被收拢成三种形态字符串表示这个节点只有一个文本子节点数组表示多个 VNode 子节点函数表示插槽常见于组件节点这里有个关键细节字符串 children 并不等于文本 VNode。divtext/div在创建 VNode 时children 字段可以直接放字符串但框架内部有些实现会悄悄把它规范化成[createTextVNode(text)]或者挂载时单独判断textContent分支。如果规范化做得不彻底后面 diff 时就会遇到旧的是数组、新的是文本的混乱情况。我自己写渲染器时踩过这个坑教训就是在 createVNode 入口就把 children 形态定死后面一律按 shapeFlag 判断绝不在挂载阶段临时猜。3.3 模板编译器如何产出 VNode模板并不是直接变成 VNode 的中间有一个编译过程模板字符串先解析成 AST再根据 AST 生成一段渲染函数代码渲染函数执行时才调用createVNode/h去产出 VNode。这一段代码长什么样大致是function render() { return createVNode(div, { class: card }, [ createVNode(h3, null, 订单总览), createVNode(span, null, total) ]) }编译器在生成这段代码时可以做很多运行时做不到的优化其中最有价值的是静态提升。如果模板里有一段内容完全不含任何动态绑定比如一大段固定文案和固定的装饰性节点那这段 VNode 每次渲染结果都一样编译器就可以只创建一次把结果提升成常量后续渲染直接复用同一个对象省掉重复创建的开销。另一个优化方向是给动态节点打标记。编译器知道某个span的文本绑定了动态数据于是生成节点时加一个此节点只有文本需要更新的标记运行时 diff 到这个节点就直接只更新文本不再递归比较 children。这就是动态标记这种东西存在的意义所有优化之所以能成立前提都是 VNode 结构设计得够清楚留得出打标的位子。3.4 手写渲染函数和模板殊途同归有的同学可能平时不写模板习惯直接用渲染函数。无论走哪条路最终都会落到渲染函数执行后产生一棵 VNode 树。模板的优势是给编译器留了静态分析的空间渲染函数的优势是灵活、不受模板语法限制。但它们在运行时面对的都是同一套 VNode 结构这也是为什么VNode 设计这件事如此独立而且重要——它是整个渲染管线的统一语言。4. 从 VNode 到真实 DOM挂载、更新、复用4.1 首次挂载从图纸到实体的关键路径拿到一棵 VNode 树渲染器第一件要做的事就是挂载。挂载一个元素节点大致走五步根据type创建真实元素把props变成真实属性和事件绑定处理children递归挂载子节点将元素插入容器把真实元素引用回填到vnode.el代码骨架大致是这样function mountElement(vnode, container, anchor) { const el document.createElement(vnode.type) vnode.el el for (const key in vnode.props) { patchProp(el, key, null, vnode.props[key]) } if (vnode.shapeFlag ShapeFlags.CHILDREN_ARRAY) { for (const child of vnode.children) { mountVNode(child, el, null) } } else if (vnode.shapeFlag ShapeFlags.CHILDREN_TEXT) { el.textContent vnode.children } container.insertBefore(el, anchor) }这里vnode.el的回填非常关键。它是后续 diff 的命根子更新属性要找到这个真实节点移动节点要找到这个真实节点卸载也要靠它从父节点移除。如果挂载时忘了回填 el或者回填错了后面整个更新链路都会乱掉。组件节点的挂载则多一层先创建组件实例调用组件的渲染逻辑拿到子树 VNode再按同样的流程挂载子树。组件对上层渲染器来说像是一个黑盒分包商。4.2 同层比较新旧 VNode 的碰头规则更新阶段的第一个问题是新旧两棵树从哪开始比一个显然的做法是递归遍历整棵树做完全比对但那样成本太高。真实项目里跨层次移动节点的情况是少数多数更新是同一个位置内容变了所以大部分框架选择只做同层比较新旧 VNode 类型不同旧节点卸载新节点挂载类型相同进入 patch继续比较 props 和 children文本节点新旧文本不同就更新文本内容组件节点触发组件实例更新重新渲染子树比较 props 也有明确的处理顺序拿到新旧两个 props 对象遍历新 props旧值不同的就更新然后遍历旧 props新值里不存在的旧属性要移除。事件订阅也是同样的套路更新时先解绑旧处理器再绑定新的。别忘了字段和属性都有一套独立的更新逻辑比如 style 是合并还是覆盖class 字符串怎么拼接这些都是 patchProp 要处理的细节。4.3 key 到底怎么参与复用同层 children 的更新是最能体现 VNode 设计价值的地方。假设页面里有一个可排序的列表列表更新后新旧 children 数组长度可能一样、内容可能只是顺序变了。如果没有 key渲染器只能按索引位置一个个对比那就会出现典型的内容错位问题。key 的作用就是给列表里的节点一个稳定身份。新的 children 来了渲染器先建立一个以 key 为键、旧 VNode 为值的映射表然后遍历新 children凡是能匹配到同 key 旧节点的就认为这是同一个东西搬了个位置可以复用旧的真实 DOM只需要移动插入位置匹配不到 key 的才新建。这样比较的复杂度从全量重建降到只在真正新增/删除时操作真实 DOM。举一个真实的例子某个后台系统的表格里每一行是一个带输入框的表单。一开始开发图省事key 用了数组索引 index。结果用户对某几行做了排序操作后输入框里的内容全乱了。原因很清楚索引没变但每一行对应的数据已经换了主人VNode 误判成同一个节点直接把旧 DOM 复用了输入框里的状态也就跟着数据错位。改成用业务数据里的唯一 id 做 key问题立刻消失。key 的选取原则是唯一、稳定、不与位置绑定。4.4 组件的更新边界和触发成本组件 VNode 的更新和元素节点不太一样。渲染器不会直接去改组件的 DOM而是调用组件实例的更新逻辑让组件自己重新渲染生成新的子树再对新旧子树做 patch。这里有一个重要的成本控制手段如果传给组件的 props 和上一次相比没有变化就可以跳过这次子组件渲染。为什么这个优化重要因为组件树可以嵌套很深用户点击一个按钮可能只是根组件里某个叶子状态变了如果每次更新都让整棵组件树重新渲染性能会非常差。靠 props 对比做短路能让更新范围缩小到真正受影响的组件。不过这个机制对 props 对象比较敏感如果父组件每次渲染都新创建一个匿名对象传给子组件即使内容一样引用不同子组件也会觉得props 变了而重新渲染。这是实际项目中非常容易踩的隐形性能坑后面我在问题速查表里会再提一次。5. 设计取舍与优化方向VNode 不是越多越省5.1 全量重建 vs 精准修补成本要算清楚很多人默认虚拟 DOM 肯定比直接操作 DOM 快这个认知其实是错的。虚拟 DOM 的目标不是在所有场景下都比手动操作快而是让开发者用声明式写法还能得到一个足够接近手动优化的性能。创建 VNode 对象、递归 diff、打补丁这些都有开销。对于一两个节点的极小页面直接重建显然比走一遍 diff 更划算但对于一个有几百个节点、其中只有一小部分会变化的中大型页面精准修补就赢了。这其实是个工程权衡取决于变化的密度。如果每次更新几乎整页都在变虚拟 DOM 的优势就很小了甚至可能更慢如果更新是局部性的虚拟 DOM 的优势会非常明显。所以很多框架还会再做一道优化通过编译期标记把 diff 范围从整棵子树缩小到确实可能变化的节点进一步压低对比成本。5.2 静态提升把不变的部分直接缓存起来模板里有很大一部分内容其实是永远不变的比如页面框架、标题文案、纯装饰的布局层。如果这些内容每次都重新生成 VNode那就是白白浪费。编译器的静态提升会把它们提到模块级编译时识别出完全静态的 VNode只创建一次存成常量渲染时直接复用同一对象diff 时看到是相同引用直接跳过这一棵子树这里有一个隐含的要求VNode 对象本身需要在更新中被当作不可变数据对待。如果有人在某次渲染后手动改了静态 VNode 上的 props 或者 el下次复用时会出各种奇怪问题。我自己就是这么踩坑的——为了调试临时改了 vnode 上的某个字段结果列表更新全都错位排查了半天最后发现是不该碰的常量被我碰了。养成好习惯渲染链路之外不要修改 VNode 的字段。5.3 渲染器抽象让 VNode 与平台解耦如果把做渲染器看成建一座桥VNode 是桥的设计图而往真实 DOM 上落地只是这座桥在浏览器里的一个入口。设计良好的 VNode 应该尽量平台无关它只描述结构、属性、内容这些抽象信息不依赖某个具体环境。真正和平台打交道的是渲染器接口比如createElement、insert、remove、patchProp这几个函数。这一点在实际工程里特别香。我参与过的一个跨端项目同一套组件层逻辑浏览器端用真实 DOM 渲染嵌入终端里换成另一套基于自绘引擎的渲染实现VNode 树完全不用改。只要把渲染器的接口适配好上层所有的挂载、diff、更新策略全部复用。这个例子也说明VNode 设计得越干净后端接什么平台就越容易。任何平台相关的脏活、杂活都应该塞进渲染器里而不是渗进 VNode 结构里。6. 常见问题与排查技巧实录6.1 高频问题速查表下面这几类问题是我在开发渲染器和排查项目性能时遇到频率最高的整理成了一张速查表症状常见原因排查方向解决建议列表排序后输入框内容错位使用了 index 作为 key检查列表 VNode 的 key 是否稳定换成业务唯一 id状态变了但界面没更新props 对象引用没变化或依赖没收集打印新旧 props 对比引用地址确保传给组件的是响应式数据或新对象子组件莫名整体重渲染父组件渲染时内联创建组件类型检查子组件 type 是否每次都是新引用把组件定义提取到模块级常量组件更新后 DOM 位置错乱el 引用丢失或移动逻辑没有走到断点看旧 VNode 的 el 是否存在检查 patch 分支是否覆盖移动场景事件多次触发或泄漏更新时没解绑旧事件、卸载时没清监听检查挂载/卸载生命周期在 patchProps 和 unmount 中对事件做对等清理6.2 排查建议和几条实操心得先说排查方法。遇到 VNode 相关的问题我通常会优先做三件事先打印新旧 VNode 的完整对象看 type、key、props、el 是否符合预期然后缩小范围把问题列表简化成最小复现最后对比去掉某个优化前后行为是否变化以此判断是不是优化逻辑出的 bug。这三个动作能解决绝大多数疑难杂症比盯着代码苦想高效得多。再分享几条平时写渲染逻辑的实操心得在 createVNode 入口统一处理 children 形态。字符串、数组、插槽函数在入口收拢不要在挂载和 diff 阶段反复猜类型否则代码很快就会变成一团乱麻。保持 VNode 的不可变习惯。渲染产生的新树和旧树是两个对象不要在旧树上去改字段。特别要紧的是不要手动改el它是渲染器维护的生命线。不要为了优化而优化。VNode 对象设计的各种标记位、静态提升方案都是为大型项目准备的。如果你的页面总共几十个节点直接更新可能更快先测量再动手。key 不只要唯一还要稳定。除了 index 之外拼接字符串、把随机数当 key也都容易出问题。key 的唯一职责是标识这是同一个东西所以它必须和数据本身绑定而不是和位置或时间绑定。复杂长列表先考虑虚拟滚动别指望 diff 能扛住几千个节点的频繁更新。VNode 机制能优化更新的成本但节点数量一上来创建和对比本身也是成本虚拟滚动从源头减少节点数量才是一劳永逸。最后再分享一个我实际做渲染器的体会。真正难的不是写挂载代码而是把每次更新时哪些 VNode 需要重新创建、哪些可以保留复用、哪些必须卸载这件事彻底想清楚。设计初期多花点时间定好字段结构、位标记和 children 规范后面所有的 diff、动态追踪、跨端适配都会顺很多。如果你最近正准备读渲染器源码或者自己动手写一个小型渲染 Demo建议先别碰谈判树上的花活拿几十行代码把VNode 创建 - 挂载 - 比对文本 - 更新 keyed 列表这条主干跑通很多概念就通了。