性能优化概述
前端性能优化的目标是提升用户体验,让页面加载更快、响应更及时、交互更流畅。
核心 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 缓存
- 延迟加载:非关键资源延迟加载
- 优化渲染:减少重排重绘
代码分割与懒加载
路由级别的代码分割
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 27 28 29
| 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( '@/views/Admin.vue' ), }, ], });
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| 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( './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> ); }
|
组件级别的懒加载
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 27 28 29 30 31 32 33 34 35 36 37 38 39
| 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(); } }, });
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| 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);
|
条件加载
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 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; } });
|
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 27 28 29 30 31 32 33 34 35 36 37
| 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>; }
|
资源优化
图片优化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| <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>
|
1 2 3 4 5 6 7 8 9 10
| <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)" />
<link rel="preconnect" href="https://cdn.example.com" /> <link rel="dns-prefetch" href="https://cdn.example.com" />
|
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| 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 }" /> `, });
|
字体优化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| <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; } </style>
|
1 2 3 4 5 6 7 8 9 10 11
| 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); }); }
|
资源预加载策略
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <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" />
|
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 27 28
| 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 缓存配置
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 27
| server { location ~* \.html$ { add_header Cache-Control "no-cache, must-revalidate"; add_header Pragma "no-cache"; expires 0; } 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; } location /api/ { add_header Cache-Control "public, max-age=60"; expires 1m; } }
|
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67
| 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; 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; } 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 缓存配置
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 27
| export default defineConfig({ build: { rollupOptions: { output: { 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'; } }, }, }, }, });
|
渲染优化
虚拟滚动
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78
| 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); 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> `, });
|
避免不必要的重渲染
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import { defineComponent, shallowRef, triggerRef } from 'vue';
export default defineComponent({ setup() { const largeData = shallowRef({ }); const updateData = () => { largeData.value.someProperty = 'new value'; triggerRef(largeData); }; return { largeData, updateData }; }, });
|
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 27
| import { memo, useMemo, useCallback } from 'react';
const ExpensiveComponent = memo(function ExpensiveComponent({ data, onClick }) { return <div>{/* ... */}</div>; });
function ParentComponent() { const processedData = useMemo(() => { return heavyComputation(rawData); }, [rawData]); const handleClick = useCallback(() => { }, [dependency]); return ( <ExpensiveComponent data={processedData} onClick={handleClick} /> ); }
|
减少重排重绘
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
| 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'); element.classList.add('my-style'); 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'; }); }
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); }
const element = document.getElementById('will-change'); element.style.willChange = 'transform, opacity';
element.addEventListener('transitionend', () => { element.style.willChange = 'auto'; });
|
网络优化
减少请求数量
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| 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 优化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
|
export default defineConfig({ build: { rollupOptions: { output: { manualChunks(id) { if (id.includes('node_modules')) { const packageName = id.split('node_modules/')[1].split('/')[0]; return `vendor-${packageName}`; } }, }, }, }, });
|
压缩与优化
1 2 3 4 5
| gzip -k -9 file.js
brotli -k -q 11 file.js
|
1 2 3 4 5 6 7 8 9
| 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 on; brotli_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| import viteCompression from 'vite-plugin-compression';
export default defineConfig({ plugins: [ viteCompression({ algorithm: 'gzip', ext: '.gz', threshold: 1024, }), viteCompression({ algorithm: 'brotliCompress', ext: '.br', threshold: 1024, }), ], });
|
性能监控
Web Vitals 监控
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 27 28 29
| import { onCLS, onFID, onLCP, onINP } from 'web-vitals';
onCLS((metric) => { console.log('CLS:', metric.value); sendToAnalytics('CLS', metric.value); });
onINP((metric) => { console.log('INP:', metric.value); sendToAnalytics('INP', metric.value); });
onLCP((metric) => { console.log('LCP:', metric.value); sendToAnalytics('LCP', metric.value); });
function sendToAnalytics(name: string, value: number) { const data = new FormData(); data.append('name', name); data.append('value', value.toString()); navigator.sendBeacon('/analytics', data); }
|
自定义性能监控
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| 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.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');
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 审计
1 2 3 4 5 6 7 8
| npx lighthouse https://example.com --view
npx lighthouse https://example.com --output=json --output-path=./report.json
npx lighthouse https://example.com --output=json --output-path=./lighthouse-report.json
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| { "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 监控关键路径
- [ ] 设置性能预算
- [ ] 建立性能回归检测机制