Skip to content

前端性能优化

为什么性能很重要

页面性能直接影响用户体验和商业指标。Google 研究表明:页面加载时间从 1s 增加到 3s,跳出率增加 32%;增加到 5s,跳出率增加 90%。

性能影响链路:

加载慢 / 交互卡顿

用户体验差

跳出率 ↑  转化率 ↓  SEO 排名 ↓

收入下降

本文将从指标定义 → 采集方式 → 优化手段 → 监控体系四个维度系统梳理前端性能优化的完整知识体系。


Core Web Vitals

Core Web Vitals 是 Google 提出的衡量真实用户体验的核心指标集,覆盖加载、交互、视觉稳定性三个维度:

Core Web Vitals(2024+)

┌──────────────┬───────────────┬──────────────┐
│    加载体验   │   交互响应     │  视觉稳定性   │
│              │               │              │
│     LCP      │     INP       │     CLS      │
│  Largest     │  Interaction  │  Cumulative  │
│  Contentful  │  to Next      │  Layout      │
│  Paint       │  Paint        │  Shift       │
└──────────────┴───────────────┴──────────────┘

注:FID(First Input Delay)已于 2024 年 3 月被 INP 正式替代

LCP(Largest Contentful Paint)

定义:视口内最大可见内容元素完成渲染的时间点。LCP 候选元素包括 <img><video> 封面、带背景图的块级元素、包含文本节点的块级元素。

阈值标准:

Good           Needs Improvement         Poor
  ≤ 2.5s     ────────────────────     > 4.0s
 ├──────────┼─────────────────────┼──────────┤
 0s         2.5s                  4.0s

LCP 时间线:

navigationStart

  ├──── TTFB ────┤
  │              FCP(首次内容绘制)
  │                ↓
  │                ├──── 图片/字体加载 ────┤
  │                │                      LCP 触发
  │                │                       ↓
  ├────────────────┼───────────────────────┤
  0s              1s                     2.5s (Good)

LCP 优化手段

1. 优化服务端响应时间(降低 TTFB)
   - 使用 CDN
   - 服务端缓存 / 边缘计算
   - 避免多次重定向

2. 消除渲染阻塞资源
   - 关键 CSS 内联
   - 非关键 JS defer / async
   - 非关键 CSS 异步加载

3. 优化 LCP 资源加载
   - <link rel="preload"> 预加载 LCP 图片
   - 使用 fetchpriority="high" 提升优先级
   - 避免懒加载首屏 LCP 图片

4. 优化客户端渲染
   - 减少 JS bundle 大小
   - SSR / SSG 替代纯 CSR
   - 避免长任务阻塞主线程

INP(Interaction to Next Paint)

定义:衡量页面整个生命周期内所有交互(点击、键盘输入、触摸)的响应延迟,取所有交互延迟中较高的值(近似 P98)作为最终分数。INP 替代了 FID,因为 FID 只衡量首次交互的输入延迟,不包含事件处理和渲染时间。

阈值标准:

Good           Needs Improvement         Poor
  ≤ 200ms    ────────────────────     > 500ms
 ├──────────┼─────────────────────┼──────────┤
 0ms        200ms                 500ms

单次交互延迟的构成:

用户点击

  ├── Input Delay ──────┤── Processing Time ──┤── Presentation Delay ──┤
  │  (输入延迟:         │  (事件处理时间:      │  (渲染延迟:           │
  │   主线程被长任务占用   │   事件回调执行耗时)   │   style/layout/paint   │
  │   导致事件排队等待)   │                      │   到下一帧上屏)        │
  ├───────────────────────────────────────────────────────────────────┤
  │                     INP 交互延迟                                   │
  ↓                                                                   ↓
  交互发生                                                      下一帧渲染完成

FID vs INP:
┌─────────────┬────────────────────────┬────────────────────────────┐
│             │ FID(已废弃)           │ INP(当前标准)              │
├─────────────┼────────────────────────┼────────────────────────────┤
│ 衡量范围     │ 仅首次交互              │ 所有交互中取最差值           │
│ 衡量阶段     │ 仅 Input Delay         │ Input + Processing + Render│
│ 代表性       │ 低(用户可能未交互)     │ 高(反映整体交互体验)       │
└─────────────┴────────────────────────┴────────────────────────────┘

INP 优化手段

1. 拆分长任务(Long Task > 50ms)
   - 使用 scheduler.yield() 主动让出主线程
   - requestIdleCallback 处理低优先级工作
   - 使用 Web Worker 卸载计算密集型任务

2. 减少事件处理时间
   - 避免在事件回调中做复杂计算
   - 使用防抖(debounce)/ 节流(throttle)
   - 将非关键更新推迟到 requestAnimationFrame

3. 减少渲染延迟
   - 减少 DOM 节点数量
   - 使用 CSS contain 限制渲染范围
   - 使用 content-visibility: auto 跳过屏外渲染

CLS(Cumulative Layout Shift)

定义:页面整个生命周期内发生的所有意外布局偏移的累积分数。使用会话窗口(Session Window) 算法:将布局偏移按最大 5 秒窗口(窗口内偏移间隔不超过 1 秒)分组,取最大窗口分数。

阈值标准:

Good           Needs Improvement         Poor
  ≤ 0.1      ────────────────────     > 0.25
 ├──────────┼─────────────────────┼──────────┤
 0           0.1                   0.25

布局偏移分数 = 影响比例(Impact Fraction)× 距离比例(Distance Fraction)

