性能与加载体验
“页面好慢”是最常见、也最难直接动手的反馈。慢在哪?是白屏时间长,是点了按钮没反应,还是滚动一卡一卡?不同的“慢”原因完全不同,优化方法也不一样。
这篇用三个业界通用的指标把“慢”拆开,每一类都给出常见原因、修复方法和定位手段。
读完这篇你会
- 用 LCP、INP、CLS 描述用户感受到的“慢”
- 修复首屏白屏久、点击响应慢、页面跳动等典型问题
- 用 Lighthouse、Performance 面板和真实用户数据定位瓶颈
1. 原理:用户感受到的三种“慢”
拿去餐厅吃饭打比方:
| 用户的感受 | 餐厅里的类比 | 指标 | 良好标准 |
|---|---|---|---|
| 打开半天才看到主要内容 | 坐下多久上第一道主菜 | LCP(最大内容绘制) | ≤ 2.5 秒 |
| 点了按钮、输入文字,半天才有反应 | 举手叫服务员,多久有回应 | INP(交互到下一次绘制) | ≤ 200 毫秒 |
| 看着看着内容突然往下跳 | 上菜时桌子被撞得乱晃 | CLS(累计布局偏移) | ≤ 0.1 |
这三个指标合称 Core Web Vitals。它们的好处是从用户视角出发:不管你用了什么框架,用户只关心这三件事。优化前先测出是哪一项不达标,再对症处理。
2. 加载阶段:从输入网址到看见内容
text
请求 HTML → 发现 CSS、JS、图片 → 下载 → 解析执行 JS → 请求接口数据 → 渲染出主要内容(LCP)任何一步慢,LCP 都会慢。常见的瓶颈是:JS 太大下载和执行太久、主图片发现得晚或下载得慢、接口一个等一个。
坑一:所有代码打成一个大包,首屏白屏好几秒
现象:网站首页在手机 4G 网络下要白屏三四秒;Network 面板里有一个好几 MB 的 index.js。
原因:所有页面、所有依赖都打进了同一个文件。用户只是打开首页,却要先下载、解析整个应用的代码,包括他可能永远不会打开的后台页面、图表库、富文本编辑器。
修复:
- 按路由拆分:路由组件用
() => import('./pages/Report.vue')懒加载,用户进入哪个页面才下载哪个页面的代码; - 大依赖按需加载:图表、编辑器、PDF 预览等只在用到时
import(); - 替换臃肿的依赖:如用
dayjs代替moment、用lodash-es按需导入; - 分析体积构成:用
rollup-plugin-visualizer等工具生成体积图,先处理最大的那几块。
怎么避免再犯:在 CI 中设置包体积预算(比如首页 JS 不超过 200KB gzip),超标就提醒。
坑二:首屏大图被“懒加载”了
现象:首页顶部的大图(往往就是 LCP 元素)出来得特别慢,Lighthouse 提示 “Largest Contentful Paint image was lazily loaded”。
原因:为了优化性能,给所有 <img> 都加了 loading="lazy"。懒加载会让浏览器等到确认图片要进入视口时才开始下载,首屏图片反而被推迟了。
修复:首屏图片不要懒加载,还可以提高它的下载优先级;屏幕下方的图片再懒加载:
html
<!-- 首屏主图:尽早、优先下载 -->
<img src="/img/hero.webp" width="1200" height="600" fetchpriority="high" alt="活动主图" />
<!-- 屏幕下方的图片:懒加载 -->
<img src="/img/detail.webp" width="800" height="600" loading="lazy" alt="商品细节" />同时用合适的尺寸和格式:手机上不需要下载 3000px 宽的原图,用 srcset 提供多个尺寸;优先使用 WebP 或 AVIF。
坑三:接口一个等一个,形成“瀑布”
现象:页面数据要等很久才全部出来;Network 面板的 Waterfall 一栏里,几个接口请求像台阶一样一个接一个排开。
原因:父组件请求完用户信息才渲染子组件,子组件挂载后才发自己的请求;或者代码里对互不依赖的请求依次 await。
修复:互不依赖的请求同时发出(Promise.all);能提前知道的请求不要等到组件挂载才发,在路由进入时就开始;后端也可以考虑为首屏提供一个聚合接口。
3. 交互阶段:点了为什么没反应
浏览器只有一个主线程(见 事件循环)。用户点击时,如果主线程正在执行一段很长的 JS,点击只能排队等待,界面看起来就“卡住了”。
坑四:点击按钮后半秒才有反应
现象:在筛选面板里勾选一个选项,要停顿一下复选框才显示被选中;Performance 面板里能看到点击后有一个很长的任务。
原因:点击事件里同步做了大量工作:重新过滤几千条数据、重新渲染整张表格、写入本地存储……这些全部做完,浏览器才能画出“复选框已勾选”。
修复:
- 先给反馈,再干重活:先更新复选框状态,让出主线程,再开始过滤(具体写法见事件循环一文);
- 减少要做的工作:数据量大时分页或虚拟滚动,只渲染看得见的部分;
- 输入类操作做防抖:搜索框每输入一个字都过滤一次全表,改为停止输入 300ms 后再处理;
- 把纯计算挪到 Web Worker。
4. 稳定性:页面为什么会跳
坑五:内容加载出来,正在看的文字被挤下去
现象:用户正要点“确认”按钮,上方突然插入了一条通知横幅,按钮被挤下去,结果点到了别的东西。
原因:页面元素在没有预留空间的情况下插入或改变尺寸。常见来源:
| 来源 | 修复 |
|---|---|
| 图片没有宽高 | 写上 width、height 或 aspect-ratio |
| 异步插入的横幅、广告 | 预先留出固定高度的位置 |
| 骨架屏和真实内容尺寸不一致 | 骨架屏按最终布局的尺寸来画 |
| 网络字体替换 | 见 CSS 性能优化 的字体部分 |
5. 长列表:渲染一万行会发生什么
坑六:列表数据一多,页面就卡死
现象:后台表格一次加载五千条数据,打开要好几秒,滚动也卡。
原因:每一行都有很多 DOM 节点,五千行就是几十万个节点。浏览器要为每一个计算样式、布局、绘制,内存也会飙升。
修复:
- 分页:大多数后台场景最简单有效;
- 虚拟滚动:只渲染可视区域内的几十行,滚动时复用这些节点、替换内容。Vue 和 React 都有成熟的虚拟列表组件;
- 分批渲染:必须一次展示时,先渲染第一屏,剩下的分批追加。
6. 等待时让用户知道发生了什么
性能不只是“快”,还包括“慢的时候体验好不好”:
- 骨架屏的形状接近最终布局,避免数据到达时页面大幅跳动;
- 超过 1 秒的操作要有进度或加载提示,超过 10 秒的要能取消或在后台进行;
- 区分不同结果,给出下一步:
text
正在加载成员列表… → 正常等待
没有符合条件的成员 → 空结果,提示调整筛选条件
连接超时,点击重新加载 → 可恢复的失败排查思路
- 先测量,再优化:用 Chrome 的 Lighthouse 跑一次,看三个指标哪个不达标,以及它给出的具体诊断项(阻塞渲染的资源、未使用的 JS、未设置尺寸的图片等)。
- 区分实验室数据和真实数据:Lighthouse 是在你的电脑上模拟的;真实用户的设备和网络差得多。PageSpeed Insights 会同时展示真实用户数据(如果网站访问量够);也可以用
web-vitals库在自己的页面上采集真实指标并上报。 - LCP 慢:看 Network 面板的 Waterfall,主图片或主要内容是什么时候开始下载的、被什么挡住了。
- INP 差:用 Performance 面板录制一次卡顿的交互,找点击后最长的那个任务,展开看是哪个函数耗时。
- CLS 高:在 Rendering 面板勾选 Layout Shift Regions,复现操作,看哪里闪蓝色。
- 用真实条件复现:Network 选 Fast 4G 或 Slow 4G,Performance 面板里把 CPU 降速 4 倍,用接近真实的数据量(不要只用三条测试数据)。
检查清单
- [ ] 路由和大依赖按需加载,首页 JS 体积有预算
- [ ] 首屏主图不懒加载,并设置了
fetchpriority="high" - [ ] 图片有宽高,使用合适的尺寸和格式
- [ ] 互不依赖的接口请求并行发出
- [ ] 交互先给即时反馈,重计算不阻塞主线程
- [ ] 超过几百行的列表使用分页或虚拟滚动
- [ ] 加载、空数据、失败都有明确的状态和下一步
- [ ] 优化前后都用同样的条件测量并记录