· by ProudCarrot

Astro 上手指南:从内容集合到 Cloudflare Pages


这篇文章是本站的第一篇正式文章,内容就是我们自己搭这个博客时用到的 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):

---
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 定义了「文章长什么样」:

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-* 属性做隔离,所以可以放心用短类名。

---
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:

---
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> 预加载:

// 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' },
			],
		},
	},
],
---
import { Font } from 'astro:assets';
---

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

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

六、RSS 与 Sitemap

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

// 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 一定要填真实域名。

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

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

七、主题令牌:一处改,全站跟着变

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

: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 会拿到独立的预览地址。

方式二:本地手动部署

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'),盒模型和部分选择器行为跟标准模式不一样,这类问题在视觉上很难一眼看出。

十、日常命令

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。


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