Notes
← All notes
·frontend·12 min

從 K-Line 專案看 Web Performance 優化:Lighthouse 91 分到 100 分的完整過程

從 Lighthouse 91 分到 100 分的完整紀錄:Route-level code splitting、Vendor chunking、Preload + fetchpriority、Google Fonts self-hosted、WebP 圖片優化、analytics chunk 拆離。

#web-performance#lighthouse#code-splitting#frontend

我最近用 Lighthouse 掃了自己的 side project K-Line Prediction,拿到 Performance 91 分,最後把分數推到 100 分。這篇文章從這個專案出發,解釋從 91 分到 100 分的完整過程:幾個我實際用上的優化技術,以及背後的瀏覽器運作原理。


先看 Lighthouse 的關鍵指標

在講優化之前,先理解 Lighthouse 在量什麼。

LCP(Largest Contentful Paint)

頁面載入過程中,最大的可見元素完成渲染的時間點。Google 用它衡量「使用者覺得頁面載好了」的感受。

分數評級
< 2.5sGood ✅
2.5s ~ 4sNeeds Improvement ⚠️
> 4sPoor ❌

LCP element 不是固定的,是瀏覽器自己算的。對首頁來說通常是最上方的大圖(hero image)或最大的文字區塊。

CLS(Cumulative Layout Shift)

頁面載入過程中,版面跳動的累積程度。數字代表元素移動距離 × 影響畫面比例,0 = 完全沒有跳動,目標 < 0.1。

常見造成 CLS 的情況:

<!-- 壞:瀏覽器不知道要預留多少空間,圖片載入後把下方內容往下推 -->
<img src="hero.png" />

<!-- 好:瀏覽器預先保留空間 -->
<img src="hero.png" width="1200" height="630" />

TBT(Total Blocking Time)

FCP 到 TTI 之間,主執行緒被阻塞超過 50ms 的總時間。主要反映 JavaScript 執行量,TBT = 0ms 代表沒有長時間阻塞主執行緒。


K-Line 的 Lighthouse baseline

Performance score: 91
  FCP:  2.1s  (0.82)
  LCP:  3.1s  (0.74)
  TBT:  0ms   (1.0)  ✅
  CLS:  0     (1.0)  ✅
  SI:   3.1s  (0.93)
  TTI:  3.1s  (0.95)

TBT 和 CLS 都是滿分,拖住分數的是 LCP 3.1s。以下逐一說明為什麼分數還是能到 91。


技術一:Route-level Code Splitting

最有效的優化。核心概念:訪客在哪個頁面,才下載那個頁面的 JavaScript。

K-Line 有 6 個 routes(/, /app, /about, /diary, /business-logic, /backtest),如果把所有頁面的 code 打包成一個檔案,訪客看首頁就要下載 lightweight-charts(160KB,只有 /app 用)和 react-markdown(115KB,只有 /business-logic 用),完全浪費。

做法是在 main.tsx 用 React 的 lazy() + Suspense

import React, { Suspense, lazy } from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'

const HomePage          = lazy(() => import('./pages/HomePage'))
const AppPage           = lazy(() => import('./AppPage'))
const AboutPage         = lazy(() => import('./pages/AboutPage'))
const DiaryPage         = lazy(() => import('./pages/DiaryPage'))
const BusinessLogicPage = lazy(() => import('./pages/BusinessLogicPage'))
const BacktestPage      = lazy(() => import('./pages/BacktestPage'))

<Suspense fallback={<LoadingSpinner />}>
  <Routes>
    <Route path="/"               element={<HomePage />} />
    <Route path="/app"            element={<AppPage />} />
    <Route path="/about"          element={<AboutPage />} />
    <Route path="/diary"          element={<DiaryPage />} />
    <Route path="/business-logic" element={<BusinessLogicPage />} />
    <Route path="/backtest"       element={<BacktestPage />} />
  </Routes>
</Suspense>

React.lazy() 告訴 bundler「這個 import 是動態的」,Vite(底層用 Rollup)就會自動把每個 page 拆成獨立的 .js 檔案。使用者導航到 /app 才下載 page-apppage.js,不是一開始就全部下載。