示例:
┌──────────────────────────────────┐  视口高度 = 800px
│  ┌──────────────┐                │
│  │   元素 A      │ ← 原始位置     │  元素占视口 50%
│  │   (400px高)   │                │
│  └──────────────┘                │
│         ↓ 向下偏移 100px          │  偏移距离 / 视口 = 100/800 = 12.5%
│  ┌──────────────┐                │
│  │   元素 A      │ ← 新位置       │  影响区域占视口 = (400+100)/800 = 62.5%
│  │              │                │
│  └──────────────┘                │
└──────────────────────────────────┘

布局偏移分数 = 0.625 × 0.125 = 0.078

CLS 优化手段

1. 为媒体元素预留空间
   - <img> / <video> 设置明确的 width 和 height
   - 使用 CSS aspect-ratio 占位
   - 避免无尺寸的广告容器

2. 避免动态注入内容
   - 不在已有内容上方插入 DOM
   - 使用 CSS transform 做动画(不触发布局偏移)
   - 预留 skeleton / placeholder

3. 字体加载优化
   - font-display: swap / optional
   - <link rel="preload"> 预加载字体
   - 使用 size-adjust 匹配 fallback 字体尺寸

4. 避免非合成动画
   - 不使用 top/left/width/height 做动画
   - 使用 transform + opacity

Performance API

浏览器提供了一整套 Performance API,用于精确测量页面加载和运行时性能。

performance.now()

返回从页面导航开始到当前的高精度时间戳(微秒级精度,DOMHighResTimeStamp):

js
const start = performance.now();

for (let i = 0; i < 1000000; i++) {
  Math.sqrt(i);
}

const duration = performance.now() - start;
console.log(`耗时: ${duration.toFixed(2)}ms`);
performance.now() vs Date.now():

┌──────────────────┬──────────────────────┬──────────────────────┐
│                  │ performance.now()    │ Date.now()           │
├──────────────────┼──────────────────────┼──────────────────────┤
│ 精度             │ 微秒(5μs,受安全限制)│ 毫秒                  │
│ 起点             │ navigationStart      │ Unix Epoch           │
│ 单调递增          │ ✅(不受系统时钟影响) │ ❌(可能回退)         │
│ 用途             │ 性能测量             │ 日期时间              │
└──────────────────┴──────────────────────┴──────────────────────┘

performance.mark() / performance.measure()

创建自定义性能标记和测量:

js
performance.mark('render-start');

renderApp();

performance.mark('render-end');

performance.measure('render-duration', 'render-start', 'render-end');

const [measure] = performance.getEntriesByName('render-duration');
console.log(`渲染耗时: ${measure.duration.toFixed(2)}ms`);
js
performance.mark('api-start');

const data = await fetch('/api/data').then(r => r.json());

performance.mark('api-end');
performance.measure('api-call', 'api-start', 'api-end');

const entries = performance.getEntriesByType('measure');
entries.forEach(entry => {
  console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
});

PerformanceObserver

异步监听性能条目,避免轮询开销:

js
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(`${entry.entryType}: ${entry.name} - ${entry.duration}ms`);
  }
});

observer.observe({ entryTypes: ['measure', 'resource', 'longtask'] });
PerformanceObserver 支持的 entryTypes:

entryType          说明                      典型用途
──────────────────────────────────────────────────────────
navigation         页面导航计时               白屏/首屏时间
resource           资源加载计时               慢资源定位
paint              FP / FCP                  首次绘制监控
largest-contentful-paint  LCP               最大内容绘制
first-input        FID                       首次输入延迟
layout-shift       CLS                       布局偏移监控
longtask           长任务(>50ms)            卡顿分析
event              交互事件计时               INP 计算
mark               自定义标记                 业务埋点
measure            自定义测量                 业务耗时

Navigation Timing API 提供页面导航全过程的精确计时:

Navigation Timing 时间线(Level 2):

←───────────────────── 页面加载全过程 ─────────────────────→

 startTime                                          loadEventEnd
    |                                                     |
    ├─ redirectStart                                      |
    ├─ redirectEnd                                        |
    ├─ fetchStart                                         |
    ├─ domainLookupStart                                  |
    ├─ domainLookupEnd                                    |
    ├─ connectStart                                       |
    ├─ secureConnectionStart (HTTPS)                      |
    ├─ connectEnd                                         |
    ├─ requestStart                                       |
    ├─ responseStart (TTFB)                               |
    ├─ responseEnd                                        |
    ├─ domInteractive                                     |
    ├─ domContentLoadedEventStart                         |
    ├─ domContentLoadedEventEnd                           |
    ├─ domComplete                                        |
    ├─ loadEventStart                                     |
    └─ loadEventEnd                                       |

各阶段耗时计算:
──────────────────────────────────────────────────────────
DNS 查询     = domainLookupEnd - domainLookupStart
TCP 连接     = connectEnd - connectStart
TLS 握手     = connectEnd - secureConnectionStart
TTFB        = responseStart - requestStart
内容下载     = responseEnd - responseStart
DOM 解析     = domInteractive - responseEnd
资源加载     = loadEventStart - domContentLoadedEventEnd
完整加载     = loadEventEnd - startTime
js
const observer = new PerformanceObserver((list) => {
  const [nav] = list.getEntries();

  console.log(`DNS: ${nav.domainLookupEnd - nav.domainLookupStart}ms`);
  console.log(`TCP: ${nav.connectEnd - nav.connectStart}ms`);
  console.log(`TTFB: ${nav.responseStart - nav.requestStart}ms`);
  console.log(`DOM 解析: ${nav.domInteractive - nav.responseEnd}ms`);
  console.log(`完整加载: ${nav.loadEventEnd - nav.startTime}ms`);
});

observer.observe({ type: 'navigation', buffered: true });

Resource Timing

监控每个资源(JS/CSS/图片/字体/API 请求)的加载耗时:

