iT邦幫忙

2025 iThome 鐵人賽

DAY 18
0
Modern Web

30 天製作工作室 SaaS 產品 (前端篇)系列 第 18

Day 18: 30天打造SaaS產品前端篇-前端效能優化方法

  • 分享至 

  • xImage
  •  

前情提要

經過 Day 17 的微服務架構建置,我們的系統具備了良好的擴展性與維護性。今天我們要轉向前端效能優化,從打包策略、資源載入、到渲染效能,全面提升使用者體驗。我們將基於現有的 React + Vite 架構,實現企業級的效能優化方案。

Vite 打包優化策略

程式碼分割與懶載入

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { splitVendorChunkPlugin } from 'vite';

export default defineConfig({
  plugins: [
    react(),
    splitVendorChunkPlugin()
  ],
  build: {
    target: 'es2015',
    minify: 'terser',
    cssMinify: true,
    reportCompressedSize: false,
    chunkSizeWarningLimit: 1000,
    rollupOptions: {
      output: {
        manualChunks: {
          // 核心框架單獨打包
          'vendor-react': ['react', 'react-dom'],
          'vendor-router': ['react-router-dom'],

          // UI 組件庫單獨打包
          'vendor-ui': ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'],

          // 工具函式庫單獨打包
          'vendor-utils': ['lodash-es', 'date-fns', 'zod'],

          // 圖表與可視化
          'vendor-charts': ['recharts', 'lucide-react']
        },
        chunkFileNames: (chunkInfo) => {
          const facadeModuleId = chunkInfo.facadeModuleId
            ? chunkInfo.facadeModuleId.split('/').pop()
            : 'chunk';
          return `js/${facadeModuleId}-[hash].js`;
        },
        assetFileNames: (assetInfo) => {
          const info = assetInfo.name.split('.');
          const extType = info[info.length - 1];
          if (/png|jpe?g|svg|gif|tiff|bmp|ico/i.test(extType)) {
            return `images/[name]-[hash][extname]`;
          }
          if (/css/i.test(extType)) {
            return `css/[name]-[hash][extname]`;
          }
          return `assets/[name]-[hash][extname]`;
        }
      }
    },
    terserOptions: {
      compress: {
        drop_console: true,
        drop_debugger: true,
        pure_funcs: ['console.log', 'console.info'],
        passes: 2
      },
      mangle: {
        safari10: true
      },
      format: {
        comments: false
      }
    }
  }
});

動態路由與組件懶載入

// src/router/LazyRoutes.tsx
import { lazy, Suspense } from 'react';
import { Route, Routes } from 'react-router-dom';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';

// 使用 React.lazy 實現路由級別的程式碼分割
const Dashboard = lazy(() => import('@/pages/Dashboard'));
const Members = lazy(() => import('@/pages/Members'));
const Courses = lazy(() => import('@/pages/Courses'));
const Analytics = lazy(() => import('@/pages/Analytics'));
const Settings = lazy(() => import('@/pages/Settings'));

// 預載入組件工廠
const withPreload = <T extends object>(
  importFn: () => Promise<{ default: React.ComponentType<T> }>
) => {
  const Component = lazy(importFn);

  // 提供預載入方法
  Component.preload = importFn;

  return Component;
};

// 高優先級路由立即載入
const DashboardWithPreload = withPreload(() => import('@/pages/Dashboard'));
const MembersWithPreload = withPreload(() => import('@/pages/Members'));

