前端 Vue 专栏 07:模板编译、虚拟 DOM、Diff 与 key

前端 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:生命周期与高级组件能力。

参考资料

相关推荐
光影少年3 小时前
react navite process.nextTick 和 Promise.then 的执行顺序
前端·react native·react.js
雪芽蓝域zzs3 小时前
第四十二节:全局字典封装(后端字典,下拉选择复用)
开发语言·前端·javascript
用户64596598710883 小时前
Nginx + Vue Router 基础:SPA History 部署、反向代理与请求分流
前端
样子20183 小时前
Js 之根据白名单过滤 HTML(防止 XSS 攻击)
android·前端·javascript·html·xss
lewis_lk3 小时前
ReactJs状态迷思:从"常量"到"闭包陷阱"的底层逻辑
前端·react.js
尘世中一位迷途小书童3 小时前
在 Cesium 上画全球网格:我们怎么把 GeoSOT 接到瓦片调度上
前端·javascript·前端框架
aixingpan3 小时前
aixingpan.cn API开发文档:api_docs_trichart_natal_progression_ter_transit2接口指南
前端·php
liuyt20224 小时前
【javaweb】day3
前端
CoderYanger4 小时前
Java EE 进阶:2.3 JavaScript
java·开发语言·前端·javascript·css·职场和发展·java-ee