讲解
Vue 应用的性能问题集中在两类:渲染慢(页面卡顿、掉帧)和加载慢(首屏白屏久)。渲染侧的优化原则只有一句话:让每次数据变化触发的重渲染尽可能小。Vue 的组件级更新追踪已经很细(只有依赖变化的组件重渲染),但架不住「单个组件里渲染一万行列表」——这种场景的答案是虚拟列表:只渲染视口内的几十行,滚动时复用 DOM 节点换数据,vue-virtual-scroller 等库开箱即用。
计算侧,把昂贵的推导放进 computed(有缓存),避免在模板里每次渲染都跑一遍重计算;不变的大数据(比如一份 10 万行的字典表)用 shallowRef 或 markRaw 告诉 Vue「别深度追踪它」,省掉响应式代理的开销。v-for 大列表配 v-memo(Vue 3.2+)可以按依赖跳过子树更新。
加载侧的主线是「少下载、晚下载」。路由懒加载(前面讲过)是第一刀;构建产物用 rollup-plugin-visualizer 或 source-map-explorer 分析,揪出意外打进包的大家伙(moment、整库引入的 lodash 是常客);第三方库按需引入;图片用现代格式(webp/avif)加懒加载。首屏要求高的场景考虑 SSR/SSG——但那是另一个工程量级,先把懒加载和按需引入做完通常就够了。
最后一条:先测量再优化。Chrome DevTools 的 Performance 面板录一段操作,看长任务在哪;Vue DevTools 能看组件渲染耗时。拍脑袋优化常常优化错地方。
示例
手写虚拟列表的核心计算:给定滚动位置,算出该渲染哪几行(在本教程构建时被真实执行):
import assert from 'node:assert/strict';
// 虚拟列表:视口 600px,每行 40px,上下各多渲染 3 行缓冲
function visibleRange(scrollTop, total, rowHeight = 40, viewport = 600, buffer = 3) {
const first = Math.max(0, Math.floor(scrollTop / rowHeight) - buffer);
const last = Math.min(total - 1, Math.ceil((scrollTop + viewport) / rowHeight) + buffer);
return { first, last, count: last - first + 1, offsetY: first * rowHeight };
}
const TOTAL = 10000;
const atTop = visibleRange(0, TOTAL);
assert.strictEqual(atTop.first, 0);
assert.strictEqual(atTop.last, 18); // 15 行视口 + 3 行缓冲
assert.strictEqual(atTop.count, 19);
const scrolled = visibleRange(4000, TOTAL);
assert.strictEqual(scrolled.first, 97); // 4000/40 = 100,减缓冲 3
assert.strictEqual(scrolled.offsetY, 3880);
// 渲染节点数与总数解耦:一万行也只渲染 ~19 个节点
assert.ok(scrolled.count < 25);
console.log('一万行列表,任意滚动位置只渲染约', atTop.count, '个 DOM 节点');
// shallowRef 思路:只追踪引用替换,不递归代理大对象
function shallowRef(value) {
return {
get value() {
return value; // 内部属性变化不会被追踪
},
set value(v) {
value = v; // 只有整体替换才触发更新
},
};
}
const bigTable = shallowRef({ rows: new Array(100000).fill(0) });
const rowsBefore = bigTable.value.rows;
bigTable.value.rows[0] = 1; // 改内部,无追踪开销
assert.strictEqual(bigTable.value.rows, rowsBefore);
console.log('shallowRef: 10 万行数据零代理开销,整体替换才更新');
组件中的性能配置:
<script setup>
import { ref, shallowRef, computed } from 'vue';
// 大数据表:浅响应式,整体替换才触发更新
const dict = shallowRef(await (await fetch('/api/dict')).json());
// 昂贵推导进 computed,有缓存
const keyword = ref('');
const filtered = computed(() => {
if (!keyword.value) return dict.value;
return dict.value.filter((r) => r.name.includes(keyword.value));
});
</script>
<template>
<input v-model="keyword" placeholder="万行数据里搜索" />
<!-- 真实项目换用 vue-virtual-scroller 等虚拟列表组件 -->
<li v-for="row in filtered.slice(0, 100)" :key="row.id" v-memo="[row.updated]">
{{ row.name }}
</li>
</template>
常见坑
- 没测量就优化:凭直觉加 memo/v-memo,复杂度上去了瓶颈却没动;先 Performance 面板录一段。
- 模板里跑重计算:
{{ list.filter(...).map(...) }}每次渲染都全量执行,收进 computed。 - 巨型静态数据用 ref:10 万行字典深度代理一遍就要几百毫秒,shallowRef 或 markRaw。
- v-memo 依赖漏写:依赖列表没写全,数据变了视图不更新——v-memo 出 bug 的第一嫌疑。
- 首屏塞全量路由组件:不懒加载时首包体积失控,路由级动态 import 先做了再说。
小结
渲染侧靠「缩小重渲染范围」(computed、shallowRef、虚拟列表、v-memo),加载侧靠「少下晚下」(懒加载、按需引入);先测量再动手。下一章讲质量保障:组件测试。