Vue.js性能优化实践 - 基于内部机制的深度优化策略

响应式的开销落在初始化那一遍遍历上,所以 Object.freeze 和 shallowRef 能省掉它。v-memo、虚拟滚动、keep-alive 各自拿什么换什么,以及为什么这些数字只有在生产构建上量才算数。

位置
第 12 篇 / 共 12 篇
预计
14 分钟

页面卡了,先分清是哪一种卡

一个一万行的表格页面,在搜索框里敲一个字,要等小半秒才有反应。这句描述对不上任何一种具体原因 —— 至少有四件不同的事都会长成这个样子,而它们的账是分开算的。

graph TB
A[性能问题] --> B[渲染性能]
A --> C[运行时性能]
A --> D[内存性能]
A --> E[网络性能]
B --> B1[首屏渲染]
B --> B2[组件更新]
B --> B3[动画流畅度]
C --> C1[响应式开销]
C --> C2[计算属性]
C --> C3[事件处理]
D --> D1[内存泄漏]
D --> D2[大对象管理]
D --> D3[组件缓存]
E --> E1[包体积]
E --> E2[资源加载]
E --> E3[API请求]

响应式在初始化时递归走了一遍几万个字段,是运行时的账;一次输入让一万行重新 patch,是渲染的账; 组件反复挂载又没清理干净,是内存的账;首屏白屏三秒,是网络的账。四条线上的手段互相不通用, 量错了地方,改一整天也不会有任何变化。

Vue 自己带了一把尺子:

打开组件耗时追踪
// Vue 3
app.config.performance = true;
// Vue 2(2.2 起)
Vue.config.performance = true;

打开之后刷新页面,浏览器 Performance 面板的 Timings 一栏里会多出每个组件的分段耗时。 两代的条目名字不一样:Vue 2 打的是 vue <组件名> init / render / patch,还有完整版才有的 compile;Vue 3 打的是 <组件名> mount 这种形状。对不上号的时候先确认量的是哪一代。

这把尺子只在开发构建里有效 —— 为什么这不妨碍用它,留到最后一节。

冻住的对象,两代 Vue 都不会碰

Vue 2 的响应式是初始化时一次性铺开的:observe() 拿到对象就 new Observerwalk 遍历所有 key 挨个 defineReactive,嵌套对象递归下去。几千行的表格数据放进 data,这一遍遍历在组件创建时就得走完。

new Observer 前面挂着五个条件:

Vue 2 · src/core/observer/index.js
export function observe (value: any, asRootData: ?boolean): Observer | void {
if (!isObject(value) || value instanceof VNode) {
return
}
let ob: Observer | void
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__
} else if (
shouldObserve &&
!isServerRendering() &&
(Array.isArray(value) || isPlainObject(value)) &&
Object.isExtensible(value) &&
!value._isVue
) {
ob = new Observer(value)
}
// 省略:asRootData 时给 ob.vmCount 加一
return ob
}

能拿来用的就是高亮那一行。Object.freeze 让对象不可扩展,Object.isExtensible 返回 false, 整个 else if 不成立,Observer 就不会建 —— 后面的遍历、__ob__Dep 全都省掉了。

Vue 3 换成了 Proxy,这个判断留着:

Vue 3 · packages/reactivity/src/reactive.ts · createReactiveObject
// only specific value types can be observed.
if (target[ReactiveFlags.SKIP] || !Object.isExtensible(target)) {
return target
}

返回的是 target 本身,不是 Proxy,所以 reactive(Object.freeze(x)) 拿回来的是个普通对象。 3.4 及更早的版本里这段判断写在 getTargetType() 里,位置挪过,结论没变。

于是有一条两代通用的写法:拉回来就不再改的字典、配置、只读的长表格,进 data 或者 reactive 之前先冻掉。

两种「别再递归下去」的写法
// 一次拉回来就不改的数据:冻掉,Vue 2 和 Vue 3 都会跳过它
this.dict = Object.freeze(await fetchDict());
// Vue 3 / Vue 2.7:只有 .value 这一层是响应式的
const rows = shallowRef([]);
rows.value = await fetchRows(); // 整体替换,触发更新
rows.value[0].name = 'x'; // 改内部字段,不触发
triggerRef(rows); // 非要就地改,得自己敲这一下

代价很直接:数据真的不能改了,更新只能整个换一个新对象,靠引用变化触发重渲染。 而且给冻结对象赋值在严格模式下抛错,在非严格模式下静默失败 —— 后一种最难查, 代码看着跑通了,界面就是不动。

