Vue 项目性能优化:从指标、定位到代码实践
性能优化不能靠背诵“Vue 八股”。要先知道用户到底慢在哪里,再用网络、浏览器、框架和业务数据共同验证。原文中的 Lighthouse、FCP/LCP、代码分割、图片懒加载、虚拟滚动等主线仍然有效,但指标体系和部分 Vue API 已发生变化。
一、性能优化先看什么
1.1 Lighthouse 与真实用户监控
Lighthouse 是一种实验室性能审计工具,可以在控制条件下分析页面。Chrome DevTools、PageSpeed Insights 和 CI 中都可以使用它。

但 Lighthouse 分数不能代表所有真实用户:网络、设备、地区、缓存和交互路径都不同。生产环境还应使用 RUM(Real User Monitoring)采集真实用户的 PerformanceEntry、导航、资源和错误数据。
实验室数据(Lighthouse)
├─ 可重复,适合回归和定位
└─ 设备、网络、脚本通常是模拟的
现场数据(RUM/CrUX)
├─ 反映真实用户体验
└─ 受地区、设备、缓存和业务路径影响
原文中“本地 70 分,线上才可能及格”只是个人经验,不能作为通用标准。性能目标应该按业务、设备分层,并关注 p75 等分位数,而不是追求一个固定 Lighthouse 分数。
1.2 当前 Core Web Vitals
目前最重要的 Core Web Vitals 是:
| 指标 | 含义 | 以 p75 为目标的“良好”阈值 |
|---|---|---|
| LCP | 最大内容绘制,反映主要内容出现速度 | ≤ 2.5s |
| INP | Interaction to Next Paint,反映交互响应 | ≤ 200ms |
| CLS | 累积布局偏移,反映视觉稳定性 | ≤ 0.1 |
FCP、TTFB、TBT 和 Speed Index 仍然是有用的诊断指标,但不是 Core Web Vitals 的全部。Lighthouse 主要提供实验室数据,不能直接代表真实用户的 INP;TBT 只能作为实验室响应性代理,INP 需要通过 RUM/CrUX 等真实用户数据评估:
| 指标 | 用途 |
|---|---|
| FCP | 首次内容绘制,观察页面何时出现第一个内容 |
| TTFB | 首字节时间,定位服务器、网络和缓存问题 |
| TBT | 实验室中长任务超过 50ms 的阻塞部分之和,常作为实验室响应性的代理指标 |
| Speed Index | 观察可视区域填充速度 |
| TTI | 历史 Lighthouse 指标,已不再是当前主要目标,应优先关注 INP/TBT |
FID 已经由 INP 取代成为主要的交互 Core Web Vital。不要继续把原文的“六大指标”表述成当前唯一标准。

