发布 Astro 网站
使用 Astro 的零运行时 SSG 方法、部分水合和广泛的 Markdown 支持,构建快速、内容驱动的静态网站。
目录
- 快速入门
- 何时不使用
- 项目结构
- SSG vs SSR vs Hybrid
- 内容集合 — Content Layer API, 自定义加载器
- 语法高亮 — Shiki, 转换器, Expressive Code
- 图表集成 — Mermaid, PlantUML, 暗色模式主题
- 客户端搜索 — Pagefind (控件, 权重), Fuse.js
- 版本化文档 — 多版本
- 国际化 — 路由, 回退
- 常见模式 — 分页, 标签, RSS, 表单
- 性能最佳实践 — 预取, 关键 CSS
- 部署 — Netlify
- 部署前检查清单
- 测试与质量 — Vitest, Playwright, 链接检查
- 故障排除
快速入门
# 创建新项目 (Markdown 站点使用 Blog 模板)
npm create astro@latest
# 开发
npm run dev # 本地服务器在 http://localhost:4321
npm run build # 在 dist/ 目录生成静态文件
npm run preview # 预览生产构建
何时不使用
此技能专注于静态网站生成 (SSG)。对于其他场景,请考虑其他方法:
- 实时数据应用 - 使用 SSR 模式配合数据库连接
- 用户认证流程 - 需要服务器端会话处理
- 具有动态库存的电子商务 - 使用混合模式或完整 SSR
- 单页应用 (SPA) - 直接考虑使用 React/Vue 框架
如需了解 SSG+SSR 混合模式,请参阅 Astro 的适配器文档。
项目结构
src/
components/ # Astro, React, Vue, Svelte 组件
content/ # 内容集合 (Markdown/MDX)
config.ts # 集合架构
docs/ # 示例集合
layouts/ # 带有插槽的页面包装器
pages/ # 基于文件的路由
public/ # 静态资源 (图片, 字体, 网站图标)
astro.config.mjs # 框架配置
SSG vs SSR vs Hybrid
| 模式 | 页面渲染时机 | 使用场景 |
|---|---|---|
| SSG (默认) | 构建时 | 博客, 文档, 营销网站 |
| SSR | 每次请求 | 动态数据, 个性化 |
| 混合 | 两者混合 | 静态页面 + 动态端点 |
对于纯静态站点,使用默认的 output: 'static' — 无需适配器。
内容集合
传统模式 (Astro 4.x)
在 src/content/config.ts 中定义架构:
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)
})
})
};
Content Layer 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: ({ image }) => z.object({
cover: image(), // 验证 src/ 中的图片
category: z.enum(['tech', 'news']),
author: reference('authors'), // 跨集合引用
relatedPosts: z.array(reference('blog')).optional(),
})
自定义加载器 (远程内容)
从外部 API (GitHub releases, 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;
}
}
行高亮和转换器
```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: 你好
Bob --> Alice: 嗨!
@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 i = 1; i <= totalPages; i++) {
paths.push({
params: { page: String(i) },
props: {
page: i,
totalPages,
posts: allPosts.slice((i - 1) * POSTS_PER_PAGE, i * POSTS_PER_PAGE)
}
});
}
return paths;
}
const { page, totalPages, posts } = Astro.props;
---
<div class="pagination">
{page > 1 && <a href={`/blog/page/${page - 1}`}>上一页</a>}
<span>第 {page} 页,共 {totalPages} 页</span>
{page < totalPages && <a href={`/blog/page/${page + 1}`}>下一页</a>}
</div>
标签系统
---
import { getCollection } from "astro:content";
export async function getStaticPaths() {
const allPosts = await getCollection("blog");
const tags = [...new Set(allPosts.flatMap(p => p.data.tags || []))];
return tags.map(tag => ({
params: { tag },
props: {
tag,
posts: allPosts.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
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
export async function GET(context) {
const posts = await getCollection('blog');
return rss({
title: '我的博客',
description: '我的 Astro 博客的 RSS Feed',
site: context.site,
items: posts.map(post => ({
title: post.data.title,
pubDate: post.data.pubDate,
description: post.data.description,
link: `/blog/${post.slug}/`,
})),
customData: `<language>zh-cn</language>`,
});
}
表单处理
对于静态站点,使用无服务器函数提交表单:
---
// src/pages/contact.astro
---
<form action="/api/contact" 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>
使用 Netlify Forms (零后端):
<form netlify name="contact">
<input type="hidden" name="form-name" value="contact" />
<!-- 表单字段 -->
</form>
性能最佳实践
预取
内置的客户端路由器支持预取:
export default defineConfig({
prefetch: true
});
或使用指令手动控制:
<a href="/about" data-astro-prefetch>关于我们</a>
关键 CSS
自动内联关键 CSS (默认启用)。对于自定义,使用 criticalCSS:
export default defineConfig({
build: {
inlineStylesheets: 'auto' // 自动内联关键 CSS
}
});
图片优化
使用 @astrojs/image 进行优化、响应式图片:
---
import { Image, Picture } from '@astrojs/image/components';
import heroImage from '../assets/hero.jpg';
---
<Image src={heroImage} alt="英雄图" width={800} height={400} />
<Picture src={heroImage} formats={['avif', 'webp']} widths={[400, 800, 1200]} />
分析工具选择
| 服务 | 特点 |
|---|---|
| Google Analytics | 全面但体积大 |
| Plausible | 轻量、隐私优先、开源 |
| Fathom | 极简、GDPR 合规 |
| Cloudflare Web Analytics | 免费、无 JS、隐私优先 |
| Umami | 自托管、开源、轻量 |
部署
Netlify
npm install @astrojs/netlify
export default defineConfig({
output: 'static',
adapter: netlify()
});
重定向: 创建 public/_redirects:
/blog/* /blog/:splat 200
/* /index.html 404
功能: 零配置部署、自动 HTTPS、无服务器函数、拆分测试、表单处理。
Vercel
npm install @astrojs/vercel
export default defineConfig({
output: 'static',
adapter: vercel()
});
GitHub Pages
npm install @astrojs/github-pages
export default defineConfig({
site: 'https://<username>.github.io',
base: '/<repository>',
adapter: githubPages()
});
GCP / Firebase
npm install @astrojs/cloudflare # 与 GCP 上的 Cloudflare Workers 类似
对于 Firebase Hosting:
// firebase.json
{
"hosting": {
"public": "dist",
"ignore": ["firebase.json", "**/.*"],
"rewrites": [{
"source": "**",
"destination": "/index.html"
}]
}
}
部署前检查清单
- 运行
astro check并修复类型错误 - 运行
astro build(无错误) - 预览生产构建:
astro preview - 测试所有内部链接和导航
- 验证表单功能
- 检查分析是否加载
- 测试暗色模式
- 验证社交媒体元数据 (Open Graph)
- 测试使用键盘导航的辅助功能
- 检查 SEO 设置:
robots.txt,sitemap.xml,rss.xml - 验证构建大小 (
dist/目录)
测试与质量
Vitest (单元测试)
npm install -D vitest
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.{js,ts}']
}
});
示例测试:
import { describe, it, expect } from 'vitest';
describe('我的组件', () => {
it('应该正确渲染', () => {
// 测试逻辑
expect(true).toBe(true);
});
});
Playwright (E2E 测试)
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',
url: 'http://localhost:4321',
reuseExistingServer: true
}
});
示例 E2E 测试:
import { test, expect } from '@playwright/test';
test('主页应该加载', async ({ page }) => {
await page.goto('http://localhost:4321');
await expect(page).toHaveTitle(/我的网站/);
});
test('导航应该有效', async ({ page }) => {
await page.goto('http://localhost:4321');
await page.click('text=关于');
await expect(page).toHaveURL(/\/about/);
});
链接检查
npm install -D broken-link-checker
npx broken-link-checker http://localhost:4321 --recursive
故障排除
| 问题 | 解决方案 |
|---|---|
| 构建错误 "cannot find module '@astrojs/...'" | 安装适配器:npm install @astrojs/netlify |
| 页面返回 404 | 检查文件名是否匹配路由规则 (例如 src/pages/about.md 映射到 /about) |
| Content layer 内容未显示 | 验证 src/content.config.ts 中的路径模式 |
| Mermaid 图表未渲染 | 确保已安装 astro-mermaid 并添加了集成 |
| 搜索不工作 | 检查 pagefind --site dist 是否在构建后运行 |
| i18n 路由失败 | 验证 astro.config.mjs 中的语言配置 |
| 暗色模式未应用 | 如果使用 Shiki 主题,检查 CSS 媒体查询。验证集成是否支持主题切换 |