Hualin Luan Cloud Native · Quant Trading · AI Engineering
返回文章

Article

从博客到技术平台的最小升级路径(四):Astro + Content Collections 实战指南

把前三篇文章的设计理念转化为代码。这篇是完整的技术实现指南,包含项目结构、Schema 设计、动态路由、搜索集成等全部代码。

Meta

Published

2026/3/23

Category

guide

Reading Time

约 12 分钟阅读

写作声明 本文基于笔者使用 Astro 构建博客的完整源码。所有代码均来自生产环境,可直接参考或修改使用。


开头:把设计转化为代码的最小可行方案

前三篇文章讨论了专题化、标签体系、首页设计的理念。这篇文章解决一个问题:如何用 Astro 完整实现这些设计?

我将从空项目开始,一步步构建出具备以下功能的博客系统:

  • 专题(Topics)内容管理
  • 文章(Posts)组织与分类
  • 指南(Guides)序列
  • 标签(Tags)系统
  • 平台型首页
  • 基础搜索

技术栈:Astro 5.x + TypeScript + Tailwind CSS


第一步:项目初始化与依赖

1.1 创建 Astro 项目

npm create astro@latest my-blog
cd my-blog

选择:

  • Template: Empty
  • TypeScript: Yes
  • Dependencies: Install

1.2 安装必要依赖

# 核心依赖
npm install @astrojs/tailwindcss @astrojs/mdx

# 搜索功能(可选)
npm install pagefind

# 工具库
npm install date-fns

1.3 配置文件

// astro.config.mjs
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://yourdomain.com',
  output: 'static',
  integrations: [
    tailwind(),
    mdx(),
    sitemap(),
  ],
});

第二步:Content Collections Schema 设计

这是整个系统的核心。我们定义三个内容集合:topics、blog、guides。

2.1 配置内容集合

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

// 专题集合
const topics = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    order: z.number().default(999),
    featured: z.boolean().default(false),
    subtopics: z.array(z.string()).optional(),
    recommendedReading: z.array(z.string()).optional(),
  }),
});

// 博客文章集合
const blog = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    topic: z.string(),  // 所属专题
    tags: z.array(z.string()).default([]),
    category: z.enum(['interpretation', 'guide', 'tutorial', 'reference']),
    featured: z.boolean().default(false),
    series: z.object({
      name: z.string(),
      order: z.number(),
    }).optional(),
  }),
});

// 指南集合
const guides = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    topic: z.string(),
    order: z.number(),
    featured: z.boolean().default(false),
    tags: z.array(z.string()).default([]),
  }),
});

export const collections = { topics, blog, guides };

2.2 目录结构

src/
├── content/
│   ├── blog/
│   │   ├── post-1.md
│   │   └── post-2.mdx
│   ├── guides/
│   │   ├── guide-1.md
│   │   └── guide-2.md
│   └── topics/
│       ├── topic-1.md
│       └── topic-2.md
├── lib/
│   └── content.ts      # 内容查询工具
├── pages/
│   ├── index.astro     # 首页
│   ├── blog/
│   │   ├── index.astro
│   │   └── [slug].astro
│   ├── guides/
│   │   ├── index.astro
│   │   └── [slug].astro
│   ├── topics/
│   │   ├── index.astro
│   │   └── [slug].astro
│   └── tags/
│       ├── index.astro
│       └── [tag].astro
└── components/
    ├── ContentCard.astro
    ├── TopicCard.astro
    └── SectionHeader.astro

第三步:内容查询工具库

3.1 基础查询函数

// src/lib/content.ts
import { getCollection, type CollectionEntry } from 'astro:content';

// 类型导出
export type Post = CollectionEntry<'blog'>;
export type Guide = CollectionEntry<'guides'>;
export type Topic = CollectionEntry<'topics'>;

// 获取所有文章(按时间倒序)
export async function getAllPosts(): Promise<Post[]> {
  return (await getCollection('blog'))
    .filter(post => !post.id.startsWith('_drafts/'))
    .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
}