二、LCP、FCP、CLS 和 INP 怎么优化
2.1 FCP:让第一个内容更快出现
FCP 受关键渲染路径影响,常见瓶颈包括:
- HTML 到达慢;
- 阻塞渲染的 CSS 过大;
- 首屏必须执行的 JavaScript 过多;
- 字体下载和字体交换阻塞文本;
- SSR/预渲染输出为空或等待过多数据。
建议:
- 减少首屏必须下载和执行的 JS;
- 抽离非关键 CSS,避免把整个组件库样式放进首屏;
- 对关键字体设置合适的
font-display,必要时预加载,但不要盲目预加载大量字体; - 在 SSR/SSG 中直接输出可见内容,在 CSR 中提供合理的骨架屏;
- 通过 Network、Coverage 和 Performance 面板确认真正的阻塞资源。
defer 和 async 不是“给所有脚本都加上就一定更快”:
defer脚本在 HTML 解析完成后按顺序执行;async脚本下载完成后立即执行,顺序不保证,依赖关系复杂时可能出错;type="module"默认具有 defer 类似的延迟执行语义;- 第三方脚本应按是否关键、是否依赖 DOM、是否影响交互来安排,不要只因为“放底部”就认为问题解决。
2.2 LCP:优化最大内容元素
LCP 候选可能是首屏图片、视频 poster、标题或大段文本。浏览器会在加载过程中不断更新候选,最终选出符合条件的最大内容元素。
对于图片 LCP:
<img
src="/hero.avif"
width="1280"
height="720"
fetchpriority="high"
decoding="async"
alt="产品首页"
/>
优化方向:
- 使用 AVIF/WebP 和合适尺寸;
- 使用
srcset/sizes,不要让移动设备下载桌面大图; - 给图片设置
width/height或aspect-ratio,避免布局跳动; - LCP 图片不要无条件使用
loading="lazy",否则会推迟加载; - 必要时使用 preload,但只预加载真正的 LCP 资源;
- 检查 CDN、缓存、压缩、响应时间和图片解码成本;
- 如果首屏是文本,检查字体请求和 CSS 是否阻塞文本显示。
<template>
<picture>
<source
type="image/avif"
srcset="/hero-640.avif 640w, /hero-1280.avif 1280w"
sizes="100vw"
/>
<img
src="/hero-1280.webp"
width="1280"
height="720"
fetchpriority="high"
alt="产品首页"
/>
</picture>
</template>
2.3 CLS:预留布局空间
CLS 常见来源:
- 图片没有宽高;
- 广告、弹窗或异步内容插入顶部;
- 字体切换导致文字尺寸改变;
- 首屏请求完成后突然插入提示条;
- 使用 transform 以外的方式在加载后移动内容。
.avatar {
width: 48px;
height: 48px;
aspect-ratio: 1;
object-fit: cover;
}
.hero {
aspect-ratio: 16 / 9;
}
2.4 INP:减少交互后的主线程工作
INP 关注用户点击、键盘输入、触摸等交互之后,到下一次绘制的响应时间。常见问题不是 Vue 的一次 diff,而是:
- 点击后执行大 JSON 解析;
- 大列表同步筛选、排序和渲染;
- 长任务占满主线程;
- 事件处理器中同步执行大量第三方代码;
- 不必要的深层响应式更新和大量组件重渲染。
建议:
- 把昂贵计算拆分或放到 Web Worker;
- 通过分页、虚拟列表和服务端筛选减少数据量;
- 对搜索输入防抖,但不要让防抖掩盖服务器慢;
- 用 Performance 面板找出 Long Task;
- 只让真正需要变化的组件依赖响应式数据;
- 非关键工作安排到空闲时段,但要保留不支持 API 的回退。
三、通用网络与资源优化
3.1 代码分割与路由懒加载
Vue Router 路由组件推荐直接使用动态 import:
const routes = [
{
path: '/reports',
component: () => import('./views/ReportsView.vue'),
},
]
大型非首屏组件也可以异步加载:
import { defineAsyncComponent } from 'vue'
const RichEditor = defineAsyncComponent(() => import('./RichEditor.vue'))
<RichEditor v-if="showEditor" />
代码分割要结合访问频率、chunk 数量和缓存策略。把所有依赖都切成极小文件会增加请求和解析开销;把整个应用打成一个大文件又会拖慢首屏。
3.2 Tree Shaking 与包体积
- 生产构建关闭开发警告和调试代码;
- 优先使用支持 ESM 的依赖;
- 避免从一个巨大入口导入不需要的全部模块;
- 检查重复依赖和多个版本;
- 用 bundle analyzer 找到真正占体积的模块;
sideEffects配置错误会破坏 Tree Shaking,不能盲目删除副作用模块。
旧项目中的:
{
"scripts": {
"report": "vue-cli-service build --report"
}
}
仍可用于 Vue CLI 项目,但 Vue CLI 已处于维护模式。Vite 项目可以使用 rollup-plugin-visualizer、Vite 的构建分析能力或对应框架工具;Nuxt 4 应结合 Nitro、Vite 和 Nuxt analyze 工具分析产物。

