主题
前端性能优化
为什么性能很重要
页面性能直接影响用户体验和商业指标。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.078CLS 优化手段:
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 + opacityPerformance 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
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 - startTimejs
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,避免路径 1CSS 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
第三方请求数 ≤ 5js
// 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 各阶段时间线
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 告警);定期出性能周报驱动优化。
追问思考
scheduler.yield()和setTimeout(fn, 0)都能让出主线程,但它们在任务调度优先级上有什么本质区别?为什么scheduler.yield()能更好地改善 INP?(提示:考虑任务队列的优先级和用户交互事件的调度顺序)content-visibility: auto可以大幅提升长页面渲染性能,但它可能带来哪些副作用?(提示:考虑页面内搜索 Ctrl+F、锚点跳转 #hash、无障碍辅助技术的行为)web-vitals 库中的
onINP为什么需要在页面visibilitychange时才上报最终值?如果用户一直停留在页面不切走,INP 值如何上报?(提示:考虑 INP 的定义是取页面生命周期内所有交互的近似 P98)虚拟列表在处理不等高列表项时面临哪些挑战?如何解决滚动位置跳动和总高度估算不准的问题?(提示:考虑 ResizeObserver、动态测量缓存、滚动锚定)
性能预算设定为 JS 总量 ≤ 300KB (gzip),但引入一个新的第三方库后超标了。除了寻找更轻量的替代品,还有哪些工程化手段可以在不放弃功能的前提下满足预算?(提示:考虑按需加载、CDN 外部化、Facade 模式、Module Federation)