搬完家,一周装修完:博客新前台开发全记录

上一篇《把博客搬成纯静态》的结尾,我给主题重写留了一句「那会是另一个系列」。结果它没成系列——成了一次冲刺。九月二十三日迁移收官那天,新前台还只是个能跑的最简骨架:四十四个路由全部预渲染,全站却只有一套临时样式。到今天写下这些字,暗色主题、独立首页、路由体系、文章目录、图片灯箱、边缘加速、追番列表、全文搜索已经全部落地。这篇把这一周从头捋一遍:做了什么、为什么这么做、又踩了什么坑。

起点与终点

迁移收官时的前台,一句话:骨架能用,仅此而已。文章页就是标题、正文加评论区,首页就是文章列表第一页,theme_color 还挂着迁移期的深色占位。预算全花在了管线上——烘焙、预渲染、评论、部署——「好看」被整段砍掉了。

一周后的现在:

  • 九十七个路由全部预渲染,一套完整的暗色主题,一个为它单独设计的「夜行列车站台」首页;
  • 文章页有了目录卡片、图片灯箱、全文搜索(Ctrl+K 呼出,中文复合词可查);
  • 分发层前面多了阿里云 ESA 边缘:缓存、WAF、源站隐藏,零成本;
  • 追番列表从 WordPress 插件时代整体迁入新架构,数据、封面、管理页全套;
  • 单元测试从十八例涨到一百零六例,全绿。

访客侧的形态一点没变:每个字节还是 nginx 直出的静态文件,唯一连着后端的依旧只有评论区。变的是这套静态文件终于像回事了。

一周时间线

日期 干了什么
09-22 ~ 09-23 迁移冲刺(上一篇的内容):管线、路由、评论、双域名、PWA、定时构建
09-24 暗色主题第一版,两轮视觉评审闭环
09-25 路由体系重构:URL 全景、分类页、标签页,方案定稿当日实施
09-26 独立首页「夜行列车站台」,header 分类下拉
09-27 独立页组件化(捐助/友链/关于),移动端钻取菜单
09-28 文章目录卡片,nginx 配置入仓治理,ESA 边缘接入(含一次宕机)
09-29 图片灯箱、追番列表全家桶、全文搜索、视觉评审三修复
09-30 中文搜索词典对齐(另有一篇专记)

下面不按天讲,按事讲。

主题:先过对比度,再谈好看

