perf(lightbox): 点开图片不再白屏干等,先糊后清 #10
用户反馈:点一张图之后先是白背景渲染出来,图片迟迟不出现,感觉特别慢。 实测归因(_v40_slow.py,自建慢速 HTTP 服务,3.2 Mbps): · 遮罩可见 31ms —— 纯 CSS 底色,一帧就画完,从来不是瓶颈 · 图片有像素 6 秒后仍然没有 线上原图最大 3.2MB(PUA的两面性与界定-scaled.png),3Mbps 下要 8 秒以上, 这段时间屏幕上是整片白 + 一个 0x0 的空 img。 三层改造: 1. 缩略图占位(blur-up):点开瞬间把正文里已经解码过的 currentSrc 顶上, 0 延迟就有像素;原图到了再淡入覆盖。 2. 原图淡入:is-ready 后 opacity 0→1,避免看到渐进渲染的半张图。 3. 空闲预取:requestIdleCallback 里串行预取视口上下 1.5 屏内的原图, 用 new Image() 不塞 DOM;尊重 saveData / slow-2g。 CSS 的 .yang-lb-stage 从 flex 改 grid,占位层/原图/转圈都 grid-area:1/1 叠在同一个格子(flex 会让子元素并排,absolute 又不参与居中)。 实测(_v40_opt2.py,5/0): 立刻点(最坏) 遮罩 25ms · 首帧有像素 25ms · 原图 15012ms 读 12 秒后点 遮罩 25ms · 首帧有像素 25ms · 原图 25ms(预取命中) 服务端各收到 1 次请求,预取一次命中、没有重复下载。 验证:交互 245/0(新增 J 段占位层与加载态 45 条)· 服务端 55/0(8.4+8.5) · 演示页 34/0 · 反向验证 27/27(新增 U11~U14) Refs #10
This commit is contained in:
+97
-8
@@ -104,16 +104,43 @@ body.yang-lb-lock {
|
||||
/* ── 图片舞台 ─────────────────────────────────────────────── */
|
||||
|
||||
.yang-lb-stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* 🔴 这里从 flex 改成 grid:要让「占位图」和「原图」**叠在同一个格子**里。
|
||||
用 flex 的话两个子元素会并排(一个在左一个在右),必须绝对定位才能叠,
|
||||
而 absolute 就不参与居中 → 又要手动算偏移,脆且难调。
|
||||
grid-area 叠放是最省事的:两图都居中,cell 尺寸由两者较大者决定。 */
|
||||
display: grid;
|
||||
place-items: center;
|
||||
/* 给左右箭头留出空间,避免大图被箭头压住 */
|
||||
width: calc(100% - 132px);
|
||||
height: calc(100% - 96px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── 两层图片:占位层 + 原图层 ─────────────────────────────
|
||||
*
|
||||
* ── 为什么要两层 ────────────────────────────────────────────
|
||||
* 用户反馈:「点一张图之后,先把这个白色背景渲染出来,然后图再出来,
|
||||
* 感觉速度特别慢」。实测(_v40_slow.py,自建慢速 HTTP 服务):
|
||||
*
|
||||
* 遮罩可见 31 ms ← 纯 CSS 底色,一帧就画完,从来不是瓶颈
|
||||
* 图片有像素 6 秒后仍然没有
|
||||
*
|
||||
* 线上原图实测最大 3.2 MB(`PUA的两面性与界定-scaled.png`),
|
||||
* 3 Mbps 下要 8 秒以上。这 8 秒里屏幕上是**整片白 + 一个 0×0 的空 img**,
|
||||
* 用户看到的就是「白背景先出来,图后到」。
|
||||
*
|
||||
* 解法:点开的瞬间先把**正文里那张已经在缓存里的缩略图**放上去。
|
||||
* 它早就下载并解码过了,0 延迟就有像素 —— 屏幕立刻「有东西」,
|
||||
* 只是糊一点;原图到了再淡入覆盖。这是业界通行的 blur-up,
|
||||
* 视觉上是「先糊后清」的连续过程,而不是「先空后突然出现」。
|
||||
*
|
||||
* ⚠️ 占位图必须 `pointer-events: none`:它只是一层视觉垫底,
|
||||
* 点击应该落在原图上(点图不关闭),不能被垫底挡住。
|
||||
*/
|
||||
.yang-lb-ph,
|
||||
.yang-lb-img {
|
||||
/* 叠在同一个格子里 —— 这是两层方案能成立的关键一行 */
|
||||
grid-area: 1 / 1;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
@@ -121,19 +148,72 @@ body.yang-lb-lock {
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.yang-lb-ph {
|
||||
/* 垫底:放大后自然发糊,不额外加 blur 滤镜
|
||||
(滤镜在大图上要额外一次全图离屏合成,慢网下雪上加霜) */
|
||||
pointer-events: none;
|
||||
/* 没拿到缩略图时(比如外链图床)不留一个空框 */
|
||||
display: none;
|
||||
}
|
||||
|
||||
.yang-lb-ph.is-on {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.yang-lb-img {
|
||||
position: relative; /* 压住占位层,确保点击落在原图上 */
|
||||
z-index: 1;
|
||||
box-shadow: var(--lb-shadow);
|
||||
border-radius: 2px;
|
||||
background-color: transparent;
|
||||
/* 图片本身要能接收点击(点图不关闭),但舞台是 none,
|
||||
所以这里必须显式改回来 */
|
||||
pointer-events: auto;
|
||||
/* 大图加载时的淡入,避免「啪」一下出现 */
|
||||
animation: yang-lb-fade 0.2s ease;
|
||||
/* 原图未就绪时先隐藏:直接显示会看到「半张图」的渐进渲染过程。
|
||||
用 opacity 而不是 display,是为了让它**参与布局**,
|
||||
该占的尺寸提前占住,避免 ready 那一刻整层跳动。 */
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes yang-lb-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
/* 原图已解码完成 → 淡入覆盖占位图 */
|
||||
.yang-lb-img.is-ready {
|
||||
opacity: 1;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
/* ⚠️ 占位图在原图就绪后不能立刻 display:none —— 那样 cell 尺寸会
|
||||
瞬间由「占位图尺寸」变成「原图尺寸」,如果两者不等就有一次跳动。
|
||||
让它淡出,淡出期间 cell 不会变(两个都在布局里)。 */
|
||||
.yang-lb-ph.is-fade {
|
||||
opacity: 0;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
/* ── 加载指示:慢网下必须让用户知道「在加载」而不是「坏了」─────
|
||||
* 3.2 MB 的原图在 3 Mbps 下要 8 秒。这 8 秒里如果只是「糊图慢慢等」,
|
||||
* 用户会怀疑是不是点坏了。一个很轻的脉冲点就够表达「还在来」。
|
||||
* 用 animationend 之外的纯 CSS,不额外占 JS。 */
|
||||
.yang-lb-spin {
|
||||
grid-area: 1 / 1;
|
||||
z-index: 2;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--lb-ctl-bd);
|
||||
border-top-color: var(--lb-fg);
|
||||
animation: yang-lb-rot 0.8s linear infinite;
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.yang-lb.is-loading .yang-lb-spin {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes yang-lb-rot {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── 左右箭头 ─────────────────────────────────────────────── */
|
||||
@@ -313,12 +393,21 @@ body.yang-lb-lock {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.yang-lb,
|
||||
.yang-lb-img,
|
||||
.yang-lb-ph,
|
||||
.yang-lb-nav,
|
||||
.yang-lb-close {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* 转圈是唯一的「还在加载」提示,减少动效下不能整个删掉,
|
||||
否则慢网用户看不到任何反馈。改成静止的文字色圆环(不那么晃眼)。 */
|
||||
.yang-lb-spin {
|
||||
animation: none;
|
||||
border-top-color: var(--lb-ctl-bd);
|
||||
border-right-color: var(--lb-fg);
|
||||
}
|
||||
|
||||
.yang-lb-close:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
+229
-5
@@ -29,7 +29,9 @@
|
||||
/* ── 状态 ───────────────────────────────────────────────── */
|
||||
var items = [] // 当前文章里所有可点图片:[{ href, alt, el }]
|
||||
var box = null // 覆盖层根节点
|
||||
var imgEl = null // 当前展示的 <img>
|
||||
var imgEl = null // 当前展示的 <img>(原图)
|
||||
var phEl = null // 占位 <img>(正文里已缓存的那张缩略图)
|
||||
var spinEl = null // 加载指示
|
||||
var capEl = null // 底部说明(alt)
|
||||
var cntEl = null // 计数「3 / 8」
|
||||
var btnPrev = null
|
||||
@@ -37,6 +39,11 @@
|
||||
var cur = 0
|
||||
var lastFocus = null // 打开前的焦点,关闭时归还
|
||||
var opened = false
|
||||
/* 当前这一张的加载状态。异步回调里必须比对 token,
|
||||
否则「点 A → 还在下载就翻到 B」时,A 的 onload 回来
|
||||
会把 B 的界面标记成已就绪。 */
|
||||
var loadToken = 0
|
||||
var pendingHref = '' // 正在等的那张原图地址(用于命中缓存判断)
|
||||
|
||||
/* 锁滚动时记住原来的值,关闭时精确还原
|
||||
⚠️ 不要写 `document.body.style.overflow = ''` 一刀切:
|
||||
@@ -84,10 +91,21 @@
|
||||
var href = abs(a.getAttribute('href'))
|
||||
if (!href) { continue }
|
||||
var im = a.querySelector('img')
|
||||
/* 动手前先把缩略图地址记下来 —— 这是「占位层」唯一的来源。
|
||||
⚠️ 必须用 `currentSrc` 优先:img 可能带 srcset,
|
||||
浏览器实际显示的是 currentSrc 挑出来的那张,
|
||||
它才是**已经在内存里解码过**的那一份。
|
||||
用 getAttribute('src') 会拿到一个可能没下载过的候选,
|
||||
点了之后还要再下一次,占位就白做了。 */
|
||||
var thumb = ''
|
||||
if (im) {
|
||||
thumb = im.currentSrc || im.src || im.getAttribute('src') || ''
|
||||
}
|
||||
out.push({
|
||||
href: href,
|
||||
alt: (im && im.getAttribute('alt')) || '',
|
||||
el: a
|
||||
el: a,
|
||||
thumb: thumb ? abs(thumb) : ''
|
||||
})
|
||||
}
|
||||
return out
|
||||
@@ -128,14 +146,34 @@
|
||||
+ '<path d="M5 5 L19 19 M19 5 L5 19" fill="none" stroke="currentColor" '
|
||||
+ 'stroke-width="2" stroke-linecap="round"/></svg>'
|
||||
|
||||
/* 两层结构(原因见 lightbox.css 里 .yang-lb-ph 的注释):
|
||||
垫底 phEl —— 正文里那张已缓存的缩略图,点开瞬间就有像素
|
||||
上浮 imgEl —— 原图,解码完成后淡入覆盖
|
||||
转圈 spinEl —— 慢网下唯一能表达「还在来」的东西
|
||||
三者叠在 stage 的同一个 grid 格子里,居中由 CSS 负责。 */
|
||||
phEl = el('img', 'yang-lb-ph')
|
||||
phEl.setAttribute('alt', '')
|
||||
phEl.setAttribute('aria-hidden', 'true')
|
||||
/* 占位图是**装饰**:屏幕阅读器不该读它,
|
||||
也不该因为它参与一次图片语义(原图已经有一个了)。 */
|
||||
|
||||
imgEl = el('img', 'yang-lb-img')
|
||||
imgEl.setAttribute('alt', '')
|
||||
imgEl.setAttribute('decoding', 'async')
|
||||
/* fetchpriority 只对「还没开始下载」的图有意义;
|
||||
这里原图是点开才下的,正是它的用武之地 —— 让浏览器
|
||||
不要把它排在页面其它资源后面。 */
|
||||
imgEl.setAttribute('fetchpriority', 'high')
|
||||
|
||||
spinEl = el('div', 'yang-lb-spin')
|
||||
spinEl.setAttribute('aria-hidden', 'true')
|
||||
|
||||
capEl = el('div', 'yang-lb-cap')
|
||||
cntEl = el('div', 'yang-lb-count')
|
||||
|
||||
stage.appendChild(phEl)
|
||||
stage.appendChild(imgEl)
|
||||
stage.appendChild(spinEl)
|
||||
|
||||
box.appendChild(btnClose)
|
||||
box.appendChild(btnPrev)
|
||||
@@ -221,7 +259,28 @@
|
||||
function paint () {
|
||||
var it = items[cur]
|
||||
if (!it) { return }
|
||||
imgEl.setAttribute('src', it.href)
|
||||
|
||||
var myToken = ++loadToken
|
||||
|
||||
/* ① 占位层:先用正文里那张已解码的缩略图顶上。
|
||||
⚠️ 只有「缩略图与原图不是同一个地址」时才启用。
|
||||
本站在多数情况下 href 与 img.src 完全相同(同一文件),
|
||||
这时占位图等于原图 —— 直接用反而更快(已在缓存里),
|
||||
不需要等原图第二次请求。 */
|
||||
phEl.classList.remove('is-fade')
|
||||
if (it.thumb && it.thumb !== it.href) {
|
||||
phEl.setAttribute('src', it.thumb)
|
||||
phEl.classList.add('is-on')
|
||||
} else {
|
||||
/* 没有独立缩略图:不改 src(保留上一次的会显示错的图),
|
||||
直接藏起来,让原图自己承担首帧。 */
|
||||
phEl.removeAttribute('src')
|
||||
phEl.classList.remove('is-on')
|
||||
}
|
||||
|
||||
/* ② 原图层:先复位成「未就绪」。
|
||||
复位必须**在同一次同步任务里**完成,否则会闪一下上一张的图。 */
|
||||
imgEl.classList.remove('is-ready')
|
||||
imgEl.setAttribute('alt', it.alt || '')
|
||||
capEl.textContent = it.alt || ''
|
||||
capEl.style.display = it.alt ? '' : 'none'
|
||||
@@ -233,6 +292,57 @@
|
||||
btnPrev.style.visibility = multi ? 'visible' : 'hidden'
|
||||
btnNext.style.visibility = multi ? 'visible' : 'hidden'
|
||||
cntEl.style.display = multi ? '' : 'none'
|
||||
|
||||
/* ③ 原图:命中缓存就立刻上屏,否则转圈等加载
|
||||
🔴 `pendingHref` 是给「命中缓存」判据用的。
|
||||
浏览器对**已缓存**的图片,设置 src 后 complete 立刻为 true
|
||||
且不会触发 load 事件 —— 只挂 onload 会永远等不到,
|
||||
图片永远 opacity:0(用户会以为点了没反应)。
|
||||
所以这里必须同步查一次 complete。 */
|
||||
pendingHref = it.href
|
||||
box.classList.add('is-loading')
|
||||
|
||||
imgEl.onload = function () {
|
||||
if (myToken !== loadToken) { return } // 已经翻到别的图了
|
||||
onImgReady()
|
||||
}
|
||||
imgEl.onerror = function () {
|
||||
if (myToken !== loadToken) { return }
|
||||
/* 原图挂了(CDN 404、被防盗链拦、离线):
|
||||
别把界面卡在转圈上。把占位图留在屏幕上 ——
|
||||
它至少是这张图的真实内容,只是糊。 */
|
||||
box.classList.remove('is-loading')
|
||||
if (phEl.classList.contains('is-on')) {
|
||||
phEl.classList.remove('is-fade')
|
||||
} else {
|
||||
/* 连占位图都没有:只能显示 alt 文本,至少让人知道发生了什么 */
|
||||
imgEl.classList.add('is-ready')
|
||||
}
|
||||
}
|
||||
|
||||
imgEl.setAttribute('src', it.href)
|
||||
|
||||
/* 同步检查:内存缓存 / 已下载完的图走这条路,不等事件 */
|
||||
if (imgEl.complete && imgEl.naturalWidth > 0) {
|
||||
onImgReady()
|
||||
}
|
||||
}
|
||||
|
||||
/* 原图可以显示了:淡入覆盖占位图、停掉转圈 */
|
||||
function onImgReady () {
|
||||
box.classList.remove('is-loading')
|
||||
imgEl.classList.add('is-ready')
|
||||
/* 占位图淡出后要摘掉 src,否则一张大缩略图会一直占着内存。
|
||||
等过渡跑完再摘,避免淡出过程中被抽走而闪黑。 */
|
||||
if (phEl.classList.contains('is-on')) {
|
||||
phEl.classList.add('is-fade')
|
||||
var phToken = loadToken
|
||||
setTimeout(function () {
|
||||
if (phToken !== loadToken) { return } // 期间又翻页了,交给新一轮管
|
||||
phEl.classList.remove('is-on', 'is-fade')
|
||||
phEl.removeAttribute('src')
|
||||
}, 240)
|
||||
}
|
||||
}
|
||||
|
||||
function show (i) {
|
||||
@@ -258,10 +368,20 @@
|
||||
function hide () {
|
||||
if (!opened || !box) { return }
|
||||
opened = false
|
||||
box.classList.remove('is-open')
|
||||
box.classList.remove('is-open', 'is-loading')
|
||||
unlockScroll()
|
||||
/* 清掉 src,让大图能被回收(长文章里逐张翻过去会占几百 MB) */
|
||||
/* 清掉 src,让大图能被回收(长文章里逐张翻过去会占几百 MB)
|
||||
⚠️ 必须同时把 onload/onerror 摘掉:清 src 在老浏览器上会触发
|
||||
error 事件,回调里又去动刚刚复位好的界面,表现为
|
||||
「关闭后立刻又自己打开」或转圈残留。 */
|
||||
loadToken++
|
||||
imgEl.onload = null
|
||||
imgEl.onerror = null
|
||||
imgEl.removeAttribute('src')
|
||||
imgEl.classList.remove('is-ready')
|
||||
phEl.classList.remove('is-on', 'is-fade')
|
||||
phEl.removeAttribute('src')
|
||||
pendingHref = ''
|
||||
if (lastFocus && lastFocus.focus) {
|
||||
try {
|
||||
lastFocus.focus({ preventScroll: true })
|
||||
@@ -286,6 +406,110 @@
|
||||
items = collect()
|
||||
}
|
||||
|
||||
/* ── 预取:让「点开」这个动作不必等下载 ─────────────────────
|
||||
*
|
||||
* 实测(_v40_slow.py,自建慢速 HTTP 服务,线上原图最大 3.2 MB):
|
||||
*
|
||||
* 带宽 3.2 Mbps:遮罩 31ms 可见,原图 6 秒后仍无像素
|
||||
* 带宽 1 Mbps :同样 31ms / 6 秒无像素
|
||||
*
|
||||
* 光靠「缩略图占位」只能**掩盖**等待,不能**消除**等待。
|
||||
* 真正把延迟干掉的办法是提前下载:用户还在读正文时,
|
||||
* 原图就已经在路上,点开那一刻直接从缓存出图(实测 2.4ms)。
|
||||
*
|
||||
* ── 什么时候开始预取 ────────────────────────────────────
|
||||
* 必须等页面自己先加载完并且空闲下来,否则会跟正文图片、
|
||||
* 字体、CSS 抢带宽 —— 那反而让**页面本身**变慢,得不偿失。
|
||||
* 所以用 requestIdleCallback(不支持就退回 load + setTimeout)。
|
||||
*
|
||||
* ── 预取到什么程度 ──────────────────────────────────────
|
||||
* 只预取「已经离视口不远、用户可能马上点」的图:
|
||||
* · 首屏就能看到的若干张 —— 一定取
|
||||
* · 视口下方 ~1.5 屏内的 —— 滚到就取
|
||||
* 远处的图不预取:一篇文章 30 张大图全预取,等于把流量翻倍,
|
||||
* 用户的流量是要花钱的。
|
||||
*
|
||||
* ── 怎么不抢带宽 ────────────────────────────────────────
|
||||
* 一次只取一张,上一张 load/error 了再取下一张。
|
||||
* 且用 `new Image()` 而不是塞进 DOM:不参与布局,不会引发布局抖动。
|
||||
*/
|
||||
var prefetched = {}
|
||||
var prefetchQueue = []
|
||||
var prefetching = false
|
||||
|
||||
function canPrefetch () {
|
||||
/* 尊重用户的省流量设置。开着省流量时预取是**反用户意愿**的:
|
||||
他明确表示不想多花流量。 */
|
||||
var c = navigator.connection
|
||||
if (c && (c.saveData || c.effectiveType === 'slow-2g')) { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
function enqueue (href) {
|
||||
if (!href || prefetched[href]) { return }
|
||||
prefetched[href] = 1
|
||||
prefetchQueue.push(href)
|
||||
drain()
|
||||
}
|
||||
|
||||
function drain () {
|
||||
if (prefetching || !prefetchQueue.length) { return }
|
||||
prefetching = true
|
||||
var href = prefetchQueue.shift()
|
||||
var im = new Image()
|
||||
var done = function () {
|
||||
prefetching = false
|
||||
/* 一张接一张:串行,绝不并发抢带宽 */
|
||||
drain()
|
||||
}
|
||||
im.onload = done
|
||||
im.onerror = done
|
||||
im.src = href
|
||||
}
|
||||
|
||||
function prefetchNearby () {
|
||||
if (!canPrefetch()) { return }
|
||||
var all = collect()
|
||||
if (!all.length) { return }
|
||||
|
||||
/* 用 getBoundingClientRect 判「离视口近不近」。
|
||||
⚠️ 不能只看 offsetTop —— 本站正文外层有定位容器,
|
||||
offsetTop 相对的是最近的定位祖先,不是文档顶。 */
|
||||
var vh = window.innerHeight || 800
|
||||
var reach = vh * 2.5 // 视口上下各 1.5 屏左右
|
||||
for (var i = 0; i < all.length; i++) {
|
||||
var r = all[i].el.getBoundingClientRect()
|
||||
if (r.bottom > -reach && r.top < vh + reach) {
|
||||
enqueue(all[i].href)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startPrefetch () {
|
||||
/* 页面都加载完了再动手,别跟首屏资源抢 */
|
||||
var run = function () { prefetchNearby() }
|
||||
if (window.requestIdleCallback) {
|
||||
requestIdleCallback(run, { timeout: 3000 })
|
||||
} else {
|
||||
setTimeout(run, 1200)
|
||||
}
|
||||
/* 滚动时补取:用户往下滚,新的图进入「可能马上点」的范围 */
|
||||
var tid = null
|
||||
window.addEventListener('scroll', function () {
|
||||
if (tid) { return }
|
||||
tid = setTimeout(function () {
|
||||
tid = null
|
||||
prefetchNearby()
|
||||
}, 300)
|
||||
}, { passive: true })
|
||||
}
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
startPrefetch()
|
||||
} else {
|
||||
window.addEventListener('load', startPrefetch)
|
||||
}
|
||||
|
||||
/* ── 事件绑定 ───────────────────────────────────────────── */
|
||||
|
||||
/* 委托到 document:正文里的图片可能在 JS 跑完之后才插入
|
||||
|
||||
Reference in New Issue
Block a user