从 WordPress 到 Hexo、Hugo,再到用 Next.js 16 自研一套完整的个人发布平台——记录N年(N>20)折腾历程、每次迁移的真实动因,以及最终技术选型的完整逻辑。
这篇文章早就该写了。
每次有人问起「你的网站是用什么搭的」、「你的文章是用什么软件写的」,我都要花五分钟解释一遍——不是因为答案复杂,而是因为现在这个状态是多年折腾的结果,不说前因后果讲不清楚。索性写成文章,下次直接发链接。
最早建站是用 WordPress(Chen 注:准确地说,是 MSN Space。),托管在 VPS 上。WordPress 的好处是开箱即用——主题商城、插件生态、所见即所得编辑器,适合快速起步。但用了多年之后,问题越来越明显:
性能:PHP 动态渲染,每次请求都要查数据库。没有 CDN 的情况下,首屏速度很差。加了缓存插件有改善,但配置繁琐,也容易失效。
维护成本:WordPress 的插件更新频率极高,安全漏洞补丁隔几周就来一次。数据库备份、版本升级、服务器续费,运维占用了大量精力,写作时间反而少了。
写作体验:古腾堡编辑器在代码块、公式支持上很弱。装了一堆插件之后,写技术文章依然很别扭。我真正想要的是 Markdown,而不是块编辑器。
后来尝试迁到了 Hexo。这次迁移很彻底——告别动态服务器,全部变成静态文件,推到 GitHub Pages。
用 Markdown 写文章这件事变得愉快多了。但 Hexo 的问题是 Node.js 生态太老,主题系统基于模板引擎(EJS / Nunjucks),扩展起来像在写模板代码,而不是写组件。我想加个自定义的交互功能,要么 jQuery 硬写,要么放弃。
另一个问题:全量构建。文章多了之后,每次 hexo generate 都要等两三分钟,改一个字也得全量重跑,开发体验很糟糕。
为了解决构建速度,换到了 Hugo。Go 编译的静态站点生成器,构建速度确实飞快,千篇文章毫秒级。
但 Hugo 带来了新的问题:模板语言的表达力。Hugo 的模板系统是 Go template,逻辑稍微复杂一点就开始反人类。写一个需要遍历、过滤、聚合的功能,代码可读性极差。更重要的是,Hugo 本质上还是静态的——如果我想要评论、浏览量、互动反应,就必须依赖外部服务(Disqus、utterances 等),而这些服务要么有隐私问题,要么样式无法和站点融合,要么需要 GitHub 账号才能评论,门槛过高。
用了三个平台之后,我的需求变得很清晰:
没有一个现成方案能同时满足这五点。自研是必然选择。
选 Next.js 而不是 Astro、Remix 或纯静态生成器,核心原因有两个:
混合渲染。博客列表页、文章详情页可以静态生成(或 ISR(Chen 注:Incremental Static Regeneration,增量静态再生成。页面在构建时生成静态 HTML,但可以设置一个重新验证间隔(revalidate),到期后下次请求触发后台重新生成,无需全量重建。兼顾了静态页面的性能和动态内容的时效性。)),但评论 API、浏览量 API 需要动态响应。Next.js 在一个框架内把这两件事处理得很干净,不需要单独维护一个后端服务。
React Server Components。文章页面的渲染链——读文件、序列化 MDX、加密受保护内容——全部在服务端完成,客户端拿到的是可以直接渲染的数据。需要交互的部分(评论区、表情反应、主题切换)单独拆成 Client Components。这个边界划分得很自然,符合「服务端做数据,客户端做交互」的直觉。
这个版本(16.x)还开了 experimental.viewTransition,页面跳转时用浏览器原生 View Transitions API 做过渡动画,几行配置,效果很好。
写作格式是 MDX——Markdown 的超集,可以在文章里嵌入 React 组件。文章存在 content/notes/*.mdx,纯文本文件,Git 管理,不需要数据库。
渲染走 next-mdx-remote,服务端序列化后传给客户端渲染。序列化时挂了一条 rehype 插件链:
| 阶段 | 插件 | 作用 |
|---|---|---|
| remark | remark-gfm | 表格、删除线等 GFM 扩展语法 |
| remark | remark-math | 识别 $...$ 和 $$...$$ LaTeX 公式 |
| rehype | rehype-slug | 为标题自动添加锚点 id |
| rehype | rehype-figure-caption | 将图片 alt 文字渲染为 <figcaption> |
| rehype | rehype-katex | 将公式节点渲染为 KaTeX HTML |
| rehype | rehype-mermaid | 提取 mermaid 代码块,替换为自定义组件 |
| rehype | rehype-pretty-code | Shiki 语法高亮,支持 diff 标注 |
| rehype | rehype-code-lang | 在代码块右上角注入语言标签 |
代码高亮用 Shiki,主题是 github-light / github-dark,支持 diff 标注(自己写了个 Shiki transformer),高亮指定行。公式用 KaTeX,服务端渲染,不需要客户端 JS。
动态数据(评论、浏览量、Reaction 计数)全部存在 Upstash Redis。选它而不是传统 Redis 或关系型数据库,原因很简单:
评论数据的结构设计:
| Key | 类型 | 用途 |
|---|---|---|
comment:{id} | Hash | 单条评论的完整数据 |
comments:{slug} | List | 某篇文章的评论 ID 列表 |
comments:__all__ | List | 全站评论 ID 列表(管理后台用) |
comment_count:{slug} | String | 已发布评论计数 |
views:note:{slug} | String | 文章浏览量 |
stats:pv | String | 全站 PV |
stats:uv | HyperLogLog | 全站 UV(去重访客估算) |
UV 统计用 HyperLogLog(Chen 注:HyperLogLog 是一种概率数据结构,用极少的内存(固定 12KB)估算集合的基数(不重复元素数量)。Redis 原生支持,用 PFADD 写入、PFCOUNT 读取,误差率约 0.81%。相比存储完整 IP 列表,它既省空间又保护隐私——只能知道「大约有多少个不重复访客」,无法还原具体 IP。)(PFADD / PFCOUNT),不存真实 IP,误差率 0.81%,对个人博客足够准确。统计在 Middleware 里做,每次请求都会命中,包括静态页面。
这是自研最值的地方。系统功能:
blocked「为什么不用 Giscus / utterances?」
因为我不想让读者必须有 GitHub 账号才能评论。技术博客的读者不都是开发者,而且这类嵌入方案在样式上很难和站点融合。自建的代价是几百行代码,换来的是完全的掌控权,值得。
有些笔记不想公开,但又想放在这里(比如涉及具体项目细节或尚未公开的分析)。早期的方案是 CSS 遮挡——页面里有内容,只是用样式盖住。这种方案形同虚设,开 DevTools 就能看。
现在的方案是真加密:
加密在构建阶段完成,构建产物里只有密文。密码来自服务端环境变量(不带 NEXT_PUBLIC_ 前缀,不会打进客户端 bundle)。解密完全在浏览器的 Web Crypto API 里运行,密码不经过服务器。
样式方案是 CSS 变量定义设计 token,Tailwind 4 做工具类补充。明暗主题通过 [data-theme="dark"] 选择器切换 CSS 变量值,next-themes 负责读写 data-theme 属性并处理 SSR hydration 问题。
没有用 styled-components 或 CSS Modules,原因是:组件数量不多,内联样式 + CSS 变量已经够用,避免引入额外的运行时或构建复杂度。
推送到 main 分支,Vercel 自动构建部署。构建时注入的系统变量(VERCEL_GIT_COMMIT_SHA、VERCEL_ENV 等)用来在首页展示当前部署信息,包括 commit hash、构建时间、部署环境。
动态页面展示过去一年的 commit 热力图,数据来自 GitHub API,每小时通过 Next.js ISR 重新请求一次。
几个在技术上有点意思、但不容易在主流教程里看到的设计:
批注系统。文章里可以用 <Revision> 和 <Follow> 组件标记修订批注和行动跟进。宽屏下批注浮在正文右侧,滚动时跟随对应段落,支持碰撞避让(批注密集时自动垂直错开)。窄屏降级为 tooltip。
pangu.js。中英文混排时自动在汉字和英文/数字之间加空格——这是排版规范,但手动打空格太烦。渲染后用 pangu 库在客户端处理一次,代价是一次轻微的 DOM 操作,换来的是所有文章都符合排版规范。
HoverPrefetchLink。鼠标悬停到文章链接时才触发 prefetch,而不是用 <Link> 的默认行为(进入视口即 prefetch)。文章列表页有几十个链接,全量 prefetch 对小流量博客来说是浪费;悬停时再 prefetch 是更合理的触发时机。
pull-to-refresh。移动端下拉刷新,用 touch 事件实现,有 CSS 动画反馈。很小的细节,但让移动端体验更接近原生 App。
@vercel/og 按文章标题动态生成。