← 所有文章

我們如何把 100 工具的 React 應用主 bundle 從 6 MB 減到 307 KB

2026-05-25

Vite 的構建輸出已經默默告訴我們這件事好幾個月了:

(!) Some chunks are larger than 500 kB after minification.

我們一直忽略它。在寬頻上站點跑得好好的。背景移除 AI 工具呼叫時會載入一個 30 MB 的 WebAssembly 檔案 —— 跟那比起來,6 MB 的 JavaScript bundle 算什麼。

但其實算很多。

一個朋友在飛機酒店搖晃的 WiFi 上用 Pixel 手機訪問 toolkoala.com。光是元素渲染延遲,Largest Contentful Paint 就花了 2.5 秒 —— 還沒算網路時間。Lighthouse 審計一直扣我們「render-blocking resources」和「reduce unused JavaScript」。最致命的:Google Search Console 開始在 Core Web Vitals 報告裡給站點黃牌。

於是我們坐下來認真看構建輸出。

dist/assets/index-CdRXzQJL.js  5,998 kB │ gzip: 1,755 kB

六。兆。位元組。的 JavaScript。投遞到每一個訪問首頁的使用者面前,在他們點選任何東西之前。

這篇文章是把它降到 307 KB raw / 92 KB gzipped —— 減少 95% —— 以及把 LCP 從 2.5 秒降到 1.5 秒以下的工程筆記。審計成績從 F 升到 A。站點本身沒變。

這 6 MB 裡到底是什麼

第一件有用的事是跑 npx vite-bundle-visualizer(或者直接看構建輸出按大小排序)。最大幾塊:

  • 全部 70+ 工具元件(RemoveBackgroundPdfMergeLoremIpsum 等)
  • 全部 8 個 i18n 語言 JSON(英文+7 種翻譯,每個約 300 KB)
  • pdf-lib + pdfjs-dist(合計約 500 KB)—— 30 個 PDF 工具用它
  • UPNG(200 KB)—— 被間接拉進來
  • markeddiffjszippptxgenxlsxmammoth —— 每個 100-500 KB

這些大部分屬於使用者當前還沒用的工具。它們被下載,只是以防使用者看完 lorem ipsum 生成器之後順手點了 PDF 合併

這是預設的 React 模式,Vite 也樂意讓你這麼幹:

import RemoveBackground from './tools/RemoveBackground'
import PdfMerge from './tools/PdfMerge'
// … 還有 65 行

<Routes>
  <Route path="/remove-background" element={<RemoveBackground />} />
  <Route path="/pdf-merge" element={<PdfMerge />} />
  // …
</Routes>

每一句 import 都在說"把這段程式碼投遞到每個頁面上"。這就是 70 個工具變成一個 6 MB bundle 的過程。

修復 1:路由級程式碼切分(省 ~5.7 MB)

標準 React 解法是 React.lazy()Suspense。Vite 配的 rollup 打包器會自動給每個動態 import 生成獨立 chunk。

我們把每個工具 import 改了:

const RemoveBackground = lazy(() => import('./tools/RemoveBackground'))
const PdfMerge = lazy(() => import('./tools/PdfMerge'))
// …

然後包住路由:

<Suspense fallback={<div className="loading">Loading…</div>}>
  <Routes>
    <Route path="/" element={<Home />} />
    <Route path="/remove-background" element={<RemoveBackground />} />
    // …
  </Routes>
</Suspense>

HomeNav 我們保留為 eagerly imported。首頁是最常見的著陸點,導航必須在每個頁面立刻渲染。

僅這一改:主 bundle 從 5,998 KB → 3,025 KB。每個工具變獨立 chunk:3-50 KB 不等,使用者導航時按需取。

代價:使用者第一次訪問某個工具,多一次網路往返取該工具的 chunk。但 chunk 很小(gzip 後常常個位數 KB),現代 HTTP/2 把往返成本攤到並行請求裡。真實網路下延遲增加幾乎看不見。

贏在第一次頁面載入 —— 對 Largest Contentful Paint 和首次訪客跳出率最重要的那一次。我們已經把它砍掉一半。

修復 2:用 import.meta.glob 按需載入語言檔案(省 ~2 MB)

bundle 裡下一個大頭是國際化。我們支援 8 種語言,每個 ~300 KB 的 JSON 包含 100+ 工具的標題、FAQ、特性描述翻譯。原始設定就是直球做法:

import en from './locales/en.json'
import zhCN from './locales/zh-CN.json'
import zhTW from './locales/zh-TW.json'
import ja from './locales/ja.json'
// … 還有 4 個

i18n.init({
  resources: {
    en: { translation: en },
    'zh-CN': { translation: zhCN },
    // …
  }
})

8 個檔案(約 2.5 MB raw,約 700 KB gzip)全打進主 bundle。一個法語訪客開啟我們英文首頁,被迫下載韓語翻譯。

Vite 有個叫 import.meta.glob 的妙用,10 行程式碼就能解決:

