用了几年的博客已经可以正常工作,但它越来越不像一个让我愿意持续写作的地方:页面偏文档站,移动端体验一般,源码、构建产物和部署平台之间的关系也不够清晰。

这次我没有继续修补旧项目,而是从零重写了一套博客。最终方案是 Astro + React + MDX,源码放在私有 GitHub 仓库中,由 GitHub Actions 构建后同时发布到 GitHub Pages 镜像仓库和 Cloudflare Pages,lucaslz.com 由 Cloudflare 托管 DNS 并作为正式入口。

这篇文章记录完整的技术选型、项目结构、自动部署、DNS 切换和故障排查过程。

最终目标

重写前先确定约束,避免在框架之间反复摇摆:

  • 博客首先要好看,而不是像 API 文档。
  • 默认适配手机、平板和桌面端。
  • 文章使用 Markdown 或 MDX 管理。
  • 继续使用 React,但不为纯静态内容引入完整的服务端框架。
  • 源码仓库保持私有,构建后的静态文件可以公开。
  • 推送 main 后自动构建、检查并部署。
  • 不维护服务器,尽量使用免费的静态托管与 CDN。
  • lucaslz.com 始终是唯一正式地址。

最终的发布链路如下:

flowchart LR
  A[私有源码仓库<br/>lucaslz-blog] -->|push main| B[GitHub Actions]
  B --> C[npm ci]
  C --> D[Astro build]
  D --> E[链接检查]
  E --> F[dist 静态文件]
  F --> G[GitHub Pages<br/>静态镜像]
  F --> H[Cloudflare Pages<br/>生产环境]
  I[lucaslz.com] --> J[Cloudflare DNS / CDN]
  J --> H

为什么选择 Astro + React + MDX

Astro 负责静态内容

博客的核心是文章。文章不需要在浏览器里重新执行一遍 React 才能显示,因此静态生成比全量客户端渲染更合适。

Astro 在构建阶段把内容生成 HTML,默认不会把不必要的 JavaScript 发送到浏览器。它的 Content Collections 可以校验文章 frontmatter,动态路由可以统一生成文章页,RSS、Sitemap、SEO 元数据也能在构建时完成。

对这个项目而言,构建结果就是一个 dist/ 目录,不依赖 Node.js 服务器,因此既能部署到 Cloudflare Pages,也能同步到 GitHub Pages。

React 只负责交互

保留 React,不等于让整个博客变成 SPA。当前项目只在需要交互的区域使用 React Island:

  • 站内搜索
  • 深色模式切换
  • 移动端导航
  • Mermaid 图表增强

文章正文、首页卡片、归档和标签页都由 Astro 直接输出 HTML。这样既保留 React 的开发体验,也控制了客户端脚本体积。

MDX 负责表达能力

普通文章继续使用 Markdown;需要组件或复杂交互时再使用 MDX。两者由同一个内容集合管理,不需要为少数高级文章牺牲整个站点的简单性。

项目的关键依赖包括:

{
  "dependencies": {
    "astro": "7.1.6",
    "@astrojs/mdx": "7.0.5",
    "@astrojs/react": "6.0.2",
    "react": "19.2.8",
    "react-dom": "19.2.8"
  }
}

如果是纯静态 Astro 项目,部署到 Pages 时不需要安装 @astrojs/cloudflare 适配器;只有使用 Cloudflare 服务端运行时或 SSR 时才需要对应适配器。

内容模型与项目结构

博客内容放在 src/content/blog/,文件路径直接决定最终 URL。例如:

src/content/blog/
├── blog/
│   └── astro-react-mdx-cloudflare-pages.md
├── css/
├── foundation/
├── java/
└── react/

文章 frontmatter 通过 Zod 校验,避免日期、标签或草稿状态写错后仍然进入生产环境:

const blog = defineCollection({
  loader: glob({
    base: "./src/content/blog",
    pattern: "**/*.{md,mdx}",
  }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    date: z.coerce.date(),
    author: z.string().default("lucaslz"),
    tags: z.array(z.string()).default([]),
    featured: z.boolean().default(false),
    draft: z.boolean().default(false),
  }),
});

发布文章只需要增加一个 Markdown 文件。首页、归档、标签、搜索索引、RSS 和 Sitemap 都会在构建时自动更新。

移动端不是最后再补

这次重写从一开始就按小屏布局设计,而不是先完成桌面版再压缩:

  • 所有页面都配置正确的 viewport。
  • 主要断点围绕内容宽度,而不是特定手机型号。
  • 文章字号和间距使用 clamp() 保持连续缩放。
  • 点击目标不小于常见的移动端可触控尺寸。
  • 桌面导航在小屏切换为抽屉菜单。
  • 代码块允许横向滚动,不撑破文章容器。
  • 表格、图片、Mermaid 图和长 URL 都有溢出保护。
  • 同时适配浅色与深色系统主题。

