如果不是我说,这是一个 Hugo 构建的博客,各位看官大爷可能感受不出来,没错我从 typecho 无缝转过来了,要是搁在一年前,想都不敢想。 本次转换,我使用的是 WorkBuddy,全程键盘侠,从文章的转换到说说页面的集成,哐哐一顿输出,耗时 3 个小时,消耗 600 多积分。 整个过程很简单,我把 typecho 打包下载,扔给 AI,告诉他原样复刻一份 Hugo 博客。
转换过程很顺利,他让我用哪条命令,我依着做就是,最关键是让他给写一个管理后台,方便发布文章,这样就不需要在本地生成静态再上传,和 typecho 一样,从我个人来说基本上无感,后台包括文章的编辑发布,友情链接的删改添加,说说的发布,一切都很完美。 下面是我让 AI 总结的转换过程。
一、先想清楚:要搬的到底是什么
Typecho 是个动态程序,一次请求要跑 PHP、连数据库、渲染模板。Hugo 是构建期生成静态文件,服务器只负责发文件。这意味着:
- 变简单的地方:没有数据库、不怕并发、备份就是拷文件夹、迁移服务器只要
rsync、速度靠 Nginx 直接怼。 - 变复杂的地方:所有「服务器端才做得到」的功能都得另找出路——评论、阅读数、说说、后台发布,这些原来一行 PHP 就解决的事情,全要重新设计。
所以动手前先把功能列一遍,逐个判断「静态化之后还留不留」:
| 功能 | 处理方式 |
|---|---|
| 文章、页面、分类、标签、归档 | Hugo 原生 |
| 评论 | 保留 Twikoo(本来就是独立云函数,跟博客程序无关) |
| 阅读数 | 删掉(静态页面拿不到真实数字,留着只会显示一个永远不变的假值) |
| 说说 | 保留 Memos,构建时拉取生成 JSON |
| 图片 | 迁到 Cloudflare R2,自己的图床 |
| 后台发布 | 写一个单文件 PHP 后台,只负责改 Markdown 和触发构建 |
| 友情链接 | 存 YAML,后台增删改 |
这一步不想清楚,后面会反复返工。我就是在「阅读数」上纠结了半天,最后发现根本不该留。
二、备份,然后把数据库拆成 Markdown
备份是唯一一个不能出错、也最容易偷懒的步骤。要备的东西比想象中多:
# 数据库
mysqldump -u root -p typecho > typecho.sql
# 附件(Typecho 默认在 usr/uploads,按年月分目录)
tar czf uploads.tar.gz /www/wwwroot/blog/usr/uploads
# 主题和插件配置(改过的 CSS、functions.php 都在里面)
tar czf theme.tar.gz /www/wwwroot/blog/usr/themes/qiwi
# 站点配置(站点地址、每页篇数、时区这些)
mysqldump -u root -p typecho typecho_options > options.sql
导出成 Markdown 的思路很笨但很稳:把 typecho_contents 表导成 CSV,用一段脚本逐行生成 .md,正文里的 HTML 和 Markdown 混排原样保留(Hugo 的 Goldmark 默认允许 HTML 混写)。
关键是 front matter 的字段映射,这一步决定了老链接会不会全废:
| Typecho 字段 | Hugo front matter | 说明 |
|---|---|---|
cid | cid + 拼进 url | 老固定链接是 /post/{cid}.html,必须一模一样 |
title | title | 用单引号包住,标题里有 : 也不会炸 |
created | date | 带时区,写成 2026-10-21T22:17:00+08:00 |
| 分类 | categories | 取 typecho_metas 里 type=category 的 |
| 标签 | tags | 同上,type=tag |
text | 正文 | 原样 |
最终每篇文章长这样:
---
title: '一件 16 & 荣耀 Magic 9'
date: 2026-10-21T22:17:00+08:00
url: "/post/3764.html"
cid: 3764
categories:
- '生活记录'
tags:
- '手机'
- '一件'
---
url 这一行是整个迁移里最重要的一行。Hugo 默认按文件名和目录生成路径,只要显式写死 url,旧的搜索引擎收录、别人收藏的链接、Twikoo 里存的评论地址,全部继续有效。评论就是这么保住的——Twikoo 的数据是按 URL 存的,URL 不变,评论就一条不丢。
正文清洗有三件事要做:
- Typecho 的
<! --more-- >摘要分隔符删掉,换成 Hugo 的.Summary(自动取前 70 字左右)。 - 附件短代码和相对路径图片,统一改写成图床绝对地址。
- 老表情
:&(名字)这类格式 Twikoo 认,Hugo 不认,得单独做映射——这一项我到现在还没做完。
三、把主题重写成 Hugo 模板
原主题是给 Typecho 写的,PHP 里嵌着 HTML。迁移不是「换个引擎继续用」,而是把页面拆成零件:
layouts/_default/baseof.html—— 骨架:head、侧栏、主容器、页脚。layouts/_default/list.html/single.html—— 列表页与文章页。layouts/partials/—— 真正干活的零件:post-card.html(首页卡片)、nav-icon.html(图标)、cover.html(封面)、comments.html、scripts.html。layouts/pages/—— 独立页面:关于、归档、友链、说说。
原主题的 CSS 是写死色的,顺手做了一件事:把颜色全部抽成 CSS 变量,暗色模式只要换一组变量,不用写两套样式。
:root {
--v2-bg: #fdfcf9;
--v2-text: #333;
--v2-accent: #c4553b;
}
:root[data-theme="dark"] {
--v2-bg: #1a1a1a;
--v2-text: #d6d6d6;
}
PJAX 是这里最大的坑
原主题用了 PJAX:点链接不发整页请求,只替换 #qiwi-pjax 容器里的内容。搬到 Hugo 后我保留了这套逻辑,然后踩了一个很难查的坑——
首次从首页点进文章,控制台报一堆跨域错误,刷新一下就好了。
原因是:Hugo 模板里用内联 <script> 输出的数据(那会儿是文章的 EXIF 信息),被放在 </main> 容器外面。PJAX 只替换容器内的 HTML,容器外的脚本永远不会重新执行,所以文章页拿到的是首页的数据——空的。于是前端以为「这篇没有预生成数据」,转而去 fetch 图片,跨域又没开 CORS,直接报错。刷新是完整加载,脚本重新跑,所以正常。
解法有两步:
- 把内联数据挪进 PJAX 容器内部(Hugo 里就是放进
baseof.html的<main>里),PJAX 重执行容器内脚本时会带上新数据。 - 前端不要缓存初始化时的快照,每次用之前实时读
window.QIWI_EXIF_DATA。
这个坑教会我一件事:在 PJAX 站点里,「脚本执行时机」和「数据作用域」是同一个问题,容器外的脚本只属于第一次加载的那个页面。
四、评论:一个字都不用改,但要改两处细节
Twikoo 是独立的云函数,跟博客程序无关,所以迁移时它几乎不用动——前提是文章的 URL 没变。
第一处细节:评论数。首页卡片上显示的评论数,我是用 Twikoo 的 GET_COMMENTS_COUNT 事件批量取的,一次请求把整页文章的评论数一起拿回来,比加载 80KB 的 twikoo.min.js 划算得多。但发现数字总比评论区里实际看到的少一半,查了半天才明白:
// 只数顶层评论
{ event: 'GET_COMMENTS_COUNT', urls: urls }
// 连楼中楼一起数
{ event: 'GET_COMMENTS_COUNT', urls: urls, includeReply: true }
同一篇文章,20 条和 42 条的区别。加上 includeReply: true 就对了。
第二处细节:URL 必须完全对齐。https://www.mrhe.net 和 https://mrhe.net、带不带结尾斜杠、/post/123.html 还是 /archives/123/,在 Twikoo 眼里都是四个不同的页面,评论会「消失」。上线前把所有变体都 301 到唯一地址,比事后导数据省事得多。
五、图片:从附件目录搬到 R2
Typecho 的图片在 usr/uploads,跟着程序目录走。静态站没有「上传」这回事,所以图片必须有独立的家。选了 Cloudflare R2:存储便宜、不限流量、可以绑自己的域名(img.mrhe.net),关键是支持 S3 协议。
迁移时批量上传,然后把正文里的旧路径全部替换成新域名。这里有个跨域的坑:R2 的自定义域名默认不带 Access-Control-Allow-Origin,前端用 fetch 去读图片做 EXIF 解析会被浏览器拦掉,控制台一片红。两个选择:给桶加 CORS 规则,或者干脆不在前端读——我现在是构建时就把 EXIF 抠出来写进页面,前端只读现成的 JSON,既不用 fetch 也没有跨域问题,还省了访客的流量。
六、说说:Memos 从三个实例拉一份 JSON
我的说说分散在三个 Memos 实例上(版本不同,接口也不一样:0.18 用 /api/v1/memo,0.21 以后用 /api/v1/memos,新版还要 /file/{name}/{filename} 拼附件地址)。静态站要在构建时把它们全拉下来:
- 一个 PHP 脚本负责拉三个实例,按
updated排序,写成memos.json; - Hugo 构建时读这个 JSON 渲染说说页;
- 后台发布说说后顺手刷新一次缓存,不用等定时任务。
memos.json 放在站点目录里,部署脚本里要显式排除它——rsync --delete 会把构建产物里没有的文件全删掉,不说一声,缓存就每次发布都丢一遍。
七、后台:一个 PHP 文件当 CMS
静态站最大的不便就是「改一篇文章得 git clone 再推」。我的办法是写一个单文件 PHP 后台,放在 /www/wwwroot/qiwi-admin/index.php,只干四件事:
- 读写
content/posts/*.md的 front matter 和正文; - 增删改友链(存
data/friends.yaml); - 上传图片到 R2(自己用 PHP 实现了 SigV4 签名,不依赖 SDK);
- 发布说说、刷新缓存。
保存完调用 deploy.sh 重新构建,这样仍然是静态站,只是编辑体验回到了「打开网页就能写」。这种方案不适合多人协作,但一个人写博客足够了。
八、部署:构建、同步、切根目录
服务器上就是一条命令:
cd /www/wwwroot/qiwi-hugo
sudo -u www bash scripts/deploy.sh
脚本做三件事:hugo --minify --gc 构建到 public/,rsync -a --delete public/ /www/wwwroot/mrhe-static/,然后在本地用 Host 头抽查几个路径的状态码是不是 200。
这里有两个必须注意的地方:
一是权限。 Nginx 和 PHP 都以 www 身份跑,如果文件是 root 上传的、权限 600,构建就会莫名其妙失败:
chown -R www:www /www/wwwroot/qiwi-hugo
chmod -R a+rX /www/wwwroot/qiwi-hugo
a+rX 里大写 X 只给目录加执行位,普通文件不会变成可执行。这句我背了很多遍,因为每次用 root 解压补丁都会忘。
二是缓存。 静态资源的 URL 后面都挂了一个 ?v=10,改完 CSS/JS 要手动把这个版本号 +1,否则访客浏览器里 7 天的强缓存会把新样式挡在外面——你会以为「改了没生效」,其实是自己在看旧文件。
九、踩坑清单
把这一路的错误整理成表,方便以后自己查:
| 现象 | 真正的原因 | 解法 |
|---|---|---|
| 老文章全部 404 | 固定链接变了 | front matter 里写死 url: /post/{cid}.html |
标题显示 &amp; | front matter 里已经写了 &,Hugo 又转义一次 | 写裸 & |
***文字*** 渲染成字面星号 | CommonMark 要求 *** 后紧跟文字 | 去掉空格 |
| 首次进文章页控制台报 CORS,刷新正常 | PJAX 容器外的内联数据不更新 | 数据放进容器内 + 前端实时读取 |
| 友链头像裂开露出 alt 文字 | 对方站防盗链 | referrerpolicy="no-referrer" + onerror 兜底图 |
bash: xxx.sh: Permission denied | 文件属主 root、权限 600 | chown www:www + chmod 644 |
| 评论数比实际少 | 只统计了顶层评论 | includeReply: true |
| 发布一次说说缓存就丢 | rsync --delete 删掉了构建产物外的文件 | --exclude memos.json |
十、值不值
迁移花了我好几个晚上,中间无数次想「要不算了,Typecho 也能用」。但现在回头看:
- 服务器上少了一个 PHP 应用和一个数据库,备份就是打包一个目录;
- 首页和文章页不再等 PHP 渲染,Nginx 直接发文件;
- 想改主题,改模板和 CSS 就行,不用碰 PHP;
- 文章是纯文本 Markdown,十年后哪怕 Hugo 没了,文件还在,随便什么工具都能读。
代价是所有动态功能都得自己补。评论靠 Twikoo、说说靠 Memos、后台靠一个 PHP 文件——它们拼起来,其实就是把原来那个「动态博客」重新组装了一遍,只是每一块都能独立替换。
如果你也在犹豫要不要搬,我的建议是:先别急着动主题,先把「哪些功能必须留」列清楚。剩下的都是体力活,而体力活总能干完。
评论区