iT邦幫忙

2025 iThome 鐵人賽

0
Modern Web

前端工程師的 Modern Web 實踐之道系列 第 17

效能最佳化實戰:從首屏載入到執行時效能的全方位最佳化

  • 分享至 

  • xImage
  •  

系列文章: 前端工程師的 Modern Web 實踐之道 - Day 18
預計閱讀時間: 12 分鐘
難度等級: ⭐⭐⭐⭐☆

🎯 今日目標

在前一篇文章中,我們探討了前端安全實踐的重要性。今天我們將深入探討效能最佳化這個現代 Web 開發中最關鍵的主題之一。效能不只是技術指標,更直接影響使用者體驗、SEO 排名和商業轉換率。

為什麼要關注效能最佳化?

想像這個場景:週一早上,產品經理衝進開發區,手機上顯示著競品的網站,載入速度快得令人驚艷。而你們的網站?使用者抱怨首屏載入要 5 秒,互動延遲讓人抓狂。這不是假設,這是許多團隊每天面對的真實痛點。

根據 Google 的研究數據:

  • 頁面載入時間每增加 1 秒,轉換率下降 7%
  • 53% 的使用者會放棄載入超過 3 秒的網站
  • 頁面載入時間從 1 秒增加到 10 秒,使用者跳出率增加 123%

這些數字告訴我們:效能最佳化不是錦上添花,而是生死攸關的競爭力。

🔍 深度分析:Web 效能的技術本質

問題背景與現狀

現代 Web 應用面臨的效能挑戰日益複雜:

  1. 資源體積膨脹:JavaScript bundle 動輒數 MB,CSS 和圖片資源龐大
  2. 執行時複雜度:前端框架、狀態管理、實時資料同步
  3. 網路環境多樣:3G/4G/5G、有線/無線、全球使用者分布
  4. 裝置效能差異:從高階桌機到低階手機,效能差距巨大

傳統的「先做功能,後最佳化」策略已經不適用。我們需要效能即特性(Performance as a Feature)的思維模式。

Core Web Vitals:現代效能的量化標準

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');
});

第三步:JavaScript 執行時效能最佳化

首屏載入只是開始,執行時效能同樣關鍵。讓我們實作一個 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 [];
}

🎯 最佳實踐建議

基於多年的實戰經驗,我整理了以下最佳實踐:

✅ 推薦做法

  1. 建立效能預算(Performance Budget)

    • 為每個頁面設定效能指標上限
    • 在 CI/CD 中自動檢查效能預算
    • 超過預算時阻止部署
  2. 使用效能分析工具鏈

    • Chrome DevTools Performance Panel
    • Lighthouse CI 自動化測試
    • WebPageTest 真實環境測試
    • Bundle Analyzer 分析打包大小
  3. 漸進式增強策略

    • 優先保證核心功能在低階裝置上可用
    • 為高階裝置提供增強體驗
    • 使用 Feature Detection 而非 User-Agent 判斷
  4. 持續監控與最佳化

    • 部署 Real User Monitoring (RUM)
    • 設定效能告警機制
    • 定期進行效能審查

❌ 避免陷阱

  1. 過早最佳化

    • 不要在沒有量化數據前盲目最佳化
    • 先用 Profiler 找出真正的瓶頸
    • 專注在影響最大的 20% 問題
  2. 忽視網路條件

    • 不要只在辦公室的快速網路測試
    • 使用 Chrome DevTools 模擬慢速網路
    • 考慮離線和弱網路場景
  3. 犧牲可維護性

    • 不要為了微小效能提升寫出難以維護的程式碼
    • 保持程式碼清晰度和效能的平衡
    • 用註釋說明效能最佳化的原因
  4. 忽略後端效能

    • 前端最佳化無法彌補後端 API 的延遲
    • 與後端團隊協作最佳化 API 回應時間
    • 考慮使用 GraphQL 減少過度取得資料

📋 本日重點回顧

  1. Core Web Vitals 是現代效能標準:LCP、FID、CLS 三大指標涵蓋載入、互動、穩定性,是效能最佳化的北極星指標。

  2. 效能監控是最佳化的基礎:沒有量化就沒有最佳化,建立完整的監控系統能幫助我們發現問題、驗證效果、持續改進。

  3. 首屏載入最佳化的多維度策略:從資源預載入、關鍵路徑最佳化、懶載入、到骨架屏,每個技術都針對特定的效能瓶頸。

  4. 執行時效能同樣重要:React 的 memo、虛擬滾動、useTransition、Web Worker 等技術能顯著提升大型應用的執行時效能。

🤔 延伸思考

  1. 如何在效能和使用者體驗之間取得平衡?

    • 有時候添加動畫和互動會增加效能負擔
    • 但適當的視覺回饋能提升使用者感知效能
    • 思考:什麼情況下應該優先考慮感知效能而非實際效能?
  2. AI 時代的前端效能挑戰

    • 大型 AI 模型在瀏覽器中執行(如 TensorFlow.js)
    • WebGPU 帶來的新效能最佳化機會
    • 思考:如何平衡 AI 功能的複雜度和頁面效能?
  3. 效能最佳化的 ROI(投資報酬率)

    • 不是所有頁面都需要極致效能
    • 如何識別哪些頁面值得投入最佳化資源?
    • 思考:如何量化效能提升帶來的商業價值?


上一篇
前端安全實踐:XSS、CSRF 到 CSP 的現代化防護策略
系列文
前端工程師的 Modern Web 實踐之道17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言