做前端性能优化,最折磨人的不是技术难度,而是你会反复问自己:改的这点东西到底有没有用?我最近把一个 Vue 3 + Vite 的项目从 Performance 55 分一步步磨到 Lighthouse 四项全部 100 分,回头把整个调整过程整理了一遍。发现真正有用的不是某一条配置,而是一套排查顺序:先砍体积、再拆代码、然后聚焦核心指标、最后补合规项。这篇文章把这套顺序写全,包括每个动作影响哪个指标、具体怎么改、改完大概能看到什么变化,给正在优化或者打算冲刺 100 分的同学当参照。
1. 把目标拆开:Lighthouse 100 分到底在考什么
1.1 四个分类的权重和大致的判分逻辑
说实话,Lighthouse 的 100 分并不是一个统一的分数,它由 Performance、Accessibility、Best Practices、SEO 四个分类组成,最终显示的是“所有分类均达到 100”。也就是说,你性能优化做得再好,只要漏掉一个 alt 属性或者 meta 描述,总分就卡在 99 或 98。
Performance 的分类逻辑是以五个实验室指标加权计算的:FCP(首次内容绘制)、SI(速度指数)、LCP(最大内容绘制)、TBT(总阻塞时间)、CLS(累积布局偏移),它们权重各不相同。Lighthouse 会在模拟的 4 倍 CPU 降速和网络降速环境下测三次取中位数,所以你在本地秒开的页面,上了线上不一定会拿 100,因为服务器位置、网络环境、设备 CPU 全都会影响结果。
这里我想说一个很反直觉的结论:Lighthouse 的 Performance 分数主要由 LCP、TBT、CLS 三个硬指标决定,FCP 和 SI 只要不是太离谱,对总分影响相对小。所以优化顺序应该是 LCP 优先、TBT 紧随、CLS 别返工。Accessibility、Best Practices、SEO 其实都是“过检查”的题,没有难度,但需要认真挨个看。
1.2 我的基线:没优化之前的样子
动手之前,我先把当时的构建产物完整记录下来:vendor 包 900KB 以上(gzip 后仍有 280KB),入口 JS 请求数超过 20 个,首屏背景图和几张产品图直接用原图(单张超过 400KB),自定义字体加载了三个字重。在未优化状态下,Lighthouse 实测 Performance 55、Accessibility 78、Best Practices 77、SEO 80。首屏白屏接近 4 秒,在弱网环境更严重,滚动页面时能明显看到卡顿。
记录基线最大的价值在最后复盘。你改了某项配置后,不会知道那项改动到底值多少钱,只有对比基线前后才看得出收益。我当时每做完一次调整,就固定用同一台机器、同一个网络环境重跑一次 Lighthouse,把分数截图存下来。这个方法帮我避免了很多“自我感觉良好但分数没动”的无效优化。
2. Vite 构建层:从体积源头砍掉一半流量
2.1 代码分割与手动分包
Vite 底层走的是 Rollup,默认行为已经支持动态 import 的自动代码分割,一个项目如果路由都是按动态 import 写的,构建后每个路由页面会各自成一个 chunk。很多人做到这一步就停了,但我发现如果 Vue、Vue Router、Pinia 这些公共依赖全被 Rollup 打成一个巨大的 vendor chunk,首屏还是得一次性下载几百 KB 的 JS 才能开始执行。
Vite 的默认策略会尝试把 node_modules 拆到 vendor 中,但它不会帮你精细化拆分。这时需要用 manualChunks 手动把核心依赖按维度切分,让浏览器可以并行下载,也让公共依赖的缓存独立出来。我的分法比较通用:一个 vendor 放 vue、vue-router、pinia,一个 charts 放 echarts 等图表库,一个 ui 放组件库相关。组件库单独放有另外一个好处:改业务代码时,用户访问时组件库 chunk 命中了强缓存,根本不用重新下载。
具体配置如下:
// vite.config.js export default defineConfig({ build: { rollupOptions: { output: { manualChunks: { 'vue-core': ['vue', 'vue-router', 'pinia'], 'charts': ['echarts'], 'ui-lib': ['element-plus'] } } } } })注意:manualChunks 配置不是越多越好,切得太碎反而会生成大量小文件,HTTP 请求数和压缩率都会受影响。一般把首屏需要同步用的核心依赖切成 3~5 个 chunk 就够了。
除了手动分包,还有几个构建参数的调整:
export default defineConfig({ build: { target: 'es2018', sourcemap: false, cssCodeSplit: true, chunkSizeWarningLimit: 600 } })target 设为 es2018 是兼容性和打包体积的平衡点;默认的 ESNext 会让语法更现代,但在旧设备会有解析问题,太老的 target 又会引入不必要的转换。sourcemap 在生产环境记得关闭,它在调试阶段很有用,但生产环境只会白白增加几十甚至上百 KB。
2.2 依赖预构建与 Brotli/Gzip 双压缩
Vite 在开发阶段会通过 esbuild 把依赖做预构建,让原本是 CommonJS 的第三方库转成 ESM 格式,并缓存到 node_modules/.vite 目录。如果你发现某个库开发环境加载很慢,多半是它在预构建时没有被正确缓存,或者被频繁触发重新预构建。可以在 optimizeDeps.include 显式加入常见的库,减少运行时处理:
export default defineConfig({ optimizeDeps: { include: ['vue', 'vue-router', 'pinia', 'axios'] } })构建完的产物,我会在服务器上再做一层压缩。大多数部署平台默认开了 Gzip,但 Brotli 的压缩率通常比 Gzip 再高 20% 左右。如果 Nginx 版本支持,建议优先配 Brotli,并同时协商兼容 Gzip。常见的静态资源,包括 JS、CSS、SVG、字体,都建议压缩。JS 文件在所有压缩方案下差异非常明显:一个 300KB 的 JS,Gzip 后大约 90KB,Brotli 后能压到 75KB 以下。
另外,Vite 构建默认用 esbuild 做压缩,这已经很快,速度比 terser 快一个数量级。如果你的项目需要兼容很老的环境,不能用 esbuild 的某些压缩特性,需要用 terser 替代;现在大部分项目没有这种约束,直接保持默认就好。
2.3 图片资源和字体:最常被忽视的两块流量
Lighthouse 的 LCP 指标有很大概率被首屏图片拖着。图片优化要守三条规则:能不用图就不用图,能用矢量就用矢量,必须用位图就用 WebP/AVIF。
我的处理方式是先用压缩工具把项目中图片全部转成 WebP 格式,并把单张超过 200KB 的原图缩小分辨率。针对首屏 hero 图,优先用 CSS 渐变或者 SVG 占位,这样的资源大小通常会从几百 KB 降到几 KB。非首屏的图片全部加上懒加载,但 LCP 图片绝对不能懒加载,反而要设置 fetchpriority="high" 并 preload。
字体优化的思路类似。自定义字体每个字重的 woff2 文件从几十 KB 到上百 KB 不等,如果用普通字体一次加载多字重,流量和渲染耗时都会暴涨。我的建议是只保留必须的 400/700 两个字重,并设置 font-display: swap,避免字体加载阻塞文本渲染。如果是给 logo 用的字体,直接用 SVG 或者图片代替都比字体好。
3. Vue 3 运行时:把响应式开销和渲染成本降下来
3.1 路由与组件两级懒加载
SPA 项目的首屏之所以慢,很大原因是把所有路由页面都打进一个 bundle 里。Vue 3 + Vue Router 4 按路由做异步组件非常简单,把静态 import 改成箭头函数返回 import() 就能让每个路由页面独立成 chunk,访问到才下载。
// router/index.js const routes = [ { path: '/', component: () => import('@/views/Home.vue') }, { path: '/dashboard', component: () => import('@/views/Dashboard.vue') } ]除了路由级懒加载,还有一些体积大的全局组件,比如富文本编辑器、看板用的图表库、PDF 预览组件,它们往往只在某个弹窗或者某个页面里用到。如果用 defineAsyncComponent 包一层,就能让它们在第一次需要时才真正加载:
import { defineAsyncComponent } from 'vue' const RichTextEditor = defineAsyncComponent(() => import('@/components/RichTextEditor.vue'))defineAsyncComponent 还支持 errorComponent 和 timeout 参数,可以在网络异常时给出降级 UI。这个细节在文档里容易漏掉,实际项目却很实用。
3.2 能不响应就不响应
Vue 3 的响应式系统基于 Proxy,创建响应式对象时本身就有内存和运行时代价。对一个超大列表用 reactive 包裹,任何时候偶尔改动一个字段,都会触发 Vue 的依赖收集和更新检查,代价相当高。
这时候直接用 shallowRef 或者 markRaw,能省掉一大笔不必要的开销。
import { shallowRef } from 'vue' const list = shallowRef([]) // 获取新数据后整体替换,而不是 push function loadNextPage() { list.value = [...list.value, ...newItems] }shallowRef 只监听 .value 的引用变化,内部字段改动不会触发响应式,这对那种“一次拉取几千条、统计图形只需要引用切换”的数据特别有效。同理,如果一个对象在页面里只显示一次、后续完全不变,可以用 markRaw 直接标记为非响应式,完全没有追踪成本。
另一个容易被忽略的点是:某些第三方组件库在内部会使用 reactive 或 ref,把你的数据做一层代理。如果你明确知道某部分数据不需要联动,传进去之前可以先 markRaw 一下。这是我在排查表格卡顿时发现的问题:一个 5000 行的数据数组,因为之前在 setup 里全程 reactive 包裹,每次渲染全量重新计算。改成 shallowRef + 按页 slice 后,交互延迟肉眼可见地下降。
3.3 模板编译优化与 v-memo
Vue 3 的编译器已经做了很多静态节点提升和事件缓存优化,所以模板本身大部分时候不需要过度优化。如果你的页面有一个很长的列表,且列表项内部嵌套了大量组件,可以用 v-memo 让 Vue 跳过未变化项的更新。典型场景是“表格/卡片列表 + 筛选条件变更”:
<div v-for="item in filteredList" :key="item.id" v-memo="[item.id, item.status]"> <UserCard :user="item" /> </div>v-memo 数组里的每一项都会参与对比,只有发生变化时,对应的元素才会重新渲染。如果列表项还有子组件,v-memo 的收益会大很多。注意别滥用:如果每一项的依赖值变化频率非常高,v-memo 的对比成本反而可能超过重新渲染的成本。
还有一类组件可以用 v-once 标记。整块模板在首次渲染后不再变化,比如静态页脚、不随数据更新的说明文案,加上 v-once 可以直接跳过后续 diff。这个不算大优化,但在多处使用后累积起来也有效果。
4. Lighthouse 五个指标逐个击破
4.1 LCP:让最大的内容块更早出现
LCP 指的是视口内最大可见元素的绘制时间,可能是首屏 hero 图、大标题或者首屏卡片。我在项目里发现 LCP 卡在 3.8 秒,最大元素是首屏的一张产品大图。调整策略有三步。
第一步,缩小图片体积。把 1200px 宽的原图换成压缩后的 WebP,尺寸按显示尺寸来,不要请求实际原图。第二步,让 LCP 元素尽快加载。给对应 img 标签直接去掉 lazy,增加 fetchpriority="high",同时在 link 标签里 preload:
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />第三步,检查 LCP 路径上的 TTFB 和 JS 执行。如果首屏组件要等某个异步接口返回后才能渲染,LCP 时间会被硬生生拖后。我的做法是把首屏需要的基础数据在 body 顶部通过 script 注入到全局变量里,让首屏组件不用等接口就能开始渲染。
LCP 是 Performance 总分里权重最高的指标之一,把它压进 2.5 秒以内,基本就解决了一大半性能分。
4.2 CLS:彻底告别布局抖动
CLS 衡量的是页面加载过程中视觉元素意外移动的程度。最常见的几个元凶:图片没设置宽高导致加载后跳一下、自定义字体切换导致字体度量变化、轮播图或广告容器高度为 0 导致内容下移、接口异步返回后动态插入高块。
修起来不复杂,但没有哪个是纯代码能解决的。图片和视图像素块全部写上 width/height,或者用 CSS 的 aspect-ratio 固定宽高比例;自定义字体用 font-display: swap,并保持 400/700 字体度量一致;异步加载的内容,提前在容器上预留最小高度;不要在首屏用 transform 做动画,也不要在页面加载初期往顶部插入 dismiss 类组件。
我在这个项目上排查过 CLS,来源不是图片而是轮播图。它初始化前高度为 0,初始化后把下方内容往下推。解决方式是传入固定的 slide 高度,或者在轮播图上外层包一层 aspect-ratio 和 max-height 约束。改完后 CLS 从 0.22 降到 0。
4.3 TBT:给主线程让路
TBT 反映的是从 FCP 到交互时间之间,主线程被长任务阻塞的总时间。Lighthouse 模拟 4 倍 CPU 减速后,任何阻塞超过 50ms 的任务都会影响 TBT。大量第三方脚本、复杂计算、大图片懒加载解析、超大组件的同步渲染,都是长任务来源。
我做的几个有效改动:
- 把 JSON 解析、字段格式化这类计算任务移到 Web Worker 或者异步的 requestIdleCallback 里;
- 第三方脚本,比如统计代码、客服组件,全部改成 async/defer,并且延后到用户空闲时再注入;
- 首屏渲染不需要的 UI 组件,用 v-if 控制延迟挂载,例如商品推荐模块在 idle 之后挂载;
- 列表数据处理改为分批处理,一次只更新一屏的数据量,而不是直接把几千条数据一次性塞给组件树。
另外还有两个小配置也直接影响 TBT:动画和过渡使用 CSS transform/opacity,避免触发强制同步布局;全局事件监听改为事件委托,减少每个子组件单独绑定和解绑事件带来的主线程开销。
5. 无障碍、最佳实践与 SEO 的最后一分
5.1 无障碍检查清单
Accessibility 100 分看起来难,其实把检查项理清后就是一次规范审。高频问题通常集中在:页面 lang 属性缺失、图片没有 alt、输入框没有关联 label、可点击元素没有键盘支持、颜色对比度不足、焦点顺序混乱、缺少 aria 状态提示。其中,表单 label 和 alt 是我最容易漏的地方,因为组件库封装的表单会生成常规 label,但某些自定义的 icon 按钮没有辅助文本,也会被扣掉。
组件库能覆盖一部分,但自定义组件一定要做二次确认。如果团队里没有专职无障碍工程师,我建议直接用 axe 浏览器扩展跑一遍,把失败项自动列出来,比人工排查快很多。开发规范期就把 label、语义化、焦点管理纳入 code review 范围,远比上线后再补要省力。
5.2 最佳实践与 SEO 的合规项
Best Practices 的检查项大多是自动化的:页面是否在 HTTPS 下运行、控制台是否有报错、所使用的浏览器 API 是否已废弃、图片是否有正确的宽高、cookie 是否设置了安全属性、错误页面是否返回合理状态码。这个分类最容易处理,但也最容易被忽略。很多时候你代码写得没问题,控制台里某个旧依赖打印了一行 warning,就会扣掉好几分,所以上线前把 devtools 里的 console 全部清干净是一个性价比极高的动作。
SEO 的检查则更看重内容可读性:每个页面有唯一的 title 和 meta description、有 canonical 指向、robots.txt 可访问、移动端 viewport 设置、结构化数据可选。viewport 是很多 SPA 项目容易忽视的点,如果 index.html 里没有<meta name="viewport" content="width=device-width, initial-scale=1" />,Lighthouse 会直接判定移动端不友好,把整个 SEO 分拉低。
<meta name="viewport" content="width=device-width, initial-scale=1" />5.3 SPA 场景下 SEO 的隐藏分
SPA 默认只有一个 index.html,如果 title 和 description 不按路由切换,搜索引擎和 Lighthouse 都会认为页面内容不可区分。我在项目里用 Vue Router 的 meta 字段维护每页的关键信息,并在 afterEach 里统一更新:
router.afterEach((to) => { const { title, description } = to.meta document.title = title ? `${title} - 我的项目` : '我的项目' if (description) { let meta = document.querySelector('meta[name="description"]') if (meta) meta.setAttribute('content', description) } })再就是 canonical 标签。SPA 页面同一个 URL 如果带了不同的 query 参数,容易被搜索引擎判定为重复内容。在 index.html 里放一个自引用 canonical,正常情况下就够用,如果路由规则比较复杂,可以在路由变化时同步更新 canonical 的 href。还有 og:image、og:title 这些分享卡片标签,对社交平台的预览分数有影响,Lighthouse 的 SEO 检查里也会看主要 meta 标签是否存在。
6. 从 55 分到 100 分的实测复盘与避坑
6.1 常见问题速查
我在这次优化里反复遇到几种情况,给后来者提前排一下雷:
| 现象 | 原因 | 解法 |
|---|---|---|
| 本地 Lighthouse 100,线上只有 70 | 本地状态覆盖、接口太快、机器 CPU 不同 | 线上同环境重测,多次取中位数 |
| LCP 偶尔掉到 4 秒 | 首屏图片或字体 preload 顺序不对 | 检查 network waterfall,确保关键资源最先请求 |
| 总分 99,差距在 Accessibility | 有一个未关联 label 的表单或图标按钮 | 用 axe 找出失败项逐一修复 |
| 图片加了 lazy 但 LCP 还是慢 | 对 LCP 图片也用 loading=lazy | 对 LCP 元素去掉 lazy 并 preload |
6.2 优化顺序的建议
如果项目时间有限,我不建议一上来就调一堆配置。我的实际顺序是:先做体积削减,包括分包、图片压缩、压缩传输;再做指标专项,顺序是 LCP 到 CLS 到 TBT;最后过一遍合规检查,把无障碍、最佳实践、SEO 全跑一遍。这个顺序的优点是见效快,效果最容易看出来,而且不会把时间浪费在影响很小的细节上。
性能优化里最常见的问题是“优化了但没有证据”。所以我每次调整前都会把 Lighthouse 报告存档,使用 Lighthouse Node 模块还可以在 CI 里对分数设门槛。给团队设一个不能低于 90 的基线,至少能防止下个版本一步到位回退到 70 分。
6.3 关于 100 分的一些实话
这个 100 分,不代表你的网页全世界最快,只代表你在 Lighthouse 定义的评价体系里把所有暴露出来的坑都填平了。真正的用户体验还要考虑服务器地域、网络带宽、用户设备、后端接口耗时等外部因素。如果你想要外部因素也稳定,得在更完整的监控体系里做长期跟踪,比如接上真实用户监控和核心 Web 指标统计。
但我还是建议每个前端项目至少跑一次 Lighthouse 100 分冲刺,因为这是一次强制体检——它会逼着你把资源体积、阻塞计算、可访问性漏洞、SEO 基础全部过一遍。在没有这套优化之前,你对项目的性能感知是模糊的;做完之后,你能清清楚楚说出每个资源压缩多少、每个 chunk 多大、每次渲染大概花多少时间,这种感觉比分数本身有意思多了。