前端 Vue 专栏 07:模板编译、虚拟 DOM、Diff 与 key
前言
前面三篇文章已经把 Vue 更新页面之前的链路连接起来了:
text
render 执行并读取响应式状态
↓
track 收集组件渲染 effect
↓
状态变化后 trigger
↓
scheduler 将组件更新任务加入队列
↓
微任务执行 flushJobs
↓
组件重新执行 render
但是执行 render() 以后发生了什么?
为什么模板可以变成 JavaScript 函数?
虚拟 DOM 究竟是什么?
为什么 Vue 不直接把整个组件的真实 DOM 删除后重新创建?
v-for 为什么经常要求使用 key?
Vue 2 双端 Diff 和 Vue 3 Diff 有什么区别?
最长递增子序列又解决了什么问题?
这一篇将沿着完整渲染管线展开:
text
template 模板
↓
compiler 编译器
↓
render 渲染函数
↓
VNode 虚拟节点
↓
虚拟 DOM 树
↓
renderer 渲染器
↓
mount 初次挂载 / patch 后续更新
↓
真实 DOM
一、Vue 模板不是直接交给浏览器执行的
下面是一段 Vue 模板:
vue
<template>
<section class="profile">
<h2>{{ username }}</h2>
<button @click="count++">
点击 {{ count }} 次
</button>
</section>
</template>
浏览器原生认识:
html
<section>
<h2>
<button>
但浏览器不认识 Vue 的:
text
{{ username }}
@click
v-if
v-for
自定义组件标签
因此 Vue 模板必须经过编译器处理,转换成普通 JavaScript 渲染函数。
这个过程叫作:
text
模板编译
二、模板怎样变成 render 函数
模板:
vue
<div id="message">
{{ text }}
</div>
编译后可以先简化理解为:
js
function render() {
return h(
'div',
{ id: 'message' },
text.value
)
}
真实编译结果会使用 Vue 内部的创建 VNode 辅助函数,并包含优化标记,结构更接近:
js
function render(_ctx, _cache) {
return openBlock(), createElementBlock(
'div',
{ id: 'message' },
toDisplayString(_ctx.text),
1 // TEXT
)
}
这里的重点不是背编译产物,而是理解:
text
模板最终会变成一个函数;
执行函数会读取组件状态;
函数返回描述页面结构的 VNode。
三、模板编译经历哪些阶段
编译器内部可以粗略拆成三个阶段。
1. Parse:解析
把模板字符串:
vue
<div>{{ message }}</div>
解析成模板抽象语法树 AST。
概念结构:
js
{
type: 'Element',
tag: 'div',
children: [
{
type: 'Interpolation',
content: 'message'
}
]
}
2. Transform:转换和分析
编译器遍历 AST,处理:
text
插值表达式;
v-if、v-for;
v-bind、v-on;
组件和插槽;
静态节点;
动态属性;
运行时需要哪些辅助函数和优化标记。
3. Generate:生成代码
根据转换结果生成 render 函数代码:
js
function render() {
return createElementVNode(...)
}
所以完整过程是:
text
模板字符串
→ AST
→ 转换后的 AST
→ render 函数代码
四、模板什么时候编译
主要有两种时机。
1. 构建时编译
在使用 Vite 和 .vue 单文件组件时,模板通常会在构建阶段提前编译。
text
开发源码中的 template
↓
Vue 编译插件
↓
JavaScript render 函数
↓
发送给浏览器
这样浏览器运行时不需要再携带完整模板编译器,通常可以使用体积更小的 runtime-only 构建。
2. 运行时编译
如果使用包含编译器的 Vue 构建,并在运行时传入模板字符串:
js
createApp({
template: '<div>{{ message }}</div>'
})
模板也可以在浏览器运行时编译。
现代工程中的单文件组件一般优先构建时编译,因为它能够提前发现模板错误,并应用更多编译优化。
五、render 函数是什么
render 是一个返回 VNode 的函数。
使用 h() 可以手写:
js
import { h } from 'vue'
export default {
props: {
username: String
},
render() {
return h(
'div',
{ class: 'user-card' },
[
h('h2', null, this.username),
h('button', { onClick: this.logout }, '退出')
]
)
},
methods: {
logout() {
// 退出逻辑
}
}
}
h() 是创建 VNode 的辅助函数,名字来自 hyperscript,也可以把它理解为:
text
createVNode 的简写入口。
通常使用方式是:
js
h(type, props, children)
例如:
js
h(
'button',
{
class: 'primary',
disabled: false,
onClick: submit
},
'提交'
)
六、为什么大多数业务仍然使用模板
render 函数更加灵活,但模板通常更适合业务页面:
vue
<button
class="primary"
:disabled="loading"
@click="submit"
>
{{ loading ? '提交中' : '提交' }}
</button>
原因包括:
- 更接近 HTML,结构直观;
- 更容易由编辑器提供模板提示;
- 设计和样式协作更自然;
- 编译器可以进行确定的静态分析;
- 编译器可以生成 patch flag、block 等优化信息。
render 函数更适合高度动态、需要 JavaScript 完整表达能力的底层组件。
所以模板和 render 不是两个互斥的渲染系统:
text
模板最终仍然会被编译成 render 函数。
七、什么是 VNode
VNode 是 Virtual Node,也就是虚拟节点。
它本质上是一个 JavaScript 对象,用来描述一个界面节点。
例如:
js
const vnode = {
type: 'button',
props: {
id: 'submit',
class: 'primary'
},
children: '提交'
}
它描述的是:
html
<button id="submit" class="primary">
提交
</button>
但 VNode 不是实际 DOM:
js
vnode instanceof HTMLElement // false
它只是描述真实节点所需信息的普通对象。
八、真实 VNode 中有什么
真实 Vue VNode 比上面的示例复杂,常见信息包括:
text
type:元素标签、组件、Fragment 等类型;
props:属性、事件和特殊 attribute;
key:节点身份提示;
children:文本或子 VNode;
el:挂载后关联的真实 DOM;
component:组件 VNode 对应的组件实例;
shapeFlag:VNode 结构类型标记;
patchFlag:编译器生成的动态更新标记;
dynamicChildren:区块中需要重点更新的动态后代。
可以概念化为:
js
const vnode = {
type: 'div',
props: {
class: 'profile'
},
key: null,
children: [],
el: null,
shapeFlag: 9,
patchFlag: 0,
dynamicChildren: null
}
初次创建 VNode 时:
js
vnode.el === null
挂载真实 DOM 后,Vue 会记录对应关系:
js
vnode.el = realElement
九、VNode 可以表示哪些内容
VNode 不只表示普通 HTML 元素。
js
h('div')
可以表示元素 VNode。
js
h(UserCard)
可以表示组件 VNode。
此外还可以表示:
text
文本节点;
注释节点;
Fragment;
Teleport;
Suspense;
静态内容;
组件和插槽结构。
因此虚拟 DOM 是整棵由 VNode 组成的树,而不只是把 HTML 标签换成对象。
十、什么是虚拟 DOM 树
模板:
vue
<section class="profile">
<h2>{{ username }}</h2>
<button>关注</button>
</section>
对应的虚拟 DOM 可以简化成:
js
const vnodeTree = {
type: 'section',
props: {
class: 'profile'
},
children: [
{
type: 'h2',
children: username.value
},
{
type: 'button',
children: '关注'
}
]
}
因为每个 VNode 还可以拥有子 VNode,所以最终形成树结构。
十一、什么是 renderer 渲染器
编译器负责:
text
template → render
渲染器负责:
text
VNode → 真实平台节点
浏览器平台的渲染器需要使用类似操作:
js
document.createElement()
parent.insertBefore()
parent.removeChild()
element.textContent = value
element.setAttribute()
element.addEventListener()
Vue 将核心渲染算法和平台操作分开,因此运行时核心可以通过不同宿主操作渲染到不同平台。
浏览器中常见宿主操作可以概念化为:
js
const nodeOps = {
createElement(tag) {
return document.createElement(tag)
},
insert(child, parent, anchor = null) {
parent.insertBefore(child, anchor)
},
remove(child) {
child.parentNode?.removeChild(child)
},
setElementText(element, text) {
element.textContent = text
}
}
十二、mount 和 patch 有什么区别
1. mount:初次挂载
第一次没有旧 VNode:
js
patch(null, newVNode, container)
Vue 需要根据新 VNode 创建完整真实 DOM。
这个过程叫 mount。
2. patch:后续更新
状态变化后,render 返回新的 VNode:
js
patch(oldVNode, newVNode, container)
Vue 比较新旧 VNode,并把必要变化应用到真实 DOM。
广义上,Vue 内部的 patch 函数也会负责分派初次挂载,但学习时可以先区分:
text
没有旧节点 → 创建;
有旧节点 → 比较并更新。
十三、手写一个最小 mount
js
function mount(vnode, container) {
const element = document.createElement(vnode.type)
vnode.el = element
for (const key in vnode.props || {}) {
element.setAttribute(key, vnode.props[key])
}
if (typeof vnode.children === 'string') {
element.textContent = vnode.children
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => {
mount(child, element)
})
}
container.appendChild(element)
}
使用:
js
const vnode = {
type: 'div',
props: {
id: 'app-content'
},
children: [
{
type: 'p',
children: '你好'
}
]
}
mount(vnode, document.querySelector('#app'))
真实渲染器还要正确处理 DOM property、attribute、事件、SVG、组件、Fragment 和生命周期等,这里只展示递归创建节点的基本思路。
十四、手写一个最小 patch
先只处理相同元素的文本和属性:
js
function patchElement(oldVNode, newVNode) {
const element = newVNode.el = oldVNode.el
const oldProps = oldVNode.props || {}
const newProps = newVNode.props || {}
for (const key in newProps) {
if (newProps[key] !== oldProps[key]) {
element.setAttribute(key, newProps[key])
}
}
for (const key in oldProps) {
if (!(key in newProps)) {
element.removeAttribute(key)
}
}
if (newVNode.children !== oldVNode.children) {
element.textContent = newVNode.children
}
}
例如旧 VNode:
js
const oldVNode = {
type: 'button',
props: {
class: 'normal'
},
children: '提交',
el: buttonElement
}
新 VNode:
js
const newVNode = {
type: 'button',
props: {
class: 'loading',
disabled: true
},
children: '提交中'
}
patch 后复用原来的 button 元素,只修改:
text
class;
disabled;
文本内容。
十五、什么情况下节点可以复用
Vue 3 判断两个 VNode 是否是同一种节点时,核心条件可以理解为:
js
oldVNode.type === newVNode.type &&
oldVNode.key === newVNode.key
类型相同、key 相同
js
old: { type: 'div', key: 'profile' }
new: { type: 'div', key: 'profile' }
可以继续 patch。
类型不同
js
old: { type: 'div', key: 'profile' }
new: { type: 'section', key: 'profile' }
虽然 key 相同,类型不同,通常需要卸载旧节点并挂载新节点。
key 不同
js
old: { type: 'div', key: 'A' }
new: { type: 'div', key: 'B' }
类型相同但 key 不同,也会被当成两个不同身份的 VNode。
十六、Diff 到底是什么
Diff 是比较新旧虚拟 DOM,并确定怎样更新真实 DOM 的过程。
旧树:
js
const oldVNode = render(oldState)
新树:
js
const newVNode = render(newState)
然后:
js
patch(oldVNode, newVNode)
主要需要判断:
text
节点能否复用;
哪些属性改变;
文本是否改变;
哪些子节点新增;
哪些子节点删除;
哪些子节点移动;
哪些组件需要更新或卸载。
Diff 不是只比较一个节点,它会由 patch 按需要递归进入子节点。
十七、Vue 会跨整棵树寻找相同节点吗
通常不会为了复用一个节点,在任意层级的整棵旧树中进行全局搜索。
Vue 从对应位置开始 patch:
text
比较当前新旧 VNode;
能够视为同一种节点,就继续比较其内容和子节点;
类型或 key 不同,就替换对应子树。
所以常见的"同层比较"说法是在强调:
text
渲染器不会进行昂贵的任意跨层节点匹配。
但不要把它机械理解成 Diff 永远只执行一层。patch 会沿可复用节点递归处理它们的 children。
十八、虚拟 DOM 一定比直接操作 DOM 快吗
不一定。
如果开发者已经精确知道只需要执行:
js
element.textContent = '新的文本'
那么直接操作这一处 DOM,通常比:
text
创建新 VNode;
比较新旧 VNode;
再修改 DOM。
更直接。
虚拟 DOM 的主要价值不是宣称它在每一个局部操作上都比手写 DOM 快,而是:
- 使用声明式状态描述界面;
- 把复杂更新规则交给统一渲染器;
- 提供组件、跨平台渲染和服务端渲染等抽象基础;
- 在不知道具体变化位置时,通过编译和运行时信息完成合理更新;
- 让开发者不需要手动维护大量 DOM 同步逻辑。
Vue 还利用模板编译器向运行时提供优化信息,减少通用虚拟 DOM 的额外工作。
十九、Vue 3 怎样优化虚拟 DOM
Vue 同时控制模板编译器和运行时渲染器。
编译器知道哪些内容永远不变,哪些内容可能变化,因此可以把信息写入生成的 render 函数。
主要优化包括:
text
缓存静态内容;
patch flag 更新类型标记;
block 和 dynamicChildren;
稳定 Fragment 标记;
事件处理函数和表达式缓存等。
这被称为:
text
带编译时信息的虚拟 DOM。
二十、静态内容缓存是什么
模板:
vue
<div>
<header>
<h1>用户中心</h1>
<p>管理账户信息</p>
</header>
<p>{{ username }}</p>
</div>
其中:
vue
<header>
<h1>用户中心</h1>
<p>管理账户信息</p>
</header>
完全静态,不依赖组件状态。
组件重新渲染时,没有必要重复创建并比较这部分结构。
编译器可以缓存或提升静态内容,让后续更新跳过无意义工作。
重点不是背具体变量名,而是:
text
编译器提前知道静态节点不会变化,运行时就可以不再完整处理它们。
二十一、patch flag 是什么
模板:
vue
<div :class="className">
静态文本
</div>
编译器知道这个元素只有 class 是动态的。
生成 VNode 时可以添加标记:
js
createElementVNode(
'div',
{ class: className },
'静态文本',
2 // CLASS
)
更新时可以快速判断:
js
if (patchFlag & PatchFlags.CLASS) {
patchClass(element, newClass)
}
不需要对所有 props 做完整遍历。
常见标记语义包括:
text
TEXT:动态文本;
CLASS:动态 class;
STYLE:动态 style;
PROPS:部分动态属性;
FULL_PROPS:需要完整比较属性。
这些数字通常使用位标记组合,业务开发不需要手写。
二十二、列表 Diff 为什么最复杂
普通文本更新很简单:
text
旧文本:张三
新文本:李四
→ 修改 textContent
但列表可能同时发生:
text
新增;
删除;
移动;
内容更新;
组件状态复用。
例如旧列表:
text
A B C D
新列表:
text
D A C E
渲染器要判断:
text
D 是移动,不应该重新创建;
B 被删除;
A、C 可以复用;
E 是新增。
这就需要稳定的节点身份,也就是 key。
二十四、key 到底是什么
key 是 Vue 提供给虚拟 DOM 算法的特殊 attribute,用于识别同一父节点下 VNode 的身份。
vue
<li
v-for="user in users"
:key="user.id"
>
{{ user.name }}
</li>
这里:
text
user.id 不是列表位置;
它表示这个 VNode 对应的是哪一位用户。
如果列表从:
text
用户 101
用户 102
用户 103
变成:
text
用户 103
用户 101
用户 102
Vue 可以根据 key 知道:
text
三个节点身份都没变,只是顺序变了。
二十五、key 是不是传给子组件的 prop
不是。
vue
<UserCard
v-for="user in users"
:key="user.id"
/>
key 是 Vue 运行时使用的特殊 attribute,子组件不会自动在 props 中得到它。
如果子组件也需要 id,要单独传递:
vue
<UserCard
v-for="user in users"
:key="user.id"
:user-id="user.id"
/>
二十六、key 需要全局唯一吗
不需要在整个应用中全局唯一。
要求主要是:
text
同一个父节点下的兄弟 VNode 之间唯一。
例如两个不同列表都使用用户 id:
vue
<ul>
<li v-for="user in activeUsers" :key="user.id">
{{ user.name }}
</li>
</ul>
<ul>
<li v-for="user in disabledUsers" :key="user.id">
{{ user.name }}
</li>
</ul>
两个列表属于不同父节点,可以使用相同 id。
同一组兄弟节点出现重复 key,则会让节点身份不明确,可能导致渲染异常。
二十七、为什么不推荐使用 index 作为 key
vue
<UserRow
v-for="(user, index) in users"
:key="index"
:user="user"
/>
问题在于 index 表示当前位置,不表示数据身份。
旧列表:
| index | user |
|---|---|
| 0 | 张三 |
| 1 | 李四 |
| 2 | 王五 |
在头部插入赵六后:
| index | user |
|---|---|
| 0 | 赵六 |
| 1 | 张三 |
| 2 | 李四 |
| 3 | 王五 |
从 key 的角度看:
text
旧 key=0 的张三
被认为对应
新 key=0 的赵六
Vue 会倾向于复用位置 0 的旧节点或组件实例,并把内容从张三更新成赵六。
如果 UserRow 内部有局部状态:
js
const draft = ref(props.user.remark)
这个局部状态可能跟着旧组件实例留在位置 0,结果出现:
text
页面展示赵六;
组件内部草稿却还属于张三。
使用稳定 id:
vue
<UserRow
v-for="user in users"
:key="user.id"
:user="user"
/>
Vue 才能知道:
text
张三的组件实例仍然属于张三,只是移动到了 index 1。
二十八、什么时候 index 作为 key 风险较低
如果列表满足下面条件:
text
内容完全静态;
永远不会排序;
不会在中间或头部插入、删除;
列表项没有表单、焦点和组件局部状态;
只会在末尾追加简单展示内容。
index 带来的身份错位风险较低。
但只要数据本身有稳定 id,通常直接使用 id 更清晰:
vue
:key="item.id"
判断标准不是"能不能运行",而是:
text
这个 key 是否稳定表示该数据实体的身份。
二十九、没有 key 会发生什么
没有 key 时,Vue 会尽可能按位置就地更新相同类型节点。
旧列表:
text
[张三, 李四, 王五]
头部插入赵六:
text
[赵六, 张三, 李四, 王五]
无 key 的位置更新可以理解为:
text
位置 0:张三节点更新成赵六;
位置 1:李四节点更新成张三;
位置 2:王五节点更新成李四;
末尾新增王五。
对于没有局部状态的简单文本,它可能看起来完全正确。
但对于组件、表单元素、焦点、过渡和需要稳定身份的节点,就可能产生状态错位。
所以 key 不只是为了性能,更重要的是表达节点身份和复用规则。
三十、有 key 就一定更快吗
不一定。
无 key 的简单列表可以按位置直接 patch,不需要建立 key 映射和移动节点。
有 key 的 Diff 需要处理:
text
key 到索引的映射;
新增和删除;
顺序移动;
最长递增子序列。
因此不能说:
text
写 key 的唯一原因是提高性能。
更准确的是:
key 为节点提供稳定身份,使 Vue 能够在列表变化时正确复用、移动、创建和卸载节点,并在此基础上尽量减少 DOM 移动。
三十一、key 还可以强制重建组件
key 改变后,即使组件类型相同,Vue 也会把它们视为不同 VNode。
vue
<UserForm
:key="userId"
:user-id="userId"
/>
userId 变化时,旧 UserForm 会被卸载,新组件重新创建。
这可以用于:
text
重置组件局部状态;
重新执行挂载逻辑;
让过渡识别为节点替换。
但不要为了"确保更新"随意修改 key。频繁重建组件会丢失状态并增加开销,应先理解组件为什么没有按预期响应。
三十二、Vue 2 双端 Diff 是什么
Vue 2 的 updateChildren 使用四个指针处理新旧子节点列表:
text
oldStart:旧列表头部;
oldEnd:旧列表尾部;
newStart:新列表头部;
newEnd:新列表尾部。
例如:
text
旧:A B C D
新:D A B C
初始指针:
text
oldStart = A
oldEnd = D
newStart = D
newEnd = C
算法会进行四组核心比较。
三十三、Vue 2 双端 Diff 的四种比较
1. 旧头和新头
text
oldStart ↔ newStart
如果相同,patch 后两边头指针向中间移动。
2. 旧尾和新尾
text
oldEnd ↔ newEnd
如果相同,patch 后两边尾指针向中间移动。
3. 旧头和新尾
text
oldStart ↔ newEnd
说明旧头节点移动到了新列表尾部。patch 后,将对应真实 DOM 移到旧尾节点之后。
4. 旧尾和新头
text
oldEnd ↔ newStart
说明旧尾节点移动到了新列表头部。patch 后,将对应真实 DOM 移到旧头节点之前。
如果四种都无法匹配,再利用 key 映射在旧列表剩余范围查找新头节点。
三十四、Vue 2 双端 Diff 示例
旧列表:
text
A B C D
新列表:
text
D A B C
第一次:
text
oldStart = A
oldEnd = D
newStart = D
newEnd = C
命中:
text
oldEnd D === newStart D
说明 D 从尾部移动到了头部。
Vue 可以复用 D 对应的真实 DOM,把它移动到 A 前面:
text
D A B C
随后继续比较剩余节点。
双端比较对于头尾移动场景非常直观。
三十五、Vue 2 四种比较都失败怎么办
例如:
text
旧:A B C D
新:B D A C
如果当前四组首尾都无法匹配,Vue 2 会为旧子节点建立 key 到索引的映射:
js
{
A: 0,
B: 1,
C: 2,
D: 3
}
然后根据新头节点的 key 查找旧节点:
text
找到 → patch 并移动旧 DOM;
没找到 → 创建新的 DOM。
旧列表中被移动过的位置会被标记,指针后续遇到时跳过。
循环结束后:
text
旧节点先处理完 → 挂载剩余新节点;
新节点先处理完 → 删除剩余旧节点。
三十六、Vue 3 keyed Diff 的整体步骤
Vue 3 的 keyed children Diff 可以按五个阶段理解:
text
1. 从头部同步相同节点;
2. 从尾部同步相同节点;
3. 旧节点处理完,挂载剩余新节点;
4. 新节点处理完,卸载剩余旧节点;
5. 处理顺序未知的中间区域。
前四步快速处理常见的追加、删除和稳定前后缀。
真正复杂的是第五步。
三十七、第一步:从头部同步
旧列表:
text
A B C
新列表:
text
A B C D
从头比较:
text
A = A → patch
B = B → patch
C = C → patch
旧列表先结束,剩余的新节点 D 直接挂载。
这种尾部追加场景不需要进入复杂中间 Diff。
三十八、第二步:从尾部同步
旧列表:
text
B C D
新列表:
text
A B C D
从尾比较:
text
D = D
C = C
B = B
旧列表先结束,剩余的新节点 A 挂载到 B 前面。
头部插入也可以快速处理。
三十九、中间未知区域怎样处理
例如:
text
旧:A B C D E F
新:A C D B G F
头部同步:
text
A
尾部同步:
text
F
剩余中间区域:
text
旧:B C D E
新:C D B G
Vue 3 会:
text
建立新中间节点 key → newIndex 的映射;
遍历旧中间节点;
旧节点在新列表不存在则卸载;
存在则 patch,并记录新旧索引关系;
识别哪些节点需要移动;
计算最长递增子序列;
倒序挂载新节点和移动必要节点。
四十、为什么建立新节点 key 映射
新中间区域:
text
C D B G
建立:
js
const keyToNewIndexMap = new Map([
['C', 1],
['D', 2],
['B', 3],
['G', 4]
])
索引只是示意。
遍历旧中间节点时,可以通过 key 快速找到它在新列表中的位置。
相比每一个旧节点都遍历整个新列表,Map 查询降低了匹配成本。
没有 key 的混合情况仍可能需要按类型在未匹配节点中查找,所以稳定 key 对列表身份判断非常重要。
四十一、newIndexToOldIndexMap 是什么
Vue 需要记录新列表每个位置对应旧列表的哪个位置。
可以构造一个数组:
text
新中间节点:C D B G
旧位置记录:3 4 2 0
这里用"旧索引加一"记录已存在节点:
text
3:C 来自旧位置 2;
4:D 来自旧位置 3;
2:B 来自旧位置 1;
0:G 在旧列表中不存在,是新节点。
加一是为了让:
text
0 专门表示新节点。
真实数组名称通常是:
js
newIndexToOldIndexMap
它同时帮助判断:
text
哪些节点需要新建;
旧节点在新列表中的相对顺序;
哪些已有节点可能需要移动。
四十二、怎样判断列表发生了移动
遍历旧节点并找到新索引时,可以记录目前见过的最大新索引:
js
let maxNewIndexSoFar = 0
let moved = false
如果新索引一直递增:
text
1、2、3、4
说明这些旧节点在新列表中的相对顺序没有改变。
如果出现:
text
2、3、1
最后的 1 小于之前最大值 3,说明顺序发生变化:
js
moved = true
只有检测到移动时,才需要进一步计算最长递增子序列。
四十三、什么是最长递增子序列
给定数组:
text
3 4 2
递增子序列可以是:
text
3 4
它保持原有先后顺序,而且数值递增。
最长递增子序列 LIS 表示:
text
当前新列表中,哪些旧节点仍然保持相对顺序,可以不移动。
对于:
text
新中间节点:C D B
旧位置记录:3 4 2
最长递增部分是:
text
3 4
对应 C、D。
因此:
text
C、D 可以保持;
B 需要移动到正确位置。
不是最长递增子序列的节点,就根据锚点移动。
四十四、为什么 LIS 可以减少 DOM 移动
假设已有节点的顺序从:
text
B C D
变成:
text
C D B
有两种做法。
第一种:
text
移动 C;
移动 D。
第二种:
text
保持 C、D 相对位置;
只把 B 移到后面。
第二种真实 DOM 移动更少。
LIS 的目的就是找出最大数量的"不必移动节点",从而让剩余移动次数尽量少。
常见高效 LIS 算法可以做到 O(n log n),但秋招面试首先应该讲清它为什么用于 Diff,再根据岗位深度决定是否手写算法。
四十五、为什么最后需要倒序处理新节点
Vue 3 在处理中间区域的挂载和移动时,会从后向前遍历新节点。
原因是可以利用下一个已经放置好的节点作为 anchor。
例如要把节点放到 C 前面:
js
parent.insertBefore(node, cElement)
从后向前时:
text
右侧节点已经处于正确位置;
当前节点可以直接插到右侧节点之前。
如果当前节点位于列表末尾,anchor 为 null,表示追加到末尾。
因此倒序并不是随意选择,而是方便稳定确定插入锚点。
四十六、Vue 2 和 Vue 3 列表 Diff 对比
| 对比项 | Vue 2 | Vue 3 |
|---|---|---|
| 主要入口 | updateChildren |
patchKeyedChildren |
| 前后处理 | 新旧两端四指针比较 | 先同步公共头部和尾部 |
| 未知区域 | 旧节点 key 映射并移动新头节点 | 新 key 映射、索引关系数组 |
| 移动优化 | 双端匹配和查找移动 | LIS 尽量减少中间节点移动 |
| 编译优化 | 有静态节点优化 | 进一步加入 patch flag、block tree 等 |
两者都不是"暴力删除全部旧列表再创建全部新列表"。
它们共同目标是:
text
正确识别节点身份;
尽可能复用已有节点;
减少不必要的 DOM 操作。
四十七、key 应该写在哪里
普通元素列表:
vue
<li
v-for="item in items"
:key="item.id"
>
{{ item.name }}
</li>
组件列表:
vue
<UserCard
v-for="user in users"
:key="user.id"
:user="user"
/>
template v-for:
vue
<template
v-for="group in groups"
:key="group.id"
>
<h2>{{ group.name }}</h2>
<p>{{ group.description }}</p>
</template>
key 应放在 v-for 所在的 <template> 上,用于标识这一组 Fragment 内容。
四十八、完整渲染更新流程
现在可以把专栏 05、06、07 合并起来。
组件初次挂载:
text
template 编译成 render
↓
Vue 创建组件渲染 ReactiveEffect
↓
首次执行 render
↓
读取响应式状态并 track
↓
得到 VNode 树
↓
patch(null, vnode)
↓
mount 创建真实 DOM
组件后续更新:
text
修改 ref / reactive
↓
setter / Proxy set
↓
trigger 组件 effect
↓
scheduler 调用 queueJob
↓
微任务 flushJobs
↓
重新执行 render
↓
得到新 VNode 树
↓
patch(oldVNode, newVNode)
↓
比较 props、文本和 children
↓
必要时执行列表 Diff
↓
修改最必要的真实 DOM
四十九、常见错误理解
1. 模板会直接生成真实 DOM
模板通常先编译成 render,render 返回 VNode,再由渲染器创建或更新真实 DOM。
2. VNode 就是真实 DOM 的复制品
VNode 是描述界面结构的 JavaScript 对象,不包含浏览器 DOM 对象的全部能力和状态。
3. 虚拟 DOM 永远比原生 DOM 快
精确手写单次 DOM 操作可能更快。虚拟 DOM 的价值在于声明式维护、统一更新和跨平台抽象,并通过编译信息减少通用 Diff 开销。
4. Diff 会比较整个页面所有节点
响应式系统先定位需要更新的组件,Diff 主要发生在该组件本次生成的新旧 VNode 子树之间,并利用 block 和 patch flag 跳过很多静态内容。
5. key 只是为了消除控制台警告
key 表示节点身份,影响节点和组件实例的复用、移动、卸载与创建。
6. key 必须全局唯一
同一个父节点下唯一即可。
7. index 永远不能当 key
绝对说法不准确。静态、不会重排且没有局部状态的简单列表风险较低;但存在稳定业务 id 时,应优先使用 id。
8. 使用随机 key 可以强制页面保持最新
随机 key 会让节点每次都被视为全新身份,导致无意义卸载和重建。
9. 有 key 一定比无 key 快
key 的首要作用是身份正确。keyed Diff 还需要建立映射和处理移动,不能简单归结为始终更快。
10. 最长递增子序列决定哪些节点相同
节点是否相同主要由 type 和 key 判断。LIS 是在已有节点匹配完成后,用来尽量减少需要移动的 DOM 节点。
五十、面试高频问题
1. Vue 模板怎样变成页面
可以这样回答:
Vue 模板经过 parse、transform 和 codegen 等阶段编译成 render 函数。组件渲染 effect 执行 render,读取响应式数据并返回 VNode 树;首次挂载时渲染器根据 VNode 创建真实 DOM,后续更新时比较新旧 VNode,并把必要变化 patch 到真实 DOM。
2. 什么是虚拟 DOM
可以这样回答:
虚拟 DOM 是用 JavaScript 数据结构描述目标 UI 的一种模式。单个 VNode 会记录 type、props、children、key 等信息,多个 VNode 形成树。渲染器可以根据它创建真实节点,也可以比较新旧 VNode 树并更新真实 DOM。
3. 为什么需要虚拟 DOM
可以这样回答:
它让开发者通过状态声明界面,不需要手动维护大量 DOM 同步逻辑,并为组件模型、跨平台渲染和 SSR 提供统一抽象。它不保证每个局部操作都比手写 DOM 快,Vue 还通过静态缓存、patch flag 和 block tree 降低运行时协调成本。
4. render 函数是什么
可以这样回答:
render 是根据当前组件状态返回 VNode 的函数。模板最终也会被编译为 render 函数。组件首次渲染以及依赖变化后的重新渲染,都会执行它来得到当前期望的虚拟 DOM。
5. Diff 是什么
可以这样回答:
Diff 是渲染器比较新旧 VNode 并决定怎样更新真实 DOM 的过程,包括复用或替换节点、更新属性和文本,以及处理子节点新增、删除和移动。Vue 不会在任意层级全局搜索节点,而是从对应 VNode 递归 patch。
6. key 的作用是什么
可以这样回答:
key 是虚拟 DOM 算法识别兄弟 VNode 身份的提示。稳定 key 使 Vue 能在列表变化时判断节点应复用、移动、创建还是卸载,也能让组件局部状态跟随真实数据实体,而不是跟随数组位置。
7. 为什么不推荐 index 作为 key
可以这样回答:
index 表示位置而不是数据身份。列表在头部插入、删除或排序后,同一个 index 会对应不同数据,Vue 可能复用原位置的 DOM 或组件实例,造成输入状态、焦点和组件局部状态错位。存在稳定 id 时应该使用 id。
8. key 可以使用随机数吗
可以这样回答:
不应该。随机 key 每次渲染都变化,Vue 会认为旧节点全部消失、新节点全部出现,从而卸载并重新创建,丢失局部状态和焦点,还会增加 DOM 与生命周期开销。
9. Vue 2 双端 Diff 怎样工作
可以这样回答:
Vue 2 同时维护新旧子列表的头尾指针,依次尝试旧头新头、旧尾新尾、旧头新尾、旧尾新头四种匹配。命中后 patch 并按需要移动真实节点;都不命中时使用 key 映射查找旧节点。最后挂载剩余新节点或删除剩余旧节点。
10. Vue 3 keyed Diff 怎样工作
可以这样回答:
Vue 3 先同步新旧列表的公共头部和尾部,快速处理追加和删除。如果仍有未知中间区域,就建立新节点 key 到索引的映射,遍历旧节点完成 patch 和卸载,记录新索引对应的旧索引,并在检测到移动时通过最长递增子序列找出可以保持不动的节点,最后倒序创建或移动其他节点。
11. 最长递增子序列有什么作用
可以这样回答:
它作用在已经完成新旧身份匹配的索引关系上,用来找出在新顺序中仍保持相对顺序的最大节点集合。这些节点可以不移动,只移动不在序列中的节点,从而减少真实 DOM 移动次数。
12. Vue 3 比 Vue 2 的渲染优化多在哪里
可以这样回答:
Vue 3 进一步利用编译器和运行时协同,缓存静态内容,为动态文本、class、style 或 props 生成 patch flag,并通过 block 收集动态后代,使更新时可以跳过更多静态节点;keyed children Diff 还使用索引映射与 LIS 减少移动。
13. patch flag 是什么
可以这样回答:
patch flag 是模板编译器写入 VNode 的动态更新类型标记。运行时可以通过位运算知道节点只有文本、class、style 或部分 props 需要更新,避免每次完整比较所有属性。
14. block tree 是什么
可以这样回答:
block 是内部结构相对稳定的 VNode 区域,会记录其中需要更新的动态后代。组件重新渲染时,Vue 可以遍历扁平化的 dynamicChildren,跳过大量静态层级;v-if、v-for 等结构性指令通常会形成新的 block 边界。
15. 响应式和虚拟 DOM 有什么关系
可以这样回答:
响应式系统负责在组件 render 执行时收集依赖,并在依赖变化后调度对应组件;虚拟 DOM 系统负责让该组件重新 render 后比较新旧 VNode,并更新必要 DOM。响应式解决"哪个组件要更新",Diff 解决"组件内部更新什么"。
五十一、本文知识地图
1. 编译阶段
text
template
→ parse AST
→ transform
→ codegen
→ render 函数
2. 初次挂载
text
render
→ VNode tree
→ mount
→ real DOM
3. 后续更新
text
状态变化
→ 组件 effect 入队
→ render 得到新 VNode
→ patch 新旧 VNode
→ 更新真实 DOM
4. 编译优化
text
静态内容缓存;
patch flag;
block;
dynamicChildren。
5. key
text
表示兄弟 VNode 的稳定身份;
决定复用、移动、创建和卸载;
应该稳定、唯一,并与业务实体相关。
6. Vue 2 列表 Diff
text
新旧列表四个头尾指针;
四种首尾交叉比较;
失败后使用 key 映射查找。
7. Vue 3 列表 Diff
text
同步头部;
同步尾部;
处理简单新增和删除;
映射未知中间区域;
LIS 减少 DOM 移动;
倒序确定插入锚点。
五十二、总结
Vue 页面更新不是模板直接操作 DOM,而是一条完整管线:
text
模板编译成 render;
render 返回 VNode;
渲染器根据 VNode 挂载或 patch;
最终把必要变化应用到真实 DOM。
虚拟 DOM 的价值首先是声明式界面和统一渲染抽象,不是保证每一次操作都比手写 DOM 更快。
Vue 3 通过编译器提供:
text
静态内容缓存;
patch flag;
block tree;
dynamicChildren。
让运行时可以跳过大量确定不会变化的内容。
列表更新中,key 不是普通 prop,也不只是为了消除警告。它告诉 Vue:
text
这个新 VNode 和哪个旧 VNode 是同一个身份。
Vue 2 主要通过双端指针和 key 映射处理列表;Vue 3 先同步公共前后缀,再处理未知中间区域,并通过最长递增子序列尽量减少 DOM 移动。
最终把 Vue 三套机制放在一起:
text
响应式系统:决定哪个组件需要更新;
调度器:决定组件何时、按什么顺序更新;
虚拟 DOM 和 Diff:决定组件内部哪些真实 DOM 需要更新。
下一篇将进入专栏 08:生命周期与高级组件能力。