一个后台的订单列表页,3000 条数据一次拉回来不分页,表格每行 8 列。切到这个路由白屏 1.8 秒,改一次筛选条件卡 400 毫秒,在输入框里敲字每个字符掉 8 帧。
这篇文章只拆这一个页面:怎么定位到具体是哪一步慢,每项改动改了什么、收益多少,以及哪些「优化」加了反而更糟。测量环境 Vue 3.4 + Vite 5、Chrome 124、M1 MacBook Pro。
先分层:慢在 render、patch 还是浏览器 layout
别上来就加 v-memo。Vue 一次更新要走三段:响应式触发 → 组件重新执行 render 生成新 vnode → patch(diff + 更新 DOM)→ 浏览器 layout/paint。三段慢掉的优化手段完全不同,先分清在哪一段。
第一件工具:Vue 自己的性能标记
// main.js
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// 仅开发环境生效,会在 Chrome Performance 面板的 User Timing 轨道
// 打出 componentRender / componentUpdate 的耗时标记
app.config.performance = true
app.mount('#app')
Chrome DevTools → Performance → 录制 → 切一次路由 → 停止。Timings 轨道里按耗时排序,元凶一眼能看到。
第二件工具:找出是谁触发了这次渲染
onRenderTriggered 是 dev-only 钩子,直接打印触发重渲染的依赖:
<script setup>
import { onRenderTriggered } from 'vue'
onRenderTriggered((e) => {
// e.type: 'set' | 'add' | 'delete' | 'clear'
// e.key: 变的是哪个属性;e.target: 哪个响应式对象
console.log('[trigger]', e.type, e.key, e.target)
})
</script>
基线数据如下,后面每项优化都对着这张表看:
| 场景 | 改之前 |
|---|---|
| 首次进入页面 | 1860 ms |
| 切换筛选条件 | 430 ms |
| 输入框每次击键 | 110 ms |
| JS 堆内存 | 142 MB |
内联箭头函数会把整张表变成全员重渲染
坏写法:
<template>
<div class="table">
<Row
v-for="row in rows"
:key="row.id"
:row="row"
@select="() => select(row)"
/>
</div>
</template>
@select="() => select(row)" 每次父组件执行 render,都会新建一个箭头函数。它会作为 onSelect 塞进 Row 的 props。Vue 的 shouldUpdateComponent 逐项比较 props,函数引用 !==,于是判定 Row 需要更新——哪怕 row 一个字段都没变。
3000 行就是 3000 次无意义 patch。单独回滚这一项测过:切换筛选 430 ms → 150 ms。
改法是让子组件 emit 时把 row 带出来,父组件传稳定引用:
<!-- Row.vue -->
<script setup>
const props = defineProps({ row: Object })
const emit = defineEmits(['select'])
</script>
<template>
<!-- 传的是 emit 和 row,没有在模板里再包一层函数 -->
<div class="row" @click="emit('select', row)">
{{ row.id }} / {{ row.status }}
</div>
</template>
<!-- 父组件:select 是 setup 里的函数,引用永远不变 -->
<Row v-for="row in rows" :key="row.id" :row="row" @select="select" />
function select(row) {
current.value = row
}
事件多、层级深时,可以直接上事件委托,把 N 个监听器压成 1 个:
<template>
<div class="table" @click="onTableClick">
<Row v-for="row in rows" :key="row.id" :row="row" />
</div>
</template>
<script setup>
function onTableClick(e) {
// closest 往上找带 data-row-id 的祖先,点到行内任意子元素都能命中
const el = e.target.closest('[data-row-id]')
if (!el) return
select(Number(el.dataset.rowId))
}
</script>
3000 次 addEventListener 换成 1 次,首屏省了大约 30~40 ms,单项不如上一条明显,但叠加起来也不少。
模板里做计算,等于每次渲染都重算
坏写法:
<td>{{ new Date(row.createdAt).toLocaleString('zh-CN') }}</td>
<td>{{ row.items.filter(i => i.checked).length }}</td>
模板表达式没有任何缓存。父组件重渲染一次,3000 行就重新 new Date + toLocaleString、重新 filter 一遍。toLocaleString 单次 5~15 μs,3000 次就是 15~45 ms,再乘以重渲染次数。
分两层处理。
数据层:加载完预处理一次,别放在渲染路径上。
const raw = await fetchOrders()
// Intl 实例复用,不要每次 new;比 toLocaleString 快 3~5 倍
const dtf = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' })
const rows = shallowRef(raw.map(o => ({
...o,
createdAtText: dtf.format(new Date(o.createdAt)),
checkedCount: o.items.reduce((n, i) => n + (i.checked ? 1 : 0), 0),
})))
渲染层:真需要随响应式变化的,用 computed。
// 依赖没变就不重算,模板里直接引用这个 computed
const checkedTotal = computed(() =>
rows.value.reduce((n, r) => n + r.checkedCount, 0)
)
reactive 大数组,是一次性付出的 24000 个 Proxy
// 坏:3000 个对象、每个对象 8 个字段,读取时全要过 get 拦截器
const rows = reactive(await fetchOrders())
Vue 3 的 reactive 是读一层代理一层,但每次读取都要经过拦截器,内存和 CPU 都是实打实的。在前面两项改完的基础上(首屏已经降到 700 ms 左右),把这一处换成 shallowRef:堆内存 142 MB → 108 MB,首屏 700 ms → 560 ms。
import { shallowRef, triggerRef } from 'vue'
const rows = shallowRef(await fetchOrders())
// 只有整体替换 .value 才触发更新
rows.value = newRows
// 改了内部字段又要让视图更新,得手动触发
rows.value[0].status = 'done'
triggerRef(rows)
边界条件要跟团队说清楚:shallowRef 的 .value 存对象时,改内部属性不触发更新,这是契约不是 bug。不然新人加一行 rows.value[0].x = 1 发现界面不动,能查一下午。
完全不需要响应式的字段(图表 option、地图实例、超大只读 JSON)用 markRaw 跳过代理:
const chartOption = markRaw(buildHugeOption()) // 永远不会变成 Proxy
顺带一个常见误解:Object.freeze 治不了这个。reactive(frozenObj) 发现对象不可扩展,会直接返回原对象而不是 Proxy,属性变化不触发更新——看着像优化,实际埋了个视图不更新的雷。
v-memo 好用,但漏一个依赖就是幽灵 bug
Vue 3.2 起有 v-memo,按依赖数组跳过子树更新:
<div v-for="row in rows" :key="row.id" v-memo="[row.id, row.status, row.amount]">
<!-- 三个字段都没变,这一行不会进 diff/patch -->
</div>
三个必须知道的代价:
- 依赖数组必须列全。 写成
v-memo="[row.id]"但模板里渲染了row.status,状态变了视图不更新。这个 bug 往往只在「其他地方也触发了更新」时才显形,极难定位。 - 比较本身有成本。 数组越长,每次逐项
===的开销越大,收益被吃回去。 - 它不阻止父组件执行 render。 跳过的是后面的 diff/patch,render 函数本身慢的,它救不了。
我的用法:只在「结构固定、更新频繁、字段明确」的地方用,比如实时行情、监控大盘。普通 CRUD 列表把内联函数和模板计算清掉,收益已经够了,不加也够。
DOM 数量才是硬上限
前面全部做完,切换筛选 150 ms、首屏 560 ms 左右。还能不能再压?能,但方向要换——之前优化的都是「每次更新多快」,现在的问题是「一次要更新多少个 DOM」。
3000 行 × 8 列 ≈ 24000 个 DOM 节点,每个节点 patch 就算只要 2 μs,也是 48 ms,再加上浏览器 layout 和 paint,怎么都下不去。
一屏只看得到 20 行,剩下 2980 行纯粹在给滚动条做贡献。上虚拟滚动:
| 普通渲染 | 虚拟滚动 | |
|---|---|---|
| DOM 节点数 | ~24000 | ~240(30 行 × 8 列) |
| 首屏 | 560 ms | 190 ms |
| 切换筛选 | 150 ms | 22 ms |
| 快速滚动 | 卡顿 | 每帧一次 slice,稳定 |
固定行高的最小实现,不引库:
<!-- VirtualList.vue -->
<template>
<div class="vlist" @scroll.passive="onScroll">
<!-- 撑开滚动条,保证滚动条长度和滚动距离是对的 -->
<div class="vlist__phantom" :style="{ height: totalHeight + 'px' }"></div>
<!-- 只渲染可视区 + buffer,靠 translateY 挪到正确位置 -->
<div class="vlist__body" :style="{ transform: `translateY(${offsetY}px)` }">
<template v-for="item in visibleItems" :key="item.id">
<slot :item="item" />
</template>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
items: { type: Array, required: true },
rowHeight: { type: Number, default: 44 },
buffer: { type: Number, default: 6 }, // 上下各多渲染几行,减少快速滚动白屏
})
const scrollTop = ref(0)
const viewportHeight = ref(600) // 简化写死,实际项目用 ResizeObserver 量真实高度
function onScroll(e) {
// 每帧最多一次,开销很小
scrollTop.value = e.target.scrollTop
}
const startIndex = computed(() =>
Math.max(0, Math.floor(scrollTop.value / props.rowHeight) - props.buffer)
)
const endIndex = computed(() =>
Math.min(
props.items.length,
Math.ceil((scrollTop.value + viewportHeight.value) / props.rowHeight) + props.buffer
)
)
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))
const offsetY = computed(() => startIndex.value * props.rowHeight)
const totalHeight = computed(() => props.items.length * props.rowHeight)
</script>
<style scoped>
.vlist {
position: relative;
height: 100%;
overflow-y: auto;
}
.vlist__phantom {
width: 1px; /* 只用来占高度 */
}
.vlist__body {
position: absolute;
top: 0;
left: 0;
right: 0;
}
</style>
用法:
<VirtualList :items="rows" :row-height="44">
<template #default="{ item }">
<Row :row="item" @select="select" />
</template>
</VirtualList>
几个绕不过去的边界:
- 行高必须固定。 不定高要上 ResizeObserver 逐行量高 + 维护位置偏移表,代码量翻三倍,行高变化时还会抖。能固定就固定。
@scroll加.passive。 否则浏览器要等 JS 跑完才滚,滚动会明显发涩。onScroll里别干重活。 那里的代码每帧都会跑,在里面加 filter/map 等于把卡顿从渲染搬到了滚动。- 和
shallowRef搭配时要注意。 items 传的是未被代理的原始数组,行内对象改字段不会触发更新,得整体替换数组或triggerRef,否则虚拟列表里的行不刷新。 - 别过早用。 经验阈值:超过 200 行、或超过 1500 个 DOM 节点再考虑。20 行的列表套虚拟滚动,维护成本远大于收益。
两个顺手要清的小坑
v-for 的 key 用 index:
<!-- 坏:删除中间一项后,后面所有行的 key 都变了,DOM 复用全部错位,
输入框里的内容会留在原来那一行上 -->
<div v-for="(row, i) in rows" :key="i">
有稳定 id 就用 id。纯静态、永不重排的数组用 index 也行,但要知道自己在赌什么。
v-if 和 v-for 写在同一个元素上:
<!-- 坏:Vue 3 里 v-if 优先级高于 v-for,拿不到 row,直接报错 -->
<div v-for="row in rows" v-if="row.visible" :key="row.id">
要过滤就先 computed:
const visibleRows = computed(() => rows.value.filter(r => r.visible))
什么时候不该做这些
3000 行不分页本身就是产品决策问题。服务端分页 + 搜索,首屏直接回到 40 ms 量级,代码还更简单——很多时候这才是正解,前端渲染优化只是给「业务就是要一屏全展示」兜底。
如果确实得优化,顺序我建议是这样:
- 先量。 没有
app.config.performance和 Performance 面板的数据,所有优化都是猜。 - 清结构性问题。 内联函数、模板内计算、index key,这三样改完,绝大多数列表页就够用了,改动小、风险低。
- 再看响应式粒度。
shallowRef/markRaw收益中等,但改动面大,容易引入「视图不更新」的坑,要先跟团队对齐契约。 - 最后上虚拟滚动。 它最有效,引入的边界条件也最多:固定行高、快速滚动白屏、行内组件状态复用。
v-memo 我一般放最后甚至不加。它跳过的是 patch,而多数业务页面的瓶颈根本不在 patch——把内联函数清掉收益就比它大。