技術二:Vendor Chunking 分層

Route-level splitting 把你自己的 code 拆開了,但 node_modules 裡的第三方 library 呢?

Rollup 預設會把所有 import 的東西打包進 bundle,包含 node_modules。瀏覽器不認識 import 'react',只懂 URL,所以 Rollup 要把你的 code 加上所有依賴一起輸出成瀏覽器可以執行的 .js 檔案。

如果不處理,所有 library 可能塞進同一個 chunk,造成首頁載入不必要的依賴。解法是在 vite.config.tsmanualChunks 手動控制:

build: {
  rollupOptions: {
    output: {
      manualChunks(id) {
        if (id.includes('react-markdown') || id.includes('remark') || ...) {
          return 'vendor-markdown'   // 115KB,只有 /business-logic 用
        }
        if (id.includes('lightweight-charts')) {
          return 'vendor-charts'     // 160KB,只有 /app 用
        }
        if (id.includes('node_modules/react/') || ...) {
          return 'vendor-react'      // 176KB,所有頁面都用
        }
      }
    }
  }
}

分層結果:

Chunk大小哪些 route 下載
vendor-react176KB全部
vendor-charts160KB只有 /app
vendor-markdown115KB只有 /business-logic
page-homepage75KB只有 /

訪客看首頁只下載 vendor-react + page-homepage,總計約 251KB,而不是把所有東西都抓下來。分層還有另一個好處:快取命中率vendor-react 的版本沒變,hash 就不變,重複訪客直接從 cache 讀,不用重新下載。


技術三:Preload + fetchpriority="high" for LCP Image

LCP element 是首頁的 hero image(hero-shot.png,68KB)。如果瀏覽器要等 JS 執行後才發現這張圖,LCP 會很晚。

解法:在 <head> 裡提前宣告:

<link rel="preload" as="image" href="/hero-shot.png" fetchpriority="high" />

兩件事同時做到:

  • preload:瀏覽器解析 HTML 時立刻發請求抓這張圖,不等 JS bundle 下載完才發現它
  • fetchpriority="high":把這個請求的優先級提升到最高,不跟其他圖片排隊

沒有這兩個屬性,hero image 的請求可能在 JS 解析完之後才發出,LCP 輕易超過 4s。


技術四:Google Fonts 的 preconnect + crossorigin

Google Fonts 的載入分兩段:

1. 瀏覽器請求 CSS    →  fonts.googleapis.com  →  回傳 @font-face 定義
2. 瀏覽器請求字型檔  →  fonts.gstatic.com     →  回傳 .woff2 字型檔

兩個不同的 server,要各自建立 TCP 連線(DNS lookup + TCP handshake + TLS)。preconnect 讓這個連線在真正需要之前就建好,省掉等待時間:

<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

為什麼 gstatic.com 要加 crossorigingoogleapis.com 不用?

Font 檔(.woff2)是跨域資源,瀏覽器用 CORS 模式發請求。CORS 連線和 non-CORS 連線即使目標是同一個 host,瀏覽器也視為兩條不同的連線。如果沒有加 crossorigin,preconnect 建的是 non-CORS 連線,後來抓 font 時用的是 CORS 連線,預建的連線沒用上,重新建一條,白做工。加了 crossorigin 之後兩條連線才對得起來。

googleapis.com 給的是 CSS stylesheet,不是 CORS 請求,所以不需要。


技術五:type="module" script 不會 block HTML parsing

<script type="module" src="/src/main.tsx"></script>

瀏覽器解析 HTML 是從上到下逐行進行的。遇到一般 <script> 時,瀏覽器停下來,等 JS 下載 + 執行完,才繼續解析後面的 HTML:

<script src="/main.js"></script>
<!-- 這段 HTML 在 script 執行完之前都看不到,FCP 被推遲 -->
<div>頁面內容</div>

type="module" 天生等同於加了 defer:瀏覽器不停下來,繼續解析 HTML,背景下載 JS,等 HTML 全部解析完再執行 JS。頁面結構更早可以被渲染,FCP 更快。


