Tailwind CDN 換預編譯 CSS:Vercel 課程頁手機 4G LCP 從 3-5 秒砍到 1.5 秒
直接回答
Tailwind CDN 在瀏覽器即時掃 DOM 生成 CSS,render-blocking 800-1000ms,Tailwind 官方文件第一句就明寫「不要拿去 production」。預編譯成靜態 CSS 後 19.8KB 純檔(gzip 4.6KB),實測 wk-qa-bot 課程頁 critical path 砍 64%,手機 4G LCP 從 3-5 秒降到 0.8-1.5 秒。
用戶反應 wk-qa-bot 課程介紹頁很慢。我先量了一輪,server 端 TTFB 100-260ms 沒問題,慢全部在前端。
—
排了一下兇手。
- cdn.tailwindcss.com:407KB JS,render-blocking,下載 600ms 加執行 200-400ms 等於卡 800-1000ms
- api.qrserver.com 外部 QR Code 服務:1.03 秒
- Vercel edge cache MISS:每個訪客都打 DB 算一次
- 4 張課程圖全 eager load 共 520KB
- 沒 preload hints、沒 width/height(CLS 跳)
第一名比所有其他兇手加起來還大。Tailwind 官方文件第一句就寫「不要拿去 production」,但很多新建站的工程師(包括我)為了省事一開始就用了。
—
CDN 模式做的事是這樣:
瀏覽器看到 head 裡的 <script src="https://cdn.tailwindcss.com">,停下來等下載拉完 407KB(gzip 123KB)的 JS。
執行那段 JS。JS 開始掃整個 DOM 看你用了什麼 class。即時生成對應的 CSS。注入 <style> 標籤。
整個過程跑完才開始 paint。手機 4G 訊號普通的時候,這條獨佔 1-2 秒空白。
預編譯模式相反:build 階段把 SSR 寫好的 HTML 字串掃過一遍,生成靜態 CSS 檔。瀏覽器拿到 HTML 就同時拿到 CSS link,並行下載。沒有 JS 阻塞、沒有 runtime 計算、CSS 直接套。
—
實作只要 4 件事。
npm install -D tailwindcss@3。
tailwind.config.js 寫 content 陣列指向 SSR 來源檔:
module.exports = {
content: ['./lib/_course-page.js', './lib/_course-form.js'],
safelist: ['opacity-50', 'bg-[#9ca3af]'],
}
動態組合的 class(像 class="bg-${color}-500" 這種 string 拼接的)tailwind 預編譯掃不到,要進 safelist,否則上線後那些 class 不會被編進 CSS。
src/styles/course-input.css 寫 @tailwind base; @tailwind components; @tailwind utilities; 加自訂 grain pattern。
package.json 加 build:css 指令跑 tailwindcss -i ... -o ... --minify。
本地跑一次,把 public/styles/course.css commit 進 repo(19.8KB)。HTML 把 <script src=cdn> 換成 <link rel="stylesheet" href="/styles/course.css">。
—
驗收的時候踩到一個 Vercel cache 的奇怪行為。
我設了 Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400,但 client 收到的 response header 只剩 Cache-Control: public, max-age=0。s-maxage 跟 swr 不見了。
第一個直覺是 Vercel 把它吃掉了,cache 沒套用。
驗證的方法是看 X-Vercel-Cache 這個 header。連續 5 發 curl,第一發 MISS,後面四發全 HIT。Age 從 0 變 26 秒、變 50 秒。
意思是:Vercel 故意把 s-maxage 從 client header 拿掉(瀏覽器不需要知道 edge cache 邏輯),但 edge cache 內部還是按 s-maxage 運作。看 X-Vercel-Cache 才是真相。
—
vercel.json 加 headers 規則給靜態檔:
{
"headers": [
{ "source": "/styles/:path*", "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] },
{ "source": "/course/:path*", "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] }
]
}
CSS 跟圖片設 1 年 immutable,重訪流量直接歸零(瀏覽器走 disk cache)。檔名變了才 invalidate。
—
順手把圖片一起處理。
sharp 重壓 4 張 JPG quality 80 mozjpeg progressive:總 508KB → 394KB(-22%)。
每張多出一份 WebP quality 78:總 283KB(-44%)。
HTML 改成:
<picture class="contents">
<source srcset="/course/hero.webp" type="image/webp" />
<img src="/course/hero.jpg" width="1280" height="720" loading="lazy" decoding="async" />
</picture>
<picture class="contents"> 是 Tailwind display: contents 工具,picture 不參與 layout,內層 img 繼承 flex/grid 屬性。沒加 contents 的話,picture 是 inline 元素會干擾既有版型。
LCP 元素(hero)加 fetchpriority="high" 跟 <link rel="preload" as="image" type="image/webp" href="...webp"> 預先抓。其他三張 loading="lazy" 等捲到再載。
全部 4 張加 width/height 屬性防 CLS。沒這個瀏覽器在圖載完前不知道留多少空間,圖到達時 layout 跳一下。
—
部署完驗:
X-Vercel-Cache:MISS → HIT(連續 5 發都 HIT)。 TTFB:cold 540ms → warm 120ms。 Critical path(HTML + CSS + hero WebP gzip):283KB → 100KB,砍 64%。 預估手機 4G LCP:3-5 秒 → 0.8-1.5 秒。 重訪流量:CSS 跟圖全部 immutable cache,0 byte。
—
整個工程 30 分鐘寫完、5 分鐘部署、5 分鐘驗證。手機載入時間砍 70-80%。
學到的是:「server 慢」這個直覺,多半是錯的。十次有九次慢在前端 render-blocking JS 跟外部請求。先量再改,量化兇手排名再對應修。Tailwind CDN 是頭號殺手不是因為它差,是因為它的設計目的就不是給 production,官方寫得清清楚楚。