Astro 内容层:把博客当作一个工程来维护

Content Collections 用 zod 校验 frontmatter,让文章字段的错误在构建期就暴露,而不是等你发现某篇文章从列表里消失了。

2 分钟Astro前端工程实践

我搭过三套博客:Hexo、Hugo、现在是 Astro。前两套的痛点不在渲染,而在内容本身没有类型。

没有类型的内容层是什么样

Hexo 时代我犯过一个低级错误:把 date 写成了 data。结果那篇文章没报错,只是安静地从时间线里消失了。我是三个月后翻旧文才发现的。

这类问题的本质是:Markdown 的 frontmatter 是一段 YAML,而 YAML 对你的字段名没有任何约束。渲染器拿不到 data 就当你没写,继续用默认值往下跑。

Content Collections 的做法

Astro 的思路是把内容声明成一个带 schema 的集合:

// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';

const posts = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/posts' }),
  schema: z.object({
    title: z.string().max(120),
    description: z.string().max(300),
    publishedAt: z.coerce.date(),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
  }),
});

export const collections = { posts };

现在 data 会直接让构建失败:

✗ src/content/posts/2026/foo.md
  publishedAt: Required

失败在构建期,而不是三个月后的某个下午。这一条就够了。

附带的好处

查询变成类型安全的。 getCollection('posts') 返回的每一项, data.publishedAt 都是 Date 而不是字符串,data.tags 一定是数组。你不需要在模板里写 post.tags || [] 这种防御性代码。

排序逻辑可以集中。 z.coerce.date() 保证所有日期都是 Date 对象,于是按时间排序只是一行比较函数,不会出现「有的文章是字符串,比较时错位」的经典 bug。

draft 是一等公民。 我在 schema 里放 draft: boolean,所有列表查询统一过滤:

const posts = await getCollection('posts', ({ data }) => !data.draft);

未完成的文章可以长期躺在仓库里跟着代码一起提交,永远不会被误发布。

一点代价

内容层的抽象意味着你得接受 Astro 的目录约定。如果你有一批历史文章散落在奇怪的路径下,迁移时得先整理一遍。

我的做法是迁移时顺手把 URL 也规范了:/posts/年份/slug/。旧链接用一份 redirects 配置兜住 —— 这是个一次性成本,但换来的是往后几年都不用再想这件事。

工程化不是为了让你写得更快,而是为了让错误更早暴露。

← 全部文章