- Published on
有人說我的部落格很絲滑,因為它幾乎不送 JavaScript
- Authors
-
-
- Name
- Alex Yu
-
前陣子在 Threads 有人留言說我的部落格「很絲滑」。
我很開心,但那跟我 CSS 寫得好沒什麼關係。是因為這個站現在幾乎不送 JavaScript 給瀏覽器。
五月底我把它從 Next.js 15 App Router 搬到 Astro 6。搬家本身一天就做完了,收尾的坑修到半夜。
一個 34 篇文章的部落格,在維護一套 app 框架
這個部落格是從 tailwind-nextjs-starter-blog 起家的,模板本身很好。問題是我用它做的事情,跟它被設計來做的事情不一樣。
34 篇文章、一個標籤頁、一個搜尋框。需要 JavaScript 的地方數得出來:深色模式切換、手機選單、留言區、Cmd+K 搜尋、回到頂端、圖片點擊放大。
但當時的相依長這樣:
{
"next": "15.1.11",
"contentlayer2": "0.5.3", // 內容 pipeline 全長在這上面
"next-contentlayer2": "0.5.3",
"next-themes": "^0.3.0", // 只為了切深色模式
"@headlessui/react": "2.2.0", // 只為了手機選單的滑入動畫
"pliny": "0.4.0" // 模板附的 kbar 搜尋 / SEO / 分析封裝
}真正推我一把的是第一行的那個 2。
Contentlayer 原專案已經停止維護,contentlayer2 是社群 fork 出來接手的版本。而我的整條內容 pipeline——frontmatter 驗證、readingTime、tag-data.json、搜尋索引——全部長在這一個套件上。
它現在還會動。但 Next.js 下一次出大版本的時候,這個 fork 會不會有人跟著更新,決定權不在我手上。
那時候我才看清楚,我在維護的是一套為互動 app 準備、卻只拿來排版文章的建置流程。
四個選項,風險最大的是「不動」
攤開來評估的時候有四個:Astro、Remix、TanStack Start、維持現狀。
直覺上維持現狀最安全——不用搬、不用學、不會多出新風險。
可是不搬也不是真的沒事。Contentlayer 這條相依會擋住我以後每一次升級,哪一次會真的裝不起來,我也說不準。
Remix 的 nested routing 和 data loading 很強,可是它仍然是為互動 app 設計的框架,我的複雜度沒有真的降下來。TanStack Start 我很喜歡,但 2026 年初它還太新。
選 Astro 的理由只有一句話:預設是靜態的。
Islands:要互動的地方才給 JavaScript
Astro 的心智模型很單純。整頁預設是純 HTML,零 JS。哪裡需要互動,就在哪裡放一個 island(島),只有那個 component 會在瀏覽器裡跑起來。
---
// src/pages/blog/[...slug].astro
import Comments from '../../components/Comments.tsx'; // React 島
import Lightbox from '../../components/Lightbox.tsx'; // React 島
---
<article set:html={post.body} /> <!-- 純 HTML,零 JS -->
<Comments client:load {...giscusConfig} />
<Lightbox client:idle />client:load 是「頁面載入就 hydrate」,client:idle 是「等瀏覽器閒下來再 hydrate」。還有 client:visible(進到 viewport 才 hydrate)跟 client:media(符合某個 media query 才 hydrate)。
hydrate(水合):把伺服器送來的靜態 HTML 接上 JavaScript,讓它開始能互動。
原本的 React component 不用丟掉,直接當島複用。這件事對搬家成本是關鍵——多數檔案是搬過去,不是重寫。
搬家的紀律:URL 一個都不准變
我給自己定了一條規則:這次只換框架,其他一律不動。
- 檔名即 slug,
YYYYMMDD-前綴保留 → 舊網址全部原樣活著 - SEO 輸出(JSON-LD、sitemap、RSS、per-tag RSS)重建,但吐出來的內容要一致
- security headers 從
next.config.js搬到vercel.json - 只砍掉一個東西:圖片 lightbox,為了先讓 MVP 上線
砍掉一個 dependency,多半得把它做的事自己寫出來。next-themes 變成 <head> 裡的一段 script:
<!-- src/components/ThemeScript.astro -->
<script is:inline>
;(function () {
try {
var stored = localStorage.getItem('theme')
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
var resolved =
stored === 'light' || stored === 'dark' ? stored : prefersDark ? 'dark' : 'light'
if (resolved === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
document.documentElement.style.colorScheme = resolved // ← 捲軸、表單元件跟著換
} catch (e) {}
})()
</script>is:inline 是叫 Astro 原樣輸出、不要打包——這段必須在頁面繪製前就跑完,晚一步就會閃白。
@headlessui/react 只用在手機選單的滑入,換成一個 data-state 屬性加兩條 CSS:
[data-mobile-nav-overlay] {
transform: translateX(100%);
opacity: 0;
pointer-events: none; /* ← 關閉時留在 DOM 裡,離場動畫才播得完 */
transition:
transform 300ms ease-in-out,
opacity 200ms ease-in;
}
[data-mobile-nav-overlay][data-state='open'] {
transform: translateX(0);
opacity: 0.95;
pointer-events: auto;
}開關的部分就是 overlay.dataset.state = 'open' 一行。kbar 搜尋則換成 Pagefind,build 時產靜態索引,支援中日韓斷詞。
搬完之後整個站只剩兩個 React 島:留言區和圖片放大。其餘全部是 .astro 加幾行原生 JS。package.json 的 dependencies 從 37 個變成 28 個。
砍掉的 lightbox 還好當天就補回來了——放螢幕截圖的文章沒有點擊放大,讀者會覺得壞掉。
收尾的坑,全部在部署那一天
程式碼搬完、本機跑起來,本以為就這樣結束了。然後 push 上 Vercel。
Vercel 還以為這是 Next.js 專案
deploy 直接失敗,訊息是 No Next.js version detected。
這個 Vercel project 當初是以 Next.js 建立的,Framework Preset 就一直釘在那裡,程式碼換掉它也不會自己改。修法是在 vercel.json 裡明講:
{
"framework": "astro",
"buildCommand": "pnpm build",
"outputDirectory": "dist"
}緊接著第二發:Astro 6 要 Node 22.12 以上,Vercel 專案預設是 Node 20。.nvmrc 在本機有效,Vercel 不看它。要寫在 package.json:
{ "engines": { "node": ">=22.12.0" } }島裡讀不到環境變數
留言區在預覽環境整個消失。島有掛上去,但 render 出來就是 null。
原因是環境變數的命名慣例。Next.js 會自動把 NEXT_PUBLIC_* 開頭的變數塞進 client bundle;Astro/Vite 不吃這個前綴,它認的是 PUBLIC_*。所以在瀏覽器裡那些值全是 undefined,component 判斷 repo 是空的就自己 return 掉了。
我不想去改 Vercel 上的環境變數名稱,所以改成在 .astro 這一端先讀好,再當 props 傳進島:
---
// ❌ 島裡面 import siteMetadata → 瀏覽器讀到 undefined
// import siteMetadata from '../data/siteMetadata';
// ✅ 在 .astro 裡讀(這裡跑在 build/server,process.env 拿得到)
const giscusConfig = {
repo: process.env.NEXT_PUBLIC_GISCUS_REPO,
repoId: process.env.NEXT_PUBLIC_GISCUS_REPOSITORY_ID,
category: process.env.NEXT_PUBLIC_GISCUS_CATEGORY,
};
---
<!-- Astro 會把這個物件寫進 astro-island 標籤,瀏覽器端從 props 讀回來 -->
<Comments client:load {...giscusConfig} />client:visible 永遠不會觸發
留言區改用 props 之後還是沒出現。這次的原因更陰。
client:visible 底層是 IntersectionObserver,它要觀察一個元素有沒有進到畫面。但 Astro 對島的容器元素套了一條全域樣式:
astro-island { display: contents; }display: contents 的意思是「這個元素本身不產生任何方框,只留下小孩」。沒有方框,IntersectionObserver 就量不到它,於是「進入畫面」這件事永遠不會發生,島也就永遠不 hydrate。
改成 client:load 就好了。留言區這種東西本來就值得無條件載入,多出來的 bundle 很小。
搜尋跳出一個白色的空框
Cmd+K 按下去,modal 開了,裡面一片空白。
原因很蠢:Pagefind 的預設 UI 我只 dynamic import 了 JS,忘了它的 CSS。修法是讓 <link> 在 SSR 階段就寫進 HTML,不要交給 Vite 去 resolve:
<link rel="stylesheet" href="/pagefind/pagefind-ui.css" />順便補了一段錯誤處理——索引沒載到的時候顯示一行訊息,不要留一個白框讓人以為壞了。
(後來我又把 Pagefind 的預設 UI 換掉,自己重刻成原本 kbar 那種指令面板:沒輸入時列出全部 34 篇,輸入了才打 Pagefind 的核心 API。這部分算功能改版,不算遷移。)
這些坑其實是同一件事
環境變數那個和 client:visible 那個,看起來一個是設定、一個是 CSS,但它們卡在同一個地方:.astro 檔跑在 build 或 server 上,島跑在瀏覽器裡,這是兩個分開的執行環境。
兩邊之間只有一種傳遞方式:你傳給島的 props,Astro 會轉成一段文字寫進 HTML 裡的 <astro-island> 標籤,瀏覽器再把那段文字讀回來還原成物件。
序列化(serialize):把記憶體裡的物件轉成一段可以存、可以傳的文字(多半是 JSON),到了另一邊再還原回來。能不能被轉成文字,決定了它過不過得去。
所以 process.env 過不去——它是一個只存在於 server 的介面,不是一份資料。它讀出來的值才過得去。
而承接這段文字的 <astro-island> 是一個自訂元素,它有自己的排版行為(就是那個 display: contents),會影響 hydration 策略能不能運作。
從 Next.js 過來的人最容易踩這裡。App Router 的 'use client' 邊界是編譯期的事情,寫起來像同一份程式碼。Astro 的邊界是執行期真的分成兩邊。
Vercel 那個是另一種:搬家的時候會記得改程式碼,但部署平台上那些「當初建專案時選的」設定,沒有人會提醒你。之前那篇 _next/static 在多架構節點上回 403 也是同一類——程式碼沒問題,環境跟你想的不一樣。
幾個帶走的判斷
選框架的時候值得誠實問一句:我真的需要這些抽象嗎?34 篇文章的站跟一個 app,用同一把尺會量錯。而「不動」看起來沒成本,其實只是把問題留給以後的自己。
遷移的那條紀律我會再用一次:只換框架,其他不動。一開始順手多改一樣東西,搬家就會變成重寫。
搬完之後這個站送出去的 JavaScript 少了很多,讀者感覺到的「絲滑」就是這個。我沒有為它優化過,Astro 的預設值本來就長這樣。
延伸閱讀
- 一天 700 個訪客之後,我把 Analytics 搬回家裡的 NAS——同一個站後來又踩到一次 Astro 在 build 階段動你的東西:第三方追蹤碼被打包成 module,要加
is:inline才會原樣輸出。