一、加载优化
场景 1:首屏白屏时间过长
用户打开页面时长时间看到空白或 loading,直接流失。
方案 A:关键 CSS 内联
将首屏所需的 CSS 直接写入 HTML <head>,避免 CSS 文件加载阻塞渲染。
1 2 3 4 5 6 7 8 9
| <head> <style> .header { position: fixed; top: 0; ... } .hero { display: flex; ... } </style> <link rel="preload" href="styles.css" as="style" onload="this.rel='stylesheet'"> </head>
|
| 维度 | 内联 CSS | 外链 CSS |
|---|
| 首屏时间 | 快(无网络请求) | 慢(需额外请求) |
| 缓存 | 随 HTML,无法单独缓存 | 可单独缓存 |
| 维护性 | 增加 HTML 体积 | 独立文件,易于维护 |
结论:首屏关键 CSS 内联,非关键 CSS 延迟加载,两者结合。
方案 B:资源预加载(Preload / Prefetch / Preconnect)
1 2 3 4 5 6 7 8 9 10
| <link rel="preload" href="font.woff2" as="font" crossorigin> <link rel="preload" href="hero.jpg" as="image">
<link rel="prefetch" href="/next-page.js" as="script">
<link rel="preconnect" href="https://api.example.com"> <link rel="dns-prefetch" href="https://api.example.com">
|
| 指令 | 时机 | 优先级 | 用途 |
|---|
preload | 当前页面解析即加载 | 高 | 字体、首屏图片、关键脚本 |
prefetch | 浏览器空闲时 | 低 | 下一页资源 |
preconnect | 立即 | 高 | 第三方域名提前建连 |
dns-prefetch | 立即 | 中 | 仅 DNS 查询 |
方案 C:SSR / SSG 预渲染
1 2 3 4
| SPA: 浏览器 → 下载 JS → 解析 JS → 渲染 → 用户看到内容 SSR: 服务器 → 生成 HTML → 浏览器直接展示首屏 → 加载 JS 接管交互
|
- SSR(Nuxt/Next):每次请求在服务端渲染 HTML,适合动态内容
- SSG:构建时生成 HTML,适合博客、文档站
- 优缺点:SSR 增加服务器成本,SSG 无法处理动态数据
场景 2:图片加载拖慢页面
图片通常占页面总流量的 60%+,是最常见的性能瓶颈。
方案 A:懒加载(Lazy Loading)
1 2 3 4 5
| <img src="placeholder.jpg" data-src="real-image.jpg" loading="lazy">
<img data-src="real-image.jpg" class="lazy">
|
1 2 3 4 5 6 7 8 9 10
| const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; observer.unobserve(img); } }); }); document.querySelectorAll('.lazy').forEach(el => observer.observe(el));
|
方案 B:响应式图片 + 现代格式
1 2 3 4 5 6
| <picture> <source srcset="image.avif" type="image/avif"> <source srcset="image.webp" type="image/webp"> <img src="image.jpg" srcset="image-400.jpg 400w, image-800.jpg 800w" sizes="(max-width: 600px) 400px, 800px"> </picture>
|
| 格式 | 压缩率(vs JPEG) | 浏览器支持 |
|---|
| WebP | 小 25-35% | 96% |
| AVIF | 小 50%+ | 88% |
| JPEG XL | 小 60%+ | 12%(新) |
方案 C:CDN 图片处理(实时缩放)
1 2 3 4
| 原始图片:1920x1080, 2MB CDN 处理后: ?w=400&q=75 → 400x225, 40KB ?w=800&q=80 → 800x450, 120KB
|
服务端根据 UA 或 devicePixelRatio 下发合适的图片尺寸。
方案对比总结
| 方案 | 效果 | 实施成本 | 适用场景 |
|---|
| 懒加载 | 减少首屏流量 50-80% | 低 | 长列表、文章配图 |
| 现代格式 | 体积减小 30-50% | 中 | 所有图片 |
| 响应式 | 避免手机加载桌面大图 | 中 | 响应式站点 |
| CDN 处理 | 按需裁剪,减少体积 90% | 高(需 CDN 支持) | 大流量站点 |
场景 3:Web 字体导致文字透明(FOIT)
字体加载期间文字不可见,用户看到空白文本区域。
方案对比
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @font-face { font-family: 'MyFont'; src: url('font.woff2'); font-display: swap; }
@font-face { font-family: 'IconFont'; src: url('data:font/woff2;base64,...'); }
@font-face { font-family: 'MyFont'; unicode-range: U+4E00-9FFF; }
|
| 方案 | FOIT 时间 | 布局偏移 | 实施难度 |
|---|
font-display: swap | 0 | 有(CLS 增加) | 低 |
| 内联 Base64 | 0 | 无 | 低(限小字体) |
unicode-range | 不影响 | 无 | 中 |
二、渲染优化
场景 4:大量列表渲染导致页面卡顿
接口返回数万条数据,直接渲染导致 DOM 过多,滚动卡顿。
只渲染可视区域内的元素 + 少量缓冲区,不可见的 DOM 被回收或复用。
1 2 3 4 5 6 7 8 9 10
| import { FixedSizeList } from 'react-window';
const Row = ({ index, style }) => ( <div style={style}>Row {index}</div> );
<FixedSizeList height={600} itemCount={100000} itemSize={50}> {Row} </FixedSizeList>
|
| 库 | 框架 | 特点 |
|---|
| react-window | React | 轻量(3KB),功能简洁 |
| react-virtuoso | React | 自动计算尺寸,支持分组 |
| vue-virtual-scroller | Vue | Vue 3 兼容 |
| TanStack Virtual | 框架无关 | 最灵活,自定义度高 |
方案 B:分页加载
| 维度 | 传统分页 | 无限滚动 | 虚拟滚动 |
|---|
| DOM 数量 | 少(当前页) | 持续增长 | 固定(仅可视区) |
| 用户体验 | 打断浏览 | 连续流畅 | 连续流畅 |
| 数据总量 | 无限制 | 无限制 | 无限制 |
| 实现复杂度 | 低 | 低 | 高 |
| 适用场景 | 列表页、搜索结果 | 社交 Feed | 千万级数据展示 |
方案 C:分批渲染(Time Slicing)
一次性获取全部数据,但分批插入 DOM,避免主线程长时间阻塞。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| function renderBatch(items, batchSize = 20) { let index = 0;
function next() { const end = Math.min(index + batchSize, items.length); for (let i = index; i < end; i++) { const el = document.createElement('div'); el.textContent = items[i]; container.appendChild(el); } index = end; if (index < items.length) { requestAnimationFrame(next); } }
next(); }
|
| 方案 | 适用数据量 | 优势 | 劣势 |
|---|
| 分批渲染 | 千级 | 全量在 DOM 中可搜索 | 万级仍会卡顿 |
| 虚拟滚动 | 十万级 | DOM 数量恒定 | 无法 Ctrl+F 搜索 |
| 分页 | 百万级 | 实现简单 | 体验割裂 |
方案对比总结
1 2 3
| 数据量 < 1000 → 直接渲染(无优化必要) 数据量 1000-10000 → 分页或分批渲染 数据量 > 10000 → 虚拟滚动 + 分页加载
|
场景 5:频繁 DOM 更新导致布局抖动
滚动事件、resize 事件、输入事件中频繁操作 DOM,触发大量回流重绘。
方案 A:防抖(Debounce)与节流(Throttle)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| function debounce(fn, delay = 300) { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); }; }
function throttle(fn, interval = 100) { let last = 0; return (...args) => { const now = Date.now(); if (now - last >= interval) { last = now; fn(...args); } }; }
|
| 场景 | 推荐方案 | 说明 |
|---|
| 搜索输入 | debounce 300ms | 用户停止输入后才请求 |
| 滚动无限加载 | throttle 200ms | 每隔 200ms 检测一次位置 |
| resize 自适应 | throttle 100ms | 避免频繁重排 |
| 拖拽缩放 | requestAnimationFrame | 跟随帧率执行 |
1 2 3 4 5 6 7 8 9 10
| .element { will-change: transform, opacity; }
.element { left: 100px; }
.element { transform: translateX(100px); }
|
使用 transform 和 opacity 做动画,不会触发回流重绘。
方案 C:批量 DOM 操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| for (const item of items) { container.appendChild(createElement(item)); }
const fragment = document.createDocumentFragment(); for (const item of items) { fragment.appendChild(createElement(item)); } container.appendChild(fragment);
container.innerHTML = items.map(item => `<div>${item}</div>`).join('');
|
场景 6:大量计算阻塞主线程
数据处理、加密、格式化等耗时 JS 执行导致页面无法响应用户操作。
方案 A:Web Worker
将计算任务移至后台线程,主线程保持响应。
1 2 3 4 5 6 7 8 9 10
| const worker = new Worker('calculate-worker.js'); worker.postMessage(largeData); worker.onmessage = (e) => updateUI(e.data);
self.onmessage = (e) => { const result = heavyCompute(e.data); self.postMessage(result); };
|
优点:不阻塞 UI,适合纯计算场景
缺点:不能操作 DOM,数据传输有拷贝开销
方案 B:闲时调度(requestIdleCallback)
1 2 3 4 5 6 7 8 9
| requestIdleCallback((deadline) => { while (deadline.timeRemaining() > 0 && tasks.length > 0) { processTask(tasks.shift()); } if (tasks.length > 0) { requestIdleCallback(processTasks); } });
|
优点:不影响交互响应
缺点:执行时间不确定,不适合时限敏感的任务
方案 C:延迟计算(Lazy / Defer)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const heavyResult = computed(() => { return expensiveCalculation(props.data); });
function processInChunks(data, chunkSize = 1000) { let i = 0; function next() { const chunk = data.slice(i, i + chunkSize); process(chunk); i += chunkSize; if (i < data.length) { setTimeout(next, 0); } } next(); }
|
三、网络优化
场景 7:接口请求慢,首屏等待数据
API 响应时间 500ms+,多次串行请求进一步拉长等待时间。
方案 A:并行请求
1 2 3 4 5 6 7 8 9 10 11
| const user = await fetch('/api/user'); const orders = await fetch('/api/orders'); const messages = await fetch('/api/messages');
const [user, orders, messages] = await Promise.all([ fetch('/api/user'), fetch('/api/orders'), fetch('/api/messages'), ]);
|
方案 B:数据预取
1 2
| <link rel="preload" href="/api/user" as="fetch" crossorigin>
|
1 2 3 4 5 6 7 8 9
| window.addEventListener('load', () => { prefetchData('/api/next-page-data'); });
link.addEventListener('mouseenter', () => { prefetchData(link.dataset.api); });
|
方案 C:缓存策略
1 2 3 4 5 6 7 8 9 10 11 12
| const cache = new Map();
async function fetchWithCache(url, ttl = 60000) { const cached = cache.get(url); if (cached && Date.now() - cached.timestamp < ttl) { return cached.data; } const data = await fetch(url).then(r => r.json()); cache.set(url, { data, timestamp: Date.now() }); return data; }
|
| 缓存位置 | 命中速度 | 容量 | 控制粒度 |
|---|
| 内存变量 | 最快 | 小 | 手动控制 |
| localStorage | 快 | 5-10MB | 手动,同步 |
| IndexedDB | 异步 | 大 | 手动,支持索引 |
| HTTP Cache | 取决于磁盘 | 大 | 服务端控制 |
| Service Worker | 快 | 大 | 编程拦截 |
方案 D:数据预加载 vs 骨架屏
1 2 3
| 数据预加载:发起请求后显示 skeleton → 数据返回后展示真实内容 骨架屏方案:在 HTML 中内联 skeleton 结构,立即展示占位布局 Suspense:React/Vue 的 Suspense 组件,等待数据时自动 fallback
|
| 方案 | 感知等待时间 | 实现成本 |
|---|
| 无处理(白屏) | 用户感知强烈 | 0 |
| Loading Spinner | 稍有改善 | 低 |
| 骨架屏 | 感觉加载更快 | 中 |
| 渐进加载 | 最快感知 | 高 |
场景 8:API 请求过多导致带宽浪费
页面发起大量小请求,或同一数据被多次请求。
方案 A:请求合并(Batching)
1 2 3 4 5
| userIds.forEach(id => fetch(`/api/users/${id}`));
fetch(`/api/users?ids=${userIds.join(',')}`);
|
方案 B:数据预拉取(GraphQL / 定制接口)
1 2 3 4 5 6 7 8 9
| REST:需要多次请求获取关联数据 GET /users/1 GET /users/1/orders GET /users/1/profile
GraphQL:一次请求精确获取所需数据 query { user(id: 1) { name, orders { total }, profile { avatar } } }
|
| 方案 | 请求次数 | 灵活性 | 学习成本 |
|---|
| REST | 多个端点 | 服务端决定返回字段 | 低 |
| GraphQL | 通常 1 次 | 客户端决定返回字段 | 中 |
四、构建优化
场景 9:JS Bundle 体积过大
单页应用的 JS 包超过 500KB,解析和执行时间过长。
方案 A:代码分割
1 2 3 4 5 6 7 8 9
| const Dashboard = React.lazy(() => import('./Dashboard')); const Settings = React.lazy(() => import('./Settings'));
const Dashboard = () => import('./Dashboard.vue');
|
方案 B:Tree Shaking
1 2 3 4 5
| import { debounce } from 'lodash-es';
import _ from 'lodash';
|
1 2
| { "sideEffects": false }
|
方案 C:三方库优化
| 优化手段 | 效果 | 示例 |
|---|
| 使用 CDN 加载(externals) | 减少打包体积 | React、Vue 走 CDN |
| 替换轻量库 | 减少体积 | moment → dayjs(-250KB) |
| 按需加载 | 减少体积 | lodash-es / antd 按需 |
| 动态 polyfill | 减少体积 | 仅低版本浏览器加载 |
1 2
| <script src="https://cdn.example.com/react@18.2.0.min.js"></script>
|
1 2 3 4 5
| externals: { react: 'React', 'react-dom': 'ReactDOM', }
|
方案 D:压缩
| 工具 | 压缩率 | 速度 |
|---|
| esbuild | 高 | 极快 |
| TerserWebpackPlugin | 高 | 慢 |
| SWC | 高 | 快 |
场景 10:CSS 文件过大且存在未使用的样式
随着项目迭代,大量 CSS 未被使用但仍在打包。
方案 A:PurgeCSS
1 2 3 4
| module.exports = { purge: ['./src/**/*.html', './src/**/*.vue'], };
|
| 方案 | 说明 | 风险 |
|---|
| PurgeCSS | 扫描 HTML/JS 中使用过的类名 | 动态类名可能被误删 |
| UnCSS | 通过 Puppeteer 渲染后分析 | 耗时,需真实渲染环境 |
方案 B:CSS Modules / CSS-in-JS
1 2 3 4 5 6 7
| import styles from './Button.module.css';
const Button = styled.button` color: ${props => props.primary ? 'blue' : 'gray'}; `;
|
五、内存优化
场景 11:内存泄漏导致页面越来越卡
长时间运行的单页应用或 SPA 页面,内存持续增长。
常见泄漏场景与修复
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| useEffect(() => { window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []);
useEffect(() => { const timer = setInterval(tick, 1000); return () => clearInterval(timer); }, []);
const elements = []; function saveRef(el) { elements.push(el); }
function process() { const largeData = new Array(1000000); return () => { console.log(largeData.length); }; }
|
监控内存
1 2 3 4 5 6 7 8 9 10 11
|
function logMemory() { if (performance.memory) { console.log({ used: (performance.memory.usedJSHeapSize / 1048576).toFixed(1), total: (performance.memory.jsHeapSizeLimit / 1048576).toFixed(1), }); } } setInterval(logMemory, 5000);
|
场景 12:图片列表导致内存占用过高
大量大图在 DOM 中,即使不可见也占用 GPU 内存。
方案对比
| 方案 | 原理 | 效果 |
|---|
| virtual scroll | 只渲染可见图片 | 内存恒定 |
| data-src 懒加载 | 未进入视口的图片不设置 src | 减少 DOM 图片加载 |
| 小图占位 | 先用低分辨率图占位,加载完成再替换 | 改善感知体验 |
| 及时释放 blob URL | img.onload 后 revokeObjectURL | 释放内存映射 |
1 2 3 4 5
|
const img = new Image(); img.onload = () => URL.revokeObjectURL(img.src); img.src = URL.createObjectURL(blob);
|
六、运行时优化
场景 13:滚动列表中的复杂动画卡顿
滚动时触发大量样式计算和动画。
方案 A:will-change + contain
1 2 3 4 5
| .scroll-item { contain: content; content-visibility: auto; }
|
方案 B:硬件加速
1 2 3 4 5
| .animated { transform: translateZ(0); will-change: transform; }
|
| 属性 | 触发 Layout | 触发 Paint | 触发 Composite |
|---|
left/top | ✅ | ✅ | ✅ |
transform | ❌ | ❌ | ✅ |
opacity | ❌ | ❌ | ✅ |
width/height | ✅ | ✅ | ✅ |
原则:动画和变换尽量使用 transform 和 opacity。
场景 14:大量事件绑定导致内存和性能开销
每个列表项都绑定独立事件监听。
方案:事件委托
1 2 3 4 5 6 7 8 9 10
| document.querySelectorAll('.item').forEach(el => { el.addEventListener('click', handleClick); });
container.addEventListener('click', (e) => { const item = e.target.closest('.item'); if (item) handleClick(item.dataset.id); });
|
七、优化策略选择路径
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| 用户反馈页面卡顿 ├── 首屏加载慢 │ ├── 网络请求多 → 并行请求 / GraphQL / 缓存 │ ├── JS 包太大 → 代码分割 / Tree Shaking / CDN │ ├── 图片太多 → 懒加载 / WebP / 响应式图片 │ └── 渲染阻塞 → 内联关键 CSS / SSR │ ├── 交互卡顿 │ ├── 大量 DOM → 虚拟滚动 / 分批渲染 │ ├── 频繁计算 → Web Worker / 闲时调度 │ ├── 频繁回流 → transform / 批量操作 / 防抖节流 │ └── 事件过多 → 事件委托 │ ├── 内存持续增长 │ ├── 事件泄漏 → 组件卸载时清理 │ ├── DOM 泄漏 → 释放引用 │ ├── 定时器泄漏 → 组件卸载时清除 │ └── 图片泄漏 → 虚拟滚动 + 懒加载 │ └── 构建产物过大 ├── 三方库过大 → dayjs 替代 moment / CDN 外置 ├── 未代码分割 → 动态 import └── 未 Tree Shaking → 按名导入
|
八、性能指标与监控
核心指标(Core Web Vitals)
| 指标 | 全称 | 目标 | 测量方式 |
|---|
| LCP | Largest Contentful Paint | ≤ 2.5s | 页面主要内容渲染时间 |
| FID | First Input Delay | ≤ 100ms | 用户首次交互到响应的时间 |
| CLS | Cumulative Layout Shift | ≤ 0.1 | 页面布局偏移量 |
测量工具
| 工具 | 用途 |
|---|
| Chrome DevTools → Lighthouse | 综合评分报告 |
| Chrome DevTools → Performance | 详细性能分析 |
performance.mark() / measure() | 自定义性能打点 |
| web-vitals 库 | 采集 Core Web Vitals |
| Sentry / 自建监控 | 生产环境性能监控 |
1 2 3 4 5 6
| import { onLCP, onFID, onCLS } from 'web-vitals';
onLCP(console.log); onFID(console.log); onCLS(console.log);
|
九、推荐学习路径
- 理解浏览器渲染原理(关键渲染路径、回流与重绘、合成层)
- 掌握 Chrome DevTools Performance / Lighthouse / Memory 面板
- 从加载优化入手:图片、字体、关键 CSS
- 处理运行时卡顿:虚拟滚动、Web Worker、transform 动画
- 优化构建产物:代码分割、Tree Shaking、CDN
- 建立监控:接入 web-vitals 和错误监控