3.3 压缩、缓存和 CDN
- 优先使用 Brotli,无法使用时使用 gzip;
- JS/CSS/字体设置长期缓存,文件名包含内容 hash;
- HTML 和 API 按更新频率配置缓存;
- CDN 能缩短网络距离,但源站、缓存命中率和回源策略同样重要;
- HTTP/2/HTTP/3、连接复用和资源优先级会改变“减少请求”的收益;
- 不要因为旧版“减少 HTTP 请求军规”就把所有文件拼成一个巨大 bundle。
3.4 预加载与资源优先级
<link rel="preconnect" href="https://cdn.example.com" crossorigin />
<link
rel="preload"
as="image"
href="/hero.avif"
type="image/avif"
/>
preload、prefetch、preconnect 只应针对确实需要的资源使用。错误的 preload 会抢占带宽,反而影响 LCP。
四、Vue 项目中的具体优化
4.1 让组件依赖稳定
Vue 3 性能指南强调,优化不是“少写几个组件”,而是减少不必要的更新:
<!-- 不够稳定:activeId 改变时,所有子组件都收到新 prop -->
<ListItem
v-for="item in items"
:key="item.id"
:active-id="activeId"
/>
更稳定的方式是由父级计算每项真正需要的布尔状态:activeId 改变时,通常只有旧的 active 项和新的 active 项需要更新:
<ListItem
v-for="item in items"
:key="item.id"
:active="item.id === activeId"
:item="item"
/>
实际是否优化要用组件更新分析验证,不要为了形式而增加复杂 computed。
4.2 computed、watch 和方法
- computed 适合同步派生值,并具有缓存;
- watch 适合请求、日志、存储和第三方副作用;
- 不要在模板中反复调用昂贵方法;
- computed getter 不要修改其他状态;
- 监听大型 reactive 对象要评估深度遍历成本;
- 只有真正需要时才使用
deep: true,Vue 3.5+ 可以使用有限深度deep: number。
4.3 v-if、v-show 和 KeepAlive
v-if不满足条件时不创建组件,适合低频切换或延迟加载;v-show保留组件和 DOM,只切换 CSS,适合高频切换;- KeepAlive 缓存组件实例和状态,适合返回后希望保留状态的页面;
- KeepAlive 不是“初始化性能优化神器”,缓存过多会占内存;
v-if和v-for不要放在同一个元素上,列表过滤使用 computed。
<KeepAlive :max="8">
<component :is="currentView" />
</KeepAlive>
4.4 v-once、v-memo 和 shallow API
<!-- 确认永远不会改变的静态区域 -->
<StaticLegalNotice v-once />
<!-- 只有依赖变化时才更新该子树,使用前先测量 -->
<div v-memo="[item.id, item.version]">
{{ item.title }}
</div>
大对象或外部不可变数据可以考虑:
import { shallowRef, triggerRef } from 'vue'
const rows = shallowRef<Row[]>([])
// 替换数组会触发更新
rows.value = nextRows
// 如果确实原地修改了 shallowRef 内部对象,需要手动通知
rows.value.push(newRow)
triggerRef(rows)
shallowRef、markRaw 会减少响应式转换,但也会减少自动追踪。不要为了“优化”而让普通业务状态失去响应式。
五、图片懒加载与媒体资源
图片懒加载只适合非首屏、低优先级图片:
<img
src="/avatar.webp"
width="80"
height="80"
loading="lazy"
decoding="async"
alt="用户头像"
/>
不要给 LCP 图片统一加 loading="lazy"。对于长列表,懒加载可以和虚拟列表组合,但仍要设置图片尺寸避免 CLS。
旧文章推荐的 vue-lazyload 适合某些 Vue 2 项目,但新项目可以优先使用原生 loading="lazy"、IntersectionObserver 或经过维护的框架方案。引入插件前要检查 Vue 版本、SSR 支持和维护状态。


六、长列表与虚拟滚动
一次渲染数万条数据会增加:
- VNode 和 DOM 数量;
- 响应式依赖和内存;
- 布局、绘制和事件处理成本;
- 滚动时的主线程工作。
虚拟列表只渲染视口附近的数据,并用占位高度保持滚动条长度:
总数据 100000 条
↓
只渲染视口 + overscan 的几十条
↓
上方/下方占位空间维持滚动位置
固定高度列表可以自己实现;动态高度、键盘可访问性和滚动定位复杂时,优先使用经过验证的库。详见 52:虚拟列表。
旧文提到的 vue-virtual-scroller、vue-virtual-scroll-list 是第三方 Vue 2 生态方案,使用前需要确认 Vue 版本和维护状态;Vue 3 也可以考虑 VueUse useVirtualList 或适配当前项目的虚拟列表库。

七、函数式组件的版本边界
Vue 2 中函数式组件没有实例和响应式状态,适合纯展示、无状态的轻量组件:
export default {
functional: true,
props: ['title'],
render(h, { props }) {
return h('span', props.title)
},
}
Vue 3 中函数式组件就是接收 props/context 并返回 VNode 的函数;普通组件的运行时开销已经降低,不能再把“把所有展示组件改成函数式组件”当作通用性能方案:
import { h } from 'vue'
export default (props: { title: string }) => h('span', props.title)
首先确保组件结构清晰,再通过性能分析判断是否真的需要这种写法。
八、分批渲染和空闲任务
原文使用 requestIdleCallback 分批更新数据,这个方向适合非关键内容,但它不是所有浏览器都稳定支持,也不应拿来延迟关键首屏内容:
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const rows = ref(
Array.from({ length: 100 }, (_, id) => ({
id,
title: `第 ${id + 1} 条内容`,
})),
)
const displayedRows = ref(rows.value.slice(0, 20))
function runWhenIdle(task: () => void) {
// SSR 阶段不能访问 window;非关键任务可以等到客户端再安排
if (typeof window === 'undefined') return
if (typeof window.requestIdleCallback === 'function') {
window.requestIdleCallback(() => task())
} else {
window.setTimeout(task, 0)
}
}
onMounted(() => {
runWhenIdle(() => {
displayedRows.value = rows.value
})
})
</script>
模板中遍历 displayedRows。真实项目可以把示例数组替换成已准备好的数据,并在首屏先返回少量内容,再在空闲时追加非关键内容。
适用场景:
- 统计、预取、非首屏索引;
- 大量低优先级内容的分批初始化;
- 不影响用户立即交互的工作。
不适用场景:
- LCP 内容;
- 用户点击后必须马上出现的结果;
- 用来掩盖一次性加载过多数据的问题。
对于真正昂贵的计算,可考虑 Web Worker;对于大列表,优先分页、服务端筛选和虚拟列表。
九、排查工具与方法
9.1 Performance 面板
用 Chrome DevTools Performance 记录一次真实操作,关注:
- Long Task;
- Recalculate Style、Layout、Paint;
- JavaScript 调用栈;
- 图片请求和解码;
- 滚动处理器是否频繁触发;
- Vue 组件更新与业务函数是否重复执行。
9.2 Coverage
Coverage 可以帮助发现首屏没有执行的 JS/CSS,但“未执行”不等于“可以删除”:它可能在第二个路由、点击后或错误路径才使用。应结合 bundle analyzer 和路由访问数据决定是否拆分。

