Skip to content

性能与加载体验 ​

“页面好慢”是最常见、也最难直接动手的反馈。慢在哪?是白屏时间长,是点了按钮没反应,还是滚动一卡一卡?不同的“慢”原因完全不同,优化方法也不一样。

这篇用三个业界通用的指标把“慢”拆开,每一类都给出常见原因、修复方法和定位手段。

读完这篇你会

  • 用 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
正在加载成员列表…        → 正常等待
没有符合条件的成员        → 空结果,提示调整筛选条件
连接超时,点击重新加载    → 可恢复的失败

排查思路 ​

  1. 先测量,再优化:用 Chrome 的 Lighthouse 跑一次,看三个指标哪个不达标,以及它给出的具体诊断项(阻塞渲染的资源、未使用的 JS、未设置尺寸的图片等)。
  2. 区分实验室数据和真实数据:Lighthouse 是在你的电脑上模拟的;真实用户的设备和网络差得多。PageSpeed Insights 会同时展示真实用户数据(如果网站访问量够);也可以用 web-vitals 库在自己的页面上采集真实指标并上报。
  3. LCP 慢:看 Network 面板的 Waterfall,主图片或主要内容是什么时候开始下载的、被什么挡住了。
  4. INP 差:用 Performance 面板录制一次卡顿的交互,找点击后最长的那个任务,展开看是哪个函数耗时。
  5. CLS 高:在 Rendering 面板勾选 Layout Shift Regions,复现操作,看哪里闪蓝色。
  6. 用真实条件复现:Network 选 Fast 4G 或 Slow 4G,Performance 面板里把 CPU 降速 4 倍,用接近真实的数据量(不要只用三条测试数据)。

检查清单 ​

  • [ ] 路由和大依赖按需加载,首页 JS 体积有预算
  • [ ] 首屏主图不懒加载,并设置了 fetchpriority="high"
  • [ ] 图片有宽高,使用合适的尺寸和格式
  • [ ] 互不依赖的接口请求并行发出
  • [ ] 交互先给即时反馈,重计算不阻塞主线程
  • [ ] 超过几百行的列表使用分页或虚拟滚动
  • [ ] 加载、空数据、失败都有明确的状态和下一步
  • [ ] 优化前后都用同样的条件测量并记录

延伸阅读 ​

为复用而记录,为理解而整理。