Files
wordpress-kratos-yang/assets/js/lightbox.js
T
laoyang f3583e07c5 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
2026-10-25 23:03:13 +08:00

588 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*!
* 图片灯箱(正文图片点击放大、多图轮播、键盘切换)
* Yongquan Yang <yangyq.net>
*
* ── 设计原则:没有 JS 也要能用 ────────────────────────────────
* 服务端已经给每个图片链接写好了 `href="原图"`,所以**无 JS 时点击
* 仍然是打开原图**(浏览器默认行为)。这个文件只负责把那次跳转
* 改成「在当前页覆盖展示」——任何异常都只是退回默认行为,不会白屏、
* 不会点了没反应。
*
* ── 为什么不用现成的灯箱库 ────────────────────────────────────
* 主题一直坚持零第三方依赖(不需要 CDN、不需要打包、不需要许可证审查)。
* 这里要的功能就三样:覆盖层、左右轮播、键盘响应。原生 API 足够,
* 引一个几十 KB 的库反而多一份被 CDN 挂掉就全站变慢的风险。
*
* ── 键盘约定 ──────────────────────────────────────────────────
* ← → 上一张/下一张(用户明确要求「左右可以查看」)
* ↑ ↓ 同样上一张/下一张(用户明确要求「上下左右键都可以切换」)
* Esc 关闭
* Home / End 首张 / 末张
* 打开时要**记住焦点来源**,关闭后把焦点还给那张图 —— 否则键盘用户
* 关掉之后焦点落在 body 上,得从头 Tab 一遍才能回到原处。
*/
;(function () {
'use strict'
var CFG = window.YANG_LB || {}
/* ── 状态 ───────────────────────────────────────────────── */
var items = [] // 当前文章里所有可点图片:[{ href, alt, el }]
var box = null // 覆盖层根节点
var imgEl = null // 当前展示的 <img>(原图)
var phEl = null // 占位 <img>(正文里已缓存的那张缩略图)
var spinEl = null // 加载指示
var capEl = null // 底部说明(alt)
var cntEl = null // 计数「3 / 8」
var btnPrev = null
var btnNext = null
var cur = 0
var lastFocus = null // 打开前的焦点,关闭时归还
var opened = false
/* 当前这一张的加载状态。异步回调里必须比对 token,
否则「点 A → 还在下载就翻到 B」时,A 的 onload 回来
会把 B 的界面标记成已就绪。 */
var loadToken = 0
var pendingHref = '' // 正在等的那张原图地址(用于命中缓存判断)
/* 锁滚动时记住原来的值,关闭时精确还原
⚠️ 不要写 `document.body.style.overflow = ''` 一刀切:
主题或插件可能本来就在 body 上设了 overflow,清空会把它弄丢。 */
var prevOverflow = null
var prevPaddingRight = null
/* ── 工具 ───────────────────────────────────────────────── */
function el (tag, cls, txt) {
var n = document.createElement(tag)
if (cls) { n.className = cls }
if (txt != null) { n.textContent = txt }
return n
}
/* 相对地址补成绝对地址:灯箱里要用 URL 做比较,
而且 <img src> 用绝对地址能避免相对路径在覆盖层里解析错。 */
function abs (u) {
if (!u) { return '' }
try {
return new URL(u, document.baseURI).href
} catch (e) {
return u
}
}
function fmt (tpl, a, b) {
return String(tpl || '%1$s / %2$s')
.replace('%1$s', a)
.replace('%2$s', b)
}
/* ── 收集可点图片 ───────────────────────────────────────── */
/* 判据与服务端一致:`data-yang-lb="1"`。
不用 `.content img` 一把梭 —— 那会把侧栏卡片、相关推荐里的图
也算进来,轮播顺序会变得莫名其妙(用户点的是正文第 2 张,
结果「下一张」跳到了页面底部的推荐图)。 */
function collect () {
var nodes = document.querySelectorAll('a[data-yang-lb="1"]')
var out = []
for (var i = 0; i < nodes.length; i++) {
var a = nodes[i]
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,
thumb: thumb ? abs(thumb) : ''
})
}
return out
}
/* ── 构建覆盖层(懒创建:只有真的点开过才建 DOM)───────── */
function build () {
if (box) { return }
box = el('div', 'yang-lb')
box.setAttribute('role', 'dialog')
box.setAttribute('aria-modal', 'true')
box.setAttribute('aria-label', '图片查看')
box.setAttribute('tabindex', '-1')
box.setAttribute('data-theme', CFG.theme === 'light' ? 'light' : 'dark')
var stage = el('div', 'yang-lb-stage')
btnPrev = el('button', 'yang-lb-nav yang-lb-prev')
btnPrev.setAttribute('type', 'button')
btnPrev.setAttribute('aria-label', CFG.prev || '上一张')
btnPrev.innerHTML = '<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">'
+ '<path d="M15 4 L7 12 L15 20" fill="none" stroke="currentColor" '
+ 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>'
btnNext = el('button', 'yang-lb-nav yang-lb-next')
btnNext.setAttribute('type', 'button')
btnNext.setAttribute('aria-label', CFG.next || '下一张')
btnNext.innerHTML = '<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">'
+ '<path d="M9 4 L17 12 L9 20" fill="none" stroke="currentColor" '
+ 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>'
var btnClose = el('button', 'yang-lb-close')
btnClose.setAttribute('type', 'button')
btnClose.setAttribute('aria-label', CFG.close || '关闭')
btnClose.innerHTML = '<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">'
+ '<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)
box.appendChild(stage)
box.appendChild(btnNext)
var foot = el('div', 'yang-lb-foot')
foot.appendChild(capEl)
foot.appendChild(cntEl)
box.appendChild(foot)
document.body.appendChild(box)
/* 点空白处关闭:判 `e.target === box` 才算「空白」。
⚠️ 不能用 `box.contains(e.target)` 反向判 —— 那样点图片本身
也会被当成空白(图片是 stage 的子节点,但不是 box 的直接
空白区),一放大地图就关掉了。 */
box.addEventListener('click', function (e) {
if (e.target === box || e.target.classList.contains('yang-lb-stage')) {
hide()
}
})
btnClose.addEventListener('click', function (e) {
e.preventDefault()
hide()
})
btnPrev.addEventListener('click', function (e) {
e.preventDefault()
e.stopPropagation()
go(cur - 1)
})
btnNext.addEventListener('click', function (e) {
e.preventDefault()
e.stopPropagation()
go(cur + 1)
})
/* 触屏左右滑动 —— 手机上箭头不好按,滑动是本能动作。
只判水平位移,且要大于竖直位移,避免和页面滚动抢手势。 */
var tx = 0
var ty = 0
var tracking = false
box.addEventListener('touchstart', function (e) {
if (e.touches.length !== 1) { return }
tx = e.touches[0].clientX
ty = e.touches[0].clientY
tracking = true
}, { passive: true })
box.addEventListener('touchend', function (e) {
if (!tracking) { return }
tracking = false
var t = e.changedTouches && e.changedTouches[0]
if (!t) { return }
var dx = t.clientX - tx
var dy = t.clientY - ty
if (Math.abs(dx) > 45 && Math.abs(dx) > Math.abs(dy) * 1.4) {
go(dx < 0 ? cur + 1 : cur - 1)
}
}, { passive: true })
}
/* ── 显示 / 隐藏 ────────────────────────────────────────── */
function lockScroll () {
prevOverflow = document.body.style.overflow
prevPaddingRight = document.body.style.paddingRight
/* 滚动条宽度补偿:隐藏滚动条会让页面横向「跳」一下,
加等宽 padding 就不会。只在真有滚动条时补。 */
var sbw = window.innerWidth - document.documentElement.clientWidth
document.body.style.overflow = 'hidden'
if (sbw > 0) {
var pad = parseInt(window.getComputedStyle(document.body).paddingRight, 10) || 0
document.body.style.paddingRight = (pad + sbw) + 'px'
}
}
function unlockScroll () {
document.body.style.overflow = prevOverflow == null ? '' : prevOverflow
document.body.style.paddingRight = prevPaddingRight == null ? '' : prevPaddingRight
}
function paint () {
var it = items[cur]
if (!it) { return }
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'
cntEl.textContent = fmt(CFG.counter, cur + 1, items.length)
/* 只有一张图时箭头没有意义,藏起来(但要保留占位,
否则图片会突然变宽、视觉上跳一下) */
var multi = items.length > 1
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) {
collectInto()
if (!items.length) { return }
build()
cur = Math.max(0, Math.min(i, items.length - 1))
lastFocus = document.activeElement
paint()
box.classList.add('is-open')
opened = true
lockScroll()
/* 焦点移到覆盖层:键盘用户按 Esc 才能被我们接住。
⚠️ 必须 preventScroll —— 有些浏览器 focus() 会把页面滚到元素位置,
覆盖层是 position:fixed,滚了就等于背景跳一下。 */
try {
box.focus({ preventScroll: true })
} catch (e) {
box.focus()
}
}
function hide () {
if (!opened || !box) { return }
opened = false
box.classList.remove('is-open', 'is-loading')
unlockScroll()
/* 清掉 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 })
} catch (e) {
lastFocus.focus()
}
}
lastFocus = null
}
function go (i) {
if (!items.length) { return }
/* 循环:到最后一张再往后回到第一张。
用户说的是「轮播」,循环是轮播的应有之义。 */
cur = (i + items.length) % items.length
paint()
}
/* 每次打开都重新收集:某些页面会动态插入图片(懒加载插件、
评论区异步加载),缓存一份数组会漏掉新图。 */
function collectInto () {
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 跑完之后才插入
(无限滚动、某些编辑器动态渲染)。绑在具体元素上会漏。 */
document.addEventListener('click', function (e) {
var a = e.target
while (a && a !== document) {
if (a.nodeType === 1 && a.getAttribute && a.getAttribute('data-yang-lb') === '1') {
/* 只接管「普通左键点击」:
Ctrl/Cmd/Shift/中键 都要放行 —— 用户想在新标签页看图
是他的正当选择,拦掉会被骂。 */
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return
}
e.preventDefault()
collectInto()
var idx = 0
for (var i = 0; i < items.length; i++) {
if (items[i].el === a) { idx = i; break }
}
show(idx)
return
}
a = a.parentNode
}
}, false)
document.addEventListener('keydown', function (e) {
if (!opened) { return }
/* 输入框里按方向键不该翻图(虽然覆盖层里没有输入框,
但插件可能往里塞东西,防一手) */
var t = e.target
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
return
}
var k = e.key
if (k === 'Escape' || k === 'Esc') {
e.preventDefault()
hide()
return
}
if (k === 'ArrowLeft' || k === 'ArrowUp') {
e.preventDefault()
go(cur - 1)
return
}
if (k === 'ArrowRight' || k === 'ArrowDown') {
e.preventDefault()
go(cur + 1)
return
}
if (k === 'Home') {
e.preventDefault()
go(0)
return
}
if (k === 'End') {
e.preventDefault()
go(items.length - 1)
}
}, false)
/* 窗口尺寸变化时不需要重算 —— 图片用 max-width/max-height 自适应,
这里只是把「滚动条宽度补偿」重做一遍更精确,但代价不值。
跳过。 */
/* 暴露一个极小接口,方便主题自己的探针与用户脚本触发 */
window.YangLightbox = {
open: function (i) { show(typeof i === 'number' ? i : 0) },
close: hide,
next: function () { go(cur + 1) },
prev: function () { go(cur - 1) },
count: function () { return collect().length },
isOpen: function () { return opened }
}
})()