前端性能优化实战指南
性能优化概述
前端性能优化的目标是提升用户体验,让页面加载更快、响应更及时、交互更流畅。
核心 Web 指标 (Core Web Vitals)
- LCP (Largest Contentful Paint): 最大内容绘制,衡量加载性能,应在 2.5 秒内
- INP (Interaction to Next Paint): 交互到下次绘制,衡量响应性,应在 200 毫秒内
- CLS (Cumulative Layout Shift): 累积布局偏移,衡量视觉稳定性,应小于 0.1
性能优化原则
- 减少请求数量:合并资源、使用雪碧图
- 减少资源体积:压缩、Gzip、Tree Shaking
- 优化加载顺序:关键资源优先加载
- 利用缓存:浏览器缓存、CDN 缓存
- 延迟加载:非关键资源延迟加载
- 优化渲染:减少重排重绘
代码分割与懒加载
路由级别的代码分割
// Vue Router 懒加载
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'Home',
// 使用动态导入实现懒加载
component: () => import('@/views/Home.vue'),
},
{
path: '/about',
name: 'About',
component: () => import('@/views/About.vue'),
},
{
path: '/admin',
name: 'Admin',
// 预加载管理后台
component: () => import(
/* webpackChunkName: "admin" */
/* webpackPrefetch: true */
'@/views/Admin.vue'
),
},
],
});// React Router 懒加载
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Admin = lazy(() => import(
/* webpackPrefetch: true */
'./pages/Admin'
));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/admin" element={<Admin />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}组件级别的懒加载
// Vue 组件懒加载
import { defineAsyncComponent } from 'vue';
// 基础用法
const AsyncComponent = defineAsyncComponent(
() => import('./components/HeavyComponent.vue')
);
// 高级配置
const AsyncComponentWithOptions = defineAsyncComponent({
// 工厂函数
loader: () => import('./components/HeavyComponent.vue'),
// 加载异步组件时显示的组件
loadingComponent: LoadingComponent,
// 加载失败时显示的组件
errorComponent: ErrorComponent,
// 延迟时间(毫秒)
delay: 200,
// 超时时间(毫秒)
timeout: 3000,
// 是否挂起
suspensible: true,
// 错误处理
onError(error, retry, fail, attempts) {
if (error.message.match(/network/) && attempts <= 3) {
// 网络错误,重试
retry();
} else {
// 其他错误或重试次数超限,失败
fail();
}
},
});// React 组件懒加载
import { lazy, Suspense, ComponentType } from 'react';
// 基础用法
const HeavyComponent = lazy(() => import('./HeavyComponent'));
// 带加载状态
function withSuspense<T extends ComponentType<any>>(
Component: T,
fallback = <div>Loading...</div>
) {
return function SuspenseWrapper(props: React.ComponentProps<T>) {
return (
<Suspense fallback={fallback}>
<Component {...props} />
</Suspense>
);
};
}
const LazyHeavyComponent = withSuspense(HeavyComponent);条件加载
// 根据条件动态加载组件
import { ref, onMounted } from 'vue';
const showHeavyComponent = ref(false);
const HeavyComponent = ref(null);
onMounted(async () => {
// 用户交互后才加载
if (userInteracted) {
const module = await import('./HeavyComponent.vue');
HeavyComponent.value = module.default;
showHeavyComponent.value = true;
}
});// React 根据视口加载
import { useEffect, useState, useRef } from 'react';
function useInView(ref: React.RefObject<HTMLElement>) {
const [isInView, setIsInView] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => setIsInView(entry.isIntersecting),
{ threshold: 0.1 }
);
if (ref.current) {
observer.observe(ref.current);
}
return () => observer.disconnect();
}, [ref]);
return isInView;
}
function LazyImage({ src, alt }: { src: string; alt: string }) {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref);
const [Component, setComponent] = useState(null);
useEffect(() => {
if (isInView && !Component) {
import('./ImageComponent').then(mod => {
setComponent(() => mod.default);
});
}
}, [isInView, Component]);
return <div ref={ref}>{Component && <Component src={src} alt={alt} />}</div>;
}资源优化
图片优化
// 响应式图片
<img
srcset="
image-320w.jpg 320w,
image-480w.jpg 480w,
image-800w.jpg 800w
"
sizes="(max-width: 320px) 280px,
(max-width: 480px) 440px,
800px"
src="image-800w.jpg"
alt="响应式图片"
/>
// 现代图片格式
<picture>
<source srcset="image.avif" type="image/avif" />
<source srcset="image.webp" type="image/webp" />
<img src="image.jpg" alt="图片" />
</picture>// 图片懒加载
<img loading="lazy" src="image.jpg" alt="懒加载图片" />
// 预加载关键图片
<link rel="preload" as="image" href="hero.jpg" />
<link rel="preload" as="image" href="hero-mobile.jpg" media="(max-width: 768px)" />
// 预连接 CDN
<link rel="preconnect" href="https://cdn.example.com" />
<link rel="dns-prefetch" href="https://cdn.example.com" />// Vue 图片懒加载组件
import { defineComponent, ref, onMounted, onUnmounted } from 'vue';
export default defineComponent({
name: 'LazyImage',
props: {
src: { type: String, required: true },
alt: { type: String, default: '' },
},
setup(props) {
const imgRef = ref<HTMLImageElement>();
const isLoaded = ref(false);
const observer = ref<IntersectionObserver>();
onMounted(() => {
observer.value = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && imgRef.value) {
imgRef.value.src = props.src;
observer.value?.disconnect();
}
},
{ threshold: 0.1 }
);
if (imgRef.value) {
observer.value.observe(imgRef.value);
}
});
onUnmounted(() => {
observer.value?.disconnect();
});
return { imgRef, isLoaded };
},
template: `
<img
ref="imgRef"
:alt="alt"
@load="isLoaded = true"
:class="{ 'loaded': isLoaded }"
/>
`,
});字体优化
<!-- 预加载关键字体 -->
<link
rel="preload"
href="/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<!-- 字体显示策略 -->
<style>
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-weight: 100 900;
font-display: swap; /* 或 fallback, optional, block */
}
</style>// 字体加载 API
if ('fonts' in document) {
const font = new FontFace('Inter', 'url(/fonts/inter-var.woff2)');
font.load().then(() => {
document.fonts.add(font);
document.documentElement.classList.add('fonts-loaded');
}).catch(err => {
console.error('字体加载失败:', err);
});
}资源预加载策略
<!-- 预加载关键资源 -->
<link rel="preload" href="/critical.css" as="style" />
<link rel="preload" href="/main.js" as="script" />
<link rel="preload" href="/hero.jpg" as="image" />
<!-- 预获取下一页资源 -->
<link rel="prefetch" href="/next-page.js" as="script" />
<link rel="prefetch" href="/api/next-data" />
<!-- 预连接第三方域名 -->
<link rel="preconnect" href="https://api.example.com" />
<link rel="dns-prefetch" href="https://analytics.example.com" />
<!-- 预渲染 -->
<link rel="prerender" href="/next-page.html" />// 动态预加载
function preloadRouteComponents(route: string) {
// 根据路由预加载对应的资源
const preloadMap: Record<string, string[]> = {
'/about': ['/about.js', '/about.css'],
'/admin': ['/admin.js', '/admin.css'],
};
const resources = preloadMap[route] || [];
resources.forEach(href => {
const link = document.createElement('link');
link.rel = 'preload';
link.href = href;
link.as = href.endsWith('.js') ? 'script' : 'style';
document.head.appendChild(link);
});
}
// 鼠标悬停时预加载
document.querySelectorAll('a[data-prefetch]').forEach(link => {
link.addEventListener('mouseenter', () => {
const href = link.getAttribute('href');
if (href) {
preloadRouteComponents(href);
}
});
});缓存策略
HTTP 缓存配置
# Nginx 缓存配置
server {
# HTML 文件 - 不缓存或短缓存
location ~* \.html$ {
add_header Cache-Control "no-cache, must-revalidate";
add_header Pragma "no-cache";
expires 0;
}
# JS/CSS 文件 - 长期缓存(带 hash)
location ~* \.(js|css)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
expires 1y;
}
# 图片/字体 - 长期缓存
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|woff|woff2|ttf|eot)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
expires 1y;
}
# API 响应 - 短缓存
location /api/ {
add_header Cache-Control "public, max-age=60";
expires 1m;
}
}// Service Worker 缓存策略
const CACHE_NAME = 'app-cache-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/main.js',
'/styles.css',
];
// 安装时缓存静态资源
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
return cache.addAll(STATIC_ASSETS);
})
);
});
// 请求拦截
self.addEventListener('fetch', event => {
const { request } = event;
// HTML 文件 - 网络优先
if (request.headers.get('Accept')?.includes('text/html')) {
event.respondWith(
fetch(request)
.then(response => {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => {
cache.put(request, clone);
});
return response;
})
.catch(() => caches.match(request))
);
return;
}
// 静态资源 - 缓存优先
if (request.url.match(/\.(js|css|jpg|png|svg|woff2)$/)) {
event.respondWith(
caches.match(request).then(cached => {
return cached || fetch(request).then(response => {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => {
cache.put(request, clone);
});
return response;
});
})
);
return;
}
// API 请求 - 网络优先,失败时返回缓存
event.respondWith(
fetch(request)
.then(response => {
const clone = response.clone();
caches.open('api-cache').then(cache => {
cache.put(request, clone);
});
return response;
})
.catch(() => caches.match(request))
);
});Vite 缓存配置
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
// 文件名带 hash
entryFileNames: 'assets/[name]-[hash].js',
chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash].[ext]',
// 手动分块
manualChunks(id) {
if (id.includes('node_modules')) {
// 将大型库单独分块
if (id.includes('vue') || id.includes('vue-router') || id.includes('pinia')) {
return 'vendor-vue';
}
if (id.includes('lodash') || id.includes('dayjs') || id.includes('axios')) {
return 'vendor-utils';
}
return 'vendor-other';
}
},
},
},
},
});渲染优化
虚拟滚动
// Vue 虚拟滚动实现
import { defineComponent, ref, computed, onMounted, onUnmounted } from 'vue';
export default defineComponent({
name: 'VirtualList',
props: {
items: { type: Array, required: true },
itemHeight: { type: Number, default: 50 },
containerHeight: { type: Number, default: 400 },
},
setup(props) {
const scrollTop = ref(0);
const containerRef = ref<HTMLElement>();
// 可见区域
const visibleRange = computed(() => {
const start = Math.floor(scrollTop.value / props.itemHeight);
const visibleCount = Math.ceil(props.containerHeight / props.itemHeight);
const end = Math.min(start + visibleCount + 2, props.items.length); // 多渲染 2 个
return { start: Math.max(0, start - 1), end };
});
// 可见项
const visibleItems = computed(() => {
const { start, end } = visibleRange.value;
return props.items.slice(start, end);
});
// 总高度
const totalHeight = computed(() => props.items.length * props.itemHeight);
// 偏移量
const offsetY = computed(() => visibleRange.value.start * props.itemHeight);
// 滚动处理
const handleScroll = () => {
if (containerRef.value) {
scrollTop.value = containerRef.value.scrollTop;
}
};
onMounted(() => {
containerRef.value?.addEventListener('scroll', handleScroll);
});
onUnmounted(() => {
containerRef.value?.removeEventListener('scroll', handleScroll);
});
return {
containerRef,
visibleItems,
totalHeight,
offsetY,
itemHeight: props.itemHeight,
};
},
template: `
<div
ref="containerRef"
class="virtual-list"
:style="{ height: containerHeight + 'px', overflow: 'auto' }"
>
<div :style="{ height: totalHeight + 'px', position: 'relative' }">
<div :style="{ transform: 'translateY(' + offsetY + 'px)' }">
<div
v-for="item in visibleItems"
:key="item.id"
:style="{ height: itemHeight + 'px' }"
>
<slot :item="item"></slot>
</div>
</div>
</div>
</div>
`,
});避免不必要的重渲染
// Vue 优化
import { defineComponent, shallowRef, triggerRef } from 'vue';
export default defineComponent({
setup() {
// 使用 shallowRef 避免深度响应
const largeData = shallowRef({
// 大型数据对象
});
// 手动触发更新
const updateData = () => {
largeData.value.someProperty = 'new value';
triggerRef(largeData);
};
return { largeData, updateData };
},
});// React 优化
import { memo, useMemo, useCallback } from 'react';
// 使用 memo 避免不必要的重渲染
const ExpensiveComponent = memo(function ExpensiveComponent({ data, onClick }) {
// 组件逻辑
return <div>{/* ... */}</div>;
});
function ParentComponent() {
// 使用 useMemo 缓存计算结果
const processedData = useMemo(() => {
return heavyComputation(rawData);
}, [rawData]);
// 使用 useCallback 缓存函数
const handleClick = useCallback(() => {
// 处理点击
}, [dependency]);
return (
<ExpensiveComponent
data={processedData}
onClick={handleClick}
/>
);
}减少重排重绘
// 批量修改 DOM
function badExample() {
const element = document.getElementById('myElement');
// 触发多次重排
element.style.width = '100px';
element.style.height = '100px';
element.style.backgroundColor = 'red';
element.style.border = '1px solid black';
}
function goodExample() {
const element = document.getElementById('myElement');
// 使用 class
element.classList.add('my-style');
// 或使用 cssText
element.style.cssText = `
width: 100px;
height: 100px;
background-color: red;
border: 1px solid black;
`;
}
// 避免强制同步布局
function badLayout() {
const elements = document.querySelectorAll('.item');
elements.forEach(el => {
const width = el.offsetWidth; // 触发重排
el.style.width = width + 10 + 'px'; // 修改样式
});
}
function goodLayout() {
const elements = document.querySelectorAll('.item');
const widths: number[] = [];
// 先读取
elements.forEach(el => {
widths.push(el.offsetWidth);
});
// 再写入
elements.forEach((el, i) => {
el.style.width = widths[i] + 10 + 'px';
});
}
// 使用 requestAnimationFrame
function animate() {
const element = document.getElementById('animated');
let position = 0;
function step() {
position += 1;
element.style.transform = `translateX(${position}px)`;
if (position < 100) {
requestAnimationFrame(step);
}
}
requestAnimationFrame(step);
}
// 使用 will-change 提示浏览器
const element = document.getElementById('will-change');
element.style.willChange = 'transform, opacity';
// 动画结束后移除
element.addEventListener('transitionend', () => {
element.style.willChange = 'auto';
});网络优化
减少请求数量
// 合并请求
async function fetchAllData() {
// 并行请求
const [users, posts, comments] = await Promise.all([
fetch('/api/users').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/comments').then(r => r.json()),
]);
return { users, posts, comments };
}
// 批量请求
async function batchRequests(ids: number[]) {
// 单次请求获取多个资源
const response = await fetch(`/api/items?ids=${ids.join(',')}`);
return response.json();
}
// 请求合并(防抖)
function createBatchFetcher(apiCall: (ids: number[]) => Promise<any>) {
let pendingIds: number[] = [];
let timer: NodeJS.Timeout | null = null;
return function(id: number): Promise<any> {
return new Promise((resolve) => {
pendingIds.push(id);
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(async () => {
const results = await apiCall(pendingIds);
pendingIds = [];
resolve(results);
}, 100);
});
};
}HTTP/2 优化
// 利用 HTTP/2 的多路复用
// 不再需要合并文件,反而应该拆分模块
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
// HTTP/2 下可以拆分更细
manualChunks(id) {
if (id.includes('node_modules')) {
// 每个包单独一个 chunk
const packageName = id.split('node_modules/')[1].split('/')[0];
return `vendor-${packageName}`;
}
},
},
},
},
});压缩与优化
# Gzip 压缩
gzip -k -9 file.js
# Brotli 压缩(更好的压缩率)
brotli -k -q 11 file.js# Nginx 启用压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
# Brotli
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;// Vite 压缩插件
import viteCompression from 'vite-plugin-compression';
export default defineConfig({
plugins: [
// Gzip
viteCompression({
algorithm: 'gzip',
ext: '.gz',
threshold: 1024,
}),
// Brotli
viteCompression({
algorithm: 'brotliCompress',
ext: '.br',
threshold: 1024,
}),
],
});性能监控
Web Vitals 监控
import { onCLS, onFID, onLCP, onINP } from 'web-vitals';
// Cumulative Layout Shift
onCLS((metric) => {
console.log('CLS:', metric.value);
// 发送到分析服务
sendToAnalytics('CLS', metric.value);
});
// Interaction to Next Paint
onINP((metric) => {
console.log('INP:', metric.value);
sendToAnalytics('INP', metric.value);
});
// Largest Contentful Paint
onLCP((metric) => {
console.log('LCP:', metric.value);
sendToAnalytics('LCP', metric.value);
});
function sendToAnalytics(name: string, value: number) {
// 使用 navigator.sendBeacon 确保数据发送成功
const data = new FormData();
data.append('name', name);
data.append('value', value.toString());
navigator.sendBeacon('/analytics', data);
}自定义性能监控
// 性能测量
class PerformanceMonitor {
private marks: Map<string, number> = new Map();
mark(name: string) {
this.marks.set(name, performance.now());
}
measure(name: string, startMark: string, endMark: string) {
const start = this.marks.get(startMark);
const end = this.marks.get(endMark);
if (start !== undefined && end !== undefined) {
const duration = end - start;
console.log(`${name}: ${duration.toFixed(2)}ms`);
// 使用 Performance API
performance.mark(`${name}-start`);
performance.mark(`${name}-end`);
performance.measure(name, `${name}-start`, `${name}-end`);
return duration;
}
return -1;
}
getEntries() {
return performance.getEntriesByType('measure');
}
}
// 使用示例
const monitor = new PerformanceMonitor();
monitor.mark('component-mount-start');
// 组件挂载逻辑
monitor.mark('component-mount-end');
monitor.measure('Component Mount', 'component-mount-start', 'component-mount-end');
// 监控 API 请求
async function fetchWithMonitoring(url: string) {
monitor.mark('api-start');
try {
const response = await fetch(url);
const data = await response.json();
monitor.mark('api-end');
monitor.measure('API Request', 'api-start', 'api-end');
return data;
} catch (error) {
monitor.mark('api-end');
monitor.measure('API Request (Failed)', 'api-start', 'api-end');
throw error;
}
}Lighthouse 审计
# 命令行运行 Lighthouse
npx lighthouse https://example.com --view
# 生成 JSON 报告
npx lighthouse https://example.com --output=json --output-path=./report.json
# CI 中使用
npx lighthouse https://example.com --output=json --output-path=./lighthouse-report.json// lighthouserc.json
{
"ci": {
"collect": {
"url": ["http://localhost:3000"],
"numberOfRuns": 3
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"categories:accessibility": ["error", { "minScore": 0.9 }],
"categories:best-practices": ["error", { "minScore": 0.9 }],
"categories:seo": ["error", { "minScore": 0.9 }]
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}性能优化清单
加载性能
- [ ] 启用 Gzip/Brotli 压缩
- [ ] 使用 CDN 加速静态资源
- [ ] 配置合理的缓存策略
- [ ] 实现代码分割和懒加载
- [ ] 优化图片(WebP、响应式、懒加载)
- [ ] 预加载关键资源
- [ ] 减少 HTTP 请求数量
- [ ] 使用 HTTP/2
- [ ] 最小化第三方脚本
- [ ] 实现 Service Worker 缓存
渲染性能
- [ ] 避免不必要的重排重绘
- [ ] 使用虚拟滚动处理大列表
- [ ] 避免强制同步布局
- [ ] 使用 requestAnimationFrame
- [ ] 使用 CSS transform 代替位置属性
- [ ] 减少 DOM 节点数量
- [ ] 使用 will-change 提示浏览器
- [ ] 避免长任务阻塞主线程
运行时性能
- [ ] 使用 Web Worker 处理复杂计算
- [ ] 防抖节流高频事件
- [ ] 优化算法复杂度
- [ ] 使用高效的数据结构
- [ ] 避免内存泄漏
- [ ] 使用事件委托
监控与分析
- [ ] 配置 Core Web Vitals 监控
- [ ] 定期运行 Lighthouse 审计
- [ ] 使用 Performance API 监控关键路径
- [ ] 设置性能预算
- [ ] 建立性能回归检测机制
