源码篇 虚拟DOM
电梯
Vue 源码
源码篇 剖析 Vue2 双向绑定原理
源码篇 使用及分析 Vue 全局 API
源码篇 虚拟DOM
持续更新中...
Vue Router 4 源码
源码篇 Vue Router 4 上篇
源码篇 Vue Router 4 中篇
源码篇 Vue Router 4 下篇
源码拉取步骤可以看这篇文章有讲解,下面直接进入正题:
源码篇 剖析 Vue 双向绑定原理-CSDN博客
前言
虚拟 DOM 常用于单页面应用中,例如 Vue、React 等框架。因此,在前端面试中,经常会被问到 什么是虚拟 DOM,它是如何实现的?本文我们将通过源码分析来揭开虚拟 DOM 的神秘面纱。
正文
在分析虚拟 DOM 的实现源码之前,我们先搞懂这两个问题:
- 什么是虚拟 DOM?
- 为什么要有虚拟 DOM?
我们看看官方对于虚拟 DOM 的说法:
- Vue3 官方文档 - 渲染机制
翻译过来就是:
- React 官方文档(Virtual DOM and Reconciliation)
翻译过来就是:
什么是虚拟 DOM?
定义一句话总结,虚拟 DOM 是用 JavaScript 对真实 DOM 的一种轻量级描述。
在页面真正渲染到浏览器之前,框架会先用 JS 对象在内存中构建一棵“虚拟的 DOM 树”。比如真实的 DOM 是:
<div id="app"><h1>Hello</h1><p>World</p>
</div>
我们将组成该真实 DOM 节点的关键点通过下面这个 JS 对象表现出来:
const vnode = {tag: 'div',props: { id: 'app' },children: [{ tag: 'h1', children: 'Hello' },{ tag: 'p', children: 'World' }]
}
vnode 对象就是这个真实 DOM 节点的 虚拟 DOM 节点
为什么要有虚拟 DOM?
原因1:真实 DOM 操作“很慢”
DOM 是浏览器提供的 API,每次改动都要触发重排和重绘;如果更新频繁,性能会明显下降。
打个比方:
- 操作真实 DOM = 直接在纸上写字、擦掉、再写
- 操作虚拟 DOM = 在草稿纸上改,最后一次性誊到纸上
原因2:虚拟 DOM 更高效的更新机制
- 先根据数据生成虚拟 DOM
- 当数据变化时,生成新的虚拟 DOM
- 对比新旧虚拟 DOM(diff 算法)
- 找出差异,只更新必要的部分
假设一个计数器组件:
let count = 0function render() {return {tag: 'button',children: `点击次数:${count}`} }如果没有虚拟 DOM,每次 count++ 时,都需要重建整个按钮元素:
document.body.innerHTML = `<button>点击次数:${count}</button>`但如果有虚拟 DOM,只需要更新文本节点,不用重新创建整个按钮:
const oldVNode = render() count++ const newVNode = render() updateDOM(oldVNode, newVNode)
原因3:虚拟 DOM 是 JS 对象,不依赖浏览器 DOM
可以让框架具备跨平台能力:
- 渲染成 浏览器 DOM(Web)
- 渲染成 原生控件(React Native)
- 渲染成 小程序、自定义渲染引擎
首先找到源码位置如图:src/core/vdom
VNode 类
在 Vue 中实际存在一个 VNode 类,它是 Vue 虚拟 DOM 的“基础数据结构”,抽象描述了真实 DOM 节点的所有必要信息:
源码位置:src/core/vdom/vnode.ts
export default class VNode {// ...变量初始化...省略...constructor(tag, // 标签名,如 'div' 或 'span'data, // 与该节点相关的数据(属性、指令、事件等)children, // 子节点(VNode 数组)text, // 文本节点内容elm, // 对应的真实 DOM 节点(渲染后会被赋值)context, // 当前 VNode 所属的组件实例componentOptions, // 如果是组件节点,这里存放组件的选项(props、listeners、tag等)asyncFactory // 异步组件的工厂函数) {this.tag = tagthis.data = datathis.children = childrenthis.text = textthis.elm = elmthis.ns = undefined // 命名空间(如 SVG 或 MathML)this.context = context // 所属组件上下文this.fnContext = undefined // 函数式组件上下文this.fnOptions = undefined // 函数式组件配置this.fnScopeId = undefined // 函数式组件作用域IDthis.key = data && data.key // key,用于 diff 时优化复用this.componentOptions = componentOptionsthis.componentInstance = undefined // 组件实例(如果是组件VNode)this.parent = undefined // 父 VNode(用于递归遍历)this.raw = false // 是否为原始HTML(如v-html)this.isStatic = false // 是否为静态节点(v-once 优化)this.isRootInsert = true // 是否作为根节点插入(控制 transition 动画)this.isComment = false // 是否为注释节点this.isCloned = false // 是否为克隆节点(优化复用)this.isOnce = false // 是否带有v-oncethis.asyncFactory = asyncFactory // 异步组件工厂this.asyncMeta = undefined // 异步组件元信息this.isAsyncPlaceholder = false // 是否是异步组件的占位节点}get child() {return this.componentInstance}
}
在这段源码中,我们看到了几种类型的节点:
| 类型名称 | 判断依据 | 说明 |
|---|---|---|
| 元素节点 | tag 有值且不是组件 | 普通 HTML 标签,如 <div>、<span> |
| 文本节点 | text 有值、tag 为空 | 纯文本内容,如 "Hello Vue" |
| 注释节点 | isComment = true | 对应 HTML 注释节点:<!-- xxx --> |
| 组件节点 | componentOptions 有值 | 对应自定义组件 <my-button />,会有 componentInstance |
| 异步组件占位节点 | isAsyncPlaceholder = true | 异步组件加载中时显示的占位符 |
| 克隆节点 | isCloned = true | 为优化 diff(静态节点复用)而克隆出的节点 |
对应源码中的属性如下:
this.tag // 有值时表示标签节点(元素或组件)
this.text // 有值时表示文本节点
this.isComment // 是否是注释节点
this.componentOptions // 有值时表示组件节点
this.isAsyncPlaceholder // 异步组件占位节点
this.isCloned // 克隆节点
下面我们一一进行说明:
1.元素节点
有 tag 属性,有 class、attributes 等 data 属性,有描述子节点信息的 children 属性...
2.文本节点
有 text 属性,用来表示具体的文本内容:
源码位置:src/core/vdom/vnode.ts
export function createTextVNode(val: string | number) {return new VNode(undefined, undefined, undefined, String(val))
}
3.注释节点
有 text 属性描述具体的注释信息,isComment 属性判断是否是注释节点
源码位置:src/core/vdom/vnode.ts
export const createEmptyVNode = (text: string = '') => {const node = new VNode()node.text = textnode.isComment = truereturn node
}
4.组件节点
有 tag 属性,并且不能是原生 DOM 标签,它还有两个属性:
- componentOptions:组件的 option 选项
普通 VNode 只需要标签、属性、子节点;组件节点需要保存更多上下文信息。
例如:
- 它对应哪个组件构造器?
- 它有哪些 props?
- 它的 render 函数是什么?
- 它的父 vnode 是谁?
- 它的生命周期钩子有哪些?
源码位置:src/types/options.ts
export type ComponentOptions = {// Vue 3 支持 Composition API 的 setup(),在组件创建前调用,用于返回响应式状态或渲染函数setup?: (props: Record<string, any>, ctx: SetupContext) => unknown[key: string]: anycomponentId?: string// 组件的响应式数据系统data: object | Function | voidprops?: | string[] | Record<string, Function | Array<Function> | null | PropOptions>propsData?: objectcomputed?: { [key: string]: | Function | { get?: Function, set?: Function, cache?: boolean } }methods?: { [key: string]: Function }watch?: { [key: string]: Function | string }// 组件的渲染逻辑el?: string | Elementtemplate?: stringrender: (h: () => VNode) => VNoderenderError?: (h: () => VNode, err: Error) => VNodestaticRenderFns?: Array<() => VNode>// 生命周期钩子beforeCreate?: Functioncreated?: FunctionbeforeMount?: Functionmounted?: FunctionbeforeUpdate?: Functionupdated?: Functionactivated?: Functiondeactivated?: FunctionbeforeDestroy?: Functiondestroyed?: FunctionerrorCaptured?: () => boolean | voidserverPrefetch?: FunctionrenderTracked?(e: DebuggerEvent): voidrenderTriggerd?(e: DebuggerEvent): void// 当前组件的“私有资源”注册表,这些资源只在该组件内有效directives?: { [key: string]: object }components?: { [key: string]: Component }transitions?: { [key: string]: object }filters?: { [key: string]: Function }// 依赖注入系统provide?: Record<string | symbol, any> | (() => Record<string | symbol, any>)inject?: | { [key: string]: InjectKey | { from?: InjectKey; default?: any } } | Array<string>model?: { prop?: string, event?: string }parent?: Componentmixins?: Array<object>name?: stringextends?: Component | objectdelimiters?: [string, string]comments?: booleaninheritAttrs?: booleanabstract?: any// 标识是否是组件_isComponent?: true_propKeys?: Array<string>// 关联父级 vnode_parentVnode?: VNode_parentListeners?: object | null_renderChildren?: Array<VNode> | null_componentTag: string | null// 设置样式作用域 ID_scopeId: string | null// 保存构造器基类_base: typeof Component
}
- componentInstance:当前组件节点对应的 Vue 实例
注:下面提到的 patch 会在后面单独分析
当 Vue 的 patch 过程执行到“创建组件 VNode”时,它会做以下事情:
- 从 componentOptions 中拿到组件构造器 Ctor
- 执行 new Ctor(options),创建一个新的 Vue 实例(组件实例)
- 把这个实例挂载到 VNode 上:
vnode.componentInstance = new Ctor(options)
- 再让这个实例去执行 mount()、渲染自己的模板等
在 Vue 2 源码的 create-component.js 中,有这样的逻辑:
// 创建组件实例 const component = new Ctor(options) // 绑定回虚拟节点 vnode.componentInstance = component // 调用 $mount() 让它真正挂载 component.$mount(hydrating ? vnode.elm : undefined, hydrating)
5.异步组件占位节点
isAsyncPlaceholder 标记的 VNode 本身不渲染实际内容,它只在等待异步组件被加载完毕后被替换掉
源码位置:
- src/core/vdom/create-component.ts
- src/core/vdom/helpers/resolve-async-component.ts
- src/core/vdom/patch.ts
这里我们只看简化版的关键部分,在 createComponent 方法中:
return createAsyncPlaceholder(asyncFactory, data, context, children, tag)
export function createAsyncPlaceholder(factory: Function,data: VNodeData | undefined,context: Component,children: Array<VNode> | undefined,tag?: string
): VNode {// 创建一个空的 VNodeconst node = createEmptyVNode()// 保存异步工厂函数node.asyncFactory = factory// 保存上下文node.asyncMeta = { data, context, children, tag }return node
}
而 createEmptyVNode() 会返回一个空的 VNode:
export function createEmptyVNode(text?: string): VNode {const node = new VNode()node.text = text || ''node.isComment = true // 空 VNode 实际是注释节点return node
}
因此,异步占位节点实际上是一个带 isComment = true 的空节点。在 patch 阶段,实际是这么判断异步占位节点的:
export function createPatchFunction(backend) {// ...if (isTrue(vnode.isComment) && isDef(vnode.asyncFactory)) {vnode.isAsyncPlaceholder = truereturn true}// ...
}
标记 vnode.isAsyncPlaceholder = true 后,异步组件未加载时,VNode 树保持完整,占位节点不会渲染实际 DOM,加载完成后平滑替换。
6.克隆节点
该节点是为了 Vue 对模板中静态节点做优化 时使用(会在下面单独分析)
源码位置:src/core/vdom/vnode.ts
// 输入一个已有的 VNode,输出一个新的 VNode
export function cloneVNode(vnode: VNode): VNode {// 通过 new VNode(...) 创建一个新的虚拟节点const cloned = new VNode(vnode.tag,vnode.data,vnode.children && vnode.children.slice(), // 克隆子节点数组,避免引用原数组vnode.text,vnode.elm,vnode.context,vnode.componentOptions,vnode.asyncFactory)// 克隆其余属性cloned.ns = vnode.nscloned.isStatic = vnode.isStaticcloned.key = vnode.keycloned.isComment = vnode.isCommentcloned.fnContext = vnode.fnContextcloned.fnOptions = vnode.fnOptionscloned.fnScopeId = vnode.fnScopeIdcloned.asyncMeta = vnode.asyncMetacloned.isCloned = truereturn cloned
}
DOM-Diff
一句话定义:对比 虚拟 DOM 树 的差异,找出需要更新的部分,然后只更新必要的节点,避免整体重渲染。
而这个过程就叫做 patch,这个过程主要做三件事:
1.创建 DOM:判断 oldVnode 是否存在,如果不存在,说明是首次渲染 → 创建新 DOM
当数据变化时,判断新旧 vnode 是否相同节点(sameVnode)—— 判断依据:新旧节点的 key 和 tag 分别都相同
2.更新 DOM:是同一个节点,以新的VNode为准,更新旧的oldVNode
3.删除 DOM:不是同一个节点,直接删除旧节点,创建新节点
源码位置: /src/core/vdom/patch.js
1.创建节点
简化版代码如下:
// 根据给定的虚拟节点创建对应的真实 DOM 节点,并将其挂载到父节点 parentElm 下
function createElm(vnode, insertedVnodeQueue, parentElm, refElm, nested, ownerArray, index) {// 获取 vnode 属性const data = vnode.dataconst children = vnode.childrenconst tag = vnode.tagif (isDef(tag)) { // 创建普通元素节点vnode.elm = vnode.ns // 命名空间? nodeOps.createElementNS(vnode.ns, tag): nodeOps.createElement(tag, vnode) // 实际创建 DOMsetScope(vnode) // 处理作用域样式createChildren(vnode, children, insertedVnodeQueue) // 递归创建子节点if (isDef(data)) {invokeCreateHooks(vnode, insertedVnodeQueue) // 调用 create 钩子}insert(parentElm, vnode.elm, refElm) // 插入到父节点,真正显示到页面} else if (isTrue(vnode.isComment)) { // 处理注释节点vnode.elm = nodeOps.createComment(vnode.text)insert(parentElm, vnode.elm, refElm)} else { // 处理文本节点vnode.elm = nodeOps.createTextNode(vnode.text)insert(parentElm, vnode.elm, refElm)}
}
2.更新节点
function patchVnode(oldVnode, vnode, insertedVnodeQueue, ownerArray, index, removeOnly) {if (oldVnode === vnode) return // 同节点直接返回if (isDef(vnode.elm) && isDef(ownerArray)) vnode = ownerArray[index] = cloneVNode(vnode) // 克隆 vnode(防止修改原数组)const elm = (vnode.elm = oldVnode.elm) // 将旧 vnode 对应的真实 DOM 复用给新 vnode,后续更新只修改属性、文本或子节点,而不替换 DOMif (isTrue(oldVnode.isAsyncPlaceholder)) { // 处理异步占位节点if (isDef(vnode.asyncFactory.resolved)) {hydrate(oldVnode.elm, vnode, insertedVnodeQueue)} else {vnode.isAsyncPlaceholder = true}return}if (isTrue(vnode.isStatic) &&isTrue(oldVnode.isStatic) &&vnode.key === oldVnode.key &&(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))) { // 如果是静态节点且 v-once,直接复用旧 vnode 的组件实例,避免重复渲染静态内容vnode.componentInstance = oldVnode.componentInstancereturn}let iconst data = vnode.dataif (isDef(data) && isDef((i = data.hook)) && isDef((i = i.prepatch))) {i(oldVnode, vnode)}const oldCh = oldVnode.childrenconst ch = vnode.childrenif (isDef(data) && isPatchable(vnode)) {for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)if (isDef((i = data.hook)) && isDef((i = i.update))) i(oldVnode, vnode)}// 这段代码单独分析if (isUndef(vnode.text)) {// ...} else if (oldVnode.text !== vnode.text) {nodeOps.setTextContent(elm, vnode.text)}if (isDef(data)) {if (isDef((i = data.hook)) && isDef((i = i.postpatch))) i(oldVnode, vnode)}
}
我们单独分析一下下面这部分的代码:
const oldCh = oldVnode.children
const ch = vnode.childrenif (isUndef(vnode.text)) {if (isDef(oldCh) && isDef(ch)) {if (oldCh !== ch)updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)} else if (isDef(ch)) {if (__DEV__) checkDuplicateKeys(ch)if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)} else if (isDef(oldCh)) {removeVnodes(oldCh, 0, oldCh.length - 1)} else if (isDef(oldVnode.text)) {nodeOps.setTextContent(elm, '')}
} else if (oldVnode.text !== vnode.text) {nodeOps.setTextContent(elm, vnode.text)
}
1.vnode.text 不存在 → 有子节点
- 旧子节点 & 新子节点都存在 → updateChildren 执行 Diff
- 新子节点存在但旧子节点不存在 → addVnodes 添加
- 旧子节点存在但新子节点不存在 → removeVnodes 删除
- 旧文本存在 → 清空文本
2.vnode.text 存在 → 文本节点
- 与旧文本比较,不同则 setTextContent 更新
总结来说,patchVNode 方法流程如下:
在上面的流程图中,可以看到
如果新旧 VNode 里都包含了子节点,那对于子节点的更新就调用了 updateChildren 方法 ,下面是整理后的代码:
源码位置: /src/core/vdom/patch.ts
function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {let oldStartIdx = 0let newStartIdx = 0let oldEndIdx = oldCh.length - 1let oldStartVnode = oldCh[0]let oldEndVnode = oldCh[oldEndIdx]let newEndIdx = newCh.length - 1let newStartVnode = newCh[0]let newEndVnode = newCh[newEndIdx]let oldKeyToIdx, idxInOld, vnodeToMove, refElmconst canMove = !removeOnlywhile (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {if (isUndef(oldStartVnode)) {oldStartVnode = oldCh[++oldStartIdx]} else if (isUndef(oldEndVnode)) {oldEndVnode = oldCh[--oldEndIdx]} else if (sameVnode(oldStartVnode, newStartVnode)) {patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)oldStartVnode = oldCh[++oldStartIdx]newStartVnode = newCh[++newStartIdx]} else if (sameVnode(oldEndVnode, newEndVnode)) {patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)oldEndVnode = oldCh[--oldEndIdx]newEndVnode = newCh[--newEndIdx]} else if (sameVnode(oldStartVnode, newEndVnode)) {patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))oldStartVnode = oldCh[++oldStartIdx]newEndVnode = newCh[--newEndIdx]} else if (sameVnode(oldEndVnode, newStartVnode)) {patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)oldEndVnode = oldCh[--oldEndIdx]newStartVnode = newCh[++newStartIdx]} else {if (isUndef(oldKeyToIdx))oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)idxInOld = isDef(newStartVnode.key)? oldKeyToIdx[newStartVnode.key]: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)if (isUndef(idxInOld)) {createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)} else {vnodeToMove = oldCh[idxInOld]if (sameVnode(vnodeToMove, newStartVnode)) {patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)oldCh[idxInOld] = undefinedcanMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)} else {createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)}}newStartVnode = newCh[++newStartIdx]}}if (oldStartIdx > oldEndIdx) {refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elmaddVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)} else if (newStartIdx > newEndIdx) {removeVnodes(oldCh, oldStartIdx, oldEndIdx)} }
updateChildren() 采用了 双端比较(double-end diff)算法,通过同时从新旧子节点的头尾两端进行对比,提高 diff 效率
基本变量说明
oldStartIdx, oldEndIdx // 旧子节点数组的开始和结束索引 newStartIdx, newEndIdx // 新子节点数组的开始和结束索引 // 每次循环时,会用下面这四个端点进行比对 oldStartVnode, newStartVnode // 当前从前向后比较的节点 oldEndVnode, newEndVnode // 当前从后向前比较的节点算法同时从两端开始比对:
- 如果 两端节点相同,则直接调用 patchVnode() 更新
- 如果不同,则尝试各种交叉匹配(头对尾、尾对头)
- 如果找不到匹配,则尝试通过 key 定位旧节点
- 若仍找不到,就创建新的真实 DOM 元素插入
- 最后根据剩余情况,添加或移除多余的节点
const canMove = !removeOnly
- removeOnly 是一个特殊标志,表示暂时只删除,不移动节点
- 当 canMove = true 时,表示允许进行节点移动操作
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {... }不断地在循环中进行:
- sameVnode 判断是否相同
- patchVnode 更新
- insertBefore 进行移动
- 有时还会把旧节点标记为 undefined(表示已处理)
1.跳过空节点
如果某些旧节点已经被处理成 undefined(被复用、移动或替换后)
oldCh[idxInOld] = undefined这个旧节点在 oldCh 数组中被标记为“空位”,但索引还在。所以,当循环指针继续移动时,有可能会“指向一个已被置为 undefined 的位置”,此时就跳过继续
if (isUndef(oldStartVnode)) {oldStartVnode = oldCh[++oldStartIdx] } else if (isUndef(oldEndVnode)) {oldEndVnode = oldCh[--oldEndIdx] }
2.四种组合匹配策略(最大化利用旧节点,避免频繁创建新 DOM)
- oldStartVnode vs newStartVnode
- 如果相同,表示前端节点未变
patchVnode(oldStartVnode, newStartVnode) oldStartIdx++ newStartIdx++
- oldEndVnode vs newEndVnode
- 如果相同,表示尾部节点未变
patchVnode(oldEndVnode, newEndVnode) oldEndIdx-- newEndIdx--
- oldStartVnode vs newEndVnode
- 旧前端节点匹配新后端节点,旧前端节点被移到末尾
patchVnode(oldStartVnode, newEndVnode) insertBefore(parentElm, oldStartVnode.elm, nextSibling(oldEndVnode.elm)) oldStartIdx++ newEndIdx--
- oldEndVnode vs newStartVnode
- 旧后端节点匹配新前端节点,旧后端节点被移到开头
patchVnode(oldEndVnode, newStartVnode) insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm) oldEndIdx-- newStartIdx++3.四种组合都不匹配
新节点的起始节点 newStartVnode 在旧节点列表中可能在“中间某个位置”,也可能是“完全新增”的节点。因此,Vue 会尝试“在旧节点中查找新节点”
if (isUndef(oldKeyToIdx))oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) idxInOld = isDef(newStartVnode.key)? oldKeyToIdx[newStartVnode.key]: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx) if (isUndef(idxInOld)) {createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } else {vnodeToMove = oldCh[idxInOld]if (sameVnode(vnodeToMove, newStartVnode)) {patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)oldCh[idxInOld] = undefinedcanMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)} else {createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)} } newStartVnode = newCh[++newStartIdx]
1.构建 key 到 index 的映射表
if (isUndef(oldKeyToIdx))oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)生成一个 { key: index } 的映射表,用来快速查找旧节点中相同 key 的节点,比如:
旧子节点 oldCh = [A(key=1), B(key=2), C(key=3)] 👉 oldKeyToIdx = { 1:0, 2:1, 3:2 }2.查找新节点在旧节点中的位置
idxInOld = isDef(newStartVnode.key)? oldKeyToIdx[newStartVnode.key]: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
- 如果 newStartVnode 有 key:就直接查表 oldKeyToIdx
- 如果 没有 key:就调用 findIdxInOld(),通过 sameVnode 逐个对比寻找相同节点
结果:
- 如果找到了 → idxInOld 是旧节点在 oldCh 中的索引
- 如果没找到 → idxInOld 是 undefined
3.如果没找到,创建新节点
if (isUndef(idxInOld)) {createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) }说明这个新节点完全是“新增”的,不在旧的虚拟节点树中
- 调用 createElm() 创建新真实 DOM
- 插入到当前 oldStartVnode 之前
4.如果找到了,旧节点可复用
else {vnodeToMove = oldCh[idxInOld]if (sameVnode(vnodeToMove, newStartVnode)) {patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)oldCh[idxInOld] = undefinedcanMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)} else {createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)} }这段代码复用了上面的逻辑
5.移动新索引指针
newStartVnode = newCh[++newStartIdx]处理完当前新节点后,移动指针,准备进入下一轮比较
至此,虚拟 DOM 源码篇 over!






