---
title: 'Astro 上手指南：从内容集合到 Cloudflare Pages'
description: '把本站用到的 Astro 知识整理成一篇：文件路由、内容集合、图片与字体优化、主题令牌、RSS / Sitemap，以及我们实际踩过的 7 个坑。'
pubDate: 2026-10-11
author: 'ProudCarrot'
heroImage: '../../assets/astro-cover.png'
---

这篇文章是本站的第一篇正式文章，内容就是我们自己搭这个博客时用到的 Astro 知识。目标是：看完之后你能从零把这个站跑起来、写完第一篇、部署到公网，并且避开我们踩过的坑。

本站当前的技术栈（都写在 `package.json` 里）：

| 依赖 | 版本 | 用途 |
| --- | --- | --- |
| `astro` | ^7.3.5 | 框架本体，**默认静态输出** |
| `@astrojs/mdx` | ^8.0.2 | 让文章支持 `.mdx` |
| `@astrojs/rss` | ^4.0.19 | 生成 `/rss.xml` |
| `@astrojs/sitemap` | ^3.7.4 | 生成 `sitemap-index.xml` |
| `sharp` | ^0.35.0 | 构建期图片压缩（转 webp） |

## 为什么用 Astro

博客是典型的内容站：以静态页面为主，交互极少。用 SPA 框架做这种站，等于为了几处交互让每个访客先下载并执行一整套运行时。Astro 的思路相反：

- **默认零客户端 JavaScript**：只有你显式写的交互组件（Vue/React/Svelte 等）才会带 JS 上船，这就是所谓 islands（岛屿）架构；
- **构建期渲染**：`npm run build` 直接把页面渲染成 HTML，产物就是一堆静态文件，扔到任何静态托管上都能跑；
- **文件即路由**：`src/pages/` 下的文件路径直接对应 URL，不需要写路由表；
- **内容集合**：Markdown 的 frontmatter 用 zod 做类型校验，写错字段构建期就报错，而不是等上线后发现页面空了。

## 一、目录结构

| 路径 | 作用 |
| --- | --- |
| `src/pages/` | 页面与端点，一个文件 = 一个路由 |
| `src/content/blog/` | 文章（Markdown / MDX），文件名即 URL 的一段 |
| `src/content.config.ts` | 内容集合定义：加载器 + frontmatter schema |
| `src/components/` | 可复用组件（Header、Footer、PostCard…） |
| `src/layouts/` | 页面骨架（文章页用 `BlogPost.astro`） |
| `src/styles/global.css` | 全局样式与设计令牌 |
| `src/consts.ts` | 站点名称、简介、导航文案（集中放，方便以后做多语言） |

## 二、路由：文件放哪儿，URL 就是什么

| 文件 | 生成的地址 |
| --- | --- |
| `src/pages/index.astro` | `/` |
| `src/pages/blog/index.astro` | `/blog/` |
| `src/pages/about.astro` | `/about/` |
| `src/pages/blog/[...slug].astro` | 每篇文章一个地址（构建期枚举） |
| `src/pages/rss.xml.js` | `/rss.xml`（这是「端点」，不是页面） |
| `src/pages/404.astro` | `404.html`，静态托管会用它兜底 |

动态路由靠 `getStaticPaths()` 在**构建期**枚举出所有地址。本站的实现（`src/pages/blog/[...slug].astro`）：

```astro
---
import { type CollectionEntry, getCollection, render } from 'astro:content';
import BlogPost from '../../layouts/BlogPost.astro';

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

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

<BlogPost {...post.data}>
	<Content />
</BlogPost>
```

新增文章不需要动这段代码：文件名变了，枚举结果就变了。

## 三、内容集合：frontmatter 有类型

`src/content.config.ts` 定义了「文章长什么样」：

```ts
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';

const blog = defineCollection({
	loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' }),
	schema: ({ image }) =>
		z.object({
			title: z.string(),
			description: z.string(),
			pubDate: z.coerce.date(),
			updatedDate: z.coerce.date().optional(),
			heroImage: z.optional(image()),
		}),
});

export const collections = { blog };
```

