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: 博客, 双栏, 响应式, 自定义背景, 自定义颜色, 自定义图标, 自定义菜单, 特色图像, 收录优化, 邮件中心, 主题选项, 小工具, 文章置顶, 无障碍友好