export const AppRoutes = () => {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <Routes>
        <Route path="/" element={<DashboardWithPreload />} />
        <Route path="/members" element={<MembersWithPreload />} />
        <Route path="/courses" element={<Courses />} />
        <Route path="/analytics" element={<Analytics />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
};

// 路由預載入 Hook
export const useRoutePreloader = () => {
  const preloadRoute = (routeName: string) => {
    switch (routeName) {
      case 'members':
        MembersWithPreload.preload();
        break;
      case 'dashboard':
        DashboardWithPreload.preload();
        break;
    }
  };

  return { preloadRoute };
};

資源預載與 CDN 優化

智能資源預載策略

// src/utils/ResourcePreloader.ts
interface PreloadResource {
  href: string;
  as: 'script' | 'style' | 'font' | 'image';
  type?: string;
  crossorigin?: 'anonymous' | 'use-credentials';
  integrity?: string;
}

export class ResourcePreloader {
  private static instance: ResourcePreloader;
  private preloadedResources = new Set<string>();
  private observer: IntersectionObserver | null = null;

  static getInstance(): ResourcePreloader {
    if (!ResourcePreloader.instance) {
      ResourcePreloader.instance = new ResourcePreloader();
    }
    return ResourcePreloader.instance;
  }

  // 關鍵資源預載入
  preloadCriticalResources(): void {
    const criticalResources: PreloadResource[] = [
      {
        href: '/fonts/inter-var.woff2',
        as: 'font',
        type: 'font/woff2',
        crossorigin: 'anonymous'
      },
      {
        href: '/js/vendor-react.js',
        as: 'script'
      },
      {
        href: '/css/app.css',
        as: 'style'
      }
    ];

    criticalResources.forEach(resource => {
      this.preloadResource(resource);
    });
  }

  // 路由切換時預載入
  preloadRouteResources(routeName: string): void {
    const routeResources: Record<string, PreloadResource[]> = {
      members: [
        { href: '/js/members-chunk.js', as: 'script' },
        { href: '/api/members?limit=50', as: 'fetch' }
      ],
      courses: [
        { href: '/js/courses-chunk.js', as: 'script' },
        { href: '/js/vendor-charts.js', as: 'script' }
      ]
    };

    const resources = routeResources[routeName];
    if (resources) {
      resources.forEach(resource => this.preloadResource(resource));
    }
  }

  // 視窗內圖片懶載入
  lazyLoadImages(): void {
    if ('IntersectionObserver' in window) {
      this.observer = new IntersectionObserver(
        (entries) => {
          entries.forEach(entry => {
            if (entry.isIntersecting) {
              const img = entry.target as HTMLImageElement;
              const src = img.dataset.src;

              if (src) {
                img.src = src;
                img.removeAttribute('data-src');
                this.observer?.unobserve(img);
              }
            }
          });
        },
        {
          rootMargin: '50px 0px',
          threshold: 0.01
        }
      );

      document.querySelectorAll('img[data-src]').forEach(img => {
        this.observer?.observe(img);
      });
    }
  }

  private preloadResource(resource: PreloadResource): void {
    if (this.preloadedResources.has(resource.href)) {
      return;
    }

    const link = document.createElement('link');
    link.rel = 'preload';
    link.href = resource.href;
    link.as = resource.as;

    if (resource.type) link.type = resource.type;
    if (resource.crossorigin) link.crossOrigin = resource.crossorigin;
    if (resource.integrity) link.integrity = resource.integrity;

    document.head.appendChild(link);
    this.preloadedResources.add(resource.href);
  }
}

智能 DNS 預解析與連線預熱

// src/utils/ConnectionOptimizer.ts
export class ConnectionOptimizer {
  private static readonly EXTERNAL_DOMAINS = [
    'api.mitake.com.tw',
    'fonts.googleapis.com',
    'cdnjs.cloudflare.com'
  ];

  static initializeConnections(): void {
    // DNS 預解析
    this.EXTERNAL_DOMAINS.forEach(domain => {
      this.addDNSPrefetch(domain);
    });

    // 重要資源域名預連線
    this.addPreconnect('https://fonts.googleapis.com');
    this.addPreconnect('https://api.mitake.com.tw');

    // 次要資源域名 DNS 預解析
    this.addDNSPrefetch('cdnjs.cloudflare.com');
    this.addDNSPrefetch('www.google-analytics.com');
  }

  private static addDNSPrefetch(hostname: string): void {
    const link = document.createElement('link');
    link.rel = 'dns-prefetch';
    link.href = `//${hostname}`;
    document.head.appendChild(link);
  }

  private static addPreconnect(url: string): void {
    const link = document.createElement('link');
    link.rel = 'preconnect';
    link.href = url;
    link.crossOrigin = 'anonymous';
    document.head.appendChild(link);
  }

  // 模組預載入
  static preloadModules(): void {
    if ('modulepreload' in HTMLLinkElement.prototype) {
      const criticalModules = [
        '/src/main.tsx',
        '/src/App.tsx',
        '/src/router/index.tsx'
      ];

      criticalModules.forEach(module => {
        const link = document.createElement('link');
        link.rel = 'modulepreload';
        link.href = module;
        document.head.appendChild(link);
      });
    }
  }
}

壓縮與編碼優化

壓縮算法深度比較與策略選擇

現代 Web 應用中,選擇正確的壓縮算法對效能影響巨大。讓我們深入比較主流壓縮算法:

壓縮算法比較表

算法 壓縮率 壓縮速度 解壓速度 瀏覽器支援 CPU 消耗 適用場景
Gzip 70-80% 99.9% 通用,相容性要求高
Brotli 75-85% 中等 95%+ 中等 現代瀏覽器,追求最佳壓縮
Zstd 80-90% 極快 有限 實驗性,未來趨勢
LZ4 60-70% 極快 極快 極低 即時壓縮需求

壓縮配置最佳實務

// vite.config.ts - 多層壓縮策略
import { defineConfig } from 'vite';
import { compression } from 'vite-plugin-compression';

export default defineConfig({
  plugins: [
    // Gzip 壓縮 - 相容性佳,作為後備方案
    compression({
      algorithm: 'gzip',
      ext: '.gz',
      threshold: 1024,
      compressionOptions: {
        level: 9,        // 最高壓縮級別
        memLevel: 9,     // 記憶體使用級別
        strategy: 0,     // 預設策略,適合大多數情況
        windowBits: 15,  // 窗口大小,影響壓縮率與記憶體使用
        chunkSize: 16384 // 處理塊大小
      },
      deleteOriginFile: false,
      filter: /\.(js|css|json|txt|html|ico|svg)(\?.*)?$/i
    }),

    // Brotli 壓縮 - 更佳壓縮率,現代瀏覽器首選
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      threshold: 1024,
      compressionOptions: {
        level: 11,           // Brotli 壓縮級別 (0-11)
        mode: 0,             // 通用模式 (0: 通用, 1: 文字, 2: 字型)
        windowBits: 22,      // 窗口大小,影響記憶體使用
        quality: 11,         // 品質級別,影響壓縮時間與率
        lgwin: 22,           // LZ77 窗口大小的對數
        lgblock: 0           // 輸入塊大小的對數 (0 為自動)
      },
      deleteOriginFile: false,
      filter: /\.(js|css|json|txt|html|ico|svg)(\?.*)?$/i
    }),

    // 自訂壓縮策略
    {
      name: 'adaptive-compression',
      generateBundle(options, bundle) {
        Object.keys(bundle).forEach(fileName => {
          const file = bundle[fileName];
          if (file.type === 'chunk' || file.type === 'asset') {
            const content = typeof file.source === 'string'
              ? file.source
              : new TextDecoder().decode(file.source);

            // 根據檔案類型選擇最佳壓縮策略
            if (fileName.endsWith('.js')) {
              // JavaScript 檔案:優先 Brotli
              this.adaptiveCompression(content, fileName, 'javascript');
            } else if (fileName.endsWith('.css')) {
              // CSS 檔案:Brotli 效果最佳
              this.adaptiveCompression(content, fileName, 'css');
            } else if (fileName.endsWith('.json')) {
              // JSON 檔案:Gzip 已足夠
              this.adaptiveCompression(content, fileName, 'json');
            }
          }
        });
      }
    }
  ]
});