// 获取最新文章
export async function getRecentPosts(limit = 5): Promise<Post[]> {
  return (await getAllPosts()).slice(0, limit);
}

// 获取精选文章
export async function getFeaturedPosts(limit = 6): Promise<Post[]> {
  return (await getAllPosts())
    .filter(post => post.data.featured)
    .slice(0, limit);
}

// 获取专题下文章
export async function getPostsByTopic(topicSlug: string): Promise<Post[]> {
  return (await getAllPosts())
    .filter(post => normalizeSlug(post.data.topic) === topicSlug);
}

// 获取所有指南
export async function getAllGuides(): Promise<Guide[]> {
  return (await getCollection('guides'))
    .sort((a, b) => (a.data.order ?? 999) - (b.data.order ?? 999));
}

// 获取所有专题
export async function getAllTopics(): Promise<Topic[]> {
  return (await getCollection('topics'))
    .sort((a, b) => (a.data.order ?? 999) - (b.data.order ?? 999));
}

// 专题标题映射
export async function getTopicTitleMap(): Promise<Map<string, string>> {
  const topics = await getAllTopics();
  return new Map(topics.map(t => [normalizeSlug(t.id), t.data.title]));
}

// 辅助函数:标准化 slug
function normalizeSlug(id: string): string {
  return id.replace(/\.md$/, '');
}

3.2 标签统计工具

// src/lib/taxonomy.ts
import { getAllPosts, type Post } from './content';

export interface TagCount {
  tag: string;
  count: number;
}

// 统计标签使用频率
export async function getTagCounts(): Promise<TagCount[]> {
  const posts = await getAllPosts();
  const counts = new Map<string, number>();

  posts.forEach(post => {
    post.data.tags?.forEach(tag => {
      counts.set(tag, (counts.get(tag) || 0) + 1);
    });
  });

  return Array.from(counts.entries())
    .map(([tag, count]) => ({ tag, count }))
    .sort((a, b) => b.count - a.count);
}

// 获取文章的所有标签
export function getPostTags(post: Post): string[] {
  return post.data.tags || [];
}

// 格式化标签显示
export function formatTagLabel(tag: string): string {
  return tag
    .split('-')
    .map(word => word.charAt(0).toUpperCase() + word.slice(1))
    .join(' ');
}

第四步:动态路由实现

4.1 博客文章详情页

---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content';
import { getTopicTitleById } from '../../lib/content';
import BaseLayout from '../../layouts/BaseLayout.astro';

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

const { post } = Astro.props;
const { Content } = await post.render();

const topicTitle = await getTopicTitleById(post.data.topic);
---

<BaseLayout
  title={post.data.title}
  description={post.data.description}
>
  <article class="max-w-3xl mx-auto">
    <!-- 文章头部 -->
    <header class="mb-8">
      <div class="flex items-center gap-2 text-sm text-slate-500 mb-4">
        <a href={`/topics/${post.data.topic}`} class="hover:text-slate-700">
          {topicTitle}
        </a>
        <span>•</span>
        <time>{post.data.pubDate.toLocaleDateString('zh-CN')}</time>
      </div>

      <h1 class="text-3xl font-bold mb-4">{post.data.title}</h1>
      <p class="text-lg text-slate-600">{post.data.description}</p>

      <!-- 标签 -->
      <div class="flex flex-wrap gap-2 mt-4">
        {post.data.tags.map(tag => (
          <a
            href={`/tags/${tag}`}
            class="px-3 py-1 text-sm bg-slate-100 rounded-full hover:bg-slate-200"
          >
            {tag}
          </a>
        ))}
      </div>
    </header>

    <!-- 文章内容 -->
    <div class="prose prose-slate max-w-none">
      <Content />
    </div>

    <!-- 文章底部导航 -->
    <footer class="mt-12 pt-8 border-t">
      <a href="/blog" class="text-slate-600 hover:text-slate-900">
        ← 返回文章列表
      </a>
    </footer>
  </article>