9.3 网络与资源
Network
├─ HTML/JS/CSS/图片大小
├─ TTFB、下载、解码和缓存
├─ 优先级与阻塞关系
└─ 是否错误预加载或重复请求
可以在 Network 中模拟移动设备、慢速网络和禁用缓存,避免只在开发机高速网络下判断性能。

十、Nuxt 4 和 SSR/SSG 项目
Nuxt 4 项目可以通过 SSR、预渲染或混合渲染减少首屏空白,但 SSR 不是自动更快:
- 服务端渲染会增加服务器计算和数据请求;
- HTML 输出后仍需要客户端 hydration;
- 过大的 hydration 负担会影响 INP;
- 缓存、边缘节点、数据请求和组件体积都需要测量;
- 只在客户端需要的组件可以使用 client-only 或懒加载,但要注意 SEO 和首屏内容。
<script setup lang="ts">
const { data, pending, error } = await useFetch('/api/products')
</script>
<template>
<ProductList v-if="data" :items="data" />
<LoadingState v-else-if="pending" />
<ErrorState v-else-if="error" />
</template>
静态站点、SSR、客户端渲染的目标不同:SEO 重要的内容可以预渲染或 SSR,登录后的后台页面可能更适合 CSR 和路由懒加载。不要用单一方案覆盖所有页面。
十一、性能优化清单
测量
- 区分 Lighthouse 实验室数据和 RUM 现场数据;
- 关注 LCP、INP、CLS,并结合 FCP、TTFB、TBT 定位;
- 用 Performance、Network、Coverage 和 bundle analyzer 验证;
- 使用 p75 和真实设备,不追求固定分数。
网络和资源
- 路由和大型功能按需加载;
- 图片使用现代格式、响应式尺寸和明确宽高;
- 不给 LCP 内容错误添加 lazy;
- 使用内容 hash、Brotli/gzip、合理缓存和 CDN;
- 只预加载确实关键的资源;
- 清理重复依赖和无效 polyfill。
Vue 代码
- 过滤列表使用 computed,不把
v-if和v-for写在同一元素; - 合理使用
v-if、v-show和 KeepAlive; - 稳定 props,避免无意义的组件更新;
- 大列表使用分页、服务端筛选或虚拟列表;
- 只对需要的状态使用深层响应式;
- 对异步请求、定时器和事件监听做好取消和清理。
用户体验
- 骨架屏与实际布局尺寸一致;
- 首屏内容优先,非关键内容延后;
- 错误和慢网络状态可见;
- 键盘、读屏和低端设备体验不能因“优化”被破坏。
十二、总结
- 性能优化首先是测量,不是背“Lighthouse 多少分”;
- 当前 Core Web Vitals 重点是 LCP、INP、CLS,FID 已由 INP 取代;
- FCP、TTFB、TBT、Speed Index 用于诊断不同阶段的瓶颈;
- LCP 需要优化资源发现、图片尺寸、服务器响应和字体,不能无脑懒加载;
- CLS 需要预留尺寸,INP 需要减少长任务和交互后的同步工作;
- Vue 项目重点关注代码分割、组件更新、响应式深度、列表规模和 hydration 成本;
- 函数式组件、KeepAlive、
v-show、CDN 和 PWA 都不是无条件有效的银弹; - 通过性能工具定位后,只修改真正影响用户的瓶颈,并用真实设备复测。
官方参考
- Vue 性能优化指南
- Vue 渲染机制
- Vue 异步组件
- Vue 3.5
v-memo - Vue 2 性能优化
- Lighthouse 官方仓库
- Web Vitals
- Largest Contentful Paint
- Interaction to Next Paint
- Cumulative Layout Shift
- VueUse
useVirtualList - Nuxt 4 Rendering Modes
原文出处
作者:老骥farmer
来源:稀土掘金
本文保留原文 Lighthouse、FCP/LCP/Speed Index、图片优化、代码分析、虚拟滚动、函数式组件和分批渲染内容,并更新 Core Web Vitals 与 Vue 3/Nuxt 4 的实践边界。