前一篇用 getCollection() 把全部文章撈出來,做了 /blog 列表和一個純前端的搜尋 island。從列表點進單篇文章時,對應的檔案是 blog/[...slug].astro,也就是一個帶方括號的檔案。這篇回答兩件事:一個檔怎麼對應到幾十個網址,以及這些網址是在 build 時先產好,還是有人開才即時算。
我常看到從 WordPress 轉到 Astro 的人,最不習慣的是這件事:WordPress 預設在有人打開頁面時,才由伺服器當場組出內容;Astro 則預設在 build 時先算好,上線後直接送出已產好的檔案。SSR、SSG 在搬遷時反覆出現,因為這是整個搬遷裡最大的轉變。但再問下去,有人說要 SSR、有人說要 SSG,理由卻只剩「剛從 WordPress 換過來」,沒說清楚網站需要哪一種,也沒說清兩個詞各自代表什麼。這時候的 SSR、SSG 比較像搬遷時的身份標籤,還不是技術決定。
兩種機制的差別講清楚之後,選哪一邊就只是照條件對。
一個檔名帶方括號的頁,像 [slug].astro,叫動態路由:一個檔案對應多個網址,方括號裡就是會變的那一段。blog/[...slug].astro 一個檔,服務 /blog/day-01、/blog/day-02⋯⋯幾十個網址。這件事本身很單純。
這些網址「什麼時候被算出來」則有兩條路,而且同一個檔就能切換。我用「名冊」和「現場」來記這兩條路:
getStaticPaths()。這幾個詞後面會反覆出現,先各給一句定義:
getStaticPaths():SSG 動態路由專用,你在這個函式裡「列出所有網址」。=印名冊的動作。prerender:頁面層級的開關,決定「這一頁」走名冊還是現場。output:整個專案的預設,決定這個開關預設朝哪邊。同一個 [slug].astro 是在 build 時算好,還是等有人開才算,決定因素只有兩個:專案的 output,加上這一頁的 prerender。跟你裝了哪個 adapter 無關。
output: 'static'(本專案的預設,因為 astro.config.mjs 沒有設定 output)→ 頁面預設走名冊,動態路由必須有 getStaticPaths() 把所有網址先列出來。export const prerender = false。adapter 會提供伺服器 runtime,讓它即時算。adapter 在需要現場渲染時提供伺服器 runtime,不會把專案的預設從名冊改成現場。
這個 repo 裡就有一個我寫錯的例子。寫這篇前重看 src/pages/blog/[...slug].astro,才發現自己幾天前寫的一行註解錯了:
// Content-first:文章頁在 build 時預渲染(adapter 預設 output:'server',這裡逐頁 opt-in SSG)。
export const prerender = true;
「adapter 預設 output:'server'」這句是錯的。裝了 @astrojs/cloudflare 不代表 output 就變成 server。跑一次 npm run build,log 第一行就打臉:
[build] output: "static"
專案是 static。那行 prerender = true 其實是多餘的(static 本來就會走名冊),而註解的前提整個站不住。已經改掉了。
我已經記不清當時為什麼這樣寫,大概是憑印象、沒回頭查。這種前提寫錯不會噴錯、不會壞頁面,所以會一路留著;npm run build 的第一行就能對答案。
同一段 build log 還有一個容易混淆的細節:除了 output: "static",它也印出一行 mode: "server":
[build] output: "static"
[build] mode: "server"
mode: "server" 指的是:站上有幾頁走現場,因此 build 產生了伺服器端的進入點。它描述的是建置模式,和 output 是兩個設定;output: "static" 與 mode: "server" 可以同時成立。只看 server 這個字,很容易延伸出「adapter=server」的誤解。
同樣是「顯示一篇 blog」,名冊版和現場版的差異直接寫在程式裡。
名冊版,blog/[...slug].astro(build 時列舉全部):
---
import { getCollection, render } from 'astro:content';
export const prerender = true; // 顯式標註走 SSG;static 專案其實不寫也一樣
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts.map((post) => ({
params: { slug: post.id }, // v6 用 post.id,不是舊的 post.slug
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post); // v6 是函式 render(entry),不是 entry.render()
---
現場版,demos/post/[id].astro(有人開才即時抓一筆):
---
export const prerender = false; // 就這一行,這頁改走現場
import { getEntry, render } from 'astro:content';
const { id } = Astro.params; // 不列舉了,直接從網址那段拿
if (id === undefined) return Astro.redirect('/404');
const post = await getEntry('blog', id); // getEntry 抓一筆,不是 getCollection 全撈
if (post === undefined || post.data.draft) return Astro.redirect('/404');
const { Content } = await render(post);
---
差別濃縮成三點:
getStaticPaths 列出全部;現場版不寫 getStaticPaths,官方明說 on-demand 路由不該用它。Astro.props 拿 build 時就塞好的資料;現場版從 Astro.params 拿當下網址那段,再自己 getEntry 去查一筆。Astro.redirect('/404');名冊版不用,因為列舉的時候就不會有不存在的網址。兩個檔在同一個專案裡,output 和 adapter 都相同;只有 prerender 不同,建置結果就不同。
光看程式碼還是抽象。跑完 npm run build,去 dist/ 翻,兩頁的落點完全不同:
blog/[...slug] → 產出 dist/client/blog/day-01-why-astro/index.html 這種純靜態 HTML。build log 裡那份「prerendering static routes」清單,15 篇文章頁全在上面。demos/post/[id] → 沒出現在那份 prerender 清單裡,dist/client/demos/ 底下也沒有 post/。它被編譯進 dist/server/chunks/,是一段有人請求才會跑的伺服器程式。在同一個 static 專案、同一個 Cloudflare adapter 裡,名冊版進 dist/client/(靜態檔),現場版進 dist/server/(伺服器程式);差別仍由 prerender 決定。
分清名冊和現場後,選擇只剩兩個問題:
內容站絕大多數符合第一種條件。這個 capstone 因此預設 static,文章頁全部走名冊:它們從 build 完到下次發文之間都不會變,沒有必要讓伺服器在每次請求時重算。
對照到這個專案,寫法如下:
| 我要做的頁 | 檔名 | 怎麼寫 | 參數哪來 | 資料哪來 |
|---|---|---|---|---|
| blog 逐篇頁(內容 build 時就定了) | blog/[...slug].astro |
不必寫 prerender(吃 static 預設) |
getStaticPaths 的 params |
getCollection 全撈再 .map() |
| 標籤頁 / 分頁列表 | tags/[tag].astro、blog/[page].astro |
不必寫 prerender;分頁用 paginate() |
getStaticPaths |
getCollection + 篩選/分頁 |
| 要讀當前登入者、即時查詢的頁 | user/[id].astro |
export const prerender = false |
Astro.params(不寫 getStaticPaths) |
getEntry / DB / Astro.locals |
社群討論和文件裡,這類混淆到處都有,而且方向不只一個:
output: 'server' 才會全站 SSR。這正是前面那個「裝了 adapter=server」的誤解。getStaticPaths()。GitHub 上有使用者回報「設了 output: 'server' 後,getStaticPaths() 被靜默忽略」。同一個「output 和路由行為的關係」,從兩個方向都有人踩。output: 'hybrid',這個選項 Astro 5 就移除了,它原本的行為(靜態站也能有幾頁走現場)現在已經內建成 output: 'static' 的預設。output: 'hybrid' 的變化,剛好對應這個系列一直在分的兩種知識:
getStaticPaths 的回傳長怎樣、post.id 還是舊的 post.slug、output: 'hybrid' 還在不在、params 的值一定要是字串,這些每個大版本都可能動。別背,學會去官方的 routing 和 on-demand 文件查,並且判斷手上這篇教學過期了沒。看到還在推 hybrid 的,你就知道它至少停在 v5 以前。決定一頁走名冊還是現場的,只有 output 和 prerender。選的時候只問一句:「這頁需要當下才知道的東西嗎?」需要就走現場,不需要就走名冊。
Day 16:分頁與轉址延續名冊這一側:文章變多後,接著會遇到分頁與轉址。下一篇會看 getStaticPaths 搭 paginate() 如何把文章切成多頁,以及 Astro 如何一次處理 redirects。
本日程式碼:step-15|只看這天的改動:step-14...step-15