用了几年的博客已经可以正常工作,但它越来越不像一个让我愿意持续写作的地方:页面偏文档站,移动端体验一般,源码、构建产物和部署平台之间的关系也不够清晰。
这次我没有继续修补旧项目,而是从零重写了一套博客。最终方案是 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 只保存构建后的静态文件。
这样拆分有三个好处:
- 写作过程和工程配置保持私有。
- GitHub Pages 仓库仍然可以作为公开、可回滚的静态镜像。
- 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_KEYSecret。 - 确认部署正常后,删除本地临时私钥副本。
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。
更稳妥的顺序是:
- 先创建并验证 Cloudflare Pages 的
pages.dev地址。 - 在 Cloudflare 中添加正式域名,检查自动扫描到的 DNS 记录。
- 确认没有遗漏 MX、SPF、DKIM、DMARC 等邮件记录。
- 在域名注册商处把权威名称服务器切换到 Cloudflare。
- 等待 Cloudflare Zone 激活。
- 在 Pages 项目的 Custom domains 中绑定根域名。
- 确认 HTTPS 与主要页面都正常后,再清理旧平台配置。
这个域名没有邮件记录,因此切换相对简单。如果域名同时承载邮箱、API 或其他子域名,必须先完整迁移 DNS,不能只关注博客的 A/CNAME 记录。
在阿里云修改名称服务器
域名注册商是阿里云,原权威名称服务器是 HiChina。Cloudflare 为 Zone 分配两条名称服务器后,在阿里云域名控制台进入:
域名详情 → DNS 管理 → DNS 修改 → 修改 DNS 服务器
填写 Cloudflare 分配的两条名称服务器,完成短信验证并保存。
名称服务器切换属于高影响网络配置。提交前至少确认:
- Cloudflare Zone 中的现有记录是否完整。
- DNSSEC 是否处于兼容状态;必要时先关闭旧服务商的 DNSSEC,激活后再由 Cloudflare 开启。
- 域名没有依赖未迁移的邮箱、接口或验证记录。
- 旧 DNS 的 TTL 是否会导致缓存持续较长时间。
Cloudflare 控制台可能会在一段时间内显示 Initializing 或 Verifying。不要因为状态没有立即变成 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,响应头中的 server 为 cloudflare,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 和正式域名。
对个人技术博客来说,这套结构足够简单,也保留了长期演进的空间。更重要的是,它把维护部署环境的时间,重新还给了写作本身。