内容集合概述#
🙋 如果你曾经用纯 Markdown 文件管理博客,一定遇到过这些问题:
- Frontmatter 字段写错了,构建时才发现
- 不同文章的 Frontmatter 格式不一致
- 没有 TypeScript 类型提示,写代码靠猜
Astro 的内容集合(Content Collections)就是为解决这些问题而设计的。
什么是内容集合?#
内容集合是一种类型安全的内容管理方式。你可以:
- 📝 用 Zod Schema 定义 Frontmatter 结构
- ✅ 构建时自动验证所有内容文件
- 🎯 获得完整的 TypeScript 类型提示
- 🔍 用统一的 API 查询和渲染内容
// 定义一个博客集合const blog = defineCollection({ loader: glob({ base: './src/content/blog', pattern: '**/*.mdx' }), schema: z.object({ title: z.string().max(60), pubDate: z.coerce.date(), tags: z.array(z.string()).default([]), }),})Astro 5 Content Layer API vs 旧版 API#
Astro 5 引入了全新的 Content Layer API,相比旧版有重大改进:
| 特性 | 旧版 (v4.x) | Content Layer API (v5) |
|---|---|---|
| 配置文件 | src/content/config.ts | src/content.config.ts |
| 数据源 | 仅本地文件 | 本地 + 远程(CMS、API) |
| 性能 | 每次构建全量处理 | 增量构建,缓存优化 |
| Loader | 内置固定 | 可自定义 |
| render() | 条目方法 | 独立函数 |
🔶 迁移注意: 如果你从 Astro 4.x 升级,需要调整配置文件位置和 render 方式。
集合 vs 普通 Markdown 文件#
不使用集合时:
---// ❌ 手动导入,没有类型检查import posts from '../posts/*.md'
// 无法保证每篇文章都有这些字段posts.forEach((post) => { console.log(post.frontmatter.title) // 可能是 undefined})---使用集合后:
---// ✅ 类型安全的查询import { getCollection } from 'astro:content'
const posts = await getCollection('blog')
posts.forEach((post) => { console.log(post.data.title) // TypeScript 知道这是 string})---定义集合#
src/content.config.ts 配置文件#
这是 Astro 5 内容集合的配置入口:
import { defineCollection, z } from 'astro:content'import { glob, file } from 'astro/loaders'
// 定义博客集合const blog = defineCollection({ loader: glob({ base: './src/content/blog', pattern: '**/*.mdx' }), schema: z.object({ title: z.string(), pubDate: z.coerce.date(), draft: z.boolean().default(false), }),})
// 定义项目集合(JSON 数据)const projects = defineCollection({ loader: file('./src/content/projects/data.json'), schema: z.object({ id: z.string(), name: z.string(), link: z.string().url(), }),})
// 导出所有集合export const collections = { blog, projects }🎯 关键点:
- 文件必须命名为
content.config.ts(注意不是config.ts) - 必须导出
collections对象 - 每个集合需要
loader和schema
defineCollection() 函数#
defineCollection() 接受一个配置对象:
const myCollection = defineCollection({ // 数据加载器(必需) loader: glob({ base: './src/content/my-collection', pattern: '**/*.md' }),
// Schema 定义(推荐) schema: z.object({ title: z.string(), // ... }),})Zod Schema 验证#
Astro 使用 Zod 进行 Schema 验证。Zod 是一个 TypeScript-first 的验证库。
基本类型:
z.string() // 字符串z.number() // 数字z.boolean() // 布尔值z.date() // Date 对象z.array(z.string()) // 字符串数组常用修饰符:
z.string().optional() // 可选z.string().default('默认值') // 默认值z.string().max(60) // 最大长度z.number().min(0).max(100) // 范围限制z.coerce.date() // 自动转换为 Date完整示例:
const postSchema = z.object({ // 必需字段 title: z.string().max(60), pubDate: z.coerce.date(),
// 可选字段(带默认值) description: z.string().default(''), draft: z.boolean().default(false), tags: z.array(z.string()).default([]),
// 可选字段(可能不存在) coverImage: z.string().optional(),
// 联合类型 category: z.enum(['tech', 'life', 'other']),
// 自定义验证 readingTime: z.number().min(1).optional(),})内置 Loader:glob / file#
glob loader - 匹配多个文件:
import { glob } from 'astro/loaders'
// 匹配所有 .md 和 .mdx 文件const blog = defineCollection({ loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}', }), schema: postSchema,})
// 排除某些文件const docs = defineCollection({ loader: glob({ base: './src/content/docs', pattern: '**/[^_]*.md', // 排除以 _ 开头的文件 }), schema: docSchema,})file loader - 加载单个数据文件:
import { file } from 'astro/loaders'
// JSON 文件const projects = defineCollection({ loader: file('./src/content/projects/data.json'), schema: projectSchema,})
// YAML 文件const config = defineCollection({ loader: file('./src/content/config.yaml'), schema: configSchema,})JSON 数据格式:
[ { "id": "project-1", "name": "我的项目", "link": "https://example.com" }, { "id": "project-2", "name": "另一个项目", "link": "https://example.org" }]Schema 设计#
必需与可选字段#
const schema = z.object({ // 必需:不提供会报错 title: z.string(),
// 可选:可以不提供(值为 undefined) subtitle: z.string().optional(),
// 可选但有默认值:不提供时使用默认值 draft: z.boolean().default(false), tags: z.array(z.string()).default([]),})🔶 最佳实践: 必需字段尽量精简,其他用 default() 提供默认值。
字段类型#
字符串类型:
z.string() // 任意字符串z.string().min(1) // 非空字符串z.string().max(60) // 限制长度z.string().url() // URL 格式z.string().email() // 邮箱格式z.string().regex(/^[a-z]+$/) // 正则匹配z.enum(['a', 'b', 'c']) // 枚举值数字类型:
z.number() // 任意数字z.number().int() // 整数z.number().positive() // 正数z.number().min(0).max(100) // 范围日期类型:
// 推荐使用 coerce,自动将字符串转为 Datez.coerce.date()
// Frontmatter 中可以这样写:// pubDate: 2025-12-02// pubDate: "2025-12-02"// pubDate: "December 2, 2025"数组类型:
z.array(z.string()) // 字符串数组z.array(z.string()).min(1) // 至少一个元素z.array(z.string()).max(5) // 最多五个元素对象类型:
z.object({ name: z.string(), url: z.string().url(),})联合类型:
// 字符串或布尔值z.union([z.string(), z.boolean()])
// 简写(两种类型)z.string().or(z.boolean())自定义验证逻辑#
使用 .refine() 添加自定义验证:
const schema = z .object({ title: z.string(), pubDate: z.coerce.date(), lastModDate: z.coerce.date().optional(), }) .refine( (data) => { // 如果有 lastModDate,必须晚于 pubDate if (data.lastModDate) { return data.lastModDate >= data.pubDate } return true }, { message: '最后修改日期必须晚于发布日期', } )reference() 集合引用#
内容集合之间可以相互引用:
import { defineCollection, z, reference } from 'astro:content'
const authors = defineCollection({ loader: file('./src/content/authors/data.json'), schema: z.object({ id: z.string(), name: z.string(), avatar: z.string().url(), }),})
const blog = defineCollection({ loader: glob({ base: './src/content/blog', pattern: '**/*.mdx' }), schema: z.object({ title: z.string(), // 引用 authors 集合 author: reference('authors'), // 引用多篇相关文章 relatedPosts: z.array(reference('blog')).default([]), }),})在 Frontmatter 中使用:
---title: '我的文章'author: john-doe # 对应 authors 集合中的 idrelatedPosts: - another-post-id - yet-another-post---查询时解析引用:
---import { getEntry, getEntries } from 'astro:content'
const post = await getEntry('blog', 'my-post')
// 解析单个引用const author = await getEntry(post.data.author)
// 解析多个引用const relatedPosts = await getEntries(post.data.relatedPosts)---
<p>作者: {author.data.name}</p>查询集合#
getCollection() 获取所有条目#
---import { getCollection } from 'astro:content'
// 获取所有博客文章const allPosts = await getCollection('blog')
// 带过滤器const publishedPosts = await getCollection('blog', ({ data }) => { return data.draft !== true})
// 复杂过滤const recentTechPosts = await getCollection('blog', ({ data }) => { const oneMonthAgo = new Date() oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1)
return ( data.draft !== true && data.category === 'tech' && data.pubDate > oneMonthAgo )})---getEntry() 获取单个条目#
---import { getEntry } from 'astro:content'
// 通过集合名和 ID 获取const post = await getEntry('blog', 'hello-world')
if (post) { console.log(post.data.title)}
// 也可以传入引用对象const authorRef = post.data.authorconst author = await getEntry(authorRef)---getEntries() 批量获取#
---import { getEntries } from 'astro:content'
// 批量获取多个条目const featuredPosts = await getEntries([ { collection: 'blog', id: 'post-1' }, { collection: 'blog', id: 'post-2' }, { collection: 'blog', id: 'post-3' },])
// 解析引用数组const relatedPosts = await getEntries(post.data.relatedPosts)---过滤与排序#
---import { getCollection } from 'astro:content'
// 获取所有文章const posts = await getCollection('blog')
// 按发布日期排序(最新在前)const sortedPosts = posts.sort( (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
// 过滤草稿(生产环境)const publishedPosts = import.meta.env.PROD ? posts.filter((post) => !post.data.draft) : posts
// 按分类分组const postsByCategory = posts.reduce( (acc, post) => { const category = post.data.category if (!acc[category]) acc[category] = [] acc[category].push(post) return acc }, {} as Record<string, typeof posts>)
// 获取所有标签const allTags = [...new Set(posts.flatMap((post) => post.data.tags))]---CollectionEntry 类型#
每个集合条目的类型结构:
import type { CollectionEntry } from 'astro:content'
type BlogPost = CollectionEntry<'blog'>
// BlogPost 包含:// - id: string (文件路径/唯一标识)// - collection: 'blog'// - data: { title: string, pubDate: Date, ... } (Schema 定义的字段)// - body: string (原始内容)在组件中使用类型:
---import type { CollectionEntry } from 'astro:content'
interface Props { post: CollectionEntry<'blog'>}
const { post } = Astro.props---
<article> <h1>{post.data.title}</h1> <time>{post.data.pubDate.toLocaleDateString()}</time></article>渲染内容#
render() 函数(Astro 5 新方式)#
Astro 5 中,render() 是一个独立函数,而不是条目的方法:
---import { getEntry, render } from 'astro:content'
const post = await getEntry('blog', 'hello-world')const { Content, headings } = await render(post)---
<article> <h1>{post.data.title}</h1> <Content /></article>🔶 与旧版的区别:
// Astro 4.x (旧版)const { Content } = await post.render()
// Astro 5.x (新版)import { render } from 'astro:content'const { Content } = await render(post)<Content /> 组件#
Content 是一个 Astro 组件,渲染 Markdown/MDX 内容:
---import { getEntry, render } from 'astro:content'
const post = await getEntry('blog', 'my-post')const { Content } = await render(post)---
<article class="prose"> <Content /></article>获取 headings 生成目录#
render() 返回的 headings 包含所有标题信息:
---const { Content, headings } = await render(post)
// headings 结构:// [// { depth: 2, slug: 'introduction', text: '介绍' },// { depth: 3, slug: 'getting-started', text: '快速开始' },// { depth: 2, slug: 'examples', text: '示例' },// ]---
<nav class="toc"> <h2>目录</h2> <ul> { headings .filter((h) => h.depth <= 3) // 只显示 h2 和 h3 .map((h) => ( <li style={`margin-left: ${(h.depth - 2) * 1}rem`}> <a href={`#${h.slug}`}>{h.text}</a> </li> )) } </ul></nav>
<article> <Content /></article>自定义组件传递#
可以覆盖 Markdown 渲染的默认组件:
---const { Content } = await render(post)import CustomImage from '~/components/CustomImage.astro'import CustomLink from '~/components/CustomLink.astro'---
<Content components={{ img: CustomImage, a: CustomLink, // 覆盖更多元素... h1: (props) => <h1 class="custom-h1" {...props} />, }}/>自定义图片组件示例:
---interface Props { src: string alt: string}const { src, alt } = Astro.props---
<figure class="image-wrapper"> <img src={src} alt={alt} loading="lazy" /> {alt && <figcaption>{alt}</figcaption>}</figure>实战:当前博客的内容架构#
让我们深入分析当前博客项目的内容集合实现。
分析 src/content.config.ts#
import { glob, file } from 'astro/loaders'import { defineCollection } from 'astro:content'
import { pageSchema, postSchema, projectSchema, streamSchema, photoSchema,} from '~/content/schema'
// 页面集合(MDX 页面的 Frontmatter)const pages = defineCollection({ loader: glob({ base: './src/pages', pattern: '**/*.mdx' }), schema: pageSchema,})
// 博客文章集合const blog = defineCollection({ loader: glob({ base: './src/content/blog', pattern: '**/[^_]*.{md,mdx}' }), schema: postSchema,})
// 项目数据集合const projects = defineCollection({ loader: file('./src/content/projects/data.json'), schema: projectSchema,})
// 照片集合const photos = defineCollection({ loader: file('src/content/photos/data.json'), schema: photoSchema,})
// 更新日志集合const changelog = defineCollection({ loader: glob({ base: './src/content/changelog', pattern: '**/[^_]*.{md,mdx}', }), schema: postSchema,})
// 外部链接流集合const streams = defineCollection({ loader: file('./src/content/streams/data.json'), schema: streamSchema,})
export const collections = { pages, blog, projects, photos, changelog, streams,}🎯 设计要点:
- 多种内容类型: blog、changelog 用 Markdown,projects、streams 用 JSON
- 排除下划线文件:
**/[^_]*.{md,mdx}排除以_开头的草稿 - 复用 Schema: blog 和 changelog 共用
postSchema
postSchema 字段详解#
import { z } from 'astro:content'
export const postSchema = z.object({ // 必需字段 title: z.string().max(60), pubDate: z.coerce.date(),
// 分类(默认"杂谈") category: z.string().default('杂谈'),
// 可选元数据 description: z.string().default(''), subtitle: z.string().default(''), lastModDate: z.union([z.coerce.date(), z.literal('')]).optional(), tags: z.array(z.string()).default([]),
// 内容类型标记 radio: z.boolean().default(false), video: z.boolean().default(false), platform: z.string().default(''),
// 显示控制 toc: z.boolean().default(true), draft: z.boolean().default(false), ogImage: z.union([z.string(), z.boolean()]).default(true),
// 功能开关 share: z.boolean().default(true), giscus: z.boolean().default(true), search: z.boolean().default(true),
// 重定向 redirect: z.string().url().optional(),
// 阅读时间 minutesRead: z.number().optional(),})文章查询与渲染流程#
查询文章 (src/utils/data.ts):
import { getCollection } from 'astro:content'import type { CollectionEntry } from 'astro:content'
export async function getFilteredPosts( collection: 'blog' | 'changelog'): Promise<CollectionEntry<typeof collection>[]> { const posts = await getCollection(collection)
// 生产环境过滤草稿 const filtered = import.meta.env.PROD ? posts.filter((post) => !post.data.draft) : posts
// 按日期排序 return filtered.sort( (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf() )}
// 按分类获取export async function getPostsByCategory(category: string) { const posts = await getFilteredPosts('blog') return posts.filter((post) => post.data.category === category)}
// 获取所有分类export async function getAllCategories() { const posts = await getFilteredPosts('blog') return [...new Set(posts.map((post) => post.data.category))]}渲染文章 (src/components/views/RenderPost.astro):
---import { render } from 'astro:content'import type { CollectionEntry } from 'astro:content'
import BaseLayout from '~/layouts/BaseLayout.astro'import PostMeta from '~/components/PostMeta.astro'import TableOfContents from '~/components/TableOfContents.astro'import Giscus from '~/components/Giscus.astro'
interface Props { post: CollectionEntry<'blog'> isCommentable?: boolean}
const { post, isCommentable = false } = Astro.propsconst { Content, headings } = await render(post)const { title, description, pubDate, toc, giscus } = post.data---
<BaseLayout title={title} description={description}> <article class="prose mx-auto"> <h1>{title}</h1> <PostMeta date={pubDate} />
{toc && headings.length > 0 && <TableOfContents {headings} />}
<Content /> </article>
{isCommentable && giscus && <Giscus slot="giscus" />}</BaseLayout>内容文件组织#
src/content/├── blog/ # 博客文章│ ├── Astro/ # 分类文件夹│ │ ├── 1-入门指南.mdx│ │ └── 2-路由与页面.mdx│ ├── TypeScript/│ │ └── 泛型详解.mdx│ └── _drafts/ # 草稿(被排除)│ └── work-in-progress.mdx├── changelog/ # 更新日志│ └── 2025-01.mdx├── projects/ # 项目数据│ └── data.json├── photos/ # 照片数据│ └── data.json├── streams/ # 外部链接│ └── data.json└── schema.ts # Schema 定义内容集合最佳实践#
Schema 设计原则#
// ✅ 好的 Schema 设计const schema = z.object({ // 1. 必需字段精简 title: z.string().max(60), pubDate: z.coerce.date(),
// 2. 可选字段有合理默认值 description: z.string().default(''), draft: z.boolean().default(false), tags: z.array(z.string()).default([]),
// 3. 使用 coerce 自动转换 pubDate: z.coerce.date(), // 接受字符串自动转 Date
// 4. 提供清晰的类型约束 category: z.enum(['tech', 'life', 'other']),
// 5. 复杂类型用联合 ogImage: z.union([z.string(), z.boolean()]).default(true),})查询优化#
// ✅ 在 getStaticPaths 中复用查询结果export async function getStaticPaths() { const posts = await getFilteredPosts('blog')
return posts.map((post) => ({ params: { slug: post.id }, props: { post }, // 传递整个 post,避免重复查询 }))}
// ❌ 避免在页面中重复查询const posts = await getCollection('blog')const post = posts.find((p) => p.id === slug) // 重复查询类型安全#
// 导出 Schema 的类型export type PostSchema = z.infer<typeof postSchema>
// 组件中使用import type { CollectionEntry } from 'astro:content'
interface Props { post: CollectionEntry<'blog'>}下一步#
🎯 现在你已经掌握了 Astro 的内容集合系统:
- Content Layer API 的使用
- Schema 定义和 Zod 验证
- 集合查询方法
- 内容渲染流程
下一篇文章,我们将学习 Astro 的集成与部署,包括:
- 官方集成的安装和配置
- 构建优化策略
- 多种部署平台的实践
- 当前博客的完整技术栈分析