Vue 3 设计与实现
第 18 章 性能工程与最佳实践
第 18 章 性能工程与最佳实践
本章要点
- Vue 3 的性能优化哲学:编译时优化 + 运行时精细控制的双轨策略
- 编译器的静态提升(Static Hoisting):将不变的 VNode 提取到渲染函数外部
- PatchFlag 与 Block Tree:精确追踪动态节点,跳过静态内容的 Diff
- 响应式系统的性能陷阱:深层响应式、大型数组、不必要的依赖收集
- shallowRef / shallowReactive / triggerRef 的精细控制策略
- 组件级优化:v-once、v-memo、defineAsyncComponent 与代码分割
- 虚拟列表与大数据渲染的底层实现原理
- 内存泄漏的检测与预防:闭包陷阱、全局事件、定时器清理
- DevTools Performance 面板:如何定位 Vue 应用的性能瓶颈
- 从源码角度理解每个优化手段的收益与代价
性能不是事后修补,而是架构决策。Vue 3 从设计之初就将性能作为核心目标——Proxy 替代 Object.defineProperty、编译器的静态分析、Block Tree 的精确 Diff、Tree-shaking 友好的模块化设计。这些底层改进让 Vue 3 在基准测试中全面领先 Vue 2。
但框架的性能上限只决定了地板,应用的实际表现取决于开发者如何使用它。本章将从源码层面剖析每个优化手段的原理,让你不仅知道”该怎么做”,更理解”为什么这样做有效”。
一个老工程师的”性能观”
在开始逐条拆解技术细节之前,先分享一个贯穿全章的判断框架——性能工作的三层优先级。
这是我做过十余个中大型前端项目,踩过各种坑之后总结的结构:
- 第一层:让 render 不跑多余的工作。这是最大块的收益来源。render 是否在组件没变时也被触发?computed 是否被错误地失效?响应式依赖是否收集过度?这一层解决好,能省掉 60%-80% 的无效工作量。
- 第二层:让 DOM 操作最小化。哪怕 render 跑了,真正落地到 DOM 的变更应该最小。VDOM diff 的精确度、Block Tree 的跳过机制、key 的使用——都属于这一层。做好这一层,能在第一层基础上再省 50% 的 DOM 操作。
- 第三层:让浏览器绘制最快。layout thrashing 的规避、transform 取代 top/left、requestAnimationFrame 的使用——这些是给前两层已经做好之后的”收尾打磨”,不是门面救火的手段。
这个优先级的顺序不能颠倒。我见过团队把时间花在第三层,纠结于”这个动画用 left 还是 transform”,但 render 层面一次状态改动触发了 300 个组件重渲染——你在三层上省的几毫秒,被第一层的几百毫秒无效工作全部吃掉。
本章的组织方式也是按这个优先级来的:先讲编译时和响应式(第一层)、再讲 Block Tree 和 diff(第二层)、最后讲虚拟滚动和绘制(第三层)。这条路径上的每个技术点都不是”技巧”,而是”层级”——掌握层级,技巧自然落位。
一个让我反复引用的”第一性”事实
Vue 的性能优化有一个终极来源:你声明式写的模板里,真正会变的东西通常只占一小部分。
一个 500 行模板的组件,每次状态改动可能只有 3 个绑定真正变化。朴素的框架会每次重走全部 500 行的对比;Vue 3 通过编译期 + 运行期的协作,把工作量压到那 3 个变量的范围。
这个”500 → 3”的落差就是 Vue 3 所有性能优化的共同母题——静态提升是把不变的 VNode 移出 render 函数让它根本不重建、Block Tree 是把动态节点登记出来让 diff 只过这几个、PatchFlag 是把动态属性精确到 class/style/text 中的某一个让 patch 连属性对比都不做。本章的每一节都是这个母题的一个变奏。读的时候带着这个心智框架,所有优化手段会突然变得互相连通。
丛书卷《React 19 源码解读》第 6 章讨论 Fiber diff 时提过一个相似的命题——但 React 的答案是”让 diff 本身变得可打断”,Vue 的答案是”干脆不让 diff 做无用功”。哲学不同,但优化压力的来源完全一致。
18.1 编译时优化
编译时优化是 Vue 3 最深刻的性能革命——它把许多原本要运行时反复计算的事情,在构建期一次算完。这种”把工作往前推”的思路在系统软件里有一个专用术语:at-compile-time specialization,rustc 的 monomorphization、V8 的 inline caching、数据库的 prepared statement 都是同构的套路。
Vue 3 能做到这一点的关键是——模板是受限的 DSL 而不是任意代码。下面每一节展示的都是”在模板的约束下,编译器能看出什么、能提前做什么”。看的时候请持续体会:模板的”不够灵活”恰恰是它最大的价值——灵活意味着运行时未知,未知意味着必须保守处理;不灵活意味着编译时确定,确定意味着运行时可以省事。
静态提升(Static Hoisting)
静态提升是最朴素也最有效的一种编译优化——把”每次 render 都要重建的不变 VNode”提升到 render 函数外、只创建一次、后续所有 render 复用同一个引用。
这个优化的直觉来源很简单:一个组件 render 一次可能创建几十个 VNode 对象(每个 VNode 都是一次内存分配);组件频繁更新时(比如每秒 30 帧的动画),这些分配累积起来就是显著的 GC 压力。如果编译器看出”这个 VNode 里没有任何动态内容(全是静态字符串、静态属性、静态嵌套结构)“,把它挪到模块顶层或 render 外层作用域,那 render 每次跑就只是”引用同一个 VNode”——不分配、不创建,纯复用。
能复用的前提是”这个 VNode 的身份在多次 render 之间可以是同一个对象”。这正是 Vue 团队早期在这项优化上反复讨论的点:如果某个 render 里把静态 VNode 作为 props 传给子组件,而子组件可能修改这个 VNode 的属性——那么复用就会污染。Vue 的解法是在 VNode 层面冻结静态提升产出的对象,任何尝试修改的代码会在开发模式下报错。这是”为了安全地复用而付出的微小代价”——比起每次 render 都重建,这个代价小到忽略。
Vue 3 编译器最重要的优化之一是静态提升——将不会变化的 VNode 创建操作提取到渲染函数外部,避免每次渲染都重复创建:
// 模板
// <div>
// <span class="title">固定标题</span>
// <span>{{ message }}</span>
// </div>
// ❌ 未优化:每次渲染都创建所有 VNode
function render(_ctx) {
return createVNode('div', null, [
createVNode('span', { class: 'title' }, '固定标题'),
createVNode('span', null, _ctx.message)
])
}
// ✅ 静态提升后:静态节点只创建一次
const _hoisted_1 = createVNode('span', { class: 'title' }, '固定标题')
function render(_ctx) {
return createVNode('div', null, [
_hoisted_1, // 直接复用,不重新创建
createVNode('span', null, _ctx.message)
])
}
编译器是如何判断哪些节点可以提升的?
// compiler-core/src/transforms/hoistStatic.ts
function walk(
node: ParentNode,
context: TransformContext,
doNotHoistNode: boolean = false
) {
const { children } = node
for (let i = 0; i < children.length; i++) {
const child = children[i]
if (child.type === NodeTypes.ELEMENT) {
// 计算节点的静态类型
const staticType = getConstantType(child, context)
if (staticType > ConstantTypes.NOT_CONSTANT) {
if (staticType >= ConstantTypes.CAN_HOIST) {
// 标记为可提升
;(child.codegenNode as VNodeCall).patchFlag =
PatchFlags.HOISTED + ` /* HOISTED */`
// 将节点移到渲染函数外部
child.codegenNode = context.hoist(child.codegenNode!)
}
}
}
}
}
// 静态类型的分级
const enum ConstantTypes {
NOT_CONSTANT = 0, // 包含动态绑定
CAN_SKIP_PATCH = 1, // 可以跳过 Patch
CAN_HOIST = 2, // 可以提升到外部
CAN_STRINGIFY = 3 // 可以序列化为字符串(最高级别)
}
静态字符串化
比静态提升更激进的一步是——对连续的大段静态 HTML 直接编译成字符串。这段 VNode 不再逐节点创建,而是直接成为一个 createStaticVNode('<div class="foo">...</div>', 5) 调用,挂载时浏览器一次 innerHTML 就搞定。
这个优化的切入点是阈值——Vue 编译器只对”连续 N 个以上静态节点”做字符串化,N 的默认值是 5。N 过小的话字符串化反而不如 VNode(innerHTML 解析 HTML 也是要钱的),N 过大的话错过很多可优化场景。这个魔法数字来自 Vue 团队的基准测试——不是凭感觉选的,是用 1000+ 真实模板跑 benchmark 找到的最优点。开源框架里这样细致的数值调优并不常见,是 Vue 3 工程质量的一个侧面体现。
当连续的静态节点数量超过阈值(默认 20 个,或 5 个带属性的节点),编译器会将它们直接序列化为 HTML 字符串:
// 大量静态内容
// <div>
// <p>段落 1</p>
// <p>段落 2</p>
// ... (20+ 个静态段落)
// <p>段落 N</p>
// <p>{{ dynamic }}</p>
// </div>
// 编译结果:静态部分变成字符串
const _hoisted_1 = createStaticVNode(
'<p>段落 1</p><p>段落 2</p>...<p>段落 N</p>',
20 // 节点数量
)
function render(_ctx) {
return createVNode('div', null, [
_hoisted_1,
createVNode('p', null, _ctx.dynamic)
])
}
createStaticVNode 的实现使用 innerHTML 一次性设置所有静态节点,比逐个 createElement 快得多:
function mountStaticNode(
vnode: VNode,
container: RendererElement,
anchor: RendererNode | null,
namespace: ElementNamespace
) {
const nodes: RendererNode[] = []
// 使用 innerHTML 一次性创建所有节点
const template = document.createElement('template')
template.innerHTML = vnode.children as string
// 将所有子节点移到目标位置
const content = template.content
while (content.firstChild) {
nodes.push(content.firstChild)
container.insertBefore(content.firstChild, anchor)
}
// 记录首尾节点,用于后续移除
vnode.el = nodes[0]
vnode.anchor = nodes[nodes.length - 1]
}
PatchFlag 精确追踪
PatchFlag 是 Vue 3 最独特的设计之一——编译器看模板时就能判断”这个节点哪些部分可能变”,把这个信息用 bit flag 塞到 VNode 上,运行时 patch 只处理 flag 里标记的部分。
举个具体例子说明威力:模板 <div :class="cls">Hello {{ name }}</div>。编译器分析后标记这个 VNode 的 patchFlag 为 TEXT | CLASS,告诉运行时”这个节点的 text 子节点会变、class 属性会变、其他一切都是静态的”。运行时 patch 到这个节点时,跳过 style / id / 其他属性 / children 数组里非文本的部分——直接只 patch text 和 class。相比 Vue 2 每个节点都要遍历全部属性做对比,这种”精确制导”省下的工作量在大型应用里非常可观。
PatchFlag 的所有值也是位掩码形式,原因和 ShapeFlag 一样——多个维度可以 OR 组合(一个节点可能同时 TEXT + CLASS + STYLE 都变)、位判断比字符串比较快一个数量级。
PatchFlag 是 Vue 3 编译器最精巧的优化。它在编译时为每个动态节点标记”哪些部分是动态的”,运行时只比较标记的部分:
// 编译器生成的 PatchFlag
const enum PatchFlags {
TEXT = 1, // 动态文本
CLASS = 1 << 1, // 动态 class
STYLE = 1 << 2, // 动态 style
PROPS = 1 << 3, // 动态非 class/style 的属性
FULL_PROPS = 1 << 4, // 有动态 key 的属性
NEED_HYDRATION = 1 << 5, // 需要 hydration 的事件监听
STABLE_FRAGMENT = 1 << 6, // 子节点顺序不变的 Fragment
KEYED_FRAGMENT = 1 << 7, // 有 key 的 Fragment
UNKEYED_FRAGMENT = 1 << 8, // 无 key 的 Fragment
NEED_PATCH = 1 << 9, // 需要非 props 的 patch(ref、指令等)
DYNAMIC_SLOTS = 1 << 10, // 动态插槽
DEV_ROOT_FRAGMENT = 1 << 11, // 开发模式的根 Fragment
HOISTED = -1, // 静态提升的节点
BAIL = -2 // 放弃优化
}
// 模板:<div :class="cls" :style="stl" :id="id" @click="handler">
// 编译后:
createVNode('div', {
class: _ctx.cls,
style: _ctx.stl,
id: _ctx.id,
onClick: _ctx.handler
}, null,
PatchFlags.CLASS | PatchFlags.STYLE | PatchFlags.PROPS,
// ↑ 位运算组合:class + style + props
['id'] // 动态属性名列表(props 时需要)
)
运行时如何利用 PatchFlag:
// runtime-core/src/renderer.ts - patchElement
function patchElement(
n1: VNode,
n2: VNode,
parentComponent: ComponentInternalInstance | null,
// ...
) {
const el = (n2.el = n1.el!)
const oldProps = n1.props || EMPTY_OBJ
const newProps = n2.props || EMPTY_OBJ
const { patchFlag } = n2
if (patchFlag > 0) {
// 有 PatchFlag,精确更新
if (patchFlag & PatchFlags.FULL_PROPS) {
// 动态 key,需要全量 diff props
patchProps(el, n2, oldProps, newProps, parentComponent, ...)
} else {
// 按位检查,只更新变化的部分
if (patchFlag & PatchFlags.CLASS) {
if (oldProps.class !== newProps.class) {
hostPatchProp(el, 'class', null, newProps.class, ...)
}
}
if (patchFlag & PatchFlags.STYLE) {
hostPatchProp(el, 'style', oldProps.style, newProps.style, ...)
}
if (patchFlag & PatchFlags.PROPS) {
// 只检查声明的动态属性
const propsToUpdate = n2.dynamicProps!
for (let i = 0; i < propsToUpdate.length; i++) {
const key = propsToUpdate[i]
const prev = oldProps[key]
const next = newProps[key]
if (next !== prev || key === 'value') {
hostPatchProp(el, key, prev, next, ...)
}
}
}
if (patchFlag & PatchFlags.TEXT) {
if (n1.children !== n2.children) {
hostSetElementText(el, n2.children as string)
}
}
}
} else if (!optimized) {
// 没有 PatchFlag,回退到全量 diff
patchProps(el, n2, oldProps, newProps, parentComponent, ...)
}
}
PatchFlag 的威力在于将 O(n) 的属性比较降低为 O(1) 的位运算检查。一个有 10 个属性但只有 1 个是动态的元素,传统 Diff 需要比较 10 次,PatchFlag 只需比较 1 次。
Block Tree 与动态节点收集
Block Tree 是前面三项优化的集成装置——静态提升节省了 VNode 创建、静态字符串化压缩了大段静态节点、PatchFlag 把动态节点的变更方面精确标记;Block Tree 则把所有 dynamic VNode 登记在一个扁平数组里,让运行时 diff 一次性扫过这个数组、完全跳过静态节点。
这是第 11 章重点讲过的内容。本章在性能视角下补充一个观察:Block Tree 让 diff 的复杂度从 O(template_size) 降到 O(dynamic_nodes),而后者通常是 template_size 的 1%-10%。这个数量级的降低解释了为什么 Vue 3 的 benchmark 分数能比 Vue 2 高 2-3 倍——不是常数因子的改良,是复杂度的改良。丛书卷 Vue 3 第 11 章对这块的 diff 细节有详细拆解。
Block Tree 是配合 PatchFlag 使用的更高层次优化。它将组件的 VNode 树”拍平”,直接追踪所有动态节点:
graph TD
subgraph "传统 VNode 树(需要逐层遍历)"
A1["div"] --> B1["header(静态)"]
A1 --> C1["main"]
C1 --> D1["p(静态)"]
C1 --> E1["span(动态 :class)"]
C1 --> F1["p(静态)"]
A1 --> G1["footer(静态)"]
end
subgraph "Block Tree(直接定位动态节点)"
A2["div(Block Root)"]
A2 -.->|dynamicChildren| E2["span(PatchFlag: CLASS)"]
end
style E1 fill:#e74c3c,color:#fff
style E2 fill:#e74c3c,color:#fff
style A2 fill:#42b883,color:#fff
// Block 的创建
export function openBlock(disableTracking = false) {
blockStack.push(
(currentBlock = disableTracking ? null : [])
)
}
export function createBlock(
type: VNodeTypes,
props?: Record<string, any> | null,
children?: any,
patchFlag?: number,
dynamicProps?: string[]
): VNode {
return setupBlock(
createVNode(type, props, children, patchFlag, dynamicProps, true)
)
}
function setupBlock(vnode: VNode): VNode {
// 将收集到的动态节点附加到 Block 根节点
vnode.dynamicChildren = currentBlock || EMPTY_ARR
// 关闭当前 Block
closeBlock()
// 如果有父 Block,将当前节点注册为父 Block 的动态子节点
if (currentBlock) {
currentBlock.push(vnode)
}
return vnode
}
// 每个动态节点在创建时自动注册到当前 Block
export function createVNode(/* ... */): VNode {
// ...
if (
currentBlock &&
vnode.patchFlag !== PatchFlags.HOISTED &&
(vnode.patchFlag > 0 || shapeFlag & ShapeFlags.COMPONENT)
) {
// 将动态节点加入当前 Block 的收集列表
currentBlock.push(vnode)
}
return vnode
}
Patch 阶段利用 dynamicChildren 直接跳过静态子树:
function patchBlockChildren(
oldChildren: VNode[],
newChildren: VNode[],
// ...
) {
for (let i = 0; i < newChildren.length; i++) {
const oldVNode = oldChildren[i]
const newVNode = newChildren[i]
// 直接 patch 动态节点,不遍历整棵树
patch(oldVNode, newVNode, /* ... */)
}
}
18.2 响应式系统的性能优化
响应式是 Vue 核心魔力所在——写 state 就像写普通变量,UI 自动跟随。但”自动”是有代价的:每一次属性读取都经 Proxy 拦截,每一次修改都可能触发 effect 重跑。当数据结构复杂、组件数量多时,这些”看不见的开销”会在真实业务里累积成可见的卡顿。
本节讲的所有手段都是给响应式系统减负——让它只在真正需要响应的地方响应,在不需要的地方保持”愚钝”。丛书卷《Vue 3 设计与实现》第 4-5 章讲过响应式内部怎么做 track / trigger,本节是那一套机制的性能侧用户指南。
避免不必要的深层响应
深层响应是 Vue reactive 的默认语义——reactive(obj) 递归把所有嵌套属性变成响应式。这个默认值对简单业务对象很友好:你改任何字段都会自动触发更新。但对大对象(例如 10000 条的列表、复杂的配置树)就成了负担——每个字段代理一次、每次读取过一次 Proxy,内存和 CPU 都被消耗。
reactive() 默认创建深层响应式代理——对象的每一层嵌套都会被代理。对于大型、层次很深的数据结构,这是巨大的开销:
// ❌ 性能隐患:10000 个对象每个都被深度代理
const state = reactive({
items: generateItems(10000) // 每个 item 有 10+ 个嵌套属性
})
// 源码中的深层代理逻辑
function createGetter(isReadonly = false, shallow = false) {
return function get(target: Target, key: string | symbol, receiver: object) {
const res = Reflect.get(target, key, receiver)
track(target, TrackOpTypes.GET, key)
if (isObject(res)) {
// 关键:访问嵌套对象时,递归代理
return isReadonly ? readonly(res) : reactive(res)
// 每次访问都会检查/创建代理,虽然有缓存,但仍有开销
}
return res
}
}
// ✅ 优化:使用 shallowRef 或 shallowReactive
const state = shallowReactive({
items: generateItems(10000) // 只有 items 属性是响应式的,内部不代理
})
// 需要更新时:
function updateItem(index: number, newData: Partial<Item>) {
// 替换整个数组元素,触发浅层响应
state.items[index] = { ...state.items[index], ...newData }
// 或者替换整个数组
state.items = [...state.items]
}
shallowRef 与 triggerRef
shallowRef 和 triggerRef 是**“我知道我在做什么、请让响应式系统闭嘴”** 的 API。适用于两种场景:
- 整体替换的大对象:比如一个从后端加载的数据集,你总是整个换(
data.value = newList),不会修改内部字段——那用 shallowRef 比 ref 省掉一大堆无效的深层代理。 - 手动触发更新:比如一个 Map 或 Set(Vue 对这两者的响应式支持有限),你自己管理状态、需要更新时手动调 triggerRef 通知 effect。这比硬挤进 reactive 体系更干净。
这些 API 是 Vue 对开发者经验的让渡——“我们不强制你用默认响应式,如果你有性能或结构上的特殊需要,这些低级 API 给你完全控制权”。这种”有默认值但允许 opt-out”的设计在顶级框架里反复出现——React 的 useMemo / useCallback、Svelte 的 $state.raw、SolidJS 的 untrack,都是同一思想。
shallowRef 只追踪 .value 的变化,不代理内部结构:
const data = shallowRef<BigDataStructure>({
matrix: Array(1000).fill(null).map(() => Array(1000).fill(0)),
metadata: { /* ... */ }
})
// ❌ 这不会触发更新(内部修改不被追踪)
data.value.matrix[0][0] = 42
// ✅ 方式 1:替换整个值
data.value = { ...data.value, matrix: newMatrix }
// ✅ 方式 2:手动触发更新
data.value.matrix[0][0] = 42
triggerRef(data) // 强制触发依赖更新
// triggerRef 的实现非常简单
export function triggerRef(ref: Ref): void {
triggerRefValue(ref, DirtyLevels.Dirty)
}
computed 的惰性求值与缓存
computed 的性能价值常被低估——很多人把它当”能获得响应式的函数”来用,但它真正的威力在自动化的缓存。
一个 computed 的生命周期:第一次读取时计算、缓存结果并订阅依赖;依赖变化时标 dirty 但不立刻重算;下次读取时如果 dirty 才重算、否则返回缓存。这个”读取驱动的惰性求值”比手动 memoization 聪明的地方在于——它不依赖你手动列出依赖,而是靠响应式系统自动发现。
对比:手动 memoization 你得写 useMemo(() => fn(a, b), [a, b]),依赖数组漏了任何一个会导致陈旧值、多了任何一个会导致无效重算。computed 的依赖是运行时自动捕获的——fn 里读了什么、就订阅什么,完全不出错。这是响应式系统相比 vanilla JS 最实在的工程价值之一:用依赖自动化换掉依赖手动维护。
computed 是 Vue 性能优化的重要工具,但误用也会带来问题:
// computed 的内部实现(简化)
class ComputedRefImpl<T> {
private _value!: T
public readonly effect: ReactiveEffect<T>
public _dirty = true // 脏标记
constructor(getter: () => T) {
this.effect = new ReactiveEffect(getter, () => {
// scheduler:依赖变化时不立即重新计算,只标记为脏
if (!this._dirty) {
this._dirty = true
triggerRefValue(this)
}
})
}
get value() {
trackRefValue(this)
// 只在脏时重新计算
if (this._dirty) {
this._dirty = false
this._value = this.effect.run()!
}
return this._value
}
}
// ❌ 计算属性链过长
const a = ref(1)
const b = computed(() => a.value * 2)
const c = computed(() => b.value + 1)
const d = computed(() => c.value * 3)
const e = computed(() => d.value + b.value) // 依赖了 d 和 b
// a 变化 → b、c、d、e 依次标记为脏
// 如果 e 在模板中被使用,求值时会触发 d → c → b 的链式求值
// ✅ 减少中间层,直接计算
const result = computed(() => {
const base = a.value * 2
const step = base + 1
return step * 3 + base
})
watchEffect 的副作用清理
watchEffect 的陷阱集中在副作用上——订阅了一个 WebSocket、注册了一个事件监听、启动了一个 setInterval——这些副作用必须在 effect 重跑或组件卸载时清理,否则泄漏。Vue 提供的 onCleanup 参数就是为这个场景设计的。
很多项目的”内存越跑越胖”问题根源就在这里——写 watchEffect 时只想着”当 X 变了我要做 Y”,忘了”当 X 再变的时候上一次的 Y 要先撤销”。特别是涉及网络请求的场景(请求 A 还没回来、请求 B 又发了,结果 A 的响应覆盖了 B 的响应)——这既是性能问题,也是正确性问题。onCleanup 能帮你正确地 abort 上一次请求,是避免”race condition in async effects”的标准工具。
// ❌ 内存泄漏:每次触发都创建新的订阅
watchEffect(() => {
const subscription = eventBus.on('data', (data) => {
processData(data)
})
// subscription 永远不会被清理!
})
// ✅ 使用 onCleanup 清理副作用
watchEffect((onCleanup) => {
const subscription = eventBus.on('data', (data) => {
processData(data)
})
onCleanup(() => {
subscription.unsubscribe() // 下次执行前清理
})
})
18.3 组件级优化
组件级优化的核心在一句话:告诉框架”这块我保证不变”。你的断言越精确,框架省的工作越多。
v-once:一次性渲染
v-once 是最”强”的一种断言——“这块我保证永不变”。一旦挂上,那段 VNode 第一次 render 后就被标记为”永不参与任何后续 diff”。这在静态内容区域(如页头版权信息、静态宣传文案)非常有用。
但要提醒一点:v-once 是双刃剑。我见过团队为了”让这个报表快”到处随手加 v-once——等到产品经理要求报表支持”切换数据源重新渲染”时,才发现这些 v-once 都得逐一摘掉。过早的 v-once 是一种耦合债——把”这块不需要更新”的临时业务事实固化到代码里,以后需求变就得改代码。所以好的经验法则:默认不用 v-once;当 DevTools 显示某区块重渲染确实是瓶颈时再加。
// <div v-once>
// <ComplexChart :data="staticData" />
// </div>
// 编译后:缓存 VNode,后续渲染直接返回缓存
function render(_ctx, _cache) {
return _cache[0] || (
setBlockTracking(-1), // 暂停 Block 追踪
_cache[0] = createVNode('div', null, [
createVNode(ComplexChart, { data: _ctx.staticData })
]),
setBlockTracking(1), // 恢复
_cache[0]
)
}
setBlockTracking(-1) 是关键——它暂停了动态节点收集,这意味着 v-once 内部的动态绑定不会被加入 dynamicChildren,后续更新会完全跳过这个子树。
v-memo:条件性缓存
v-memo 是比 v-once 柔软一个层级的断言——“除非这些依赖变了、否则这块不变”。语法上传一个依赖数组,运行时对比数组浅相等则跳过整段 diff。
这个”浅相等即跳过”的语义和 React.memo 的 props shallow-compare 模式完全一致,也和 React.useMemo 的依赖数组一样。同一套思路在 Vue 和 React 里表现为不同的 API,但心智模型可以无缝迁移。如果你之前写过 React.memo,看到 v-memo 基本零学习成本——只是把”整个 props 浅比”换成”你指定的依赖数组浅比”,粒度更细。
v-memo 是 Vue 3.2 引入的更灵活的缓存指令:
// <div v-for="item in list" :key="item.id" v-memo="[item.selected]">
// <HeavyComponent :data="item" />
// </div>
// 编译后:
function render(_ctx, _cache) {
return openBlock(true), createBlock(Fragment, null,
renderList(_ctx.list, (item, _, __, _cached) => {
// v-memo 检查:如果 memo 依赖没变,返回缓存
const _memo = [item.selected]
if (_cached && isMemoSame(_cached, _memo)) {
return _cached
}
const _block = createVNode('div', { key: item.id }, [
createVNode(HeavyComponent, { data: item })
])
_block.memo = _memo
return _block
}),
128 /* KEYED_FRAGMENT */
)
}
// isMemoSame 的实现
function isMemoSame(cached: VNode, memo: any[]): boolean {
const prev = cached.memo!
if (prev.length !== memo.length) return false
for (let i = 0; i < prev.length; i++) {
if (hasChanged(prev[i], memo[i])) return false
}
// 阻止此节点进入 Block 的 dynamicChildren
// 因为它没有变化,不需要 patch
if (currentBlock) {
currentBlock.push(cached)
}
return true
}
v-memo 在长列表中的效果惊人。假设一个 1000 项的列表,只有 1 项的 selected 状态变了,传统渲染需要 diff 1000 个组件,v-memo 只需 diff 1 个。
KeepAlive 的缓存策略
KeepAlive 解决的是跨路由或跨条件渲染时的组件状态保留问题。典型场景:Tab 切换时你不希望”切回来丢失已输入的表单”、路由前进后退时希望列表滚动位置回到原处。没 KeepAlive 时组件一 unmount 就全部丢掉、再切回来要重新初始化(重新拉数据、重新绑定事件、重新算布局)——用户体验和请求成本都很差。
KeepAlive 的实现思路其实和浏览器的 bfcache(back-forward cache)异曲同工:把 DOM 节点和组件实例保留在内存里、不拆开、不销毁生命周期。唯一的区别是 KeepAlive 主动控制这个缓存是否生效,bfcache 是浏览器隐式管理。丛书卷《React 19 源码解读》提过 React 曾经有一个 Offscreen 概念对标 KeepAlive,但一直实验到 React 19 才正式稳定。Vue 在这块上一直领先,原因仍然是模板 DSL 的约束让缓存边界更容易识别。
// KeepAlive 的核心:LRU 缓存
const KeepAliveImpl = {
setup(props, { slots }) {
const cache = new Map<CacheKey, VNode>()
const keys = new Set<CacheKey>()
const instance = getCurrentInstance()!
// 缓存当前组件
function cacheSubtree() {
if (pendingCacheKey != null) {
cache.set(pendingCacheKey, getInnerChild(instance.subTree))
}
}
onMounted(cacheSubtree)
onUpdated(cacheSubtree)
return () => {
const children = slots.default!()
const vnode = children[0]
const key = vnode.key ?? vnode.type
const cachedVNode = cache.get(key)
if (cachedVNode) {
// 命中缓存:复用组件实例
vnode.el = cachedVNode.el
vnode.component = cachedVNode.component
// 标记为 KEPT_ALIVE,跳过挂载流程
vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
} else {
// 未命中:可能需要淘汰旧缓存
keys.add(key)
// LRU 淘汰
if (props.max && keys.size > parseInt(props.max as string, 10)) {
// 删除最早加入的 key
const oldest = keys.values().next().value
pruneCacheEntry(oldest)
}
}
// 标记为 SHOULD_KEEP_ALIVE,卸载时不销毁而是停用
vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
return vnode
}
}
}
18.4 列表渲染优化
前端性能 90% 的问题出在列表渲染——节点数量是问题规模的主变量。一个 10 条的列表怎么写都不会卡,一个 10000 条的列表写错一处就卡到爆。这一节讲的两件事(正确的 key + 虚拟滚动)几乎是所有中大型前端应用的性能救命稻草。
key 的重要性
key 几乎是 Vue 性能话题里被错用最多的 API。绝大多数的”列表卡顿”最终追查下来都是 key 的问题。
三种典型错误:
- 不写 key:列表更新退化为位置对比。如果列表只追加、只从尾部删除,位置对比恰好和身份对比等效,不会出 bug——于是很多开发者以为”不写 key 也能跑”。等到某天需要在列表头部插入或者排序,隐藏的 bug 全部爆出来(组件内部 state 错位、过渡动画乱套)。
- 用 index 作 key:和不写 key 等效。index 会随列表变化而变,根本不是”稳定身份”。
- key 重复:两条 item 拿到了同一个 key(比如数据里本来就有重复 id)。Diff 算法相信 key 唯一,遇到重复会出现未定义行为——Vue 开发模式下会 warn,生产模式下结果可能是某个 item 永远不更新。
正确的 key 是”数据本身的天然稳定标识”——数据库主键、UUID、业务上保证唯一不变的字段。这是列表渲染 bug 最少、Diff 效率最高的前提。
key 的细节下一节详细拆解,这里只想留一句抓手:任何时候写 v-for 忘了 :key,立刻补上——这是列表渲染里最便宜、收益最高的一次性投入。
key 不仅是一个”最佳实践”,它直接影响 Diff 算法的执行路径:
// runtime-core/src/renderer.ts - patchKeyedChildren(简化)
function patchKeyedChildren(
c1: VNode[], // 旧子节点
c2: VNode[], // 新子节点
container: RendererElement,
// ...
) {
let i = 0
const l2 = c2.length
let e1 = c1.length - 1
let e2 = l2 - 1
// 1. 从头部同步
while (i <= e1 && i <= e2) {
const n1 = c1[i]
const n2 = c2[i]
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, ...)
} else {
break
}
i++
}
// 2. 从尾部同步
while (i <= e1 && i <= e2) {
const n1 = c1[e1]
const n2 = c2[e2]
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, ...)
} else {
break
}
e1--
e2--
}
// 3. 新增节点
if (i > e1 && i <= e2) {
while (i <= e2) {
patch(null, c2[i], container, ...)
i++
}
}
// 4. 删除节点
else if (i > e2) {
while (i <= e1) {
unmount(c1[i], ...)
i++
}
}
// 5. 未知序列:最长递增子序列算法
else {
const s1 = i
const s2 = i
// 建立新节点 key → index 的映射
const keyToNewIndexMap = new Map<string | number | symbol, number>()
for (i = s2; i <= e2; i++) {
keyToNewIndexMap.set(c2[i].key!, i)
}
// 寻找最长递增子序列,最小化 DOM 移动
const toBePatched = e2 - s2 + 1
const newIndexToOldIndexMap = new Array(toBePatched).fill(0)
let moved = false
let maxNewIndexSoFar = 0
for (i = s1; i <= e1; i++) {
const prevChild = c1[i]
const newIndex = keyToNewIndexMap.get(prevChild.key!)
if (newIndex === undefined) {
unmount(prevChild, ...) // 旧节点不在新列表中,删除
} else {
newIndexToOldIndexMap[newIndex - s2] = i + 1
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
moved = true // 检测到顺序变化
}
patch(prevChild, c2[newIndex], container, ...)
}
}
// 用最长递增子序列确定哪些节点需要移动
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR
// 从后向前遍历,确保插入位置正确
let j = increasingNewIndexSequence.length - 1
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i
const nextChild = c2[nextIndex]
const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : parentAnchor
if (newIndexToOldIndexMap[i] === 0) {
// 新增节点
patch(null, nextChild, container, anchor, ...)
} else if (moved) {
if (j < 0 || i !== increasingNewIndexSequence[j]) {
// 需要移动
move(nextChild, container, anchor, MoveType.REORDER)
} else {
j-- // 在递增子序列中,不需要移动
}
}
}
}
}
最长递增子序列(LIS)算法是 Vue 3 Diff 的核心创新——它确保 DOM 移动次数最少。假设旧列表是 [A, B, C, D, E],新列表是 [B, D, A, C, E],LIS 找到 [B, D, E](这些节点保持相对顺序),只需要移动 A 和 C。
虚拟滚动的实现原理
虚拟滚动的本质是欺骗视觉系统——用户以为自己在滚动一个 10000 条的长列表,其实 DOM 里始终只有视口里那十几条可见的元素。其他 9000 多条从没进入过 DOM,它们只存在于 JavaScript 数组里。
这个概念听起来像魔法,实际上原理朴素得惊人:监听 scrollTop,算出应该显示数组的哪一段,用 transform 或 padding 把可见段定位在滚动条应该的位置。难点不在原理,在细节——每一个滚动帧都要重算、每一次尺寸变化都要重测、滚动过快要不要 throttle、动态高度的 item 怎么处理、什么时候 preserveScrollPosition 什么时候不。
这些细节是”虚拟滚动库有 N 多个但生产级就那几个”的原因。读下面的实现骨架时,把自己想象成一个写库的作者——你会看到每一行代码都在处理一个具体的边界情况。
当列表项多达数万条时,即使 Diff 再快,渲染如此多的 DOM 节点本身就是瓶颈。虚拟滚动的核心思想是只渲染可视区域内的节点:
graph LR
subgraph "可视区域"
V1["Item 51"]
V2["Item 52"]
V3["..."]
V4["Item 60"]
end
subgraph "缓冲区(上)"
B1["Item 48"]
B2["Item 49"]
B3["Item 50"]
end
subgraph "缓冲区(下)"
B4["Item 61"]
B5["Item 62"]
B6["Item 63"]
end
UP["↑ 上方占位<br/>height: 4700px"] --> B1
B6 --> DOWN["↓ 下方占位<br/>height: 93700px"]
style V1 fill:#42b883,color:#fff
style V2 fill:#42b883,color:#fff
style V3 fill:#42b883,color:#fff
style V4 fill:#42b883,color:#fff
// 虚拟滚动的核心逻辑
interface VirtualScrollState {
startIndex: number // 渲染起始索引
endIndex: number // 渲染结束索引
offsetTop: number // 顶部占位高度
offsetBottom: number // 底部占位高度
}
function useVirtualScroll<T>(
items: Ref<T[]>,
containerRef: Ref<HTMLElement | null>,
options: {
itemHeight: number // 固定行高(变高度需要更复杂的方案)
overscan?: number // 上下缓冲区大小
}
) {
const { itemHeight, overscan = 5 } = options
const state = reactive<VirtualScrollState>({
startIndex: 0,
endIndex: 0,
offsetTop: 0,
offsetBottom: 0
})
const visibleItems = computed(() => {
return items.value.slice(state.startIndex, state.endIndex)
})
const totalHeight = computed(() => items.value.length * itemHeight)
function onScroll() {
const container = containerRef.value
if (!container) return
const scrollTop = container.scrollTop
const viewportHeight = container.clientHeight
// 计算可见范围
const start = Math.floor(scrollTop / itemHeight)
const visibleCount = Math.ceil(viewportHeight / itemHeight)
// 加上缓冲区
state.startIndex = Math.max(0, start - overscan)
state.endIndex = Math.min(
items.value.length,
start + visibleCount + overscan
)
// 计算占位高度
state.offsetTop = state.startIndex * itemHeight
state.offsetBottom = (items.value.length - state.endIndex) * itemHeight
}
onMounted(() => {
containerRef.value?.addEventListener('scroll', onScroll, { passive: true })
onScroll() // 初始计算
})
onUnmounted(() => {
containerRef.value?.removeEventListener('scroll', onScroll)
})
return { visibleItems, state, totalHeight }
}
18.5 异步组件与代码分割
代码分割解决的不是”执行慢”而是”加载慢”——首屏下载多少字节的 JS 直接决定 TTFP/TTI。一个 5MB 的 bundle 在 4G 下要 5 秒以上才能下完解析完,这期间屏幕一片白。
Vue 的异步组件把”组件定义”从”同步加载”解耦——让你声明式地把某个组件标记为”懒加载的”,框架配合构建工具(Vite / Webpack)自动把它打成单独 chunk,只在真正需要的时候才下载。这是前端工程里”按需加载”最成熟的实现。
defineAsyncComponent 的加载策略
defineAsyncComponent 不只是”把 import 延迟到渲染时”——它还处理了一堆加载过程中的用户体验问题:加载中显示什么(loadingComponent)、加载失败显示什么(errorComponent)、多久算超时(timeout)、最短展示加载态多久(delay,防止快速加载完的闪烁)。
// defineAsyncComponent 的完整选项
const AsyncComponent = defineAsyncComponent({
loader: () => import('./HeavyComponent.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200, // 200ms 后才显示 loading(避免闪烁)
timeout: 10000, // 10 秒超时
suspensible: false, // 是否配合 Suspense
onError(error, retry, fail, attempts) {
if (attempts <= 3) {
retry() // 自动重试
} else {
fail()
}
}
})
// 内部实现(简化)
function defineAsyncComponent(source: AsyncComponentLoader | AsyncComponentOptions) {
const {
loader,
loadingComponent,
errorComponent,
delay = 200,
timeout,
onError: userOnError
} = normalizeSource(source)
let resolvedComp: ConcreteComponent | undefined
const load = (): Promise<ConcreteComponent> => {
return loader()
.catch(err => {
if (userOnError) {
return new Promise((resolve, reject) => {
const retry = () => resolve(load())
const fail = () => reject(err)
userOnError(err, retry, fail, retries++)
})
}
throw err
})
.then(comp => {
resolvedComp = comp
return comp
})
}
return defineComponent({
setup() {
const loaded = ref(false)
const error = ref<Error>()
const delayed = ref(!!delay)
if (delay) {
setTimeout(() => { delayed.value = false }, delay)
}
if (timeout != null) {
setTimeout(() => {
if (!loaded.value && !error.value) {
error.value = new Error(`Async component timed out after ${timeout}ms.`)
}
}, timeout)
}
load()
.then(() => { loaded.value = true })
.catch(err => { error.value = err })
return () => {
if (loaded.value && resolvedComp) {
return createVNode(resolvedComp)
} else if (error.value && errorComponent) {
return createVNode(errorComponent, { error: error.value })
} else if (!delayed.value && loadingComponent) {
return createVNode(loadingComponent)
}
}
}
})
}
路由级代码分割
路由级分割是最常用、ROI 最高的分割策略——每个路由对应一个独立 chunk,访问到哪个路由才加载哪个。对多页后台系统特别有效,首屏只需要下首页的 20-50KB,剩下的几 MB 代码按用户访问路径 on-demand 加载。
这块的实现细节涉及到 Vue Router 的导航流程——丛书卷前文第 16 章(Vue Router 内核)讲过 navigate 函数里 “第 5 步:解析异步路由组件”就是专门处理路由懒加载的。读者应该有个整体感知:代码分割是编译链路 + 运行时框架 + 构建工具三者配合的结果,少一环都不成立。
代码分割带来的另一个问题是”部署后旧 chunk 失效”——第 16 章 18.5 小节提到过。生产上建议配合 Vite 的 build.rollupOptions.output.entryFileNames 定义好 content-hash 命名、在 CDN 保留旧 chunk 一段时间;配合 router.onError 做”chunk 加载失败 → 全局刷新”的兜底——两道防线共同保障用户不会因为部署而卡在”点击无反应”的状态。这是现代 SPA 和传统 MPA 最不同的运维挑战之一,值得单独做成团队的 checklist。
// 结合 Vue Router 的最佳实践
const routes = [
{
path: '/dashboard',
component: () => import('./views/Dashboard.vue'),
// Vite 会为每个动态 import 生成独立的 chunk
},
{
path: '/settings',
component: () => import('./views/Settings.vue'),
},
{
path: '/reports',
// 预加载:鼠标悬停在链接上时开始加载
component: () => import(/* webpackPrefetch: true */ './views/Reports.vue'),
}
]
// 预加载策略
const router = createRouter({ routes, history: createWebHistory() })
router.beforeResolve(async (to) => {
// 路由解析前,预加载下一个页面可能需要的组件
const matched = to.matched
for (const record of matched) {
if (typeof record.components?.default === 'function') {
await (record.components.default as () => Promise<any>)()
}
}
})
18.6 内存管理与泄漏预防
这一节我想用”故事式”来讲——因为内存泄漏都是工程师犯过的具体错误。抽象讲”要释放引用、要解绑监听器”没用,必须看到真实的代码长什么样、在什么情境下被写下来、最后为什么泄漏。
前端内存泄漏的根源可以归纳为”对某个不再使用的对象的隐性引用”——对象本该被 GC 回收,但因为某处还有引用链指向它,就一直活着。组件 unmount 了但 event listener 还挂着、定时器还在 tick、watchEffect 还在订阅——这些都是典型模式。本节把最容易踩的四种逐个拆给你看。
常见的内存泄漏模式
下面列的四种模式是我在 code review 中见过的”真实在生产代码里出现过”的泄漏案例,不是理论假设。每一种都有明确的诊断方法和修复方法。
// ❌ 泄漏 1:全局事件未清理
export default {
setup() {
window.addEventListener('resize', handleResize)
// 组件卸载后,handleResize 仍然被调用
// 且通过闭包引用了组件的响应式数据
// ✅ 修复
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
}
}
// ❌ 泄漏 2:定时器未清理
export default {
setup() {
const timer = setInterval(() => {
fetchData() // 组件卸载后仍在运行
}, 5000)
// ✅ 修复
onUnmounted(() => clearInterval(timer))
}
}
// ❌ 泄漏 3:第三方库实例未销毁
export default {
setup() {
let chart: EChartsInstance
onMounted(() => {
chart = echarts.init(chartRef.value)
chart.setOption(/* ... */)
})
// ✅ 修复
onUnmounted(() => {
chart?.dispose()
})
}
}
// ❌ 泄漏 4:闭包持有大对象
export default {
setup() {
const hugeData = ref(loadHugeDataset()) // 100MB 数据
const summary = computed(() => {
// 这个 computed 通过闭包持有 hugeData 的引用
return hugeData.value.reduce(/* ... */)
})
// 即使 hugeData 不再在模板中使用,
// 只要 summary 存在,hugeData 就不会被 GC
// ✅ 修复:使用后释放
const summaryValue = ref(null)
onMounted(() => {
const data = loadHugeDataset()
summaryValue.value = data.reduce(/* ... */)
// data 在函数结束后可被 GC
})
}
}
响应式系统的内存模型
Vue 3 响应式的内存模型比想象中”轻”——得益于 WeakMap 的使用。
理解这一点需要先知道 reactive 内部怎么组织:每个 reactive 对象对应一个 targetMap,key 是对象本身、value 是该对象的每个属性 → 订阅这个属性的 effect 集合。这个 targetMap 是全局的、单例的。
如果这个 Map 用普通 Map,即使组件 unmount、reactive 对象不再被业务引用,Map 里还有一条 obj → subscribers 记录保持强引用,导致 obj 永远不会被 GC。Vue 3 聪明地选择了 WeakMap——key 上的引用不计入 GC 判断。对象一旦业务侧没有引用,即使 WeakMap 里还有 entry,也会被 GC 掉;WeakMap 的 entry 自动删除。
这种”用 WeakMap 实现天然 GC 友好的索引”是 JavaScript 现代内存管理的标配。丛书卷《JavaScript 引擎内幕》(如果你以后写 V8 相关书)会深入到 V8 的 HiddenClass 和 GC 标记阶段解释 WeakMap 的底层——这里只要理解:Vue 的响应式系统不会因为自身结构阻止你的业务对象被 GC,是很硬核的设计选择。
// 每个 reactive 对象的内存开销
// 1. 原始对象本身
// 2. Proxy 实例(约 100 bytes 额外开销)
// 3. 依赖映射 Map<target, Map<key, Set<effect>>>
// targetMap 的结构(全局的依赖追踪表)
const targetMap = new WeakMap<object, Map<string | symbol, Set<ReactiveEffect>>>()
// ^^^^^^^^ WeakMap 允许 target 被 GC
// 当组件卸载时:
// 1. 组件的 ReactiveEffect 被停止(effect.stop())
// 2. effect 从所有依赖的 Set 中移除
// 3. 如果 target 不再被引用,WeakMap 允许整个条目被 GC
function stop(effect: ReactiveEffect) {
if (effect.active) {
// 从所有依赖集合中清除此 effect
cleanupEffect(effect)
if (effect.onStop) {
effect.onStop()
}
effect.active = false
}
}
function cleanupEffect(effect: ReactiveEffect) {
const { deps } = effect
if (deps.length) {
for (let i = 0; i < deps.length; i++) {
deps[i].delete(effect)
}
deps.length = 0
}
}
18.7 渲染性能分析
前面讲的都是”知道的可以怎么优化”;这一节讲的是”不知道的怎么定位”——在真实的业务应用里,性能问题往往不在你以为的地方,必须靠工具测出来。
不依赖测量的优化是猜测——丛书卷《Harness Engineering》第 6 章讲过”profile first, optimize second”的原则。Vue 的场景下,这个原则具体化为三件事:先用 DevTools Timeline 找到是哪次 render 花了异常多的时间、再用 Performance 面板看这次 render 的火焰图里哪个函数占大头、最后用自定义性能埋点对比优化前后。一个完整的调优 cycle 就是这三步的循环。
Vue DevTools 的性能面板
Vue DevTools 的 Performance 面板是每个 Vue 开发者都应该熟练使用的工具。它能录制一段时间内所有组件的 render 次数、每次 render 耗时、触发原因——这些信息用纯代码是捞不到的。
Vue DevTools 提供了组件级别的渲染性能追踪:
// Vue 内部的性能追踪 hook
if (__DEV__) {
// 组件渲染开始
startMeasure(instance, 'render')
const subTree = (instance.subTree = renderComponentRoot(instance))
// 组件渲染结束
endMeasure(instance, 'render')
// Patch 开始
startMeasure(instance, 'patch')
patch(prevTree, nextTree, /* ... */)
// Patch 结束
endMeasure(instance, 'patch')
}
// 性能标记使用 Performance API
function startMeasure(instance: ComponentInternalInstance, type: string) {
if (instance.appContext.config.performance && isSupported) {
perf.mark(`vue-${type}-${instance.uid}`)
}
}
function endMeasure(instance: ComponentInternalInstance, type: string) {
if (instance.appContext.config.performance && isSupported) {
const startTag = `vue-${type}-${instance.uid}`
const endTag = startTag + ':end'
perf.mark(endTag)
perf.measure(
`<${formatComponentName(instance, instance.type)}> ${type}`,
startTag,
endTag
)
perf.clearMarks(startTag)
perf.clearMarks(endTag)
}
}
自定义性能监控
DevTools 只能在开发环境用,线上要看真实用户的性能数据必须自己上报。自定义性能监控用 performance.mark / performance.measure API 给关键事件打点,然后配合 sentry / bugsnag / 自建的上报体系送到后端。这是从”开发期主观调优”走向”生产期数据驱动调优”的分水岭。
// 组件渲染次数追踪
function useRenderCount(componentName: string) {
if (__DEV__) {
let count = 0
onRenderTracked((event) => {
console.log(`[${componentName}] 依赖追踪:`, event)
})
onRenderTriggered((event) => {
count++
console.log(`[${componentName}] 第 ${count} 次重渲染,触发者:`, event)
if (count > 50) {
console.warn(
`[${componentName}] 渲染次数过多(${count}),可能存在性能问题`
)
}
})
}
}
// 使用
export default {
setup() {
useRenderCount('MyComponent')
// ...
}
}
18.8 Tree-shaking 与包体积
Bundle size 是性能工作里最容易被忽视但最直接影响体验的维度——前面讲的 render 优化、diff 优化再好,如果首屏要下载 3MB 的 JS,用户还是要等 5 秒才能看到内容。
Vue 3 最被 React 社区羡慕的一点就是 tree-shakable:没用到的 API 不会进你的 bundle。这在 Vue 2 是做不到的(因为 Vue 2 的 API 挂在 Vue 实例上、全量打包),Vue 3 通过”纯函数 API + ES Modules”的重构实现了这一点。下面拆这件事的两个层面——官方 API 的 tree-shake 语义、第三方组件库的按需导入策略。
Vue 3 的 Tree-shaking 设计
Vue 3 为了 tree-shakable 做了一个API 架构上的大手术——从 Vue 2 的”所有 API 挂在 Vue 实例上”(Vue.nextTick、Vue.set)改成”每个 API 都是独立的 ES Module export”(import { nextTick, set } from 'vue')。
这个改动在使用体验上只是多写一次 import,但在 bundler 层面是决定性的——因为 ES Modules 的 import 可以被静态分析,bundler 能精确知道你用了哪些 API、没用哪些;Vue 2 的实例挂载模式下,bundler 只能保守地打包整个 Vue。这个差别让 Vue 3 的”只用 ref / reactive”的最小应用 bundle 可以压到 13KB gzipped,而 Vue 2 是 30KB+。
丛书卷《Vite 设计与实现》深入讲过 ES Modules 的 tree-shaking 机制;丛书卷《微前端三派源码解读》讲到 Module Federation 时也涉及到 ESM 的动态加载特性——这三本书放一起读,你会看到ESM 是整个现代前端基础设施的共同底座,Vue 3 的架构改造只是这个趋势里的一个节点。
Vue 3 的 API 全部采用命名导出,使得未使用的功能可以被打包工具移除:
// Vue 3:只导入使用的 API
import { ref, computed, watch } from 'vue'
// Transition、KeepAlive、Teleport 等如果未使用,不会进入最终 bundle
// 编译时特性标志(Feature Flags)
// 通过 define 插件在编译时替换为常量
if (__VUE_OPTIONS_API__) {
// Options API 相关代码
// 如果设置为 false,这整块代码会被 tree-shake
}
if (__VUE_PROD_DEVTOOLS__) {
// 生产环境 DevTools 支持
// 默认 false,会被移除
}
// vite.config.ts 中配置
export default defineConfig({
define: {
__VUE_OPTIONS_API__: false, // 不用 Options API 可节省 ~10KB
__VUE_PROD_DEVTOOLS__: false,
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false
}
})
按需导入组件库
现代组件库(Element Plus / Naive UI / Vant 4)都支持”按需自动导入”——用 unplugin-vue-components / unplugin-auto-import 这类插件,你写了 <ElButton> 它自动解析并只打包 Button 组件。相比 Vue 2 时代满屏的 import { Button } from 'element-ui',开发体验和 bundle 优化同时兼顾。
推荐的配置模板见各组件库官方文档;本书不在这里展开。这类”开发时人工友好、构建时精确分析”的工具链是 Vue 3 生态相对 Vue 2 最肉眼可见的进步之一。
// ❌ 全量导入:引入整个组件库
import ElementPlus from 'element-plus'
app.use(ElementPlus) // 打包体积 800KB+
// ✅ 按需导入:只引入使用的组件
import { ElButton, ElInput, ElTable } from 'element-plus'
app.component('ElButton', ElButton)
// ✅ 更好的方式:使用 unplugin-vue-components 自动按需导入
// vite.config.ts
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
Components({
resolvers: [ElementPlusResolver()]
// 模板中使用 <ElButton> 时自动导入,无需手动 import
})
]
})
18.9 性能优化检查清单
前面八节讲的每个点都值得反复咀嚼,但真正做项目的时候你需要一份可落地的清单——不是背诵知识点,是按顺序过的检查流程。下面的清单我在多次带队优化中反复提炼,基本可以覆盖 90% 的前端性能工作。
graph TD
A["性能问题"] --> B{"首屏慢?"}
A --> C{"交互卡顿?"}
A --> D{"包体积大?"}
B -->|是| B1["SSR / SSG"]
B -->|是| B2["路由懒加载"]
B -->|是| B3["关键 CSS 内联"]
B -->|是| B4["预加载 / 预连接"]
C -->|是| C1["检查不必要的重渲染"]
C -->|是| C2["v-memo / v-once"]
C -->|是| C3["虚拟滚动"]
C -->|是| C4["shallowRef/Reactive"]
C -->|是| C5["Web Worker"]
D -->|是| D1["Tree-shaking"]
D -->|是| D2["动态导入"]
D -->|是| D3["Gzip / Brotli"]
D -->|是| D4["关闭 Options API"]
style A fill:#e74c3c,color:#fff
style B fill:#f39c12,color:#fff
style C fill:#f39c12,color:#fff
style D fill:#f39c12,color:#fff
以下是从源码层面总结的性能优化优先级:
| 优化手段 | 影响范围 | 实施难度 | 源码原理 |
|---|---|---|---|
| 路由懒加载 | 首屏体积 | 低 | defineAsyncComponent + 动态 import() |
| v-memo | 列表渲染 | 低 | isMemoSame 跳过整个子树的 patch |
| shallowRef/Reactive | 大数据响应 | 中 | 避免 Proxy 递归代理内部属性 |
| computed 缓存 | 派生数据 | 低 | _dirty 标记 + 惰性求值 |
| 虚拟滚动 | 长列表 | 中 | 只渲染可视区 DOM,上下占位 |
| KeepAlive | 页面切换 | 低 | LRU 缓存组件实例,避免销毁重建 |
| 关闭 Options API | 包体积 | 低 | __VUE_OPTIONS_API__ 编译时剔除 |
| SSR/SSG | 首屏速度 | 高 | 服务端渲染 + Hydration |
| Web Worker | 计算密集 | 高 | 将计算移出主线程 |
18.10 本章小结
这一章看似讲”怎么让 Vue 跑得快”,其实更大的收获是——怎么在一个复杂系统里思考性能。
Vue 3 的性能哲学可以浓缩成一条线:把能在编译期确定的事情在编译期做完,让运行时只做不可避免的工作。静态提升、静态字符串化、PatchFlag、Block Tree——四个编译期优化全部围绕这条主线。响应式优化、KeepAlive、异步组件——四个运行期手段围绕的是”在不可避免的工作里让开发者有精确控制权”。这两条线一头一尾扎起来,是 Vue 3 性能工程的全部。
延伸阅读的几个方向
从本章出发,能串起丛书里一串相关的主线:
- 丛书卷《Vue 3 设计与实现》第 4-5 章:响应式的原理。本章第 18.2 节每个”避免某某”的建议,根源都在那两章讲的 track/trigger 机制。
- 丛书卷《Vue 3 设计与实现》第 11 章(本书):VDOM 和 Diff。本章讲的 Block Tree / PatchFlag 都在第 11 章有完整源码拆解。
- 丛书卷《Vue 3 设计与实现》第 12 章:调度器与异步更新。本章没细讲的”一次微任务内合并多次状态变化”就是调度器做的事。
- 丛书卷《Vite 设计与实现》:整本书都是讲”现代构建工具如何配合框架做 tree-shaking、代码分割、HMR”。本章第 18.5 / 18.8 的内容在 Vite 视角下还会反复出现。
- 丛书卷《React 19 源码解读》:对比视角。React 和 Vue 在性能命题上选了不同的路,对比读能看出”面对同一个问题,不同假设下的不同解法”——这种能力在工程中极有价值。
终极建议
不要为了”优化而优化”。前面讲的所有手段,只有在 DevTools Performance 能明确指出瓶颈时才上。过早的优化不止浪费时间、还污染代码——一个普通的 v-for 加了 v-memo,一个简单的组件包了 KeepAlive,代码复杂度上升、阅读成本上升,但真实收益几乎为零。
判断标准:DevTools Performance 指出瓶颈才上优化。没有 profile 数据支撑的优化,既浪费时间又增加阅读成本。
性能优化的本质是减少不必要的工作。Vue 3 在框架层面做了大量优化:
- 编译时:静态提升减少 VNode 创建、PatchFlag 精确标记动态内容、Block Tree 拍平动态节点
- 运行时:Proxy 惰性代理、computed 惰性求值与缓存、最长递增子序列最小化 DOM 移动
- 组件级:v-once 一次性渲染、v-memo 条件缓存、KeepAlive LRU 缓存、异步组件延迟加载
- 应用级:Tree-shaking 移除未使用代码、路由懒加载分割 chunk、SSR 优化首屏
理解这些优化的源码原理,比死记优化手段更重要。当你知道 shallowRef 之所以更快是因为它避免了 Proxy 递归代理,你就能准确判断什么场景该用它、什么场景不需要。
性能优化的铁律是:先测量,再优化。不要过早优化,不要凭直觉优化,用 Chrome DevTools、Vue DevTools 和 Lighthouse 找到真正的瓶颈,然后对症下药。
思考题
-
静态提升会增加内存使用(提升的 VNode 永远不会被 GC),在什么场景下这可能成为问题?如何权衡内存与 CPU 的取舍?
-
PatchFlag 使用位运算组合多种标记。为什么选择位运算而不是数组或 Set?从 V8 引擎的角度分析它的性能优势。
-
假设一个 computed 的 getter 函数执行耗时 100ms,它的 5 个依赖中有一个频繁变化(每秒 60 次)。使用
computed还是手动watch + debounce更合适?分析两种方案的执行时序。 -
虚拟滚动在变高度列表(每行高度不固定)场景下会遇到什么难题?设计一个支持变高度的虚拟滚动方案。
-
Vue 3 的 Block Tree 在
v-if/v-for场景下会退化为什么?为什么这些结构需要创建新的 Block?