前端性能优化实战

一、加载优化

场景 1:首屏白屏时间过长

用户打开页面时长时间看到空白或 loading,直接流失。

方案 A:关键 CSS 内联

将首屏所需的 CSS 直接写入 HTML <head>,避免 CSS 文件加载阻塞渲染。

1
2
3
4
5
6
7
8
9
<head>
<style>
/* 首屏关键 CSS */
.header { position: fixed; top: 0; ... }
.hero { display: flex; ... }
</style>
<!-- 非关键 CSS 延迟加载 -->
<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
<!-- preload:当前页面必需的资源,尽早加载 -->
<link rel="preload" href="font.woff2" as="font" crossorigin>
<link rel="preload" href="hero.jpg" as="image">

<!-- prefetch:下一个页面可能用到的资源,空闲时加载 -->
<link rel="prefetch" href="/next-page.js" as="script">

<!-- preconnect:提前建立连接,减少 DNS + TCP + TLS 时间 -->
<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">

<!-- IntersectionObserver 实现 -->
<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
/* 方案 A:font-display: swap(强烈推荐) */
@font-face {
font-family: 'MyFont';
src: url('font.woff2');
font-display: swap; /* 先用后备字体显示,字体加载后替换 */
}

/* 方案 B:内联字体(首屏关键字体用 Base64) */
@font-face {
font-family: 'IconFont';
src: url('data:font/woff2;base64,...');
}

/* 方案 C:只加载用到的字符(unicode-range) */
@font-face {
font-family: 'MyFont';
unicode-range: U+4E00-9FFF; /* 仅中文字符 */
}
方案FOIT 时间布局偏移实施难度
font-display: swap0有(CLS 增加)
内联 Base640低(限小字体)
unicode-range不影响

二、渲染优化

场景 4:大量列表渲染导致页面卡顿

接口返回数万条数据,直接渲染导致 DOM 过多,滚动卡顿。

方案 A:虚拟滚动(Virtual Scroll)

只渲染可视区域内的元素 + 少量缓冲区,不可见的 DOM 被回收或复用。

1
2
3
4
5
6
7
8
9
10
// 使用 react-window
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-windowReact轻量(3KB),功能简洁
react-virtuosoReact自动计算尺寸,支持分组
vue-virtual-scrollerVueVue 3 兼容
TanStack Virtual框架无关最灵活,自定义度高

方案 B:分页加载

1
2
3
// 传统分页 vs 无限滚动
// 传统分页:用户点击"下一页",数据一次性切换
// 无限滚动:滚动到底部时自动追加下一页
维度传统分页无限滚动虚拟滚动
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);
};
}

// 节流:固定频率执行(适合滚动、resize)
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跟随帧率执行

方案 B:强制合成层(will-change / transform)

1
2
3
4
5
6
7
8
9
10
/* 告知浏览器该元素将变化,提前创建合成层 */
.element {
will-change: transform, opacity;
}

/* 或使用 transform 触发合成层(跳过 Layout + Paint) */
/* ❌ 改变 left 触发 Layout */
.element { left: 100px; }
/* ✅ 改变 transform 仅触发 Composite */
.element { transform: translateX(100px); }

使用 transformopacity 做动画,不会触发回流重绘。

方案 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));
}

// ✅ 使用 DocumentFragment 批量插入
const fragment = document.createDocumentFragment();
for (const item of items) {
fragment.appendChild(createElement(item));
}
container.appendChild(fragment);

// ✅ 或使用 innerHTML 一次性构建
container.innerHTML = items.map(item => `<div>${item}</div>`).join('');

场景 6:大量计算阻塞主线程

数据处理、加密、格式化等耗时 JS 执行导致页面无法响应用户操作。

方案 A:Web Worker

将计算任务移至后台线程,主线程保持响应。

1
2
3
4
5
6
7
8
9
10
// main.js
const worker = new Worker('calculate-worker.js');
worker.postMessage(largeData);
worker.onmessage = (e) => updateUI(e.data);

// calculate-worker.js
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);
}); // Vue computed 或 useMemo

// 或拆分计算,每次只算一部分
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
// ❌ 串行:总耗时 = 500ms + 400ms + 300ms = 1200ms
const user = await fetch('/api/user');
const orders = await fetch('/api/orders');
const messages = await fetch('/api/messages');