壓縮效能測試與監控

// src/utils/CompressionAnalyzer.ts
interface CompressionResult {
  algorithm: string;
  originalSize: number;
  compressedSize: number;
  compressionRatio: number;
  compressionTime: number;
  decompressionTime: number;
}

export class CompressionAnalyzer {
  static async analyzeCompressionEfficiency(
    content: string,
    algorithms: string[] = ['gzip', 'brotli']
  ): Promise<CompressionResult[]> {
    const results: CompressionResult[] = [];
    const originalSize = new Blob([content]).size;

    for (const algorithm of algorithms) {
      const result = await this.testCompression(content, algorithm, originalSize);
      results.push(result);
    }

    return results.sort((a, b) => b.compressionRatio - a.compressionRatio);
  }

  private static async testCompression(
    content: string,
    algorithm: string,
    originalSize: number
  ): Promise<CompressionResult> {
    const encoder = new TextEncoder();
    const data = encoder.encode(content);

    // 壓縮測試
    const compressStart = performance.now();
    let compressedData: Uint8Array;

    switch (algorithm) {
      case 'gzip':
        compressedData = await this.gzipCompress(data);
        break;
      case 'brotli':
        compressedData = await this.brotliCompress(data);
        break;
      default:
        throw new Error(`Unsupported algorithm: ${algorithm}`);
    }

    const compressEnd = performance.now();
    const compressionTime = compressEnd - compressStart;

    // 解壓測試
    const decompressStart = performance.now();
    await this.decompress(compressedData, algorithm);
    const decompressEnd = performance.now();
    const decompressionTime = decompressEnd - decompressStart;

    const compressedSize = compressedData.length;
    const compressionRatio = ((originalSize - compressedSize) / originalSize) * 100;

    return {
      algorithm,
      originalSize,
      compressedSize,
      compressionRatio: Math.round(compressionRatio * 100) / 100,
      compressionTime: Math.round(compressionTime * 100) / 100,
      decompressionTime: Math.round(decompressionTime * 100) / 100
    };
  }

