用 Astro 搭一个纯静态博客

这篇文章记录站点本身是怎么搭起来的。如果你也想搞一个,照着走一遍大概半小时。

环境要求

只需要 Node.js 22 以上版本:

node -v   # v22.12.0 或更高

目录结构

搭好之后长这样:

个人博客/
├── astro.config.mjs      # 站点配置:域名、集成、Markdown 高亮
├── package.json
├── public/               # 原样拷贝到产物里的文件(favicon、robots.txt)
└── src/
    ├── consts.ts         # 站点名、导航、页脚链接都在这
    ├── content.config.ts # 内容模型:文章有哪些字段
    ├── components/       # 可复用的小组件
    ├── layouts/          # 页面骨架
    ├── styles/           # 全局样式
    ├── pages/            # 每个文件对应一个网址
    └── content/
        ├── posts/        # 文章,一篇一个 .md
        └── works/        # 作品,一个项目一个 .md

关键约定是 src/pages/ 下的文件路径就是网址。比如 src/pages/about.astro 对应 /about/,src/pages/posts/index.astro 对应 /posts/。

内容模型

文章有哪些字段,由 src/content.config.ts 里的 schema 决定:

const posts = defineCollection({
	loader: glob({ base: './src/content/posts', pattern: '**/*.{md,mdx}' }),
	schema: ({ image }) =>
		z.object({
			title: z.string(),
			description: z.string(),
			pubDate: z.coerce.date(),
			updatedDate: z.coerce.date().optional(),
			tags: z.array(z.string()).default([]),
			cover: z.optional(image()),
			draft: z.boolean().default(false),
		}),
});

这样做的好处是:frontmatter 写错了会在构建时报错,而不是等到页面上出现一个空白标题才发现。比如把 pubDate 拼成 pubdate,构建直接失败并告诉你是哪一篇。

一篇文章是怎么变成网页的

流程比想象的简单:

  1. src/pages/posts/[...slug].astro 里的 getStaticPaths() 把 posts/ 下每个 md 文件变成一个路径
  2. 页面拿到文件内容,render() 把 Markdown 正文转成 HTML,同时给出目录用的标题列表
  3. PostLayout.astro 套上页头页脚,输出成一个静态 HTML 文件

整个过程在构建时完成,访问者拿到的就是最终的 HTML。

发一篇文章

新建 src/content/posts/文章名.md,头部写上:

---
title: 文章标题
description: 一句话摘要,会显示在列表页
pubDate: 2026-09-24
tags: ['标签一', '标签二']
---

正文从这开始写。

保存,刷新浏览器就能看到。文件名会成为网址的一部分,所以建议用英文或拼音,比如 astro-blog-setup.md 对应 /posts/astro-blog-setup/。

构建与部署

npm run build     # 产物输出到 dist/
npm run preview   # 用本地服务器预览构建结果

dist/ 里就是完整的静态站点,把它放到任何一台能跑 Nginx 的服务器上都能直接访问,不需要 Node 运行时。

几个设计上的取舍

不加载网络字体。 中文站点用 webfont 动辄几 MB,首屏会明显闪一下。这里全部使用系统字体栈,标题走衬线、正文走无衬线,零请求、零闪烁。

不做深色模式。 这个站点的定位是「像纸一样的阅读体验」,暖白底配深色字是它的一部分。强行加一套深色配色反而会让设计意图变得模糊。

导航只有五项。 每多一项,读者做选择的成本就高一分。文章、作品、标签、关于、友链 —— 够了。