想只留一半响应式,Vue 3 有 shallowRefshallowReactive:最外层是响应式的, 里面的字段原样存着。这两个 API 跟着组合式 API 一起进了 Vue 2.7,2.6 及以前没有对应物 —— 那边能做的只有 Object.freeze,或者干脆把数据放在组件外面,只把要触发重渲染的那一份放进 datashallowReactive 的文档专门标了一句注意:别把它嵌进深层响应式对象里, 那样会得到一棵响应行为不一致的树,出了问题很难查。

两代还有个结构性差别:Vue 3 是访问到哪一层才转哪一层,Vue 2 是初始化时递归到底。 同一份大对象,开销落在不同时刻 —— 一个在创建时,一个摊到第一次读取时。 省下多少取决于你到底访问了多少,没访问到的部分 Vue 3 根本不转。

v-once 和 v-memo 把「不用重算」写进模板

v-once 两代都有,语义一样:第一次照常渲染,之后这个元素和它的所有子节点都当静态内容跳过。

v-memo 是 3.2 才有的,Vue 2 没有。它比 v-once 多一个开关 —— 给一个定长的依赖数组, 每次重渲染比一遍,全都没变就跳过整棵子树的更新。文档里强调的是跳过的范围: 连 VNode 都不建,直接复用上一次那份记忆。v-memo="[]" 的效果等于 v-once

Vue 3.2+ · v-memo 和 v-for 写在同一个元素上
<div v-for="item in list" :key="item.id" v-memo="[item.id === selected]">
<p>ID: {{ item.id }} - selected: {{ item.id === selected }}</p>
<p>...more child nodes</p>
</div>

:key 里已经出现过的值不必再写进依赖数组,编译器会自己带上。

两条边界值得抄下来。一是位置:v-memov-for 必须在同一个元素上,文档原话是 「v-memo does not work inside v-for」。二是分寸:文档把它定位成 micro optimization, 说主要用在一千行以上的长列表,而且「should be rarely needed」。

代价是这个指令唯一的风险点:依赖数组漏一项,那一项变化时界面停在旧值上,不报错也不告警, 看起来就像数据没更新。所以它值得用的场合很窄 —— 得先量出这一段确实把时间花在了重渲染上。

长列表卡的不是 diff,是同时存在的节点数

一万行数据真渲染成一万个 <tr>,浏览器要给每个节点算样式、排版、占内存,这部分开销跟用哪个框架无关。 编译期的 PatchFlag 和 Block Tree 省的是「比对」,不是「存在」—— 节点已经在那儿了,省不掉。

虚拟滚动换的就是这个:DOM 里只留可视区那几十行,用一个撑到全高的空盒子骗住滚动条, 滚动时改的是可视区那一段的偏移和内容。

虚拟滚动的核心:只算可视区那几十行
const ROW = 40; // 行高固定,才能直接用除法算下标
const BUFFER = 5; // 上下各多渲染几行,滚动时不至于露白
const first = computed(() =>
Math.max(0, Math.floor(scrollTop.value / ROW) - BUFFER));
const count = computed(() =>
Math.ceil(viewportHeight.value / ROW) + BUFFER * 2);
const visible = computed(() =>
rows.value.slice(first.value, first.value + count.value));
// 撑开滚动条的空盒子,高度由全部数据算出来
const totalHeight = computed(() => rows.value.length * ROW);
// 可视区整体下移,让第一行落在它本来该在的位置
const offsetY = computed(() => first.value * ROW);

跑起来会看到:滚动条的长度和一万行对得上,Elements 面板里却始终只有二三十个行节点。

它的代价比多数文章讲的重:

  • 没渲染的行 Ctrl+F 搜不到。页内查找、打印、读屏都只看得见可视区那几十行。
  • 上面那几行除法的前提是行高固定。行高不定就得先量再放,还要缓存每一行的高度、 回滚时补偿滚动位置,复杂度是另一个量级。
  • 锚点跳转、列对齐、拖拽排序,全都要重新处理一遍。

所以先问一句能不能分页 —— 分页没有上面任何一条问题,代价只是用户多点一下。 真要虚拟滚动,先看现成的 vue-virtual-scroller 和 TanStack Virtual,不定高那部分它们已经踩过了。

keep-alive 是拿内存换重建

<KeepAlive> 把组件实例留着,切回来不重建,DOM 状态、滚动位置、填了一半的表单都还在。 代价同样直白:实例一直活着,占着内存。

