Skip to content

第 22 章 · 性能优化 · 高频面试题

性能是大厂前端面试的"必考压轴题"。以下题目按"理解 → 实战 → 进阶"递进排列。


1. 请说说 Core Web Vitals 三个指标是什么?分别衡量什么?

:Google 提出的核心网页性能指标,覆盖"加载、交互、视觉稳定性"三个维度。

指标全称衡量优秀阈值
LCPLargest Contentful Paint最大内容绘制时间,衡量"加载快不快"≤ 2.5s
INPInteraction to Next Paint用户交互到下一帧绘制,衡量"操作跟不跟手"(2024 年起取代了 FID)≤ 200ms
CLSCumulative 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、字体加载完成

避免

  1. 批量修改element.style.cssText = '...' 或加/换 class
  2. 脱离文档流后修改display: none → 改完 → display: block
  3. DocumentFragment 批量插入
  4. 动画用 transform / opacity:走合成层,不触发回流和重绘
  5. 避免在循环里读取 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 解析(最便宜)即将用到的第三方域名
preconnectDNS + 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,把没用到的导出删掉。

前提

  1. 必须用 ES Moduleimport/export),CommonJS(require)摇不动——它的导出是动态的。
  2. 没有副作用package.json 里要标 "sideEffects": false,告诉打包工具"删了我也没事"。
  3. production 模式:开发模式不做。

常见不生效原因

  • 用了 import * as utils from 'lib'(全部引入)
  • 库的 ESM 入口配错(module 字段没指向 ESM)
  • Babel 把 import 转成了 require(设 "modules": false
  • 副作用代码:模块顶层有 console.logwindow.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)?请给一套完整方案。

:从"网络 → 资源 → 渲染"三层入手。

网络层

  1. CDN + HTTP/2/3,开启 brotli 压缩
  2. 关键 API/字体 preconnect
  3. SSR 或预渲染(HTML 首屏直出,避免空白等待 JS)

资源层

  1. 关键 CSS 内联到 <head>,非关键 CSS 异步加载(media="print" onload="this.media='all'"
  2. JS 用 defer/async;首屏 JS 用 <script type="module" src="..." crossorigin>
  3. 路由级 + 组件级 Code Splitting,首屏 chunk < 100KB(gzip 后)
  4. 图片:首屏 LCP 图加 fetchpriority="high",其他用 loading="lazy",全部 WebP/AVIF

渲染层

  1. 避免首屏触发回流(图片写宽高,骨架屏占位)
  2. 字体 font-display: swap/optional,避免 FOIT
  3. 第三方脚本(统计、广告)延后到首屏后

监控

  1. web-vitals 上报 LCP,按设备/网络分组观察 P75

11. (进阶)如何优化 INP?长任务怎么拆?

INP 慢的根因:交互回调里有"长任务"(>50ms 的 JS 任务),阻塞了下一帧渲染。

优化手段

  1. 拆长任务:用 setTimeout(fn, 0)MessageChannelscheduler.postTask() 把大任务切片让出主线程。
  2. scheduler.yield()(最新 API,2024):在长任务中主动让出,等待浏览器处理输入后继续。
  3. Web Worker:CPU 密集任务(解析、压缩、加密)丢到 Worker。
  4. 避免在 click 回调里同步 setState 大列表:先 requestAnimationFrame 让浏览器先画反馈(按钮按下态),再做重活。
  5. 取消防抖的 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"就显得有深度。