</BaseLayout>

4.2 专题详情页

---
// src/pages/topics/[slug].astro
import { getCollection } from 'astro:content';
import { getPostsByTopic, getGuidesByTopic } from '../../lib/content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import ContentCard from '../../components/ContentCard.astro';

export async function getStaticPaths() {
  const topics = await getCollection('topics');
  return topics.map(topic => ({
    params: { slug: topic.slug },
    props: { topic },
  }));
}

const { topic } = Astro.props;
const { Content } = await topic.render();

const [posts, guides] = await Promise.all([
  getPostsByTopic(topic.slug),
  getGuidesByTopic(topic.slug),
]);
---

<BaseLayout
  title={topic.data.title}
  description={topic.data.description}
>
  <div class="max-w-4xl mx-auto">
    <!-- 专题头部 -->
    <header class="mb-12">
      <h1 class="text-4xl font-bold mb-4">{topic.data.title}</h1>
      <p class="text-xl text-slate-600">{topic.data.description}</p>

      <div class="mt-4 text-sm text-slate-500">
        {posts.length} 篇文章 · {guides.length} 篇指南
      </div>
    </header>

    <!-- 专题介绍(可选) -->
    <div class="prose prose-slate mb-12">
      <Content />
    </div>

    <!-- 指南列表 -->
    {guides.length > 0 && (
      <section class="mb-12">
        <h2 class="text-2xl font-bold mb-6">指南</h2>
        <div class="space-y-4">
          {guides.map(guide => (
            <ContentCard
              href={`/guides/${guide.slug}`}
              title={guide.data.title}
              description={guide.data.description}
              meta={`指南 ${guide.data.order}`}
            />
          ))}
        </div>
      </section>
    )}

    <!-- 文章列表 -->
    <section>
      <h2 class="text-2xl font-bold mb-6">文章</h2>
      <div class="space-y-6">
        {posts.map(post => (
          <ContentCard
            href={`/blog/${post.slug}`}
            title={post.data.title}
            description={post.data.description}
            meta={post.data.pubDate.toLocaleDateString('zh-CN')}
            tags={post.data.tags}
          />
        ))}
      </div>
    </section>
  </div>
</BaseLayout>

4.3 标签页

---
// src/pages/tags/[tag].astro
import { getCollection } from 'astro:content';
import { formatTagLabel, getTagCounts } from '../../lib/taxonomy';
import BaseLayout from '../../layouts/BaseLayout.astro';
import ContentCard from '../../components/ContentCard.astro';

export async function getStaticPaths() {
  const tagCounts = await getTagCounts();

  // 只生成有 2 篇以上文章的标签页
  return tagCounts
    .filter(({ count }) => count >= 2)
    .map(({ tag }) => ({
      params: { tag },
      props: { tag },
    }));
}

const { tag } = Astro.props;

const posts = await getCollection('blog', post =>
  post.data.tags?.includes(tag)
);

// 按时间倒序
posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---

<BaseLayout title={`标签:${formatTagLabel(tag)}`}>
  <div class="max-w-4xl mx-auto">
    <header class="mb-8">
      <h1 class="text-3xl font-bold">{formatTagLabel(tag)}</h1>
      <p class="text-slate-600 mt-2">{posts.length} 篇文章</p>
    </header>

    <div class="space-y-6">
      {posts.map(post => (
        <ContentCard
          href={`/blog/${post.slug}`}
          title={post.data.title}
          description={post.data.description}
          meta={post.data.pubDate.toLocaleDateString('zh-CN')}
        />
      ))}
    </div>
  </div>
</BaseLayout>

第五步:平台型首页实现

