给静态博客做「零依赖」前端增强:滚动入场、PWA 与构建期数据注入
做这个博客的时候,我一直在跟自己做的一件事是加东西,但不引依赖。
NexT 主题本身已经很重,如果再叠上 AOS、Workbox、chart.js、动画库这些,加载体积会一路往上飘,而收益往往只是「看起来动了一下」。所以这次我把三件事都压在原生 API 上 —— IntersectionObserver 做滚动入场、手写 Service Worker 做离线、after_render filter 做构建期数据注入。
一、为什么不用 AOS
AOS 是做滚动动画最容易被推荐的一个库。但我实测了一下它的代价:
- 引入
aos.js+aos.css约 15KB(gzip 后约 6KB) - 初始化要遍历所有带
data-aos的节点,逐个挂观察器 - 对
data-aos-delay的支持意味着要在每个元素上写延迟数值,模板会很脏
而我真正要的效果只有一句话:元素滚进视口时淡入并上移一点。
用 IntersectionObserver 写,核心逻辑不到 30 行:
1 | var io = new IntersectionObserver(function (entries) { |
真正决定「不动的时候好不好看」的其实是 CSS,只有一条规则:
1 | .js-obs .reveal-item { opacity: 0; transform: translateY(22px); } |
这里有个关键设计:初始隐藏依赖 body 上的 .js-obs 类,而这个类是 JS 执行后才加的。
1 | document.body.classList.add('js-obs'); |
顺序不能反。反了就有一帧的「先显示、再消失」,用户会看到明显闪动。反过来做的好处是:万一脚本被拦截或执行失败,.js-obs 不存在,元素照常显示 —— 动画是增强,不是内容的必要条件。这个降级思路和渐进增强是同一回事。
再加两条兜底,让特殊场景不至于出问题:
1 | @media (prefers-reduced-motion: reduce) { |
前庭功能障碍的用户在系统里开了「减少动态效果」,就不该被强制看动画;打印时更是必须全部展开。
错峰:同批进入的元素
如果一屏内有 4 个卡片同时进入视口,它们会「啪」地一起出现,很生硬。解决办法是给同一批回调加一个计数器:
1 | var batch = 0; |
60ms 的间隔、3 档循环,是试出来的手感。间隔太小看不出层次,太大就变成逐个蹦出来。
pjax 之后要重新扫描
NexT 默认开 pjax,点菜单只替换 .main-inner,新插入的节点没人观察:
1 | document.addEventListener('pjax:end', scan); |
scan() 内部还要先判断元素是不是已经在视口内 —— 是的话直接揭示,不该从屏幕外飞进来。
二、PWA:手写 Service Worker 够用了
PWA 的门槛主要是 manifest 和 Service Worker 两件事。manifest 是纯 JSON,写一次就不用管了:
1 | { |
purpose: "maskable" 值得单独说一句。Android 会把它裁成你不想看到的形状(圆形、squircle、甚至全屏横幅),所以 maskable 图标必须把内容缩进中间 80% 的安全区里,四角也不能自己画圆角 —— 形状交给系统,内容才不会被切掉。
Service Worker 我只写了三条策略,没有引入任何构建工具:
1 | // 1) 导航请求 → network-first:优先拿最新,失败退回缓存 |
为什么博客要用 network-first 而不是 cache-first? 缓存优先虽然更快,但文章更新后读者可能好几天看不到新内容。对内容站来说,「拿到旧的」比「慢 200ms」糟糕得多。
而静态资源反过来用 SWR,因为 JS/CSS 变了文件名或内容哈希也会变,慢一点更新没影响,换来的是二次访问秒开。
还有一条不要碰跨域请求:
1 | // 跨域请求不碰:一言 / Live2D 等外部接口失败时应该走正常报错路径, |
这行注释是我被坑过的原因。页脚有一句「一言」是运行时从外部 API 拉的,如果无差别缓存,某次接口抽风返回的 404 响应会被存下来,之后每次访问都重放这个错误 —— 缓存把一次偶发故障变成了永久故障。
缓存名带版本号,改了策略改一下 VERSION 就能触发全量更新,不用教用户怎么清缓存:
1 | const VERSION = 'v1'; |
注册那段有个容易忽略的判断:
1 | if (location.protocol !== 'https:' && location.hostname !== 'localhost' && |
Service Worker 只在 HTTPS 或 localhost 下可用。IP + HTTP 访问(比如局域网调试、或者用 IP 直连域名)时 register() 会直接抛 SecurityError,不判断的话控制台会一直报错。
三、统计数字:构建期算好,别在客户端数
首页想放「多少篇文章 / 多少分类 / 多少标签」,最直白的做法是客户端 fetch 一个 JSON 自己数。但列表页是分页懒加载的,前端根本拿不到全部文章,数出来的数字会骗人。
Hexo 的正确姿势是在 after_render:html 这个阶段算好,注入 HTML:
1 | const SLOT = /ZZSTATS_([A-Z]+)_END/g; |
几个细节:
.filter(p => p.published !== false && !p.draft)—— 不排掉 draft 的话,前台数字会比实际可见的文章多categories要.filter(c => c.length > 0)—— Hexo 的分类集合里会混入空节点,不过滤会多出几个「未分类」- 占位符用字母 KEY 而不是数字 ——
ZZSTATS_(\d+)_END匹配不到22。这个低级错误让我排查了半天才发现正则根本没命中 if (result.indexOf('ZZSTATS_') === -1) return result—— 大部分页面没有占位符,提前返回省掉一次无谓的正则扫描
after_render:html 的签名要特别注意,它不是「接收对象返回对象」:
1 | function (result, data) { } |
四、一个把首页构建成 0 字节的 bug
这次改动的过程里踩了一个很隐蔽的坑,值得单独记一笔。
我最初写统计 filter 的时候,是这样取数据的:
1 | // ❌ 错误写法 |
看起来是把方法存起来再调,逻辑上没错。但 hexo.locals.get 依赖 this 指向 hexo.locals,解绑之后 this 变成 undefined,于是:
1 | TypeError: Cannot read properties of undefined (reading 'cache') |
要命的是这个错误发生在 filter 里,Hexo 会吞掉它,首页直接产出一个 0 字节的 index.html。构建不报致命错误,generate 依然打印「184 files generated」,但首页是空的。
排查花了几步,最后靠对着堆栈一行行读才定位到:
1 | at collect (scripts/site-stats.js:15:17) |
修法就是别解绑:
1 | // ✅ 正确写法 |
教训:在 Hexo filter 里抛异常不一定让构建失败,可能只是让某个页面变成空文件。所以每次改完 filter,除了看构建有没有报错,还应该 stat -c%s public/index.html 确认首页不是 0 字节 —— 静默失败比崩溃难查得多。
五、最终效果
- 首页 Hero 下方一行统计胶囊,数字用主色渐变,跟随主题切换自动变色
- 列表页 / 说说 / 归档的条目滚动进视口时逐个浮现,同批最多 3 档错峰
- 手机浏览器可「添加到主屏幕」,standalone 模式打开,断网仍可浏览已访问过的页面
- 零新增运行时依赖:没装任何 npm 包
顺便说一句,<meta name="theme-color"> 这个小东西成本几乎为零,但体验提升很实在 —— 手机浏览器地址栏会跟着主色变,配合 media="(prefers-color-scheme: dark)" 还能日夜分别给色值:
1 | <meta name="theme-color" content="#ff8fab" media="(prefers-color-scheme: light)"> |
小结
这几个功能加下来大概两百行代码,一个 npm 包都没装。回过头看,值得记住的不是某个 API 的用法,而是那条贯穿始终的原则:
增强必须是增强。 动画挂了内容还在,PWA 挂了站点还能访问,filter 出错也不该让页面变空。把「锦上添花」和「能不能用」这两件事分开做清楚,前端体验才不会有下限。
如果这套东西你觉得有意思,我后面打算再写一篇讲 Hexo 构建期 KaTeX 渲染的 —— 公式在服务端转成静态 HTML,客户端零 JS,同样是「不引客户端依赖」的思路。