  private static async gzipCompress(data: Uint8Array): Promise<Uint8Array> {
    const stream = new CompressionStream('gzip');
    const writer = stream.writable.getWriter();
    writer.write(data);
    writer.close();

    const chunks: Uint8Array[] = [];
    const reader = stream.readable.getReader();

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      chunks.push(value);
    }

    const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
    const result = new Uint8Array(totalLength);
    let offset = 0;

    chunks.forEach(chunk => {
      result.set(chunk, offset);
      offset += chunk.length;
    });

    return result;
  }

  private static async brotliCompress(data: Uint8Array): Promise<Uint8Array> {
    // 使用 Web Streams API 的 Brotli 壓縮
    if ('CompressionStream' in window) {
      const stream = new CompressionStream('deflate-raw');
      // Brotli 在某些瀏覽器中可能不直接支援,這裡使用 deflate 作為示例
      return this.processStream(stream, data);
    }

    // 降級到 Web Workers 中的 Brotli 實作
    return new Promise((resolve, reject) => {
      const worker = new Worker('/workers/brotli-worker.js');
      worker.postMessage({ data, action: 'compress' });
      worker.onmessage = (e) => {
        if (e.data.error) {
          reject(new Error(e.data.error));
        } else {
          resolve(e.data.result);
        }
        worker.terminate();
      };
    });
  }

  private static async decompress(
    data: Uint8Array,
    algorithm: string
  ): Promise<Uint8Array> {
    if ('DecompressionStream' in window) {
      const stream = new DecompressionStream(algorithm === 'gzip' ? 'gzip' : 'deflate-raw');
      return this.processStream(stream, data);
    }

    throw new Error('Decompression not supported in this environment');
  }

  private static async processStream(
    stream: CompressionStream | DecompressionStream,
    data: Uint8Array
  ): Promise<Uint8Array> {
    const writer = stream.writable.getWriter();
    writer.write(data);
    writer.close();

    const chunks: Uint8Array[] = [];
    const reader = stream.readable.getReader();

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      chunks.push(value);
    }

    const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
    const result = new Uint8Array(totalLength);
    let offset = 0;

    chunks.forEach(chunk => {
      result.set(chunk, offset);
      offset += chunk.length;
    });

    return result;
  }

  // 即時壓縮效能監控
  static startCompressionMonitoring(): void {
    if ('PerformanceObserver' in window) {
      const observer = new PerformanceObserver((list) => {
        const entries = list.getEntries();
        entries.forEach(entry => {
          if (entry.name.includes('compression')) {
            console.log(`Compression ${entry.name}: ${entry.duration.toFixed(2)}ms`);
          }
        });
      });

      observer.observe({ entryTypes: ['measure'] });
    }
  }
}