---
// src/pages/index.astro
import BaseLayout from '../layouts/BaseLayout.astro';
import {
  getRecentPosts,
  getFeaturedPosts,
  getAllTopics,
  getTopicTitleMap,
} from '../lib/content';
import { getTagCounts, formatTagLabel } from '../lib/taxonomy';
import ContentCard from '../components/ContentCard.astro';
import TopicCard from '../components/TopicCard.astro';
import SectionHeader from '../components/SectionHeader.astro';

const [
  recentPosts,
  featuredPosts,
  topics,
  tagCounts,
  topicTitleMap,
] = await Promise.all([
  getRecentPosts(4),
  getFeaturedPosts(6),
  getAllTopics(),
  getTagCounts(),
  getTopicTitleMap(),
]);
---

<BaseLayout title="首页">
  <div class="space-y-16">
    <!-- Hero 区域 -->
    <section class="hero">
      <h1>技术专题化知识库</h1>
      <p>涵盖后端工程、AI 工程化、分布式系统等领域</p>
      <div class="actions">
        <a href="/topics" class="btn-primary">浏览专题</a>
        <a href="/blog" class="btn-secondary">查看文章</a>
      </div>
    </section>

    <!-- 精选内容 -->
    <section>
      <SectionHeader
        title="精选内容"
        description="优先阅读这些代表性内容"
        actionHref="/blog"
        actionLabel="浏览全部"
      />
      <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {featuredPosts.map(post => (
          <ContentCard
            href={`/blog/${post.slug}`}
            title={post.data.title}
            description={post.data.description}
            topicLabel={topicTitleMap.get(post.data.topic)}
            tags={post.data.tags.slice(0, 3)}
          />
        ))}
      </div>
    </section>

    <!-- 专题入口 -->
    <section>
      <SectionHeader
        title="专题入口"
        description="按技术领域浏览内容"
        actionHref="/topics"
        actionLabel="查看全部"
      />
      <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {topics.map(topic => (
          <TopicCard
            href={`/topics/${topic.slug}`}
            title={topic.data.title}
            description={topic.data.description}
          />
        ))}
      </div>
    </section>

    <!-- 最新更新 -->
    <section>
      <SectionHeader
        title="最近更新"
        description="最新发布的内容"
      />
      <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
        {recentPosts.map(post => (
          <ContentCard
            href={`/blog/${post.slug}`}
            title={post.data.title}
            description={post.data.description}
            meta={post.data.pubDate.toLocaleDateString('zh-CN')}
            topicLabel={topicTitleMap.get(post.data.topic)}
          />
        ))}
      </div>
    </section>

    <!-- 快速索引 -->
    <section>
      <h2>快速索引</h2>
      <div class="flex flex-wrap gap-2">
        {tagCounts.slice(0, 15).map(({ tag, count }) => (
          <a
            href={`/tags/${tag}`}
            class="px-4 py-2 bg-slate-100 rounded-full hover:bg-slate-200"
          >
            {formatTagLabel(tag)} ({count})
          </a>
        ))}
      </div>
      <a href="/tags" class="text-slate-600 hover:text-slate-900 mt-4 inline-block">
        查看全部标签 →
      </a>
    </section>
  </div>
</BaseLayout>

第六步:搜索功能集成(可选)

6.1 安装 Pagefind

npm install pagefind

6.2 构建脚本配置

// package.json
{
  "scripts": {
    "build": "astro build && pagefind --site dist",
    "dev": "astro dev"
  }
}

6.3 搜索页面

---
// src/pages/search.astro
import BaseLayout from '../layouts/BaseLayout.astro';
---

<BaseLayout title="搜索">
  <div class="max-w-2xl mx-auto">
    <h1 class="text-3xl font-bold mb-8">搜索</h1>

    <!-- Pagefind 搜索框 -->
    <div id="search" class="pagefind-ui"></div>
  </div>

  <script is:inline>
    // Pagefind 会自动注入
  </script>
</BaseLayout>

第七步:部署到 GitHub Pages

7.1 GitHub Actions 配置

# .github/workflows/deploy.yml
name: Deploy to GitHub Pages