後來的優化:從 91 到 100

這五個技術讓首頁在沒有 SSR、純 SPA 的情況下拿到 91 分。Lighthouse 的 Opportunities 區塊還剩三項,後來都實際動手解決了。

1. Hero Image PNG → WebP

hero-shot.png(68KB)是 LCP element。轉成 WebP 後大小降到 28KB(-59%),瀏覽器下載更快,LCP 直接改善。

<!-- index.html:preload 指向 WebP -->
<link rel="preload" as="image" href="/hero-shot.webp" fetchpriority="high" />
<!-- HeroSection.tsx:<picture> 提供 WebP + PNG fallback -->
<picture>
  <source srcSet="/hero-shot.webp" type="image/webp" />
  <img src="/hero-shot.png" alt="..." width={1280} height={720}
       loading="eager" fetchPriority="high" decoding="async" />
</picture>

<picture> + <source type="image/webp"> 讓支援 WebP 的瀏覽器(>96%)自動使用,其餘 fallback 到 PNG,不需要任何 JS。

LCP:3.1s → 0.8s

2. Google Fonts self-hosted,移除 render-blocking stylesheet

preconnect 只解決了建連線的等待,Google Fonts 的 <link rel="stylesheet"> 本身仍是 render-blocking——CSS 下載完之前頁面不會渲染,FCP 被推遲約 450ms。

解法是改用 @fontsource self-hosted fonts,fonts 打進 Vite build output 由 Firebase CDN 提供,完全移除外部 stylesheet 依賴。

做的過程中還發現:原本的 Google Fonts URL 請求了四個 font family,但 IBM Plex Mono 和 Newsreader 在幾個月前的設計改版中已棄用,瀏覽器每次都在下載沒有用到的 fonts。遷移前先 grep 確認哪些 family 還在使用:

grep -r "font-family.*IBM Plex Mono" src/ tailwind.config.js
# 零命中 → 不 import
/* src/fonts.css — 只留實際用到的 */
@import '@fontsource/geist-mono/400.css';
@import '@fontsource/geist-mono/700.css';

@fontsource 預設 font-display: swap,文字先用 system fallback 渲染,Geist Mono 載完後換上,沒有 FOIT(Flash of Invisible Text)。

FCP:2.1s → 0.4s

3. Analytics Chunk 拆離,消除首頁多餘的 eager preload

Lighthouse 標示 ~64KB unused JS。實際原因不是 tree-shaking 沒發揮,而是 dependency chain 讓不必要的 chunk 進了 index.html 的 modulepreload 名單:

ConsentBanner(全域 shell)
  → imports analytics.ts
    → Rollup 把 analytics.ts 合併進 page-apppage
      → page-apppage depends on vendor-charts
        → 兩個 chunk 都進了 modulepreload 名單
          → 首頁訪客 eager preload 了 251KB 跟首頁完全無關的 JS

解法:在 vite.config.tsanalytics.ts 拆成獨立 chunk,斷開依賴鏈:

if (id.includes('/frontend/src/utils/analytics')) {
  return 'utils-analytics'
}
狀態Eager preload 的 chunkRaw 大小
Beforevendor-react + vendor-charts + page-apppage~427KB
Aftervendor-react + utils-analytics~181KB

首頁 eager preload 減少 251KBvendor-chartspage-apppage 回到真正 lazy load,只有進入 /app 才下載。


小結

技術主要改善指標結果
Route-level code splittingTBT、TTIbaseline
Vendor chunking 分層初始 payload 大小baseline
Preload + fetchpriorityLCPbaseline
type="module"FCPbaseline
preconnect + crossoriginFCP被 self-hosted fonts 取代
Hero image WebP(-59%)LCP3.1s → 0.8s
Google Fonts self-hostedFCP、render-blocking2.1s → 0.4s
Analytics chunk 拆離首頁 eager preload-251KB

Lighthouse 最終:99–100 分(CLI ±1 variance 屬正常)