Tailwind CDN 300KB 到本地 17KB:一个首页的减肥之旅
一、CDN Tailwind 的隐藏成本
先看常见的用法:
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: { owOrange: "#F97316" }
}
}
}
</script>
看起来轻巧。但在浏览器里实际发生的事情:
- 下载 ~300KB 的 JavaScript(Tailwind 的 JIT 运行时)
- 解析 HTML DOM,扫描所有
class="..."属性 - 动态生成 CSS,注入
<style>标签 - 监听 DOM 变化,新增元素再次生成
这意味着首页的首屏渲染依赖一个 300KB 的外部 JS 加载完成。如果 CDN 挂了、网络慢了、用户离线了,页面就是白屏。
二、本地构建的核心思路
Tailwind v3 的 npx tailwindcss CLI 可以在构建时扫描 HTML 文件,只编译实际使用的 class,输出纯 CSS 文件。
HTML 源文件 → tailwindcss --content → 只含 165 个 class 的 CSS
三、实操步骤
Step 1: 初始化
mkdir tw-build && cd tw-build
npm init -y
npm install tailwindcss@3
这里锁的是 Tailwind v3。v4 改动不小,配置从
tailwind.config.js挪进了 CSS,不再需要单独的配置文件。本文整套流程是 v3 的写法,v4 用户看思路即可,命令细节对不上。
Step 2: 配置文件
// tailwind.config.js
module.exports = {
content: ["/var/www/venxine.vip/index.html"],
darkMode: "class",
theme: {
extend: {
colors: {
owOrange: "#F97316",
}
}
},
safelist: [
{ pattern: /lg:|md:|sm:/ }, // 确保响应式变体被保留
{ pattern: /grid-cols/ }, // 动态 grid 列
]
}
Step 3: 构建
# input.css
@tailwind base;
@tailwind components;
@tailwind utilities;
npx tailwindcss -i input.css -o /var/www/venxine.vip/tailwind.min.css --minify
四、Safelist 的正则填坑
Tailwind 的 content 扫描是静态的,它只分析 HTML 文件中出现的 class 字符串。如果你的 class 名是 JavaScript 动态拼接的,它扫不到。
这个项目的首页用了暗色模式变体(dark:bg-zinc-950)、响应式变体(lg:grid-cols-3)、hover 变体(group-hover:text-owOrange)。这些变体在 HTML 里是完整的字符串,Tailwind 会自动识别。
但仍然有一个坑:lg:grid-cols-3 被 tree-shake 掉了。
原因是首页实际使用的 class 只有 165 个,Tailwind 的 tree-shaking 认为 grid-cols-1 被用了但 grid-cols-3 没被用(因为它在 HTML 里是 lg:grid-cols-3,需要 lg: 前缀匹配)。
解决方法:safelist 用正则兜底:
safelist: [
{ pattern: /lg:|md:|sm:/ }, // 所有响应式前缀
{ pattern: /grid-cols/ }, // 所有 grid 列数
]
构建后 CSS 从 17,065B 增加到 17,812B,增加了 ~700B,但 lg:grid-cols-3 回来了。
五、首页 HTML 的改动
改动前(两段 JS 加载):
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: { owOrange: "#F97316" }
}
}
}
</script>
改动后(一行 CSS):
<link rel="stylesheet" href="/tailwind.min.css">
没了。暗色模式切换依旧工作,因为首页的 dark class 是由一个独立的 6 行 <script> 控制的,和 Tailwind 的配置无关:
<script>
if (localStorage.theme === "dark" ||
(!("theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.classList.add("dark")
} else {
document.documentElement.classList.remove("dark")
}
</script>
六、效果对比
| 指标 | CDN 方式 | 本地构建 |
|---|---|---|
| 传输方式 | <script> JSONP 运行时 |
<link> 静态 CSS |
| 首屏加载 | HTML 28KB + JS ~300KB = ~328KB | HTML 28KB + CSS 17KB = ~46KB |
| 首次渲染 | 等 JS 下载+解析+DOM 扫描+CSS 注入 | CSS 下载完即渲染 |
| 离线 | 白屏 | 正常显示 |
| CDN 依赖 | 外部 cdn.tailwindcss.com | 无 |
| 缓存策略 | 浏览器缓存 CDN JS | Nginx immutable 30 天 |
| 新增 class | 自动(JIT 运行时扫描) | 需重新构建 CSS |
首屏减少 86%。 从一个咖啡厅的弱 Wi-Fi 打开,loading 时间从 3 秒变成 300 毫秒。
七、附赠:favicon 一并瘦身
首页减肥的时候顺手把 favicon 也处理了。
优化前:
favicon.ico 1,661B (32x32)
favicon.png 134,859B (512x512,无任何 HTML 引用,完全是冗余文件)
─────────────────────────
总计: 136,520B
优化后:
from PIL import Image
src = Image.open("favicon.png")
# 多尺寸 ICO: 16/32/48/64
sizes = [16, 32, 48, 64]
frames = []
for s in sizes:
resized = src.resize((s, s), Image.LANCZOS)
if resized.mode == "RGBA":
bg = Image.new("RGB", (s, s), (255, 255, 255))
bg.paste(resized, mask=resized.split()[3])
resized = bg
frames.append(resized)
frames[0].save("favicon.ico", format="ICO",
sizes=[(f.width, f.height) for f in frames],
append_images=frames[1:])
# 64px 高清 PNG fallback + Apple Touch Icon
src.resize((64, 64), Image.LANCZOS).save(
"favicon-64.png", "PNG", optimize=True)
favicon.ico 634B (16+32+48+64 四合一)
favicon-64.png 5,237B (64x64)
─────────────────────────
总计: 5,871B (节省 95.7%)
HTML 更新:
<link rel="icon" href="/favicon.ico" sizes="48x48">
<link rel="icon" href="/favicon-64.png" sizes="64x64">
<link rel="apple-touch-icon" href="/favicon-64.png" sizes="64x64">
八、Tailwind 本地构建的维护成本
每次修改首页的 HTML class 后,需要重新构建:
cd /tmp/tw-build
npx tailwindcss -i input.css \
-o /var/www/venxine.vip/tailwind.min.css \
--minify \
--content /var/www/venxine.vip/index.html
已经记在监控文档里了,不会忘。
如果觉得手动麻烦,可以做 watch 模式:
npx tailwindcss -i input.css -o ... --watch
或者放 CI/CD 流程里。
九、什么时候不该用本地构建
| 场景 | 建议 |
|---|---|
| 只有 1-2 个页面 | ✅ 本地构建(更小更快) |
| class 是 JS 动态生成的 | ⚠ 需要用 safelist 或 content 通配 |
| 几十个页面频繁改 class | ⚠ CDN 更灵活(但体积大) |
| 生产环境 + 稳定 UI | ✅ 本地构建(性能和缓存最优) |
| 原型 / 开发阶段 | ✅ CDN(快速迭代) |
把一个 300KB 的
<script>换成一行<link>,没什么技术含量,难的是意识到那行方便的 CDN 背后有代价。原型阶段用 CDN 无所谓,上了生产就该把它换下来。