Vue 3 · 只缓存这两个,最多留三份
<KeepAlive :include="['UserList', 'OrderList']" :max="3">
<component :is="current" />
</KeepAlive>

max 是 Vue 2.5 加的,超出就淘汰最久没被访问的那一个。两代都是 LRU,实现不同:Vue 2 用数组, 命中时先 removepush 把 key 挪到末尾,超了删 keys[0];Vue 3 换成 Set, 命中时 deleteadd,超了删 keys.values().next().value

include / exclude 按组件的 name 匹配,这里有个跨版本的坑:Vue 2 匹配不到 name 时 会退回局部注册用的标签名,Vue 3 只认 name 本身(<script setup> 从 3.2.34 起按文件名推断出一个)。 同一套代码从 Vue 2 迁到 Vue 3,缓存可能就这么悄悄失效了,而页面上什么都看不出来。

更容易忘的是另一头:被缓存住的组件不走 unmounted,只走 deactivated。定时器、WebSocket、 手动挂上去的 addEventListener 全都还在跑。

Vue 3 · 缓存住的组件,定时器得自己停
let timer;
onActivated(() => { timer = setInterval(poll, 5000); });
onDeactivated(() => { clearInterval(timer); }); // 不停的话它在后台一直跑

反过来推不成立:真正卸载的时候 deactivated 也会触发一次。 别拿「deactivated 触发了」当成「实例还在缓存里活着」的证据。

懒加载切的是下载时机,不是下载总量

异步组件 · 两代的写法
// Vue 3(Vue 2.7 也有)
const UserPanel = defineAsyncComponent({
loader: () => import('./UserPanel.vue'),
loadingComponent: Spinner,
delay: 200,
timeout: 3000,
});
// Vue 2.3 起的工厂函数写法
const UserPanel = () => ({
component: import('./UserPanel.vue'),
loading: Spinner,
delay: 200,
timeout: 3000,
});

delay 常被读成「延迟加载」,它管的其实是 loading 组件多久之后才出现:200 毫秒之内加载完, Spinner 压根不显示。快网络下刚显示就被替换掉,看起来只是闪了一下。200 这个默认值文档里写着, 源码 apiAsyncComponent.ts 里也是它。

两边的字段名不通用:Vue 3 是 loaderloadingComponent,Vue 2.3 起的工厂函数是 componentloading

判断标准只有一条:首屏用不到的才值得切出去。把首屏要用的组件切成单独的 chunk, 省下的字节要拿一次额外的网络往返换回来,通常是亏的。路由级的切分几乎总是对的, 组件级的要看它出现的概率。

函数式组件那条经验在 Vue 3 里作废了

函数式组件 · 两代写法完全不同
// Vue 2:带 functional 标记的选项对象,第二个参数是 context
export default {
functional: true,
render(h, { props, children }) {
return h('li', props.item.name);
},
};
// Vue 3:就是一个函数,签名和 setup() 一样
function Item(props, { slots, emit, attrs }) {
return h('li', props.item.name);
}
Item.props = ['item'];

Vue 2 里 functional: true 省掉的是一个组件实例:没有 this、没有响应式、没有生命周期, 渲染时只跑一个函数。列表项这种量大又没状态的组件用它确实更轻。

Vue 3 的迁移指南把这条建议收回了,原话是这份收益「now negligible in 3.x」, 推荐直接用普通的有状态组件。写法也换了:functional: true 选项和 Vue 2.5 加的 <template functional> 都被删掉,函数式组件就是一个普通函数。

编译期已经替你做掉的那几件事

graph TB
A[Vue 3编译优化] --> B[静态提升]
A --> C[Patch Flag]
A --> D[缓存事件处理器]
A --> E[Block Tree]
B --> B1[静态节点提升]
B --> B2[静态属性提升]
C --> C1[动态文本: 1]
C --> C2[动态类名: 2]
C --> C3[动态样式: 4]
C --> C4[动态属性: 8]
C --> C5[动态键值: 16]
D --> D1[内联事件缓存]
D --> D2[组件事件缓存]
E --> E1[区块收集]
E --> E2[动态节点数组]

这几样不用写任何代码就能拿到,条件只有一个:模板得是编译器能静态分析的。

去 Vue 的 SFC Playground 贴一段模板,右边那栏就是产物:静态节点被提到 render 函数外面, 动态节点后面跟着一个整数(1 /* TEXT */),内联事件处理器被存进 _cache[0] —— 每次 render 拿到的是同一个函数引用,子组件因此不会因为「props 里的函数换了」白白重渲染一次。