Content-Encoding 協商策略

// src/utils/ContentEncodingNegotiator.ts
export class ContentEncodingNegotiator {
  private static readonly COMPRESSION_PREFERENCE = ['br', 'gzip', 'deflate'];

  static getBestEncoding(acceptEncoding?: string): string {
    if (!acceptEncoding) {
      return 'gzip'; // 默認降級
    }

    const supportedEncodings = acceptEncoding
      .split(',')
      .map(encoding => encoding.trim().toLowerCase());

    // 根據偏好順序選擇最佳編碼
    for (const preferred of this.COMPRESSION_PREFERENCE) {
      if (supportedEncodings.includes(preferred)) {
        return preferred;
      }
    }

    return 'identity'; // 無壓縮
  }

  // 檢測瀏覽器壓縮支援能力
  static detectCompressionSupport(): Promise<{
    gzip: boolean;
    brotli: boolean;
    zstd: boolean;
  }> {
    return new Promise(async (resolve) => {
      const support = {
        gzip: false,
        brotli: false,
        zstd: false
      };

      // 測試 Gzip 支援
      try {
        const gzipStream = new CompressionStream('gzip');
        support.gzip = !!gzipStream;
      } catch (e) {
        support.gzip = false;
      }

      // 測試 Brotli 支援(通過檢測 Accept-Encoding header)
      try {
        const response = await fetch('/api/compression-test', {
          headers: {
            'Accept-Encoding': 'br'
          }
        });
        support.brotli = response.headers.get('content-encoding') === 'br';
      } catch (e) {
        support.brotli = false;
      }

      // 測試 Zstd 支援(實驗性)
      try {
        const response = await fetch('/api/compression-test', {
          headers: {
            'Accept-Encoding': 'zstd'
          }
        });
        support.zstd = response.headers.get('content-encoding') === 'zstd';
      } catch (e) {
        support.zstd = false;
      }

      resolve(support);
    });
  }

  // 動態選擇最佳壓縮策略
  static async getOptimalCompressionStrategy(
    contentType: string,
    contentSize: number
  ): Promise<{
    algorithm: string;
    level: number;
    shouldCompress: boolean;
  }> {
    // 小檔案不壓縮,避免額外開銷
    if (contentSize < 1024) {
      return {
        algorithm: 'identity',
        level: 0,
        shouldCompress: false
      };
    }

    const support = await this.detectCompressionSupport();

    // 根據內容類型選擇策略
    switch (contentType) {
      case 'application/javascript':
      case 'text/javascript':
        return {
          algorithm: support.brotli ? 'br' : 'gzip',
          level: support.brotli ? 11 : 9,
          shouldCompress: true
        };

      case 'text/css':
        return {
          algorithm: support.brotli ? 'br' : 'gzip',
          level: support.brotli ? 11 : 9,
          shouldCompress: true
        };

      case 'application/json':
        return {
          algorithm: support.gzip ? 'gzip' : 'deflate',
          level: 6, // JSON 壓縮不需要最高級別
          shouldCompress: true
        };

      case 'text/html':
        return {
          algorithm: support.brotli ? 'br' : 'gzip',
          level: support.brotli ? 8 : 6, // HTML 壓縮適中級別即可
          shouldCompress: true
        };

      default:
        return {
          algorithm: 'gzip',
          level: 6,
          shouldCompress: contentSize > 2048
        };
    }
  }
}

