加载反馈的两种做法:pjax 进度条与图片骨架屏
上一篇写完「零依赖前端增强」,我在评论区给自己提了个问题:
既然那么在意体验,加载动画为什么没做?
想了一下,确实是个 oversight。我们的 pjax 换页现在是这样:
- 点菜单 → 旧页面淡出 → 新页面淡入
中间那段「等待新 HTML 拉回来」的时间是完全无反馈的。本地服务器上大概 50ms 就过去了,感受不到;但 EdgeOne 跨省回源、或者手机弱网下可能要几百毫秒甚至一两秒 —— 这段时间用户看到的是「我点了,怎么没反应」。
配图也是同理。文章里的 SVG 配图从 opacity: 1 直接跳到可见,虽然有 <transition> 但那是 NexT 主题统一加的淡入,在骨架层面没有任何东西。
所以这轮补两块:顶部进度条和图片骨架屏。
一、顶部进度条:nprogress 的思路
很多 Hexo 主题的进度条都是引 nprogress 这个库(1KB gzip)。但我看了 Sakurairo 内嵌的实现后,发现真正需要的部分只有 50 行 CSS,于是决定手写。
它的结构是两个元素:
1 | <div id="zzprogress"> |
样式:
1 | #zzprogress { |
rotate(3deg) 那个小歪角是精髓 —— 纯矩形进度条看起来像「网页没画完」,带个斜切尖端才像「正在走」。加上双层 box-shadow 辉光,深色模式下也有存在感。
进度怎么算:假装自己不知道
真实进度条能显示百分比,是因为它知道总量和已加载量。pajax 换页这两样都没有 —— 你不知道新 HTML 有多大,也不知道还要多久。
nprogress 的解法很朴素:随机递增,装作在动。
1 | var MIN = 0.08; // 最小可见量 |
三个参数都有讲究:
为什么是 MAX = 0.994 而不是 1?
如果条子爬到 100% 但页面还没来,用户会立刻察觉到「骗人」。卡在 99% 反而更像「快好了」,主观等待感更短。这是个纯粹的心理设计。
为什么增量要乘 (1 - status)?
线性递增的话,前面涨得慢、后面突然窜出去。乘上剩余量之后是减速曲线,视觉上更接近「匀速 + 接近终点时放缓」。
为什么要 MIN = 0.08?
进度太小的话条子几乎不动,会让人以为根本没触发。从 8% 起步,第一帧就能看到东西。
触发时机与兜底
1 | document.addEventListener('pjax:send', start); |
三个都要挂。漏掉 pjax:error 的话,请求失败时条子会永远留在屏幕上。
还有一个容易忽略的场景:点了链接但 pjax 没接管。
1 | document.addEventListener('click', function (e) { |
target="_blank"、下载链接、页内锚点这三种都不会触发 pjax。如果不处理,用户点了个新窗口链接,看到一条永远不消失的进度条。
那个 setTimeout(..., 60) 的 60ms 是个权衡:pjax 的 pjax:send 是在点击处理链里同步派发的,但保险起见留一点余量。给太短会误杀,给太长(超过 100ms)弱网下会有可察觉的「条子卡住」。
我砍掉了 spinner
nprogress 原本右上角还有一个转圈 spinner。我去掉了,理由:
3px 的细条已经足够表达「在加载」,多一个转圈反而抢视觉。而 spinner 放在右上角在移动端容易和返回手势区域打架。
另外它的触发我只挂在 pjax 上,没做「计时器兜底」。原因是首屏加载时浏览器自带的转圈图标已经在转了,再叠一层纯属画蛇添足。pjax 换页才是浏览器「不介入」的那个空档。
二、图片骨架屏:一道扫光
思路更简单:图片没加载完时铺一层灰底 + 一道斜向微光慢慢扫过去。
1 | @keyframes zz-shimmer { |
微光用 100deg 而不是 90deg —— 完全水平会显得像加载条,斜一点更像「高光扫过物体表面」。
颜色用的是 var(--accent-faint) 而不是纯白,所以深色模式下会自然变成暗色调的微光,不需要额外适配。
包裹逻辑的几个细节
图片本身是裸 <img>,没有外层容器,所以要 JS 包一层:
1 | function wrap(img) { |
img.complete 那个判断不能省。 缓存命中的图片在脚本执行时就已经加载完了,如果不管,load 事件永远不会触发,骨架会永久停在那里 —— 内容直接消失,比不做还糟。
error 也必须处理。 配图 404 时不能停在骨架上,虽然骨架比破图好看,但也别把内容藏没了。让它淡入显示 alt 文本或破图,至少用户知道「这里本该有张图」。
选择器要限定作用域:
1 | var SEL = '.post-body img, .post-body .post-gallery img'; |
侧栏头像、页脚图片、Live2D 的模型贴图都不该被套骨架。全局选 img 的话会给一堆不相干的图加扫光。
pjax:success 之后要重新扫一遍,因为新页面的 DOM 是刚插入的。
三、验证里踩到的坑
写完我想验证「图片加载中的骨架态」,结果卡了很久。
第一个坑:用 page.route 拦截图片延迟,但拦不住。 配图是 SVG,浏览器可能已经从 Service Worker 缓存或内存缓存拿到了,route 完全没触发。
第二个坑:加个 ?x=1 破坏缓存,还是拦不住。 因为 NexT 的 pjax 请求和缓存策略让这条路很难干净地测。
第三个坑:断网测试,结果 SW 兜住了。 Service Worker 的 stale-while-revalidate 策略让离线时页面照样能打开 —— 这本身证明 SW 工作正常,但测不出加载态。
最后我换了思路:不去测「加载中」,而是直接测两个终态分支。
1 | // 分支 A:缓存命中 → 立即 ready |
把真实配图的 src 改成不存在的路径,触发 error:
1 | document.querySelector('.post-body img').src = '/images/posts/broken-' + Date.now() + '.svg'; |
结果验证通过:图片 404 时 zz-img-loading 被移除、zz-img-ready 加上、骨架消失、图片淡入。
教训:测异步 UI 状态时,与其费劲造条件触发中间态,不如把「终态」和「异常态」都测一遍。 中间态只是这两者之间的插值。
另外验证 pjax 生命周期时,playwright-cli 的 console.log 不回显到 stdout,得用 eval 配合返回值来做断言:
1 | playwright-cli eval "(() => JSON.stringify({log: window.__log, url: location.pathname}))()" |
四、验收结果
用 playwright 逐项确认过:
| 检查项 | 结果 |
|---|---|
pjax:send 后进度条出现 | transform 从 -100% 爬到 -49.95% |
pjax:complete 后清理 | 节点被移除,无残留 |
| 真实点击「日志」菜单 | pjax:send → complete → success 全序列触发 |
| 换页后进度条 | 正确清理(progressGone: true) |
| 跨页后图片骨架 | 新页面图片自动包裹并 ready |
| 图片 404 | 骨架撤除,图片淡入,不卡死 |
| 浅色 + 樱粉 | 渐变粉色,辉光可见 |
| 深色 + 藤紫 | 渐变自动跟随主题色 |
一共 CSS 约 60 行 + JS 约 120 行,零新增依赖。
小结
这两个功能本身不难,难的是想清楚几个问题:
加载反馈的本质不是「显示进度」,而是「消除不确定感」。 用户不在意你进度条走到 37% 还是 62%,他在意的是「我点了到底有没有生效」。所以那个 MAX = 0.994 的设计 —— 宁可停在 99% 也不假装完成 —— 比精确的百分比更重要。
骨架屏的作用不是「更好看」,是「占住位置」。 布局跳动(CLS)比加载本身更让人烦躁。灰底把空间占住,图片到了直接淡入,不推挤任何东西。
兜底比主流程更容易出问题。 主流程跑通只是「正常情况下能用」,真正的考验是图片 404、pjax 失败、链接是新窗口、用户开了减少动画。上面三个 if 和两个事件监听,全是为了不在这四种情况下留一个「永远在转」的东西在屏幕上。
写这类东西有个心态上的提醒:动画层的 bug 是最烦人的 bug。功能坏了能报错,白屏了能看出来,但「进度条卡在 99% 不消失」这种,只有盯着屏幕发呆三十秒的人才会发现。所以每个分支都得手动过一遍。