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