把博客搬成纯静态:一次 WordPress → Halo 迁移的全记录
这个九月,我把跑了七年的 WordPress 博客整个搬了家:写作后台换成 Halo,前台重写成 Angular 静态站点,访客看到的每一个字节都来自 nginx 直出的静态文件——全站唯一还连着后端的,只剩评论区。这篇文章记录整个迁移的架构、流程和踩过的坑。坑的密度超出我的预期,其中最大的一个悬案(一个 p 标签弄哑了整个 SPA 导航)值得单独写了篇续篇,本文只剧透结论。
为什么要搬
老站是标准的 WordPress:CentOS + Apache + MySQL,跑在一台小 ECS 上。七年用下来,它的问题不是「坏了」,而是形态不对:
- 访客 99% 的行为是读内容,但每一次阅读都要打进 PHP 和 MySQL——缓存插件能缓解,但架构上的账没变;
- 动态面 = 安全面:登录入口、XML-RPC、插件 CVE、数不清的扫描器噪音,日志里全是知道的和不知道的爆破尝试;
- 一切个性化都要跟主题搏斗,而主题本身也是攻击面和维护面。
我想要的终局形态一句话能说清:后台有个舒服的 Markdown 编辑器就行,前台是构建产物——纯 HTML 和 JSON,访客全程零后端。
选型:Halo 只当后台,前台自己写
后台选了 Halo(开源博客系统,Java 系):控制台体验好、自带评论系统和插件生态、Docker 部署省心。但有个关键判断:不写 Halo 主题,把它用成纯 headless CMS——管理后台 + 内容存储 + 评论后端,前台跟它的主题系统零关系。
选型阶段排掉两个想当然:
- Halo 官方有个「静态网页服务」插件,名字很像我要的东西,实际方向相反——它是把外部静态站搬进 Halo 托管,不是把 Halo 内容导出成静态站;
- Halo 生态里没有「全站烘焙导出」类插件。所以「自建前台构建管线」不是可选项,是必选项。
前台选了 Angular SSG:构建期把全部内容预渲染成 HTML + JSON,同时开着 hydration(首屏吃预渲染 HTML,后续点击走 SPA 导航拉静态 JSON,几乎无感)。
最终架构一张图:
写作层 Halo 控制台(Markdown)
│
构建层 bake.py 拉内容 → Shiki 高亮注入 → Angular 全路由预渲染
│ (Halo Content API 只允许回环/SSH 隧道访问,不公开)
▼
分发层 nginx 双域名
├─ sinon.top 纯前台:静态直出,白名单只放行评论相关接口
└─ halo.sinon.top 纯管理:basic auth + 全量代理 console
访客层 首屏 = 预渲染 HTML;点击 = SPA 导航,fetch 静态 JSON 渲染
└── 唯一动态路径:评论组件 → Halo API
双域名是我这次最坚持的设计:主域(访客域)连登录表单都不存在——动态接口面收缩到评论需要的几个前缀,其余 /apis 一律 403;管理后台整体搬去子域名,外面再套一层 basic auth。访客域从此没有值得爆破的东西。
内容迁移:先演练,再动手
老站数据(三十多篇文章、若干独立页、二十多条评论、五十多个附件)走 Halo 的 WXR 导入。但动手前先做了一件更重要的事:备份验收铁律——SQL 全库 + 整站目录 + 后台 WXR 三件套,先在异机做一轮真实恢复演练(临时容器真导 SQL、真解包、真解析),数量逐项对账到闭环,才允许碰旧机。
两个迁移细节值得记:
- 老站代码块用的是 Enlighter 插件的私有标记,写了个脚本批量改写成标准
<pre><code class="language-xxx">(语言名做映射:shell→bash、generic→plaintext),改的是 WXR 本体,导入即就位——这个决定一个月后在烘焙管线里获得了巨额回报,见下文坑一; - 新机 Docker 拉镜像时被国内网络教育,daemon.json 配了 registry mirror 才顺。
构建管线:三个环节一条龙
① bake.py(烘焙):纯 Python 标准库、零第三方依赖。全量拉取 Content API,组装成站点自己的 JSON(文章/页面/分页列表/标签分类树),顺手做四件事:带上 Halo 的 metadata.name(评论组件靠它锚定评论归属)、注入封面头图、剥离老主题残留(见坑二)、生成自产 sitemap 和预渲染路由清单。全站四十篇内容,全量重建秒级——不做增量,简单方案完胜。
② highlight.mjs(高亮注入):Shiki 代码高亮在烘焙期一次性写入 JSON。为什么放烘焙期?候选时机有三个:浏览器端跑(要拉 80KB 的高亮库,SPA 导航有段「裸代码期」)、Angular 渲染期(每个入口重复劳动)、烘焙期(只发生一次,预渲染 HTML 和 SPA 导航都是它的下游)。选了第三个:所有入口零 JS 就能看到颜色,且永不漂移。代价是 content 体积膨胀(原始 +425%,gzip 后只 +33%),值得。
③ ng build(预渲染):production 构建直接输出全路由静态页 + 一个纯 CSR 兜底壳,rsync 上线即完成发布。HTML 和 JSON 都是 no-cache,文件一落盘刷新即生效——静态站的「发布」就是这么朴实无华。
部署流程也进化了两代:第一代是本地一键脚本(SSH 隧道 → 烘焙 → 构建 → rsync);第二代是服务器上 cron 每两小时自动构建上线,发文后最迟两小时生效,急了手动跑一次秒出。
踩坑大赏
以下坑全部实测踩中,按剧情顺序。
坑一:我以为 Halo 会帮我高亮
设计初期想当然:Halo 渲染产物里应该自带代码高亮。实测打脸——WP 迁移的文章 raw 是 HTML 直存,根本不经过 Halo 的 Markdown 渲染管线,拉回来的「渲染产物」里高亮标记数量为零,全是裸的 <pre><code>。幸好当初把 Enlighter 标记批量改成了标准格式,语言信息还在,Shiki 可以按语言高亮。于是才有烘焙期注入的方案(见上节)。教训:对「上游会替我做好」的假设,一行代码的探测就能证伪。
坑二:老主题阴魂不散
烘焙时发现 37/37 篇文章都带着老 WP 主题注入的内联样式块,里面有 pre:has(code) { filter: blur(10px) }——老站靠配套 JS 把它摘掉,静态直出后浏览器会真实应用它,全站代码块永久糊化。祸不单行:Halo 官方的 shiki 插件开着「主题侧渲染」时,还会给每个代码块套一层无功能的外壳标签。解法分两层:console 里关掉插件的「主题侧渲染」(治本),bake.py 里加两个剥离器(兜底)。教训:从旧系统搬来的内容,默认它带着旧系统的幽灵。
坑三:innerHTML 偷走我的高亮
Angular 的 [innerHTML] 默认安全清洗会剥掉 style 属性——而 Shiki 高亮全靠内联 style,一剥全灭。解法是 bypassSecurityTrustHtml() 放行,但必须立一条红线:这个口子只给自家烘焙管线产出的 content 开,评论等一切外部内容绝不进去。信任要有边界。
坑四:兜底页选错,整个路由像幻觉
预渲染 + hydration 之下藏着一个反直觉的坑:SPA 的兜底页如果回落到「预渲染的首页 HTML」,Router 会按预渲染时的路由状态恢复——直链任何未预渲染的路径,看到的都是首页内容,且分页参数静默失效。正确姿势是回落到纯 CSR 壳(构建产物里的 index.csr.html),Router 完整初始化,软 404、重定向全部正常。同族问题连坐:依赖 URL 区分的状态(分页)不能用查询参数,改路径路由 /archives/page/2,每页独立预渲染。
坑五:一个 p 标签弄哑了 SPA 导航
上线日最大的悬案:点导航 URL 变了、标题变了、页面纹丝不动,console 干净得像什么都没发生。一度结案为「框架 bug」,关闭 hydration 先上了线;当天翻案——真凶是从 WP 主题抄来的页脚里一个非法的 <p> 嵌套:编译器照字面输出,浏览器按规范自动拆开,hydration 认领失败,在 production 静默地掐断了 router-outlet 的实例化。完整机制链和排查工具箱写在续篇《一个 p 标签,弄哑了我的 SPA 导航》。这里只留守则:从旧系统搬 HTML 进现代框架的模板前,先确认它经得起浏览器的解析。
坑六:构建机被构建打趴了
管线要自动化,就得让服务器自己构建。服务器是台 1 核 1.8G 的小 ECS,Halo(JVM)加 PostgreSQL 常驻吃掉 1.2G。第一次在服务器上跑构建:几分钟后,两个 SSH 终端一起假死——敲键没反应,free 卡住刷新不动。这是教科书级的内存挤兑(thrashing):构建进程吃光内存后,内核把连 SSH 会话在内的进程页全部换到云盘,单核又被构建和换页线程吃满,整台机器在「动一会卡一会」中走向假死。有个迷惑性细节:free 显示 swap 用量是 0——不是没有内存压力,而是内核回收顺序先榨页缓存,还轮不到换匿名页。
解法是一个「借」字:烘焙(需要 Halo 在线)完成后,把 Halo 和数据库停掉,让构建独占全机——前台是纯静态直出,根本不经过 Halo,只牺牲几分钟的评论;再配一道 node 堆上限(超了就干净地报错退出,而不是拖垮全机)和 2G swap 兜底。这些全部固化进构建脚本,失败时保证数据库一定被拉回来。效果:全量构建 15~26 秒,评论每班只停 20 秒,升配的钱省了。
坑七:手动正常,定时就挂
自动化的最后一脚踢在了 PATH 上:手动跑构建一切正常,cron 定时触发报 pnpm: command not found。cron 是非登录 shell,PATH 只有 /usr/bin:/bin;而 tarball 安装的 node 和 pnpm 在 /usr/local/bin——登录 shell 的完整 PATH 把这个问题遮得严严实实。解法:构建脚本自带 PATH 导出,外加一道工具链预检(缺哪个命令开局就报,别跑到一半才撞墙)。教训:给无终端环境写的脚本,别相信任何登录环境悄悄给你的默认值。
现在的形态
- 44 个路由全预渲染,全量重建约 20 秒;发文后等 cron 最迟两小时上线,急了手动触发即时生效;
- 评论:Turnstile 人机验证 + 匿名可评,老评论全量搬家,匿名发评实测走通;
- PWA:可安装,离线能读访问过的文章;访问统计走 Cloudflare Web Analytics,不占自建后端一字节;
- 备份:数据库和附件每日自动打包上对象存储;老站六件套(SQL/整站包/WXR/配置/校验和/演练记录)永久归档;
- 成本:服务器没有升配,1 核 1.8G 继续服役。
写在最后
回头看这次迁移,最深的体会是那句老话的静态站版本:**「静态优先」不是复古,是把 99% 的读请求从后端拿走。**动态面收缩到只剩评论之后,安全补丁、容量、迁移、甚至「关掉后端博客还能访问多久」这类问题,答案全都变成了「很久」。
另一个感受是:架构文档值得写。这次迁移我边做边把结论沉淀进仓库的两份文档和踩坑清单(现在有七条「必踩坑」速查),本文里的每个坑都能在那里找到机制级的完整版本——写文章用的素材,就是排障时留下的笔记。
下一步:前台现在还是最简骨架样式,主题重写和独立页 Angular 化才刚刚开始。那会是另一个系列了。