几个实际好用的点：

- `glob` 加载器直接把一个目录变成集合，不用一个个登记；
- `z.coerce.date()` 允许 frontmatter 里写字符串日期，自动转成 `Date`；
- `image()` 会校验路径有效性，并让图片走 Astro 的图片优化管线；
- 页面里用 `getCollection('blog')` 拿数据、`render(post)` 渲染正文。

> 一句提醒：**别在 schema 里直接 `z.coerce.date()` 就完事**。非 ISO 字面量（例如 `'Jun 19 2024'`）会被按构建机本地时区解析，同一个提交在不同机器上会产出不同的时间戳。我们在坑清单第 3 条里写了完整的处理方式。

## 四、组件与样式：三段式单文件

`.astro` 文件由三部分组成：`---` 包裹的前置脚本、模板、`<style>`。样式默认是**组件作用域**的，构建时会加上 `data-astro-cid-*` 属性做隔离，所以可以放心用短类名。

```astro
---
interface Props {
	title: string;
	strong?: boolean;
}
const { title, strong = false } = Astro.props;
---

<h3 class:list={[{ highlight: strong }]}>{title}</h3>

<style>
	.highlight {
		color: var(--accent);
	}
</style>
```

需要跨组件统一设计时，用 `is:global` 或 `:global()` 把少量**设计基元**放进 `global.css`，而不是每个组件各写一套。本站就是这么做的：卡片、按钮、区块标题、卡片网格都只有一份定义（见第七节）。

## 五、图片与字体

图片用 `astro:assets` 的 `Image` 组件，宽高必须给，构建期会按需生成 webp：

```astro
---
import { Image } from 'astro:assets';
import cover from '../../assets/blog-placeholder-1.jpg';
---

<Image width={640} height={320} src={cover} alt="" loading="lazy" />
```

字体用 Astro 的字体 API，在 `astro.config.mjs` 里声明一次，然后在 `<head>` 预加载：

```js
// astro.config.mjs
fonts: [
	{
		provider: fontProviders.local(),
		name: 'Atkinson',
		cssVariable: '--font-atkinson',
		fallbacks: ['sans-serif'],
		options: {
			variants: [
				{ src: ['./src/assets/fonts/atkinson-regular.woff'], weight: 400, style: 'normal' },
			],
		},
	},
],
```

```astro
---
import { Font } from 'astro:assets';
---

<Font cssVariable="--font-atkinson" preload />
```

中文字体没必要自托管（体积太大），在 CSS 变量里给系统字体兜底更划算——本站的 `--font-sans` 就串了 `PingFang SC` / `Microsoft YaHei` / `Noto Sans SC`。

## 六、RSS 与 Sitemap

Sitemap 由集成自动生成，RSS 写一个端点文件即可：

```js
// src/pages/rss.xml.js
import { getCollection } from 'astro:content';
import rss from '@astrojs/rss';
import { SITE_DESCRIPTION, SITE_TITLE } from '../consts';

export async function GET(context) {
	const posts = await getCollection('blog');
	return rss({
		title: SITE_TITLE,
		description: SITE_DESCRIPTION,
		site: context.site,
		items: posts.map((post) => ({
			...post.data,
			link: `/blog/${post.id}/`,
		})),
	});
}
```

**这里有个必须做的配置**：`astro.config.mjs` 里的 `site` 一定要填真实域名。

```js
export default defineConfig({
	site: 'https://your-site.pages.dev',
	// ...
});
```

它决定了 canonical URL、sitemap、RSS 里的绝对链接。模板默认值是 `https://example.com`，不换掉的话这些元数据全都是错的（而页面看起来一切正常，所以特别容易漏）。

## 七、主题令牌：一处改，全站跟着变

全站颜色、圆角、字体都定义成 CSS 变量，集中在 `src/styles/global.css` 的 `:root`，组件里只引用变量：

```css
:root {
	--accent: #2fe3ff;
	--surface: #0b1120;
	--border: rgba(120, 165, 225, 22%);
	--radius: 16px;
	--font-mono: ui-monospace, Menlo, Consolas, monospace;
}
```