反过来说,手写 render 函数和 JSX 从一开始就拿不到这一套:编译器看不到模板,只能按最坏情况处理。 选模板还是选 JSX,是个性能上的决定,不只是口味问题。

内存和渲染是两本账

泄漏不会让某一帧变慢。它的表现是页面用得越久越慢,来回切十几次之后开始掉帧,最后崩掉 —— 盯着单次渲染的耗时永远发现不了。

Vue 回收的是它自己建的东西:模板里 @click 绑的监听器、watchcomputed 的依赖、 组件的 DOM。它不知道你在 mounted 里做过什么。

Vue 只回收它自己建的东西
onMounted(() => {
window.addEventListener('resize', onResize);
timer = setInterval(poll, 5000);
chart = echarts.init(el.value);
});
onBeforeUnmount(() => { // Vue 2 里这个钩子叫 beforeDestroy
window.removeEventListener('resize', onResize);
clearInterval(timer);
chart.dispose(); // 第三方实例通常有自己的销毁方法
});

查法是 Chrome 的 Memory 面板:打一次 heap snapshot,把组件挂上再销毁几轮,再打一次, 比较两次之间 Detached 节点和组件实例的数量。别用 performance.memory 下结论 —— MDN 上它同时挂着 non-standard 和 deprecated 两块牌子,只有 Chromium 系有, 而且多个页面共用一个堆时它报得偏大,用了 worker 或跨站 iframe 时又偏小。

首屏那几个指标是另一本账,和 Vue 的内部机制没关系,得用 web-vitals 这类库单独量。 顺带更正一个过时的常识:FID 在 2024 年 3 月 12 日被 INP 取代,已经不是 Core Web Vitals 之一。 web.dev 现在给的三条线是 LCP 2.5 秒、INP 200 毫秒、CLS 0.1,都按第 75 百分位看, 移动端和桌面端分开算。

这张清单上的每一项都在拿什么换什么

graph TB
A[Vue性能优化] --> B[开发时优化]
A --> C[构建时优化]
A --> D[运行时优化]
B --> B1[合理的组件设计]
B --> B2[避免过度响应式]
B --> B3[使用计算属性缓存]
B --> B4[正确使用v-if/v-show]
C --> C1[代码分割]
C --> C2[Tree Shaking]
C --> C3[压缩优化]
C --> C4[静态资源优化]
D --> D1[虚拟滚动]
D --> D2[懒加载]
D --> D3[缓存策略]
D --> D4[Web Worker]
手段 省下的 代价
Object.freeze 初始化时的整棵遍历 数据只能整体替换
shallowRef / shallowReactive 深层字段的响应式转换 就地改不触发更新,要自己 triggerRef
v-memo 子树的更新和 VNode 创建 依赖数组漏一项,就是不报错的静默 bug
虚拟滚动 同时存在的 DOM 节点 页内查找、打印、不定高全要另外处理
keep-alive 组件重建和数据重取 实例常驻内存,定时器要自己停
异步组件 首屏要下载的字节 多一次网络往返,切错地方是负收益

没有哪一项是无条件的。不知道慢在哪就照着清单抄一遍,多半是把开销从一个地方挪到了另一个地方, 顺便把代码变复杂。

量之前先换成生产构建

开发构建比生产构建慢,而且慢得不均匀:多了参数校验和警告,多了 devtools 的钩子, 完整版还把模板编译器一起打进了包里。在开发构建上量出来的毫秒数,不是用户会遇到的那个数。

麻烦的是,开头那把尺子恰好只在开发模式下才有:app.config.performance 是开发模式限定, onRenderTracked / onRenderTriggered 这两个调试钩子,文档里也写明了 development-mode-only, 服务端渲染期间不触发。

Vue 3 · 问「它为什么又重渲染了」
import { onRenderTriggered } from 'vue';
onRenderTriggered((e) => {
// e.type 是 set / add / delete,e.key 是被改动的那个字段
console.log('重渲染是因为', e.type, e.key, e.oldValue, '→', e.newValue);
});

所以两步是分开的。开发构建里用这些钩子回答「谁在重渲染、被哪个字段触发的」—— 这个答案和构建方式无关,哪个构建里都是同一个字段。生产构建里用 Performance 面板量 「一次交互到底花了多少毫秒」,这才是绝对值。拿开发构建的耗时去调优, 调的是一个用户永远不会运行的版本。

这篇是 Vue.js 内部机制深度解析的最后一篇,前一篇是 Vue.js 状态管理模式:构建可扩展的应用架构