新闻 本站已迁移到 Astro + Fuwari 主题 文章 全站共 5 篇原创,持续更新中 浏览 欢迎在文章下方留言交流 RSS 订阅 /rss.xml 获取更新 关于 前端工程 / 设计系统 / 随笔 提示 评论由 Waline 驱动,数据存放在自己的数据库 新闻 本站已迁移到 Astro + Fuwari 主题 文章 全站共 5 篇原创,持续更新中 浏览 欢迎在文章下方留言交流 RSS 订阅 /rss.xml 获取更新 关于 前端工程 / 设计系统 / 随笔 提示 评论由 Waline 驱动,数据存放在自己的数据库
1018 字
5 分钟
Astro 内容集合:把 Markdown 变成类型安全的数据库

用 Markdown 写博客最舒服的部分是”只管写”,最难受的部分是”写完才发现少了一个字段”。

Astro 的内容集合(Content Collections)就是为了解决后者:给每一篇 Markdown 声明 schema,构建时校验,写错了直接报错,而不是等页面渲染出 undefined 才发现。

从”文件”到”数据源”#

旧的做法是把 src/pages 里的 Markdown 当成页面。新做法是把内容放进 src/content/blog/,当成数据,由页面去查询。

这个视角的转变带来三个好处:

  • 内容可以被打标签、排序、聚合,而不只是渲染成一个页面
  • frontmatter 有了类型,编辑器里能补全
  • 内容来源可以不止是本地文件——API、CMS、数据库都行

Astro 5 之后,内容集合从”约定目录”变成了显式的 loader:

import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };

glob loader 会扫描目录,把每个文件的 frontmatter 当数据、正文当 body,一起放进一个类型化的数据仓库。

Schema 里的几个实用技巧#

z.coerce.date() 而不是 z.date()#

YAML 里的 2026-03-04 会被解析成字符串,z.date() 会直接报错。coerce 会先转换再校验,省掉一堆手动 new Date()。

用 default() 让可选字段真的可选#

tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),

这样在页面里 post.data.tags.map(...) 不需要先判断 undefined。类型是 string[],不是 string[] | undefined。

用 transform() 做派生字段#

如果列表页需要”月-日”格式的短日期,与其在每个页面重复格式化,不如一次性算好:

pubDate: z.coerce.date().transform((date) => ({
date,
iso: date.toISOString(),
short: `${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`,
})),

代价是 schema 变复杂了,取值时要写 post.data.pubDate.date。我一般只在字段被三处以上复用时才这么做。

查询与渲染#

getCollection 返回的是数组,可以随便用 JS 处理:

import { getCollection, render } from 'astro:content';
const posts = await getCollection('blog', ({ data }) => !data.draft);
const sorted = posts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
// 单篇渲染
const { Content, headings } = await render(sorted[0]);

render() 返回的 headings 是自动生成的标题树,包含 depth、slug、text。做目录(TOC)不需要额外插件。

注意:render() 是异步的。旧版本可以直接 post.render(),新 API 改成独立函数,这样静态分析和 tree-shaking 都更友好。

动态路由:一篇一个页面#

内容集合不会自动生成路由,要在 [...slug].astro 里显式声明:

export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}

post.id 是相对于 base 的路径去掉扩展名。content/blog/hello.md 的 id 就是 hello;content/blog/2026/hello.md 的 id 是 2026/hello——所以用 rest 参数 [...slug] 更稳妥。

几个文档没写的坑#

构建期的草稿过滤要一致。 如果你在列表页过滤了 draft,但 getStaticPaths 没过滤,草稿依然会被构建成一个可访问的页面——只是没人链接到它。搜索引擎可能会找到。

排序要显式做。 getCollection 的返回顺序不保证是文件系统顺序,也不保证是日期顺序。永远自己 sort。

别在 schema 里塞业务逻辑。 我见过把阅读时长、相关文章推荐都放进 transform 的写法。构建会变慢,而且改一次逻辑要重新构建全部内容。这些应该在页面层算。

类型推导有时会偷懒。 复杂嵌套的 schema 推断偶尔会退化成 any,写一个显式的 type Post = CollectionEntry<'blog'> 更保险。

值得吗#

如果你只有三五篇文章,手写 frontmatter 加个类型声明也能过。

但一旦你需要:列表分页、标签聚合、年份归档、RSS、上一篇下一篇——这些都需要”把所有文章当成一个集合来查询”。内容集合就是为这个场景准备的。

它不复杂,只是要求你在写第一篇文章之前,先想清楚文章有哪些字段。这个约束本身,其实挺有价值。

Astro 内容集合:把 Markdown 变成类型安全的数据库
https://lumen-blog-3iq.pages.dev/posts/astro-content-collections/
作者
Lumen
发布于
2026-02-16
许可协议
CC BY-NC-SA 4.0
评论

评论还没接上

在项目根目录的 .env 里加上 PUBLIC_WALINE_SERVER_URL=你的 Waline 地址,重新构建即可启用。

怎么部署 Waline
目录
1
从”文件”到”数据源”
2
Schema 里的几个实用技巧
z.coerce.date() 而不是 z.date()
用 default() 让可选字段真的可选
用 transform() 做派生字段
3
查询与渲染
4
动态路由:一篇一个页面
5
几个文档没写的坑
6
值得吗
站点统计
文章 5
分类 3
标签 7
总字数 4949
最后活动 03/04
运行天数 1032 天
欢迎来到Lumen!

这里记录前端工程、设计系统,以及一些与技术无关的观察。慢慢看,不着急。