js
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 1000) {
      console.warn(`慢资源: ${entry.name} 耗时 ${entry.duration.toFixed(0)}ms`);
    }
  }
});

observer.observe({ type: 'resource', buffered: true });
js
function getSlowResources(threshold = 1000) {
  return performance.getEntriesByType('resource')
    .filter(entry => entry.duration > threshold)
    .map(entry => ({
      name: entry.name,
      type: entry.initiatorType,
      duration: Math.round(entry.duration),
      size: entry.transferSize,
    }))
    .sort((a, b) => b.duration - a.duration);
}

性能指标采集

使用 PerformanceObserver 原生采集

js
function observeFP() {
  new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.name === 'first-paint') {
        console.log(`FP: ${entry.startTime.toFixed(0)}ms`);
      }
      if (entry.name === 'first-contentful-paint') {
        console.log(`FCP: ${entry.startTime.toFixed(0)}ms`);
      }
    }
  }).observe({ type: 'paint', buffered: true });
}

function observeLCP() {
  let lcpValue = 0;
  new PerformanceObserver((list) => {
    const entries = list.getEntries();
    const lastEntry = entries[entries.length - 1];
    lcpValue = lastEntry.startTime;
  }).observe({ type: 'largest-contentful-paint', buffered: true });

  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') {
      console.log(`LCP: ${lcpValue.toFixed(0)}ms`);
    }
  });
}

function observeCLS() {
  let clsValue = 0;
  let sessionValue = 0;
  let sessionEntries = [];

  new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (!entry.hadRecentInput) {
        const firstSessionEntry = sessionEntries[0];
        const lastSessionEntry = sessionEntries[sessionEntries.length - 1];

        if (
          sessionValue &&
          entry.startTime - lastSessionEntry.startTime < 1000 &&
          entry.startTime - firstSessionEntry.startTime < 5000
        ) {
          sessionValue += entry.value;
          sessionEntries.push(entry);
        } else {
          sessionValue = entry.value;
          sessionEntries = [entry];
        }

        if (sessionValue > clsValue) {
          clsValue = sessionValue;
        }
      }
    }
  }).observe({ type: 'layout-shift', buffered: true });
}

function observeINP() {
  const interactionMap = new Map();

  new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.interactionId) {
        const existing = interactionMap.get(entry.interactionId);
        if (!existing || entry.duration > existing.duration) {
          interactionMap.set(entry.interactionId, entry);
        }
      }
    }
  }).observe({ type: 'event', buffered: true, durationThreshold: 16 });
}

function observeLongTasks() {
  new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      console.log(`长任务: ${entry.duration.toFixed(0)}ms`, entry.attribution);
    }
  }).observe({ type: 'longtask', buffered: true });
}

使用 web-vitals 库采集

web-vitals 是 Google 官方维护的轻量级库(~1.5KB gzip),封装了所有 Core Web Vitals 的采集逻辑:

js
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';

function sendToAnalytics(metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    delta: metric.delta,
    id: metric.id,
    navigationType: metric.navigationType,
  });

  if (navigator.sendBeacon) {
    navigator.sendBeacon('/analytics', body);
  } else {
    fetch('/analytics', { body, method: 'POST', keepalive: true });
  }
}

onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);
性能指标采集全景图:

指标      采集方式                         触发时机
────────────────────────────────────────────────────────
FP       PerformanceObserver(paint)       首次像素绘制
FCP      PerformanceObserver(paint)       首次内容绘制
LCP      PerformanceObserver(lcp)         最大内容完成渲染
FID      PerformanceObserver(first-input) 首次交互(已废弃)
INP      PerformanceObserver(event)       页面隐藏时取最差交互
CLS      PerformanceObserver(layout-shift)页面隐藏时取最大会话窗口
TTI      Long Tasks + Network idle        无直接 API,需计算
TBT      Long Tasks 累积                  sum(taskDuration - 50ms)

TTI(Time to Interactive):
  从 FCP 之后,找到一个 5 秒窗口内没有长任务且网络请求 ≤ 2 个的时间点

TBT(Total Blocking Time):
  FCP 到 TTI 之间所有长任务超出 50ms 的部分之和

  示例:
  Task 1: 80ms  → 阻塞 30ms (80 - 50)
  Task 2: 40ms  → 阻塞 0ms  (< 50ms)
  Task 3: 120ms → 阻塞 70ms (120 - 50)
  TBT = 30 + 0 + 70 = 100ms

加载性能优化

代码分割(Code Splitting)

将单一大 bundle 拆分为多个小块,按需加载:

js
// React.lazy + Suspense
import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}
js
// Dynamic import(框架无关)
button.addEventListener('click', async () => {
  const { openEditor } = await import('./heavy-editor.js');
  openEditor();
});
代码分割策略:

1. 路由级分割    → 每个路由页面独立 chunk
2. 组件级分割    → 弹窗、抽屉、重型组件懒加载
3. 第三方库分割   → vendor chunk 独立(长期缓存)
4. 按条件分割    → 仅管理员加载 admin 模块

分割前 vs 分割后:

分割前:
  main.js (800KB) ──────────────────────────→ 全量加载

分割后:
  main.js (200KB)   ──────→ 首屏必须
  dashboard.js (150KB)      按需加载(路由切换时)
  settings.js (100KB)       按需加载
  vendor.js (250KB)  ──────→ 长期缓存
  editor.js (100KB)         按需加载(交互触发)

Tree-shaking

构建工具通过静态分析 ES Module 的 import/export,移除未使用的代码:

js
// math.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export function multiply(a, b) { return a * b; }
export function divide(a, b) { return a / b; }

// app.js — 只用了 add
import { add } from './math.js';
console.log(add(1, 2));

// 构建后:subtract / multiply / divide 被移除
Tree-shaking 生效条件:

✅ 使用 ES Module(import / export)
✅ package.json 设置 "sideEffects": false
✅ 避免副作用代码(模块顶层执行的 console.log / DOM 操作)
✅ 使用具名导入 import { x } from 'lib'

❌ CommonJS(require / module.exports)无法 Tree-shake
❌ import * as lib → 部分打包器无法优化
❌ 有副作用的模块不会被移除

压缩(Compression)

压缩格式对比:

┌──────────┬────────────┬─────────────┬──────────────┐
│ 格式      │ 压缩率      │ 压缩速度     │ 浏览器支持    │
├──────────┼────────────┼─────────────┼──────────────┤
│ Gzip     │ ~60-70%    │ 快           │ 所有现代浏览器 │
│ Brotli   │ ~70-80%    │ 较慢(压缩)  │ 所有现代浏览器 │
│          │            │ 快(解压)    │ (仅 HTTPS)  │
└──────────┴────────────┴─────────────┴──────────────┘

实际体积对比(以 React + 业务代码为例):

           原始        Gzip        Brotli
main.js   800KB  →   240KB  →    200KB
style.css 200KB  →    40KB  →     32KB

Nginx 配置 Brotli:
  brotli on;
  brotli_types text/plain text/css application/javascript application/json;
  brotli_comp_level 6;

图片优化

图片格式选择:

┌────────┬───────────┬──────────┬──────────────┬───────────────┐
│ 格式    │ 压缩方式   │ 透明通道  │ 动画支持      │ 适用场景       │
├────────┼───────────┼──────────┼──────────────┼───────────────┤
│ JPEG   │ 有损       │ ❌       │ ❌           │ 照片          │
│ PNG    │ 无损       │ ✅       │ ❌           │ 图标/截图      │
│ WebP   │ 有损/无损  │ ✅       │ ✅           │ 通用替代方案    │
│ AVIF   │ 有损/无损  │ ✅       │ ✅           │ 更高压缩率      │
│ SVG    │ 矢量       │ ✅       │ ✅           │ 图标/插画      │
└────────┴───────────┴──────────┴──────────────┴───────────────┘

WebP 比 JPEG 小 25-35%,AVIF 比 JPEG 小 50%+
html
<!-- srcset 响应式图片 -->
<img
  src="photo-800.jpg"
  srcset="
    photo-400.jpg 400w,
    photo-800.jpg 800w,
    photo-1200.jpg 1200w
  "
  sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
  alt="photo"
/>

<!-- <picture> 格式降级 -->
<picture>
  <source srcset="photo.avif" type="image/avif" />
  <source srcset="photo.webp" type="image/webp" />
  <img src="photo.jpg" alt="photo" />
</picture>

<!-- 原生懒加载 -->
<img src="photo.jpg" loading="lazy" alt="photo" />

字体优化

css
/* font-display 策略 */
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2') format('woff2');
  font-display: swap;
  /* swap: 立即用 fallback 字体显示,字体加载完后替换(可能 CLS) */
  /* optional: 仅在字体已缓存时使用,否则用 fallback(最佳 CLS) */
  /* fallback: 短暂隐藏(~100ms),然后用 fallback,字体加载完可替换 */
}
html
<!-- 预加载关键字体 -->
<link rel="preload" href="/fonts/custom.woff2" as="font" type="font/woff2" crossorigin />
font-display 对比:

策略       不可见期   后备字体期   字体替换    CLS 风险
─────────────────────────────────────────────────────
auto      由浏览器决定                        不确定
block     ≤3s       ≤3s         ✅ 替换     中
swap      0         无限        ✅ 替换     高
fallback  ~100ms    ~3s         可能替换    低
optional  ~100ms    0           ❌ 不替换   无

预加载策略

html
<!-- dns-prefetch: 提前解析 DNS -->
<link rel="dns-prefetch" href="https://cdn.example.com" />

<!-- preconnect: 提前建立连接(DNS + TCP + TLS) -->
<link rel="preconnect" href="https://api.example.com" />

<!-- preload: 提前加载当前页面必需的资源(高优先级) -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/hero.webp" as="image" />

<!-- prefetch: 预加载下一个页面可能需要的资源(低优先级,空闲时加载) -->
<link rel="prefetch" href="/next-page.js" />
预加载策略对比:

┌───────────────┬──────────────┬───────────┬────────────┬─────────────┐
│ 策略           │ 作用          │ 优先级     │ 使用时机    │ 典型场景     │
├───────────────┼──────────────┼───────────┼────────────┼─────────────┤
│ dns-prefetch  │ DNS 解析      │ 低        │ 当前页面    │ 第三方域名   │
│ preconnect    │ DNS+TCP+TLS  │ 中        │ 当前页面    │ 关键 API 域  │
│ preload       │ 下载资源      │ 高        │ 当前页面    │ 字体/LCP 图  │
│ prefetch      │ 下载资源      │ 最低      │ 未来页面    │ 下一页 JS    │
└───────────────┴──────────────┴───────────┴────────────┴─────────────┘

注意事项:
- preconnect 不宜超过 4-6 个域(过多反而浪费连接)
- preload 的资源如果 3 秒内未使用,Chrome 会在控制台发出警告
- prefetch 在移动端弱网环境下可能浪费用户流量
- preload 不会执行资源(JS 不会运行),只是提前下载

运行时性能优化

长任务拆分

浏览器主线程执行超过 50ms 的任务被定义为长任务(Long Task),长任务会阻塞用户交互、导致页面卡顿:

主线程时间线(未拆分):

         50ms 阈值
           |
  ┌────────┼──────────────────────────┐
  │ Long Task (200ms)                  │
  │████████████████████████████████████│
  └────────────────────────────────────┘
                                        用户点击被延迟

                                        响应滞后 200ms

