通过DeepSeek Harness修复和优化网站
一、起因:红字不是那天才有的
先把一个容易被误会的说法纠正掉:这不是「某天随手按了下 F12,才发现控制台红了一片」的故事。
这些报错我早就知道。
三四年跑下来,站里依赖的那些外部资源是一件一件慢慢死掉的。它们很守规矩,从来不一起罢工:某段时间是某个 CDN 开始抽风,过一阵换成某个图床连不上,再往后是某个公共接口悄悄停了服。死一个,页面上就少一样东西 —— 某个图标不显示、某张图变成裂图、某个小模块空着。文章照样能读,页面照样能开。
我也不是没看见。F12 按过很多次,每次都想「下次一起修」,然后下次又变成了下次。坏掉的东西只要不影响你看文章,就永远排在待办清单的最后一条。
真正让我下决心的是发现死掉的东西已经从边角蔓延到了骨架。那天我本来只是想看看某个 CSS 改动的效果,随手按了下 F12,看到的就是这些:
一眼扫过去,大概三类毛病:
- 51la 的统计脚本:
blocked by CORS policy,接着是403 - TianliCDN:
net::ERR_CONNECTION_CLOSED连着三条 - 字节跳动静态资源库:直接
404
底下还有几条 JS 报错更扎眼:
1 | Uncaught ReferenceError: $ is not defined |
说白了就是:该加载的没加载,加载了的又用不上。
一边是「还能用」,一边是「再拖下去就是给未来的自己挖坑」—— 这些红字存不存在,取决于你今天开不开 F12,但损失是实实在在的:样式丢一半、脚本半死不活、统计全废、某个模块永远空着。
所以那天我没有像往常一样把 F12 关掉了事,而是想:要不,一次修完?
刚好那阵子我在重度使用 DeepSeek Harness,于是顺手把这事丢给了它 —— 我描述症状,它去翻文件。
二、第一轮:先把「哪些东西真的死了」搞清楚
修之前得先知道坏了多少。
这里有个坑值得单独说:光看浏览器报错是不够的。浏览器只会告诉你「我这次没拿到」,但不会告诉你「这东西是不是早就没人维护了」。有些 CDN 你本地挂了是因为你自己的网络,有些是真死了。
所以第一件事是挨个探测:把首页要加载的第三方资源全列出来,逐个发请求,看状态码、看响应字节、看首字节时间。
结果比我想的惨:
| 资源 | 症状 | 结论 |
|---|---|---|
cdn1.tianli0.top | ERR_CONNECTION_CLOSED × 3 | 域名已停服 |
cdn.staticfile.org | 能访问,但解析到 202.181.25.22 | 香港节点,大陆访问绕路 |
cdn.jsdelivr.net | 104.17.207.5,TTFB 520ms ~ 1.8s | Cloudflare,时快时慢 |
cdnjs.cloudflare.com | MathJax 从这加载 | 国内基本不通 |
sdk.51.la | 403 + 本地 CORS | 接口变了 |
lf3-cdn-tos.bytecdntp.com | 404 | 已下线 |
看到第三行的时候我愣了一下 —— staticfile 我一直以为是国内的。查了下 IP 归属才发现是香港,难怪有时候打开慢,原来一直在绕。
第一轮的原则定得很简单:
- 能换公共库的都换成公共库,优先国内源;
- 尽量收敛到同几个域名,这样浏览器能复用连接,还能提前
preconnect; - 实在找不到源的就本地化,塞进
source/js/里自托管。
这一轮一共动了 14 处,分布在:
[BlogRoot]/_config.butterfly.yml—— element-ui、jQuery、Vue、clipboard、sweetalert2、vanilla-lazyload、instant.page、typed.js、FontAwesome、APlayer、Meting 等[BlogRoot]/source/social/fcircle/index.md—— 友链朋友圈用的两个 JS-Heo 脚本[BlogRoot]/source/_data/link.yml—— 友链里的两张图
其中 winbox 这个库比较特殊,全网的国内镜像都没收它,最后只能把 15KB 的文件下载下来放进 source/js/winbox.bundle.min.js 自己托管。
三、换源路上踩的几个坑
这段单拎出来说,因为国内镜像站的路子和 jsDelivr 不一样,直接照搬 URL 大概率是 404。
3.1 npmmirror 不是每个包都有 .min
registry.npmmirror.com 的路径规则是 /包名/版本/files/包内相对路径。听起来很直观,但:
algoliasearch@4.14.3没有.min版本,只有files/dist/algoliasearch-lite.umd.js@fancyapps/ui@4.0.31同理,只有files/dist/fancybox.umd.js和files/dist/fancybox.csstyped.js@2.0.12的主文件在files/lib/typed.min.js,不在dist/下
硬套 .min 后缀的结果是返回一段 JSON:
1 | { |
3.2 lib.baomitu 的「假 200」
这个更阴。360 的 lib.baomitu.com 遇到不存在的路径,返回的是 HTTP 200,正文只有 14 个字节:
1 | 404: Not Found |
也就是说,如果你只看状态码,会以为一切正常。
所以判断一个源到底可不可用,必须同时看状态码、字节数和内容,三个都对上才算数。这一条我后来在探测 leaflet 的时候又用上了。
3.3 同一个库,镜像之间路径不一样
MathJax 3.2.2 在两个源上的文件形态就差很多:
| 源 | 路径 | 大小 | 首字节 |
|---|---|---|---|
| npmmirror | /mathjax/3.2.2/files/es5/tex-mml-chtml.js | 1146 KB(未压缩) | 352 ms |
| 360 baomitu | /mathjax/3.2.2/es5/tex-mml-chtml.min.js | 1114 KB | 281 ms |
只差 40KB,最后我选了 npmmirror。原因不是它更快,而是想统一域名 —— 站上已经有一堆资源在 npmmirror 了,preconnect 建一次连接就能全用上,省掉一次 DNS + TLS。这种账要算总账,不能单看一个文件。
四、「这些源都是国内的么?」——然后有了第二份体检报告
第一轮换完,我盯着配置文件看了半天,突然想到一个问题:我换的这些都是国内的么?国内访问到底快多少?
这问题问得挺好,因为它逼出了一份真正有价值的报告。
做法是把首页要加载的第三方资源全抓出来,统计三件事:域名解析到哪、首字节多少、总共多大。
部分结果:
| 域名 | IP 归属 | 实测 TTFB |
|---|---|---|
| registry.npmmirror.com | 1.194.31.54(河南) | 37 ~ 105 ms |
| npm.elemecdn.com | 183.61.231.x(广东) | 40 ~ 105 ms |
| cdn.jsdmirror.com | 113.240.66.249 等 | 43 ms |
| lib.baomitu.com | 240e 段(中国电信 IPv6) | 48 ms |
| sdk.51.la | 27.159.90.74 等 | 122 ms |
| cdn.staticfile.org | 202.181.25.22(香港) | 203 ~ 356 ms |
| cdn.jsdelivr.net | 104.17.207.5(Cloudflare) | 520 ms ~ 1.8 s |
换源的效果是实打实的:typed.js 从 391ms 掉到 40ms。
但报告里最扎心的是最后一行:
| 资源 | TTFB | 总耗时 |
|---|---|---|
| www.fomal.cc 首页(208 KB) | 693 ms | 911 ms |
| source.fomal.cc 一张 35 KB 的图 | 1041 ms | — |
我自己站点的 TTFB,比所有第三方 CDN 都慢。 换源能省的也就几百毫秒,真正的天花板在部署侧。这个后面单独开一篇再聊,Vercel 的国内访问优化又是另一个坑了。
也正是这份报告,让我发现了真正的"体积刺客"。
五、重头戏:905KB 的 Vue + Element-UI,只为了弹几个提示框
报告里有一行数据特别刺眼:
1 | 首屏第三方资源 33 个 URL,共 2.72 MB |
拆开 staticfile 那 1.01MB,是三个东西:
| 文件 | 大小 |
|---|---|
element-ui JS(index.js) | 577 KB |
element-ui CSS(index.css) | 236 KB |
| Vue 2.6.14 | 92 KB |
加起来 905 KB。
而我的站点用 Vue 干了什么呢?我把 fomal.js 从头到尾看了一遍,所有 Vue 用法长这样:
1 | new Vue({ |
全文只有 14 处这样的代码,全是右下角/左上角冒出来的那个小提示框。 参数形态一模一样,没有 v-if、没有 v-for、没有模板、没有路由、没有一个组件。
905 KB 换来 14 个 Toast。
当时我的表情大概是:???
那为什么一直没删
因为当年抄过来的时候,教程都这么写。Element-UI 的 Notification 确实好用,一句 this.$notify() 就弹出来了,谁管它多大呢。
而且删起来有心理障碍:站点里到处是 .el- 开头的 class,看着就像"离不开它"。
结果一查:
themes/butterfly/的 layout 里,没有一处el-classsource/js/里也没有custom.css里的.el-*规则,几乎全部包在#twikoo/.twikoo作用域内
而 Twikoo 1.6.17 的发行版自带一副 Element-UI 样式(我去包里翻过,.el-textarea__inner、.el-button--primary、.el-input__inner、el-loading-spinner 全都在)。
也就是说:评论框的样式是 Twikoo 自己带的,跟全局那份 Element-UI 一点关系都没有。
那就删。
手写一个 7KB 的通知组件
弹窗不能不管,所以抄一份最小的实现出来。新文件 [BlogRoot]/source/js/notify.js,7150 字节,零依赖原生 JS:
1 | fomalNotify({ |
内部做了这些事:
- 四种类型
success / warning / info / error,图标是内联 SVG,配色沿用 Element-UI 那套(#67c23a/#e6a23c/#f56c6c/#909399) - 六种位置,按
位置 + offset缓存容器,同一个位置的通知自动堆叠 - 进出场动画用
requestAnimationFrame加.is-enter类控制,关闭时加.is-leave后 320ms 移除节点 - 鼠标悬停暂停计时,移开继续
- 容器
isConnected检查,兼容 pjax 局部刷新
然后把 fomal.js 里那 14 处整个换掉:
1 | - new Vue({ |
再回到 _config.butterfly.yml,把三行注释掉:
1 | - <link rel="stylesheet" href="https://.../element-ui@2.15.6/lib/theme-chalk/index.css"> |
905 KB → 7 KB。
顺带说一个细节:Element-UI 的 CSS 原来挂在 inject.head 里,是同步阻塞渲染的 —— 也就是说那 236KB 没下完,页面就是白屏。现在也没了。
六、MathJax 背了三年的锅
我写算法题解那阵子一直纳闷:有几篇带公式的文章,公式就是显示不出来,全是红字或者原始 LaTeX。
当时以为是 Markdown 语法写错了,改了又改,没用。
这次顺手查了一下,真相挺无语的。
_config.butterfly.yml 里有这么一段:
1 | mathjax: |
看起来没毛病对吧?问题是 —— 主题压根不读这个字段。
Butterfly 4.3.1 实际的取值路径是:
1 | theme.asset.mathjax ← themes/butterfly/scripts/events/cdn.js 生成 |
所以最终产物里写的是 https://cdnjs.cloudflare.com/ajax/libs/mathjax/...,而 cdnjs.cloudflare.com 国内基本不通。
182 个页面里有 35 个加载了 MathJax,35 个全挂。
修法反而简单,让配置文件里的字段真正生效就行:
1 | + mathjax: https://registry.npmmirror.com/mathjax/3.2.2/files/es5/tex-mml-chtml.js |
写进 _config.butterfly.yml 的 CDN.option 段之后,构建产物里 35 个页面全部指向 npmmirror。
这个坑我记得最清楚 —— 因为它不是"坏了",而是"你以为它开着,其实从来就没开过"。 这类问题最难发现,因为没有任何报错,只是东西不出现。
七、意外收获:hexo-tag-map 把路径写死在了 node_modules 里
修完上面这些,我以为完事了。结果在扫"还有没有漏网的境外域名"时,又揪出来一个。
地图标签用的是 hexo-tag-map 插件,它加载 leaflet 的方式是写死的:
1 | // node_modules/hexo-tag-map/index.js 第 14-18 行 |
五个文件全指向 cdn.jsdelivr.net,也就是 Cloudflare。
改 node_modules 是最直接的办法,但下次 npm i 就没了,这种改法属于给自己埋雷。
我试了下国内镜像,都不太顺:
- npmmirror 对
hexo-tag-map直接返回 403(这个包被屏蔽了) cdn.jsdmirror.com三个文件都能拿到,但首字节 839 ~ 1142ms,比 jsDelivr 还慢lib.baomitu.com只有 leaflet 本体,中文地图源和 proj4 都没有
那就自托管吧 —— 还能比自托管更快的东西不存在,除非你不加载。
1 | source/js/tag-map/ |
文件是从 node_modules/hexo-tag-map/lib/ 原样复制的,只把文件名里的 @ 换成了 -(@ 在 URL 里要转义,麻烦)。
然后写了个 Hexo 插件放 [BlogRoot]/scripts/tag-map-local.js:
1 | const CDN_PREFIX = "https://cdn.jsdelivr.net/npm/hexo-tag-map/lib/"; |
选 after_render:html 而不是改依赖包,是因为 scripts/ 目录会跟着仓库走,重装依赖也不会丢。
配套还要在 _config.yml 里加一行,让这五个库文件原样复制、别被 Nunjucks 渲染:
1 | skip_render: |
至此,全站产物里 cdn.jsdelivr.net 的引用归零。
八、以为收工了:背景图没了
改完上面那一批,我以为可以收工了。刷新页面 —— banner 一片黑,背景图没了。
8.1 先抓到的不是背景,是 Service Worker
控制台里最扎眼的是这几条:
`@
Uncaught (in promise) TypeError: Failed to convert value to ‘Response’. sw.js:1
Fetch error: http://localhost:4000/css/index.css
TypeError: Failed to convert value to ‘Response’.
at handle (sw.js:197:16)
at sw.js:15:27
`@
Failed to convert value to 'Response' 是个很好认的症状:respondWith() 收到了一个 undefined。
问题出在 themes/butterfly/source/sw.js(现已改名为 themes/fomalhaut/) —— 这是早年从别处抄来的一个「CDN 分流器」,作用是把静态资源转到对象存储去取。要命的是这一段:
@js return fetch(req).catch(error => { console.error('Fetch error:', req.url, error); }); @
catch 回调里少了一个 return。请求成功时看不出来,一旦失败这个函数就返回 undefined,紧接着 event.respondWith(undefined) 直接抛错,这个请求当场作废。
更糟的是它给所有同源请求都套了 respondWith(文件里甚至注册了两个 fetch 监听器,第二个是半成品,里面用了一个从未定义过的变量)。也就是说:那一跳只要失败,CSS、图片、脚本就可能整片加载不出来。
「背景图没了」其实只是它的连带伤害之一。
修法没什么玄学:
- 只拦截目标域名(
www.fomal.cc)的 GET 请求,其余请求直接return,根本不调用respondWith; - 分流失败先回退
return await fetch(req),再失败才返回 504 兜底; - 本地开发环境(
localhost/127.0.0.1)自动注销 Service Worker,免得调试时一直被缓存的版本阴。
8.2 背景图本来就是个「依赖 JS 才存在」的东西
Service Worker 修好之后再刷新 —— 还是黑的。于是顺着渲染链路一路往下查,最后定位到一行 CSS:
@css #web_bg { background: var(--default-bg); } @
--default-bg 这四个变量(还有 darkmode / mobileday / mobilenight 三个)在整个构建产物里只有使用、没有任何一处定义。唯一定义它们的地方,是 fomal.js 在运行时往这个标签里写内容:
`@html
`@
而它在配置文件里就是一个空标签。
也就是说:背景图从来不是「CSS 里的图」,而是「JS 运行时塞进去的图」。JS 一旦没跑到那一步,背景必然空白 —— 这个设计平时看不出来,出事的时候就是黑屏,而且你怎么刷新都没用。
所以第一步是把默认值直接内联进去,让「JS 全挂」和「背景还在」这两件事脱钩:
`@html
`@
这四张图我挨个测了,全部 200,最慢的一张 278ms。图一直好好地在服务器上躺着,是我们自己没去取。
8.3 真正的元凶:7 个随机壁纸 API 死了 5 个
那 JS 为什么写不出有效的背景?因为美化模块允许自定义背景,地址存在 localStorage.blogbg 里。顺着这个值往下查,挖出两个坑。
坑一:一个拼错的单词。
「恢复默认背景」按钮的代码是这样的:
@js function resetBg() { localStorage.setItem("blogbg", "deafult"); // default 拼成了 deafult resetBg_(); } @
只要点过一次,这个非法值就永久留在浏览器里:之后每次加载,判断 blogbg == 'default' 都是假,于是走自定义分支,把 --default-bg: deafult; 写进 CSS —— 一个非法值,浏览器直接忽略,背景全空。
坑二:设置面板里那些「随机壁纸」,源已经死了一大半。
我把面板里的壁纸 API 挨个测了一遍:
| 壁纸源 | 实测结果 |
|---|---|
bing.img.run(必应每日 / 随机) | 连接失败 |
api.yimian.xyz | 8 秒超时 |
cdn.seovx.com | 连接失败 |
api.ixiaowai.cn/gqapi | 连接失败 |
source.unsplash.com | 已停服(503) |
api.btstu.cn | 只有 http,HTTPS 站被混合内容拦掉 |
picsum.photos | 能用,但要 2.8 秒 |
七个里死了五个。 而当年在设置面板里点过这些按钮的人(包括我自己),浏览器里存的就是这些死链 —— 每次打开网站,都在等一张永远不会来的图。
修法是三件套:
- 换源:必应每日 / 随机 →
bing.biturl.top,二次元图 →loliapi.com/acg,seovx →api.dujin.org/pic,unsplash 和 btstu →t.mwm.moe。实测 195~430ms 出图,全部国内可达。 - 写入前先探测:点了新壁纸先发一个
Image请求试加载,加载不出来就不写进 localStorage,并弹一句「这个壁纸源失效了🥲」—— 至少让人知道为什么没反应,而不是傻等。 - 自愈:启动时校验
localStorage里的值,只要它既不是default、也不是颜色、也不像url(...)或http开头,一律按default处理并回写。以后就算再出现类似的脏值,用户也不用自己去清缓存。
(最开始那条 $ is not defined 也是这一轮顺带根治的,方法记在 10.3 节。)
九、战果
改完必须验证,不然等于没改。
验证方式是在临时目录里跑一次完整构建,然后扫描产物。为什么不直接在博客目录跑?因为本地的 hexo server 正占着 public/ 和 db.json,会打架。
构建结果:182 个 HTML 页面,7.6 秒,重复跑了三次都稳定。
然后是逐项验收:
| 检查项 | 结果 |
|---|---|
唯一的外部 <script src> | 26 个 → 24 个 |
cdn.jsdelivr.net 引用 | 0(此前 3 处 leaflet) |
cdn.staticfile.org 作为资源加载 | 0(换源前 1.01 MB) |
| Vue + Element-UI | 0(换来自研的 7 KB) |
| MathJax 指向 npmmirror 的页面 | 35 / 35 |
引入 /js/notify.js 的页面 | 182 / 182 |
含 preconnect 的页面 | 182 / 182 |
| 带 jQuery 本地兜底的页面 | 186 / 186 |
| 内联默认背景变量的页面 | 186 / 186 |
(第一轮统计时全站 182 个页面;背景修复那一轮之后站里多了几篇文章,最后一次完整构建是 186 个 HTML 页面 / 11 秒,零报错。所以表格里的分母会不太一样。)
现在剩下的 24 个外部脚本,全部在这些域名上:
1 | cdn.jsdmirror.com 2 JS-Heo 朋友圈 |
境外域名:0。
体积账:
| 之前 | 之后 | |
|---|---|---|
| 首屏第三方资源 | 2.72 MB | ~1.82 MB |
| 其中 Vue + Element-UI | 905 KB | 7 KB |
省下来的 898 KB,差不多是原来的三分之一。
十、几个值得单独记下来的坑
这段是给自己(和未来的你)看的。
10.1 别用正则去改 JS 源码,尤其别用非贪婪跨块匹配
我一开始想批量替换那 14 处 new Vue,写了个看着挺聪明的正则:
1 | /new Vue\(\s*\{\s*data:\s*function\s*\(\)\s*\{\s*this\.\$notify\((\{[\s\S]*?\})\)\s*;?\s*\}\s*\}\)/g; |
匹配到 13 处(预期 14 处),写回文件之后语法直接炸了:
1 | SyntaxError: Missing catch or finally after try |
原因是 [\s\S]*? 这种非贪婪匹配会跨过相邻代码块的边界,把一个块的收尾花括号吞掉,结构就被破坏了。
后来改成"扫描器 + 括号配对":先预处理出一个掩码,跳过字符串、注释和正则字面量,然后用括号深度去精确定位每一处的起止位置,从后往前替换。
结果是精确命中 14 处,一处不多一处不少。
教训就是:改源码这种活儿,正则是最不靠谱的工具。 要么用 AST,要么老老实实做括号配对 —— 图省事的下场就是回滚重来。
10.2 Hexo 会给 source/ 下的 .js 跑 Nunjucks
我新建 notify.js 的时候,JSDoc 里写了这么一行:
1 | /** |
构建直接报错:
1 | Nunjucks Error: js/notify.js [Line 83, Column 21] expected variable end |
因为我写 JSDoc 时习惯性用了双花括号来标注可选字段,那对括号被当成了 Nunjucks 的变量插值。Hexo 的 source/ 目录下所有文本文件都会过一遍 Nunjucks,包括 .js。
改成 @param {Object} opt 就没事了。
顺便一提,fomal.js 里我故意嵌了 18 处本站的提示框和折叠块标签,靠的就是这个机制 —— 每次构建的时候展开成真正的 HTML。同一个特性,既是坑也是特性。
10.3 $ is not defined:一个 CDN 挂掉,整个文件停摆
回到最开始那两条 JS 报错。
fomal.js:72 那段是在拿访问者的大致位置(在首页欢迎语里显示「你是来自 xx 的朋友」):
@js $.ajax({ type: 'get', url: 'https://apis.map.qq.com/ws/location/v1/ip', data: { key: '...', output: 'jsonp' }, dataType: 'jsonp', success: function (res) { /* ... */ } }); @
问题是它依赖 jQuery。而 jQuery 是从 CDN 加载的,CDN 一死,fomal.js 第 44 行的 $(window).scrollTop() 就抛错,整个文件从这里中断 —— 后面 3800 行代码一行都不会执行,包括写背景图的那段。
这就是「一个外部依赖挂掉,整个文件停摆」的经典样本:同步的顶层代码里出现了一个可能不存在的全局变量,这份脚本就变成了定时炸弹。
第一轮只是把 jQuery 换到了国内镜像,炸弹还在。这次加了一行本地兜底:
`@html
`@
CDN 活着就走 CDN,死了就用站内自托管的备份(89 KB)。自托管一份兜底,是从「依赖别人」到「自己兜得住」最便宜的一步。
(字符串里的 </script> 必须写成 <\/script>,否则 HTML 解析器会以为脚本块提前结束了 —— 顺手记一笔。另外 LazyLoad is not defined 属于同一类毛病,出在主题的 main.js 里,这次没动,留个记号。)
回到最开始那两条 JS 报错。
fomal.js:72 那段是在拿访问者的大致位置(在首页欢迎语里显示"你是来自 xx 的朋友"):
1 | $.ajax({ |
问题是它依赖 jQuery,而 jQuery 是在页面底部才加载的,顺序对不上,于是就有了 $ is not defined。
这次换源把 jQuery 换到了国内镜像,但加载顺序的问题没动。LazyLoad is not defined 也是同一类毛病。
留着下次修吧 —— 反正这篇已经够长了 🤣
十一、顺带排掉的一颗雷:三个 Markdown 渲染器抢一个位置
有一件事我原本没打算管——直到「关于」页自己先坏了。
某天点进去发现,「1.线路信息🚁」之后那一整块变成了没被解析的原始 Markdown:列表全连成一段,链接还带着 [fomal.cc](https://www.fomal.cc/) 的括号,小标题的 ## 两个字面量直接杵在页面上。第一反应是标签没闭合、要么缩进写坏了,回头一看那篇文件一个字都没动过。
真正的原因藏得比较深:这个站同时装了三个 Markdown 渲染器。
1 | @upupming/hexo-renderer-markdown-it-plus |
它们注册的是同一批扩展名(md、markdown、mkd …),而 node_modules/hexo/lib/extend/renderer.js 里的注册是「后注册者胜」;更麻烦的是 hexo/lib/hexo/load_plugins.js 用 bluebird 的 Promise.map 并发加载插件,谁先加载完基本看运气——同一份仓库连跑两次,插件的完成顺序都不一样。
运气不好、hexo-renderer-kramed 赢的时候,它的 HTML 块规则会把 <div class="about_page"> 之后的整段 Markdown 当成纯文本吞掉,就是我看到的那个样子。判它赢还有个简单指纹:kramed 会给标题补 <a class="headerlink">、给表格套一层 div.table-container。
最麻烦的不是它坏,是它坏了不会自己好。 Hexo 不重渲染已经缓存的文档:
Cache.compareFile只要cache.modified === mtime就直接返回skip(hash 一样也是 skip),touch 源文件完全没用;node_modules/hexo/lib/plugins/processor/post.js:77碰到type是skip的文档直接return;- 而
before_generate/render_post.js里的 renderPostFilter 只认post.content == null的文档。
三条叠在一起的结果是:db.json 里那份坏 HTML 会一直赖着,除非整站 clean 重建——而重建又是在赌运气。后来那次全站重建,kramed 又赢了一次:public/personal/about/index.html 里 ## 2.技术栈 是字面量、<h2> 只剩 3 个(正常 5 个),db.json 里有标题的 53 篇文档全部是 kramed 风格。
修法分两步。急救是把 db.json 里那篇的缓存记录和 content 字段手术删掉(15807407 → 15785922 字节),先让页面能看;根治是从 package.json 里删掉那一行:
1 | - "hexo-renderer-kramed": "^0.1.4", |
这里有个容易吓到自己的地方:删了依赖要不要 npm uninstall?不用。 Hexo 的 load_plugins 只加载 package.json 里列出的包,node_modules/ 里的残留放着不加载、无害。
删之前当然要做对照——万一 kramed 才是当前生效的那个,删掉等于把 84 篇文章全部重渲染一遍。做法是把 84 篇 .md 用 hexo.post.render() 重新渲染取 sha256:含 kramed 和不含 kramed,指纹完全一样(0ceb6517…),说明它早就不生效了,摘掉对现有输出逐字节零影响。
留了个尾巴:@upupming/hexo-renderer-markdown-it-plus 和 hexo-renderer-markdown-it 还在并存,只是 6 次观测里 plus 总是先完成、markdown-it 后完成,所以是后者生效。想把竞态彻底消掉,可以把 plus 也摘掉,但要重做一遍同样的对照验证。
副作用一个:kramed 独有的标题自链接 <a class="headerlink"> 不见了。想留可以在 _config.yml 的 markdown.anchors 下面加 permalink: true、permalinkClass: headerlink、permalinkSide: left,再把符号留空。我没接回去——标题 id 两种渲染器本来就一致。
十二、给页脚加了个「旅行者 1 号」
页脚那个计时器(「本站居然运行了 X 天 X 小时 X 分 X 秒」,白天挂「科研摸鱼中」的牌子、晚上换成「下班休息啦」)跑了两年多,这次在旁边加了一句更远的东西:
旅行者 1 号当前距离地球 25,0xx,xxx,xxx 千米,约为 1xx.xxxxxx 个天文单位(信号单程需 XX 小时 XX 分)🚀
这东西不能随便算。「距离 = 速度 × 时间」那种近似公式,放到两百多亿公里外误差能到几百万公里。所以做法是从 JPL Horizons 取真实星历(DE440 地球星历 + Jacobson 2022 的旅行者 1 号解),离线做最小二乘拟合,把系数直接打进 fomal.js,运行时只做多项式求值——不联网、不请求接口,也不会哪天因为第三方接口停服而变成空白(这个教训前面刚吃过)。
模型拆成两块:
- 旅行者 1 号:线性趋势 + 切比雪夫残差(3 个坐标 × 47 项)
- 地球:年谐波(含线性慢调制)+ 月球项(3 个坐标 × 26 项)
在日心黄道 J2000 坐标下分别算出两体位置,再取几何距离 |r_V1 − r_earth|。时间也要处理:系数是按 TDB 儒略日拟合的,输入毫秒时间戳得先换算 jd = ms / 86400000 + 2440587.5 + 69.184 / 86400(那里多出来的 69.184 秒,是从 UTC 到 TDB 的近似差)。
有效期 2026-01-01 ~ 2100-01-01,窗口外自动钳位到边界(退化成只保留线性趋势),不会算出离谱的数。精度上,与 Horizons 的几何距离对着比(2026—2100 年两万多个独立采样点)均方根约 4000 千米、最大约 12000 千米(≈ 8×10⁻⁵ 天文单位)——已经远好于 JPL 自己对旅行者 1 号位置的预测不确定度(2030 年约 ±20 万千米)。
还有个细节值得单独说:它输出的是几何距离(此刻真实距离),不是光行时修正后的视距离;旁边那句「信号单程需 X 小时 Y 分」就是拿这个距离除光速再取整。
顺手把欢迎信息也重做了:
- 站长坐标改成深圳市南山区(
113.930413, 22.531901,代码里留了注释「换坐标只改这一行」) - 原来那套「按省份/城市各写一句欢迎语」的大 switch 整个删掉了——辽宁省是「我想吃烤鸡架!」,上海市是「众所周知,中国只有两个城市。」,写的时候很开心,但它有一百多行,而且只对国内 IP 有意义。现在改成按访问者的本地时间打招呼:上午好 / 中午好 / 下午好 / 晚上好 / 夜深了,再带上当前时刻
- IP 显示加了个小处理:在每个冒号后面插一个
<wbr>,让 IPv6 地址能在冒号处断行,不至于把卡片撑破
文案从「欢迎来自 广东省 深圳市 龙华区 的小伙伴,晚上好,夜生活嗨起来!您现在距离站长约 88 公里,当前的IP地址为:…」变成了「来自 广东省 深圳市 龙华区 的访客,现在是 22:41,你目前距站长约 16 公里,IP地址:…」。
公告栏也一起精简了:删掉 zeabur.fomal.cc、fleek.fomal.cc 两条早就停掉的备用线路,以及整块「— chatGPT —」,只留主域名两个 + netlify 备用 + APP 下载。理由很直白——挂着不能用的东西比不挂更糟,它会让人以为还有退路。
十三、主题正式改名叫 Fomalhaut 了
这主题从 Butterfly 4.3.1 改到现在,已经没几行是原来的样子了,但目录还叫 themes/butterfly、配置还叫 _config.butterfly.yml,每次看都觉得别扭。于是改名。
改名听起来是体力活,但它属于那种一处漏了就直接坏的操作——Hexo 找主题配置,是按 _config.<主题目录名>.yml 拼出来的:
1 | themes/butterfly/ → themes/fomalhaut/ |
顺带给主题加了个自报家门的启动横幅:在 Hexo 的 ready 事件里打 5 行 ASCII 艺术字 FOMALHAUT,下面跟一行版本号:
1 | 主题版本:1.0.0 (Inherited from Butterfly 4.3.1) |
主题自己的版本号单独写在一个 fomalhautVersion 常量里(以后改这一行就行),Butterfly 的版本从主题 package.json 的 version 字段读出来。两个小坑:
- 版本行里中文要按两个字符宽算才能居中(判断
\u2E80-\uFFEF这个区段),不然会明显偏左; - 别顺手去改主题 package.json 里的 version —— 主题的
cdn.js还拿这个版本号拼 jsdelivr / unpkg 的资源地址,改了会把静态资源路径一起改坏。
最后还有两处小打磨:
- 作者卡片的按钮从「前往小窝」改成「Github主页」,指向
github.com/fomalhaut1998 - 首页卡片右上角那个小箭头,主题里是直角
clip-path,在custom.css里用同链选择器(特异性相同、顺序更靠后)覆盖成圆角:元素固定 20×10px、圆角半径约 2px,曲线按 4 段采样成多边形顶点坐标——border-radius对clip-path不生效,只能这么硬算
改完的感受是:这种「机械但零容错」的活儿,交给 AI 一遍过是最划算的;真正难的是判断「哪些地方必须跟着改」——上面那张清单里,漏掉任何一行都不会报错,只会安静地不生效,或者干脆整个站渲染不出来。
十四、把「刷新时先闪一下」也抹平了
前面背景图修好之后,我注意到一个更细的毛病:每次刷新页面,会先闪一帧默认壁纸(还有默认字体),然后才跳成我自己设的那张。
不算故障,但很扎眼。原因也不复杂:壁纸、卡片透明度、主题色这些美化设置都存在 localStorage 里,而负责读它们、再把值写进 CSS 变量的那 3800 行 fomal.js,是放在页面底部、等 HTML 和 CSS 都处理完才开始跑的。第一帧渲染时,CSS 用的还是 :root 里的默认值——于是先画一帧默认壁纸,再被脚本改掉。
要抹掉这一帧只有一条路:赶在首帧之前把值写进去。于是在 _config.fomalhaut.yml 的 inject.head 里插了一段内联脚本(<script id="beautyBoot">):它同步执行,早于 body 解析、也早于首帧。位置很讲究——必须放在那批 <style id="..."> 标签后面,否则 document.getElementById() 什么都拿不到。
它一共接管八类设置,套路都一样:读 localStorage,然后直接改对应 <style> 标签的 textContent:
- 自定义壁纸 →
defineBg - 卡片透明度 →
transPercent - 背景滤镜(模糊 / 饱和度 / 对比度)→
bgFilterParam - 主题色 →
themeColor - 字体 →
--global-font(选「默认字体」时,还要像fomal.js那样动态插一段<style>) - 侧边部件 / 侧栏显隐 / 侧栏位置 / 顶栏常驻 / 霓虹灯 / 帧率开关 →
rightSide、aside-show、aside-pos、nav-display、menu_shadow、#fps
几个必须对齐的细节:
- 壁纸要连
--darkmode-bg一起写(脚本里那个SAME = ";--darkmode-bg:"常量就是干这个的),不然切到夜间模式还是旧图; - 取值规则必须和
fomal.js的初始化逐字一致:null、空串、字符串"undefined"/"null"都算「没设置」,走默认值; - 这段脚本只是提前把同样的值写一遍,不新增任何请求,也不改
fomal.js的逻辑——两边的结果必须一模一样,否则会出现「刷新后闪回默认值」这种更诡异的现象。
写的时候先在 .tmp-boot-draft.js 里当独立文件调通,再内联进配置。要说这轮修的东西里哪些最不容易被发现,就是这种「一帧」级别的问题:它不影响任何功能,只在每次刷新的头 100 毫秒里出现一次。
十五、美化面板里那根「点不动」的滑条
顺手修的第二个体验问题:卡片透明度那根进度条,只有拖右边那个圆点才有反应,点轨道左边完全没动静。
两个原因叠在一起:
input[type=range]本体只有 5px 高,圆点却有 15px——想点中那 5px 的细线本来就很难;- 更麻烦的是它上面盖了一层
.rang_width(position: absolute)用来显示彩色进度,这层把鼠标事件全吃掉了,点下去根本传不到 input。
修法分三层,缺一层都不成立。
一、把可点击区域做大(custom.css):.range 加 padding: 9px 0,视觉高度不变,可点区域从 5px 扩到 23px;顺手加 cursor: pointer、user-select: none,触屏上给 touch-action: pan-y——纵向照样滚页面,只有横向拖动才交给滑条。
二、让进度条别再挡路:.rang_width 的 top 跟着 padding 对齐到 9px,并加 pointer-events: none。它只是装饰,不该接收事件。
三、坐标换算(fomal.js 里新写的 rangeValueFromX()):把鼠标横坐标换成滑条的值。这里有几个容易算错的地方:
- 要扣掉圆点自身的宽度(
usable = rect.width - 15),否则拖到最右边永远到不了 max,最左边也点不出 min; - 按
step取整,再抹掉浮点误差的尾巴; - 直接改
value不会触发oninput,所以另外补一个input事件(new Event("input", { bubbles: true }))派发出去,复用原来那套setTrans()——这样「点击跳转」和「拖动滑块」走的是同一条逻辑,不会变成两套代码各自维护。
再往上包一层:bindRangeJump() 在滑条所在的框上接 mousedown / touchstart,initRangeJump() 在 document 上只注册一次 pointermove / pointerup(拖出轨道再松手也能正确结束,反复开关小窗也不会重复绑定)。最后在美化小窗创建完之后挂两行:
1 | initRangeJump(); |
改主题文件之前,先抽了个独立页面 slider-fix-check.html 出来——把真实的 CSS 和一段真实代码的切片(.tmp-real-slice.js)拼进去,在浏览器里先把手感确认好。这种小交互最怕「改完不知道好没好」,有个能单独打开的自测页,比每次刷新整站去试效率高得多。
十六、两枚新徽章,和一个「不用再管」的朋友圈
页脚那排徽章更新了:
- 主题徽章从
Theme-Butterfly-6513df.svg换成自制的Theme-Fomalhaut-6513df.svg(沿用同一个色号),链接指向改名后的hexo-theme-Fomalhaut仓库; - 新增一枚
CDN-npmmirror-fff2cc.svg——这轮换源把大部分静态库搬到了 npmmirror,值得在页脚记一笔; - 两张 SVG 也存进了
source/assets/badge/存档,线上依旧走缤纷云的桶。
比徽章更值得写的是朋友圈(友链动态)那条链路。它原来的数据源是 https://fcircle.fomal.cc/,也就是我自建的 Vercel 爬虫接口。
这次把 source/social/fcircle/index.md 里的 private_api_url 换成了仓库里的静态文件:
1 | // 数据源:仓库里的静态 data.json(由 GitHub Action 每 6 小时生成,经 jsDelivr 国内 CDN 分发) |
看起来只是换了个地址,其实是把「需要有人维护的服务」换成了「一个文件」:爬虫照旧每 6 小时跑一次,结果直接提交进 GitHub 仓库;前端拿的是 CDN 上的静态 JSON,没有函数、没有运行时、没有要续费的平台。写这篇之前顺手查了下它是活的:HTTP 200、7689 字节(8 KB),statistical_data 和 article_data 都在,26 篇文章。
回头看,这一整晚修的都是同一件事:把依赖第三方、依赖人去盯的东西,换成放在自己仓库里、躺在 CDN 上的东西。 它们唯一的失效条件,就是我自己不提交。
十七、顺手的三件小事
标签统计图的排序一直是错的。 标签页那张「Top 10 标签统计图」由主题的 hexo_echarts.js 这个 helper 生成,它取前 N 名的写法是 sort((x, y) => y[0] - x[0])——而 x[0] 是标签名(字符串),字符串相减得到 NaN,排序函数永远返回 NaN,排了等于没排。改成按文章数降序、同数按名字排:
1 | tagArr.sort( |
「随便逛逛」在本地预览时会跳到生产站。 那个按钮是随机跳一篇博文,数据来自 /baidusitemap.xml;而 sitemap 里的 <loc> 是绝对地址(域名由 _config.yml 的 url 决定),所以本地 localhost:4000 点一下,会把自己的浏览器直接扔到 www.fomal.cc 去。改成统一转成同源相对路径:
1 | const u = new URL(el.textContent.trim(), location.origin); |
顺手加了道保险:抽到的地址如果和当前页面相同,最多重抽 20 次(原来只判断一次)。
帧率显示的采样窗口从 1 秒缩到 0.5 秒(now > 500 + lastTime),刷新频率翻倍。这一处是上一轮改的,我写这篇的时候才发现漏记了——所以「顺手」这话不能全信,改完不写下来,过一天自己都不记得。
十八、统计页的三张图,其实是一张过期了 30 天的 token
上次修完背景图之后,我去 /site/census/ 看了一眼,发现三张图 —— 访问地图、访问趋势、访问来源 —— 全是空白。
「空白」这个状态特别有迷惑性:它不像报错那么显眼,也不像「本来就还没数据」那么理直气壮。点开控制台才知道,census.js 从百度统计接口拿回来的是:
1 | { "error_code": 111, "error_msg": "Access token expired" } |
而三个 .catch() 里只写了一行 console.log(error),错误被安安静静地吞掉了。页面上看不出「没数据」和「服务挂了」的区别,这就是它能坏很久而我一直没发现的原因。
百度统计的 access_token 有效期只有 30 天,过期之后要拿 refresh_token 去换新的。这里有个坑值得单独记:refresh_token 是一次性的。我用同一个 token 调了第二次刷新接口,直接返回:
1 | error 400 expired_token: refresh token has been used |
所以刷新完必须把返回的新 refresh_token 一并写回去。census.js 里现在是三行联动(第 4 行刷新链接里的、第 9 行注释里的、第 10 行的 access_token),下次到期是 10 月 31 日之前 —— 我把这句话也写进注释里了,因为这个坑一年会来十二次,而人一年会忘十二次。
顺手做了两件事,让这类故障下次别再静默。
第一,接口挂了要看得见。 加了两个小函数:
1 | function censusResult(data) { ... } // 校验返回体,异常时抛错 |
原来那三个 .catch 只打日志,现在会在图表原来的位置上写清楚「统计服务暂不可用」,以及具体是什么原因 —— 从界面就能看出来是服务挂了,而不是自己网卡了。
第二,让地图跟着主题色走。 那张中国地图原来写死了 ['#39c5bb', '#b9ebe4'] 两级色阶,主题色是绿的时候看着还行,换成粉的、紫的就格格不入。但主题色不是常量:它是主题运行时写进 <style id="themeColor"> 的 CSS 变量,取值来自设置面板,所以只能在运行时读:
1 | getComputedStyle(document.documentElement).getPropertyValue("--theme-color"); |
读到 rgb 之后,再用「叠白」「叠黑」两个函数派生出同色系的浅色调和深色调,把悬浮高亮的 gold 换成主题色的深色,悬浮标签的文字颜色按背景亮度在深灰和白色之间自适应。最后加一个 MutationObserver 盯着那个 <style> 标签 —— 你在设置面板里换主题色,地图会跟着一起变,不用刷新页面。
顺带还改了 51la 那排数字。原来只显示 5 格,把「总访问量」漏掉了,order 数组改一行就能加回来:
1 | let order = [6, 1, 3, 2, 4, 5]; // 下标 6 = 总访问量 |
再加千分位。这里有个细节:51la 的 quote.js 是被当纯文本抓回来的,它自带的格式化函数根本不会执行,所以只能自己补一个正则;顺手给 match() 加了 || [] 兜底 —— 抓不到的时候原来是 TypeError,整个统计块直接空白。
(顺便说一句:改完这些,统计页终于从「三张白板」变回了有内容的页面。这类改动没有任何技术难度,纯粹是没人往那儿看 —— 前面屏幕上那些红字是这样,这三张图也是这样。)
十九、一块贡献日历的三次搬家
统计页顶部还有一块 GitHub 贡献日历,它今天挪了三次家 —— 大概是整篇里最能说明「自建服务为什么会烂掉」的例子。
起点是它在转圈。链路是这样的:hexo-filter-gitcalendar 往页面里注入一句 GitCalendarInit(jsonurl, color, user),前端的 gitcalendar.js 拿这个地址去 fetch 一年的贡献数据。
第一次搬家:先找个别人的接口顶着。 原来的数据由自建的 Vercel 函数 gitcalendar.fomal.cc 提供 —— 它的做法是抓 https://github.com/fomalhaut1998 的主页 HTML,再用两条正则把日历抠出来。GitHub 后来把日历改成了异步加载:
| 旧代码期望 | 实测 | |
|---|---|---|
| 数据来源 | 主页内嵌的日历 | 主页 HTML 里 data-date= 出现 0 次 |
| 日期正则 | data-date="(.*?)" data-level | 属性顺序变成 data-date … id … data-level |
| 计数正则 | <span class="sr-only">N contribution | 每天的计数搬进了 <tool-tip> |
结果 zip(*sorted_data) 解到一个空列表,抛 ValueError,Vercel 只回一个 96 字节的 FUNCTION_INVOCATION_FAILED。抓 HTML 的接口本质上是在别人家的装修上打洞,人家一改版就塌。
临时方案很省事 —— _config.yml 里加一行 jsonurl,先指向别人的公开接口:
1 | jsonurl: "https://gh-calendar.rschristian.dev/user/fomalhaut1998" |
第二次搬家:把自己的接口修好。 老代码改了两版才跑通。v1 换了 requests 库还是 500,而且返回的是 Vercel 的通用错误页 —— 说明异常发生在进入 try 之前,也就是模块导入阶段就崩了(requirements.txt 里 pin 的是 2020 年的版本)。所以 v2 干脆做了两件事:
- 全部改用标准库
urllib,不再有任何第三方依赖; - 所有分支收敛到一个出口,任何异常都变成 JSON,不再出现 Vercel 的通用 500 页。
还加了一个自检入口 ?__debug,返回运行时 Python 版本、抓取状态、抓到的字节数、关键正则的命中次数,以及页面开头 200 字。排查远程接口最痛苦的是「你看不见里面」,那就先让自己能看见。
第三次搬家:连前端资源也拿回来。 修接口的时候才发现,不只是数据在别人手里 —— gitcalendar.css 和 gitcalendar.js 也挂在 npm.elemecdn.com 上。于是两个文件搬回本站(14564 与 3045 字节),顺手给 js 打了个补丁:原版只在成功分支里摘掉加载动画 #git_loading,
1 | }).catch(function(error) { |
补丁在 catch 里补上收尾,并在日历容器里写一句「📅 贡献日历暂不可用 / 数据来源:GitHub · 稍后刷新重试」。这里还有一个隐藏依赖:#git_container { display: none } 这条样式写在 #git_loading 内部,所以「摘掉转圈」和「让日历显示出来」是同一个动作,顺序不能颠倒。
终点(这次的答案):不要服务,只要一个文件。 前面两版修的都是「抓 HTML」,GitHub 下次改版还会坏。所以最后走了一条和朋友圈(hexo-circle-of-friends)完全一样的路:
1 | GitHub GraphQL API → GitHub Action(每 6 小时)→ gitcalendar-data 仓库里的 data.json |
没有 Vercel 函数、没有第三方接口、没有会欠费或过期的项目 —— 唯一能让它失效的事情是 GitHub 自己挂了。几个值得记下来的细节:
- 数据单独放一个仓库。如果把这个每 6 小时提交一次的任务放在博客仓库里,每次提交都会触发一次整站重新部署;
- 生成脚本的形状校验是硬性的:周数不是 53、最后一周为空、GraphQL 报错 —— 任一情况直接退出,并且不覆写 data.json。宁可数据旧 6 小时,也不要页面空白;
- 实测:11943 字节、53 周、369 天,与
raw.githubusercontent.com上的原文逐字节一致;gitcalendar.js里硬编码取的git_data[47]/[48]/[51]/[52]在 53 周结构下全部合法; - 代价是当天的贡献最多滞后 6 小时。原来的
apiurl留着没删,删掉jsonurl那行就能切回自己的接口。
最后是一个反复踩到的坑:_config.yml 不在 hexo server 的监听范围里。node_modules/hexo/lib/hexo/index.js:345-346 只监听 source 和 theme,所以改完配置必须重启,否则页面里注入的还是旧地址(实测改完刷新,源码里仍然是 GitCalendarInit("https://gitcalendar.fomal.cc/api?fomalhaut1998"...))。
二十、滚动条,和一只挡路的猫
两件视觉上的小事,都是今天才发现的。
滚动条上的斜条纹其实一直没出现。 很早以前我给滚动条写过一套样式:主题色滑块、45 度斜条纹、圆角。但它从来没显示过 —— 因为后来我又给 Firefox 加了标准属性:
1 | scrollbar-width: thin; |
问题是 Chrome / Edge 121+ 也认这两个标准属性了,而一旦它们不是 auto,浏览器就会完全忽略 ::-webkit-scrollbar 伪元素样式 —— 不是被覆盖,是整个不生效。所以修法反直觉:在支持伪元素的浏览器里,把标准属性退回 auto:
1 | @supports selector(::-webkit-scrollbar) { |
Firefox 不认识 selector(::-webkit-scrollbar),会跳过这一段,继续用它认识的标准双色滚动条 —— 两边就都能看了。
条纹本身也重做了一版「发廊转筒」:
1 | ::-webkit-scrollbar-thumb { |
条纹宽度试了三档:3px 太密,像被马赛克糊过;7px 仍然偏密;9px 是松弛、但还没糊成一块色板的那个档位。
然后是那只猫。 右下角有只报时猫,鼠标移上去会冒一个气泡显示当前时间。它平时不显眼,直到你发现它把底下的东西全挡住了 —— 侧边栏的按钮、导航图标,指针移过去既不亮也点不动,因为它是绝对定位浮在页面右侧的一层。
修法很简单,让它只当「视觉挂件」:
1 | .neko { |
代价立刻出现:pointer-events: none 的元素收不到 :hover,报时气泡再也不出来了。于是把「悬停」这件事从 CSS 搬到 JS 自己做:
1 | var r = el.getBoundingClientRect(); |
三个细节:
mousemove触发频率很高,套一层requestAnimationFrame合流 —— 一帧最多判定一次;- 指针移出整个窗口之后不会再有
mousemove,补一个window.mouseout+e.relatedTarget为空的兜底,否则气泡会一直挂在那儿; - 定时器在
window上留了__nekoClockTimer标记,脚本被重复执行也不会叠出好几个。
这两件事的共同点是:它们都不报错。 一个样式被默默忽略、一只猫默默挡住按钮,控制台里没有一行红字,你只会觉得「这个按钮怎么点不动」。所以回头看,修网站最费时间的从来不是修,而是发现。
二十一、节日提醒:从「必须点一下确定」换成左上角一张卡片
先说一个此前一直被我无视的东西:博客的节日提醒。
旧版是一套 SweetAlert2 居中弹窗 —— 到了元旦、春节、国庆这种日子,页面正中间弹出一个框,挡住内容,必须点一下「确定」才能继续看文章。老实讲,作为一个偶尔路过自己博客的人,我都被它烦过。所以这次干脆重写。
旧的写法是二十多个 if。 节日判断全塞在 source/js/fomal.js 里:一个节日一个 if,里面写死「今年是哪一年、今天是不是那一天」,最后调一次 Swal.fire(...)。这种写法有两个麻烦:
- 清明、冬至这种跟节气走的节日,日期每年都不一样,而旧代码是按年份硬编码的 —— 意思是过一年你就得进去改一次数。人不会记得改。
- 想加一个节日,得在将近四千行的
fomal.js里找到对的位置,复制一坨if再改文案。
新的写法是一张表。 现在多了一个 source/js/festival.js(14199 字节 / 433 行),里面只有一张 FESTIVALS 表,30 条节日各占一块:
1 | { |
判断条件支持四种写法,可以叠加,满足任意一种就弹:
| 写法 | 例子 | 说明 |
|---|---|---|
solar | [[10, 1], [10, 2], [10, 3]] | 公历,国庆一次管三天 |
term | [4, 7] | 节气:4 月的第 7 个节气 = 清明,按年自动算 |
lunar | [['八月', '十五']] | 农历,写法跟页面上显示的农历一致 |
week | [5, 0, 2] | 5 月第 2 个星期日 —— 母亲节是这么写的 |
文案里有几个占位符:{year} 是当前年份,{since:1949} 会算成「距 1949 年多少年」,所以国庆那条写的是「祝祖国{since:1949}岁生日快乐!」,不用每年改;愚人节那种需要随机挑一句的,用 pick: ['…', '…']。
弹的箱子也换了。 节日卡片现在走的是我上一轮写的那个通知组件(source/js/notify.js),落在左上角,十秒后自己收起,想提前关就点右上角的叉:
1 | fomalNotify({ |
一天只弹一张卡(记在 sessionStorage 的 fomal-festival-shown 里;隐身模式下 sessionStorage 可能不可用,代码里 catch 掉就算了,不值得为它报个错)。
顺便,这张卡还能不等节日就预览:
- 控制台输入
fomalFestival.list看全部节日; fomalFestival.preview('national')立刻弹出国庆那张卡(不占用当天的去重名额);- 或者直接在地址后面加
#festival=mid-autumn(换成表里的 id)。
农历换算拆成了单独一个文件。 节气与农历那套算法原来也在 fomal.js 里,这次抽成了 source/js/lunar.js(24401 字节 / 1089 行,覆盖 1900–2100 年),对外就三个函数:solar2lunar()(不传参就是今天)、lunar2solar()、getTerm(year, n)(第 n 个节气,1 是小寒、7 是清明、24 是冬至)。
两个细节值得记下来:
- 顺序不能错。
festival.js依赖lunar.js,注入时它必须在前 ——_config.fomalhaut.yml:1263是 lunar.js,:1265才是 festival.js。 lunar.js故意没写成 IIFE、也没开严格模式。 那几个函数必须挂在全局上给festival.js用;文件头也写明了:里面的算法是逐行照搬过来的,要加新逻辑请另开文件,别动它。
这一进一出,主脚本瘦了一圈。 source/js/fomal.js 从 3961 行降到 2799 行 —— 少了一千一百多行。搬出去的部分变成了两个各管一事的文件:lunar.js(24 KB)+ festival.js(14 KB)。
顺手删掉的还有两样东西。 一是四条公祭日提醒(九一八、卢沟桥、南京大屠杀、世界慰安妇纪念日),二是一整套「纪念日到点把整站变灰」的代码(fomal.js 里原来有 4 处 grayscale 处理)。这两处是我自己要求删的:网站是个人博客,不是宣传栏,该记的事我自己会记得,不需要代码到点提醒我。删干净之后,grayscale 在 fomal.js 里归零。
同时补了八个日常节日(情人节、母亲节、父亲节、儿童节、建军节、感恩节、平安夜、腊八节),原来那些祝福语的文案也顺手改稳了一点。表里还留着两条私货:cat-birthday 和 fomal-birthday —— 一只猫的生日,和站长的生日。
最后,SweetAlert2 那条外链脚本也一起卸了:现在整个仓库搜不到一处 sweetalert。
二十二、首页改成 3 × 3,以及一种把置顶写在正文里的写法
这轮还有两件事都落在首页上。
第一件:一页 12 篇改成 9 篇。
起因是首页看着有点「糊」。我这边首页是三列布局(themes/fomalhaut/source/css/_index_card_style/multicard.styl:146-150:窗口宽 ≥1069px 时卡片宽度 32.3%,也就是一行三张),而 _config.yml:84 的 index_generator.per_page 写的是 12 —— 12 除以 3 正好四行,一屏密密麻麻塞满。
改成 9 就是三列三行,一屏刚好摆满,底下的留白出来了,翻页从 5 页变成 6 页。不算大改,但第一眼的观感确实不一样了。
顺手把侧栏那张「最新文章」卡片也撤了(_config.fomalhaut.yml:967 的 aside.card_recent_post.enable 改成 false):首页已经全是文章列表,侧栏再来一份「最新 5 篇」纯属重复。模板里是 themes/fomalhaut/layout/includes/widget/card_recent_post.pug:1 的那个 if 控制的,改完这个 DOM 整个不会生成。
验证还是老老实实构建一遍:hexo clean && hexo generate 退出码 0、263 个文件;public/index.html 里真实文章卡片 9 个(.recent-post-item 一共 11 个,另外 2 个是轮播和分类磁贴的整行容器,不算),出现 /page/6/,全站找不到 card-recent-post 这个 DOM。
第二件:置顶换了个写法。
这事有点好笑。上一轮为了解决「新文章排在第 13 位、首页根本看不见」,我把 12 篇文章的置顶全删了,让首页恢复纯时间倒序。结果现在又需要置顶了 —— 但这次我不想再往 Front-Matter 里塞 sticky: 数字:那玩意儿肉眼看不见,一篇文章到底有没有被置顶,你得点开文件才知道。
所以现在可以在正文里任意位置写一行 HTML 注释:
1 | <!-- sticky --> |
- 标记会在渲染时被自动删掉,不会漏进正文、摘要、RSS 和搜索结果;
- Front-Matter 里的
sticky优先,正文标记只在没写的时候生效; - 排序还是
node_modules/hexo-generator-index/lib/generator.js:10那一行sort((a, b) => (b.sticky || 0) - (a.sticky || 0)),不用装任何置顶插件。
实现是新增的 scripts/sticky-post.js(2753 字节),注册在 after_post_render 上、priority = 5。这个数字是故意的:Hexo 的 excerpt 过滤器用的是默认 priority 10,所以这个小脚本一定先跑,标记会在任何摘要切分之前就被剥掉 —— 否则摘要的头一行就是一句 HTML 注释,很煞风景。剥的时候四个地方都过一遍(data.raw、content、excerpt、more),顺手把可能留下的空段落(<p></p>)清掉。
首页卡片的左上角现在会挂一个置顶角标:themes/fomalhaut/layout/includes/mixins/post-ui.pug:143 的 postStickyBadge 混入负责渲染,只有首页才画(is_home() && article.sticky > 0),sticky >= 2 会多一个 is-top 类,颜色更深更红。样式在 themes/fomalhaut/source/css/_custom/custom.css:3519-3604:26px 的胶囊、橙红渐变底、13px 的图标,另加一道斜向高光每 3.4 秒划过一次 —— 没什么技术原因,就是让人第一眼能看见。暗色模式下阴影加深一点,prefers-reduced-motion 下高光动画直接不显示。
有个细节记一下:角标只能贴在卡片内部的左上角。因为 .recent-post-item 本身是 overflow: hidden,想做成越界的小缎带会被直接裁掉。
现在有两篇用了这个标记:这篇,和那篇《Markdown 语法与外挂标签写法汇总》。
二十三、美化面板里那面错排的图墙,和那个不像控件的取色框
美化面板(右下角那个糖果按钮)里「四、背景设置」有一面图墙,壁纸一张挨一张,点一下就换。这面墙在窄屏下一直是错排的。
问题长这样: 一行只放得下一张图,右边留一大片空白;而在 579px 这种「一行刚好能放两三张」的宽度下,最后一行只剩两张时会被 justify-content: space-between 推到最左和最右,中间空出一大块。
原因在两条 CSS 上:
themes/fomalhaut/source/css/_custom/custom.css:1114:.bgbox { display: flex; flex-wrap: wrap; justify-content: space-between; }custom.css:1120:.pimgbox, .imgbox, .box { height: 100px; width: 166px; margin: 10px; }(≤768px 时宽度改成 135px、高度 73px)
固定宽度 + space-between:一行装不下两张就只能放一张;末行不满时两端对齐,中间必然出现空洞。
改法就是换成 grid。 从「一行能塞几张算几张」改成「列宽固定、从左往右排满一行再换行」:
1 | /* 桌面 */ |
auto-fill + minmax() 的意思是「按容器宽度自己算能放几列」,所以不会再出现手写的宽度和缝隙对不上。实测(真实构建出来的页面,注入探针量的):
| 视口宽度 | 改之前 | 改之后 |
|---|---|---|
| 320 | 8 行 × 1 张(135×73) | 4 行 × 2 张(114×62) |
| 390 | 8 行 × 1 张 | 4 行 × 2 张(147×80) |
| 414 | 2 张/行,行内空隙 37px | 2 张/行(159×86),间距 10 |
| 579 | 3 张/行,末行两端对齐空 194px | 2 行 × 4 张(114×61) |
| 768 | 4 张/行,行内空隙 35px | 5 张/行(125×67),末行左对齐 |
| 1280 | 3 张/行,末行空 350px | 空洞消失(列宽自适应) |
第二个是那个取色框。 「四、背景设置 → 5. 纯色」里的取色框一直是浏览器原生外观:比旁边的色块高 10px(110 对 100)、方角、多一圈 1px 边框,一眼就能看出「这不是同一个世界的控件」。改法是:
appearance: none去掉原生外观,尺寸对齐同级色块(宽 100%、高 100px、圆角 10px);- 底下垫一层彩虹渐变
conic-gradient(...),再把原生的色块用::-webkit-color-swatch { opacity: 0 }藏掉; - 「自定义颜色」四个字从写死的
left/top改成水平居中(left: 0; right: 0; top: 50%; transform: translateY(-50%))。
点击还是弹系统取色器,只是外观换成了自己的。手机端再补一条 .bgbox > #define_colors { height: auto; aspect-ratio: 135 / 73 },跟同级色块一样高。
最后一条教训:_custom/ 下的文件不吃 hexo generate。 主题把 source/css/_custom/custom.css 当私有文件(下划线开头),改完必须 hexo clean && hexo generate 才会重新编译。我第一次改完只跑了 generate,浏览器里死活没变化,一度以为是缓存。
二十四、欢迎信息的空蓝框、页脚的第四年、手机上的滚动条
欢迎信息先占个位。 侧栏那张欢迎卡片要等腾讯地图的接口回来才知道你在哪,接口慢的时候它就是一个空蓝框。现在:
- 脚本一执行就先写一行「欢迎信息正在加载中…」进去;
showWelcome()开头加了守卫:没拿到ipLoacation.result.location就先显示加载提示然后 return(顺手修掉了控制台里那个未捕获的 TypeError);pjax:complete之后再挂一次,页面切换回来也有占位;- 十秒还没等到数据,就换成「欢迎信息加载失败,请刷新重试🥺」。
实测:0.6 / 1.6 / 5 秒都停在「加载中」,11.5 秒变成失败提示;接口正常时 0.6 秒就换成真正的欢迎语。
页脚那个运行时长开始算「年」了。 以前显示「本站居然运行了 1514 天 17 小时 21 分 05 秒」—— 一千多天到底是几年,得自己算。现在按满周年计算:
- 建站时间 2022-08-09,到 2026-08-09 记 4 年,剩下的天数从周年日重新数;
- 具体是
anniv = new Date(grt)、years = now.getFullYear() - grt.getFullYear(),如果周年日还没过就把years--并重算anniv,再dnum = Math.floor(rest / 86400000); - 秒从
Math.round换成了Math.floor—— 原来的写法在最后一秒会算出「60 秒」,看着挺怪的。
现在显示的是「本站居然运行了 4 年 53 天 17 小时 21 分 05 秒」。
手机端补了一条自绘滚动条。 上一轮把电脑端的滚动条改成主题色斜条纹之后,手机端一直没跟上 —— 因为手机浏览器(以及 Chromium 的 overlay 滚动条模式)根本不认 ::-webkit-scrollbar,只用系统滚动条,那条规则在手机上等于没写。
所以 ≤768px 干脆把系统滚动条藏起来(scrollbar-width: none + html::-webkit-scrollbar { width: 0; height: 0 }),用一个自己画的替代:custom.css:69-116 里的 #mscrollbar / #mscrollbar-thumb,由 source/js/fomal.js:2740-2798 驱动 ——
- 固定在右边缘,宽 8px,滑块高度按
vh * vh / total算、最小 30px; - 随滚动进度
translateY移动,滚动时淡入,停下来 0.9 秒后淡出(HIDE_DELAY = 900); - 条纹和颜色跟电脑端那条一样(
var(--theme-color)+ 45° 的 9px/18px 重复渐变); - 监听
scroll(passive: true)、resize、DOMContentLoaded、pjax:complete,换页之后它还在。
桌面端一行都没动 —— 窗口超过 768px 时自绘条不会加 mscrollbar-on 类,也就不会出现两条滚动条。
二十五、节日那天,除了卡片还放了一场礼炮
卡片是「告诉你今天是什么日子」,礼炮是「替今天高兴一下」。 节日提醒从居中弹窗改成左上角卡片之后,喜庆的那几天还是有点安静,于是又加了一层:命中「喜庆节日」时,卡片弹出的同时整屏放一遍烟花。
规则写得很克制:
- 节日表
source/js/festival.js里只有celebrate: true的条目才放,一共 16 个 —— 元旦、情人节、劳动节、520 情人节、儿童节、建党节、建军节、国庆节、平安夜、圣诞节、站长生日,加上春节、元宵、端午、七夕、中秋这五个农历节日;表里一共 29 条,喜庆的占一半多一点。 - 平常日子一个字节都不下载:
festival.js:376-395的loadFx()命中时才document.createElement('script')插入/js/celebrate.js(async,onload之后才调fomalCelebrate({ colors: f.colors })),fxLoading防止重复插入;新脚本 6946 字节。 source/js/celebrate.js的参数都写在文件开头:8 发礼炮(ROCKETS = 8)、每发 104 个粒子(SPLASH = 104)、整段动画硬上限 7600ms(TOTAL = 7600),到点自己收工并把 canvas 从 DOM 里摘掉。- canvas 铺满全屏、
pointer-events: none、z-index: 9998—— 比通知卡片的 9999 低一层,所以卡片上的字不会被烟花糊住,也不会挡住任何点击。 - 像素比最高按 2 渲染、粒子上限约 500(低端机不卡);系统开了「减少动态效果」(
prefers-reduced-motion: reduce)时直接不播放。
顺带按要求下线了「小猫咪生日」那条提醒 —— 生日从此只有站长一个。
二十六、把「标签 / 分类 / 文章统计」并成一张「文章统计」
理由很朴素:三张页面说的是同一件事(本站写了些什么、各标签各分类各有多少篇),却分在「文章」和「网站」两个下拉里,点进去三张图长得还差不多。
合并的方式不是删页面,而是给主题加了一个通用跳转页:
source/tags/index.md的type从tags改成echarts、title改成「文章统计」,它成为唯一的落点(/tags/);source/categories/index.md与source/site/echarts/index.md只留 front-matter:type: redirect+redirect_to: /tags/+comments: false+sitemap: false(不让搜索引擎记两条重复地址);- 新增
themes/fomalhaut/layout/includes/page/redirect.pug(488 字节):meta http-equiv="refresh"加location.replace()双保险,下面留一行「该页面已合并到「文章统计」,正在自动跳转……」和手动链接(万一脚本被挡); themes/fomalhaut/layout/page.pug:8-18的case page.type里补一个when 'redirect'分支,以后任何页面想搬家,只需要写两行 front-matter;includes/page/echarts.pug在原三张图后面补上「标签云」和「分类列表」两块(复用原标签页、分类页的正文),统计页自己就是完整的一页;_config.fomalhaut.yml里「文章」下拉删掉「标签」「分类」,换成一条「文章统计: /tags/ || icon-shujutongji1」;「网站」下拉里的「文章统计: /site/echarts/」删掉。
顺手把归档页顶部那张「文章发布统计」图删了。 归档页的主角是时间线,页顶再压一张柱状图很挤;themes/fomalhaut/layout/archive.pug 从 510 字节缩到 310 字节(删掉 echarts.min.js 的引入、#posts-echart 容器和 posts_echarts(...) 调用),但 posts_echarts 这个 helper 没删 —— /tags/ 那页还在用它。
二十七、三张统计图的标题挪到图表外面
同一批图还有个小问题:标题是 ECharts 自己画的(title 选项),于是图例、标题、图形挤在同一块 canvas 里,窄屏下标题容易被图例压住。
改法是让助手函数支持一个新参数:
themes/fomalhaut/scripts/helpers/hexo_echarts.js三个 helper(posts_echarts、tag_ranking_bar、categories_pic)里各加一行const showTitle = options.titlePosition !== 'outside',把title.show接到这个变量上;includes/page/echarts.pug里三张图各配一个<center><font size="4" style="font-weight:bold">标题</font></center>,调用时传titlePosition: 'outside';- 不传这个参数的老调用行为完全不变。
二十八、菜单动手术:二级菜单居中、四个入口下线
二级菜单居中。 主题原来的做法是按栏目写死偏移 —— custom.css 里躺着 6 条 nth-child(2)~(7) .menus_item_child { left: -149px ~ -105px },每个栏目一条,宽度一变就得重算。「标签/分类」并成「文章统计」之后子菜单变窄,一眼就看得出对不齐。现在只留一条规则:
1 | #nav .menus_items .menus_item .menus_item_child { left: 50%; right: auto; transform: translateX(-50%); } |
坑在动画上:主题 _global/function.styl 里的 @keyframes sub_menus 只做 translateY,而 head.styl:304 用的是 animation: sub_menus .3s .1s ease both —— both 会把动画结束态一直保持着,于是静态的 translateX(-50%) 被覆盖,菜单又跑偏。所以我在 custom.css 里同名覆盖了一份 @keyframes sub_menus,把 -50% 一起写进关键帧(translate(-50%, 10px) → translate(-50%, 0)),进场动画和下落的观感都还在。实测二级菜单中心与父项盒中心差 −4px(父项左侧 8px 内边距造成,肉眼看不出来);宽度是自适应的 —— 父项从 186px 变到 254px 时子菜单从 623px 跟到 691px,中心始终是 1060.5px。
四个入口下线。 这次删掉的是「游戏」「动画」两个菜单项,以及整个「个人」父项(「唠叨」「恋爱小屋」),「关于」挪到「网站」栏。删的是菜单入口而不是页面:source/personal/bb/、source/personal/love/ 两个目录删了,但 /life/games/、/box/animation/ 这些页面文件都还在,直链打开依然正常 —— 有些老文章的正文里还链着它们。
「关于」就更有意思了:页面文件一直在 source/personal/about/ 下,如果为了让 URL 好看而搬去 /about/,页脚、右键菜单和好几篇旧文章里的链接就全断了,所以路径没动,只是把它挂进了「网站」下拉。改完 hexo clean && hexo generate 是 262 files in 3.19s。
二十九、列表页的翻页也换成首页那种方块
首页的翻页是圆角小方块卡片,归档页和标签/分类的列表页却还是主题默认的「细边线数字」,两处风格对不上。这次没动主题源码,而是把一段样式注进了 _config.fomalhaut.yml 的 inject.head(<style id="listPagination">):
- 选择器写成
.layout > div:not(.recent-posts) .pagination > *,正好放过首页(首页的.recent-posts已经在主题里排好了),只管归档、标签、分类这些列表页; - 每个页码
display: inline-block、width / height / line-height: 2.5em、margin: 0 6px,圆角 8px,底色var(--card-bg)、阴影var(--card-box-shadow),悬停换成var(--btn-hover-color); - 第一版被说「字太扁」,于是提高选择器优先级、把行高写死成 2.5em,第二版才对齐。
三十、打赏二维码:换源、居中、缩小
三件事一起做:
- 换源:
_config.fomalhaut.yml(第 476、479 行)与关于页source/personal/about/index.md里两个收款码都换成https://sourcebucket.s3.bitiful.net/img/vx_code.webp和…/zhifubao_code.webp(关于页那个微信 tab 的缩放从 60% 调到 50%); - 弹窗居中:
reward.styl里.reward-button加position: relative,.reward-main从left: -55%; width: 150%改成left: 50%; translate: -50% 0; width: max-content; - 缩小:二维码高度 280px → 140px,整张卡片约 328×197 CSS px。
这里踩了两个纯 CSS 的坑。第一个:用 transform: translateX(-50%) 居中不行 —— 主题给弹窗挂了 animation: donate_effcet .3s .1s ease both,那个关键帧只做 translateY,fill-mode: both 会把横向位移一起吃掉。第二个:改用 left: 0; right: 0; margin: auto 加 width: max-content 也不行 —— 绝对定位下 max-content 会按 width: auto 解析,auto 外边距算出来是 0。最后用的是独立属性 translate:它和 transform 是两条不同的属性(合成顺序是 translate → rotate → scale → transform),动画动它的 transform,居中动它的 translate,互不干扰。实测弹窗中心与按钮中心偏差 0 设备像素。
三十一、美化面板的初始尺寸不再靠猜
侧栏那个「美化」面板以前的初始尺寸是 resize(窗口宽 × 0.6, 70%) —— 于是窗口越宽它越宽:1920 宽的屏上弹出 1152×700 的面板,右边一大片空白,第一节还没显示完。
现在改成按内容量(source/js/fomal.js 新增 winboxNeedSize()):
- 宽度 = 首按钮左缩进 + 前 5 个
.swf按钮的宽度与外边距 − 末位右边距 + 容器左右内边距 + 面板装饰宽 + 10px 余量。为什么是 5 个?因为「字体设置」第一行正好是 5 个按钮,能排下不折行,宽度就够了; - 高度 = 第一节最后一行(
#asidePosSet所在那一行)的底边 − 面板顶 + 下内边距 + 6px; - 上下限 620×420,再用视口的 95% × 92% 夹一道;量不到(元素还没渲染)就回退到 780×540。
配套改了 winResize():桌面分支做两趟量 —— 先把宽度定下来,再量一次高度(宽度一变,行是否折行才确定,高度才是最终值),最后 move('center', 'center');toggleWinbox() 里 createWinbox() 之后补一次 winResize()(createWinbox 是先 resize 再写 innerHTML 的,写完内容尺寸才准)。
实测:1423×736 下从 854×515 变成 802×534;1920×1000 下从 1152×700 变成 802×534 —— 不再随窗口变宽;800×744 下是 752×681(被上限夹住)。手机端分支(≤768px,95% 宽 / 90% 高)一行没动。
三十二、给 139 个友链做了一次体检:81 个搬进「失联友链」
友链页越攒越多,「茫茫人海」那一组里有 118 个站点,早就不知道哪些还活着。
这次做了一件笨但有用的事:把 0~3 这四个分类里记录在册的 139 个在线友链全部探测一遍,判定顺序是 HTTPS 请求(跟随跳转)→ HTTP 回退 → 直连 TLS → 公共 DNS(DoH)复核 → 首轮失败的再重试一次。结论是 81 个已经打不开,全部搬进「4.失联友链🎈」,那一组从 10 条变成 95 条:
| 分类 | 改前 | 改后 | 移出 |
|---|---|---|---|
| 0.推荐网站🍔 | 4 | 1 | 3 |
| 1.技术支持🍟 | 13 | 9 | 4 |
| 2.宝藏网站🍉 | 30 | 16 | 14 |
| 3.茫茫人海🍗 | 118 | 54 | 64 |
| 4.失联友链🎈 | 10 | 95 | +81 |
失联的原因也分了类:55 个是域名解析都没了(NXDOMAIN,用公共 DNS 复核结果一致),其余是域名被抢注 / 劫持成垃圾站、TLS 证书过期或域名不匹配、连接直接超时。过程留在四个文件里:probe-pass1.json(首轮 200 判定与页面标题)、probe-dns-doh.json(53 个 DNS 死站)、probe-nondns-pass2.json(超时与 TLS 类的二次复核,比如「海底捞里没有鱼」是 UND_ERR_CONNECT_TIMEOUT、「鹤川博客」是 ERR_TLS_CERT_ALTNAME_INVALID)、move-plan.json(每一条的搬迁原因:dns 还是 tls:…)。
搬家的写法很克制:只加两行 —— theme_color: "#ebebeb" 和 offline: true,原来的头像、简介、截图都留着 —— 因为站是站长的,不该因为一年打不开就替人删掉,归到最后一组、头像变成灰度,等站长修好了再搬回来。改动只落在 source/_data/link.yml,主题源码一行没碰。
页脚也跟着换血。 页脚那 8 个位置里,有 4 个(Ethan.Tzy / 小染Blog / 初柒工坊云 / Leonus)都在这份失联名单里,索性换成了「广告位招租」的占位图(ad.webp),保留的 4 个是 Fomalhaut🥝 / Butterfly / Akilarの糖果屋 / 安知鱼 —— 8 格的排版没变,只是不再挂打不开的链接。
三十三、侧栏多了日历和倒计时两张卡
侧栏原来有一张「通讯录」卡,下面是公告栏。这次在公告栏上面插了两张自绘卡片(themes/fomalhaut/layout/includes/widget/index.pug 里把 card_top_self 从公告栏下方挪到上方,文章页和非文章页两个分支都改了):
- 日历卡:「日历加载中…」占位之后换成「第 39 周 周四 / 01 / 2026 年 10 月 第 274 天 / 丙午马年 八月廿一」,下面跟一张当月迷你月历(今天高亮,表头是「日一二三四五六」);
- 倒计时卡:「距离春节 / 128 / 2027-02-06」,再加本年、本月、本周三条进度条(含「还剩 N 天」)。
两张卡都是纯前端算的:source/js/aside-calendar.js(13795 字节)加 source/css/aside-calendar.css(6370 字节),卡片定义挂在 source/_data/widget.yml 里,每 60 秒重算一次 —— 跨零点会自动换日、换月历。周数按 ISO 口径(当年第一个周四所在的那一周算第 1 周),农历那行走 lunar.js 的 solar2lunar() 和 calendarFormatter.lunar2solar(),哪天农历库没加载进来,也只是少一行字,不会整张卡报错。
这里有个我记了很久的坑:不能拿 innerHTML 直接覆盖卡片容器。 widget.yml 里的 HTML 是这样写的:
1 | <div class="cal-skeleton">日历加载中…</div> |
innerHTML = ... 一执行,<link> 就从 DOM 里被摘掉了,样式表随之失效 —— 之前怎么改都不生效,就是栽在这儿。所以脚本里用 mount() 只替换挂载节点(优先 .cal-skeleton,其次 [data-cal-mount],都没有才追加一个新盒子),容器本身永远不动。调试时也能直接调:fomalAsideCalendar.compute() / .setNow(new Date(2026, 9, 1, 21, 0, 0)) / .renderAll();source/cal-test/index.html 是留给它的预览页。
另外按要求把侧栏的「通讯录」(友链)卡隐藏了:_config.fomalhaut.yml 里 aside.card_friend_link.enable: false。要注意 hexo server 只在启动时读主题配置,运行期改了不会重新渲染,所以 aside-calendar.css 末尾补了一条兜底 #aside-content .card-friend-link { display: none } —— 重启之后卡片本身不再输出,这条规则就自动变成空转。
三十四、202 张图,一个文件
画廊的「一些好看的图片」这个页面,之前是用很蠢的办法分页的:photo/ 目录下 p2.md 到 p17.md 一共 17 个文件,每个文件里手写 12 张图、末尾再手写「上一页 / 下一页」的链接;壁纸页也一样,风景、萌宠、手机三个入口各是一个文件。加一张图要翻到对应那一页,改页码要连着改好几个文件。
现在改成:一个相册只有一个 markdown 文件,图片按顺序一行一张往下贴,分页交给构建期生成。front-matter 里写一行 gallery_per_page: 12 就够了,hexo generate 的时候 scripts/gallery-pager.js 把这个页面按 12 张一页切开,生成 index.html、p2.html、p3.html……;翻页条直接用主题自带的 paginator helper(prev_text / next_text / mid_size 与首页列表页逐字一致),不手写一个 <a>。
几个细节值得记一笔:
- 旧链接不 404。
p2.html~p17.html这些路径名不变,只是内容从「你手写的那一页」变成「按顺序数的第 N 页」,站外引用的老链接照样能打开。 - 想按主题分段,加一行注释就行。 在交界处写
<!-- pagebreak -->,生成器就地断页。 scripts/目录不被 hexo server 监听。 新增或修改gallery-pager.js之后必须重启 hexo server,否则页面会退化成「所有图片挤在一页、没有翻页条」—— 不报错、不空白,只是静悄悄没生效。
photo 现在是 202 张图一个文件、17 页;壁纸 54 张、5 页。
三十五、画廊大厅的两张卡片居中
画廊首页就两张卡片(封面专区 / 背景专区),一直靠在左边,右边空一大块。查下来靠左不是配置项,而是主题样式写死的:
1 | #article-container figure.gallery-group { float: left; width: calc(50% - 8px) } |
float: left 把两张卡片按 50% 宽度往左推,行尾自然空一半。主题源码能改,但主题一升级就丢,所以放在 _custom/custom.css 里覆盖:容器改成 flex + justify-content: center,卡片 float: none,宽度顺手从 50% 收到 calc(33% - 11px),窄屏再退回两列。
三十六、「休闲」栏目下线
三个要求是一起做的:删掉休闲下的影院子页面、把休闲里的「八音盒」改名「影音」并挪到八宝箱第一个、然后整个休闲栏目拿掉。
改动落在 _config.fomalhaut.yml 的菜单表里:删掉「休闲」父项与「影院」子项;「八音盒」改名「影音」、/life/music/ 路径不动、整体上移到八宝箱首位;source/life/music/index.md 的 title 跟着改;source/life/movies/ 整目录删除(原来那页 193 行)。改完还要手工清一次 public/life/movies/ —— 源目录删了,产物还在。
三十七、归档页不要侧栏,宽屏页面收窄
两件跟宽度有关的事。
一是归档页的右侧栏:_config.fomalhaut.yml 第 947 行 aside: archive: false,归档页不再输出侧栏,正文占满。归档页本来就是按年翻列表,侧栏那些卡片基本用不上。
二是给非首页的独立页面加了个宽度上限:
1 | <style id="pageWidthLimit">.layout:has(#page),.layout:has(#archive),.layout:has(#tag),.layout:has(#category){max-width:1200px}</style> |
:has() 正好能表达「页面里含 #page(独立页)/ #archive / #tag / #category 时收窄到 1200px」,首页和文章页不受影响;窄屏下 max-width 本来就不起作用,自动铺满。之前 27 寸屏上这些页面能拉到 1900px 以上,一行字 200 多字符宽,看着很累。
三十八、友链卡片的写死高度
友链页的卡片高度原来是写死的 —— 写死那会儿整页宽度是 1400px,后来页面收窄到 1200px,卡片变窄而高度不变,于是每张卡片都显得又高又瘦、图片上下留白。
现在把高度交给图片自己撑:.site-card:not(.mini-link) { height: auto !important } + .site-card .img { height: auto !important; aspect-ratio: 16 / 10 }。16:10 是原来那批截图的固有比例,宽度怎么变高度自动跟着走,一行 5 / 4 / 3 / 2 张四个断点都不用再调数字。顺手把悬停缩放也定了:.site-card:hover .img { scale: 1.02 !important }。
三十九、美化面板多了「三、字体设置」
美化面板加了一节「三、字体设置」,分两组:常规字体 7 款(霞鹜文楷 Screen / 霞鹜文楷 / 霞鹜新晰黑 / 思源宋体 / 筑紫 A 丸 2.0 / 汉仪唐美人 / 甜甜圈海报)加一个「系统默认」,代码块字体 4 款(Monaco / Consolas / Maple Mono / Hack)。
最值得说的是为什么代码字体要单独立一个变量:主题只给 #article-container pre/code 设了等宽字体,文章区之外的行内 code(侧栏、卡片、通知里的那些)根本不受影响。所以新加了 --code-font,并在 custom.css 里补一条覆盖面更广的规则:
1 | pre, code, kbd, samp { font-family: var(--code-font, Monaco_1), var(--global-font), "Microsoft YaHei", … } |
面板里点一下,JS 只做 document.documentElement.style.setProperty('--code-font', name),全站代码字体当场切换,选择记在 localStorage。
另一个坑是系统默认字体在 Windows 上其实是「四个字都不同」:原来那档的字体栈只写了 -apple-system,macOS 认,Windows 不认,于是 font-family: var(--global-font) 的元素(左上角站点名、首页大标题、副标题、作者名)直接掉到浏览器默认字体上 —— 我把 Chrome 的标准字体设成霞鹜文楷,那几个标题就变成楷体了。现在补成完整系统栈(-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Lato, Roboto, 'PingFang SC', 'Microsoft JhengHei', 'Microsoft YaHei', sans-serif),顺手把混进正文栈的 Consolas 拿掉。
字体名、@font-face、面板按钮 id(swf_ / swfc_ 加字体名)三处是一一对应的,改名要一起改。这次重命名了三个(汉仪唐美人、甜甜圈海报、筑紫 A 丸 2.0),下架了两个(MiSans、优设好身体);存量访客 localStorage 里的旧值会在打开面板时被白名单清掉,回落到默认。
四十、摸鱼牌开始认法定节假日
页脚那块「摸鱼牌」,以前按周末判断,现在按国务院的放假通知判断:休息日显示「放假啦,光明正大地摸鱼~」,工作日显示「上班偷偷摸鱼,别被发现~」。
数据用的是 holiday-cn 那份(只记录法定放假日与调休上班日,其余按周末算):2024~2026 直接内置在 fomal.js 的 HOLIDAY_TABLE 里,离线也能算对;2027 年以后自动从 jsDelivr 拉一份当年的 JSON,按年缓存在 localStorage;拉不到就退回「周末即休息日」,不让页脚因为一个接口挂掉。
副作用是调休终于正常了:假期前那个调休上班的周末会显示上班,假期里的周末显示放假 —— 之前那种「今天是周六所以显示放假,实际却要去上班」的错位没有了。
四十一、侧栏日历:农历、节气、日出日落
侧栏那两张卡这两天又长了一圈。
日历卡除了月份和当天,现在还有:ISO 周数(第 39 周)、农历日期(丙午马年 八月廿一)、当月迷你月历(今天高亮),以及两条小字 —— 下一个节气(「N 天后是霜降」,当天就显示「今天就是霜降」)和当天的日出日落时刻(☀️06:32 / 🌙18:07)。
日出日落是按访客位置现算的:优先用定位拿到的经纬度,拿不到就按访客时区推一个经度,再兜底到站长的纬度,用一套标准天文公式(儒略日 + 太阳赤纬 + 时角)算,两极那种算不出来的情况直接不显示。
倒计时卡加了本周 / 本月 / 本年的进度条与剩余天数,目标(现在是春节)支持按农历或公历指定。
还有一个默认行为要说明:只要访客没手动切过深浅色,这两张卡就会按真实日出日落自动切日夜模式;访客自己点过暗黑模式按钮之后就再也不会动它 —— 尊重手动选择优先。
实现上踩的坑也记一下:渲染结果必须塞进卡片里的一个挂载节点,不能 innerHTML 整个卡片。因为 widget.yml 里的 html 带着 <link rel="stylesheet"> 和 <script>,它们都是 .item-content 的子节点,一旦被覆盖掉,<link> 从 DOM 里移除、样式表按规范失效 —— 症状就是「CSS 完全不生效」。
四十二、天文馆:旅行者号现在飞到了哪里
八宝箱新增一个「天文馆」,里面是一张全屏的交互式太阳系星图:八颗行星的实时相对位置,加上两艘已经飞出日球层的探测器。鼠标移到任意天体上看数据,拖下方的时间条能看它们 80 多年的运动,切到「立体」还能把整个太阳系转起来。
页面是独立的 voyager.html(自带样式和脚本,在 _config.yml 里 skip_render 掉),用 iframe 嵌进文章页。两个小机关:
- iframe 自己长高。 图表高度是动态的,子页面用
postMessage发voyager-height、父页面收到就改iframe.style.height;反过来父页面在 DOMContentLoaded、load、以及 1.5 秒后各发一次voyager-ping主动问一次 —— 只做一边,慢网络下就会留一截滚动条。 - 配色跟着站点主题走。 底色不是写死的,而是
color-mix(in srgb, var(--theme-color) 5%, #0d0d0d)从站点主题色派生 —— 换主题色,这一页的整体色调跟着变。
页面上「发射时 470 W / 每年衰减约 4 W」、旅行者 1 号 3.567 AU/年、2 号 3.246 AU/年,以及「40.5 AU 外回头拍的暗淡蓝点」,都是算距离用的常量,不是随手写的文案。
四十三、给博客安了一个 AI 页面助手
右侧按钮列多了一个小鲸鱼,点开是「页面助手」:能读当前这一页的正文,回答跟这一页有关的问题;问「这个页面讲了什么」,它会先一句话概括、再列 3-5 条要点。
调用的是 DeepSeek 的接口,流式返回,字是边生成边出来的。几个设计上的取舍:
- Key 不进仓库。 配置里
api_key留空,改读站点根目录的.ai-chat-key(已加 .gitignore)。 - 上下文按页分桶。 对话历史存在 localStorage,但键里带页面路径 —— 文章页聊的和关于页聊的互不干扰,各自记 12 轮;本页正文最多 32000 字进上下文(超长文章取开头 60% + 结尾 35%,中间省略的部分只把小节标题给模型,让它直说「这段我没看到」而不是猜),面板底部实时显示「上下文 ≈ N 字 · 本页 N 字(全文 N,取了开头和结尾) + 对话 N 条」。
- 面板可以拖着改大小。 右下角手柄缩放,尺寸记在 localStorage,不满意一键复位;手机上自动收成窄面板。
- 超时保护。 「首字迟迟不来」和「中途卡住不再吐字」都算超时(每收到一段就重新计时),提示「N 秒没收到回复,稍后重试」;接口报错也有对应的中文提示,不把原始错误甩给访客。
- 不依赖任何前端库。 按钮、面板、样式全部运行时创建,不用 jQuery、不用 Vue —— 上次刚把 905KB 的 Element-UI 请出去,没必要为了一个聊天框再请回来。
四十四、又是两个「静默失效」的 bug
这轮又逮到两个不报错、但确实已经坏了的功能。
一是首页「小冰磁贴」点进去跳到外站。 首页顶部那一排短链接(🍡 小Fの算法学习笔记 等)和末尾的「查看更多…」,点下去跳的是 www.fomal.cc,也就是生产站自己 —— 本地预览时一跳就出去了。原因是插件 hexo-magnet-fomal 拼 HTML 时把 config.url 硬拼在 href 前面,而它没有任何「链接前缀」配置项;直接改 node_modules 又会在下次装依赖时丢掉。最后在站点根 scripts/ 里加了个补丁:用 after_render:html 过滤器,只把 class="magnet_link" / "magnet_link_more" 的链接就地改成站内相对路径 —— canonical、og:url、sitemap、分享链接这些该绝对的地方一律不碰。
二是首页轮播「有时候图片出不来」。 具体是站内跳转回首页(pjax 往返)之后,轮播绕回那一下图片区域是空的,直接刷新首页反而正常 —— 所以是「有时候」。链条捋下来是三方时序问题:主题开了懒加载,图片 src 被换成占位图、真图放在 data-lazy-src,靠 main.js 里一个 LazyLoad 实例在登记的时候填回去;pjax 完成时主题先重新执行一遍轮播脚本(新建 Swiper),紧接着同步调了一次 lazyLoadInstance.update() —— 而 Swiper 的循环克隆 swiper-slide-duplicate 是在构造之后才插进 DOM 的,克隆永远晚于那次 update,于是循环到克隆位就没图。修法是在页面加载完成、pjax 完成、以及 MutationObserver 观察到轮播 DOM 变化之后,各补一次 update()。刷新不复现的原因也清楚了:正常加载时 defer 脚本先跑,LazyLoad 构造时克隆已经在 DOM 里。
四十五、APP 下载链接换源,右键模式按钮有了状态
两件小事收尾。
公告栏里「APP 下载」的链接原来指向缤纷云的对象存储,换成了 unpkg 上的 fomalhaut1998_picgodemo@latest/Fomalhaut.apk —— 应用包跟着 npm 包走版本,两边不用手动对齐,顺手把链接两侧的 🍧 去掉了。
侧栏「右键模式」按钮以前点一下会弹通知告诉你切成了哪种模式,但按钮本身看不出当前状态。现在给它加了状态显示:开启时跟随主题色,关闭时变成灰色底加一条 45° 斜杠,title 也会写清楚当前是站内右键还是系统右键。状态源和 fomal.js 里那份 localStorage 是同一个键,包装了 changeMouseMode() 在切换时同步刷新,pjax 换页后会重新应用一次。
四十六、统计页:三张图不再用别人的蓝色
网站统计页(/site/census/)是拼起来的:上方 51LA 的实时访客与总访问量、中间 GitHub 贡献日历、下面百度统计的三张图(访客地域分布 / 访问趋势 / 来源构成)。这一轮把这一页整个翻新了一遍,起因是两个毛病。
一是样式被当成了页面摘要。 原来整页的样式全写在这个 markdown 文件里,而 Hexo 会把页面开头那一段当摘要塞进 <meta name="description"> —— 搜索引擎和分享卡片拿到的是一坨 CSS。现在样式整段挪进 source/css/census.css(383 行),页面只留结构:外层 div.census,每个板块一个 section.census-sec,标题用 .census-sec__head / __icon / __text / __title 拼出来。
二是三张图用的是别人的蓝色。 地图主色是写死的 'rgba(128,255,165)'、趋势线 'rgba(1,191,236)'、无数据省份底色 'rgb(230,232,234)' —— 美化面板换主题色,这三张图一动不动。现在加了 censusRgbToHsl()(读 --theme-color,支持 rgb() 和 #hex 两种写法)配 censusHsl(h,s,l):把主题色转到 HSL,钳住饱和度(30–92)和明度(30–78),再生成一串同色相、不同深浅的色阶,地图四档(主色 / 浅色调低值 / 深色调悬浮 / 无数据省份底色)都从这串色里取。深色模式另走一套判断(censusIsDark() 看根元素的 data-theme)。
census.js 从 449 行长到 579 行,多出来的基本都是配色与两套主题的分支。
四十七、/tags/ 的标题变成了胶囊,图表跟着主题色走
文章统计页(/tags/)那三张 echarts 是主题的 helper 生成的:标题直接输出成 <center><font size="4">标题📃</font></center>,配色写死在主题脚本里(themes/fomalhaut/scripts/helpers/hexo_echarts.js 里的 #425aef)。
改的原则还是不动主题:新增 source/css/stats.css(269 行)与 source/js/stats.js(430 行)。样式全部挂在 #page:has(#posts-echart) 底下 —— 整个站只有这一页有那个 id,所以规则不会漏到别的页面去;浏览器不支持 :has() 时整块失配、页面退回原样,属于渐进增强而不是渐进破坏。
JS 做两件事:把 <center> 那个标题拆成「图标胶囊 + 标题 + 说明」,让排版和站内其他页面统一;再取主题内联脚本里暴露的全局 postsChart / tagsChart / categoriesChart,按 --theme-color 重新 setOption() —— canvas 背景置空(让卡片自己的底色透出来)、网格线和图例跟明暗模式走。触发时机三个:首屏、pjax:complete、以及主题色或明暗变化时。页面上找不到 #posts-echart 就直接返回,不做任何事。
四十八、朋友圈:59 行变 528 行,只为一面卡墙
朋友圈页原本是主题自带的列表样式,这次换成了网格卡片(内部叫「方案 A · 网格卡」):59 行 → 528 行。
规矩和上一轮一致:全部落在页内内联的 <style> / <script> 里,主题源码一行没改。配色不用写死的颜色,一律 color-mix(in srgb, var(--theme-color) X%, transparent) 从主题色派生,也刻意不覆盖朋友圈引擎自己的 --heo-* 变量 —— 引擎升级时这套样式不至于把它的布局打散。
两个细节是不得不动 JS 的:圆形头像在引擎输出里带着一圈「灰色残缺弧」(图片本身被裁过),用 clip-path 裁成圆把残边压掉;引擎输出里没有「来源域名」这个字段,所以底部补了一小段脚本注入「域名 + favicon」角标,顺手把顶部那几个统计数字填上。纯 CSS 拿不到的东西,才允许动用脚本。
三个候选方案都留了对比页(bak/2026-10-03-朋友圈改版/朋友圈改版预览-三版对比.html)。
四十九、友人帐:数量角标是 CSS 数出来的
友人帐那一页这次从 79 行写到了 302 行,改版的重点是卡片和配色,但我觉得最有意思的是那三处数字。
页面上要显示三种计数:每个分类标题上的「N 位」、每个分组那一排卡片的总数、以及页脚那行「— 共 N 位朋友 · M 个分类 —」。这三处没有一行 JS,也没有构建期统计,全是 CSS 计数器数出来的:counter-reset: fklall fklcat 开在容器上,每个分类 counter-increment: fklcat,每张卡片 counter-increment: fklall fklcard,然后 content: counter(fklcard) " 位"、页脚 content: "— 共 " counter(fklall) " 位朋友 · " counter(fklcat) " 个分类 —"。好处很直接:构建完就是死的静态文本,pjax 换页不用重算,也不用担心 JS 出错时数字消失。代价是计数器的重置点要写在正确的位置上 —— 每个托盘的 counter-reset 必须挂在那组 site-card-group 上。
配色是一个变量:--fkl-accent: var(--theme-color, #39c5bb),卡片、悬停、角标都从它派生。
顺手修掉两个旧毛病:悬停时是「青字配蓝底」(对比度很差),改成白字主题色药丸;页脚的「推荐友链」原来不足四个、里面还夹着我自己的卡片,现在补足 4+4 并把「0.推荐网站🍔」整组从我自己的友链数据里删掉(source/_data/link.yml 1131 → 1121 行)。
五十、八宝箱导航:四版迭代,78 个站点
八宝箱(网址导航)这一页从 98 行长到 381 行,最后是六个分类、78 个站点,原来的三个分类(算法之家 / 实用工具 / 上帝之眼)重新拆成了 AI 助手与评测 / 博客托管 / 图床服务 / 知识社区 / 实用工具 / 仰望星空。
卡片从「大截图」换成「圆形头像 + 标题 + 两行描述」的小卡 —— 和友人帐的小卡同源,两页看起来是一套东西。原来的站点大截图不删,只给 display: none:配合主题的 lazyload,它连下载都不会发生,等于白省流量。
图标全部自托管。 78 个图标(.png / .ico / .svg / .jpg)一起进了 source/box/nav/icons/。外链 favicon 一是慢、二是经常被墙,本地化之后这一页的打开速度是整个站里最快的之一。
四个版本的过程也都留着以备回看:v1「算法之家版」353 行 → v2 427 行 → v3 373 行 → v4 380 行(定稿)。改到第三版才想明白:真正要改的不是「再加一类站点」,而是先把卡片的信息密度降下来。
五十一、公告卡调了八版
侧栏那张公告卡这一轮前后改了八个版本,最后定型是「主线路 / 备用线路 / 手机 App」三段式:线路做成可点的胶囊(hover 时填主题色、白字),右侧用一行淡色小字标部署平台(Vercel / Netlify / Cloudflare / Render),手机 App 那行是一个下载按钮 + 内联 svg 图标 + 「3.3 MB」的说明。
八个版本大致分两类:前两版在改结构 —— v2 还是老写法(<center> + class="anno_content" + <font color>),v3 和 v4 换成了 a-row / a-chip 的胶囊结构,但标签还叫「线路一 / 备用」,并且多带一行「已运行 N 天 · 文章 N 篇 · 线路 4 条」的元信息(后来觉得这一行和侧栏资讯卡重复,砍掉了);后面五版都在改 CSS —— 换行、收窄以配合评论区样式、加宽、侧栏 360px、侧栏 380px,最后让它填满。
配色的核心是「浅色压深、深色提亮」:主题色在浅底上对比度不够,所以浅色模式下把它压到 62% 的明度(#25706b),深色模式下提亮到 78%(#7fe3da)。
最后那个宽度问题交给容器查询:卡片更窄时(窗口变小、或者侧栏收窄)自动降一档,不用再去猜一个固定像素值。
五十二、侧栏那张个人卡:玻璃、马克笔,和一个会看时间的状态
侧栏第一张卡(#aside-content > .card-widget.card-info,只作用于这一张,公告栏、最新文章、网站资讯那些卡片一律不受影响)这次做成了「磨砂玻璃 + 马克笔名字」。
玻璃是在卡内加一层 ::before:backdrop-filter: blur(11px) saturate(1.45) 加一个上浅下深的渐变,再用 mask-image: linear-gradient(180deg, transparent 0, #000 22%) 让最上面那一段变透明 —— 头像那部分于是露出卡片自己的背景图,像是压在玻璃底下。卡里所有内容都提到 z-index: 1 之上。
名字用黄色马克笔高亮加一点微倾。位置这里踩了个坑:一开始用 inline-block,结果侧栏一宽,名字就被挤到头像右侧同一行、看着像贴在头像右下角。换成 display: table; max-width: 100%; margin: .14em auto 0 —— 块级收缩盒,宽度还是跟着文字走,但一定另起一行、水平居中在头像正下方。深色模式下原来的白字压黄条对比度不够,改成墨字压黄条。
状态是头像右下角那颗 28×28 的小圆点。它原来永远写死一句「🎯 专注学习中」,现在会看时间了:睡觉中 zzz 😴(23:00–07:00)、放假摸鱼中 🏖️(周末 + 法定节假日)、上班摸鱼中 🐟(工作日 07:00–18:00)、下班玩耍中 🎮(工作日 18:00–23:00)。四个状态按顺序判断、命中即停;判定表用的是和页脚摸鱼牌同一份 2026 年放假与调休数据,年份不在表里就退回「周末即休息日」。鼠标移上去时小圆点从 28px 展到 105px 把文字露出来,pjax 换页后重新应用,并且每 60 秒校准一次。
夜间模式这一块来回比过六种名字配色和两档玻璃模糊(bak/card-redesign-20261003/ 里 18 张对照图),最后挑的就是上面这套。
五十三、贡献日历的小方块终于圆了
统计页那块 GitHub 贡献日历,每一格原来是尖角的 fillRect。这次把方块改成 30% 圆角。
不能用 ctx.roundRect(部分浏览器还没有),所以写了个 git_fillroundrect(ctx,x,y,w,h,r) 用 arcTo 画路径兜底;半径取边长的 30%,超过半边长时自动退化成圆/胶囊,不会画出畸形的格子。调用处只换了函数名,颜色、坐标、悬停逻辑一行没动。gitcalendar.js 337 → 351 行。
五十四、不蒜子换成 vercount,以及那个转不完的遮罩
这两件事都落在「访客计数」这条链上。
换源:文章页脚那行访问量原来由不蒜子提供(busuanzi.ibruce.info),多地访问不稳定,现在换成 vercount(events.vercount.one):preconnect 换掉、脚本源换掉、文章页的 PV 位置补一个「—」占位。顺带把加载期的转圈图标删了 —— 数还没来的时候显示「—」比转一个圈更诚实。之前为了让不蒜子不阻塞渲染而做的「脚本搬到底部异步加载」补丁,换源之后也就不需要了,一并撤掉。
遮罩 bug 是用户先发现的:进 /tags/ 再点别的页面,页面会整份刷新,而且加载遮罩一直转。根因在主题的 pjax 配置里 —— pjaxSelectors 写死了三个只存在于 /tags/ 的选择器(#tag-echarts / #posts-echart / #categories-echarts)。pjax 每次换页都要比对新旧页面的选择器数量,数量不等就抛错:DOM doesn’t look the same on new loaded page: '#tag-echarts' - new 0, old 1。这个异常被上层捕获后只触发 pjax:error、不触发 pjax:complete,而关遮罩的 preloader.endLoading() 恰好挂在 pjax:complete 的最后一句 —— 于是错误分支里页面被强制整份跳转,遮罩却没人关,一直转。
修法是运行期把这三个选择器从 selectors 里摘掉(那张图仍然由 '#body-wrap' 那一项重新执行,不会漏图),再用访问器接住 window.Pjax 的每次赋值、把滤镜包一层,防止主题在换页后把列表还原回去。兜底三处:pjax:send 后 6 秒还没 complete、pjax:error、从 bfcache 恢复,都强制 endLoading()。回滚只要删掉这个文件和配置里那一行。
五十五、Twikoo 评论区跟着主题色走了
评论用的是 Twikoo 2.x(registry.npmmirror.com 上的 twikoo.all.min.js,评论卡片宽度与头像那部分本来就在 custom.css 里单独调过)。原样能跑,但和站内卡片的语言对不上,所以新增 source/css/twikoo.css(656 行)做了一层美化。
两个原则。一是只写 .tk-* 类名 —— Twikoo 1.x 那批 .el-* 类完全不动,以后升级 Twikoo 也不会被这套样式拖住。二是配色不写死:主色就是 --tk-accent: var(--theme-color);按钮这种实心场景需要更深的底,用 hsl(from var(--tk-accent) h s 30%) 把明度直接压到 30% —— 因为满饱和度的主题色(默认 #39c5bb)在白底上对比度只有 2.1,按钮文字根本看不清。每个用 color-mix() 的变量前面都留一行 rgba(...) 回退值,老浏览器不至于整块失效。
五十六、把打赏关掉,页脚补上「听点音乐」
打赏暂停。 微信和支付宝两张收款二维码从「关于」页撤下,文章底部的「投喂作者」按钮也一起关掉(配置里 reward 关)。关于页原来那段「投喂方式」的 tab 换成一段说明:投喂栏目 2022-12-7 开启,此前的所得都用在建站上,历史投喂名单继续保留在下面。
页脚多了「听点音乐」。 页脚那几行链接里,最后一行只放了「网站统计」一个,右半边空着 —— 因为 custom.css 里 .ft-links li a 写的是 width: 50%。补上「听点音乐」指向 /life/music/,两列刚好填满。
为什么用注入脚本(source/js/footer-music.js,40 行)而不是改主题:页脚那几行的链接是写死在 footer.pug 里的,主题的 footer 配置只有 owner / custom_text / copyright 三项,inject 也没有「注入到页脚内部」的位置。所以走脚本,带 data-ft-music 做幂等,pjax 换页后重补一次。
八音盒那页顺手补了内容:歌单来源(网易云「温柔治愈:所幸世上有光 总会相逢」,71 首)和四条热评(3227 / 6275 / 4.7 万 / 1.1 万赞)—— 只有一个播放器的页面太素了。
五十七、版权卡里的链接不再是写死的域名
症状:文章底部版权卡里的「文章链接」点下去会跳到 www.fomal.cc。主站上没问题,但从镜像线路或 localhost 打开时,它会把访客带回主域。
根因在主题模板 post-copyright.pug:链接取自 page.copyright_url 或 page.permalink,再交给 hexo 的 url_for —— 而 url_for 对 http(s):// 开头的地址是原样放行的,所以写死的绝对地址一路穿到页面上;主题的 post_copyright 配置只有 enable / author / license 那几项,没有「链接用相对还是绝对」的开关。
改法是在渲染期只改版权卡里那一个 <a>:scripts/post-copyright-local-link.js 把 href 换成站内相对路径 /posts/xxx.html,显示文字也一起换;浏览器里再由 source/js/inject/pc-local-link.js 补上当前访问域名。canonical / og:url / sitemap / 分享按钮 / 图片 CDN 这些「本来就该绝对」的地方一律不碰。顺手还修了浅色主题下版权卡的配色。
五十八、Service Worker 的第二版:从「只换源」到「真的快」
上一版 SW 的本意是给缤纷云的图加缓存,实际上只干了「换源」一件事,有两个 bug:
- 版本号读的是
db.read('blog_version'),但 KV 里从来没有人写过这个键 —— 每次读出来都是空,缓存判断永远走不到,等于白装上; - 拼缓存 URL 的
fullpath()把查询串丢了,于是缤纷云的图片处理参数(?w=400&fmt=webp&q=73)全部失效,缓存下来的是原图。
新版(177 行)把这两件事都补上了:站点版本改成 index.html 的内容哈希写进 KV,版本一变就清空资源缓存;查询串原样带到对象存储。另外只介入白名单域名 —— TARGET_HOSTS = ['www.fomal.cc','fomal.cc','www.fomal.cn','fomal.cn'],netlify / cloudflare / render / github.io 那几个备用线刻意排除在外:它们本来就是「万一主域挂了」时的退路,不该被主域的缓存逻辑影响。
抓取策略:HTML 回源优先(拿不到再吃缓存),其余资源缓存优先、超过 TTL 后台静默校正一次,任何异常都回退到浏览器原始请求。SW 最忌讳的行为是「用户点不动页面」,所以每个分支的兜底都是「放行」。
五十九、一天之内拆掉三座屎山
这一天的后半程没再动界面,都在拆三个堆了很久的东西:source/js/fomal.js(13 万字节)、_config.fomalhaut.yml(2037 行)、custom.css(4124 行)。
原则先说清楚:能走配置就不改主题源码(themes/ 下的 pug / styl / js 一个都没动)、每个阶段先做可校验的备份、所有新增和改动的代码都带中文块注释。分四步:
P0 止血。 翻代码时发现,全站的 console.log / warn / error 早就被整体置空了 —— 当年大概是嫌控制台吵,于是这三年的脚本报错都被静音了。现在只静音 log,error 和 warn 永远保留,?debug=1 或在本地时 log 也照常输出。同时移走三个被一起发布的 fomal.js.bak-*(369KB 死 JS),并修掉 gulpfile.js 的 compress 任务没有 return 的问题(terser 还没写完,gulp 就退出了)。
P1 拆 fomal.js。 2973 行单体 → 105 行主引导 + 10 个模块/数据文件:data/voyager1.js(114 行,旅行者号距离模型)、data/holidays.js(72 行,法定休息日 + 来年 CDN 拉取)、modules/console-art.js(100 行)、modules/shell.js(278 行)、modules/effects.js(253 行)、modules/reading.js(109 行)、modules/nav.js(217 行)、modules/cursor.js(670 行)、modules/footer-time.js(64 行)、modules/settings.js(1253 行,最大的一块)。不套 IIFE —— 主题模板里到处是内联 onclick,这些函数必须留在全局。验证:逐文件 vm.Script 编译、全局重名扫描、构建 368 个文件、浏览器四个页面零异常且 21 个全局入口全在。
P2 把配置里的内联代码抽干。 inject 段里塞了 26 段内联 style / script(约 647 行),抽成 source/css/site-inject.css(10 个样式块合并)和 source/js/inject/ 下 6 个脚本,配置只留 <link> / <script src>,配置文件 2037 → 1482 行。抽的时候逐字比对、YAML 解析、构建 375 个文件,再用浏览器的注入探针验证:滚动锚点 70px、公告卡 70 条规则、侧栏 360px、翻页按钮圆角,全部命中。
P3 整理 custom.css。 4124 → 3936 行(净删 341 行代码,另加 152 行文件头索引)。删的是真死代码:侧栏卡片动态渐变整段、一条 order: 3px solid(border 的笔误,浏览器本来就丢弃它)、没有人引用的 @keyframes、已经下线功能的整段(TianliGPT 189 行)、与无前缀版逐字相同的几个前缀 @keyframes,以及 42 条可以证明被更晚规则覆盖的前缀声明。判据是「只有较早那块声明的每个属性,都被同选择器的更晚块声明过,才可删」—— 11 组重复选择器里只有 1 条满足。
这一步的验证方式值得单独记:把改前改后的 CSS 分别挂进无头 Chrome,遍历 document.styleSheets 导出「媒体上下文 + 选择器 + 属性 = 值」的快照逐条比对 —— 559 条 → 558 条,唯一差异就是那条 order:。这种清理最怕「看起来删干净了」,而层层覆盖的样式单看文件根本数不清,只有让浏览器把最终生效的那份摊开给你看才算数。
文件头还写了 31 条分节目录、25 个媒体查询 / 19 个 @keyframes / 11 个 @font-face 的索引、6 条改动约定,以及 6 条「故意不改、只记录」的已知问题 —— 比如 .settings input[type="checkbox"] 的 transition 漏了时长(本意大约 .2s),因为不动行为所以只留条记录。
六十、AI 助手的 Key 挪到了服务端
前端那个 AI 页面助手,Key 原来放在站点根目录的 .ai-chat-key 里(不进 git)。只有站长自己在本机跑起来才用得上 —— 一旦想让访客也能用,Key 就不能再进浏览器。
于是新增了 api/chat/completions.js(171 行,部署在 Vercel 的 Serverless 函数)和 vercel.json:真 Key 只存服务端环境变量 DEEPSEEK_API_KEY,前端 api_base 改成同源 /api。顺带做了三件必要的门:来源白名单(默认 fomal.cc / www.fomal.cc,*.vercel.app 预览自动放行)、每 IP 每小时限流(默认 120 次)、以及 max_tokens 上限 8192、messages 最多 24 条、请求体上限 400KB。
用不用这套代理由构建期决定:scripts/ai-chat-inject.js 发现环境里有 DEEPSEEK_API_KEY,就把前端配置改成走 /api,否则保持本机直连 + 读 .ai-chat-key。同一个仓库、两种运行方式,不改配置。
接口也能自检:GET 一下会返回 {"ok":true,"mode":"proxy","hasKey":true} 这一类的信息,部署对了没有一目了然。
六十一、最后说两句
回头看这次维修,我大概有这么几个感受。
第一,老站的问题往往是"你以为它在工作"。 MathJax 那个字段挂在配置文件里三年,主题从来没读过;Element-UI 拖着 905KB,实际只用了个 Toast;背景图那 4 个 CSS 变量干脆没有人定义过。这类问题不会报错,只会让你觉得"网站好像有点慢,但也能用"。
而且它们不是某天一起坏掉的,是一件一件慢慢失效的 —— 今天死个 CDN,下周死个图床,隔几个月死个接口。每次只坏一点点,每次都不影响你读文章,于是就永远轮不到修。等哪天真的攒够了,你会以为是"网站突然出问题了",其实是三年的账单一起到期。
第二,判断性能不能只看单点。 换源之前我以为 CDN 就是瓶颈,结果一测发现我自己首页的 TTFB(693ms)比任何一个 CDN 都慢。省下来的那几百毫秒,可能还不如把 Vercel 的接入优化一下。做事要算总账。
第三,AI 帮了大忙,但坑还是得自己踩。 这次 DeepSeek Harness 干了绝大部分体力活:翻遍 fomal.js 3800 行找到全部 14 处 Vue、逐个探测几十个 CDN 的存活和速度、写通知组件、跑构建验证、统计产物里的资源分布。这些活儿如果我自己干,大概是熬两个晚上。
但有几个判断它替不了我:
- 905KB 的 Vue 到底删不删 —— 删错了整个站的弹窗全没了,这个风险得我拍板
- 文章正文里出现
cdn.staticfile.org到底是"资源没换干净"还是"文章历史记录"—— 它一开始也搞混了,把那些字样当成漏网之鱼,后来才想明白要看标签属性而不是全文匹配 - 最后要不要合并提交、什么时候部署
它会犯错,也会自我纠正;它给的结论要验证,但验证的成本比从头做低太多了。
不过,上面这些其实都还是第一轮。 9 月 30 号那晚算是急救 —— 把真正死了的东西找出来、换掉、验证。后面两天多则是我自己主动想改的:首页从 4×3 变回 3×3、图墙从 flex 换成 grid、菜单砍掉四个入口、相册从 17 个手写页面收成一个文件、侧栏添了日历和倒计时、还给博客塞了个 AI 页面助手。修坏掉的东西只是止损,改这些才是我当初搭这个站的原因。
第四,很多问题不是读代码读出来的,是量出来的。 图墙那段最典型:我一开始只知道「有时候一行排不满」,盯着 CSS 也看不出所以然。最后是把页面在 320 / 390 / 414 / 579 / 768 / 1280 六个宽度下各截一张图,一张一张数格子,才看清固定宽度的 flex 会在 579px 这种宽度上剩出 194px 的空档。这类问题只有截图和数字能证明,源码里找不到答案。
第五,静默失效也长在自己写的代码里。 换源那轮死的是外部依赖,这一轮的坑是自己埋的:首页轮播里 lazyLoadInstance.update() 比 Swiper 克隆节点早了一步,于是循环到克隆位就没图;用 innerHTML 整体覆盖卡片容器,把 <link> 从 DOM 里摘掉,样式表跟着失效;首页磁贴的链接被插件写死成绝对地址,点一下就跳出站。它们全都不报错,表现只是「有时候」「偶尔」「这里怎么没生效」。 两条教训收下:带 <style> / <link> 的容器永远别整体覆盖,只替换挂载节点;懒加载遇上会自己克隆节点的组件,update() 的时机要往后放。
第六,也给自己留了退路。 这三天一共留下 57 组 bak/ 备份目录,每组里是改动前的文件加一页回滚说明。改坏了不用翻 git 历史猜,把 before__ 开头的文件覆盖回去就行 —— 对我这种经常在凌晨两点改 CSS 的人,这比什么技巧都重要。
第七,顺手把这几天的数字记一下: 砍掉 905KB 的 Vue + Element-UI,换成 7KB 的自研通知;139 个友链条目体检,81 个失联搬进「失联友链」;相册从 17 个手写页面收成 1 个文件;美化面板从不能选字体变成 11 款(常规 7 + 代码 4);侧栏多了两张卡;博客多了个 46KB、零依赖的 AI 助手,Key 存在 .ai-chat-key 里不进 git。全部改动的文件清单在文末,一共 154 条 —— 这篇记录本身,也从当初的十来节写到了 61 节。
第八,最贵的 bug 是不报告的那种。 拆 fomal.js 的时候才发现,全站的 console.log / warn / error 早就被整体置空了 —— 当年大概只是嫌控制台吵,结果是这三年里任何脚本挂掉,浏览器都安静得像什么都没发生。现在只静音 log,error 和 warn 永远保留,?debug=1 或者本地打开时 log 也照常输出。省掉几行噪音的代价,是三年看不见的错误。
第九,连记录自己都会写错日期。 这一天留下的备份目录里,有一批名字写的是 20261004、20261005,其实全都是 10 月 3 号建的 —— 另一个会话以为自己活在两天后。文件的修改时间不会骗人,名字会。所以前面那句「它给的结论要验证」,也包括验证它自己起的文件名。
要说还有什么意外,那就是连这份记录本身都出过 bug:旧时光那页交给 AI 顺手维护,它写漏了一个时间轴的闭合标记,结果那一整天的记录渲染成一堆没有日期的条目、编号从 1 重新开始 —— 是我自己打开页面觉得不对,才发现标记少了一个。所以前面那句话得再说一遍:它给的结论要验证,包括它替你写出来的文档。
所以这次的经验大概就是:把"翻文件、查资料、跑验证"交给它,把"要不要动、动到什么程度"留给自己。
就这样,网站又续了几年命 🍬
如果这篇对你有帮助,或者你也发现自家站里藏着个 900KB 的巨物,欢迎在评论区聊聊 —— 说不定你的比我更离谱。