好处很直接：想换配色只改这几行；想要「深色/浅色」两套主题，就是再定义一组变量、在 `<html>` 上切个属性的事。本站最终选定了深色科技风，于是直接把深色值放进 `:root`，不做切换——**单一主题就不要留两套变量的分支**，那是迟早会不同步的隐患。

同样的思路也用在结构上：`.page-wide`（宽容器）、`.section-head`（区块标题）、`.post-grid`（卡片网格）各只有一份定义，首页和文章列表共用同一个卡片组件。改一处，两页一起变。

## 八、部署到 Cloudflare Pages

本站是纯静态输出（没有设置 `output: 'server'`，也没有装 adapter），所以部署就是「把 `dist/` 发上去」。

**方式一：Git 集成（推荐）**

在 Cloudflare 面板把仓库连上，配置三项：

| 配置项 | 值 |
| --- | --- |
| Build command | `npm run build` |
| Build output directory | `dist` |
| Node 版本 | 见下面第 5 条坑，用仓库里的 `.node-version` 固定 |

之后 `git push` 到生产分支就会自动构建上线，其他分支和 PR 会拿到独立的预览地址。

**方式二：本地手动部署**

```bash
npm run build
npx wrangler pages deploy dist --project-name <项目名>
```

## 九、我们踩过的 7 个坑

1. **`astro dev` 在 Windows 上只监听 IPv6 回环。** `netstat` 显示 `[::1]:4321`，于是任何拨号 `127.0.0.1` 的工具（反向隧道、容器、某些代理）都会连不上。要固定成 IPv4 就在配置里写 `server: { host: '127.0.0.1' }`。
2. **Vite 会拦截未知 Host。** 开发服务器做过防 DNS rebinding 校验，通过域名/公网 IP 访问时会直接返回 `Blocked request. This host is not allowed.`，需要在 `vite.server.allowedHosts` 里放行。
3. **日期会随构建机漂移。** frontmatter 写 `'Jun 19 2024'` 这种非 ISO 字面量时，V8 按**本地时区**解析：本地（UTC+8）得到 `2024-06-18T16:00Z`，CI 容器（UTC）得到 `2024-06-19T00:00Z`。页面上的中文日期看着一样，但 RSS 里的 `pubDate` 相差 8 小时，读者可能看到差一天。解决办法是把日期统一归一化成「UTC 零点的日历日」，带显式时区的时间戳则保留原意。
4. **换行符会在跨平台之间翻动。** 仓库里加一行 `.gitattributes`：`* text=auto eol=lf`，Windows 与 Linux 构建之间就不会产生无意义的 diff。
5. **Cloudflare Pages 的构建镜像不读 `package.json` 的 `engines`。** 这是官方文档明确列出的限制，所以 `"node": ">=22.19.0"` 对构建环境无效。要固定版本得用仓库根的 `.node-version` 文件或 `NODE_VERSION` 环境变量；两者都留会形成两个真相源，建议只留文件。
6. **强推（force push）并不会让旧提交从托管平台上消失。** 如果历史里有不该出现的密钥、内网地址，改完历史强推之后，知道旧 SHA 的人在一段时间内仍能按 SHA 取到它。真正干净的只有删库重建。
7. **`<html>` 别忘了 `<!doctype html>`。** 少了它会进怪异模式（`document.compatMode === 'BackCompat'`），盒模型和部分选择器行为跟标准模式不一样，这类问题在视觉上很难一眼看出。

## 十、日常命令

```bash
npm install        # 装依赖
npm run dev        # 本地开发，http://localhost:4321
npm run build      # 产出静态文件到 dist/
npm run preview    # 用本地服务预览 dist/
npx astro check    # 类型检查（需要额外安装 @astrojs/check）
```

写作流程就三步：在 `src/content/blog/` 新建 `.md`（文件名即 URL）→ 写 frontmatter 和正文 → 提交推送，剩下的交给 CI。

---

这份文档会跟着本站的实践更新：后面聊到新东西，就接着往里补。