主线程时间线(拆分后):

  ┌──────┐  ┌──────┐  ┌──────┐  ┌──────┐
  │ 50ms │  │ 50ms │  │ 50ms │  │ 50ms │
  │██████│  │██████│  │██████│  │██████│
  └──────┘  └──────┘  └──────┘  └──────┘
          ↑          ↑          ↑
       浏览器可在间隙处理用户交互、渲染帧
js
// scheduler.yield()(Chrome 129+,最推荐的方式)
async function processLargeList(items) {
  for (let i = 0; i < items.length; i++) {
    processItem(items[i]);

    if (i % 100 === 0) {
      await scheduler.yield();
    }
  }
}

// requestIdleCallback — 在浏览器空闲时执行低优先级工作
function processInIdleTime(tasks) {
  function workLoop(deadline) {
    while (deadline.timeRemaining() > 0 && tasks.length > 0) {
      const task = tasks.shift();
      task();
    }
    if (tasks.length > 0) {
      requestIdleCallback(workLoop);
    }
  }
  requestIdleCallback(workLoop);
}

// 手动 yield — 兼容性最好的方案
function yieldToMain() {
  return new Promise(resolve => setTimeout(resolve, 0));
}

async function processChunked(items, chunkSize = 50) {
  for (let i = 0; i < items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize);
    chunk.forEach(processItem);
    await yieldToMain();
  }
}

虚拟列表(Virtual Scrolling)

当渲染超大列表(数千~数万条)时,只渲染视口内可见的元素:

传统渲染 vs 虚拟列表:

传统渲染(10000 条):                虚拟列表(10000 条):
┌──────────────────┐                ┌──────────────────┐
│ Item 1           │ ← 已渲染       │                  │ ← 空白占位
│ Item 2           │ ← 已渲染       │                  │
│ Item 3           │ ← 已渲染       ├──────────────────┤
│ ...              │ ← 全部渲染     │ Item 501         │ ← 只渲染
│ Item 10000       │ ← 已渲染       │ Item 502         │   视口内
│                  │                │ Item 503         │   约 20 条
│                  │                │ ...              │
│ DOM 节点:10000  │                │ Item 520         │
│ 初始渲染:~2s    │                ├──────────────────┤
│ 内存:很高       │                │                  │ ← 空白占位
└──────────────────┘                │ DOM 节点:~20    │
                                    │ 初始渲染:~10ms  │
                                    │ 内存:很低       │
                                    └──────────────────┘
