一个 5000 行的订单列表,顶部一个搜索框。用户敲一个字符,光标要等两百多毫秒才往前走一格。
这个页面不是 Vue 慢,是每次按键都在 patch 四万个 DOM 节点。下面是从复现到压下去的完整过程,每一处改动我都标了代价。
先把它测出来,别靠感觉改
Vue 3 提供了一个开关,打开之后 Chrome Performance 面板里才会出现组件级别的耗时标记:
// main.js
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// 必须在 mount 之前设置;只在开发构建生效,生产构建里这个开关是空操作
app.config.performance = true
app.mount('#app')
打开 DevTools → Performance → 点录制 → 在输入框里敲几个字 → 停止。火焰图里找 component render 这一层,你能直接看到一次按键触发了多少次渲染、每次多久。
坑在这里:用生产构建测,火焰图里组件名全被压成 anonymous,你根本分不清是哪个组件。要么用 dev 构建测(绝对值偏大但比例可靠),要么在 vite 里给生产构建保留组件名。
另一个更直接的工具是 onRenderTriggered,它会告诉你「是哪个响应式数据的变化触发了这次重渲染」:
<script setup>
import { onRenderTriggered } from 'vue'
// 只在开发构建可用
onRenderTriggered((e) => {
// e.key 是触发的属性名,e.type 是 'set' / 'add' / 'delete'
console.log('[trigger]', e.type, e.key)
})
</script>
别在里面写 debugger,断点会打断 Performance 的录制,你测出来的数字全是假的。
复现:一段能跑出卡顿的代码
<!-- OrderListSlow.vue -->
<script setup>
import { ref } from 'vue'
const keyword = ref('')
const rows = ref(
Array.from({ length: 5000 }, (_, i) => ({
id: i,
orderNo: 'SO' + String(i).padStart(6, '0'),
customer: '客户' + (i % 200),
amount: ((i * 37) % 100000) / 100,
status: ['待付款', '已发货', '已完成'][i % 3],
createdAt: '2024-01-' + String((i % 28) + 1).padStart(2, '0'),
}))
)
// 模板里直接调用,每次重渲染都会重跑一遍
function filterRows() {
const k = keyword.value.trim()
if (!k) return rows.value
return rows.value.filter(
(r) => r.orderNo.includes(k) || r.customer.includes(k)
)
}
</script>
<template>
<input v-model="keyword" placeholder="搜索订单号 / 客户" />
<table>
<thead>
<tr><th>订单号</th><th>客户</th><th>金额</th><th>状态</th><th>日期</th></tr>
</thead>
<tbody>
<!-- 三个问题叠在一起:index 做 key、方法调用、5000 行全渲染 -->
<tr v-for="(row, i) in filterRows()" :key="i">
<td>{{ row.orderNo }}</td>
<td>{{ row.customer }}</td>
<td>{{ row.amount.toFixed(2) }}</td>
<td>{{ row.status }}</td>
<td>{{ row.createdAt }}</td>
</tr>
</tbody>
</table>
</template>
v-model 默认监听 input 事件,也就是每敲一个字符就同步更新一次 keyword,然后整个组件重渲染,然后 5000 行 × 5 列 = 25000 个单元格 diff 一遍。
我在这台 M1 / Chrome 120 / dev 构建上量的数字:
| 版本 | 首屏渲染 | 敲一个字符到画面更新 |
|---|---|---|
| 上面这段基线代码 | ~820ms | ~260ms |
只把 filterRows() 换成 computed |
~820ms | ~245ms |
再把 :key="i" 换成 :key="row.id" |
~810ms | ~232ms |
| 加上虚拟滚动,只渲染视口内约 25 行 | ~55ms | ~9ms |
再加 shallowRef + 120ms 输入防抖 |
~55ms | ~5ms |
dev 构建比生产构建慢 2~3 倍,绝对值你不用对,看相对比例就行。
第一个反直觉的点:computed 救不了你
很多人第一反应是「模板里调方法没有缓存,改成 computed 就好了」。改完发现还是卡。
原因很简单:filterRows() 遍历 5000 个对象,大概 1~3ms。而 patch 25000 个 DOM 节点,200ms 起步。你把 3ms 优化成 0,剩下的 200ms 一点没动。
computed 的价值在别处:当组件因为其他原因重渲染时(比如某个无关的 ref 变了),有缓存就不会重新过滤。但如果重渲染的触发源就是 keyword 本身,缓存必然失效,该算还是得算。
// 这个改动值得做,但它解决的不是你现在的瓶颈
const filteredRows = computed(() => {
const k = keyword.value.trim()
if (!k) return rows.value
return rows.value.filter(
(r) => r.orderNo.includes(k) || r.customer.includes(k)
)
})
顺手把 key 改对
:key="i" 在「列表内容稳定、只做尾部追加」的场景没问题。但你这个列表会被筛选、排序、删除,index 做 key 会让 Vue 按位置复用 DOM 节点:
<!-- 从 5000 行筛到 3 行时,Vue 复用了前 3 个 <tr>,只是把内容改掉 -->
<tr v-for="(row, i) in filteredRows" :key="i">
纯文本单元格看不出问题。但只要有行内 <input>、展开/收起状态、或者 <video>,用户输入的内容会残留在错误的那一行上。这是我在真实项目里踩过的 bug,排查了两个小时。
改成 :key="row.id",并且保证 id 在数据生命周期里唯一且稳定。用随机数或者 Math.random() 生成 key 更糟,每渲染一次全部节点重建。
真正的解法:不渲染你看不见的行
虚拟滚动的思路就一句话:容器高度按总行数算(撑出滚动条),真正渲染的只有滚动位置附近那几十行。
<!-- VirtualList.vue -->
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
const props = defineProps({
rows: { type: Array, required: true },
rowHeight: { type: Number, default: 36 },
buffer: { type: Number, default: 6 }, // 上下多渲染几行,快速滚动时不露白
})
const el = ref(null)
const scrollTop = ref(0)
const viewH = ref(0)
const startIndex = computed(() =>
Math.max(0, Math.floor(scrollTop.value / props.rowHeight) - props.buffer)
)
const endIndex = computed(() =>
Math.min(
props.rows.length,
Math.ceil((scrollTop.value + viewH.value) / props.rowHeight) + props.buffer
)
)
const visibleRows = computed(() => props.rows.slice(startIndex.value, endIndex.value))
const padTop = computed(() => startIndex.value * props.rowHeight)
const totalHeight = computed(() => props.rows.length * props.rowHeight)
// 滚动事件一秒能触发上百次,用 rAF 合并到每帧一次
let ticking = false
function handleScroll(e) {
if (ticking) return
ticking = true
const top = e.currentTarget.scrollTop
requestAnimationFrame(() => {
scrollTop.value = top
ticking = false
})
}
// 容器高度变化(窗口 resize、侧边栏收起)要重新算可见行数
let ro
onMounted(() => {
viewH.value = el.value.clientHeight
ro = new ResizeObserver(() => {
viewH.value = el.value.clientHeight
})
ro.observe(el.value)
})
onBeforeUnmount(() => ro && ro.disconnect())
</script>
<template>
<div ref="el" class="vs" @scroll.passive="handleScroll">
<!-- 这一层只负责撑出滚动条的高度,不渲染内容 -->
<div class="vs-ph" :style="{ height: totalHeight + 'px' }">
<!-- 用 transform 平移,避免改 top 触发重排 -->
<div class="vs-body" :style="{ transform: `translateY(${padTop}px)` }">
<div
v-for="row in visibleRows"
:key="row.id"
class="vs-row"
:style="{ height: rowHeight + 'px' }"
>
<slot :row="row" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.vs { height: 100%; overflow-y: auto; position: relative; }
.vs-ph { position: relative; }
.vs-body { position: absolute; top: 0; left: 0; right: 0; will-change: transform; }
.vs-row { box-sizing: border-box; }
</style>
用法:
<VirtualList :rows="filteredRows" :row-height="36">
<template #default="{ row }">
<div class="grid grid-cols-5">
<span>{{ row.orderNo }}</span>
<span>{{ row.customer }}</span>
<span>{{ row.amount.toFixed(2) }}</span>
<span>{{ row.status }}</span>
<span>{{ row.createdAt }}</span>
</div>
</template>
</VirtualList>
这几个坑必须提前知道
1. 固定行高是前提。 上面代码假设每行 36px。只要有一行内容换行变成 54px,后面所有行的位置全错。行高不定的场景要么用 ResizeObserver 逐行测量 + 二分查找定位(实现复杂度翻三倍),要么直接用 vue-virtual-scroller 的 VariableSizeList,别自己造。
2. 丢掉 <table> 语义。 上面用 div 模拟行,<thead> 和列宽对齐都没了。如果一定要用 <table>,得把 <tbody> 设为 display: block; overflow-y: auto,再把 <tr> 设为 display: table; table-layout: fixed; width: 100% 来对齐列宽,调起来很烦。做后台系统,直接上 div + CSS Grid,别跟原生表格较劲。
3. 浏览器 Ctrl+F 废了。 虚拟滚动下 DOM 里只有二三十行,用户按 Ctrl+F 搜订单号搜不到。这是硬代价,不是 bug。如果你们的用户有这个习惯,要么放弃虚拟滚动,要么给一个显式的搜索框并在 UI 上引导。
4. SSR 下会炸。 ResizeObserver 在服务端不存在,必须放在 onMounted 里。document 同理。
剩下那 9ms:从响应式和 props 上抠
虚拟滚动之后已经不怎么卡了,但还有两处白给的开销。
大数组别用深度响应式
ref([...5000 条]) 会创建深度响应式代理,每条记录的每个字段在首次访问时都会生成 Proxy。列表页通常不需要「改一个字段自动更新视图」这种能力,用 shallowRef 就够了:
import { shallowRef, triggerRef } from 'vue'
const rows = shallowRef([])
// 整体替换 → 触发更新
rows.value = await fetchOrders()
// 原地改 → 不触发,视图不会变
rows.value[0].status = '已完成' // ❌ 没反应
这个坑很隐蔽:同事写了个「标记完成」的功能,数据改了、接口调了、刷新页面确实变了,但当前页面就是不更新。补救有两种:
// 方式一:不可变更新,适合批量改
rows.value = rows.value.map((r) =>
r.id === targetId ? { ...r, status: '已完成' } : r
)
// 方式二:手动触发,适合改单条
rows.value[0].status = '已完成'
triggerRef(rows) // 强制通知依赖重新求值
5000 条浅拷贝一次大概 0.5ms,可以接受。但如果要在一个循环里改 100 条,用 triggerRef 只触发一次,比 map 100 次便宜得多。
另外两类对象千万别塞进响应式系统:ECharts 实例、编辑器实例、大段原始 JSON。用 markRaw 跳过代理:
import { markRaw } from 'vue'
// 不加 markRaw,Vue 会给整个 echarts 实例建 Proxy,后续每次内部属性访问都多一层
const chart = markRaw(echarts.init(el.value))
内联对象 props 会让子组件白渲染一次
这一点容易被从 React 过来的人搞反。先说要澄清的:Vue 里不需要 useCallback。下面这种写法是安全的,Vue 内部的 props 比对会跳过 emit 监听器:
<!-- 没问题,不会因为这个箭头函数导致子组件重渲染 -->
<RowItem v-for="row in rows" :key="row.id" :row="row" @select="(id) => select(id)" />
但有影响的是内联对象:
<!-- 每次父组件重渲染,都会生成一个新的 config 对象 → 引用不同 → 子组件重渲染 -->
<RowItem :row="row" :config="{ highlight: true }" />
改法是把常量提到模块作用域:
// 模块级常量,引用永远不变
const ROW_CONFIG = Object.freeze({ highlight: true })
<RowItem :row="row" :config="ROW_CONFIG" />
同样的道理,computed 里返回新对象、watch 里返回新数组,只要作为 props 传给子组件,都会破坏引用稳定性。
v-memo 能用,但很容易写出 bug
v-memo 会在依赖数组没变时跳过整个子树的 diff。对「行内容几乎不变」的表格有效:
<div
v-for="row in visibleRows"
:key="row.id"
v-memo="[row.id, row.status, row.amount]"
>
...
</div>
坑在于依赖数组必须手写全。漏一个字段,那个字段就再也不更新:
<!-- 漏了 row.amount,用户改了金额,视图纹丝不动 -->
<div v-for="row in rows" :key="row.id" v-memo="[row.id]">
{{ row.amount }}
</div>
这种 bug 不报错、不警告,只能靠人肉发现。所以只有在行数据确实不可变(比如日志流、只读报表)时才用 v-memo,可编辑表格别碰。
输入那一侧:别让每次按键都触发筛选
Vue 生态里最省事的是 @vueuse/core 的 refDebounced:
import { ref } from 'vue'
import { refDebounced } from '@vueuse/core'
const keyword = ref('')
const debouncedKeyword = refDebounced(keyword, 120) // 停顿 120ms 后才更新
// 筛选只依赖 debouncedKeyword
const filteredRows = computed(() => {
const k = debouncedKeyword.value.trim()
if (!k) return rows.value
return rows.value.filter((r) => r.orderNo.includes(k))
})
或者用 v-model.lazy,它只在 change 事件(失焦 / 回车)时同步值:
<!-- 适合「输入完按回车搜」的场景,不适合实时搜索 -->
<input v-model.lazy="keyword" />
防抖时长的边界:120ms 以内用户基本无感;超过 300ms 就会觉得「输入框有延迟」,反而更差。如果你的筛选逻辑超过 50ms,说明该上虚拟滚动了,不是靠加防抖能盖住的。
那些看着有用、实际没用的操作
用 v-show 隐藏大列表。 切 Tab 时把列表藏起来,DOM 还在,样式计算和内存占用一样在。要隐藏就用 v-if,让它真正销毁。
watch(rows, fn, { deep: true }) 监听大数组。 每次任何字段变化都要深度遍历 5000 条。改成监听具体字段,或者配合 shallowRef 只在整体替换时触发。
用 Object.freeze 当优化手段。 Vue 3 不保证跳过冻结对象的代理,而且冻结之后你连改都改不了。想减少代理开销,用 shallowRef,别绕这个弯。
给所有组件套 v-once。 只要有一个字段需要更新,你就得把它拆出来,拆到最后发现整棵树都不能 once,白折腾。
一张取舍表
| 手段 | 改造成本 | 收益 | 什么时候别用 |
|---|---|---|---|
| 虚拟滚动 | 中(要处理行高、语义、Ctrl+F) | 极大,O(n) → O(视口) | 列表 < 200 行;行高不定且不想引入库;用户依赖 Ctrl+F |
shallowRef |
低 | 中,省掉深层代理 | 需要频繁原地改单个字段并自动更新 |
v-memo |
低 | 中 | 行数据可变;依赖数组维护不了 |
| computed 替代模板方法 | 极低 | 小(省的是计算,不是 patch) | 计算本身有副作用时(那本来就不该用 computed) |
| 输入防抖 | 极低 | 中 | 搜索需要即时反馈;用户明确要求实时 |
key 用稳定 id |
极低 | 中(避免状态错位 bug) | 列表只追加、从不重排 |
| 拆分组件 | 中 | 小到中 | 拆完 props 还是整个大数组,等于没拆 |
最后一行值得多说一句:拆分组件之所以有用,是因为父组件重渲染时,props 没变的子组件会被跳过。但如果你把 5000 条数据整个传下去,props 引用一直没变,子组件确实不重渲染,可它内部还是要渲染 5000 行。拆组件解决的是「不必要的重渲染」,不解决「一次重渲染太贵」,后者只有虚拟滚动能治。
什么时候这套东西全是负收益
列表只有 100 行,上面所有优化加起来换不来一帧的提升,反而多了一堆 ResizeObserver、shallowRef、v-memo 的维护负担。判断标准很简单:用 Performance 面板量一次按键的耗时,低于 16ms 就别动它。
还有一个容易被忽略的边界:如果你的页面在低端安卓机上跑,上面所有数字要乘以 3 到 5。开发机上 9ms 的操作,在千元机上就是 40ms,已经掉帧了。测试的时候拿真机,别只看自己的 MacBook。
先量,再改。别猜。