视图渲染与编译优化 (Vue3)
模板编译与 render 函数
Vue 的模板(Template)在底层最终都会被编译器转化为 render 函数。render 函数执行后返回的就是虚拟 DOM(VNode)。
整个过程大致为:Template -> AST 语法树 -> render 函数 -> VNode -> 真实 DOM。
普通元素的编译
假设有这样一个基础模板:
<div id="app">
<p>{{name}}</p>
<p>{{age}}</p>
</div>
Vue 会基于 AST 将其编译成如下的 render 函数(Vue3 语法风格):
import {
toDisplayString as _toDisplayString,
createElementVNode as _createElementVNode,
openBlock as _openBlock,
createElementBlock as _createElementBlock,
} from "vue";
export function render(_ctx, _cache) {
return (
_openBlock(),
_createElementBlock("div", { id: "app" }, [
_createElementVNode("p", null, _toDisplayString(_ctx.name), 1 /* TEXT */),
_createElementVNode("p", null, _toDisplayString(_ctx.age), 1 /* TEXT */),
])
);
}
注意:这里的 _createElementVNode 就是常说的 h 函数的底层实现,尾部的 1 即前文提到的 PatchFlag。
Slot 插槽的用法与编译机制
面试扩展:为什么 React 没有插槽(Slot)的概念?Vue 也能传 Props,为什么还要搞个 Slot?
React 崇尚 “All in JS”,在 React 中,一切 UI 皆为组件,而组件本质上就是一个普通的 JavaScript 函数。既然是普通函数,那么我们完全可以通过给函数传递参数(Props)的方式来传递 UI 结构。
- 对应 Vue 的“默认插槽”:React 直接通过内置的
props.children来接收父组件夹在标签中间的内容。- 对应 Vue 的“具名插槽”/“作用域插槽”:React 称之为 Render Props,即直接把一个返回 JSX 的函数作为 prop 传给子组件(例如
<Modal headerRender={() => <h2>警告</h2>} />)。既然 Vue 也可以传 Props,为什么还要发明
<slot>呢?
- 模板的表达力限制:Vue 的核心是模板语法(Template)。在 Vue 模板中,如果用 Props 传递一大段包含各种指令(
v-if,v-for)的复杂 HTML 会非常痛苦,只能写成字符串或者手写h函数,这就丧失了模板直观易读的优势。- 作用域隔离:在模板中,
slot能够非常清晰地划分“父组件作用域”和“子组件作用域”。- 所以,为了在像原生 HTML 一样的结构中实现内容分发,Vue 必须设计出
<slot>这个特定的标签作为占位符指令。其实在底层编译后,Vue 的插槽本质上也就是把函数当作 Props 传给了子组件(详见后文的编译机制),和 React 殊途同归。
插槽(Slot)是 Vue 提供的一种内容分发机制,允许父组件向子组件的指定位置插入一段 HTML 结构。它的本质是父组件把渲染函数传递给子组件,由子组件决定在什么地方、甚至用什么数据来调用它。
1. 基础用法与场景
场景:封装一个通用的 Modal (弹窗) 组件,它的外框和关闭逻辑是固定的,但弹窗内部的内容需要由使用它的父组件来决定。
<!-- 子组件 Modal.vue -->
<div class="modal-wrapper">
<!-- 具名插槽,供父组件替换头部 -->
<header><slot name="header">默认标题</slot></header>
<!-- 默认插槽,供父组件替换主体内容 -->
<main><slot>默认内容</slot></main>
</div>
<!-- 父组件 App.vue 使用 -->
<Modal>
<template v-slot:header>
<h2>警告</h2>
</template>
<!-- 没有指定 name 的内容会进入默认插槽 -->
<p>您确定要删除吗?</p>
</Modal>
2. 底层编译机制
那么,上述模板在底层是如何运作的呢?
以子组件模板为例:
<div>
<slot name="header"></slot>
</div>
编译生成的 render 函数如下:
import {
renderSlot as _renderSlot,
openBlock as _openBlock,
createElementBlock as _createElementBlock,
} from "vue";
export function render(_ctx) {
return (
_openBlock(),
_createElementBlock("div", null, [
// _renderSlot 会去 _ctx.$slots 对象中寻找名为 "header" 的函数并执行
_renderSlot(_ctx.$slots, "header"),
])
);
}
渲染管线 (Render Pipeline)
Vue3 的核心渲染管线可以概括为以下三大步:https://cn.vuejs.org/guide/extras/rendering-mechanism.html#render-pipeline
- 编译 (Compile):将 Vue 模板 (
<template>) 编译为返回虚拟 DOM (VNode) 的render函数。在构建阶段完成,极大减少了运行时的性能开销。 - 挂载 (Mount):运行时,调用
render函数,遍历返回的 VNode 树,基于它创建真实的 DOM 节点。此过程会触发响应式依赖收集。 - 更新 (Patch/Update):当响应式依赖发生变化,重新执行
render函数,生成新的 VNode 树。Vue 会比对 (Diff) 新旧 VNode 树,将最小的变更应用到真实 DOM 上。- pre:DOM 更新前执行(如
watch默认行为)。 - flushing:批量执行 DOM 更新 (
queueJob)。 - post:DOM 更新后执行(如
onMounted,onUpdated, 设置了flush: 'post'的 watch)。
- pre:DOM 更新前执行(如
面试必考:批量异步更新(批处理)是怎么实现的?
Vue 会将触发了更新的副作用(effect/job)放入一个 Set 队列中。由于是 Set 结构,相同 id 的 effect(比如同一个组件的多次数据修改触发的同一个渲染 effect)会自动去重。 然后在下一个 Tick(通过 Promise.resolve().then())统一清空这个队列并执行 DOM 更新。这就是为什么你连续同步修改 100 次 count.value,组件只会重新渲染 1 次的原因。
编译优化 (Vue3 性能飙升的秘密)
Vue2 的 Diff 算法是对整棵 VNode 树进行全量遍历比对,当组件很大而动态节点很少时,性能极差。Vue3 在编译阶段(AOT)做了大量分析,在生成的 render 函数里打上标记,实现了“靶向更新”。
为了直观理解,我们假设有这样一段模板:
<div>
<span>我是静态文本,永远不变</span>
<span :class="dynamicClass">{{ message }}</span>
</div>
1. 静态提升 (Static Hoisting)
在 Vue2 中,即使是纯静态的 <span>,每次组件更新触发 render 时,都会被重新创建一次 VNode 实例并参与 Diff 比对。
Vue3 的优化:
把纯静态的节点提升到 render 函数之外的全局作用域。每次重新渲染时直接复用同一个 VNode 内存引用,并在 Diff 时直接跳过。
// 编译后的伪代码
const _hoisted_1 = /*#__PURE__*/ createVNode(
"span",
null,
"我是静态文本,永远不变",
);
export function render(ctx) {
return createBlock("div", null, [
_hoisted_1, // 永远复用这个引用,不再重新创建
createVNode(
"span",
{ class: ctx.dynamicClass },
ctx.message,
3 /* PatchFlags */,
),
]);
}
2. PatchFlags (靶向更新)
在上面的代码中,细心的你会发现第二个动态 span 后面多了一个数字 3。这就是 PatchFlag。
Vue3 的优化: 编译器会给动态节点打上二进制的标志位:
TEXT = 1(代表文本是动态的)CLASS = 2(代表 class 是动态的)STYLE = 4... 在这个例子中,文本和 class 都是动态的,所以1 | 2 = 3。 在运行时 Diff 时,Vue 看到标志位是3,就只去比对文本内容和 class,完全忽略其他属性的比对,这就是“靶向更新”。
3. Block Tree (区块树)
这是 Vue3 性能最核心的飞跃。
在 Vue2 中,寻找动态节点需要像递归遍历 DOM 树一样,一层一层往下找(哪怕中间有 100 层静态包裹节点)。
Vue3 的优化:
Vue 会把带有 v-if / v-for 的节点,以及根节点作为一个 Block。在组件挂载时,这个 Block 会把内部深层所有的动态节点提取出来,收集到一个扁平的一维数组 dynamicChildren 中。
// 运行时,根节点 div 变成了一个 Block
const block = {
tag: "div",
children: [静态span, 动态span],
// 核心:直接把几百层下的动态节点拍平收集到这里!
dynamicChildren: [动态span],
};
更新时,直接遍历 dynamicChildren 数组即可!直接跳过了所有静态节点层级,将 Diff 算法的时间复杂度从“整棵树的层级大小”直接降低到了“动态节点的数量”。
核心 Diff 算法原理
面试题:说说 Vue2 和 Vue3 的 Diff 算法有什么区别?
Vue 在进行虚拟 DOM 的更新时,当遇到同一层级的多个子节点(通常出现在 v-for 列表中),就需要使用 Diff 算法来最高效地复用真实 DOM。
1. Vue2 的双端 Diff 算法
Vue2 采用的是双端比较算法。核心思想是:设定 4 个指针,分别指向新旧列表的头尾。
- 比较顺序:
- 头与头对比(相同则指针后移)
- 尾与尾对比(相同则指针前移)
- 旧头与新尾对比(相同则将 DOM 移动到最后,指针向中间靠拢)
- 旧尾与新头对比(相同则将 DOM 移动到最前,指针向中间靠拢)
- 如果以上 4 种都没命中,则在旧节点中通过
key查找匹配的节点并移动。若找不到则创建新节点。
- 缺点:即使列表中只有一小部分发生了移动,双端算法也会进行大量的不必要比较。
2. Vue3 的快速 Diff (Fast Diff) 算法
Vue3 借鉴了 ivi 和 inferno 框架,采用了去头尾的最长递增子序列算法,性能有了显著提升。
第一步:前置与后置预处理 (Sync from start / end)
- 从头部开始,按顺序比对新旧节点,相同的直接复用(Patch),遇到不同则停止。
- 从尾部开始,按逆序比对新旧节点,相同的直接复用(Patch),遇到不同则停止。
- 这一步处理了绝大多数常见场景(如在列表末尾追加元素、在列表头部插入元素),剩下的中间部分才是真正乱序的节点。
第二步:处理仅有新增或仅有删除的情况
- 如果经过第一步,旧节点已经遍历完,但新节点还有剩余,说明这些是新增节点,直接挂载。
- 如果新节点遍历完,旧节点还有剩余,说明这些是多余节点,直接卸载。
第三步:最长递增子序列 (处理未知乱序序列)
- 如果新旧节点都有剩余,说明存在乱序。Vue3 会构建一个
newIndexToOldIndexMap(新节点在旧节点中的索引映射数组)。 - 然后,Vue3 会利用贪心 + 二分查找算法,对这个映射数组求解最长递增子序列 (Longest Increasing Subsequence)。该算法的时间复杂度为 O(n log n)。
- 精髓所在:最长递增子序列代表了不需要移动的节点集合。只需要把不在子序列中的节点移动(插入)到正确的位置即可,从而保证了 DOM 的移动操作次数最少。相比于 Vue2 的双端 Diff($O(n)$),Vue3 虽然在求解子序列上复杂度略高,但它能最大限度减少真实 DOM 的移动操作,而 DOM 操作才是性能最昂贵的地方。
图示与举例理解:
假设前后的列表变化如下(已去除了头尾相同的节点): 旧列表:
a b c d e f新列表:a c d b e f- Vue 会先生成一个索引映射数组
newIndexToOldIndexMap,记录新节点在旧节点中的位置索引: 新节点[a, c, d, b, e, f]旧索引[0, 2, 3, 1, 4, 5](注: 实际源码中为了避免 0 被当作无映射,会全部加 1,此处为方便理解使用原始索引) - 求解数组
[0, 2, 3, 1, 4, 5]的最长递增子序列。 很明显,它的最长递增子序列是[0, 2, 3, 4, 5](对应节点a, c, d, e, f)。 - Vue 从后向前遍历新列表:
- 节点
f,它的索引5在子序列中,不移动。 - 节点
e,它的索引4在子序列中,不移动。 - 节点
b,它的索引1不在子序列中,说明它位置不对!于是执行 DOM 移动操作,把b插入到e的前面。 - 节点
d,索引3在子序列中,不移动。 - ...以此类推。
- 节点
结论:通过最长递增子序列,Vue 发现只需要移动
b这一步操作,就能完成整个列表的更新!这就是 Vue3 Diff 算法性能极高的核心秘密。- 如果新旧节点都有剩余,说明存在乱序。Vue3 会构建一个