这个博客有个「保险箱」(vault)板块和一堆主文章。我经常回头改旧文章——修错别字、补一段新想法——但读者根本不知道哪里变了,只能靠自己重新通读一遍去发现。于是我给整个站加了一套「更新提示系统」,目标是回答一个问题:
“自我上次来访之后,这个网站哪里变了?”
最终效果分三层,正好对应三个页面场景:
| 场景 | 效果 |
|---|---|
| 列表页 | 有更新的文章标题旁亮起呼吸灯小圆点(保险箱里新日记也会亮) |
| 文章页 | 改动过的那几段被高亮标注(不是整篇文章!) |
| 文章页顶部 | 一个「跳转到最新更新」按钮,点一下跳一处,循环覆盖所有改动 |
保险箱里是主题色的蓝色圆点,主文章列表是醒目的黄色圆点,两套配色、两份存储,互不干扰。
核心思路:指纹比对 链接到标题
整套系统的地基只有一个概念——内容指纹。
在 Hugo 模板里,把每篇内容的 SHA256 直接烘进 HTML 属性:
<li
data-posts-path="{{ .RelPermalink }}"
data-posts-hash="{{ crypto.SHA256 .Content }}"
>
这样构建产物本身就自带"这篇内容长什么样"的信息,浏览器端只需要做一件事:
- 打开文章页时,把
(路径 → 内容指纹 + 各段落哈希)存进localStorage; - 下次逛列表页时,逐条比较"存的指纹"和"现在的指纹",不一致就点亮圆点。
不需要后端、不需要 API、不需要 RSS 解析,纯静态方案。
第一层:呼吸灯小圆点 链接到标题
圆点本身是个行内 <span>,默认带 hidden 属性——JS 没加载、或者没有更新时,它根本不会出现。只有 JS 判定"确实有更新"才移除 hidden。
呼吸动画是重点,我想要的效果是像设备上的待机指示灯那样缓慢地「吸-呼」:
@keyframes posts-dot-breathe {
0%, 100% {
opacity: 0.45;
transform: scale(0.8);
box-shadow: 0 0 0 0 rgba(255, 193, 7, 0.55);
}
50% {
opacity: 1;
transform: scale(1);
box-shadow: 0 0 0 0.45rem transparent;
}
}
三个属性一起动:透明度起伏管"明暗"、缩放管"大小"、外圈 box-shadow 从实色扩散到透明管"光晕"。周期 2.4 秒无限循环,看着就很安详。
另外补了一条 prefers-reduced-motion: reduce 时关掉动画——系统开了"减少动态效果"的用户不该被迫看东西一直闪。
第二层:段落级 diff 链接到标题
这是整个项目里最有意思的部分。最初的实现是按 H2/H3 标题切块比对,但很快就发现问题:改了某个 ### 小节里的一句话,整个小节都被高亮,等于没说清楚哪里变了。
所以切分粒度改成:每个顶层元素各算一段——<p>、标题、代码块、引用各自独立,列表再细拆到每一个 <li>。这样改动粒度直接对应到 markdown 源文件里的具体某一行。
比对算法用的是教科书级的 LCS(最长公共子序列):
function diffBlockHashes(oldHashes, newHashes) {
// dp[i][j] = 两序列前 i、j 段的最长公共子序列长度
// 回溯时命中的就是"没变"的段落,
// 新序列里没被命中的下标 = 新增或改动的段落
}
每个段落的哈希用一个轻量的 FNV-1a(只对 textContent 计算),省去引入加密库;整篇文章级别的真伪由 Hugo 烘进去的 SHA256 把关,两层各司其职。
两个实现细节值得记下来:
- 高亮直接打在元素上,不外包
<div>。因为"段"可能是<li>,往<ul>里塞 div 是非法 DOM,浏览器会把它拆出来,样式全乱。 - 改动可能不止一处,也可能位置会漂移(比如中间插了一段,后面的段落全挪位了)。所以不能存"第 N 段变了",必须存整份分段哈希序列,每次现算 LCS。
配套的「跳转到最新更新」按钮插在正文前面,文案区分单复数:「跳转到最新更新」/「跳转到最新更新(共 N 处)」。点击用 scrollIntoView({ behavior: "smooth", block: "center" }) 平滑滚动,索引取模循环,N 处改动点 N+1 次就绕回第一处。
几个刻意的设计决策 链接到标题
新文章要不要亮,两边策略刻意不同。 保险箱的判定是"没见过 || 内容变了"——日记本里出现一篇新日记,正是读者最该被提示的事,所以首次解锁时会静默把当前内容记为基线,此后冒出来的新日记就会亮灯。公共文章库则保守得多:“之前读过 && 内容变了"才亮,新文章一律不亮——这个列表对所有人生效,新文章也亮的话,第一次来的访客会满屏圆点,提示就彻底失去意义了。
离开页面时才保存基准。 监听 visibilitychange 和 pagehide,而不是一进来就写。这样哪怕你扫了一眼马上关掉,这次看到的样子也会成为下次比对的基准;同时在同一页面内重复触发也有 saved 标志位防抖。
快照带版本号。 存储结构是 { v: 1, fullHash, blockHashes, seenAt }。哪天切分粒度又调整了,把版本号 +1,所有旧快照直接作废重录——否则新旧粒度混着比,会把整篇文章误判成"全是新增”。
保险箱用独特的蓝色 主文章列表里使用和网站框架一样的主体黄色,但是为了突出保险箱内的独特,保险箱内全部使用蓝色。
复用到主文章列表 链接到标题
功能跑通后,把它移植到主文章列表只是换个皮肤的工作量,但我坚持了几个原则:
- 完全独立的文件:
posts-updates.js/posts-updates.scss,和 vault 那套零共享状态; - 独立命名空间:类名
posts-*、存储 keymugee-posts-seen,CSS 变量都不沾 vault 的; - 颜色换成黄色
#ffc107,深色底上按钮配深色文字保证对比度,高亮底的透明度压得很低(12%),浅色主题下也不刺眼。
这样以后想删掉任何一边,都不会误伤另一边。
踩坑:本地好好的,部署炸了 链接到标题
移植完成、本地构建全绿,push 上去 Cloudflare 直接报错:
TOCSS: failed to transform “scss/vault.scss”: wrong number of arguments (4 for 3) for
rgb
原因是我写了 CSS Level 4 的空格语法 rgb(21 101 192 / 0.7) 以及逗号四参的 rgb(r, g, b, a)——浏览器的现代解析器认,但 Cloudflare 构建环境用的 libsass 只认识三参数的 rgb(),透明度必须写成 rgba()。
更坑的是这个错误本地完全复现不出来:Hugo 会把 SCSS 编译结果缓存在 resources/_gen/ 里,源文件改错了但缓存还是旧的,构建照样绿。最后是删掉 public/ 和 resources/_gen/ 做了一次全干净构建,才在本地复现并确认修复。教训很实在:
本地缓存会掩盖编译错误。排查这类问题时,先删缓存再构建,让环境和 CI 一致。
好在 CSS 里的 color-mix() 反而不用处理——libsass 不认识它就原样输出,浏览器端自行解析,而且每处都保留了 rgba 兜底在前、color-mix 在后的顺序,老内核也能安全回退。
怎么验证? 链接到标题
纯前端 + 构建期的功能,单元测试不太好写。我的办法是在 Node 里用最小 DOM 桩模拟真实流程:伪造 document.querySelector、classList、localStorage,然后按场景断言行为——首访不亮、无变化不亮、只改一个 <li> 时仅它亮、两处改动按钮显示"共 2 处"、看完之后熄灭……十来个场景一轮跑完,逻辑层面的正确性就有底了。剩下的视觉效果交给真浏览器肉眼看。
结语 链接到标题
这套东西加起来不过两三百行代码,没有引任何依赖,但它精确回答了"哪里变了"这个问题。回访的读者打开文章,视线直接被带到改动的那一行——这大概就是静态博客也能有的一点"活的"感觉。