圖片優化與 WebP 轉換

// src/components/OptimizedImage.tsx
import { useState, useEffect } from 'react';

interface OptimizedImageProps {
  src: string;
  alt: string;
  width?: number;
  height?: number;
  className?: string;
  loading?: 'lazy' | 'eager';
  priority?: boolean;
}

export const OptimizedImage: React.FC<OptimizedImageProps> = ({
  src,
  alt,
  width,
  height,
  className = '',
  loading = 'lazy',
  priority = false
}) => {
  const [imageSrc, setImageSrc] = useState<string>('');
  const [isLoaded, setIsLoaded] = useState(false);

  useEffect(() => {
    // 檢測 WebP 支援
    const supportsWebP = () => {
      const canvas = document.createElement('canvas');
      canvas.width = 1;
      canvas.height = 1;
      return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0;
    };

    // 智能圖片格式選擇
    const getOptimizedSrc = (originalSrc: string): string => {
      const isWebPSupported = supportsWebP();
      const baseUrl = originalSrc.replace(/\.(jpg|jpeg|png)$/i, '');

      if (isWebPSupported) {
        return `${baseUrl}.webp`;
      }

      return originalSrc;
    };

    // 響應式圖片大小
    const getResponsiveSrc = (src: string, width?: number): string => {
      if (!width) return src;

      const devicePixelRatio = window.devicePixelRatio || 1;
      const targetWidth = Math.round(width * devicePixelRatio);

      // 假設有圖片 CDN 服務
      return `${src}?w=${targetWidth}&q=80&f=auto`;
    };

    const optimizedSrc = getResponsiveSrc(getOptimizedSrc(src), width);
    setImageSrc(optimizedSrc);

    // 優先載入重要圖片
    if (priority) {
      const img = new Image();
      img.onload = () => setIsLoaded(true);
      img.src = optimizedSrc;
    }
  }, [src, width, priority]);

  return (
    <picture>
      <source srcSet={`${imageSrc}?format=webp`} type="image/webp" />
      <source srcSet={`${imageSrc}?format=avif`} type="image/avif" />
      <img
        src={imageSrc}
        alt={alt}
        width={width}
        height={height}
        className={`${className} ${isLoaded ? 'loaded' : 'loading'}`}
        loading={loading}
        decoding="async"
        onLoad={() => setIsLoaded(true)}
      />
    </picture>
  );
};

關鍵渲染路徑優化

Critical CSS 提取與內聯

// build/critical-css-plugin.ts
import { Plugin } from 'vite';
import { generateCriticalCSS } from '@vitejs/plugin-critical';

export const criticalCSSPlugin = (): Plugin => {
  return {
    name: 'critical-css',
    generateBundle(options, bundle) {
      // 提取首屏關鍵 CSS
      const criticalCSS = generateCriticalCSS({
        inline: true,
        minify: true,
        extract: true,
        dimensions: [
          { width: 375, height: 667 },   // Mobile
          { width: 1200, height: 800 }   // Desktop
        ]
      });

      // 內聯關鍵 CSS 到 HTML
      Object.keys(bundle).forEach(fileName => {
        const file = bundle[fileName];
        if (file.type === 'asset' && fileName.endsWith('.html')) {
          file.source = file.source.toString().replace(
            '</head>',
            `<style>${criticalCSS}</style></head>`
          );
        }
      });
    }
  };
};

React 渲染效能優化

// src/hooks/usePerformanceOptimization.ts
import { useCallback, useMemo, useRef, useEffect } from 'react';

