我们如何把 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)
- 完整博客更多工程笔记
正常网络下,所有这些一秒内加载。现在是。