尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

深入解析虚拟DOM与Diffing算法:原理、优化与面试要点

发布时间:2026/9/26 7:46:43

资讯中心
01
ARTICLE

深入解析虚拟DOM与Diffing算法:原理、优化与面试要点

深入解析虚拟DOM与Diffing算法:原理、优化与面试要点
“虚拟DOM和diff算法”这两个词前端圈子里但凡准备过面试的同学都不陌生。带新人的时候十个人里至少有七八个能把“虚拟DOM就是用一个JS对象来描述真实DOM”这句话背出来但再往下问一层——diff到底怎么比、为什么这么比、Vue3为了少做diff在编译阶段做了什么——能流畅说清楚的就非常少了。这篇文章想聊的就是这层往下延伸的部分。我会从虚拟DOM要解决的问题讲起把Diffing算法一步步拆开最后落到框架源码和面试题上适合三种人刚开始接触前端框架想啃原理的新手写了好几年业务代码但一被问到虚拟DOM就只会背概念的中级前端以及准备面试、想在这个高频题上答出区分度的同学。1. 虚拟DOM到底解决了什么问题1.1 在虚拟DOM出现之前前端是怎么更新页面的老一辈前端应该都经历过jQuery时代。那时候更新页面是这样的点击某个按钮先用$(#list)找到DOM节点然后要么append一段拼好的HTML字符串要么empty()之后重新渲染整个列表。页面复杂以后代码很难看到处都是互相牵连的操作节点语句。更麻烦的是你得自己记住“哪个状态对应哪个节点”一旦状态多了或者交互层级深了极容易漏掉某处该更新的地方于是出现各种“页面显示了但数据和实际不符”的诡异bug。性能上也扛不住。每次直接操作真实DOM浏览器都得走一遍样式计算、布局、绘制。一个几百行的表格如果只是某个单元格里的数字变了不少人图省事直接innerHTML整个重绘眨眼间还行数据多了就明显卡顿。就算你精确到只改那一个格子手动维护的成本也很高——每加一个需求都要记着同步修改对应的节点操作逻辑。为什么真实DOM这么“贵”因为浏览器里DOM节点是一棵庞大的对象树每个节点上挂着大量属性、事件、样式信息。你调用一次getElementById内部可能要做一堆匹配和遍历改一次style可能触发回流重绘。频繁操作开销成倍放大。这也是后面虚拟DOM能站稳脚跟的原始动机我们能不能把“操作真实DOM”这件事降频、瘦身1.2 虚拟DOM是一种“先记账、后统一结账”的思路虚拟DOM的核心思路说穿了就是一句话用轻量的JS对象先描述界面等状态变完以后再把新旧两份描述做对比找出最小的差异去改真实DOM。把它想象成管家记账。真实DOM是家里实际装修好的房间虚拟DOM是账本上画的户型图。每次状态变化你就在账本上重新画一份新的户型图然后和旧账本对比是沙发挪了位置还是墙刷了颜色还是整个房间拆了重装。管家只派工人去动那些有变化的地方而不是把整个房子重新装修一遍。这里有个关键认知要纠正虚拟DOM不是为了让“更新”绝对比“直接手动操作DOM”快而是为了把“手动维护DOM关系”这个容易出错的负担转成“算法自动计算最小更新”这件事。它牺牲了一点极致性能换来的是开发体验和正确性的大幅提升。UI被抽象成了状态的函数UI f(state)。状态变了重新算一遍f(state)得到新的描述再和旧的比对。你不用再关心具体改哪个节点框架帮你搞定。除了降低心智负担虚拟DOM还有一个隐藏红利因为描述界面的东西变成了纯JS对象不依赖浏览器环境它自然可以跑到服务端做服务端渲染也可以映射到小程序、原生应用的视图层。这就是跨平台的底子。1.3 虚拟DOM与Diffing算法是什么关系有了虚拟DOM自然就产生了一个问题新旧两份VNode之间怎么知道谁变了、谁没变这就是Diffing算法出场的地方。虚拟DOM是描述界面的数据结构diff算法是处理这份数据结构的策略。没有diff虚拟DOM的意义就只剩“换一种方式描述DOM”每次更新还得重建整棵真实DOM等于没省成本。有了diff才能做到保留不变的节点只更新真正变化的节点。如果暴力比对两棵树的差异理论上是一个树编辑距离问题复杂度能到O(n³)节点一多算法本身就成了性能瓶颈。所以业内实际使用的diff算法靠的是一系列工程假设把复杂度降到O(n)这个“怎么降”的细节就是整个算法的精髓。后面章节我会重点拆解这三个假设。2. VNode一场“内存里的DOM彩排”2.1 VNode节点到底长什么样聊diff之前必须先搞清楚被比较的对象——VNode——到底是什么。VNode不是HTML字符串而是一个结构化的普通JS对象描述“这个位置的DOM应该长什么样”。不同框架的字段名略有差别但核心大同小异。一个典型的VNode对象大概长这样// Vue 3 风格 { type: div, // 节点类型也可能是组件对象 props: { id: app, class: container }, children: [ { type: h1, props: {}, children: 标题 }, { type: p, props: {}, children: 一段描述 } ], key: null, // 用于列表复用的身份标识 el: null // 指向真实DOM节点的引用 }字段看着简单但每个都有讲究。type决定这个节点是普通元素、文本还是组件props里存的是DOM属性、事件监听器、stylechildren可以是子节点数组也可以是纯文本字符串key是列表复用时的身份标识没有key的数组diff只能靠位置猜有了key才算“对人下菜”。el这个字段容易被忽略它在diff后用来把变化写回真实DOM时做桥梁。VNode比真实DOM“轻”非常多。一个真实DOM元素上有几十上百个内置属性、方法而VNode只是几个普通字段的Plain Object。创建一万个VNode的开销远小于创建一万个真实DOM节点。这就让“每次状态变化都重新生成一棵新树”这件事变得可承受。2.2 手写一个够用的h函数既然VNode是普通对象总得有个函数生成它这就是大家常见的h函数。Vue里叫hReact里是React.createElementVue底层还有个createVNode本质都一样接收标签名、属性、子节点帮你拼成VNode对象。简化版可以这样写function h(type, props, children) { // 兼容 children 是数组还是单个文本的情况 let normalizedChildren children; if (Array.isArray(children)) { normalizedChildren children.flat(); // 压平嵌套数组 } else if (typeof children string || typeof children number) { normalizedChildren String(children); } return { type, props: props || {}, children: normalizedChildren, key: props props.key ! undefined ? props.key : null }; } // 用法h(ul, { class: list }, [ // h(li, { key: 1 }, 第一项), // h(li, { key: 2 }, 第二项) // ]);写这个函数时你就能体会到一件事框架在编译模板或者你写JSX时做的事情就是把“类HTML的写法”转换成这种嵌套的对象结构。所谓template编译本质上就是生成一串h函数调用。真实的VNode还会带一些运行时标记位比如Vue3里的shapeFlag、patchFlag。这些标记位是为了让diff时快速判断“这个节点是什么类型、哪些属性是动态的”省去不必要的比较。比如shapeFlag用二进制位组合表示“元素/文本/组件/数组子节点”比对时一个位运算就能快速分流比每次判断多个条件快得多。2.3 key在VNode里的特殊地位VNode里的其他字段都相对直观唯独key值得单独拎出来讲因为列表diff的优化和踩坑几乎全围绕它展开。想象一个班级点名场景。没有学号老师只能按座位顺序点第一个人、第二个人、第三个人……一旦有人中途换座位后面的编号就全乱了。有了学号无论学生怎么换座位老师一眼都能认出“还是那个张三”。key就是VNode的学号。列表渲染时框架大概率会做插入、删除、重排操作。如果每个节点都有稳定的业务ID作为keydiff就能精确判断“哪个是老节点、哪个是新来的”从而复用旧DOM节点只做移动或内容更新。如果不用key或者用index当key框架只能按位置匹配一旦在中间插入一条数据后续所有位置上的节点都会被判定为“变化”产生大范围的不必要更新。后续章节我会专门展开“为什么不能用index当key”这个经典问题这里先记住结论key一定要用能代表“节点身份”的稳定值业务ID优先于数组索引。3. Diffing算法从一次性重建到最小更新3.1 三个前提假设让复杂度从O(n³)降到O(n)暴力比较两棵树的差异本质是求最小编辑距离需要对树里每个节点做交叉比较复杂度能飙升到O(n³)。真实业务中节点一多这个复杂度完全不可接受。所以框架里的diff算法都基于三个工程假设把复杂度压到O(n)。第一个假设只做同层比较。真实DOM操作里跨级移动节点是极少数情况绝大多数变化都发生在同一层级内部。所以diff时直接把两棵树按层切分只比较同一层级的节点不做跨层比较。这样每层最多是数组与数组的比对不会出现跨层交叉比较爆炸。第二个假设节点类型不同直接替换。如果新旧两个节点type不一样比如原来是div现在是section框架不再浪费时间比较它里面的子节点直接判定为“整个节点变了”销毁旧的创建新的。为什么敢这么干因为实际业务中这种外层标签变化往往意味着结构型改动硬要逐个比内部节点意义不大直接换更稳。第三个假设用key帮助复用节点。同层级列表比较时如果节点有key框架就能在旧children里快速找到对应的旧节点实现“位置变了但节点不销毁”的复用。这是所有列表优化算法的基石。这三个假设放在一起diff从“全树交叉比较”变成了“逐层、按key、按类型比较”复杂度自然降到O(n)。面试时能把这三个假设说出来基本就能证明你不是在背概念。3.2 单节点patch是同就修不是就换diff的整体流程先看单个节点怎么处理。把新旧两个VNode丢进patch函数第一步判断类型function patch(oldVNode, newVNode) { // 类型都不一样直接替换没必要看子节点 if (oldVNode.type ! newVNode.type) { const newEl createElement(newVNode); oldVNode.el.parentNode.replaceChild(newEl, oldVNode.el); newVNode.el newEl; return newEl; } // 文本节点直接改文本内容 if (typeof newVNode.type string newVNode.type text) { if (oldVNode.children ! newVNode.children) { oldVNode.el.textContent newVNode.children; } return oldVNode.el; } // 同一个元素类型先更新属性再递归patch子节点 patchProps(oldVNode.el, oldVNode.props, newVNode.props); patchChildren(oldVNode, newVNode); return oldVNode.el; }这里有一个容易忽略的细节同一个div并不代表里面的内容全都不用动。外层类型相同只是说“这个DOM容器可以复用了”容器内部还需要继续判断props变了没有、children变了没有然后递归往下patch。所以patch是一个递归过程走到叶子节点才终止。属性更新props patch也有不少坑。比较新旧props对象时要处理三种情况新props里有旧props没有的属性要新增旧props里有新props没有的属性要删除两边都有但值不同的属性才更新。事件监听器在框架内部通常是onClick这种形式patch时要区分“新增监听、删除监听、替换监听”处理不好会出现事件绑定叠加事实这也是早期新手手写虚拟DOM库最容易踩的坑之一。3.3 多节点children diff新旧列表怎么互相认出来单个节点好办真正的难点在patchChildren。因为children往往是一个数组而数组最常见的操作是插入、删除、移动。React和Vue在这个环节策略不同但共同点是都依赖key来“认人”。最简单的无key场景只能按索引一一对应新的第一项对比旧的第一项新的第二项对比旧的第二项……这种方案代码最简单但一旦在中间插一条数据后面所有项都会被误判为“不同节点”需要整段更新。有key之后策略完全不一样。Vue2采用双端比较用四个指针分别指向新旧children的头尾优先做四种尝试头和头、尾和尾、头和尾、尾和头尽可能多地命中“没移动的节点”。如果这四种都没命中再通过key到旧children的Map里查找复用。Vue3在此基础上结合了最长递增子序列让“需要移动的节点”数量最少。这里不展开源码细节只给一个带key的diff简化思路function diffChildren(oldChildren, newChildren, container) { // 先把旧children按key建索引 const oldKeyMap new Map(); oldChildren.forEach((child, index) { if (child.key ! null) oldKeyMap.set(child.key, index); }); let lastPlacedIndex -1; // 倒序遍历新children方便做插入判断 for (let i newChildren.length - 1; i 0; i--) { const newChild newChildren[i]; const oldIndex oldKeyMap.get(newChild.key); if (oldIndex undefined) { // 旧列表里没有这个key说明是新增节点 insertBefore(container, createElement(newChild), newChildren[i 1]?.el || null); } else { const oldChild oldChildren[oldIndex]; const canMove oldIndex lastPlacedIndex; lastPlacedIndex Math.max(lastPlacedIndex, oldIndex); // 原地patch这一个节点 patch(oldChild, newChild); if (!canMove) { insertBefore(container, oldChild.el, newChildren[i 1]?.el || null); } } } }这段代码是高度简化版但体现的思路是对的先用key找到旧节点能复用就复用不能复用就新建按顺序调整位置。真实框架还需要处理边界情况比如key相同的节点已经patch过不能重复patch、需要跟踪已使用的key等。理解diff时最容易犯的一个错误是认为“复用了DOM节点就什么都不用改了”。复用只是说这个DOM容器保留容器内部的文本、属性、子孙节点该patch还是得patch。所以key优化的是“节点级别的创建和销毁”而节点内部该做的事一样不少。3.4 面试常问为什么不建议用index作为key“为什么不能用index作为key”是diff算法面试题的经典延伸很多公司把它单独拎出来问因为它能直接看出一个人是真懂diff还是只会背口诀。我们模拟一个场景一个列表[A, B, C]每个li里有个输入框用户在第一项输入了“张三”。此时VNode的key分别是0、1、2输入框的值属于DOM内部状态和VNode无关。现在想在列表头部插入一个新数据“D”新列表变成[D, A, B, C]。如果key还是用index那么新旧对应关系变成了新0(旧0)、新1(旧1)、新2(旧2)……你觉得它们只是位置变了但实际上“原来输入张三的那个输入框”被移动到了第二行而新插入的D占据了第一行的位置。从用户视角看输入框里的“张三”跟着旧节点去了新位置看起来像“数据串位了”。用业务ID当key就不会有这个问题。因为D的ID是新的diff能明确判断出D是新插入的而A、B、C的key没变DOM节点跟着自己的内容整体移动输入框的状态也不会错乱。这个例子在React文档里也提过核心是index会随着数组顺序变化而改变不稳定不能代表“节点的身份”。顺带补充一个常见误区有时候大家说“不用key性能更好”这不是因为key本身慢而是列表规模大的时候key匹配的算法本身有开销但换来的是正确性和更少的DOM操作。绝大多数场景下稳定key的收益远大于开销。对比项index作为key稳定业务id作为key识别原理按数组位置识别按数据身份识别列表头部插入所有key变化大量节点被误判更新/替换只有新节点被识别为新增其余复用带输入框的列表可能出现状态错位状态跟随正确节点移动/排序场景难以正确复用DOM可以精确复用并调整位置适用场景纯静态、不插入删除的展示列表勉强可用动态列表、可交互列表的推荐方案4. Diffing算法在框架中的真实落地与优化4.1 Vue3在diff之外还做了什么聊到这里你应该对diff有了比较完整的认识。但真正到源码层面Vue3的diff并不是孤立运行的它在编译阶段就提前做了大量优化让运行时需要diff的动态节点数量大幅减少。首先是静态提升。模板里那些不依赖任何响应式数据的节点编译时会被提升成常量每次渲染直接复用根本不需要参与diff。比如一段纯静态的导航栏它在每次render时都是同一个VNode引用diff一看引用相等直接跳过。其次是patchFlag。编译模板时Vue会分析哪些属性是动态的在生成VNode时打上标记比如1 /* TEXT */表示只有文本是动态的8 /* PROPS */表示只有props变化还附上具体哪些prop需要对比。运行时看到标记就知道“这个节点我只需要比较文本不需要遍历所有属性更不用深入子节点”。这相当于把“哪里变了”这个信息最大程度前置到了编译期。再配合block treeVue会把模板里动态节点收集成一个扁平的数组diff时直接遍历这个数组跳过整棵静态树。所以Vue3的diff更像“精确制导”不再是全量遍历VNode树。这套组合拳下来同样规模的应用Vue3需要diff的节点数量比Vue2少一个量级。这就带出一个反直觉的结论框架性能不只是靠diff算法本身多巧妙也靠“能不能让diff尽量少干活”。Vue3的优化思路是让静态的部分彻底静态动态的部分缩小到最小范围这样即使diff算法本身没有做翻天覆地的改进整体开销也能大降。4.2 React的diff策略有什么不一样React和Vue的diff思路总体一致都基于同层比较、key复用但实现上差异不小。React的递归diff发生在fiber架构的beginWork阶段React会逐个遍历新旧子节点并用key来快速判断是否可以复用。React没有Vue2那种双端比较的“头移尾移”优化也没有Vue3基于最长递增子序列的最小移动算法所以React里如果列表顺序频繁变化更新成本会偏高。但React有它的优势fiber架构支持渲染过程被中断和恢复diff和commit可以分片进行不会因为一棵大树的比较把所有JS主线程时间占满页面就不会出现长时间无响应。这两种设计方向没有绝对优劣。Vue的思路是“算得更准、让diff步骤更少”React的思路是“把diff过程切片、保证主线程不被饿死”。对开发者来说更重要的是明白一点不管框架内部策略多复杂你写代码时影响最大的还是那件事——key写没写对、组件颗粒度合不合理、有没有触发不必要的重复渲染。对比维度Vue 2Vue 3React调度能力同步不可中断同步不可中断fiber架构可中断恢复子节点比较双端比较双端比较 最长递增子序列递归 key匹配编译优化少量预判静态节点不细分patchFlag、静态提升、block tree依赖JSX运行时编译优化较少移动优化尽量复用已存在节点最小化移动次数按顺序对比移动次数可能偏多适用范围中小型应用为主大型应用编译优化明显大型应用注重并发渲染能力4.3 组件级别的更新diff到哪里为止我们一直在说元素节点div、li这些但实际业务里大量节点是组件。组件在实际渲染时也会变成一个VNode只是type不是一个字符串而是一个组件对象。diff遇到这种VNode时不会深入组件内部的DOM细节而是直接触发组件实例的update让组件自己重新执行渲染函数产生新的子树后再继续diff。这就是“组件的更新边界”。这个设计对性能优化有直接影响。组件的颗粒度决定了每次状态变化会牵动多少范围内的节点重新diff。如果整个页面是一个大组件那任何状态变化都会导致整棵树重新render和diff列表一长就卡如果你把列表项拆成独立组件并配合memo/shallowRef等手段某个列表项自己的局部状态变化就不会波及全局。这也是diff算法在日常业务中最实用的延伸——控制好组件的更新边界比研究具体算法细节更能带来直接的性能收益。5. 面试怎么答日常怎么用5.1 一套能直接拿去用的面试答法虚拟DOM和diff算法是前端高频题但很多人答得千篇一律。如果你想在这个题上拿高分建议按“是什么-为什么-怎么做-延伸”四个层次来答。“是什么”虚拟DOM是一个用JS对象描述真实DOM的数据结构diff算法是对比新旧两份VNode差异的策略目标是找到最小更新路径减少真实DOM操作。“为什么”直接操作真实DOM有两大问题一是手动维护节点关系容易出错二是频繁DOM操作性能代价高。虚拟DOM把UI抽象成状态的函数状态变化后通过diff找出差异再更新把复杂度从“人肉维护”转为“算法计算”。“怎么做”讲三个假设同层比较、同类型节点才比较、key帮助复用讲单节点patch流程类型不同直接替换类型相同再比较props和children讲列表diff时key的作用以及为什么不用index做key。“延伸”可以主动提一句Vue3在diff之外的优化比如编译期的patchFlag和静态提升让运行时真正需要diff的节点大幅减少或者对比React的fiber调度说明不同框架在“diff效率”和“调度能力”之间的不同取舍。这一句延伸就能拉开和普通背诵者的差距。这套答法不需要背源码关键是逻辑连贯、有层次。面试官追问时你可以从三个假设和key这个两个点往下细化基本不会跑偏。5.2 常见问题速查表问题一句话答案要点虚拟DOM一定比真实DOM操作快吗不一定它的价值更多是开发体验和正确性批量更新避免重复操作时才有明确性能收益diff算法的时间复杂度是多少经过同层、同类型、key三个假设优化后是O(n)暴力比较理论上是O(n³)diff是深度优先还是广度优先深度优先。从根节点递归到叶子节点先子后兄弟key的作用是什么作为节点的身份标识让列表diff能精确复用对应节点避免按位置误判为什么不能用index作keyindex随数组顺序变化而不稳定插入删除时会错位复用导致DOM和状态错乱Vue3比Vue2 diff快在哪儿编译期静态提升和patchFlag让动态节点更少block tree让diff只遍历动态节点集合React的diff有什么不同fiber架构支持中断恢复调度能力强但没有Vue3那种编译期优化和最小移动策略父组件更新一定会更新所有子组件吗默认会可以通过拆组件、memo、shallowRef等手段缩小更新边界5.3 写业务代码时这些知识点能帮什么忙搞懂diff不只是为了应付面试。实际业务里最直接的应用就是列表渲染。写v-for或map渲染列表时先检查你有没有给每一项一个稳定唯一的key。很多历史代码偷懒用index组件少看不出来数据一变、条目一多各种诡异bug就来了。还有一个日常容易踩的坑外层标签类型频繁变化。比如一个卡片组件根据某个状态在div和section之间切换虽然样式没变但diff会判定type不同直接把整个子树销毁重建里面积累的DOM状态全没了。这种情况应该保持外层标签稳定只动态切换内部属性和样式。另一个实用场景是性能排查。页面卡顿先看有没有巨型列表再看这个列表是否经常被父组件的无关状态变化牵动。把列表项抽成独立组件、给它们稳定的key、合理控制父组件的更新触发源往往比盲目上虚拟列表库更能解决问题。diff是一次从根节点开始的深度优先递归任何大范围的组件边界失守都会导致一次灾难性的全量重算。之前为了彻底搞懂这些我自己照着社区里流传的迷你框架实践过手写过一版简化h函数和patch那个周末最大的收获不是代码量而是把“为什么需要key”“为什么说diff是深度优先”“为什么外层标签不能乱切”这些问题彻底想通了。后来带团队做性能优化遇到长列表卡顿我第一反应不是赶潮流上虚拟列表而是先看key写没写对、外层标签有没有频繁切换、组件边界有没有失控——这些小细节对diff的影响远比想象中大得多。如果你也想真正吃透虚拟DOM和diff算法我也强烈建议动手写一个能渲染、能更新、能复用的迷你版本踩一遍坑比死记硬背有用得多。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。