從 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 拆離。
我最近用 Lighthouse 掃了自己的 side project K-Line Prediction,拿到 Performance 91 分,最後把分數推到 100 分。這篇文章從這個專案出發,解釋從 91 分到 100 分的完整過程:幾個我實際用上的優化技術,以及背後的瀏覽器運作原理。
先看 Lighthouse 的關鍵指標
在講優化之前,先理解 Lighthouse 在量什麼。
LCP(Largest Contentful Paint)
頁面載入過程中,最大的可見元素完成渲染的時間點。Google 用它衡量「使用者覺得頁面載好了」的感受。
| 分數 | 評級 |
|---|---|
| < 2.5s | Good ✅ |
| 2.5s ~ 4s | Needs Improvement ⚠️ |
| > 4s | Poor ❌ |
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.ts 用 manualChunks 手動控制:
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-react | 176KB | 全部 |
vendor-charts | 160KB | 只有 /app |
vendor-markdown | 115KB | 只有 /business-logic |
page-homepage | 75KB | 只有 / |
訪客看首頁只下載 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 要加 crossorigin 但 googleapis.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.ts 把 analytics.ts 拆成獨立 chunk,斷開依賴鏈:
if (id.includes('/frontend/src/utils/analytics')) {
return 'utils-analytics'
}
| 狀態 | Eager preload 的 chunk | Raw 大小 |
|---|---|---|
| Before | vendor-react + vendor-charts + page-apppage | ~427KB |
| After | vendor-react + utils-analytics | ~181KB |
首頁 eager preload 減少 251KB,vendor-charts 和 page-apppage 回到真正 lazy load,只有進入 /app 才下載。
小結
| 技術 | 主要改善指標 | 結果 |
|---|---|---|
| Route-level code splitting | TBT、TTI | baseline |
| Vendor chunking 分層 | 初始 payload 大小 | baseline |
| Preload + fetchpriority | LCP | baseline |
type="module" | FCP | baseline |
| preconnect + crossorigin | FCP | 被 self-hosted fonts 取代 |
| Hero image WebP(-59%) | LCP | 3.1s → 0.8s |
| Google Fonts self-hosted | FCP、render-blocking | 2.1s → 0.4s |
| Analytics chunk 拆離 | 首頁 eager preload | -251KB |
Lighthouse 最終:99–100 分(CLI ±1 variance 屬正常)