主题
第 22 章 · 性能优化 · 高频面试题
性能是大厂前端面试的"必考压轴题"。以下题目按"理解 → 实战 → 进阶"递进排列。
1. 请说说 Core Web Vitals 三个指标是什么?分别衡量什么?
答:Google 提出的核心网页性能指标,覆盖"加载、交互、视觉稳定性"三个维度。
| 指标 | 全称 | 衡量 | 优秀阈值 |
|---|---|---|---|
| LCP | Largest Contentful Paint | 最大内容绘制时间,衡量"加载快不快" | ≤ 2.5s |
| INP | Interaction to Next Paint | 用户交互到下一帧绘制,衡量"操作跟不跟手"(2024 年起取代了 FID) | ≤ 200ms |
| CLS | Cumulative Layout Shift | 累计布局偏移,衡量"页面稳不稳" | ≤ 0.1 |
LCP 优化:CDN、预加载关键资源、减小首屏图片体积、SSR/预渲染、压缩。 INP 优化:拆分长任务(scheduler.yield()/setTimeout)、避免主线程阻塞、Web Worker。 CLS 优化:图片/视频写死宽高、字体用 font-display: optional、广告位预留空间、避免动态插入元素挤压内容。
2. 强缓存和协商缓存有什么区别?请画出完整决策流程。
答:
请求资源
│
▼
强缓存(Cache-Control / Expires)
│
├── 命中 → 直接读本地(200 from cache,不发请求)
│
└── 未命中 / 过期
│
▼
协商缓存(ETag / Last-Modified)
│
├── 资源没变 → 304 Not Modified(无 body)
│
└── 资源变了 → 200 + 新内容- 强缓存优先:浏览器先看
Cache-Control: max-age=...,没过期就直接用本地,不发请求。 - 协商缓存兜底:强缓存过期后,浏览器带上
If-None-Match(对应 ETag)和If-Modified-Since(对应 Last-Modified)发请求;服务器对比后决定返回 304 还是 200。
实战策略:
- HTML:
Cache-Control: no-cache(每次都协商,保证用户拿到最新版本) - 带 hash 的静态资源:
Cache-Control: max-age=31536000, immutable(一年长缓存)
3. 防抖(debounce)和节流(throttle)的区别?分别用在什么场景?
答:
| 概念 | 行为 | 类比 | 场景 |
|---|---|---|---|
| 防抖 | 触发后等 N 毫秒;期间再触发就重新计时,只执行最后一次 | 电梯等人 | 搜索框输入、resize 后重排、表单校验 |
| 节流 | N 毫秒内只执行一次,多余的扔掉 | 自动售货机 | scroll、mousemove、按钮防止重复点击 |
最小实现:
js
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
function throttle(fn, delay) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= delay) {
last = now;
fn.apply(this, args);
}
};
}陷阱:很多面试者忘了 apply(this, args),导致 this 丢失或参数没传过去。
4. 回流(Reflow)和重绘(Repaint)的区别?怎么避免?
答:
- 回流:修改了几何属性(width/height/position/display),浏览器要重新计算布局,最贵。
- 重绘:仅修改外观属性(color/background/visibility),浏览器只重新画,不算布局。
- 回流一定伴随重绘;重绘不一定有回流。
触发回流的常见操作:
- 修改 width / height / margin / padding / position / display 等
- 读取 offsetWidth / offsetHeight / scrollTop 等(强制同步布局)
- window resize、字体加载完成
避免:
- 批量修改:
element.style.cssText = '...'或加/换 class - 脱离文档流后修改:
display: none→ 改完 →display: block - DocumentFragment 批量插入
- 动画用
transform/opacity:走合成层,不触发回流和重绘 - 避免在循环里读取 offsetXxx
5. 一张图片怎么做现代化的优化?
答:从"格式 / 尺寸 / 加载策略 / 占位"四方面下手。
html
<picture>
<!-- 1. 格式:AVIF > WebP > JPG,按浏览器能力降级 -->
<source srcset="hero.avif" type="image/avif" />
<source srcset="hero.webp" type="image/webp" />
<!-- 2. 尺寸:根据屏幕 DPR / 视口宽度自适应 -->
<img
src="hero.jpg"
srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1600.jpg 1600w"
sizes="(max-width: 600px) 400px, 800px"
width="800"
height="450"
loading="lazy"
fetchpriority="low"
alt="..."
/>
</picture>要点:
- WebP/AVIF 比 JPG/PNG 小 30%~50%
- width / height 必须写,避免 CLS
- 首屏图用
fetchpriority="high";首屏外图用loading="lazy" - 配合LQIP(低质量占位图)/ blurhash,加载时不空白
6. 什么是虚拟列表?为什么需要它?怎么实现?
答:
问题:列表 1 万条 → 1 万个 DOM 节点 → 内存爆炸 + 渲染卡顿。
虚拟列表:
- 用一个高度等于
总条数 × 单条高度的占位容器撑出滚动条。 - 监听 scroll,根据
scrollTop计算"当前可视区在哪几条"。 - 只渲染可视区 + 上下缓冲区的 ~30 个 DOM。
- 用
transform: translateY(...)把这堆 DOM 定位到正确位置。
简化代码:
js
container.addEventListener('scroll', () => {
const start = Math.floor(container.scrollTop / itemHeight);
const end = start + visibleCount + buffer;
renderItems(data.slice(start, end), start * itemHeight);
});进阶:动态高度列表用"测量后缓存"或者用 IntersectionObserver。
7. preload / prefetch / dns-prefetch / preconnect 区别?
答:
| 标签 | 作用 | 适用 |
|---|---|---|
dns-prefetch | 提前 DNS 解析(最便宜) | 即将用到的第三方域名 |
preconnect | DNS + TCP + TLS 全做完 | 关键的第三方域名(如自家 API) |
preload | 当前页面马上要用的关键资源 | 首屏字体、关键 CSS/JS |
prefetch | 下一个页面可能用到的资源 | 路由级预取,浏览器空闲时下载 |
modulepreload | 预加载 ES 模块 + 解析 | 现代项目的 JS 模块 |
html
<link rel="dns-prefetch" href="//cdn.example.com" />
<link rel="preconnect" href="https://api.example.com" crossorigin />
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin />
<link rel="prefetch" href="/next-page-bundle.js" />坑:
preload必须写as,否则浏览器不知道优先级,可能加载两次。- 跨域字体的
preload必须加crossorigin,否则浏览器会重新请求。
8. 什么是 Tree Shaking?前提是什么?为什么有时不生效?
答:
Tree Shaking = 摇掉死代码。打包时静态分析 import/export,把没用到的导出删掉。
前提:
- 必须用 ES Module(
import/export),CommonJS(require)摇不动——它的导出是动态的。 - 没有副作用:
package.json里要标"sideEffects": false,告诉打包工具"删了我也没事"。 - production 模式:开发模式不做。
常见不生效原因:
- 用了
import * as utils from 'lib'(全部引入) - 库的 ESM 入口配错(
module字段没指向 ESM) - Babel 把
import转成了require(设"modules": false) - 副作用代码:模块顶层有
console.log、window.xx = ...,打包工具不敢删
9. 怎么测量真实用户的性能(RUM)?
答:
实验室数据 vs 现场数据:
- 实验室(Lab):Lighthouse、WebPageTest——可控、可重复,但不一定反映真实用户。
- 现场(Field/RUM, Real User Monitoring):用户真实上报的数据——千差万别,但更真实。
RUM 实施:
js
import { onLCP, onINP, onCLS, onTTFB, onFCP } from 'web-vitals';
function send(metric) {
navigator.sendBeacon('/api/perf', JSON.stringify(metric));
}
onLCP(send);
onINP(send);
onCLS(send);
onTTFB(send);
onFCP(send);为什么用 sendBeacon:它能在页面 unload/pagehide 时也保证发出去,且不阻塞主线程。
进阶:上报时带上用户分组(设备、网络、国家、版本号),才能找出"哪批用户慢"。
10. 如何优化首屏加载(首屏 LCP)?请给一套完整方案。
答:从"网络 → 资源 → 渲染"三层入手。
网络层:
- CDN + HTTP/2/3,开启 brotli 压缩
- 关键 API/字体
preconnect - SSR 或预渲染(HTML 首屏直出,避免空白等待 JS)
资源层:
- 关键 CSS 内联到
<head>,非关键 CSS 异步加载(media="print" onload="this.media='all'") - JS 用
defer/async;首屏 JS 用<script type="module" src="..." crossorigin> - 路由级 + 组件级 Code Splitting,首屏 chunk < 100KB(gzip 后)
- 图片:首屏 LCP 图加
fetchpriority="high",其他用loading="lazy",全部 WebP/AVIF
渲染层:
- 避免首屏触发回流(图片写宽高,骨架屏占位)
- 字体
font-display: swap/optional,避免 FOIT - 第三方脚本(统计、广告)延后到首屏后
监控:
- web-vitals 上报 LCP,按设备/网络分组观察 P75
11. (进阶)如何优化 INP?长任务怎么拆?
答:
INP 慢的根因:交互回调里有"长任务"(>50ms 的 JS 任务),阻塞了下一帧渲染。
优化手段:
- 拆长任务:用
setTimeout(fn, 0)、MessageChannel、scheduler.postTask()把大任务切片让出主线程。 scheduler.yield()(最新 API,2024):在长任务中主动让出,等待浏览器处理输入后继续。- Web Worker:CPU 密集任务(解析、压缩、加密)丢到 Worker。
- 避免在 click 回调里同步 setState 大列表:先
requestAnimationFrame让浏览器先画反馈(按钮按下态),再做重活。 - 取消防抖的 trailing:搜索框 input 改用节流 + 取消旧请求,避免一长串积压计算。
js
// 让出主线程的现代写法
async function processLargeArray(items) {
for (let i = 0; i < items.length; i++) {
doWork(items[i]);
if (i % 100 === 0) {
await new Promise(r => setTimeout(r)); // 让出
}
}
}12. (进阶)CDN 的工作原理?什么是回源?怎么解决"刚发布资源还没同步到边缘节点"?
答:
原理:
用户 → DNS 解析 → 离用户最近的 CDN 边缘节点
│
├── 命中缓存:直接返回
│
└── 未命中:回源(去源站拉取)→ 缓存到本节点 → 返回回源:边缘节点没有这个资源,去源站(你的服务器)拉一份。
新发布资源同步问题:
- 预热(Push):发布后主动调 CDN API,把新资源推到所有边缘节点,用户首次访问就命中。
- 刷新(Purge):把旧版本从所有节点驱逐。
- 配合 hash 文件名:
app.a3f9c.js内容变了文件名也变,根本不存在"旧版本要刷新"问题——这才是最佳实践。
部署顺序(重点):先发静态资源(CDN),再发 HTML(源站)。否则用户拿到新 HTML 引用的新文件,CDN 还没同步 → 404。
一句话总结
性能优化的面试套路:先讲指标(Core Web Vitals)→ 再分层(加载/渲染/构建)→ 给具体方案 → 落地监控(web-vitals + RUM),最后能聊到"如何拆长任务保 INP"就显得有深度。