export const usePerformanceOptimization = () => {
  const renderTimeRef = useRef<number>(0);

  // 節流 Hook
  const useThrottle = <T extends (...args: any[]) => any>(
    callback: T,
    delay: number
  ): T => {
    const lastRun = useRef(Date.now());

    return useCallback(
      ((...args) => {
        if (Date.now() - lastRun.current >= delay) {
          callback(...args);
          lastRun.current = Date.now();
        }
      }) as T,
      [callback, delay]
    );
  };

  // 防抖 Hook
  const useDebounce = <T extends (...args: any[]) => any>(
    callback: T,
    delay: number
  ): T => {
    const timeoutRef = useRef<NodeJS.Timeout>();

    return useCallback(
      ((...args) => {
        clearTimeout(timeoutRef.current);
        timeoutRef.current = setTimeout(() => callback(...args), delay);
      }) as T,
      [callback, delay]
    );
  };

  // 虛擬化列表 Hook
  const useVirtualList = (
    items: any[],
    itemHeight: number,
    containerHeight: number
  ) => {
    const [scrollTop, setScrollTop] = useState(0);

    const visibleItems = useMemo(() => {
      const startIndex = Math.floor(scrollTop / itemHeight);
      const endIndex = Math.min(
        startIndex + Math.ceil(containerHeight / itemHeight) + 1,
        items.length
      );

      return items.slice(startIndex, endIndex).map((item, index) => ({
        ...item,
        index: startIndex + index,
        top: (startIndex + index) * itemHeight
      }));
    }, [items, itemHeight, containerHeight, scrollTop]);

    return {
      visibleItems,
      totalHeight: items.length * itemHeight,
      setScrollTop
    };
  };

  // 效能監控
  const measureRenderTime = (componentName: string) => {
    useEffect(() => {
      const startTime = performance.now();
      renderTimeRef.current = startTime;

      return () => {
        const endTime = performance.now();
        const renderTime = endTime - startTime;

        if (renderTime > 16) { // 超過一幀的時間
          console.warn(`${componentName} render took ${renderTime.toFixed(2)}ms`);
        }
      };
    });
  };

  return {
    useThrottle,
    useDebounce,
    useVirtualList,
    measureRenderTime
  };
};

Web Vitals 監控與優化

核心 Web Vitals 追蹤

// src/utils/WebVitalsMonitor.ts
import { getCLS, getFID, getFCP, getLCP, getTTFB, Metric } from 'web-vitals';

export class WebVitalsMonitor {
  private static metrics: Map<string, number> = new Map();

  static initialize(): void {
    // Largest Contentful Paint
    getLCP((metric: Metric) => {
      this.reportMetric('LCP', metric.value);
      this.optimizeLCP(metric.value);
    });

    // First Input Delay
    getFID((metric: Metric) => {
      this.reportMetric('FID', metric.value);
      this.optimizeFID(metric.value);
    });

    // Cumulative Layout Shift
    getCLS((metric: Metric) => {
      this.reportMetric('CLS', metric.value);
      this.optimizeCLS(metric.value);
    });

    // First Contentful Paint
    getFCP((metric: Metric) => {
      this.reportMetric('FCP', metric.value);
    });

    // Time to First Byte
    getTTFB((metric: Metric) => {
      this.reportMetric('TTFB', metric.value);
    });
  }

  private static reportMetric(name: string, value: number): void {
    this.metrics.set(name, value);

    // 發送到分析服務
    if (typeof gtag !== 'undefined') {
      gtag('event', name, {
        event_category: 'Web Vitals',
        value: Math.round(value),
        non_interaction: true,
      });
    }

    // 控制台警告
    const thresholds = {
      LCP: 2500,
      FID: 100,
      CLS: 0.1,
      FCP: 1800,
      TTFB: 800
    };

    if (value > thresholds[name as keyof typeof thresholds]) {
      console.warn(`${name} is ${value}ms (threshold: ${thresholds[name as keyof typeof thresholds]}ms)`);
    }
  }

