用 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、上一篇下一篇——这些都需要”把所有文章当成一个集合来查询”。内容集合就是为这个场景准备的。
它不复杂,只是要求你在写第一篇文章之前,先想清楚文章有哪些字段。这个约束本身,其实挺有价值。
评论还没接上
在项目根目录的
怎么部署 Waline.env里加上PUBLIC_WALINE_SERVER_URL=你的 Waline 地址,重新构建即可启用。