给静态博客做「零依赖」前端增强:滚动入场、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
2
3
4
5
6
7
8
9
10
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
entry.target.classList.add('revealed');
io.unobserve(entry.target); // 已揭示的移出观察,省一次回调
});
}, {
rootMargin: '0px 0px -40px 0px', // 下方收 40px,元素往上滚一点动画就完成
threshold: 0.12 // 露出 12% 就播,比「完全进入」更跟手
});

真正决定「不动的时候好不好看」的其实是 CSS,只有一条规则:

1
2
.js-obs .reveal-item          { opacity: 0; transform: translateY(22px); }
.js-obs .reveal-item.revealed { opacity: 1; transform: translateY(0); }

这里有个关键设计:初始隐藏依赖 body 上的 .js-obs 类,而这个类是 JS 执行后才加的。

1
2
document.body.classList.add('js-obs');
scan();

顺序不能反。反了就有一帧的「先显示、再消失」,用户会看到明显闪动。反过来做的好处是:万一脚本被拦截或执行失败,.js-obs 不存在,元素照常显示 —— 动画是增强,不是内容的必要条件。这个降级思路和渐进增强是同一回事。

再加两条兜底,让特殊场景不至于出问题:

1
2
3
4
5
6
@media (prefers-reduced-motion: reduce) {
.js-obs .reveal-item { opacity: 1 !important; transform: none !important; }
}
@media print {
.js-obs .reveal-item { opacity: 1 !important; transform: none !important; }
}

前庭功能障碍的用户在系统里开了「减少动态效果」,就不该被强制看动画;打印时更是必须全部展开。

错峰:同批进入的元素

如果一屏内有 4 个卡片同时进入视口,它们会「啪」地一起出现,很生硬。解决办法是给同一批回调加一个计数器:

1
2
3
4
5
6
7
8
var batch = 0;
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
var d = batch % 3; // 最多 3 档,再多就拖沓
entry.target.style.transitionDelay = d === 0 ? '' : (d * 0.06) + 's';
entry.target.classList.add('revealed');
batch++;
});

60ms 的间隔、3 档循环,是试出来的手感。间隔太小看不出层次,太大就变成逐个蹦出来。

pjax 之后要重新扫描

NexT 默认开 pjax,点菜单只替换 .main-inner,新插入的节点没人观察:

1
document.addEventListener('pjax:end', scan);

scan() 内部还要先判断元素是不是已经在视口内 —— 是的话直接揭示,不该从屏幕外飞进来。

二、PWA:手写 Service Worker 够用了

PWA 的门槛主要是 manifest 和 Service Worker 两件事。manifest 是纯 JSON,写一次就不用管了:

1
2
3
4
5
6
7
8
9
10
11
12
{
"name": "zzWei の 笔记",
"short_name": "zzWei",
"start_url": "/",
"display": "standalone",
"background_color": "#f6f7f9",
"theme_color": "#ff8fab",
"icons": [
{ "src": "/images/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/images/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

purpose: "maskable" 值得单独说一句。Android 会把它裁成你不想看到的形状(圆形、squircle、甚至全屏横幅),所以 maskable 图标必须把内容缩进中间 80% 的安全区里,四角也不能自己画圆角 —— 形状交给系统,内容才不会被切掉。

Service Worker 我只写了三条策略,没有引入任何构建工具:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 1) 导航请求 → network-first:优先拿最新,失败退回缓存
if (req.mode === 'navigate') {
e.respondWith(
fetch(req)
.then(res => { caches.open(CACHE).then(c => c.put(req, res.clone())); return res; })
.catch(() => caches.match(req).then(r => r || caches.match('/')))
);
return;
}

// 2) 静态资源 → stale-while-revalidate:有缓存先秒出,后台顺便更新
if (/\.(?:js|css|png|jpg|svg|woff2?|json)$/.test(url.pathname)) {
e.respondWith(
caches.match(req).then(cached => {
const network = fetch(req).then(res => {
if (res && res.status === 200 && res.type === 'basic') {
caches.open(CACHE).then(c => c.put(req, res.clone()));
}
return res;
}).catch(() => cached);
return cached || network;
})
);
}

为什么博客要用 network-first 而不是 cache-first? 缓存优先虽然更快,但文章更新后读者可能好几天看不到新内容。对内容站来说,「拿到旧的」比「慢 200ms」糟糕得多。

而静态资源反过来用 SWR,因为 JS/CSS 变了文件名或内容哈希也会变,慢一点更新没影响,换来的是二次访问秒开。

还有一条不要碰跨域请求:

1
2
3
// 跨域请求不碰:一言 / Live2D 等外部接口失败时应该走正常报错路径,
// 而不是被缓存成一个错误的 JSON 反复重放
if (url.origin !== self.location.origin) return;

这行注释是我被坑过的原因。页脚有一句「一言」是运行时从外部 API 拉的,如果无差别缓存,某次接口抽风返回的 404 响应会被存下来,之后每次访问都重放这个错误 —— 缓存把一次偶发故障变成了永久故障。

缓存名带版本号,改了策略改一下 VERSION 就能触发全量更新,不用教用户怎么清缓存:

1
2
3
4
5
6
7
8
9
10
const VERSION = 'v1';
const CACHE = 'zzblog-' + VERSION;

self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys()
.then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))))
.then(() => self.clients.claim())
);
});