on:
  push:
    branches: [main]

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: pages
  cancel-in-progress: false

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: withastro/action@v2
        with:
          node-version: 20
          package-manager: npm

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v3

7.2 Astro 配置适配

// astro.config.mjs
export default defineConfig({
  site: 'https://yourusername.github.io',
  base: '/your-repo-name',  // 如果使用项目页面
  // ...
});

性能优化建议

图片优化

使用 Astro 的 Image 组件:

---
import { Image } from 'astro:assets';
import myImage from '../assets/image.jpg';
---

<Image src={myImage} alt="描述" width={800} height={400} />

部分水合

对交互组件使用 client:* 指令:

<SearchComponent client:load />

预加载关键页面

<link rel="prefetch" href="/blog" />
<link rel="prefetch" href="/topics" />

结语:从代码到内容

完成技术实现后,你的博客已经具备了技术平台的全部基础设施:

  • ✅ 专题化内容组织
  • ✅ 标签分类系统
  • ✅ 平台型首页
  • ✅ 内容发现路径
  • ✅ 搜索功能

但记住:技术只是载体,内容才是价值。

这个系列讨论的不仅是如何搭建一个博客系统,更是如何重新思考内容组织方式——从”文件堆”到”知识库”,从”时间流”到”主题空间”。

希望这个系列对你的博客升级有所帮助。完整的源码可以在 GitHub 找到,欢迎参考和交流。


系列文章

  1. 从”文件堆”到”专题化”——博客内容组织的第一性原理
  2. 标签与专题的设计艺术——如何构建不混乱的内容分类学
  3. 构建平台型首页——让读者从”看到”到”发现”
  4. Astro + Content Collections 实战指南 ← 本文

参考资源

Series context

你正在阅读:从博客到技术平台的最小升级路径

当前为第 4 / 4 篇。阅读进度只写入此浏览器的 localStorage,用于回到系列页时定位继续阅读入口。

查看完整系列 →

Series Path

当前系列章节

点击章节会在此浏览器记录本地阅读进度;刷新后可继续阅读。

4 chapters
  1. Part 1 已在路径前序 从博客到技术平台的最小升级路径(一):从'文件堆'到'专题化' 当你的博客文章超过20篇,读者开始迷失在时间里。这篇文章分享一个实战经验:为什么专题化是博客升级的第一步,以及如何判断你是否已经到了需要升级的时刻。
  2. Part 2 已在路径前序 从博客到技术平台的最小升级路径(二):标签与专题的设计艺术 专题和标签有什么区别?为什么标签多了反而更难找内容?这篇文章拆解内容分类学中最常见的三个误区,并分享一个实用的'三层标签体系'设计方法。
  3. Part 3 已在路径前序 从博客到技术平台的最小升级路径(三):构建平台型首页——让读者从'看到'到'发现' 专题化解决了内容归属,但读者打开首页时应该看到什么?这篇文章分享如何设计一个'内容发现型'首页,而不是简单的时间流列表。
  4. Part 4 当前阅读 从博客到技术平台的最小升级路径(四):Astro + Content Collections 实战指南 把前三篇文章的设计理念转化为代码。这篇是完整的技术实现指南,包含项目结构、Schema 设计、动态路由、搜索集成等全部代码。

Reading path

继续沿这条专题路径阅读

按推荐顺序继续阅读 内容平台工程 相关内容,而不是只看同专题的随机文章。

查看完整专题路径 →

Next step

继续深入这个专题

如果这篇内容对你有帮助,下一步可以回到专题页继续系统阅读,或者订阅后续更新。

返回专题页 订阅 RSS 更新

RSS Subscribe

订阅更新

通过 RSS 阅读器订阅获取最新文章推送,无需频繁访问网站。

推荐使用 FollowFeedlyInoreader 等 RSS 阅读器

评论与讨论

使用 GitHub 账号登录参与讨论,评论将同步至 GitHub Discussions

正在加载评论...