const localeLoaders = import.meta.glob('./locales/*.json')
// localeLoaders 現在是:
// {
//   './locales/en.json': () => import('./locales/en.json'),
//   './locales/zh-CN.json': () => import('./locales/zh-CN.json'),
//   …
// }

const initial = detectLangFromPath() || 'en'
const [enData, activeData] = await Promise.all([
  localeLoaders['./locales/en.json'](),
  initial !== 'en' ? localeLoaders[`./locales/${initial}.json`]() : null,
])

i18n.init({
  resources: {
    en: { translation: enData.default },
    [initial]: { translation: activeData?.default },
  },
  lng: initial,
})

Vite 看到每一個 import('./locales/<x>.json') 呼叫,給每種語言生成獨立 chunk。啟動時只載入使用者當前語言(加上英文作 fallback)。

英文 fallback 很小(提前載入,避免其它語言缺譯時崩)。使用者手動切換語言時,我們覆蓋 i18n.changeLanguage 按需取:

const origChangeLanguage = i18n.changeLanguage.bind(i18n)
i18n.changeLanguage = async (lng) => {
  if (!i18n.hasResourceBundle(lng, 'translation')) {
    const data = await loadLocale(lng)
    i18n.addResourceBundle(lng, 'translation', data, true, true)
  }
  return origChangeLanguage(lng)
}

有個坑:這讓 i18n 初始化變成非同步,React 不能在語言解析之前渲染。我們用了 Vite 對 ESM 頂層 await 的支援:

// main.jsx
import { initI18n } from './i18n'
await initI18n()
createRoot(...).render(<App />)

頂層 await 現代瀏覽器都支援(Chrome 89+、Safari 15+、Firefox 89+)。老瀏覽器看 Vite 構建 target 處理。要支援 IE11 的話改用基於 Suspense 的 loader。

這步之後:主 bundle 3,025 KB → 307 KB(gzip 1,755 KB → 92 KB)。從關鍵路徑卸下了 2.7 MB 的本地化資料。

修復 3:次級洩漏 —— PdfToolShell(省 ~600 KB)

前兩步之後,PageSpeed Insights 還在抱怨"Reduce unused JavaScript",點名:

  • pdf-*.js —— 116 KB,96% 沒用
  • UPNG-*.js —— 118 KB,28% 沒用

為什麼首頁要載入 PDF 庫?

我們有個共享元件 PdfToolShell 包住 30+ 個 PDF 工具路由(PDF 合併、拆分、旋轉等)。它本身不是工具,是個可複用 shell。它在 App.jsx 裡被靜態 import:

import PdfToolShell from './components/PdfToolShell'

而 PdfToolShell 頂部就這麼寫:

import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib'
import * as pdfjsLib from 'pdfjs-dist'

整個 PDF 堆疊被提升到主 bundle —— 哪怕使用者在訪問 /lorem-ipsum

修復一樣:

const PdfToolShell = lazy(() => import('./components/PdfToolShell'))

教訓:僅僅 lazy 載入工具元件還不夠,如果共享 shell 元件仍然靜態 import 它們的重依賴。審計每一個靜態 import 看它間接拉進了什麼。

修復 4:Logo(省 640 KB)

這個有點丟人。站點 logo 是一張 1024×1024 的 PNG,640 KB。它在導航條裡以 32×32 畫素顯示。從沒人質疑過。

修復用的時間比你讀這段還短:我們把 logo.png 拖進自家的 圖片壓縮 工具。滑塊已經是預設 80% 質量。我們點了 Download。

結果:6.9 KB。99% 減少,32×32 下肉眼無差 —— 甚至放大到 256×256(社交卡片同款 logo)也沒差。

社交卡片(Twitter、Facebook、LinkedIn 預覽圖)要求 logo 縮放到 ~256 px 時清晰,所以我們把原 1024×1024 拖進 圖片縮放 工具設 256×256,再過一遍 圖片壓縮 設 90% 質量,得到 logo-256.png 64 KB。og:image meta 標籤現在指向它。

兩個工具,兩分鐘,每次頁面載入省 633 KB。

如果你在手機上讀這篇,上面這個 logo 看起來正常:那是 6.9 KB 圖片。640 KB 那版長得一模一樣。

如果你自己的網站 logo 超過 50 KB,ToolKoala 圖片壓縮 就在那。拖進去,看 before/after 預覽,下載小的那個。檔案不上傳 —— 你的 logo 不會傳到我們伺服器,全在瀏覽器裡處理。這就是整個專案的核心,也包括我們用它處理自己 logo 的這件事。

修復 5:刪 Google Fonts 依賴(省 ~1.2 秒 LCP 阻塞)

我們 <head> 長得跟你見過的每個 web 應用一樣:

<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">

PageSpeed 報告這條關鍵路徑延遲 1,258 ms。瀏覽器要:

  1. 解析 fonts.googleapis.com(DNS、TLS)
  2. 下載 CSS 檔案(一堆 @font-face 塊)
  3. 解析 fonts.gstatic.com(DNS、TLS,不同 origin!)
  4. 下載真正的 .woff2 檔案

工具站點用 Inter 字型好看是好看,但不必要。我們 CSS 裡本來就有完美的系統字型 fallback:

font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
             system-ui, Roboto, "Helvetica Neue", Arial, sans-serif;

macOS 解析到 San Francisco(Apple 系統字型)。Windows 是 Segoe UI。Android 是 Roboto。三個都是優秀字型,作業系統預裝。它們用網路頻寬、載入時間。

我們刪了 Google Fonts 連結。站點看起來差不多 95% 一樣 —— Linux 上沒裝好 sans-serif 的話有差異,但那群體很小,fallback 也夠看。

PageSpeed 的"關鍵路徑延遲"降了 1.2 秒。

修復 6:內聯 bundle CSS(省 160 ms)

剩下唯一的 render-blocking 資源是 Vite 輸出的 CSS bundle:

<link rel="stylesheet" href="/assets/index-CcF97ram.css">

5.4 KB。典型連線下往返約 160 ms —— 阻塞首次繪製。

這種小 CSS bundle,內聯到 HTML 比快取更划算。內聯的成本是每個 HTML 頁面多 5.4 KB。收益是首次載入少一次阻塞請求。

我們的構建指令碼(後處理 Vite 輸出做 SSR)現在讀 CSS 檔案,替換 <style> 塊:

const cssMatch = baseHtml.match(/<link\s+rel="stylesheet"[^>]*href="([^"]+)"[^>]*>/)
if (cssMatch) {
  const cssPath = join(DIST, cssMatch[1].replace(/^\//, ''))
  const css = readFileSync(cssPath, 'utf8')
  baseHtml = baseHtml.replace(cssMatch[0], `<style>${css}</style>`)
}

注意:我們 5 KB 的樣式表這麼幹沒問題。要是你 CSS 100 KB,內聯會破壞跨頁快取,留外部(或抽 critical CSS)更好。

我們故意沒修的

Google AdSenseGoogle Analytics gtag。兩個都是 async 指令碼,PageSpeed 還是吐槽 forced reflow 和高傳輸量。它倆加起來卡了 500+ ms 關鍵路徑。

但 AdSense 是站點資金來源。刪它不是工程權衡,是商業決定。Analytics 也是 —— 沒它我們就盲飛,不知道哪個工具受歡迎、哪個 bug 沒人遇到。

理論上可以延遲 AdSense 到 LCP 元素繪製完之後。我們試了半小時;要覆蓋 AdSense 自己的注入邏輯,結果廣告載入後視覺閃爍。為了 ~200 ms 節省,工程複雜度不值得。

如果你最佳化的是非廣告站點,你比我們選項多。我們得接受現實。

結果

https://www.toolkoala.com/ 的冷快取首次繪製(基礎 benchmark):

指標 起點 現在 變化
主 bundle (raw) 5,998 KB 307 KB -95%
主 bundle (gzip) 1,755 KB 92 KB -95%
Logo PNG 640 KB 7 KB -99%
首屏總 JS ~3 MB 544 KB -82%
Render-blocking CSS 160 ms 0 ms (inlined) -100%
Google Fonts 阻塞 1,258 ms 0 ms -100%
FCP ~2.4 s 256 ms -89%
LCP / DOM Complete ~2.5 s 1,295 ms -48%
Lighthouse 效能等級 D A (6/6)

站點本身沒變 —— 一樣的 React 元件、一樣的 UI、一樣的後端。每一個修復都是配置或構建過程的改動。

如果你在做 React + Vite 應用

  1. 每次釋出都看 bundle 輸出。 CI log 裡就兩行。任何東西超過 500 KB 就查。Vite 免費告訴你。

  2. React.lazy()Suspense 是效能投入產出比最高的修法。 路由級切分十分鐘就能寫完,通常省 50-90% 主 bundle。

  3. import.meta.glob 是 Vite 處理"同類多檔案"的超能力 —— 語言檔案、圖示、部落格文章等。用它替代長列表靜態 import。

  4. 審計間接 import。 一個靜態 import 重庫的共享 "shell" 元件會把你所有路由級 lazy 努力打水漂。PdfToolShell 這個 bug 讓每個頁面都揹著 600 KB chunk,直到我們注意到。

  5. 你的 logo 大機率太大。 大多數站點的 logo 顯示 ≤64 px。應該 ≤10 KB。本站工具就能用,沒裝 ImageMagick 也能用。

  6. 質疑每一次外部字型載入。 macOS、Windows、Android 的系統字型都很好。如果你的品牌不依賴具體字型(大多數不依賴),直接刪 Google Fonts 連結。

  7. 小 CSS 內聯。 約 10 KB 以下,省一次請求的延遲勝過外部檔案的快取收益。

  8. AdSense 和 Analytics 大部分不在你控制範圍。 接受它,最佳化能最佳化的。

整個工作約 6 小時工程時間,分散在兩天。最大改進(路由切分 + 語言切分)各約 2 小時。剩下 4 小時是測量、迴歸測試和寫這篇部落格。

如果你覺得有用,整理後跑起來的工具仍在這裡,仍免費,仍在你的瀏覽器裡執行:

正常網路下,所有這些一秒內載入。現在是。