注册那段有个容易忽略的判断:

1
2
if (location.protocol !== 'https:' && location.hostname !== 'localhost' &&
location.hostname !== '127.0.0.1') return;

Service Worker 只在 HTTPS 或 localhost 下可用。IP + HTTP 访问(比如局域网调试、或者用 IP 直连域名)时 register() 会直接抛 SecurityError,不判断的话控制台会一直报错。

三、统计数字:构建期算好,别在客户端数

首页想放「多少篇文章 / 多少分类 / 多少标签」,最直白的做法是客户端 fetch 一个 JSON 自己数。但列表页是分页懒加载的,前端根本拿不到全部文章,数出来的数字会骗人。

Hexo 的正确姿势是在 after_render:html 这个阶段算好,注入 HTML:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const SLOT = /ZZSTATS_([A-Z]+)_END/g;

hexo.extend.filter.register('after_render:html', function (result) {
if (typeof result !== 'string' || result.indexOf('ZZSTATS_') === -1) return result;

const posts = hexo.locals.get('posts').toArray()
.filter(p => p.published !== false && !p.draft);
const cats = hexo.locals.get('categories').toArray().filter(c => c.length > 0);
const tags = hexo.locals.get('tags').toArray();

let chars = 0;
posts.forEach(p => { chars += (p.raw || '').length; });

return result.replace(SLOT, (_, id) => {
if (id === 'POSTS') return String(posts.length);
if (id === 'CATS') return String(cats.length);
if (id === 'TAGS') return String(tags.length);
if (id === 'WORDS') return fmtWords(Math.round(chars / 2));
return _;
});
}, 5);

几个细节:

  • .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
2
function (result, data) { }
// ↑ 是 HTML 字符串本身 ↑ 才是 { path, text }

四、一个把首页构建成 0 字节的 bug

这次改动的过程里踩了一个很隐蔽的坑,值得单独记一笔。

我最初写统计 filter 的时候,是这样取数据的:

1
2
3
// ❌ 错误写法
const locals = hexo.locals.get;
const posts = locals('posts');

看起来是把方法存起来再调,逻辑上没错。但 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
2
at collect (scripts/site-stats.js:15:17)
at locals.js:11:21 ← get(name) { return this.cache.apply(...) }

修法就是别解绑:

1
2
3
// ✅ 正确写法
const get = (name) => hexo.locals.get(name);
const posts = get('posts').toArray();

教训:在 Hexo filter 里抛异常不一定让构建失败,可能只是让某个页面变成空文件。所以每次改完 filter,除了看构建有没有报错,还应该 stat -c%s public/index.html 确认首页不是 0 字节 —— 静默失败比崩溃难查得多。

五、最终效果

  • 首页 Hero 下方一行统计胶囊,数字用主色渐变,跟随主题切换自动变色
  • 列表页 / 说说 / 归档的条目滚动进视口时逐个浮现,同批最多 3 档错峰
  • 手机浏览器可「添加到主屏幕」,standalone 模式打开,断网仍可浏览已访问过的页面
  • 零新增运行时依赖:没装任何 npm 包

顺便说一句,<meta name="theme-color"> 这个小东西成本几乎为零,但体验提升很实在 —— 手机浏览器地址栏会跟着主色变,配合 media="(prefers-color-scheme: dark)" 还能日夜分别给色值:

1
2
<meta name="theme-color" content="#ff8fab" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#181818" media="(prefers-color-scheme: dark)">

小结

这几个功能加下来大概两百行代码,一个 npm 包都没装。回过头看,值得记住的不是某个 API 的用法,而是那条贯穿始终的原则:

增强必须是增强。 动画挂了内容还在,PWA 挂了站点还能访问,filter 出错也不该让页面变空。把「锦上添花」和「能不能用」这两件事分开做清楚,前端体验才不会有下限。

如果这套东西你觉得有意思,我后面打算再写一篇讲 Hexo 构建期 KaTeX 渲染的 —— 公式在服务端转成静态 HTML,客户端零 JS,同样是「不引客户端依赖」的思路。