我們如何把 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+ 工具元件(
RemoveBackground、PdfMerge、LoremIpsum等) - 全部 8 個 i18n 語言 JSON(英文+7 種翻譯,每個約 300 KB)
pdf-lib+pdfjs-dist(合計約 500 KB)—— 30 個 PDF 工具用它UPNG(200 KB)—— 被間接拉進來marked、diff、jszip、pptxgen、xlsx、mammoth—— 每個 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>
Home 和 Nav 我們保留為 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。瀏覽器要:
- 解析
fonts.googleapis.com(DNS、TLS) - 下載 CSS 檔案(一堆
@font-face塊) - 解析
fonts.gstatic.com(DNS、TLS,不同 origin!) - 下載真正的
.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 AdSense 和 Google 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 應用
每次釋出都看 bundle 輸出。 CI log 裡就兩行。任何東西超過 500 KB 就查。Vite 免費告訴你。
React.lazy()加Suspense是效能投入產出比最高的修法。 路由級切分十分鐘就能寫完,通常省 50-90% 主 bundle。import.meta.glob是 Vite 處理"同類多檔案"的超能力 —— 語言檔案、圖示、部落格文章等。用它替代長列表靜態 import。審計間接 import。 一個靜態 import 重庫的共享 "shell" 元件會把你所有路由級 lazy 努力打水漂。PdfToolShell 這個 bug 讓每個頁面都揹著 600 KB chunk,直到我們注意到。
你的 logo 大機率太大。 大多數站點的 logo 顯示 ≤64 px。應該 ≤10 KB。本站工具就能用,沒裝 ImageMagick 也能用。
質疑每一次外部字型載入。 macOS、Windows、Android 的系統字型都很好。如果你的品牌不依賴具體字型(大多數不依賴),直接刪 Google Fonts 連結。
小 CSS 內聯。 約 10 KB 以下,省一次請求的延遲勝過外部檔案的快取收益。
AdSense 和 Analytics 大部分不在你控制範圍。 接受它,最佳化能最佳化的。
整個工作約 6 小時工程時間,分散在兩天。最大改進(路由切分 + 語言切分)各約 2 小時。剩下 4 小時是測量、迴歸測試和寫這篇部落格。
如果你覺得有用,整理後跑起來的工具仍在這裡,仍免費,仍在你的瀏覽器裡執行:
- 圖片壓縮(是的,包括你的 logo)
- PDF 工具(觸發 lazy loading 靈感的那些)
- 影片壓縮(FFmpeg.wasm,也 lazy)
- OCR(Tesseract.js,也 lazy)
- 完整部落格更多工程筆記
正常網路下,所有這些一秒內載入。現在是。