学完这篇你能拿到一套可落地的 Vue 3 渲染性能优化路径:先用工具定位卡顿来源,再按「列表 diff → 响应式开销 → 组件切换成本」三层依次下手。
第一步:先量再改,别凭感觉优化
打开 Vue DevTools 的 Performance 面板,或者装一个小工具记录渲染次数:
// main.js 里临时加,只在开发环境生效
app.config.performance = true
// 组件内定位是谁在重复渲染
onRenderTriggered((e) => {
console.log('重渲染原因:', e.key, e.type)
})
跑一遍卡顿的操作(滚动长列表、切换 Tab、输入搜索词),看哪个组件的 `onRenderTriggered` 疯狂刷屏。优化目标就是它。
注意:`app.config.performance = true` 会强制走慢速性能埋点路径,只用于排查,别带到生产环境。
第二步:v-memo 跳过没必要的子树 diff
典型场景:一个几百行的列表,每行结构复杂,但只有「选中状态」在变。Vue 默认会 diff 每一行的整棵子树,白白烧 CPU。
<template>
<div
v-for="item in list"
:key="item.id"
v-memo="[item.id, item.selected]"
>
<Row :item="item" />
</div>
</template>
依赖数组里所有值都没变时,这个元素及其子树会被整块跳过。实测在 500 行、每行 20 个节点的列表里,滚动帧率通常能从 30fps 左右拉回 55fps 以上。
注意:`v-memo` 的依赖数组必须写全。只要模板里用到了数组外的变量(比如 Pinia store 里的 `keyword`),就必须把它写进依赖数组,否则视图会「不更新」,这种 bug 最难查。另外 `v-memo` 必须和 `v-for` 写在同一个元素上,并且保留 `:key`。
第三步:shallowRef 砍掉深层响应式开销
`ref()` 会递归把对象转成响应式,几千条数据的大数组,光 Proxy 包装就要几十毫秒,后续每次访问还有代理层开销。数据只做整体替换(比如接口返回后一次性赋值)时,用浅层 API 就够:
import { shallowRef, triggerRef, markRaw } from 'vue'
// 大列表:只在整体赋值时触发更新
const bigList = shallowRef([])
bigList.value = await fetchList() // 触发更新
// 确实要改内部某个元素时,手动通知
bigList.value[0].selected = true
triggerRef(bigList)
// 第三方实例(ECharts、地图、编辑器)永远不要让它进响应式系统
const chart = shallowRef(null)
chart.value = markRaw(echarts.init(el))
同类还有 `shallowReactive()`,适合「只在顶层增删字段」的配置对象。
注意:`shallowRef` 里的嵌套属性修改不会触发更新,这是设计如此,不是 bug。表单、多层嵌套状态千万别用它,否则你会陷入到处补 `triggerRef` 的泥潭。
第四步:动态组件 + KeepAlive 管住切换成本
后台系统常见的 Tab 切换,如果每个 Tab 都是重组件(带图表、长列表),反复创建销毁的代价极高。用动态组件配合缓存:
<script setup>
import { shallowRef, defineAsyncComponent } from 'vue'
import TabBasic from './TabBasic.vue'
const currentView = shallowRef(TabBasic)
// 首屏不用的重组件,改成异步加载
const TabReport = defineAsyncComponent(() => import('./TabReport.vue'))
const tabs = { basic: TabBasic, report: TabReport }
function switchTo(key) {
currentView.value = tabs[key]
}
</script>
<template>
<KeepAlive :max="3">
<component :is="currentView" />
</KeepAlive>
</template>
`defineAsyncComponent` 把非首屏组件拆成独立 chunk,首屏体积立刻下降;`KeepAlive` 保住组件实例和滚动位置;`max="3"` 限制缓存上限,防止内存无限涨。
注意:`KeepAlive` 的 `include` / `exclude` 匹配的是组件 `name`。用 `<script setup>` 时没有隐式 name,得显式声明 `defineOptions({ name: 'TabReport' })`,否则 include 永远命中不了。另外被缓存的组件不会触发 `unmounted`,定时器、WebSocket 要在 `onDeactivated` 里暂停、`onActivated` 里恢复。
第五步:改完再量一遍
回到 Performance 面板,对比优化前后的帧率和渲染次数。同时看一眼打包产物体积,确认异步拆分真的生效:
npm run build
如果帧率没变,说明瓶颈不在渲染层,可能在接口请求或大数组排序上,别再折腾模板了。
小结
- 先定位再优化,用 `onRenderTriggered` 找出真正的重渲染组件。
- `v-memo` 用在大列表行上,依赖数组必须写全,漏一个就会出现「视图不更新」。
- `shallowRef` + `triggerRef` 对付「整体替换」的大数据;第三方实例一律 `markRaw`。
- 动态组件配 `defineAsyncComponent` 拆包、配 `KeepAlive` 缓存,`max` 限制内存。
- `<script setup>` 下 `KeepAlive` 的 include 需要 `defineOptions` 显式声明 name。
- 每改一步都回测一次,没变化就说明改错了地方。