  private static optimizeLCP(value: number): void {
    if (value > 2500) {
      // 動態載入非關鍵資源
      this.deferNonCriticalResources();

      // 優化圖片載入
      this.optimizeImageLoading();
    }
  }

  private static optimizeFID(value: number): void {
    if (value > 100) {
      // 分割長時間執行的任務
      this.scheduleTaskChunking();
    }
  }

  private static optimizeCLS(value: number): void {
    if (value > 0.1) {
      // 為動態內容預留空間
      this.preventLayoutShifts();
    }
  }

  private static deferNonCriticalResources(): void {
    // 延遲載入第三方腳本
    const scripts = document.querySelectorAll('script[data-defer]');
    scripts.forEach(script => {
      setTimeout(() => {
        const newScript = document.createElement('script');
        newScript.src = script.getAttribute('data-defer')!;
        newScript.async = true;
        document.head.appendChild(newScript);
      }, 3000);
    });
  }

  private static optimizeImageLoading(): void {
    // 實施漸進式圖片載入
    const images = document.querySelectorAll('img:not([loading])');
    images.forEach(img => {
      img.setAttribute('loading', 'lazy');
      img.setAttribute('decoding', 'async');
    });
  }

  private static scheduleTaskChunking(): void {
    // 使用 scheduler API 分割任務
    if ('scheduler' in window && 'postTask' in window.scheduler) {
      // 現代瀏覽器使用 Scheduler API
      window.scheduler.postTask(() => {
        // 執行低優先級任務
      }, { priority: 'background' });
    } else {
      // 降級到 requestIdleCallback
      if ('requestIdleCallback' in window) {
        requestIdleCallback(() => {
          // 執行低優先級任務
        });
      }
    }
  }

  private static preventLayoutShifts(): void {
    // 為動態內容設置 aspect-ratio
    const dynamicContainers = document.querySelectorAll('[data-dynamic-content]');
    dynamicContainers.forEach(container => {
      const element = container as HTMLElement;
      if (!element.style.aspectRatio) {
        element.style.aspectRatio = '16/9'; // 預設比例
      }
    });
  }

  static getMetrics(): Map<string, number> {
    return new Map(this.metrics);
  }
}

整合應用初始化

// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { ResourcePreloader } from './utils/ResourcePreloader';
import { ConnectionOptimizer } from './utils/ConnectionOptimizer';
import { WebVitalsMonitor } from './utils/WebVitalsMonitor';

// 效能優化初始化
const initializePerformanceOptimizations = () => {
  // 連線優化
  ConnectionOptimizer.initializeConnections();
  ConnectionOptimizer.preloadModules();

  // 資源預載入
  const preloader = ResourcePreloader.getInstance();
  preloader.preloadCriticalResources();
  preloader.lazyLoadImages();

  // Web Vitals 監控
  WebVitalsMonitor.initialize();
};

// 立即執行效能優化
initializePerformanceOptimizations();

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
);

今日總結

今天我們實現了全面的前端效能優化策略:

  1. 打包優化:智能程式碼分割、Tree Shaking、壓縮優化
  2. 資源預載:模組預載入、DNS 預解析、連線預熱
  3. 圖片優化:WebP 轉換、響應式圖片、懶載入
  4. 渲染優化:Critical CSS、虛擬化列表、防抖節流
  5. 監控體系:Web Vitals 追蹤、自動化優化建議

這些優化措施將顯著提升應用載入速度與互動響應性,為使用者提供流暢的 SaaS 體驗。

明天我們將深入後端效能優化,包括資料庫查詢優化、快取策略、以及伺服器端效能調校。

參考資源


上一篇
Day 17: 30天打造SaaS產品前端篇-微服務架構升級與 WebSocket 通信最佳化實作
系列文
30 天製作工作室 SaaS 產品 (前端篇)18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言