主题第一版是暗色玻璃拟态:深蓝卡片浮在纯黑底上,链接用金色,正文浅米色,背景是一段视频。这套配色没有一步是「凭感觉」定的——所有前景/背景色对的 WCAG 对比度都逐对算过,而且真算出了问题:玻璃头部直接压在背景视频上,撞上视频最亮的帧,头部文字对比度最坏只有 1.27:1,接近不可读。解法是在视频上下各加一层渐变 scrim,压暗文字落区的背景;颜色一个没换,对比度回到 9:1 以上。正文表格里还藏着一处从浅色主题遗留下来的表头色(#f6f8fa),和浅色正文放在一起只有 1.20:1,等于隐形。

定稿流程也就此固化:桌面 1440 与移动 390 两档截图目检,配一轮对比度逐对实算,问题全部闭环才算完。这套评审后来又跑了几轮,最近一轮抓出的不是配色,是可访问性——下拉菜单的触发器原本不在 Tab 序里,键盘用户根本打不开菜单,典型的 WCAG 现挂;修法是把键盘交互上收到壳层组件,全站的下拉统一继承。

主题落地过程中还交了一类「生态适配税」:第三方组件把样式写死在自己的 shadow DOM 里(比如评论组件提交按钮的字色,上游代码里是硬编码的 text-white),站点主题想接管,只能在挂载后往它的 adoptedStyleSheets 里追加一条带变量兜底的覆盖,再用 MutationObserver 盯住异步渲染出来的后代。不体面,但变量留了名——上游哪天原生支持,这层补丁自然失效退出。

首页:夜行列车站台

独立首页做成了一个概念:夜行列车经过的站台。站牌式的标题排版——题记在上,站名字距疏朗,下面一条短金线,再一行宽字距的「SINON BLOG」;顶部天区开了一扇流星划过的窗口;底部一张「最新文章」玻璃卡片权当站台,一个透明背景的动态小人站在卡片边仰望流星,旁边立着一座金色的复古双面车站钟。小人是一段透明视频:底图用 Flux2 生成,再用 Wan2 驱动摆动;钟是纯 SVG 手写的——檐形雨帽、罗马数字、夜灯背光的搪瓷盘面。

这座钟的第一版做出来不太对头,看着完全就是柱子上长了个秒表,于是推倒重来。中途加过的跳格秒针也因为气质不对被砍掉,只留时针分针每分钟步进。

真正有技术含量的是小人的 alpha 通道。上线实测:首次加载正常,刷新后小人变成黑底方块。排查结论相当冷门:这段 VP9 alpha 视频首次走软解码,alpha 完好;刷新后命中 HTTP 缓存,浏览器改走 GPU 硬解,而部分 Windows 显卡的硬解并不支持 alpha——静默丢弃,连个警告都没有。浏览器的 canPlayType 只回答「支不支持这个编解码器」,回答不了「解出来还有没有 alpha」。最后的修法是不再询问、直接实测:把视频首帧画到 canvas 上,读角落像素的 alpha 值,丢了就自动换 VP8 源重探(VP8 在多数 Windows GPU 上没有硬解,只能走软解,alpha 得以保住),两级都失才落到静态帧——一套三级降级状态机。

关于这事,我也有一篇专门的文章介绍了ffmepg在处理透明视频素材时的一些奇怪的行为,请参阅《我的透明 WebM,被 ffmpeg 冤枉了》。

路由与独立页:趁没沉淀,把家底定死

迁移收官次日,我把全站 URL 体系推倒重排了:/ 改为独立首页,/archives 是文章列表的规范第一页,/timeline 接管按年归档,分类和标签各得其所(/categories/:slug、/tags/:slug,各带分页)。方案定稿当天实施完毕。敢这么干,恰恰因为迁移刚完成——新 URL 空间还没有沉淀任何外部链接,现在改是零成本,过半年再改就是一张 301 映射表。

配套把分类/标签数据也重铸了:taxonomies.json 改成「数据与结构分离」——分类按 slug 建 map 管查找,另附一棵只管层级顺序的树;slug 全局唯一从「现状恰好如此」升级为烘焙期不变量,重复直接让构建失败。标签页属于薄内容(三十多篇文章撒在三十多个标签里),有意不进 sitemap,但页面照常预渲染供内链 discovery——真要翻案,只需加一行循环。

独立页也在这周陆续收编:版权页、捐助页、友链页、关于页,从「共用详情页直出 HTML」变成各自的专属组件。过程里有几个小决定值得记:

  • 友链页顺手做了一轮存活性复检:十年老站的友链,十家只剩四家存活。挂掉的六家收进原生 <details> 折叠区,保留名称、URL 和失效原因——它们也是这段互联网历史的一部分;
  • 捐助页的收款码是透明 PNG,直接放在深色底上会变成反色码,人眼和扫码器都读不出——必须垫一张白底卡片,白边顺便充当静区;
  • 关于页的邮箱做了防爬:完整地址只在浏览器端拼装,预渲染产物和首屏 DOM 里都不存在这个字符串。防的是 HTML 爬虫,不防逆向 JS 的定向攻击——静态站的防爬上限本来就在这。

这一轮还留下了一条自此生效的硬约束:下拉面板必须是触发器元素的兄弟节点,不能做它的子节点——面板里一旦渲染真实链接,<a> 套 <a> 的非法结构会被浏览器解析器自动拆开,hydration 认领失败,就是迁移期那个「一个 p 标签弄哑 SPA 导航」的同款事故。踩过一次的坑,后面的设计要提前绕开。

边缘:零成本的 CDN,和一次小时级的宕机

服务器是 1 核 1.8G 的小水管,静态站已经把它整个空了出来,这份余力该用在别处——比如把分发也挪走。九月二十八日,站点前面接入了阿里云 ESA(边缘安全加速)免费版:零元一年,流量和请求数都不计费,换来的是源站隐藏、边缘缓存、WAF 和 Bot 防护。接入模式选了 NS(权威 DNS 整体迁给 ESA),管理域保持仅 DNS 直连,不经边缘。

接入前先做了一件朴素但关键的事:给全站每个响应补上显式的 Cache-Control。ESA 边缘按源站响应头决定缓存行为,而 Halo 代理的响应原本是裸奔的——不带任何缓存头,会被边缘的默认策略误缓存,破坏「发布即时生效」和评论实时性。HTML/JSON no-cache、带哈希的 JS/CSS immutable 一年、评论 API 一律 no-store——源站响应头从此成了边缘缓存语义的唯一事实源。

然后是这次开发里唯一的一次宕机,小时级。建站时 ESA 自动为泛域名申请边缘证书失败(申请触发在 NS 切换之前,DNS 验证记录在旧权威解析处不可达)——边缘节点没有证书,所有 HTTPS 握手全部失败,全站不可达。排查时先被一个假象骗了:激活后的头几分钟页面能打开——那是本机 DNS 缓存还指着旧源站,请求根本没经过边缘。识别这类问题的指纹记在了修订记录里:边缘 TLS 全挂 + 源站直连正常 + 权威 DNS 记录正确 = 证书问题。NS 切换完成后重试签发,一次成功;泛域名证书免费,九十天自动续期。

宕机修完,顺手把纵深防御补齐了:回源改走 mTLS 双向校验,边缘出示 ESA 自有 CA 签发的证书,源站只信这枚 CA 根——直接扫到源站 IP 的请求会在握手层吃 400;nginx 侧还原访客真实 IP(边缘回源段 v4 六段、v6 两段),日志里增加对请求ip和ESA附带的真实ip的记录;WordPress 时代的残留路径(xmlrpc.php、wp-login.php)直接 444 断连。

追番列表:一次 WordPress 时代的考古,和一次绕行

追番列表是老站上活得最久的自建功能:一个 WordPress 插件,数据存在数据库的 option 表里,格式是 PHP 序列化串。之前手工导出过一份 JSON,这次拿出来一看——是个截断的残件,只有六条,断在一个转义序列的正中间。正主还在:从 SQL 备份的 wp_options 里把序列化串完整提取出来,一百三十八条逐条规范化入库——HTML 实体解码、中文日期转 ISO——封面改为本地一次性拉取图片直接提供(上了ESA之后这里也开始财大气粗了)。

新架构里,它的形态完全对齐「访客纯静态」的哲学:服务器上跑一个纯标准库的单文件小服务(SQLite 单文件,docker compose 常驻),自带一套手机可用的管理页——追番进度在手机上点加减号就能记。构建期烘焙脚本从服务拉数据,产出静态 JSON 和封面副本;服务挂了,当班构建失败,旧产物继续在线——「不上线半成品」的纪律对它同样生效。

中途撞上一堵墙:服务器的机房 IP 段被 bgm.tv 屏蔽了,服务端代理搜索和元数据的路全断。解法是把活儿挪回浏览器——bgm.tv 的官方 API 是 CORS 全开的,管理页的 JS 直连搜索、直连取元数据;服务端只留封面下载,直连失败自动切图片代理兜底,再不行就在管理页手动上传封面。封锁没有打开缺口,但绕行的路全铺上了。

封面缓存也做了一轮升级:全站图片最重的一页(一百三十八张),策略定为 immutable 一年,配上 ?v=<文件修改时间> 指纹——内容一变 URL 就变,缓存自动击穿,和带哈希的 JS/CSS 是完全同款语义。

阅读体验三件套:目录、灯箱、搜索

目录卡片:文章页右侧一栏 sticky 目录,移动端收成一个悬浮球。细节里最花工夫的是锚点兼容——Halo 渲染的老文章里,既有自带 id 的标题、也有需要补 id 的、还有 id 本身是百分号编码字面量的,页内锚点与路由 fragment 导航都得能命中;头图随滚动折叠又会把 scrollIntoView 的落点算过头,最后按折叠斜率做了停稳校正。

图片灯箱:选了 PhotoSwipe,缩放、平移、起跳动画全家桶白送,core 压缩后十七 KB、首次点击才加载。适配暗色主题改了两个默认值:遮罩不透明度拉满(默认 0.8 会把背后的金色导航透出来,看着像渲染 bug)、图注取 figcaption 回退 alt。这次唯一的真坑很典型:在 TypeScript 里用副作用方式 import 灯箱样式,Angular 构建器把 CSS 拆出去装进了 main.css——而 index.html 只链接 styles.css,main.css 成了无人引用的孤儿文件,样式全丢,零报错。最后把样式配成独立 bundle、运行时注入 <link> 才收场。

全文搜索:静态站没后端,选了 Pagefind——构建完对预渲染 HTML 建索引,访客在浏览器里查;索引分片按词懒加载,全站分片加起来七百多 KB,首搜九十六毫秒(含脚本与分片首次加载),热搜一至九毫秒。UI 做成 PowerToys 式居中弹层,Ctrl+K 呼出。这条线的中文支持踩出了一个词典分裂坑,大到足够单独成篇,已经写完:《从 charabia 到 ICU:我的静态博客搜索里,两本词典打了一架》。结论先剧透:索引和查询两侧各带一本中文词典、互不理睬,修法是在建索引前按浏览器的词典词边界预处理副本——现在搜「单片机」,六篇文章整整齐齐。

数字盘点

指标 迁移收官(09-23) 现在(09-30)
预渲染路由 44 97
单元测试 18 例 106 例
架构修订记录 18 条 53 条
必踩坑速查 7 条 9 条
git 提交 22 57(其中 35 个在这七天)

散装数字:搜索索引三十九页、分片共约 740 KB、首搜 96ms;追番数据 138 条;「单片机」搜索结果 0 → 6;全量构建照旧 15~26 秒,cron 两小时一班,内容没变就空转跳过。

AI 辅助的一周

这周的开发全程有 AI 辅助,值得单独记一段——不是晒工具,而是这套协作确实改变了一个人做 side project 的形态。按环节拆开看。

灵感落到方案,靠讨论: 「夜行列车站台」最早只有几句画面描述,和 AI 过了几轮才变成可实施的方案:流星窗口用纯 CSS 错峰变量画、小人视频配三级降级链、钟要不要加秒针。每一轮的产出不是代码,而是定稿的方案——先写进修订记录,再动工。路由体系怎么重排、追番列表用什么数据源、ESA 选哪档套餐,走的都是同一条路径:我出需求和偏好,AI 核实可行性(备案是否受影响、套餐配额对比、bgm.tv 的 CORS 是不是真全开)并摆出权衡,拍板权始终在我手里。

原型占位加一段描述,就能换来实现: 我管「长什么样」:小人的视频素材自己生成、在页面上的位置自己手调,玻璃卡、站台卡、配色的视觉方向我给关键词;AI 管「怎么实现」:布局结构、降级状态机、响应式断点、动效细节。迭代最快的环节是那座车站钟——AI 出第一版,我看一眼说「像秒表,金属感假」,它推倒重做,换上檐形雨帽和搪瓷盘面,两轮收敛。人的品味做验收,AI 出工时。

检查类杂务整体外包了: 每轮改动之后的视觉评审(桌面 1440 加移动 390 双档截图目检)、对比度逐对实算(1.27:1 那个问题就是算出来的,不是看出来的)、键盘走查(抓出了下拉触发器不进 Tab 序、键盘用户打不开菜单的可访问性现挂)、发布前的产物断言(直接对构建产物做断言,比如「预渲染 HTML 里不得出现邮箱明文」)——这些事以前最容易偷懒跳过,现在成了每条修订的固定收尾。

不过现在的AI显然还没有达到万能的境界,仍然随时有出错的可能:-b:v 0 那道转码坑是我在真机上播出来的,灯箱样式全丢是产物断言抓出来的,这两处 AI 自己也看不见。AI 不会替你看真机,方向盘得一直在人手里。

写在最后

这周最大的体会有两点。

其一,「装修」比「搬家」琐碎一个量级,但每件事都有可计算的验收标准。对比度实算、产物断言、真机截图、键盘走查——「我觉得好了」和「测出来了」之间隔着的那一段,就是评审流程。视觉工作尤其如此:小人 alpha 问题、像秒表的钟、隐形的表头,全是真机和数字抓出来的,代码 review 抓不到。

其二,架构文档值得当日记写。这周五十三条修订记录,每条都写清了决策、依据和验收结果——这篇回顾一半的素材直接来自那里,包括那次宕机的排查指纹和「零成本改 URL」的机会窗口判断。文档不是写给别人的作业,是写给一个月后自己的档案。

现在,访客看到的每个字节依然来自静态文件——只是终于好看了,还带搜索。