js
// 核心实现思路
function VirtualList({ items, itemHeight, containerHeight }) {
  const [scrollTop, setScrollTop] = useState(0);

  const startIndex = Math.floor(scrollTop / itemHeight);
  const endIndex = Math.min(
    startIndex + Math.ceil(containerHeight / itemHeight) + 1,
    items.length
  );
  const visibleItems = items.slice(startIndex, endIndex);
  const totalHeight = items.length * itemHeight;
  const offsetY = startIndex * itemHeight;

  return (
    <div
      style={{ height: containerHeight, overflow: 'auto' }}
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        <div style={{ transform: `translateY(${offsetY}px)` }}>
          {visibleItems.map((item, i) => (
            <div key={startIndex + i} style={{ height: itemHeight }}>
              {item.content}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

防抖与节流

js
// 防抖(Debounce):事件停止触发后才执行
function debounce(fn, delay) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

const handleSearch = debounce((query) => {
  fetch(`/api/search?q=${query}`);
}, 300);

// 节流(Throttle):固定时间间隔内最多执行一次
function throttle(fn, interval) {
  let lastTime = 0;
  return function (...args) {
    const now = Date.now();
    if (now - lastTime >= interval) {
      lastTime = now;
      fn.apply(this, args);
    }
  };
}

const handleScroll = throttle(() => {
  console.log('scroll position:', window.scrollY);
}, 100);
防抖 vs 节流:

防抖(Debounce):
事件触发  ×  ×  ×  ×  ×  ×  ×  ×          (密集触发)
执行                                 ✓     (停止后才执行 1 次)
适用:搜索框输入、窗口 resize 结束后处理

节流(Throttle):
事件触发  ×  ×  ×  ×  ×  ×  ×  ×          (密集触发)
执行      ✓        ✓        ✓        ✓     (固定间隔执行)
适用:滚动监听、mousemove、拖拽

Web Worker

将 CPU 密集型任务卸载到独立线程,不阻塞主线程:

js
// main.js
const worker = new Worker(new URL('./worker.js', import.meta.url));

worker.postMessage({ type: 'sort', data: hugeArray });

worker.onmessage = (e) => {
  const sortedData = e.data;
  renderList(sortedData);
};

// worker.js
self.onmessage = (e) => {
  if (e.data.type === 'sort') {
    const sorted = e.data.data.sort((a, b) => a - b);
    self.postMessage(sorted);
  }
};
Web Worker 使用限制:

✅ 可以使用:
   - 纯计算(排序、加密、图像处理)
   - fetch / XMLHttpRequest
   - setTimeout / setInterval
   - IndexedDB

❌ 不能使用:
   - DOM API(document / window)
   - 直接操作 UI
   - localStorage / sessionStorage

数据传递方式:
   - 结构化克隆(默认):深拷贝,大数据有序列化开销
   - Transferable Objects:零拷贝转移所有权(ArrayBuffer)
     worker.postMessage(buffer, [buffer])

requestAnimationFrame

将视觉更新对齐到浏览器的渲染帧(通常 60fps = 16.67ms/帧):

js
// 平滑动画
function animate(element, from, to, duration) {
  const startTime = performance.now();

  function frame(currentTime) {
    const elapsed = currentTime - startTime;
    const progress = Math.min(elapsed / duration, 1);

    const eased = 1 - Math.pow(1 - progress, 3);
    const current = from + (to - from) * eased;
    element.style.transform = `translateX(${current}px)`;

    if (progress < 1) {
      requestAnimationFrame(frame);
    }
  }

  requestAnimationFrame(frame);
}
setTimeout vs requestAnimationFrame:

setTimeout(fn, 16):
帧 ──┤────┤────┤────┤────┤────┤────┤──
回调    ×     ×   ×      ×  ×       ×
       ↑ 可能与帧不对齐,导致丢帧或抖动

requestAnimationFrame(fn):
帧 ──┤────┤────┤────┤────┤────┤────┤──
回调  ✓    ✓    ✓    ✓    ✓    ✓    ✓
     ↑ 每帧精确触发一次,与浏览器渲染同步

渲染性能优化

减少回流重绘(简要回顾)

详见 render-pipeline.md。核心要点:

渲染流水线三条路径:

路径 1(最重):JS → Style → Layout → Paint → Composite
  修改几何属性(width / height / margin / padding)

路径 2(中等):JS → Style → Paint → Composite
  修改视觉属性(color / background / visibility)

路径 3(最轻):JS → Style → Composite
  修改 transform / opacity(GPU 直接处理)

优化原则:尽量走路径 3,避免路径 1

CSS contain

contain 属性告诉浏览器某个元素的渲染边界,限制回流/重绘的影响范围:

css
.widget {
  contain: layout style paint;
}

.card {
  contain: content;
}
contain 属性值:

值           作用                                  影响
────────────────────────────────────────────────────────────────
layout      元素内部布局变化不影响外部              回流范围限制
style       计数器、引号等样式不会逃逸到外部        样式隔离
paint       元素内容不会超出边界渲染                重绘范围限制
size        元素的尺寸不依赖子元素                  布局计算优化
content     等同于 layout + style + paint          推荐使用
strict      等同于 layout + style + paint + size   最强限制

效果:
  无 contain:修改一个元素 → 可能导致整页回流
  有 contain:修改一个元素 → 回流/重绘限制在容器内

content-visibility: auto

让浏览器跳过屏幕外元素的渲染工作(Layout + Paint),滚动到可见区域时才渲染:

css
.article-section {
  content-visibility: auto;
  contain-intrinsic-size: auto 500px;
}
content-visibility: auto 的工作原理:

┌───────────────────────────────┐ ← 视口顶部
│  Section 1                    │ ← 可见:正常渲染
│  完整 Layout + Paint          │
├───────────────────────────────┤ ← 视口底部
│  Section 2                    │ ← 即将可见:可能预渲染
│  contain-intrinsic-size 占位  │
├───────────────────────────────┤
│  Section 3                    │ ← 不可见:跳过渲染
│  仅保留估算尺寸占位            │    (节省 Layout + Paint)
├───────────────────────────────┤
│  Section 4                    │ ← 不可见:跳过渲染
│  仅保留估算尺寸占位            │
└───────────────────────────────┘

性能收益:
  - 长页面首次渲染时间可减少 50%+
  - contain-intrinsic-size 用于提供估算高度,避免滚动条跳动

will-change

提前告知浏览器元素即将发生变化,让浏览器提前创建合成层并做优化准备:

css
.animated-element {
  will-change: transform, opacity;
}

.animated-element.done {
  will-change: auto;
}
will-change 使用原则:

✅ 正确用法:
   - 在动画即将开始前设置(如 hover 父元素时)
   - 动画结束后移除(恢复为 auto)
   - 只对确实需要优化的元素使用

❌ 错误用法:
   - * { will-change: transform; }     ← 对所有元素设置
   - 永远不移除                          ← 持续消耗 GPU 内存
   - 对静态元素设置                      ← 无意义的资源浪费

过度使用的后果:
   - 每个 will-change 元素创建独立合成层
   - 每个合成层消耗 GPU 显存
   - 可能导致"层爆炸"——GPU 内存不足,性能反而下降

性能监控体系

RUM vs 合成监控

┌─────────────────────────────────────────────────────────────────┐
│                     性能监控体系                                  │
├───────────────────────────┬─────────────────────────────────────┤
│   RUM(真实用户监控)       │   合成监控(Synthetic Monitoring)   │
│   Real User Monitoring    │   Lighthouse / WebPageTest          │
├───────────────────────────┼─────────────────────────────────────┤
│ 数据来源:真实用户设备      │ 数据来源:模拟环境(固定设备/网络)  │
│ 网络环境:多样(3G~5G~WiFi)│ 网络环境:预设条件                  │
│ 设备范围:真实用户设备分布   │ 设备范围:固定配置                  │
│ 数据量:大(百万级样本)    │ 数据量:小(每次测试一个样本)       │
│ 时效性:持续实时采集        │ 时效性:按需/定时运行               │
│ 指标:CWV + 自定义业务指标  │ 指标:Performance Score + 审计建议  │
│ 代表性:高(反映真实体验)   │ 代表性:低(不代表所有用户)        │
│ 回归检测:数据波动,需聚合   │ 回归检测:稳定可重复,适合 CI/CD   │
├───────────────────────────┼─────────────────────────────────────┤
│ 工具:web-vitals / Sentry  │ 工具:Lighthouse / WebPageTest      │
│       自建 SDK / 商业 RUM   │       Lighthouse CI / SpeedCurve    │
├───────────────────────────┴─────────────────────────────────────┤
│ 最佳实践:两者结合使用                                            │
│ - 合成监控:开发/CI 阶段发现性能回归                               │
│ - RUM:线上持续监控真实用户体验                                    │
└─────────────────────────────────────────────────────────────────┘

Lighthouse 评分体系

Lighthouse Performance 评分权重(v12):

指标        权重       阈值(Good)
─────────────────────────────────
FCP        10%       ≤ 1.8s
SI         10%       ≤ 3.4s
LCP        25%       ≤ 2.5s
TBT        30%       ≤ 200ms
CLS        25%       ≤ 0.1

评分 0-100:
  90-100  绿色(Good)
  50-89   橙色(Needs Improvement)
  0-49    红色(Poor)

性能预算(Performance Budget)

为团队设定可量化的性能目标,超出即触发告警或阻止发布:

性能预算示例:

类别              指标                预算值
───────────────────────────────────────────
加载指标          LCP                ≤ 2.5s
                 FCP                ≤ 1.5s
                 TTI                ≤ 3.5s
交互指标          INP                ≤ 200ms
                 TBT                ≤ 200ms
视觉稳定          CLS                ≤ 0.1
资源体积          JS 总量            ≤ 300KB (gzip)
                 CSS 总量           ≤ 50KB (gzip)
                 图片总量            ≤ 500KB
                 字体总量            ≤ 100KB
请求数量          关键资源请求数       ≤ 10
                 第三方请求数         ≤ 5
js
// Lighthouse CI 配置性能预算
// lighthouserc.js
module.exports = {
  ci: {
    assert: {
      assertions: {
        'categories:performance': ['error', { minScore: 0.9 }],
        'first-contentful-paint': ['warn', { maxNumericValue: 1500 }],
        'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
        'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
        'total-blocking-time': ['error', { maxNumericValue: 200 }],
      },
    },
  },
};
性能预算执行流程:

开发者提交代码

CI/CD Pipeline

┌─────────────────────────┐
│  Lighthouse CI 运行      │
│  - 启动无头浏览器        │
│  - 加载页面并测量指标     │
│  - 与预算对比            │
└────────┬────────────────┘

    ┌────┴────┐
    │ 通过?   │
    └────┬────┘
    Yes  │  No
    ↓    │   ↓
  部署   │  ❌ 阻止合并 / 发送告警
         │  → Slack 通知 / PR 评论

      生产环境

    RUM 持续监控

    P75 超标 → 告警 → 排查优化

ASCII 图展示

Navigation Timing Level 2 — 完整时间线

                    ┌─ Redirect ─┐
                    │             │
  startTime ────────┤             ├──── fetchStart
                    │  redirects  │         │
                    └─────────────┘         │

  ┌────────── AppCache ───────────┐        │
  │ 检查 Service Worker / Cache   │←───────┘
  └───────────────┬───────────────┘

  ┌───── DNS ─────┤
  │               │
  domainLookup    domainLookup
  Start           End

  ┌── TCP/TLS ────┤
  │               │
  connectStart    │secureConnection   connectEnd
  │               │Start (TLS)        │
  └───────────────┴───────────────────┘

  ┌──── Request ──────────────────────┤
  │                                   │
  requestStart                        responseStart (← TTFB)

  ┌──── Response ─────────────────────┤
  │                                   │
  responseStart                       responseEnd

  ┌──── Processing ───────────────────┤
  │                                   │
  │  domInteractive                   │
  │  domContentLoadedEventStart       │
  │  domContentLoadedEventEnd         │
  │  domComplete                      │
  └───────────────────────────────────┤

  ┌──── onLoad ───────────────────────┤
  │                                   │
  loadEventStart                      loadEventEnd

优化策略分类矩阵

前端性能优化策略矩阵

             ┌──────────────────────────────────────────────────────┐
             │                    优化阶段                          │
             ├──────────────┬──────────────┬────────────────────────┤
             │  构建时        │  加载时       │  运行时                │
             │  (Build-time) │ (Load-time)  │ (Runtime)             │
┌────────────┼──────────────┼──────────────┼────────────────────────┤
│ 网络层      │ Brotli/Gzip  │ CDN          │                       │
│            │ 代码分割      │ preconnect   │                       │
│ Network    │ Tree-shaking  │ preload      │                       │
│            │ 图片压缩      │ prefetch     │                       │
│            │              │ HTTP/2 推送   │                       │
├────────────┼──────────────┼──────────────┼────────────────────────┤
│ 解析层      │              │ defer/async  │                       │
│            │              │ 关键 CSS 内联 │                       │
│ Parse      │              │ DNS 预解析    │                       │
├────────────┼──────────────┼──────────────┼────────────────────────┤
│ 渲染层      │ SSR / SSG    │ 骨架屏       │ CSS contain           │
│            │              │ font-display │ content-visibility     │
│ Render     │              │              │ will-change            │
│            │              │              │ transform 动画         │
├────────────┼──────────────┼──────────────┼────────────────────────┤
│ 交互层      │              │              │ 长任务拆分              │
│            │              │              │ 虚拟列表               │
│ Interact   │              │              │ 防抖节流               │
│            │              │              │ Web Worker             │
│            │              │              │ requestAnimationFrame  │
├────────────┼──────────────┼──────────────┼────────────────────────┤
│ 监控层      │ Lighthouse CI│ Navigation   │ RUM(web-vitals)      │
│            │ 性能预算      │ Timing       │ PerformanceObserver   │
│ Monitor    │ Bundle 分析   │ Resource     │ Long Task 监控         │
│            │              │ Timing       │ 错误/慢请求追踪        │
└────────────┴──────────────┴──────────────┴────────────────────────┘

面试高频题

1. 什么是 Core Web Vitals?每个指标分别衡量什么,如何优化?

Core Web Vitals 是 Google 定义的三个核心用户体验指标:LCP(Largest Contentful Paint)衡量加载性能,标准 ≤2.5s,优化方向包括降低 TTFB、preload LCP 资源、使用 CDN、SSR/SSG;INP(Interaction to Next Paint)衡量交互响应性,标准 ≤200ms,优化方向包括拆分长任务(scheduler.yield)、减少事件处理时间、使用 Web Worker;CLS(Cumulative Layout Shift)衡量视觉稳定性,标准 ≤0.1,优化方向包括为图片/视频预设尺寸、使用 font-display: optional、避免动态注入内容。INP 已于 2024 年 3 月正式替代 FID,因为 FID 只衡量首次交互的输入延迟,而 INP 衡量所有交互的完整延迟(输入延迟 + 处理时间 + 渲染延迟),更能反映真实用户体验。

2. Performance API 中 PerformanceObserver 的作用是什么?如何用它采集 LCP 和 CLS?

PerformanceObserver 是一个异步观察者接口,可以监听浏览器产生的各类性能条目(performance entries),无需轮询,避免影响页面性能。采集 LCP:创建 PerformanceObserver 监听 largest-contentful-paint 类型,浏览器会在 LCP 候选元素渲染时触发回调,取最后一个条目的 startTime 作为 LCP 值,在页面 visibilitychange 变为 hidden 时上报。采集 CLS:监听 layout-shift 类型,过滤掉 hadRecentInput 为 true 的条目(用户交互触发的偏移不计入),使用会话窗口算法(最大 5 秒窗口,间隔不超过 1 秒)累加 value,取最大窗口值。实际项目中推荐直接使用 Google 的 web-vitals 库,它内部已封装了完整的采集逻辑和边界处理。

3. preload、prefetch、preconnect 和 dns-prefetch 有什么区别?分别在什么场景使用?

dns-prefetch 仅做 DNS 解析,适用于页面中引用的第三方域名(如 CDN、统计平台),开销最小。preconnect 完成 DNS + TCP + TLS 三步连接建立,适用于关键 API 域或 CDN 域,不宜超过 4-6 个。preload 以高优先级提前下载当前页面必需的关键资源,如 LCP 图片、关键字体、首屏 JS,配合 as 属性告知资源类型;注意 preload 只下载不执行,且 3 秒内未使用 Chrome 会发警告。prefetch 以最低优先级在空闲时下载未来页面可能用到的资源,如下一页的 JS chunk;在移动端弱网环境需谨慎使用以避免浪费流量。总结:dns-prefetch 和 preconnect 优化连接建立阶段,preload 优化当前页面的资源加载,prefetch 优化后续导航的加载速度。

4. 什么是长任务(Long Task)?如何拆分长任务以改善 INP?

浏览器主线程上执行时间超过 50ms 的任务称为长任务。长任务执行期间,浏览器无法响应用户输入、无法渲染新帧,导致页面卡顿和 INP 恶化。拆分长任务的方法:①使用 scheduler.yield()(Chrome 129+ 原生支持),在循环中定期调用 await scheduler.yield() 主动让出主线程,让浏览器处理排队的用户交互和渲染任务;②使用 setTimeout(resolve, 0) 包装的 Promise 实现手动 yield,兼容性最好;③使用 requestIdleCallback 在浏览器空闲时处理低优先级工作;④使用 Web Worker 将 CPU 密集型计算(排序、加密、数据处理)卸载到独立线程。React 的 Concurrent Mode 和 useTransition 本质上也是通过时间切片拆分长任务来保证交互响应性。

5. RUM 和 Lighthouse 有什么区别?如何建立完整的性能监控体系?

Lighthouse 是合成监控(Synthetic Monitoring),在受控环境下模拟用户访问,产出确定性分数和优化建议,适合开发阶段、CI/CD 性能门禁,优点是稳定可重复。RUM(Real User Monitoring)是真实用户监控,通过在页面中嵌入 SDK(如 web-vitals)采集真实用户的性能数据,覆盖不同设备、网络、地域,适合线上持续监控,数据更有代表性但波动较大,通常关注 P75/P95 百分位。完整的性能监控体系应两者结合:开发阶段用 Lighthouse 发现问题并设定性能预算;CI 中用 Lighthouse CI 做性能回归检测(LCP > 2.5s 阻止合并);线上用 RUM 持续采集 CWV 指标,按维度(设备类型、网络类型、地域、页面路由)聚合分析;设置告警规则(如 P75 LCP 连续 5 分钟 > 3s 告警);定期出性能周报驱动优化。


追问思考

  1. scheduler.yield()setTimeout(fn, 0) 都能让出主线程,但它们在任务调度优先级上有什么本质区别?为什么 scheduler.yield() 能更好地改善 INP?(提示:考虑任务队列的优先级和用户交互事件的调度顺序)

  2. content-visibility: auto 可以大幅提升长页面渲染性能,但它可能带来哪些副作用?(提示:考虑页面内搜索 Ctrl+F、锚点跳转 #hash、无障碍辅助技术的行为)

  3. web-vitals 库中的 onINP 为什么需要在页面 visibilitychange 时才上报最终值?如果用户一直停留在页面不切走,INP 值如何上报?(提示:考虑 INP 的定义是取页面生命周期内所有交互的近似 P98)

  4. 虚拟列表在处理不等高列表项时面临哪些挑战?如何解决滚动位置跳动和总高度估算不准的问题?(提示:考虑 ResizeObserver、动态测量缓存、滚动锚定)

  5. 性能预算设定为 JS 总量 ≤ 300KB (gzip),但引入一个新的第三方库后超标了。除了寻找更轻量的替代品,还有哪些工程化手段可以在不放弃功能的前提下满足预算?(提示:考虑按需加载、CDN 外部化、Facade 模式、Module Federation)

用心学习,用代码说话 💻