Vue.js性能优化实践 - 基于内部机制的深度优化策略
响应式的开销落在初始化那一遍遍历上,所以 Object.freeze 和 shallowRef 能省掉它。v-memo、虚拟滚动、keep-alive 各自拿什么换什么,以及为什么这些数字只有在生产构建上量才算数。
页面卡了,先分清是哪一种卡
一个一万行的表格页面,在搜索框里敲一个字,要等小半秒才有反应。这句描述对不上任何一种具体原因 —— 至少有四件不同的事都会长成这个样子,而它们的账是分开算的。
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 3app.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 Observer,walk 遍历所有 key
挨个 defineReactive,嵌套对象递归下去。几千行的表格数据放进 data,这一遍遍历在组件创建时就得走完。
但 new Observer 前面挂着五个条件:
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,这个判断留着:
// 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 有 shallowRef 和 shallowReactive:最外层是响应式的,
里面的字段原样存着。这两个 API 跟着组合式 API 一起进了 Vue 2.7,2.6 及以前没有对应物 ——
那边能做的只有 Object.freeze,或者干脆把数据放在组件外面,只把要触发重渲染的那一份放进 data。
shallowReactive 的文档专门标了一句注意:别把它嵌进深层响应式对象里,
那样会得到一棵响应行为不一致的树,出了问题很难查。
两代还有个结构性差别: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。
<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-memo 和 v-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 状态、滚动位置、填了一半的表单都还在。
代价同样直白:实例一直活着,占着内存。
<KeepAlive :include="['UserList', 'OrderList']" :max="3"> <component :is="current" /></KeepAlive>max 是 Vue 2.5 加的,超出就淘汰最久没被访问的那一个。两代都是 LRU,实现不同:Vue 2 用数组,
命中时先 remove 再 push 把 key 挪到末尾,超了删 keys[0];Vue 3 换成 Set,
命中时 delete 再 add,超了删 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 全都还在跑。
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 是 loader 和 loadingComponent,Vue 2.3 起的工厂函数是 component
和 loading。
判断标准只有一条:首屏用不到的才值得切出去。把首屏要用的组件切成单独的 chunk, 省下的字节要拿一次额外的网络往返换回来,通常是亏的。路由级的切分几乎总是对的, 组件级的要看它出现的概率。
函数式组件那条经验在 Vue 3 里作废了
// Vue 2:带 functional 标记的选项对象,第二个参数是 contextexport 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 绑的监听器、watch 和 computed 的依赖、
组件的 DOM。它不知道你在 mounted 里做过什么。
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,
服务端渲染期间不触发。
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 状态管理模式:构建可扩展的应用架构。