Tailwind CDN 300KB 到本地 17KB:一个首页的减肥之旅

一、CDN Tailwind 的隐藏成本

先看常见的用法:

<script src="https://cdn.tailwindcss.com"></script>
<script>
    tailwind.config = {
        darkMode: "class",
        theme: {
            extend: {
                colors: { owOrange: "#F97316" }
            }
        }
    }
</script>

看起来轻巧。但在浏览器里实际发生的事情:

  1. 下载 ~300KB 的 JavaScript(Tailwind 的 JIT 运行时)
  2. 解析 HTML DOM,扫描所有 class="..." 属性
  3. 动态生成 CSS,注入 <style> 标签
  4. 监听 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 无所谓,上了生产就该把它换下来。