diff --git a/assets/css/lightbox.css b/assets/css/lightbox.css
index dfc34db..be15b31 100644
--- a/assets/css/lightbox.css
+++ b/assets/css/lightbox.css
@@ -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;
}
diff --git a/assets/js/lightbox.js b/assets/js/lightbox.js
index cb35f94..a4071d3 100644
--- a/assets/js/lightbox.js
+++ b/assets/js/lightbox.js
@@ -29,7 +29,9 @@
/* ── 状态 ───────────────────────────────────────────────── */
var items = [] // 当前文章里所有可点图片:[{ href, alt, el }]
var box = null // 覆盖层根节点
- var imgEl = null // 当前展示的
+ var imgEl = null // 当前展示的
(原图)
+ var phEl = null // 占位
(正文里已缓存的那张缩略图)
+ 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 @@
+ ''
+ /* 两层结构(原因见 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 跑完之后才插入
diff --git a/style.css b/style.css
index 278a2b0..4d4eb55 100644
--- a/style.css
+++ b/style.css
@@ -1,7 +1,7 @@
/*
Theme Name: Kratos_Yang
Text Domain: kratos_Yang
-Version: 3.2.40
+Version: 3.2.41
Requires PHP: 7.0
Description: 这是一个Kratos主题的分支版本,用来增加一些定制化功能。
Tags: 博客, 双栏, 响应式, 自定义背景, 自定义颜色, 自定义图标, 自定义菜单, 特色图像, 收录优化, 邮件中心, 主题选项, 小工具, 文章置顶, 无障碍友好