發布 Astro 網站
使用 Astro 的零執行時期 SSG 方法、部分水合 (partial hydration) 和廣泛的 Markdown 支援,打造快速、內容驅動的靜態網站。
目錄
- 快速開始
- 何時不該使用
- 專案結構
- SSG vs SSR vs 混合模式
- 內容集合 — 內容層 API、自訂載入器
- 語法反白 — Shiki、轉換器 (Transformers)、Expressive Code
- 圖表整合 — Mermaid、PlantUML、暗色模式主題
- 客戶端搜尋 — Pagefind (控制項、權重)、Fuse.js
- 版本化文件 — 多版本
- 國際化 (i18n) — 路由、後備機制
- 常見模式 — 分頁、標籤、RSS、表單
- 效能最佳實務 — 預先擷取、關鍵 CSS
- 部署 — Netlify
- 部署前檢查清單
- 測試與品質 — Vitest、Playwright、連結檢查
- 疑難排解
快速開始
# 建立新專案 (使用 Blog 範本用於 Markdown 網站)
npm create astro@latest
# 開發
npm run dev # 本機伺服器位於 http://localhost:4321
npm run build # 在 dist/ 中產生靜態檔案
npm run preview # 預覽正式環境建置
何時不該使用
此技能專注於靜態網站生成 (SSG)。對於以下情況,請考慮其他方法:
- 即時資料應用程式 - 使用 SSR 模式並搭配資料庫連線
- 使用者認證流程 - 需要伺服器端 session 處理
- 具有動態庫存的電子商務 - 使用混合模式或完整的 SSR
- 單頁應用程式 (SPA) - 直接考慮 React/Vue 框架
如需混合 SSG+SSR 模式,請參閱 Astro 的配接器 (adapter) 文件。
專案結構
src/
components/ # Astro、React、Vue、Svelte 元件
content/ # 內容集合 (Markdown/MDX)
config.ts # 集合 schema
docs/ # 範例集合
layouts/ # 帶有 slot 的頁面包裝器
pages/ # 基於檔案的頁面路由 (File-based routing)
public/ # 靜態資產 (圖片、字型、favicon)
astro.config.mjs # 框架設定
SSG vs SSR vs 混合模式
| 模式 | 頁面渲染時機 | 使用案例 |
|---|---|---|
| SSG (預設) | 建置時 | 部落格、文件、行銷網站 |
| SSR | 每次請求 | 動態資料、個人化 |
| 混合模式 | 兩者混合 | 靜態頁面 + 動態端點 |
對於純靜態網站,使用預設的 output: 'static' - 不需要配接器。
內容集合
舊有模式 (Astro 4.x)
在 src/content/config.ts 中定義 schema:
import { defineCollection, z } from "astro:content";
export const collections = {
docs: defineCollection({
schema: z.object({
title: z.string(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
order: z.number().optional(),
draft: z.boolean().default(false)
})
})
};
內容層 API (Astro 5.0+)
使用 glob() 載入器的新模式 - 對於大型網站,建置速度最多可提升 75%:
// src/content.config.ts (注意:檔案名稱不同)
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/data/blog' }),
schema: ({ image }) => z.object({
title: z.string(),
pubDate: z.coerce.date(),
draft: z.boolean().default(false),
cover: image(), // 驗證圖片存在
author: reference('authors'), // 跨集合參考
})
});
export const collections = { blog };
進階 Schema 模式
schema: ({ image }) => z.object({
cover: image(), // 驗證 src/ 中的圖片
category: z.enum(['tech', 'news']),
author: reference('authors'), // 跨集合參考
relatedPosts: z.array(reference('blog')).optional(),
})
自訂載入器 (遠端內容)
從外部 API (GitHub 發布、CMS 等) 擷取內容:
// src/loaders/github-releases.ts
import type { Loader } from 'astro/loaders';
export function githubReleasesLoader(repo: string): Loader {
return {
name: 'github-releases',
load: async ({ store, logger }) => {
logger.info(`Fetching releases for ${repo}`);
const response = await fetch(`https://api.github.com/repos/${repo}/releases`);
const releases = await response.json();
for (const release of releases) {
store.set({
id: release.tag_name,
data: {
version: release.tag_name,
published_at: release.published_at,
body: release.body // Markdown 發布說明
}
});
}
}
};
}
在 content.config.ts 中註冊:
import { githubReleasesLoader } from './loaders/github-releases';
const releases = defineCollection({
loader: githubReleasesLoader('owner/repo'),
schema: z.object({
version: z.string(),
published_at: z.string(),
body: z.string(),
})
});
查詢並渲染集合:
---
import { getCollection } from "astro:content";
export async function getStaticPaths() {
const docs = await getCollection("docs");
return docs.map(doc => ({
params: { slug: doc.slug },
props: { doc }
}));
}
const { doc } = Astro.props;
const { Content } = await doc.render();
---
<article>
<h1>{doc.data.title}</h1>
<Content />
</article>
語法反白
基本 Shiki 設定
import { defineConfig } from "astro/config";
export default defineConfig({
markdown: {
shikiConfig: {
theme: "github-dark",
wrap: true
}
}
});
雙主題 (亮色/暗色)
shikiConfig: {
themes: {
light: 'github-light',
dark: 'github-dark',
},
}
新增 CSS 以切換主題:
@media (prefers-color-scheme: dark) {
.astro-code, .astro-code span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
}
}
行號反白與轉換器 (Transformers)
```typescript {2,4}
const a = 1;
const b = 2; // 反白
const c = 3;
console.log(a + b + c); // 反白
```
Shiki 轉換器 (Astro 4.14+):
import { transformerNotationFocus, transformerNotationDiff } from '@shikijs/transformers';
shikiConfig: {
transformers: [transformerNotationFocus(), transformerNotationDiff()],
}
在程式碼中使用註解標記:
// [!code focus]- 聚焦此行// [!code ++]- 標記為新增 (綠色)// [!code --]- 標記為刪除 (紅色)
Expressive Code (推薦用於文件)
豐富的程式碼區塊,包含複製按鈕、檔案名稱、差異反白:
npm install astro-expressive-code
import expressiveCode from 'astro-expressive-code';
export default defineConfig({
integrations: [expressiveCode()],
});
功能:複製按鈕、檔案索引標籤、行標記、終端機框架、文字標記。
圖表整合
Mermaid (推薦)
安裝 Astro 整合套件:
npm install astro-mermaid mermaid
// astro.config.mjs
import { defineConfig } from 'astro/config';
import mermaid from 'astro-mermaid';
export default defineConfig({
integrations: [mermaid({ theme: 'default' })]
});
在 Markdown 中使用:
```mermaid
graph TD;
A-->B;
B-->C;
```
功能:客戶端渲染、自動主題切換、可離線使用、無需 Playwright。
替代方案 (建置時靜態 SVG): 使用 rehype-mermaid 搭配 Playwright 來預渲染圖表 (需要執行 npx playwright install --with-deps)。
暗色模式主題策略:
- CSS 變數 - 讓瀏覽器在執行時期解析顏色:
// mermaid 設定
mermaid.initialize({
theme: 'base',
themeVariables: {
primaryColor: 'var(--diagram-primary)',
lineColor: 'var(--diagram-line)'
}
});
- Picture 元素 - 產生兩種主題,使用媒體查詢切換:
<picture>
<source srcset="/diagrams/flow-dark.svg" media="(prefers-color-scheme: dark)">
<img src="/diagrams/flow-light.svg" alt="流程圖">
</picture>
- 行內 SVG - 使用 CSS 定位 SVG 類別 (風險:樣式衝突) - 加入無障礙標準,可考慮使用 role='image' 或 role="presentation":
.dark .mermaid-svg .node rect {
fill: var(--bg-dark);
}
PlantUML
npx astro add plantuml
在 Markdown 中使用:
```plantuml
@startuml
Alice -> Bob: Hello
Bob --> Alice: Hi!
@enduml
```
客戶端搜尋
Pagefind (推薦用於大型網站)
零設定靜態搜尋,在建置時建立索引:
npm install pagefind
新增至 package.json:
{
"scripts": {
"build": "astro build && npx pagefind --site dist",
"postbuild": "pagefind --site dist"
}
}
在元件中使用:
<link href="/pagefind/pagefind-ui.css" rel="stylesheet" />
<script src="/pagefind/pagefind-ui.js" type="text/javascript"></script>
<div id="search"></div>
<script>
window.addEventListener('DOMContentLoaded', () => {
new PagefindUI({ element: '#search', showSubResults: true });
});
</script>
功能:無需外部服務、可離線使用、自動索引、小體積 (~10KB)。
細粒度索引控制:
<!-- 僅索引主要內容,不含頁首/側邊欄 -->
<main data-pagefind-body>
<h1 data-pagefind-meta="title">頁面標題</h1>
<p data-pagefind-weight="10">重要的介紹文字</p>
<!-- 從搜尋片段中排除 -->
<nav data-pagefind-ignore>
<a href="/related">相關文章</a>
</nav>
</main>
| 屬性 | 用途 |
|---|---|
data-pagefind-body | 將索引範圍限制在此元素內 |
data-pagefind-ignore | 從索引中排除此元素 |
data-pagefind-meta="key" | 定義後設資料欄位 |
data-pagefind-weight="10" | 提升相關性 (預設值:1) |
Pagefind vs Fuse.js
| 功能 | Pagefind | Fuse.js |
|---|---|---|
| 架構 | 預先建置的二進位區塊 | 執行時期記憶體中 |
| 頻寬 | 低 (僅載入所需的區塊) | 高 (下載完整索引) |
| 可擴展性 | 10,000+ 頁面 | < 500 頁面 |
| 多語言 | 原生詞幹處理 | 需手動設定 |
| 使用案例 | 全域網站搜尋 | 小型列表過濾 |
Fuse.js (輕量級替代方案)
適用於需要自訂 UI 的較小網站:
---
import { getCollection } from "astro:content";
const posts = await getCollection('blog');
const searchIndex = JSON.stringify(posts.map(post => ({
title: post.data.title,
slug: post.slug,
body: post.body.slice(0, 500)
})));
---
<input type="search" id="search" placeholder="搜尋..." />
<ul id="results"></ul>
<script define:vars={{ searchIndex }}>
import Fuse from 'fuse.js';
const fuse = new Fuse(JSON.parse(searchIndex), {
keys: ['title', 'body'],
threshold: 0.3
});
document.getElementById('search').addEventListener('input', (e) => {
const results = fuse.search(e.target.value);
const resultsEl = document.getElementById('results');
// 安全地清除之前的結果
resultsEl.replaceChildren();
// 使用安全的 DOM 方法建構結果
results.forEach(r => {
const li = document.createElement('li');
const link = document.createElement('a');
link.href = `/blog/${r.item.slug}`;
link.textContent = r.item.title;
li.appendChild(link);
resultsEl.appendChild(li);
});
});
</script>
對於企業需求,請考慮 Algolia (託管搜尋 API)。
版本化文件
多版本文件模式
使用基於資料夾的結構:
src/content/docs/
v1/
getting-started.md
api-reference.md
v2/
getting-started.md
api-reference.md
new-feature.md
國際化 (i18n)
在 astro.config.mjs 中設定:
export default defineConfig({
i18n: {
defaultLocale: "en",
locales: ["en", "fr", "es"],
routing: {
prefixDefaultLocale: false
}
}
});
按語言環境組織內容:
src/content/docs/
en/
getting-started.md
fr/
getting-started.md
在元件中偵測語言環境:
---
const locale = Astro.currentLocale || 'en';
---
缺少翻譯時的回退機制
當翻譯不存在時,顯示帶有提示橫幅的英文內容:
---
// src/pages/[lang]/[...slug].astro
import { getCollection, getEntry } from "astro:content";
const languages = ['en', 'es', 'fr'];
const defaultLang = 'en';
export async function getStaticPaths() {
const englishDocs = await getCollection('docs', ({ id }) => id.startsWith('en/'));
const paths = [];
for (const doc of englishDocs) {
const slug = doc.id.replace(/^en\//, '');
for (const lang of languages) {
const localizedId = `${lang}/${slug}`;
const localizedDoc = await getEntry('docs', localizedId);
paths.push({
params: { lang, slug },
props: {
entry: localizedDoc || doc, // 回退至英文
isFallback: !localizedDoc
}
});
}
}
return paths;
}
const { entry, isFallback } = Astro.props;
const { Content } = await entry.render();
---
{isFallback && (
<div class="translation-notice">
此頁面尚無您的語系版本。
</div>
)}
<Content />
常見模式
分頁列表
對於超過 50 篇文章的網站,將其分割成多個頁面:
---
// src/pages/blog/page/[page].astro
import { getCollection } from "astro:content";
const POSTS_PER_PAGE = 10;
export async function getStaticPaths() {
const allPosts = await getCollection("blog");
const totalPages = Math.ceil(allPosts.length / POSTS_PER_PAGE);
const paths = [];
for (let page = 1; page <= totalPages; page++) {
paths.push({
params: { page: String(page) },
props: { page, totalPages }
});
}
return paths;
}
const { page, totalPages } = Astro.props;
const allPosts = await getCollection("blog");
const paginatedPosts = allPosts.slice(
(page - 1) * POSTS_PER_PAGE,
page * POSTS_PER_PAGE
);
---
<h1>部落格 - 第 {page} 頁</h1>
<ul>
{allPosts.map(post => <li><a href={`/blog/${post.slug}`}>{post.data.title}</a></li>)}
</ul>
<nav>
{page > 1 && <a href={`/blog/page/${page - 1}`}>上一頁</a>}
{page < totalPages && <a href={`/blog/page/${page + 1}`}>下一頁</a>}
</nav>
多標籤篩選
使用 URL 搜尋參數實作 AND/OR 邏輯:
---
// src/pages/blog/[...slug].astro
import { getCollection } from 'astro:content';
import { parseSlug } from './_helpers';
export async function getStaticPaths() {
const posts = await getCollection('blog');
const paths = [];
// 根據標籤結構產生病態路由
for (const post of posts) {
const tags = post.data.tags || [];
// 以多種方式串接
if (tags.length === 1) {
paths.push({ params: { slug: [tags[0]] }, props: { post } });
} else if (tags.length >= 2) {
paths.push({ params: { slug: tags }, props: { post } });
}
}
// 包含不含標籤的文章
paths.push({ params: { slug: undefined }, props: { post: null } });
return paths;
}
const { post } = Astro.props;
---
{post && (
<article>
<h2>{post.data.title}</h2>
<p>{post.data.description}</p>
<ul>
{post.data.tags?.map(tag => <li>{tag}</li>)}
</ul>
</article>
)}
// src/pages/blog/_helpers.ts
export function parseSlug(slug: string[] | undefined): string[] {
if (!slug) return [];
return Array.isArray(slug) ? slug : [slug];
}
標籤索引頁面
從集合動態產生標籤頁面:
---
// src/pages/tags/[tag].astro
export async function getStaticPaths() {
const posts = await getCollection('blog');
const tags = [...new Set(posts.flatMap(p => p.data.tags || []))];
return tags.map(tag => ({
params: { tag },
props: { tag, posts: posts.filter(p => p.data.tags?.includes(tag)) }
}));
}
const { tag, posts } = Astro.props;
---
<h1>標籤:{tag}</h1>
<ul>
{posts.map(post => (
<li><a href={`/blog/${post.slug}`}>{post.data.title}</a></li>
))}
</ul>
RSS Feed
使用 Astro 內建的 RSS 支援:
// astro.config.mjs
import { defineConfig } from 'astro/config';
// 自訂 RSS 產生器函式
async function generateRSS() {
const posts = await getCollection('blog');
const site = 'https://example.com';
const items = posts
.filter(p => !p.data.draft)
.sort((a, b) => b.data.pubDate - a.data.pubDate)
.slice(0, 20)
.map(post => ({
title: post.data.title,
link: `${site}/blog/${post.slug}`,
pubDate: post.data.pubDate,
description: post.data.description,
}));
return new Response(
`
<rss version="2.0">
<channel>
<title>我的部落格</title>
<link>${site}</link>
<description>最近的文章</description>
${items.map(item => `
<item>
<title>${escapeXML(item.title)}</title>
<link>${escapeXML(item.link)}</link>
<pubDate>${item.pubDate.toUTCString()}</pubDate>
<description>${escapeXML(item.description)}</description>
</item>
`).join('')}
</channel>
</rss>
`,
{ headers: { 'Content-Type': 'application/rss+xml' } }
);
}
function escapeXML(str: string): string {
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
表單處理
靜態網站通常需要外部表單服務。推薦的選項:
| 服務 | 優點 | 限制 |
|---|---|---|
| Formspree | 簡單的 API,免費方案 | 每月提交次數有限 |
| Netlify Forms | 原生 Netlify 整合,零伺服器 | 僅限 Netlify 託管 |
| Google Forms | 免費,可擴展 | 自訂選項較少 |
| Getform | 檔案上傳,電子郵件通知 | 付費方案才有進階功能 |
Netlify Forms 範例:
<form name="contact" netlify>
<input type="text" name="name" placeholder="您的姓名" required />
<input type="email" name="email" placeholder="您的電子郵件" required />
<textarea name="message" placeholder="您的訊息"></textarea>
<button type="submit">傳送</button>
</form>
Formspree 範例:
<form action="https://formspree.io/f/{your-form-id}" method="POST">
<input type="text" name="name" placeholder="您的姓名" required />
<input type="email" name="email" placeholder="您的電子郵件" required />
<textarea name="message" placeholder="您的訊息"></textarea>
<button type="submit">傳送</button>
</form>
效能最佳實務
預先擷取 (Prefetching)
import { defineConfig } from 'astro/config';
import prefetch from '@astrojs/prefetch';
export default defineConfig({
integrations: [prefetch({
selector: 'a[href^="/"]',
throttle: 3
})]
});
策略:
- 使用
intent進行觸控預先擷取 (懸停時載入) - 使用
hover立即載入 - 對首頁可見的導覽連結使用
viewport
關鍵 CSS 提取
啟用自動關鍵 CSS 內嵌 (適用於 Netlify、Vercel、Cloudflare):
import { defineConfig } from 'astro/config';
export default defineConfig({
build: {
inlineStylesheets: 'auto'
}
});
圖片最佳化
import { defineConfig } from 'astro/config';
import image from '@astrojs/image';
export default defineConfig({
integrations: [image({
serviceEntryPoint: '@astrojs/image/sharp'
})]
});
HTML 壓縮
import { defineConfig } from 'astro/config';
import compress from 'astro-compress';
export default defineConfig({
integrations: [compress()]
});
移除未使用的 CSS
import { defineConfig } from 'astro/config';
import purgecss from 'astro-purgecss';
export default defineConfig({
integrations: [purgecss()]
});
部署
Netlify
# 安裝 Netlify 配接器
npx astro add netlify
# netlify.toml
[build]
command = "npm run build"
publish = "dist"
[[redirects]]
from = "/blog/*"
to = "/blog/:splat"
status = 200
Vercel
npx astro add vercel
GitHub Pages
npx astro add node
name: Deploy to GitHub Pages
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./dist
GCP / Firebase
npx astro add node
# 在 astro.config.mjs 中將 output 設定為 'static'
# 並在 firebase.json 中將 public 設定為 'dist'
部署至 Firebase Hosting:
npm install -g firebase-tools
firebase init
firebase deploy --only hosting
部署前檢查清單
-
npm run build無錯誤完成 - 所有內部連結有效 (考慮使用
hyperlink) - 圖片路徑使用 ESM 匯入 (
import logo from "./logo.png") - 環境變數在
astro.config.mjs和public/env.js中均正確設定 - 自訂 404 頁面 (
src/pages/404.astro) - SEO 中繼標籤 (使用
@astrojs/seo) - 啟用 Sitemap 生成 (
@astrojs/sitemap) - 殘缺檔案處理 (
public/_redirects或astro.config.mjs中的trailingSlash) - 自訂字型已預載 (使用
<link rel="preload">) - 測試 Lighthouse 效能分數 (目標:> 90)
- 無障礙檢查 (使用 axe 工具)
測試與品質
Vitest (單元測試)
npm install -D vitest
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts']
}
});
Playwright (端對端測試)
npm install -D @playwright/test
npx playwright install
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
webServer: {
command: 'npm run preview',
port: 4321,
reuseExistingServer: true
}
});
連結檢查
npm install -D hyperlink
{
"scripts": {
"check:links": "hyperlink dist/ --recursive --sources src/"
}
}
疑難排解
| 問題 | 解決方案 |
|---|---|
getCollection 回傳空值 | 確認路徑在 src/content/ 下且 schema 有效。嘗試簡化為不帶選擇器的 getCollection('blog')。 |
| 建置時圖片載入器錯誤 | 使用 ESM 匯入路徑 (import logo from "./logo.png"),而非字串 (<img src="/logo.png" />)。 |
| 水合 (Hydration) 錯誤 | 確認僅用戶端元件使用 JS;靜態 HTML 無需水合。使用 client:load 或 client:idle。 |
| Mermaid 主題變數未套用 | 使用建置時 SVG 方法 (圖片元素);CSS 變數無法在 Shadow DOM 內運作。 |
| i18n 路由問題 | 檢查特定 API 文件中的 prefixDefaultLocale: false。使用 Astro.currentLocale 進行語言環境偵測。 |
| 部署時 404 錯誤 | 檢查 SPA 模式的伺服器重新導向設定。對於 Netlify/Vercel,使用 [[redirects]] 或 @astrojs/vercel 的 isr 設定。 |
| 元件未找到 | 確認元件路徑相對於使用點正確。使用絕對路徑 (從 src/ 開始) 以避免混淆。 |
