用 Astro 搭建一个静态博客
从零开始,用 Astro 的内容集合(Content Collections)组织 Markdown,构建期生成静态页面,顺便聊聊为什么静态站点正在回归。
目录(5 节)
静态站点生成器(SSG)这几年悄悄回到了舞台中央。原因不复杂:内容为主的网站根本不需要服务器渲染每一篇文章。
为什么选 Astro
比较过 Hexo、Hugo、Eleventy,最后选了 Astro。理由有三条:
- 默认零 JavaScript。页面只是 HTML 和 CSS,除非你显式写了
<script> - 内容集合类型安全。Markdown 的 frontmatter 用 Zod 校验,写错字段会在构建时直接报错
- 可以局部上交互。搜索框这种需要脚本的地方,用普通的
<script>就行,不会被强行塞进框架运行时
内容集合怎么用
Astro 5 之后引入了 Content Layer API,配置文件从 src/content/config.ts 挪到了 src/content.config.ts:
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
const blog = defineCollection({
loader: glob({ base: './src/content/blog', pattern: '**/*.md' }),
schema: z.object({
title: z.string(),
description: z.string().default(''),
pubDate: z.coerce.date(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
注意 z 要从 astro/zod 导入。从 astro:content 导入虽然在部分版本还能用,但已经是过时写法了。
路由与静态路径
文章页用 rest 参数匹配,这样将来想把文章放在子目录里也不用改路由:
---
import { getCollection, render } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { id: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content, headings } = await render(post);
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
有个容易踩的坑:getStaticPaths 返回的 params 值必须是字符串。数字会被静默忽略,然后你会在构建产物里找不到那个页面。
阅读时长
我没有引入 remark-reading-time 这类插件。Astro 7 默认用 Sätteri 处理 Markdown,要挂 remark 插件得额外装 @astrojs/markdown-remark 并改配置。既然只是数一下字数,不如自己算:
function countWords(text: string): number {
const cjk = text.match(/[\u4e00-\u9fff]/g)?.length ?? 0;
const latin = text.replace(/[\u4e00-\u9fff]/g, ' ');
const words = latin.match(/[A-Za-z0-9'-]+/g)?.length ?? 0;
return cjk + words;
}
中文按字算,英文按词算,最后套一个「中文 400 字/分钟、英文 220 词/分钟」的经验值。不精确,但够用了。
部署
npm run build 之后,dist/ 就是一个完整的网站,扔到任何静态托管上都能跑。Cloudflare Pages、Vercel、Netlify、GitHub Pages 都行,不需要 Node 运行时。
静态的代价是构建时间随文章数量线性增长。不过对于个人博客,几千篇文章也才几秒钟,完全不是问题。