加载反馈的两种做法:pjax 进度条与图片骨架屏

上一篇写完「零依赖前端增强」,我在评论区给自己提了个问题:

既然那么在意体验,加载动画为什么没做?

想了一下,确实是个 oversight。我们的 pjax 换页现在是这样:

  • 点菜单 → 旧页面淡出 → 新页面淡入

中间那段「等待新 HTML 拉回来」的时间是完全无反馈的。本地服务器上大概 50ms 就过去了,感受不到;但 EdgeOne 跨省回源、或者手机弱网下可能要几百毫秒甚至一两秒 —— 这段时间用户看到的是「我点了,怎么没反应」。

配图也是同理。文章里的 SVG 配图从 opacity: 1 直接跳到可见,虽然有 <transition> 但那是 NexT 主题统一加的淡入,在骨架层面没有任何东西。

所以这轮补两块:顶部进度条和图片骨架屏。


一、顶部进度条:nprogress 的思路

很多 Hexo 主题的进度条都是引 nprogress 这个库(1KB gzip)。但我看了 Sakurairo 内嵌的实现后,发现真正需要的部分只有 50 行 CSS,于是决定手写。

它的结构是两个元素:

1
2
3
4
5
<div id="zzprogress">
<div class="zzprogress-bar">
<span class="zzprogress-peg"></span>
</div>
</div>

样式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#zzprogress {
position: fixed;
top: 0;
left: 0;
z-index: 9999;
width: 100%;
height: 3px;
opacity: 0; // 平时透明,靠 .on 点亮
pointer-events: none; // 绝对不能挡住点击
transition: opacity 0.2s var(--ease);

&.on { opacity: 1; }

.zzprogress-bar {
position: relative;
width: 100%;
height: 100%;
background: var(--accent-grad); // 跟随主题色
transform: translate3d(-100%, 0, 0); // 起点:整体在视口左侧
transition: transform 0.3s var(--ease);
}

.zzprogress-peg {
position: absolute;
right: 0;
width: 90px;
height: 100%;
transform: rotate(3deg) translate(0, -3px); // 歪一点
box-shadow: 0 0 10px var(--accent-line), 0 0 5px var(--accent);
}
}

rotate(3deg) 那个小歪角是精髓 —— 纯矩形进度条看起来像「网页没画完」,带个斜切尖端才像「正在走」。加上双层 box-shadow 辉光,深色模式下也有存在感。

进度怎么算:假装自己不知道

真实进度条能显示百分比,是因为它知道总量和已加载量。pajax 换页这两样都没有 —— 你不知道新 HTML 有多大,也不知道还要多久。

nprogress 的解法很朴素:随机递增,装作在动。

1
2
3
4
5
6
7
8
9
10
11
12
13
var MIN = 0.08;       // 最小可见量
var MAX = 0.994; // 永远不主动到 1
var TRICKLE_MS = 160; // 爬行间隔

function trickle() {
timer = setTimeout(function () {
if (status >= MAX) return;
// 越接近上限涨得越慢,否则后半段会突然窜到头
status += Math.random() * 0.06 * (1 - status);
render(status);
trickle();
}, TRICKLE_MS);
}

三个参数都有讲究:

为什么是 MAX = 0.994 而不是 1?
如果条子爬到 100% 但页面还没来,用户会立刻察觉到「骗人」。卡在 99% 反而更像「快好了」,主观等待感更短。这是个纯粹的心理设计。

为什么增量要乘 (1 - status)?
线性递增的话,前面涨得慢、后面突然窜出去。乘上剩余量之后是减速曲线,视觉上更接近「匀速 + 接近终点时放缓」。

为什么要 MIN = 0.08?
进度太小的话条子几乎不动,会让人以为根本没触发。从 8% 起步,第一帧就能看到东西。

触发时机与兜底

1
2
3
document.addEventListener('pjax:send',     start);
document.addEventListener('pjax:complete', done);
document.addEventListener('pjax:error', done); // 失败也要收

三个都要挂。漏掉 pjax:error 的话,请求失败时条子会永远留在屏幕上。

还有一个容易忽略的场景:点了链接但 pjax 没接管。

1
2
3
4
5
6
7
8
9
10
document.addEventListener('click', function (e) {
var a = e.target.closest('a[href]');
if (!a) return;
if (a.target && a.target !== '_self') return; // 新窗口
if (a.hasAttribute('download')) return; // 下载
if (a.getAttribute('href').charAt(0) === '#') return; // 锚点

// 同一 tick 内 pjax:send 还没触发,所以延时确认
setTimeout(function () { if (status === null) done(); }, 60);
});

target="_blank"、下载链接、页内锚点这三种都不会触发 pjax。如果不处理,用户点了个新窗口链接,看到一条永远不消失的进度条。

那个 setTimeout(..., 60) 的 60ms 是个权衡:pjax 的 pjax:send 是在点击处理链里同步派发的,但保险起见留一点余量。给太短会误杀,给太长(超过 100ms)弱网下会有可察觉的「条子卡住」。

我砍掉了 spinner

nprogress 原本右上角还有一个转圈 spinner。我去掉了,理由:

3px 的细条已经足够表达「在加载」,多一个转圈反而抢视觉。而 spinner 放在右上角在移动端容易和返回手势区域打架。

另外它的触发我只挂在 pjax 上,没做「计时器兜底」。原因是首屏加载时浏览器自带的转圈图标已经在转了,再叠一层纯属画蛇添足。pjax 换页才是浏览器「不介入」的那个空档。


二、图片骨架屏:一道扫光

思路更简单:图片没加载完时铺一层灰底 + 一道斜向微光慢慢扫过去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@keyframes zz-shimmer {
100% { transform: translateX(100%); }
}

.zz-img-wrap {
position: relative;
overflow: hidden;
background: var(--surface-sunken);

&::before {
content: '';
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background: linear-gradient(
100deg,
transparent 20%,
var(--accent-faint) 50%,
transparent 80%
);
transform: translateX(-100%);
animation: zz-shimmer 1.8s var(--ease) infinite;
}

img { opacity: 0; transition: opacity 0.4s var(--ease); }

&.zz-img-ready {
&::before { display: none; }
img { opacity: 1; }
}
}

微光用 100deg 而不是 90deg —— 完全水平会显得像加载条,斜一点更像「高光扫过物体表面」。

颜色用的是 var(--accent-faint) 而不是纯白,所以深色模式下会自然变成暗色调的微光,不需要额外适配。

包裹逻辑的几个细节

图片本身是裸 <img>,没有外层容器,所以要 JS 包一层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function wrap(img) {
if (!img || img.dataset.zzWrapped) return; // 幂等
img.dataset.zzWrapped = '1';

// 跳过明显不该套骨架的
if (img.src.indexOf('data:') === 0) return; // 内联小图
if (/\.gif(\?|$)/i.test(img.src)) return; // GIF 本身就在动

var box = document.createElement('span');
box.className = 'zz-img-wrap';
img.parentNode.insertBefore(box, img);
box.appendChild(img);

// 缓存命中时已经加载好了,直接就绪,不闪一下
if (img.complete && img.naturalWidth > 0) {
box.classList.add('zz-img-ready');
return;
}
box.classList.add('zz-img-loading');

img.addEventListener('load', function () { /* 切 ready */ }, { once: true });
img.addEventListener('error', function () { /* 同样切 ready */ }, { once: true });
}

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
2
// 分支 A:缓存命中 → 立即 ready
// 分支 B:error 事件 → 也必须 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% 不消失」这种,只有盯着屏幕发呆三十秒的人才会发现。所以每个分支都得手动过一遍。