静态页面并不等于简单页面。移动端体验更多取决于布局、字体、触控区域和资源体积,而不是使用了哪个 JavaScript 框架。

为什么使用两个 GitHub 仓库

源码仓库 lucaslz-blog 是私有仓库,包含文章源文件、Astro 配置和 GitHub Actions 工作流。另一个仓库 lucasleelz.github.io 只保存构建后的静态文件。

这样拆分有三个好处:

  1. 写作过程和工程配置保持私有。
  2. GitHub Pages 仓库仍然可以作为公开、可回滚的静态镜像。
  3. Cloudflare Pages 与 GitHub Pages 使用同一份 dist/,不会出现两套构建逻辑。

构建产物不是源码,因此不要在源码仓库中手动维护 dist/。它应该由 CI 每次重新生成。

GitHub Actions 自动发布

工作流只监听 main。每次推送依次执行安装、类型检查、静态构建、站内链接检查和发布。

下面是核心结构的简化版本:

name: Build and publish site

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read
  deployments: write

concurrency:
  group: publish-production
  cancel-in-progress: false

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout source
        uses: actions/checkout@v6

      - name: Setup Node
        uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Build site
        run: npm run build

      - name: Check internal links
        run: npm run check:links

同步 GitHub Pages 镜像

源码仓库通过专用 Deploy Key 取得静态仓库的写权限。工作流检出目标仓库后,使用 rsync --delete 让目标分支与本次 dist/ 完全一致:

- name: Checkout Pages repository
  uses: actions/checkout@v6
  with:
    repository: lucaslz2020/lucasleelz.github.io
    ref: master
    ssh-key: ${{ secrets.PAGES_DEPLOY_KEY }}
    path: publish

- name: Copy static files
  run: rsync -a --delete --exclude=.git dist/ publish/

- name: Publish generated site
  working-directory: publish
  run: |
    git config user.name "github-actions[bot]"
    git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
    git add --all
    if git diff --cached --quiet; then
      exit 0
    fi
    git commit -m "deploy: ${GITHUB_SHA}"
    git push origin HEAD:master

这里要确认目标仓库实际使用的是 main 还是 master。分支名写错时,构建会成功,但发布步骤会失败。

发布到 Cloudflare Pages

Cloudflare Pages 项目采用 Direct Upload,由 GitHub Actions 完成自定义构建后上传 dist/。Cloudflare 官方也将这种方式用于接入自定义 CI 平台。

- name: Deploy to Cloudflare Pages
  uses: cloudflare/wrangler-action@v3
  with:
    apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
    accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
    command: >-
      pages deploy dist
      --project-name=lucaslz-blog
      --branch=main
    gitHubToken: ${{ secrets.GITHUB_TOKEN }}

生产工作流中,我会进一步把第三方 Action 固定到完整 commit SHA,并在注释中保留对应版本,降低上游标签被意外修改带来的供应链风险。

相关文档:

凭据采用最小权限

自动部署需要两个方向的写权限,但不应该复用个人 SSH 密钥或全局管理员令牌。

GitHub Pages Deploy Key

  • 单独生成一对 SSH 密钥。
  • 公钥只添加到静态发布仓库,并开启写权限。
  • 私钥保存为源码仓库的 PAGES_DEPLOY_KEY Secret。
  • 确认部署正常后,删除本地临时私钥副本。

Cloudflare API Token

  • 创建自定义 API Token,而不是使用 Global API Key。
  • 只授予目标账号的 Cloudflare Pages 写权限。
  • Token 保存到 CLOUDFLARE_API_TOKEN
  • Account ID 保存到 CLOUDFLARE_ACCOUNT_ID
  • 不要在终端输出、构建日志、截图或文章中展示 Token 原文。

如果令牌曾经出现在日志或工具输出中,不要只删除日志:应该立即吊销旧令牌,生成新令牌并覆盖 GitHub Secret。

GitHub 也建议凭据只授予完成任务所需的最小权限,优先使用 Deploy Key、GitHub App 或专用服务身份,而不是长期复用个人凭据。参考:GitHub Actions Secrets

从 Vercel 切换到 Cloudflare Pages

迁移时最容易出问题的不是构建,而是操作顺序。

我的域名最初仍有一条根域名 A 记录指向 Vercel。删除 Vercel 项目后,访问域名出现:

404: NOT_FOUND
Code: DEPLOYMENT_NOT_FOUND

这个错误不代表 Cloudflare Pages 部署失败,而是 DNS 仍然把访问请求送到了已经被删除的 Vercel Deployment。