// ✅ 并行:总耗时 = max(500ms, 400ms, 300ms) = 500ms
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;
}
缓存位置命中速度容量控制粒度
内存变量最快手动控制
localStorage5-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
// React.lazy + Suspense
const Dashboard = React.lazy(() => import('./Dashboard'));
const Settings = React.lazy(() => import('./Settings'));

// Vue 动态导入
const Dashboard = () => import('./Dashboard.vue');

// Webpack / Vite 自动代码分割
// 每个动态 import 点生成独立的 chunk

方案 B:Tree Shaking

1
2
3
4
5
// ✅ 按名导入,摇掉未使用的代码
import { debounce } from 'lodash-es';

// ❌ 全量导入,无法 tree-shaking
import _ from 'lodash';
1
2
// package.json(确保库支持 ESM)
{ "sideEffects": false }

方案 C:三方库优化

优化手段效果示例
使用 CDN 加载(externals)减少打包体积React、Vue 走 CDN
替换轻量库减少体积moment → dayjs(-250KB)
按需加载减少体积lodash-es / antd 按需
动态 polyfill减少体积仅低版本浏览器加载
1
2
<!-- CDN 加载 React,不打包到主包 -->
<script src="https://cdn.example.com/react@18.2.0.min.js"></script>
1
2
3
4
5
// webpack.config.js
externals: {
react: 'React',
'react-dom': 'ReactDOM',
}

方案 D:压缩

工具压缩率速度
esbuild极快
TerserWebpackPlugin
SWC
1
2
# Vite 默认使用 esbuild 压缩
# Webpack 推荐 esbuild-minify-plugin 替代 terser

场景 10:CSS 文件过大且存在未使用的样式

随着项目迭代,大量 CSS 未被使用但仍在打包。

方案 A:PurgeCSS

1
2
3
4
// tailwind.config.js(自动移除未使用的 CSS)
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
// CSS Modules:每个组件独立作用域,无全局泄漏
import styles from './Button.module.css';

// CSS-in-JS:只生成用到的样式
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
// 场景 A:全局事件监听未移除
useEffect(() => {
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize); // ✅
}, []);

// 场景 B:定时器未清除
useEffect(() => {
const timer = setInterval(tick, 1000);
return () => clearInterval(timer); // ✅
}, []);

// 场景 C:DOM 引用未释放
const elements = [];
function saveRef(el) {
elements.push(el); // ❌ 组件卸载后 DOM 仍然被引用
}

// 场景 D:闭包持有大对象
function process() {
const largeData = new Array(1000000);
return () => {
// ❌ 闭包持续持有 largeData
console.log(largeData.length);
};
}

监控内存

1
2
3
4
5
6
7
8
9
10
11
// Chrome DevTools → Performance → Memory
// 或编程方式监控
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 URLimg.onload 后 revokeObjectURL释放内存映射
1
2
3
4
5
// 使用虚拟滚动 + 懒加载
// 图片加载完成后释放 Blob URL
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
/* 触发 GPU 合成层,跳过 Layout/Paint */
.animated {
transform: translateZ(0); /* 或 */
will-change: transform;
}
属性触发 Layout触发 Paint触发 Composite
left/top
transform
opacity
width/height

原则:动画和变换尽量使用 transformopacity

场景 14:大量事件绑定导致内存和性能开销

每个列表项都绑定独立事件监听。

方案:事件委托

1
2
3
4
5
6
7
8
9
10
// ❌ 1000 个元素绑定 1000 个事件
document.querySelectorAll('.item').forEach(el => {
el.addEventListener('click', handleClick);
});

// ✅ 在父容器绑定 1 个事件,通过 target 分发
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)

指标全称目标测量方式
LCPLargest Contentful Paint≤ 2.5s页面主要内容渲染时间
FIDFirst Input Delay≤ 100ms用户首次交互到响应的时间
CLSCumulative 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
// 使用 web-vitals 接入真实用户监控
import { onLCP, onFID, onCLS } from 'web-vitals';

onLCP(console.log);
onFID(console.log);
onCLS(console.log);

九、推荐学习路径

  1. 理解浏览器渲染原理(关键渲染路径、回流与重绘、合成层)
  2. 掌握 Chrome DevTools Performance / Lighthouse / Memory 面板
  3. 从加载优化入手:图片、字体、关键 CSS
  4. 处理运行时卡顿:虚拟滚动、Web Worker、transform 动画
  5. 优化构建产物:代码分割、Tree Shaking、CDN
  6. 建立监控:接入 web-vitals 和错误监控