把博客从动态框架搬到 Hexo 静态站,再亲手写一套主题,前后折腾了不少时间。这篇把过程中那些「为什么这么做、又为什么不那么做」的取舍记录下来——既是给自己的复盘,也许也能给同样想折腾博客的人一点参考。

核心的一条原则贯穿始终:够用就好,不为了炫技堆功能;但该有的体验细节,一个都不将就。

一、为什么是静态站

最初的博客跑在一套动态框架上,评论、点赞、访问统计都依赖后端接口和数据库。它能力强,但代价也实在:要租服务器、要管数据库备份、要担心被刷被攻击,每个月还得交一笔钱。对一个更新频率不高的个人博客来说,这套重型基建有点杀鸡用牛刀。

静态站的思路完全不同:把所有内容在构建期编译成纯 HTML/CSS/JS,扔到 GitHub Pages 这类静态托管上。好处很直接——

  • 零成本:托管免费,没有服务器要养。
  • 够快:纯静态文件走 CDN,首字节时间极短。
  • 够安全:没有后端、没有数据库,攻击面小到几乎没有。
  • 可移植:内容就是一堆 Markdown,换平台、做备份都轻松。

代价是那些「天生需要后端」的动态功能得换个思路实现。这恰恰是最有意思的部分。

二、动态功能的静态化方案

评论:Giscus

放弃自建评论系统,接入 Giscus——它把 GitHub Discussions 当作评论数据库,评论数据存在 GitHub 上,前端只是个壳。代价是评论者需要 GitHub 账号,但对一个技术博客来说,读者群体重合度很高,可以接受。额外做的一件事是让 Giscus 的深浅色主题跟随本站切换:监听主题变化,用 postMessage 通知 Giscus 的 iframe 同步。

访问统计:纯前端计数

用不蒜子 / vercount 这类纯前端脚本统计 PV/UV,数据存在第三方,页面只负责展示数字。轻量、无侵入,缺点是依赖外部服务的可用性——但统计数字挂了也不影响阅读,属于可接受的降级。

站内搜索:索引前置

动态站的搜索是「输入 → 后端查库 → 返回」。静态站没有后端,于是把索引提前到构建期:生成时把所有文章打包成一个 search.json,随站点一起部署,浏览器加载后在内存里做匹配。

1
2
3
4
5
6
// 弹出搜索框时才懒加载索引,省首屏流量
let data = null;
async function load() { data = await fetch('/search.json').then(r => r.json()); }
function search(q) {
return data.filter(p => (p.title + p.content).toLowerCase().includes(q)).slice(0, 10);
}

几十上百篇文章的规模,这种暴力子串匹配毫秒级完成,体验完全够。真到上万篇再考虑倒排索引或 Pagefind 不迟——不提前为不存在的规模买单,也是一种取舍。

三、无刷新导航:PJAX 的克制

最影响「手感」的决定,是上 PJAX(pushState + Ajax)。点击站内链接时,不整页刷新,而是 fetch 回目标页面,只替换变化的区域:

1
2
3
4
5
fetch(url).then(r => r.text()).then(html => {
const doc = new DOMParser().parseFromString(html, 'text/html');
document.querySelector('.main').replaceWith(doc.querySelector('.main'));
// 顶栏、侧栏、音乐播放器在替换区域之外,纹丝不动
});

因为顶栏、左侧栏和左下角的音乐播放器都在被替换的区域之外,换页时它们保持不动——音乐不断、主题不闪、滚动条不跳,体验接近单页应用。

但 PJAX 也有一堆坑,每一个都得认真填:

  • 脚本不会自动执行:插入的 <script> 不运行,得手动重建节点(否则评论、统计换页后失效)。
  • 事件要用委托:监听绑在 document 上,否则换页后节点被替换、监听就丢了。
  • 滚动管理:换页瞬时回顶(behavior:'instant' 强制忽略全局平滑滚动),并把 history.scrollRestoration 设为 manual 自己接管,前进/后退时再恢复记录的位置。

最关键的是把它做成渐进增强:每个链接仍是真实的 <a href>,每页在服务端都能独立访问、返回完整 HTML。PJAX 只在支持的浏览器里拦截优化,不支持或出错就回退整页跳转。这样既拿到了顺滑手感,又完整保留了静态站的 SEO 优势——搜索引擎爬的是真实 URL 和完整 HTML,根本不关心前端有没有拦截。

四、主题系统:两个正交的维度

配色是这套主题里我最满意的部分。核心是 CSS 自定义属性:把所有颜色抽成变量,换主题就是换一组变量的值

