Vue 性能优化体系:先衡量,再分加载 / 更新两层下手
面试向梳理 Vue 性能优化:官方把性能分成"页面加载"和"更新"两类,先测量再动手; 更新侧讲清依赖追踪粒度、props 稳定性、computed 缓存与 3.4 的值稳定性、v-once / v-memo 的正确用法与坑、shallowRef 适用条件(含实测开销对比);加载侧只保留 Vue 特有手段, 最后给一个能直接复述的面试口述模板。
一句话概括
Vue 性能优化最容易答砸的地方是”报菜名”——把 20 个手段一口气说一遍,面试官听完不知道你会不会排查。正确的开局是先分层,再测量,最后才说手段。
官方文档自己就是这么分的:
| 层面 | 定义 | 主要指标 |
|---|---|---|
| 页面加载性能 | 首次访问时”看到内容”和”能交互”的速度 | LCP、INP |
| 更新性能 | 应用响应输入后更新的速度 | 交互响应耗时、组件渲染耗时 |
这两层的优化手段几乎不重叠,答的时候分开说,逻辑立刻就清楚了。而且两层的起点都是”先量”:生产环境用 PageSpeed Insights / WebPageTest,本地开发用 Chrome Performance 面板 + Vue DevTools 的 profiler,再打开 app.config.performance = true 让 Vue 在时间线上打出自己的性能标记(组件初始化、render、patch 的耗时)——没有这步,所有优化都是猜的。 这句”先量再优化”在一线面试里比多背三个 API 管用。
下面按”更新 → 渲染 → 加载”讲,只讲 Vue 特有的部分;虚拟列表、代码分割、Worker 这些通用手段只留一句话,细节各自有专题。
核心知识点
1. 更新性能的地基:依赖追踪是”按组件 + 按属性”的
Vue 的渲染副作用是组件粒度的:每个组件实例一个 render effect,只有”这次渲染真正读过的响应式数据”变了,它才重渲染。
那子组件什么时候会跟着更新?两个条件之一:① 它自己这次渲染读过的响应式值变了;② 它的 props 变了(Vue 用 !== 做浅比较判断)。实测两种情形:
| 场景 | 子组件渲染次数 |
|---|---|
| 父组件因无关状态重渲染,子组件 props 值没变 | 1 → 1(跳过) |
| 父组件重渲染,子组件收到内联新建的对象 / 箭头函数 | 1 → 2(照常更新) |
所以有两个说法要纠正:
- “父组件重渲染就带崩整棵子树”在 Vue 里不成立——props 没变的子组件会被跳过,中间那些只做透传的纯结构组件不产生额外渲染。这也是 Vue 不需要满屏
memo的原因。 - 但”传新对象 / 新函数无所谓”同样是错的:引用变了就算 props 变了(实测 1 → 2)。Vue 里的解法不是
useMemo/useCallback,而是下一节的 props 稳定性——把值在父组件里算成稳定的原始值(布尔、id、字符串)再传下去,或者用computed把对象引用缓存住。
另外还有一个方向常被忽略:一个大组件 = 一个大依赖集合。一个读了 20 个 ref 的巨型组件,任何一个变了它都要整体重渲染;把它拆成若干小组件,”每个组件依赖什么”就被切窄了。这通常是收益最大、也最容易被忽略的结构性优化。
2. props 稳定性:让子组件的 props”尽量别变”
官方给的例子非常典型,也是”如何在列表里减少无用更新”的标准答案:
1
2
3
4
<!-- ❌ 每个 item 都拿着 activeId,activeId 一变,列表里所有项都更新 -->
<ListItem v-for="item in list" :id="item.id" :active-id="activeId" />
<!-- ✅ 在父组件里算好布尔值,activeId 变化时只有真正受影响的那一项更新 -->
<ListItem v-for="item in list" :id="item.id" :active="item.id === activeId" />
核心思想一句话:把”只有部分子组件关心的状态”在父组件里消化成布尔值/稳定值再传下去。 这个技巧在长列表、表格行、菜单项里非常常用,能直接把”一次更新 O(n) 个组件”降成 O(1)。
3. computed:既有缓存,也有”值稳定性”
面试常问”computed 和 watch 谁性能好”,标准答案是 computed 有缓存:依赖不变就不重算。这条大家都熟,但 3.4 之后多了一条同样重要、答题能拉开差距的性质:
computed 只有在”计算值真的变了”时才会触发副作用。 官方例子:isEven = computed(() => count.value % 2 === 0),把 count 从 2 改成 4,watchEffect 不会再触发,因为结果仍是 true。实测确认(Vue 3.5.42):初始一次 + 后续两次赋值,watchEffect 总共只跑了 1 次。
但有个陷阱:如果 computed 每次返回新对象,这条优化就失效了(技术上永远”不等”),这时要手动比较旧值:
1
2
3
4
5
6
7
8
9
// ❌ 每次都是新对象,依赖它的 effect 每次都跑
const summaryBad = computed(() => ({ even: count.value % 2 === 0 }))
// ✅ 内容没变就返回旧引用(注意:完整计算要先做完,保证依赖收集一致)
const summary = computed((oldValue) => {
const next = { even: count.value % 2 === 0 }
if (oldValue && oldValue.even === next.even) return oldValue
return next
})
computed 的 getter 能收到”上一次的值”(3.4+)——这个参数很多同学不知道,答出来就是加分项。
4. v-once / v-memo:两个”逃生舱”,别当习惯用
v-once | v-memo | |
|---|---|---|
| 版本 | 3.0 | 3.2+ |
| 语义 | 只渲染一次,之后永不再更新(整个子树跳过) | 依赖数组里每个值都和上次相同,则跳过子树更新(连新 vnode 都不建,复用缓存副本) |
| 依赖数据但不再变 | ✅ 适用 | ✅ 适用 |
| 依赖数据会变 | ❌ 会渲染出脏数据 | ✅ 依赖写对就行 |
| 典型场景 | 顶栏、页脚、版权信息、纯静态大块模板 | 1000 行以上的 v-for 列表 |
| 空数组效果 | — | v-memo="[]" 等价于 v-once |
v-memo 官方示例(列表里只有”选中态”在变,其余行全部跳过 diff):
1
2
3
4
<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>
三个必须记住的坑(面试高频):
- 依赖数组写漏 = 静默脏数据。 少了子树真正读到的值,更新就被跳过,页面显示旧内容——这比”慢”严重得多。
v-memo必须和v-for写在同一个元素上,不能写在v-for内部。- 官方说它是”性能至上场景的微小优化,应该很少需要”,主要针对长度 1000+ 的列表。业务代码里到处写
v-memo属于反面案例。
顺带把边界说清:v-memo 依赖数组里不需要包含 item.id,因为 :key 已经参与判断了。
5. 大对象:用 shallowRef 关掉深度响应式
Vue 的响应式默认是深度的:每个属性的访问都会触发代理的依赖追踪。数据量巨大时(官方举例:一次渲染访问 10 万个以上属性)这部分开销才开始明显,解决办法是 shallowRef / shallowReactive——只有 .value 这一层是响应式的,深层属性不再被代理。
实测数据(Vue 3.5.42,同一份 1000 行嵌套对象,读取 30 万次属性):
| 方式 | 耗时 |
|---|---|
reactive(深层代理) | ~17ms |
shallowRef(浅层) | ~1ms |
约 16 倍差距,而且更新行为同样要记准(实测):
| 操作 | 用 shallowRef 时是否重渲染 |
|---|---|
list.value.push(x) | ❌ 不会 |
list.value[0].foo = 1 | ❌ 不会 |
list.value = [...list.value] | ✅ 会 |
所以适用条件非常明确:一个”整体替换、不原地改”的大对象(接口返回的大列表、解析出的报表数据、图表数据集)。如果你需要改深层属性又想让 UI 更新,要么整体替换,要么手动 triggerRef(list)——否则就是静默不更新,这个 bug 很难查。 另外还有两个配套工具:shallowReactive(对象版)和 markRaw(让某个值永远不进代理,适合第三方实例、大配置常量)。
版本层面也值得提一句:Vue 3.5 把响应式系统又重构了一轮,内存占用降低 56%,并优化了对”大型深层响应式数组”的追踪(官方说某些操作快 10 倍),且无行为变化——所以”升级版本”本身就是一条真实的优化手段。
6. 组件实例是”贵”的,抽象也是有成本的
官方原话:组件实例比普通 DOM 节点贵得多。为抽象而抽象(无渲染组件、只做一层透传的高阶组件)会产生大量实例。但要分清场合:
- 只渲染几次的抽象组件——不用管,优化收益为零;
- 长列表里的每一项——拆掉/合并一层不必要的组件,能省下成百上千个实例,这才是值得动手的地方。
7. watch 的代价:别对着大对象开 deep
watch(source, cb, { deep: true }) 会递归遍历对象,把每一层属性都摸一遍来收集依赖——对象越大越贵,而且任何一层变化都会触发回调。更省的做法是用 getter 精准监听:
1
2
3
4
// ❌ 深度遍历大对象,任何字段变动都触发
watch(bigForm, cb, { deep: true })
// ✅ 只关心真正需要的字段
watch(() => bigForm.userId, cb)
再配上时机控制:不需要读 DOM 的副作用用默认的 flush: 'pre';只有必须在 DOM 更新后执行才用 flush: 'post'。高频输入联动(搜索建议)该防抖就防抖,别让每次击键都打一次接口。
8. 渲染层:长列表只有三条路
DOM 节点数是硬成本,Vue 再快也救不了 10 万行 DOM。按官方推荐顺序:
- 分页——最简单,也最该先考虑;
- 增量加载(滚动到底再拉下一页);
- 虚拟列表——只渲染视口内的行,DOM 数量恒定;社区常用
vue-virtual-scroller、vue-virtual-scroll-grid、vueuc/VVirtualList。代价是会牺牲键盘导航、页面内查找和无障碍的一部分能力,所以放在最后。
另外两条高频但简单的结论:
v-ifvsv-show:频繁切换用v-show(只切display),一次性条件渲染用v-if。<KeepAlive>:Tab 切换、多标签路由这种”来回切”的场景,缓存住组件实例能同时省下”重新渲染”和”状态丢失”两个问题。
9. 加载层:Vue 特有的只有四件事
通用手段(图片、关键 CSS、preload、CDN)各处都有专题,这里只收 Vue 特有的:
- 一定要用构建步骤:模板预编译后不需要在浏览器里载入 Vue 的编译器,官方给的数字是体积少约 14kb(同样 gzip 后),还省掉运行时的编译开销。所以打包配
vue时用 runtime-only 版本是对的。 - Tree-shaking 是默认友好的:没用到的内置组件(比如
<Transition>)不会进产物;引入依赖时优先选 ESM 版本(lodash-es优于lodash)。 - 代码分割靠异步组件:
defineAsyncComponent(() => import('./Foo.vue'))会把组件和它的依赖切成独立 chunk,路由组件一律懒加载是最常见也最有效的一刀。 - 架构选择:对首屏敏感就别做纯客户端 SPA。内容页用 SSG(静态生成),强交互应用上 SSR;广告落地页/营销页和主应用分开部署,让它们保持”HTML + 极少 JS”。
10. 面试口述模板(30 秒版)
“我会先把问题归到两类:加载慢还是交互卡。加载侧先看 LCP 和包体积,Vue 特有的手段是构建期预编译(省 14kb)、按需引入 ESM 依赖、路由级异步组件、必要时上 SSR/SSG;交互侧先开
app.config.performance和 Performance 面板定位到具体组件,然后按顺序想四件事:props 是否稳定(把activeId变成active这种)、计算是否被重复执行(该用 computed 的别写在模板里)、有没有必要更新(v-once、v-memo,只用在千行级列表)、响应式开销是否过大(大对象换shallowRef,别对全量对象开 deep watch)。如果是一个几万行的列表,那就不是调优问题了,先分页或虚拟化。”
其实你每天都在用
- 表格里”当前选中行高亮”不要传
selectedId,传:active="row.id === selectedId",这就是 props 稳定性最日常的落地。 - 页脚版权、侧边栏菜单这种渲染后不变的块,套一个
v-once,后续任何更新都跳过它。 - 后台管理里一个 5000 行的日志表格,改完发现”只有一行变了却整表闪一下”,答案通常在
v-memo或 props 稳定性上。 - 从接口拿回来一整份大报表明细、只在翻页时整体替换,用
shallowRef就能省掉几十万次代理访问。 - 搜索框每敲一个字符就请求一次接口,正确写法是防抖 +(能取消就取消)——同一主题在请求竞态那篇里展开过。
- 多标签后台把路由组件包进
<KeepAlive :max="10">,切回来滚动位置和表单内容都在。 app.config.performance = true打开后,Chrome 性能时间线上会多出 Vue 自己的component-init、render、patch标记——排查”到底是谁慢”的第一站。- 项目从 Vue 3.3 升到 3.5 之后,长列表滚动明显顺了——那 56% 的内存下降和数组追踪优化就是原因。
常见误解(FAQ)
❌ 误区1:”Vue 性能优化就是背一堆 API(v-memo、shallowRef、KeepAlive)”
错。官方文档开篇就写”Vue 在大多数常见场景下性能都很优秀,通常不需要手动优化”,专门优化的前提是先测量(app.config.performance + Performance 面板 + Vue DevTools)。一上来报菜名,等于告诉面试官你不会排查。正确的答题结构是”先分加载/更新两类 → 再说怎么定位 → 最后说手段”,并且说清每个手段的适用条件。
❌ 误区2:”v-memo 和 v-once 差不多,都是缓存”
差别很关键:v-once 是再也不更新(依赖会变的数据绝对不能加,会渲染出脏数据),v-memo 是依赖数组不变才跳过,数据变了照样更新。v-memo="[]"(空数组)才等价于 v-once。另外 v-memo 必须与 v-for 挂在同一个元素上,且官方明确说”应该很少需要”。
❌ 误区3:”用了 shallowRef 就能自动省性能,随便用”
它的适用条件很窄:大对象 + 整体替换。实测 shallowRef 下 push 和改深层属性都不会触发重渲染,如果业务需要局部更新,就得整体替换或手动 triggerRef——用错的表现是”数据变了但页面不动”,非常难查。另外它对小对象几乎没有收益(代理开销本来就不明显)。
❌ 误区4:”computed 只要依赖变了就会重新触发副作用”
3.4 之后不是。computed 只在计算值真的变化时才触发副作用(count 从 2 到 4、isEven 仍是 true,下游 effect 不跑,实测确认)。但要注意失效场景:computed 返回新对象时这条优化自动作废,需要手动比较旧值后返回旧引用。
❌ 误区5:”Vue 里传新对象 / 新函数当 prop 也没关系,反正不像 React 要 useMemo“
半对半错,这是最容易答崩的一条。事实是:只要 props 引用变了,子组件就该更新(实测:父组件因无关状态重渲染时,收到内联对象 / 箭头函数的子组件渲染次数 1 → 2;而 props 值没变的子组件保持 1)。所以 Vue 里也有”引用稳定性”问题,只是官方推荐的解法不同:不是加 useMemo 缓存函数,而是让 props 尽量是稳定的原始值——把”只有部分子组件关心的状态”在父组件里算成布尔值传下去(就是上面 props 稳定性 那一节)。会答”Vue 不需要 memo”的人很多,能把”但传新引用照样触发更新、所以要做 props 稳定性”也说出来的人少。
❌ 误区6:”列表卡就把每一项都用 v-memo 包起来”
先分清楚瓶颈在哪。几千行 DOM 的瓶颈是DOM 数量,靠 v-memo 省掉 diff 也没用——那种情况下分页、增量加载或虚拟列表才是正解。v-memo 的收益场景很具体:列表很长(官方说 1000+)且大部分项的内容不变(只有选中态之类的一两个字段在动)。用错了不但没收益,还多了一处”依赖漏写就出脏数据”的风险。
❌ 误区7:”deep: true 的 watch 只是多花点性能,无所谓”
不只是”慢一点”:它会递归遍历整个对象收集依赖,对象越大成本越高,而且任何一层变化都会触发回调——大表单里改一个字段就跑一遍全量校验,是典型的性能事故。需要监听什么就 watch(() => obj.field, cb),精准且便宜。
一句话总结
Vue 性能优化的答题顺序应该是”先分加载 / 更新两类 → 先量再改 → 再说手段”:更新侧靠”让 props 稳定 + 依赖集合变窄 + 计算别重复(computed 缓存与值稳定性)+ 必要时刻才用 v-once / v-memo + 大对象改 shallowRef“五件事,渲染侧只有分页 / 增量 / 虚拟化三条路,加载侧就记住”构建期预编译省 14kb、ESM 依赖、路由懒加载、必要时 SSR/SSG”这四刀——手段是最后才说的,条件才是面试官想听的。**