回顾这些年折腾 Hexo 博客的经历,从初建到复活再到各种功能配置,踩了不少坑也积累了一些经验。这篇文章把整个过程串联起来,既是记录也是分享。
建站之初
从 2021 年 10 月中旬开始,陆陆续续搭建这个自己的网站。由于不会用 GitHub,也不了解 Hexo、Node.js 这一类东西,所以花的时间比大部分人长。
建站路上遇到最坑的点就是下载的 NexT 主题有问题——需要修改主题目录下的 _config.yml 文件,否则会多一个 %20,删去 || 之间的空格即可。后来才发现原来我用的这个 NexT 已经停止维护了,于是换成了最新的 NexT。
反反复复换了很多次主题,但都不尽如人意,所以最后还是用了 NexT。
第一次复活
2023 年 2 月,寒假结束前,我复活了博客。
这个寒假过得有些混乱,但也有些收获。从家里急事回老家开始,在车上看完了《四月是你的谎言》——本来就弹钢琴的我被触动,买了一台雅马哈 PSR-e373 电子琴。发现电子琴可以作为 MIDI 输入器后,还想写一个 Minecraft mod 做实时 MIDI 转换,可惜老电脑带不动。
后来兴趣又回到了编程上,学习了 Python 的 OpenCV 和 PyQt 写了学校科技创新比赛的作品,又学了一点 Lua 语法准备做饥荒 mod。
一个寒假过去,学习没怎么搞,博客倒是又捡起来了。
第二次复活:全面功能配置
2024 年 2 月,再次清空电脑后,一切又重新开始。惊喜地发现上次复活博客也正好是 2 月 2 日——整整一年前的同一天。
这次趁着重新搭建,把博客的各种功能系统地配置了一遍。
LaTeX 支持
博客中经常需要数学公式(尤其是机器学习相关的笔记),LaTeX 支持是刚需。
方案一:hexo-renderer-kramed(适用于 matery 主题)
Hexo 默认的 hexo-renderer-marked 渲染引擎会将 LaTeX 中的下划线 _ 解析为 HTML 的 <i> 标签。hexo-renderer-kramed 修复了这个问题:
npm uninstall hexo-renderer-marked --save
npm install hexo-renderer-kramed --save
还需要修改 node_modules/kramed/lib/rules/inline.js 解决语义冲突:
// 第 11 行 escape 变量
escape: /^\\([`*\[\]()#$+\-.!_>])/, // 去掉对 {} 的转义
// 第 20 行 em 变量
em: /^\*((?:\*\*|[\s\S])+?)\*(?!\*)/, // 禁用 _ 作为斜体标记
方案二:hexo-renderer-markdown-it-plus(推荐)
npm uninstall hexo-renderer-marked --save
npm install hexo-renderer-markdown-it-plus
然后在主题 _config.yml 中启用 KaTeX:
math:
...
katex:
enable: true
copy_tex: false
踩坑记录:渲染器冲突
配置完成后发现 LaTeX 还是不生效,排查发现是因为同时安装了三个 Markdown 渲染器,它们互相冲突:
| 插件 | 支持 $...$ |
最终状态 |
|---|---|---|
hexo-renderer-marked |
❌ | ❌ 卸载 |
hexo-renderer-kramed |
✅,但修改 | ❌ 卸载 |
hexo-renderer-markdown-it-plus |
✅ 内置 KaTeX | ✅ 保留 |
Hexo 7 中,多个渲染器注册同一扩展名(.md)时,最后注册的胜出。按加载顺序,hexo-renderer-marked 总是覆盖前面的,而它根本不处理 $...$ 数学公式。
修复方法:
npm uninstall hexo-renderer-marked hexo-renderer-kramed --save
同时在站点 _config.yml 中配置 markdown_it_plus 插件并显式启用 KaTeX:
markdown_it_plus:
highlight: true
html: true
plugins:
- plugin:
name: '@iktakahiro/markdown-it-katex'
enable: true
options:
throwOnError: false
另外,NexT 主题默认 math.per_page: true,只有 front-matter 中声明了 katex: true 的文章才会加载 KaTeX 的 CSS/JS。所以每篇用到数学公式的文章都需要加上:
---
katex: true
---
做完这三步,$E=mc^2$ 和 $$\frac{1}{x^2-1}$$ 就能正常渲染了。
需要注意的是,KaTeX 不支持 \begin{equation} 环境,如果文章中用到了需要改写成 \begin{aligned} 等 KaTeX 支持的写法,或者换用 MathJax 引擎。
SSH Deploy Key 配置
GitHub 在国内连接不稳定,终端用 git 命令常失败,所以配置 SSH 密钥来提升部署稳定性。
生成 SSH 密钥对
ssh-keygen -t rsa -N '' -f /home/${USER}/.ssh/id_rsa_github_blog -q
.pub 结尾的是公钥。
添加 Deploy Key
进入 GitHub 仓库设置页,填写标题,粘贴公钥内容,勾选 “Allow write access”,点击 Add key。
配置 SSH Config
Linux(~/.ssh/config):
Host github.com
Hostname ssh.github.com
IdentityFile ~/.ssh/id_rsa_github_blog
Windows(C:\Users\Administrator\.ssh\config):
配置方法大体相同,如果遇到端口 22 被屏蔽:
Host github.com
Hostname ssh.github.com
IdentityFile ~/.ssh/id_rsa_github_blog
Port 443
出现以下提示时键入 yes 即可:
The authenticity of host '[ssh.github.com]:443' can't be established.
ED25519 key fingerprint is SHA256:...
Are you sure you want to continue connecting (yes/no/[fingerprint])?
LeanCloud 评论与阅读量
评论功能(Valine)
在 LeanCloud 仪表盘的应用凭证中找到 AppID 和 AppKey,配置 NexT 主题的 _config.yml:
# Valine
valine:
enable: true
appid: [appid]
appkey: [appkey]
notify: false
verify: false
placeholder: Just go go
avatar: mm
guest_info: nick,mail,link
pageSize: 10
language:
visitor: false
comment_count: true
recordIP: true
阅读量功能
在 LeanCloud 中新建应用,数据存储 → 结构化数据 → 新建 Class,命名为 Counter,选择无限制。然后在主题 _config.yml 中配置:
leancloud_visitors:
enable: true
app_id: <your app id>
app_key: <your app key>
server_url:
security: false
Gitee / GitHub 镜像同步
虽然 Gitee 的实名认证没通过,但可以设置为从 GitHub 自动同步。
在 Gitee 仓库 → 管理 → 仓库镜像管理 → 添加镜像,设为 Pull 方向,选择镜像仓库和 GitHub 私人令牌。
申请 GitHub Token:用户头像 → Settings → Developer setting → Personal access tokens → Generate new token,勾选 repo 和 admin:repo_hook 字段。
详细教程参考 Gitee 官方文档。
SEO 优化
生成 Sitemap
npm install hexo-generator-sitemap --save
npm install hexo-generator-baidu-sitemap --save
在 _config.yml 中配置:
sitemap:
path: sitemap.xml
baidusitemap:
path: baidusitemap.xml
推送到必应
进入 Bing Webmasters 进行配置。
文件目录管理
随着文章增多以及 Obsidian 记录习惯的养成,决定在 _posts 目录下按类别建立文件夹,每篇文章单独建文件夹,图片和文章放在同一级。
移动后发现 {% post_link %} 标签会报错,改用替代方案后解决。之前配置的拼音地址在分类目录上起了作用——提前铺路总是好事。
适配 Obsidian 双链引用
使用 hexo-backlink 插件将 Obsidian 的 [[WikiLink]] 转换为 Hexo 站内文章链接:
npm install hexo-backlink
在 _config.yml 中配置:backlink: true
Obsidian 中的设置:设置 → 文件和链接 → 新链接格式选择"文件的相对路径",并开启"使用 [[维基链接]]"。
字数统计
使用 hexo-word-counter 统计文章字数及阅读时间:
npm install hexo-word-counter
hexo clean
在站点 _config.yml 中添加:
symbols_count_time:
symbols: true
time: true
total_symbols: true
total_time: true
exclude_codeblock: false
awl: 4
wpm: 275
suffix: "mins."
在 NexT 主题 _config.yml 中设置 item_text_total: true。
参数说明:中文为主的博客推荐设置 awl: 2、wpm: 300。
文章短链接(abbrlink)
Hexo 默认生成的文章地址是类似 YEAR/MONTH/DAY/TITLE 的长路径,既不利于 SEO 也不利于链接持久化。
方案一:hexo-permalink-pinyin(拼音转写)
npm install hexo-permalink-pinyin --save
permalink_pinyin:
enable: true
separator: "-"
但这个方法仍然依赖文章标题,改了标题链接也会变。
方案二:hexo-abbrlink(短链接,推荐)
hexo-abbrlink 基于文章标题生成固定短链接(CRC16 或 CRC32),文章内容不变链接就不变,不依赖分类和标题。
npm install hexo-abbrlink --save
在 _config.yml 中配置:
# abbrlink config
abbrlink:
alg: crc32 # crc16(default) or crc32
rep: hex # dec(default) or hex
force: false # false(default), 设为 true 会重新生成所有 abbrlink
# 关键:需要在 permalink 中使用 :abbrlink
permalink: posts/:abbrlink/
踩坑记录
之前安装了这个插件但一直没生效,排查后发现原因很简单:permalink 没有使用 :abbrlink。
插件在 before_post_render 阶段工作,它会读取(或生成)文章的 abbrlink 值并注入到数据中。但 Hexo 最终生成链接时,只用 permalink 模式中声明的变量。如果 permalink 里只有 :category 和 :title,那 :abbrlink 再好看也不会出现在 URL 里。
所以配置 hexo-abbrlink 的关键就两步:
- 安装插件 + 写好
abbrlink配置段 - 修改
permalink为posts/:abbrlink/(或其他含:abbrlink的模式)
这样生成的链接就是稳定的短链接了:
https://mxhanks.github.io/posts/48721/ # 而不是 /技术踩坑/hexopostassetfix/
https://mxhanks.github.io/posts/21936/ # 而不是 /软件工程/javabase/
旧链接兼容
开启 abbrlink 后,之前以旧格式发布的所有页面链接都会变化。被搜索引擎收录或外部引用的旧链接会 404。建议:
- 更新 sitemap 让搜索引擎重新抓取
- 如果使用 GitHub Pages 或 Nginx,可配置 301 重定向将旧路径永久指向新路径
post_asset_folder 图片路径修复
问题背景
我的博客文章组织和大多数人不太一样——习惯用 Obsidian 写文章,并且让图片和 markdown 文件保持在同一级目录。
目录结构类似:
source/_posts/
├── 人工智能/
│ ├── Regression/
│ │ ├── Regression.md ← 文章
│ │ ├── output_6_1.png ← 图片(与 md 同级)
│ │ └── output_15_0.png
│ └── ...
在 Hexo 开启 post_asset_folder: true 的情况下,图片却一直无法正常显示——<img> 的 src 指向了根目录 /output_6_1.png,自然是 404。
原因分析
当 _config.yml 启用以下配置时:
post_asset_folder: true
marked:
prependRoot: true
postAsset: true
Hexo 会为每篇文章创建一个同名子文件夹作为资源目录。例如文章 my-post.md 的预期资源目录是 my-post/。
但我的文章本身就在文件夹中,且文件夹名和文章名相同(Regression/Regression.md)。Hexo 计算的 asset_dir 变成了:
source/_posts/人工智能/Regression/Regression/
注意出现了两层 Regression/——第一层是文章所在的目录,第二层是 Hexo 自动追加的同名子文件夹。而我的图片放在第一层(与 Regression.md 同级),不在这个二层子文件夹里。
所以在构建流程中:
- Processing 阶段:
scanAssetDir扫描.../Regression/Regression/→ 目录不存在 → 没注册任何PostAsset hexo-renderer-marked渲染:postAsset: true查找PostAsset.findById("...output_6_1.png")→ 没找到 → 回退到url_for("output_6_1.png")→ 输出<img src="/posts/688035c/output_6_1.png" >→ 404
解决方案
核心思路是在渲染之前纠正图片 URL,在生成阶段把图片复制到正确位置。用了一个 Hexo 脚本(scripts/fix_asset_dir.js)实现两个过滤器:
Phase 1:注册 PostAsset(before_generate,优先级 1)
在 render_post 之前运行,扫描符合"同名目录"模式的文章目录,为同目录下的图片手动注册 PostAsset,让资源生成器把图片复制到 public 目录中。
Phase 2:修复 HTML 中的 <img> 路径(after_post_render)
文章渲染完成后,将 <img src="/posts/688035c/output_6_1.png" > 替换为正确的绝对路径。
最终效果
| 项目 | 修复前 | 修复后 |
|---|---|---|
<img src> |
/output_6_1.png ❌ |
/posts/28955/output_6_1.png ✅ |
图片文件在 public/ |
不存在 ❌ | 存在 ✅ |
多篇受影响的文章(Regression、Classification、Regularization、STM32 等)均自动修复,无需修改 markdown 源文件。
为什么不改源文件?
Obsidian 和 Hexo 共享同一套源文件。Obsidian 中图片就是和 md 同级的相对路径,改了 Hexo 的构建逻辑去适配它,比反过来改 Obsidian 配置更可持续。
自建服务器部署
博客一直部署在 GitHub Pages 上,国内偶有不稳定。手头有一台云服务器(CentOS + 宝塔面板),就把博客也同步部署上去,实现双线访问。
需求
- 保留 GitHub Pages 部署不变
- 增加自建服务器同步
hexo d一条命令搞定两边- 不需要手动上传文件
方案对比
| 方案 | 原理 | Windows 兼容性 |
|---|---|---|
hexo-deployer-rsync |
本地 rsync → 远程 rsync over SSH | ❌ Windows 无 rsync |
hexo-deployer-sftp |
Node.js ssh2 库直连 SFTP | ⚠️ 密钥格式问题 |
| 自定义 tar+ssh | tar 打包 → SSH pipe → 远端解压 | ✅ |
前两个方案在 Windows 上都遇到了问题,最终选择了第三种。
SSH 密钥配置
ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa_blog_server -N "" -C "blog-server"
ssh-copy-id -i ~/.ssh/id_rsa_blog_server root@47.115.49.75
配置 ~/.ssh/config:
Host 47.115.49.75
Hostname 47.115.49.75
IdentityFile ~/.ssh/id_rsa_blog_server
User root
Nginx 配置
server {
listen 80;
server_name _;
root /www/wwwroot/default;
index index.html;
...
}
核心:tar + SSH 管道
cd public && tar cf - . | ssh root@47.115.49.75 "tar xf - -C /www/wwwroot/default"
原理:tar cf - . 把当前目录打包输出到 stdout,通过 SSH 管道发送到远端解压。
优点:不需要 rsync、tar 和 ssh 在 Git Bash 中都是原生可用、一条管道效率高。
踩坑:Hexo 7 的事件 vs 过滤器
第一次尝试用 hexo.extend.filter.register('after_deploy', ...) 来挂载服务器部署,但脚本加载了却从不执行。查了 Hexo 7 源码才发现问题——部署完成时触发的是事件而不是过滤器:
// node_modules/hexo/dist/plugins/console/deploy.js
this.emit('deployAfter'); // ← 触发的是事件
所以应该用:
// ❌ 没用——Hexo 没有 'after_deploy' 过滤器
hexo.extend.filter.register('after_deploy', function() { ... });
// ✅ 正确——监听 deployAfter 事件
hexo.on('deployAfter', function() { ... });
最终脚本
完整脚本见 scripts/deploy-to-server.js,Hexo 会自动加载 scripts/ 目录下的 .js 文件,无需额外配置。部署时执行顺序:
hexo-deployer-git推送到 GitHub Pages- Hexo 触发
deployAfter事件 - 脚本执行 tar+ssh 同步到私有服务器
密钥格式教训
Windows 上用 ssh-keygen 默认生成 OpenSSH 格式,但很多 Node.js 的 SSH 库(ssh2、node-ssh 等)只支持 PEM 格式。如果需要转换:
ssh-keygen -p -m PEM -f ~/.ssh/id_rsa_xxx -P "" -N ""
注意转换后公钥不变,不需要重新添加到服务器。
最终效果
| 项目 | 说明 |
|---|---|
| GitHub Pages | https://mxhanks.github.io ✅ |
| 自建服务器 | http://47.115.49.75 ✅ |
| 部署命令 | hexo g && hexo d 两边同步 |
LeanCloud 替代:自建阅读量计数 API
背景
博客一直用 LeanCloud 做文章阅读量统计,NexT 主题内置了 leancloud_visitors 支持,配合前端的 lean-analytics.swig 模板工作得很好。
但 LeanCloud 宣布将在 2027 年 1 月 停止服务,需要找一个替代方案。刚好手头有自建服务器,决定写一个兼容 LeanCloud API 的计数服务。
方案选型
NexT 主题其实内置了三个统计方案:
| 方案 | 维护成本 | 可控性 | 备注 |
|---|---|---|---|
| LeanCloud | 零 | ❌ | 即将关停 |
| 不蒜子 (Busuanzi) | 零 | ❌ | 第三方服务,历史数据无法迁移 |
| Firebase Firestore | 低 | ⚠️ | Google 服务,国内访问慢 |
| 自建 API(最终选择) | 低 | ✅ | 已有服务器,数据完全可控 |
自建服务架构
浏览器 (fetch) → Nginx (/counter-api/) → Node.js (Express) → SQLite (counter.db)
NexT 主题的前端 JS(lean-analytics.swig)会调用三个接口:
| 操作 | HTTP 请求 |
|---|---|
| 查询某篇文章的计数 | GET /1.1/classes/Counter?where={"url":"..."} |
| 首次访问创建记录 | POST /1.1/classes/Counter |
| 递增计数 | PUT /1.1/classes/Counter/:id {"time":{"__op":"Increment","amount":1}} |
用 Express + better-sqlite3 写了个约 100 行的服务,完整模拟了这三个接口。前端代码不需要改一行——只要在主题配置中把 server_url 指向自建服务即可。
数据迁移
LeanCloud 导出的 JSON 中有 44 条计数记录,但因为改过文章链接(从日期路径 /2024/02/12/title/ 和拼音分类路径 /人工智能/classification/ 迁移到 /posts/:abbrlink/),大部分旧 URL 已经失效。
写了一个迁移脚本做三件事:
- 删除 time ≤ 1 的记录(只有自己点过一次的无效数据)
- 通过 URL 中的拼音关键词匹配现有文章(如
regularzation→/posts/9044/) - 合并同篇文章的多个旧 URL 计数(同一篇文章可能有过日期路径和拼音路径两条记录)
匹配结果示例:
302 /posts/57901/ ← Windows mklink 使用
49 /posts/2931/ ← Luogu P5707 题解
28 /posts/48643/ ← CSP-J 游记
41 /posts/30549/ ← Python 文件夹映射
...
16 篇文章,共 609 次访问记录全部保留。
部署
服务部署在 CentOS Stream 10 上,用 systemd 管理进程:
# 安装 Node.js
dnf install -y nodejs
# 上传服务代码
scp -r server/ root@server:/opt/blog-counter
cd /opt/blog-counter && npm install --production
# systemd 服务:开机自启 + 崩溃重启
systemctl enable blog-counter
systemctl start blog-counter
Nginx 反代配置:
location /counter-api/ {
proxy_pass http://127.0.0.1:3000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
主题配置只需改 server_url(themes/next/_config.yml):
leancloud_visitors:
enable: true
server_url: "http://47.115.49.75/counter-api"
app_id: ""
app_key: ""
security: false
踩坑记录
迁移脚本的 Bug:第一次跑迁移时,匹配到的旧记录虽然插入了新 URL,但旧记录本身没有删除。结果数据库里同时存在新旧两条记录,计数翻倍。排查后发现是 toDelete 数组漏加了匹配记录的 rowid,补上后重新跑就正常了。
SSH 连接偶发退出码 255:连续执行多条 SSH 命令时,偶尔会出现 Exit code 255。原因可能是服务器资源紧张或 SSH 会话被复用。解决方法:加 sleep 间隔或将多个命令合并到一次 SSH 连接中执行。
NexT 主题的 hostname 校验导致计数不显示:部署完成后发现页面阅读数始终显示为 0,API 返回数据却是正常的。排查发现 NexT 的 lean-analytics.swig 模板中有这么一行:
if (CONFIG.hostname !== location.hostname) return;
网站 _config.yml 中配置的 url: http://mxhanks.github.io/ 决定了 CONFIG.hostname 的值。但自建服务器部署在 47.115.49.75 上,location.hostname 是 IP 地址,两者不相等,计数脚本直接 return 了。
修复方法很简单——当使用自定义 server_url(自建 API)时,跳过这个 hostname 校验:
if (!server_url && CONFIG.hostname !== location.hostname) return;
这样既保留了原版在 LeanCloud 下的保护行为(防止开发环境污染计数),又让自建 API 场景下能够正常工作。
HTTPS 问题
计数器 API 目前通过 HTTP 提供服务。博客在 GitHub Pages(HTTPS)上访问时,浏览器会因混合内容策略阻止 HTTP 请求,所以计数只在自建服务器版本上生效。
解决方案是申请一个域名配 Let’s Encrypt 证书,备案完成后将 stats.你的域名.com 解析到服务器,即可同时服务两个站点。
完整代码
服务端代码不到 100 行,放在博客项目的 server/ 目录下:
server/
├── package.json
├── index.js # 主服务(Express + SQLite)
├── import.js # LeanCloud 数据导入
├── migrate.js # 旧 URL 迁移匹配
├── counter.db # SQLite 数据库
└── blog-counter.service # systemd 单元文件
PageSpeed 优化:hexo-optimize
背景
博客一直缺少 CSS/JS/HTML 的自动化压缩优化,导致页面加载速度和 Google PageSpeed Insights 得分不太理想。Google 的 Lighthouse 审计跑下来,移动端性能分明显偏低,主要扣分项集中在未压缩的 CSS/JS 资源和阻塞渲染的 CSS上。
常见的优化手段无非几样:压缩资源、内联关键 CSS、异步加载非关键样式、给静态资源加版本号。这些操作完全可以自动化,没必要每次构建手动处理。
hexo-optimize 是 NexT 官方团队维护的 Hexo 插件,使用 Rust 编写(minify-html 是 Rust 编译的原生模块),专门解决这些问题。
安装
npm install hexo-optimize --save
当前版本 v3.5.0。
配置
在站点 _config.yml 中添加 filter_optimize 配置块:
# hexo-optimize
filter_optimize:
enable: true
# 静态资源版本号(添加内容哈希,解决缓存问题)
versioning: true
css:
# 压缩所有 CSS
minify: true
excludes:
# 对 Font Awesome 和 Google Fonts 使用 preload 动态加载
delivery:
- '@fortawesome/fontawesome-free'
- 'fonts.googleapis.com'
# 将关键 CSS 内联到 HTML 中
inlines:
- css/main.css
js:
# 压缩所有 JS
minify: true
excludes:
# 移除 JS 中的注释
remove_comments: false
html:
# 压缩所有 HTML
minify: true
excludes:
# 插件执行优先级(值越小越先执行,Hexo 默认 10)
priority: 12
参数说明
| 参数 | 作用 | 建议 |
|---|---|---|
versioning |
给静态资源文件名追加内容哈希,浏览器缓存过期后自动加载新版本 | 推荐开启 |
css.minify |
压缩 CSS,删除多余空格和注释 | 开启 |
css.delivery |
指定哪些 CSS 用 preload 动态加载,不阻塞页面渲染 |
Font Awesome、Google Fonts 等第三方字体库 |
css.inlines |
指定哪些 CSS 直接内联到 HTML <head> 中 |
css/main.css(主题核心样式) |
js.minify |
压缩 JS 文件 | 开启 |
html.minify |
压缩 HTML,去掉多余空白 | 开启 |
priority |
插件执行顺序 | 12(在 Hexo 默认过滤器之后执行) |
开发时禁用
构建时压缩会拖慢速度,开发阶段可以通过环境变量临时禁用:
# Linux / macOS
export NODE_ENV=development
# Windows (cmd)
set NODE_ENV=development
# Windows (PowerShell)
$env:NODE_ENV="development"
设置后 hexo generate 会跳过优化流程,预览时不会卡在构建上。
注意事项
- 内联 CSS 路径:
css.inlines配置的是生成后的绝对路径。NexT 主题的 CSS 在css/main.css,如果用了其他主题或自定义 CSS,需要对应调整。 - CSS delivery 与 inlines 不冲突:
delivery是将指定 CSS 改为动态加载(不阻塞渲染),inlines是将指定 CSS 嵌入 HTML(减少请求)。一般来说主题核心样式内联,第三方字体库动态加载,配合使用效果最好。 - priority 的意义:Hexo 的过滤器系统按优先级顺序执行,默认值是 10。设成 12 表示在大部分 Hexo 内置过滤器之后执行优化,确保内容是最终状态再压缩。如果和其他优化插件搭配,需要注意执行顺序。
代码高亮升级:从 highlight.js 切换到 PrismJS
背景
博客的代码块一直"高亮就和没有一样"——关键字和普通文字的对比度极低,颜色几乎看不出来。最初使用的是 Hexo 内置的 highlight.js(syntax_highlighter: highlight.js),配合 hljs: false 和 NexT 主题默认的 highlight_theme: normal(浅灰底 + 深灰文字配色)。
问题出在三个地方叠加:
| 问题 | 原因 |
|---|---|
| 高亮引擎冲突 | syntax_highlighter: highlight.js 和 hexo-renderer-markdown-it-plus 的 highlight: true 同时处理代码块,互相覆盖 |
| hljs 标记关闭 | highlight.hljs: false 使用了 Hexo 自己的老式 <figure><table> 包裹结构,而不是标准 CSS 类名 |
| 主题配色太淡 | highlight_theme: normal 是浅色主题,对比度极低 |
解决方案
换成 PrismJS,Hexo 7 内置支持,无需额外安装插件。
第一步:修改站点 _config.yml
# 关闭 highlight.js,切换到 PrismJS
syntax_highlighter: prismjs
prismjs:
preprocess: true
line_number: true
tab_replace: ''
highlight:
enable: false
# 关闭 markdown-it-plus 的高亮,避免双重重叠
markdown_it_plus:
highlight: false
第二步:修改 NexT 主题配置(可选)
themes/next/_config.yml 的 codeblock.highlight_theme 控制的是旧 highlight.js 的配色,对 PrismJS 无影响。可以保持默认或随意设置:
codeblock:
highlight_theme: night eighties # 仅对 highlight.js 生效,PrismJS 不受此影响
copy_button:
enable: true # 开启代码块复制按钮
show_result: false
style: flat
第三步:本地托管 PrismJS 资源
由于博客面向国内读者,CDN(如 cdn.jsdelivr.net)在国内经常不稳定甚至被屏蔽。直接从 node_modules 中复制 PrismJS 文件到主题目录下本地托管:
# 创建主题 lib 目录
mkdir -p themes/next/source/lib/prism/
# 复制 CSS 主题(可替换为其他主题:prism.min.css / prism-coy.min.css / prism-okaidia.min.css 等)
cp node_modules/prismjs/themes/prism-coy.min.css themes/next/source/lib/prism/
# 复制 core(需要加 data-manual,见下文说明)
cp node_modules/prismjs/components/prism-core.min.js themes/next/source/lib/prism/
# 复制 line-numbers 的 CSS(行号由 Hexo 构建时生成,只需 CSS 即可)
cp node_modules/prismjs/plugins/line-numbers/prism-line-numbers.min.css themes/next/source/lib/prism/
⚠️ 关键踩坑:
prism-core.min.js加载后会自动执行Prism.highlightAll(),将构建时已着色的 token 全部覆盖为纯文本,导致代码颜色消失。解决方案是在 script 标签上加data-manual属性阻止自动执行。详见下一步。
第四步:通过主题自定义文件加载
启用 NexT 的 custom_file_path,创建 source/_data/head.swig 和 source/_data/body-end.swig:
{# head.swig - 插入 <head> #}
<link rel="stylesheet" href="/lib/prism/prism-coy.min.css">
<link rel="stylesheet" href="/lib/prism/prism-line-numbers.min.css">
<style>
pre[class*="language-"] {
font-size: 0.85em;
border-radius: 4px;
}
code[class*="language-"] {
text-shadow: none;
}
</style>
{# body-end.swig - 插入 </body> 前 #}
<!-- data-manual 阻止自动 highlightAll,避免覆盖构建时生成的 token -->
<script src="/lib/prism/prism-core.min.js" data-manual></script>
<!-- 复制按钮手动实现(NexT 自带的只认 figure.highlight,不兼容 PrismJS) -->
<script>
(function() {
document.querySelectorAll('pre[class*="language-"]').forEach(function(pre) {
if (pre.parentNode && pre.parentNode.classList.contains('highlight-container')) return;
var container = document.createElement('div');
container.className = 'highlight-container';
pre.parentNode.insertBefore(container, pre);
container.appendChild(pre);
var btn = document.createElement('div');
btn.className = 'copy-btn';
btn.innerHTML = '<i class="fa fa-clipboard fa-fw"></i>';
container.appendChild(btn);
btn.addEventListener('click', function() {
var code = pre.querySelector('code');
if (!code) return;
navigator.clipboard.writeText(code.textContent).then(function() {
btn.innerHTML = '<i class="fa fa-check fa-fw"></i>';
setTimeout(function() { btn.innerHTML = '<i class="fa fa-clipboard fa-fw"></i>'; }, 2000);
});
});
});
})();
</script>
为什么不加载
prism-autoloader.min.js和prism-line-numbers.min.js?因为preprocess: true模式下,Hexo 在构建时已完成所有着色和行号插入,客户端无需重复处理。加载 prism-core 只是为了给 NexT 的插件体系提供Prism命名空间。
最终效果
| 项目 | 改造前 | 改造后 |
|---|---|---|
| 高亮引擎 | highlight.js + markdown-it-plus 冲突 | PrismJS 构建时预处理(preprocess: true) |
| 主题配色 | 浅灰底 #f7f7f7 + 深灰字 #4d4d4c |
可选多种主题(当前为 Coy:白底 #fdfdfd) |
| 关键字 | 几乎看不见的暗色 | 主题配色,清晰可辨 |
| 字符串 | 不可区分的灰色 | 主题配色,鲜明区分 |
| 行号 | 有但样式平淡 | 构建时内联,带分隔线 |
| 复制按钮 | 无 | 代码块右上角一键复制 |
| 资源加载 | CDN(国内可能被墙) | 本地静态文件,秒加载 |
| 防客户端覆盖 | ❌ 未处理 | ✅ data-manual 阻止 PrismJS 自动重绘 |
可自由切换 PrismJS 主题:
prism.css(默认)、prism-coy.css(当前)、prism-dark.css、prism-funky.css、prism-okaidia.css、prism-solarizedlight.css、prism-twilight.css。只需替换head.swig中的 CSS 链接即可。
换电脑:源码与成品分离 + Git 版本管理
2026 年 8 月,大学开学,这台电脑要留在家里。博客却还想继续写——于是第一次认真思考"如何跨机器管理这个项目"。最先想到的就是丢到 GitHub 上,可我的博客本身就在 GitHub Pages 上,一个不小心就会把自己网站搞坏。
核心思路:两个仓库
mxhanks.github.io 这个仓库现在只存放构建后的成品(hexo g 生成的 public/ 内容),由 hexo d 推到 master 分支,GitHub Pages 从它读网页。
如果把源码(markdown、_config.yml、主题……)直接推进去,Pages 会开始展示 .md 源码,站点直接报废。所以正确做法是源码与成品分离:
| 仓库 | 内容 | 角色 |
|---|---|---|
mxhanks.github.io |
只有 public/ |
网站本体,由 hexo d 写入,不变 |
mxhanks/blog-source(新建) |
整个 Hexo 工程 | 源码,换电脑后 clone 下来就是完整环境 |
两个仓库互不干扰,部署逻辑一行都不用改。
迁移清单
换电脑后在新机器上要准备的东西:
- git + Node.js(版本以
package.json里的hexo依赖为准) - SSH 私钥:
~/.ssh/id_rsa_github_blog(推 GitHub Pages)和~/.ssh/id_rsa_blog_server(连自建服务器),连同~/.ssh/config一起备份到 U 盘或密码管理器——这是部署的两把钥匙 - 源码:clone 源码仓库 →
npm install→ 照常npm run deploy:all
.gitignore 早就配好了(node_modules/、public/、.deploy_git/、db.json 都不进仓库),源码仓库会很干净;package-lock.json 记得提交,保证换机后 npm ci 装出一模一样的环境。
themes/next 自带 .git 的坑
项目里 themes/next 是从 GitHub clone 下来的,自带一个 .git 目录。如果不处理,git init 后主题会被当成"子仓库"(gitlink),文件不会被主仓库追踪——提交上去的是一个空引用,换电脑 clone 下来主题是空的,站点直接坏。
解决办法二选一:
- 简单法(推荐):删掉
themes/next/.git,让主题文件直接跟主仓库走。主题本来就不怎么改它,它自己的历史没有保留价值。 - 正式法:
git submodule add管起来,但换机后每次都要--recurse-submodules,麻烦且没必要。
GitHub Actions 一键自动部署
换电脑后,如果还要在新机器上装 Node、配 SSH 密钥才能发文章,多少有点麻烦。干脆把构建和部署都搬进云端——以后只写文章、git push,剩下全自动。
2026-09 起已改为仅手动触发(
workflow_dispatch):push 不再自动部署,需要时去 Actions 页面点 Run workflow。原因见文末「换机实跑」一节。
workflow 文件
在源码仓库下新建 .github/workflows/deploy.yml:
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: 安装依赖并构建
run: |
npm ci
npx hexo generate
- name: 部署到 GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
deploy_key: ${{ secrets.PAGES_DEPLOY_KEY }}
external_repository: mxhanks/mxhanks.github.io
publish_branch: master
publish_dir: ./public
force_orphan: true
- name: 部署到自建服务器
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SERVER_SSH_KEY }}" | base64 -d > ~/.ssh/id_rsa_server
chmod 600 ~/.ssh/id_rsa_server
ssh -i ~/.ssh/id_rsa_server -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
root@47.115.49.75 "rm -rf /www/wwwroot/default/* && mkdir -p /www/wwwroot/default"
tar -C public -cf - . | ssh -i ~/.ssh/id_rsa_server -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
root@47.115.49.75 "tar -C /www/wwwroot/default -xf -"
流程就是:push 到 main → 云端 npm ci + hexo generate → ① 把 public/ 推到 mxhanks.github.io 的 master;② tar+ssh 同步到自建服务器(其实就是把 deploy-server.sh 的逻辑搬进 CI,连 dotfile 一起打包,行为和本地一致)。
两个 Secrets
在源码仓库 Settings → Secrets and variables → Actions 里配置:
| Secret | 内容 | 获取方式 |
|---|---|---|
PAGES_DEPLOY_KEY |
一把只推 mxhanks.github.io 的 SSH 私钥 |
ssh-keygen -t ed25519 -f ~/.ssh/pages_deploy_key -N "",公钥加到 mxhanks.github.io 仓库 Settings → Deploy keys(勾 Allow write access),私钥内容存进 secret |
SERVER_SSH_KEY |
~/.ssh/id_rsa_blog_server 的 Base64 |
base64 -w0 ~/.ssh/id_rsa_blog_server(私钥含换行,直接存会弄坏 YAML,所以编码后再存、workflow 里解码) |
为什么 Pages 部署不用 GitHub 内置的
GITHUB_TOKEN?它只能推同一个仓库,而成品在mxhanks.github.io是另一个仓库,所以要一把专门授权给那个仓库的 deploy key——比给整个账号权限的 PAT 更安全。
踩坑:updated_option: mtime 在 CI 下的副作用
站点 _config.yml 里配的是 updated_option: 'mtime'(文章"更新日期"取文件修改时间)。本地编辑时这个值很合理,但云端 checkout 时所有文件的 mtime 都等于拉取时刻,导致每次 push 所有文章都会刷新"更新日期"。介意的话改成 updated_option: 'date'(取正文 frontmatter 里的 date,不再被刷新)。
统一图片命名:Obsidian 粘贴图片重命名
用 Obsidian 写文章时直接粘贴截图,会自动命名为 Pasted image 20260803170445.png 这种带空格的长名字。带空格的文件名在 Markdown 引用、URL 和部分环境里都很容易出问题。
约定
博客里的图片统一叫 image-xx(image-1.png、image-2.png……),Obsidian 粘贴产生的图片在发布前需要重命名并同步更新引用。
工具
scripts/convert_wikilink_images.py 一键完成三件事:
- 把
Pasted image ...图片重命名为下一个可用的image-N - 把文中
![[旧名.png]]双链同步改成标准 - 剩余的
![[...]]全部转成标准语法
python scripts/convert_wikilink_images.py
这次在 STM32Learning 里跑了一次:3 张粘贴图片重命名为 image-27/28/29,文中 3 处引用自动更新,目录里不再有带空格的文件名。
只重命名文件却不更新引用,图片会 404。所以"重命名 + 引用替换"必须是一体的——这正是这个脚本存在的意义。
安全教训:泄漏的 GitHub Token
准备推仓库前扫了一遍项目,发现 _config.yml 末尾的注释里躺着一个真实的 GitHub token(ghp_ 开头)。全局搜了一遍代码,确认它已经没人用了——部署走的是 SSH,不是它。
私有仓库 ≠ 不会泄漏
当时的第一反应是"提交到私有仓库不就行了吗"。对了一半:私有仓库确实不会被公开看到。但真正的风险在于它是一条没人用的悬空凭证,而且 git 历史会永久保留已提交的内容——将来只要发生任一情况就全完了:
- 仓库哪天改成公开,token 就跟着历史一起曝光
- 电脑或备份落到别人手里
- 账号被扫到旧文件泄露
教训
- 凭证永远不进仓库,尤其是文本注释。需要密钥的地方用 GitHub Secrets 或 SSH deploy key
- 一旦进过 git 历史,光删文件是删不干净的,必须吊销
- 处理方式:去 github.com/settings/tokens 吊销 + 删掉注释行。这次是在首次提交之前删的,所以 git 历史里干干净净
换机实跑:从源码仓库恢复双端部署
2026 年 9 月,正式在新电脑上实践当初"两个仓库"的设想:clone 源码仓库,恢复 GitHub Pages + 自建服务器双线部署。理想很丰满,现实是——新机器上什么都没有。
新机器到底缺什么
按前面"迁移清单"核对,这台电脑没有 Node、没有 ~/.ssh,git 全局身份也没设。最要命的是两把部署私钥都留在旧电脑上了。结论:换机器意味着环境从零重来,两把私钥重新生成即可,别指望能无缝"搬"过去。
| 组件 | 处理方式 |
|---|---|
| Node.js | 见下"免安装便携版" |
| 服务器私钥 | 重新 ssh-keygen -t ed25519,把公钥追加到服务器 ~/.ssh/authorized_keys(宝塔终端里执行) |
| GitHub 私钥 | 重新生成 ed25519,公钥加到 GitHub → Settings → SSH keys |
| git 身份 | git config --global user.name/email(不设的话,hexo-deployer-git 向 Pages 仓库提交时会失败) |
顺带一个认知:只要源码仓库的 Actions Secrets 还在(
PAGES_DEPLOY_KEY/SERVER_SSH_KEY),push 一样能双端部署,根本不需要本地密钥。本地密钥只为"用hexo d在本地发"这条习惯服务——两条发布路别搞混。
Node.js 免安装便携版
Windows 的 winget 在无交互的 shell 里会卡住(要先同意源协议、还可能弹 UAC),不适合脚本化安装。干脆不用安装器——直接下官方 win-x64 zip,解压到固定目录,自己加进用户 PATH:
curl -sL -o node.zip https://nodejs.org/dist/v20.20.2/node-v20.20.2-win-x64.zip
tar -xf node.zip && mv node-v20.20.2-win-x64 /f/Workspace/node
# 加入用户 PATH(PowerShell,需重开终端生效)
[Environment]::SetEnvironmentVariable('Path',
[Environment]::GetEnvironmentVariable('Path','User') + ';F:\Workspace\node', 'User')
版本选 Node 20 LTS,和 Actions workflow 里 setup-node 的 node-version: 20 对齐——避免 hexo-renderer-markdown-it-plus 这类老插件撞上新版 Node。
借重装修掉的三个历史遗留
源码里躺着三个问题,平时没炸,这次全暴露了:
1. url 还指向 GitHub Pages
_config.yml 里 url: http://mxhanks.github.io/ 是历史遗留。在自建服务器上,它会让主题脚本里 CONFIG.hostname !== location.hostname(就是阅读量计数那节踩过的坑)直接 return,计数失效。这次把 url 改成 http://47.115.49.75/ 后,服务器上 hostname 校验自然通过,canonical / sitemap 也指向了服务器。
代价是 GitHub Pages 那份的 canonical 也一起指向服务器 IP(两站共用同一份 public/)。要让 Pages 版的规范链接正确,得按部署目标拆分 url,或给 Pages / 计数器 API 上 HTTPS——留待后续。
2. scripts/ 目录里混进了 Python 文件
Hexo 会把 scripts/ 下每个文件都当插件加载,.py 也不例外。之前把纯手动工具 convert_wikilink_images.py 放在这里,结果每次跑 hexo 命令都先刷一段报错:
ERROR Script load failed: scripts\convert_wikilink_images.py
SyntaxError: Invalid or unexpected token
红字看着吓人,好在非致命(exit 0、不影响产物),但每条命令都刷屏。把它挪到 tools/ 后就清净了。教训:scripts/ 只放真正的 Hexo 脚本(注册过滤器 / 事件的那种),杂项工具放别处。
3. 脚本里写死的旧电脑路径
deploy-server.sh 里 PUBLIC_DIR="F:/Projects/Blog-Pages/public" 是旧电脑的目录名,换机必挂。改成从脚本自身定位,走到哪都成立:
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PUBLIC_DIR="${SCRIPT_DIR}/public"
发布前先备份
脚本发布是"先 rm -rf 清空线上、再上传",中途断了线上就空了。上服务器前先留个底:
ssh root@47.115.49.75 "tar czf /root/blog_backup_\$(date +%Y%m%d_%H%M%S).tgz -C /www/wwwroot/default ."
两条发布路,这次总算理清
实测两遍都通,各自的作用边界也清楚了:
| 发布路 | 触发 | Pages 靠什么 | 服务器靠什么 |
|---|---|---|---|
| 本地 | hexo g && hexo d |
hexo-deployer-git(账号 SSH key) |
deployAfter 事件 → scripts/deploy-to-server.js 的 tar+ssh |
| 云端 | push / 手动 Run workflow | Actions + deploy key(PAGES_DEPLOY_KEY) |
Actions 里把 deploy-server.sh 的逻辑搬进 CI(SERVER_SSH_KEY) |
这次把 Actions 从"push 自动触发"改成了"仅手动":
on:
workflow_dispatch: # 原来还有 push: { branches: [main] }
理由:既然已经习惯本地 hexo d,每次 git push 还让云端重建一遍属于重复劳动;真想走云端时,Actions 页面点一下 Run workflow 即可。想改回自动,把 on: 加回 push 就行。
⚠️ 两条路并存的血泪提醒:源码仓库的状态必须始终和线上一致。这节的改动(
url、脚本路径、挪 py)如果只部署却不 push,哪天 Actions 被随便一推触发,就会用旧源码把线上重新覆盖回旧配置。所以"改完 → 部署 → push"要当成一套动作完成。
从 2021 年初建到现在,博客的形态变了很多次。从一开始连 GitHub 都不会用,到后来自己写 Hexo 脚本、配服务器,每一步踩坑都成了经验。这些折腾的价值或许不只是技术本身——它让我习惯了记录、整理和分享,而这一点大概是写博客最大的收获。
如果你也在折腾 Hexo 博客,希望这篇文章能帮你少走一些弯路。