系列文章: 前端工程師的 Modern Web 實踐之道 - Day 18
預計閱讀時間: 12 分鐘
難度等級: ⭐⭐⭐⭐☆
在前一篇文章中,我們探討了前端安全實踐的重要性。今天我們將深入探討效能最佳化這個現代 Web 開發中最關鍵的主題之一。效能不只是技術指標,更直接影響使用者體驗、SEO 排名和商業轉換率。
想像這個場景:週一早上,產品經理衝進開發區,手機上顯示著競品的網站,載入速度快得令人驚艷。而你們的網站?使用者抱怨首屏載入要 5 秒,互動延遲讓人抓狂。這不是假設,這是許多團隊每天面對的真實痛點。
根據 Google 的研究數據:
這些數字告訴我們:效能最佳化不是錦上添花,而是生死攸關的競爭力。
現代 Web 應用面臨的效能挑戰日益複雜:
傳統的「先做功能,後最佳化」策略已經不適用。我們需要效能即特性(Performance as a Feature)的思維模式。
Google 在 2020 年推出 Core Web Vitals,提供了三個關鍵指標:
/**
* Core Web Vitals 三大核心指標
*/
const coreWebVitals = {
// LCP (Largest Contentful Paint): 最大內容繪製
// 衡量載入效能 - 理想值 < 2.5s
LCP: {
threshold: {
good: 2500, // 良好
needsWork: 4000, // 需要改善
poor: Infinity // 差
},
meaning: '主要內容完成渲染的時間'
},
// FID (First Input Delay): 首次輸入延遲
// 衡量互動性 - 理想值 < 100ms
FID: {
threshold: {
good: 100,
needsWork: 300,
poor: Infinity
},
meaning: '使用者首次互動到瀏覽器回應的時間'
},
// CLS (Cumulative Layout Shift): 累積版面配置位移
// 衡量視覺穩定性 - 理想值 < 0.1
CLS: {
threshold: {
good: 0.1,
needsWork: 0.25,
poor: Infinity
},
meaning: '頁面載入過程中元素位移的累積分數'
}
};
這三個指標涵蓋了效能的三個關鍵維度:載入速度、互動回應、視覺穩定。
在最佳化之前,我們需要先能量化效能。讓我們實作一個完整的效能監控工具:
/**
* 現代化效能監控工具
* 整合 Web Vitals API 和自訂指標
*/
class PerformanceMonitor {
private metrics: Map<string, number> = new Map();
private observer: PerformanceObserver | null = null;
constructor(private reportEndpoint: string) {
this.initCoreWebVitals();
this.initCustomMetrics();
}
/**
* 監控 Core Web Vitals
*/
private initCoreWebVitals(): void {
// 監控 LCP
this.observeMetric('largest-contentful-paint', (entry) => {
const lcp = entry.startTime;
this.metrics.set('LCP', lcp);
this.reportMetric('LCP', lcp);
});
// 監控 FID
this.observeMetric('first-input', (entry) => {
const fid = entry.processingStart - entry.startTime;
this.metrics.set('FID', fid);
this.reportMetric('FID', fid);
});
// 監控 CLS
let clsScore = 0;
this.observeMetric('layout-shift', (entry) => {
if (!(entry as any).hadRecentInput) {
clsScore += (entry as any).value;
this.metrics.set('CLS', clsScore);
}
});
}
/**
* 監控自訂效能指標
*/
private initCustomMetrics(): void {
// Time to First Byte (TTFB)
const navigationEntry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
if (navigationEntry) {
const ttfb = navigationEntry.responseStart - navigationEntry.requestStart;
this.metrics.set('TTFB', ttfb);
this.reportMetric('TTFB', ttfb);
}
// First Contentful Paint (FCP)
this.observeMetric('paint', (entry) => {
if (entry.name === 'first-contentful-paint') {
this.metrics.set('FCP', entry.startTime);
this.reportMetric('FCP', entry.startTime);
}
});
// Time to Interactive (TTI)
this.measureTTI();
}
/**
* 通用的效能觀察器
*/
private observeMetric(
type: string,
callback: (entry: PerformanceEntry) => void
): void {
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
callback(entry);
}
});
observer.observe({ type, buffered: true });
} catch (error) {
console.error(`Failed to observe ${type}:`, error);
}
}
/**
* 測量 Time to Interactive
*/
private measureTTI(): void {
// 簡化版 TTI 計算
if ('PerformanceObserver' in window) {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
// 當主執行緒空閒 5 秒後認為達到 TTI
setTimeout(() => {
const tti = performance.now();
this.metrics.set('TTI', tti);
this.reportMetric('TTI', tti);
}, 5000);
});
observer.observe({ entryTypes: ['longtask'] });
}
}
/**
* 回報效能指標到後端
*/
private async reportMetric(name: string, value: number): Promise<void> {
try {
// 使用 sendBeacon 確保資料能在頁面關閉時送出
const data = JSON.stringify({
metric: name,
value: Math.round(value),
url: window.location.href,
timestamp: Date.now(),
userAgent: navigator.userAgent,
connection: this.getConnectionInfo()
});
if (navigator.sendBeacon) {
navigator.sendBeacon(this.reportEndpoint, data);
} else {
// Fallback
await fetch(this.reportEndpoint, {
method: 'POST',
body: data,
headers: { 'Content-Type': 'application/json' },
keepalive: true
});
}
} catch (error) {
console.error('Failed to report metric:', error);
}
}
/**
* 取得網路連線資訊
*/
private getConnectionInfo(): any {
const connection = (navigator as any).connection
|| (navigator as any).mozConnection
|| (navigator as any).webkitConnection;
if (connection) {
return {
effectiveType: connection.effectiveType,
downlink: connection.downlink,
rtt: connection.rtt,
saveData: connection.saveData
};
}
return null;
}
/**
* 手動標記自訂效能時間點
*/
public mark(name: string): void {
performance.mark(name);
}
/**
* 測量兩個標記點之間的時間
*/
public measure(name: string, startMark: string, endMark: string): number {
performance.measure(name, startMark, endMark);
const measure = performance.getEntriesByName(name, 'measure')[0];
const duration = measure.duration;
this.reportMetric(name, duration);
return duration;
}
/**
* 取得所有效能指標
*/
public getMetrics(): Map<string, number> {
return new Map(this.metrics);
}
}
// 使用範例
const monitor = new PerformanceMonitor('/api/metrics');
// 標記關鍵業務流程
monitor.mark('checkout-start');
// ... 執行結帳邏輯
monitor.mark('checkout-end');
monitor.measure('checkout-duration', 'checkout-start', 'checkout-end');
首屏載入是使用者的第一印象,直接影響跳出率。讓我們實作一個完整的最佳化方案:
/**
* 首屏載入最佳化管理器
*/
class FirstScreenOptimizer {
private criticalResources: Set<string> = new Set();
private lazyResources: Set<string> = new Set();
/**
* 1. 關鍵資源預載入
*/
preloadCriticalResources(resources: Array<{
url: string;
type: 'script' | 'style' | 'font' | 'image';
priority?: 'high' | 'low';
}>): void {
resources.forEach(resource => {
const link = document.createElement('link');
link.rel = 'preload';
link.href = resource.url;
link.as = resource.type === 'script' ? 'script' :
resource.type === 'style' ? 'style' :
resource.type === 'font' ? 'font' : 'image';
if (resource.type === 'font') {
link.crossOrigin = 'anonymous';
}
if (resource.priority) {
(link as any).importance = resource.priority;
}
document.head.appendChild(link);
this.criticalResources.add(resource.url);
});
}
/**
* 2. 動態 Script 載入策略
*/
loadScriptAsync(src: string, options: {
defer?: boolean;
async?: boolean;
module?: boolean;
} = {}): Promise<void> {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
if (options.module) {
script.type = 'module';
}
if (options.defer) {
script.defer = true;
}
if (options.async) {
script.async = true;
}
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
document.body.appendChild(script);
});
}
/**
* 3. 圖片懶載入 with Intersection Observer
*/
setupLazyImages(): void {
const imageObserver = new IntersectionObserver(
(entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement;
const src = img.dataset.src;
if (src) {
// 使用新的 Image API 預載入
const tempImage = new Image();
tempImage.onload = () => {
img.src = src;
img.classList.add('loaded');
this.lazyResources.add(src);
};
tempImage.onerror = () => {
console.error(`Failed to load image: ${src}`);
img.classList.add('error');
};
tempImage.src = src;
observer.unobserve(img);
}
}
});
},
{
// 提前 50px 開始載入
rootMargin: '50px 0px',
threshold: 0.01
}
);
// 觀察所有 data-src 圖片
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
}
/**
* 4. CSS 關鍵路徑最佳化
*/
inlineCriticalCSS(criticalCSS: string): void {
const style = document.createElement('style');
style.textContent = criticalCSS;
document.head.insertBefore(style, document.head.firstChild);
}
/**
* 5. 非關鍵 CSS 延遲載入
*/
loadNonCriticalCSS(href: string): void {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
link.media = 'print'; // 先設為 print 避免阻塞
link.onload = function() {
(this as HTMLLinkElement).media = 'all'; // 載入後改回 all
};
document.head.appendChild(link);
}
/**
* 6. 資源提示 (Resource Hints)
*/
setupResourceHints(): void {
// DNS Prefetch - 提前解析域名
this.addResourceHint('dns-prefetch', 'https://cdn.example.com');
// Preconnect - 建立早期連接
this.addResourceHint('preconnect', 'https://api.example.com');
// Prefetch - 預取下一頁資源
this.addResourceHint('prefetch', '/next-page.js');
}
private addResourceHint(rel: string, href: string): void {
const link = document.createElement('link');
link.rel = rel;
link.href = href;
document.head.appendChild(link);
}
/**
* 7. 首屏骨架屏實作
*/
showSkeleton(containerId: string): void {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = `
<div class="skeleton-wrapper">
<div class="skeleton-header"></div>
<div class="skeleton-content">
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
</div>
`;
}
hideSkeleton(containerId: string): void {
const container = document.getElementById(containerId);
if (!container) return;
const skeleton = container.querySelector('.skeleton-wrapper');
if (skeleton) {
skeleton.classList.add('fade-out');
setTimeout(() => skeleton.remove(), 300);
}
}
}
// 實際使用範例
const optimizer = new FirstScreenOptimizer();
// 預載入關鍵資源
optimizer.preloadCriticalResources([
{ url: '/fonts/main.woff2', type: 'font', priority: 'high' },
{ url: '/js/critical.js', type: 'script', priority: 'high' },
{ url: '/images/hero.webp', type: 'image' }
]);
// 內聯關鍵 CSS
const criticalCSS = `
body { margin: 0; font-family: system-ui; }
.hero { height: 100vh; display: flex; align-items: center; }
`;
optimizer.inlineCriticalCSS(criticalCSS);
// 延遲載入非關鍵 CSS
optimizer.loadNonCriticalCSS('/css/non-critical.css');
// 設定懶載入
optimizer.setupLazyImages();
// 顯示骨架屏
optimizer.showSkeleton('app-root');
// 當內容載入完成後隱藏骨架屏
window.addEventListener('load', () => {
optimizer.hideSkeleton('app-root');
});
首屏載入只是開始,執行時效能同樣關鍵。讓我們實作一個 React 應用的效能最佳化範例:
/**
* React 效能最佳化最佳實踐
*/
import React, { memo, useCallback, useMemo, useTransition, useDeferredValue } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
/**
* 1. 使用 memo 避免不必要的重渲染
*/
interface ProductCardProps {
product: {
id: string;
name: string;
price: number;
image: string;
};
onAddToCart: (id: string) => void;
}
const ProductCard = memo<ProductCardProps>(({ product, onAddToCart }) => {
// 使用 useCallback 穩定函式引用
const handleClick = useCallback(() => {
onAddToCart(product.id);
}, [product.id, onAddToCart]);
return (
<div className="product-card">
<img
src={product.image}
alt={product.name}
loading="lazy" // 原生懶載入
decoding="async" // 非同步解碼
/>
<h3>{product.name}</h3>
<p>${product.price}</p>
<button onClick={handleClick}>加入購物車</button>
</div>
);
}, (prevProps, nextProps) => {
// 自訂比較邏輯,只在關鍵屬性改變時重渲染
return prevProps.product.id === nextProps.product.id &&
prevProps.product.price === nextProps.product.price;
});
/**
* 2. 虛擬滾動處理大量列表
*/
interface VirtualListProps {
items: any[];
renderItem: (item: any, index: number) => React.ReactNode;
}
const VirtualList: React.FC<VirtualListProps> = ({ items, renderItem }) => {
const parentRef = React.useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100, // 預估每個項目高度
overscan: 5 // 額外渲染 5 個項目避免閃爍
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`
}}
>
{renderItem(items[virtualItem.index], virtualItem.index)}
</div>
))}
</div>
</div>
);
};
/**
* 3. 使用 useTransition 處理非緊急更新
*/
const SearchComponent: React.FC = () => {
const [query, setQuery] = React.useState('');
const [isPending, startTransition] = useTransition();
const [results, setResults] = React.useState<any[]>([]);
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value); // 緊急更新:立即更新輸入框
// 非緊急更新:搜尋結果可以延遲
startTransition(() => {
const filtered = performExpensiveSearch(value);
setResults(filtered);
});
};
return (
<div>
<input
type="text"
value={query}
onChange={handleSearch}
placeholder="搜尋產品..."
/>
{isPending && <div className="loading-spinner">搜尋中...</div>}
<div className="results">
{results.map(item => (
<div key={item.id}>{item.name}</div>
))}
</div>
</div>
);
};
/**
* 4. 使用 useDeferredValue 最佳化搜尋體驗
*/
const OptimizedSearch: React.FC = () => {
const [query, setQuery] = React.useState('');
const deferredQuery = useDeferredValue(query);
// 只在 deferredQuery 改變時重新計算
const results = useMemo(() => {
return performExpensiveSearch(deferredQuery);
}, [deferredQuery]);
return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div>
{results.map(item => (
<div key={item.id}>{item.name}</div>
))}
</div>
</div>
);
};
/**
* 5. Web Worker 處理密集計算
*/
class PerformanceWorkerManager {
private worker: Worker | null = null;
constructor() {
this.initWorker();
}
private initWorker(): void {
// 使用內聯 Worker
const workerCode = `
self.onmessage = function(e) {
const { type, data } = e.data;
switch(type) {
case 'HEAVY_CALCULATION':
const result = performHeavyCalculation(data);
self.postMessage({ type: 'CALCULATION_COMPLETE', result });
break;
case 'PROCESS_IMAGE':
const processed = processImage(data);
self.postMessage({ type: 'IMAGE_PROCESSED', result: processed });
break;
}
};
function performHeavyCalculation(data) {
// 模擬密集計算
let result = 0;
for (let i = 0; i < 1000000; i++) {
result += Math.sqrt(i) * data.factor;
}
return result;
}
function processImage(imageData) {
// 圖片處理邏輯
return imageData;
}
`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
this.worker = new Worker(URL.createObjectURL(blob));
}
public calculate(data: any): Promise<any> {
return new Promise((resolve, reject) => {
if (!this.worker) {
reject(new Error('Worker not initialized'));
return;
}
const handleMessage = (e: MessageEvent) => {
if (e.data.type === 'CALCULATION_COMPLETE') {
this.worker?.removeEventListener('message', handleMessage);
resolve(e.data.result);
}
};
this.worker.addEventListener('message', handleMessage);
this.worker.postMessage({ type: 'HEAVY_CALCULATION', data });
});
}
public terminate(): void {
this.worker?.terminate();
this.worker = null;
}
}
// 模擬搜尋函式
function performExpensiveSearch(query: string): any[] {
// 實際專案中這裡會是複雜的搜尋邏輯
return [];
}
基於多年的實戰經驗,我整理了以下最佳實踐:
建立效能預算(Performance Budget)
使用效能分析工具鏈
漸進式增強策略
持續監控與最佳化
過早最佳化
忽視網路條件
犧牲可維護性
忽略後端效能
Core Web Vitals 是現代效能標準:LCP、FID、CLS 三大指標涵蓋載入、互動、穩定性,是效能最佳化的北極星指標。
效能監控是最佳化的基礎:沒有量化就沒有最佳化,建立完整的監控系統能幫助我們發現問題、驗證效果、持續改進。
首屏載入最佳化的多維度策略:從資源預載入、關鍵路徑最佳化、懶載入、到骨架屏,每個技術都針對特定的效能瓶頸。
執行時效能同樣重要:React 的 memo、虛擬滾動、useTransition、Web Worker 等技術能顯著提升大型應用的執行時效能。
如何在效能和使用者體驗之間取得平衡?
AI 時代的前端效能挑戰
效能最佳化的 ROI(投資報酬率)