我把主题拆成两个互相独立的维度:

  • data-theme:亮 / 暗 / 跟随系统。
  • data-palette:主色(青 / 蓝 / 绿 / 粉 / 橙 / 紫……)。

两者正交组合——任意主色都能配深色或浅色。更进一步,配色列表做成了配置式:在配置文件里列出每套配色的主色,模板用 color-mix() 自动派生出 --accent-soft 等衍生色并生成对应 CSS,增删一套配色只改配置、不碰样式表。

1
--accent-soft: color-mix(in srgb, var(--accent) 13%, transparent);

几个体验细节:

  • 防首屏闪烁(FOUC):在 <head> 里放一段阻塞执行的内联脚本,在首次绘制前就把 data-theme/data-palette 设好,暗色用户不会先看到一道白光。
  • 圆形扩散切换:支持 View Transition API 的浏览器,主题切换会从点击位置圆形扩散开,不支持的则瞬切降级。
  • 记忆与随机:默认记住上次选择;也提供「每次进入随机一种配色」的开关,每天打开都有新鲜感。

五、性能与体验的细节

这些细节单独看都不起眼,叠起来就是「这博客用着真舒服」和「平平无奇」的差别。

图片懒加载 + 骨架屏。正文图片用 loading=lazy,加载前显示一条流动微光的骨架占位;文章封面用 aspect-ratio 预留盒子,既能显示骨架,又消除了图片加载时的高度跳动。

虚拟滚动 + 无限加载。首页文章列表只挂载可视窗口内的卡片,滚走的回收,上下用占位撑住滚动条高度;滚到底自动按页号拉取下一页。为了让「返回时恢复滚动位置」精准,还把实测的卡片高度持久化下来——这样从文章返回列表,能回到几乎同一像素的位置。

这里其实有过一次反复:虚拟滚动和「按像素恢复滚动位置」天然冲突(懒加载时页面高度对不上)。最后的解法是按总数预留全部卡片的估算高度,让滚动条一开始就是全长的,再用持久化的真实高度校正。这类「想要 A 又想要 B,而 A、B 看似矛盾」的问题,往往才是值得记录的部分。

全局平滑滚动也踩过坑:它会把刷新时浏览器的滚动恢复也变成动画(页面”自己往上滚”)。根治办法是先用 scrollRestoration: manual 关掉浏览器的自动恢复,平滑滚动就只作用于真正的滚动动作了。

六、内容渲染

需求方案取舍点
数学公式KaTeX 服务端渲染在 marked 之前渲染,规避 $x_i$ 下划线被当斜体;无前端闪烁
流程图Mermaid 按需懒加载只在含图表的页面加载脚本,跟随主题色重渲染
代码块highlight.js + Mac 三色点顶部语言标签、一键复制
中英排版Pangu 自动空格只处理文本节点、跳过代码,PJAX 换页也生效

数学公式特意做成服务端渲染而非客户端:构建期就把 $...$ 编译成 HTML,既避免了 Markdown 解析器把公式里的下划线、星号当成强调语法的经典问题,也没有「先看到源码再变公式」的闪烁。

七、一点「无用之用」

理性上,顶栏中间那只会蹦跶、会追着鼠标跳、点一下还会冒泡说话的小史莱姆,对博客毫无用处。但它让我每次打开页面都会心一笑——而「让自己愿意经常回来写」,对个人博客来说可能比任何功能都重要。

分寸感在于克制:它只占顶栏中间那块本来就空着的地方,移动端自动隐藏,还做成了可配置开关。乐趣和干净,不必二选一。

八、部署:源码私有,产物公开

这套主题的视觉设计参考了 ihewro 的 handsome 主题——一款基于 PHP / Typecho 框架的付费主题。我用 Hexo 把它的前端独立重写了一遍,并获得作者授权用于个人非开源用途。如果你也喜欢这套设计,欢迎去支持正版、购买原作者的主题。

正因为有这层授权限制,主题源码不能公开;可 GitHub Pages 偏偏需要一个公开仓库来托管产物——这就成了一对矛盾。解法是拆成两个仓库:源码(含主题)放私有仓库,CI 构建后只把 public/ 产物推到公开的 Pages 仓库。这样线上能正常访问、源码不外泄,部署也全自动。

写在最后

回头看,这套主题没有什么惊天动地的技术,就是一堆小决定的叠加:每个功能都先问一句「真的需要吗」,需要的话再问「最轻的实现是什么」。

够用就好,不堆功能;但凡留下的,都打磨到顺手。这大概就是我对「个人博客」这四个字的理解。