更稳妥的顺序是:

  1. 先创建并验证 Cloudflare Pages 的 pages.dev 地址。
  2. 在 Cloudflare 中添加正式域名,检查自动扫描到的 DNS 记录。
  3. 确认没有遗漏 MX、SPF、DKIM、DMARC 等邮件记录。
  4. 在域名注册商处把权威名称服务器切换到 Cloudflare。
  5. 等待 Cloudflare Zone 激活。
  6. 在 Pages 项目的 Custom domains 中绑定根域名。
  7. 确认 HTTPS 与主要页面都正常后,再清理旧平台配置。

这个域名没有邮件记录,因此切换相对简单。如果域名同时承载邮箱、API 或其他子域名,必须先完整迁移 DNS,不能只关注博客的 A/CNAME 记录。

在阿里云修改名称服务器

域名注册商是阿里云,原权威名称服务器是 HiChina。Cloudflare 为 Zone 分配两条名称服务器后,在阿里云域名控制台进入:

域名详情 → DNS 管理 → DNS 修改 → 修改 DNS 服务器

填写 Cloudflare 分配的两条名称服务器,完成短信验证并保存。

名称服务器切换属于高影响网络配置。提交前至少确认:

  • Cloudflare Zone 中的现有记录是否完整。
  • DNSSEC 是否处于兼容状态;必要时先关闭旧服务商的 DNSSEC,激活后再由 Cloudflare 开启。
  • 域名没有依赖未迁移的邮箱、接口或验证记录。
  • 旧 DNS 的 TTL 是否会导致缓存持续较长时间。

Cloudflare 控制台可能会在一段时间内显示 InitializingVerifying。不要因为状态没有立即变成 Active 就反复删除和重建记录,先从公共 DNS 和 HTTPS 两个角度验证真实结果。

绑定根域名

Zone 激活后,在 Cloudflare Dashboard 中进入:

Workers & Pages
→ lucaslz-blog
→ Custom domains
→ Set up a custom domain

输入 lucaslz.com。Cloudflare 会把旧的根域名记录替换为指向 lucaslz-blog.pages.dev 的记录,并处理根域名 CNAME Flattening、代理和证书签发。

最终状态应当显示:

lucaslz.com  Active
SSL enabled

GitHub Pages 镜像仓库不再保存 CNAME 文件,避免两个托管平台同时声明同一个正式域名。

上线后的验证清单

不能只看控制台里的绿色状态。发布完成后至少检查以下内容:

# 权威名称服务器
dig NS lucaslz.com

# HTTPS、跳转与响应头
curl -I -L https://lucaslz.com/

# 关键页面
curl -I -L https://lucaslz.com/archive/
curl -I -L https://lucaslz.com/tags/

# RSS 与 Sitemap
curl -I https://lucaslz.com/rss.xml
curl -I https://lucaslz.com/sitemap-index.xml

浏览器中还需要验证:

  • 首页、文章页、归档和标签页。
  • 手机宽度下的导航、搜索和代码块。
  • 浅色与深色主题。
  • 404 页面。
  • Canonical、Open Graph、RSS 与 Sitemap 地址。
  • 页脚是否显示正确的部署平台。

这次发布后,首页、归档和文章路由都返回 200,响应头中的 servercloudflare,Cloudflare Pages 控制台显示 Active · SSL enabled

几个值得保留的经验

先打通临时域名,再碰正式 DNS

pages.dev 是最好的隔离验证入口。只要临时域名尚未通过构建、链接和页面检查,就不应该切换正式域名。

源码部署与静态产物发布是两件事

私有源码仓库负责写作与构建,公开 Pages 仓库只负责保存产物。两者用 GitHub Actions 连接,比手动复制 dist/ 更稳定,也更容易审计。

删除旧平台前先确认 DNS

旧项目被删除而 DNS 没切换,会立即得到平台 404。生产迁移应先准备新环境,再切流量,最后清理旧环境。

Secret 泄露后的正确动作是轮换

“界面里看不到了”不等于“凭据安全了”。一旦凭据可能被读取,就应吊销、重建、更新 Secret,并清除不再需要的本地副本。

CI 必须包含构建之外的检查

静态构建成功不代表站点可用。类型检查、内容 Schema、站内链接检查和线上 smoke test 应当一起构成发布门槛。

最终结果

新的博客没有运行中的应用服务器,也没有运行时数据库。日常发布流程只剩下:

写 Markdown / MDX
→ 本地预览
→ git push main
→ GitHub Actions 构建与检查
→ 同步 GitHub Pages 镜像
→ 部署 Cloudflare Pages
→ lucaslz.com 自动更新

Astro 负责把内容变成尽可能轻的静态页面,React 只处理真正需要的交互,GitHub Actions 负责可重复发布,Cloudflare Pages 负责 CDN、HTTPS 和正式域名。

对个人技术博客来说,这套结构足够简单,也保留了长期演进的空间。更重要的是,它把维护部署环境的时间,重新还给了写作本身。