refactor(lightbox): modernize to const/let, template literals, drop IIFE

纯现代化重构,行为不变(23 个回归测试全绿作证):
- var → const/let(state、gotoIndex 的 newIndex 用 let,其余 const)
- 循环 for+var+IIFE包装 → for..of/forEach + const(天然捕获迭代变量,
  消除旧 var 循环闭包必须立即执行函数固定变量的写法)
- 字符串拼接 → 模板字面量(counter 文本、transform、width/height)
- 事件处理 function() → 箭头函数(无 this 依赖场景)
- 拆掉外层 (function(){'use strict'; ... })():ES module 作用域已封装,
  IIFE 是历史遗留;辅助函数仍用 function 声明(openLightbox↔closeLightbox
  互递归依赖提升)

几何纯函数(fitCentered/transformFor/originalUrl)已在 geometry.ts,
本文件只剩 DOM 胶水 + state 单例。
This commit is contained in:
xfy 2026-06-24 11:44:06 +08:00
parent aadfed2595
commit 25eacc1c3c

View File

@ -33,9 +33,6 @@ declare global {
export {};
(function () {
"use strict";
// ============ 工具函数 ============
function prefersReducedMotion(): boolean {
@ -50,7 +47,7 @@ export {};
// left/top/width/height而 fitCentered/transformFor 用 x/y/w/h
// 这里转成一致格式,避免 .w 读到 undefined。
function rectOf(el: Element): Rect {
var r = el.getBoundingClientRect();
const r = el.getBoundingClientRect();
return {
x: r.left,
y: r.top,
@ -59,27 +56,23 @@ export {};
};
}
// 注意fitCentered / transformFor / originalUrl 已抽到 ./geometry.ts(见 Task 2
// 注意fitCentered / transformFor / originalUrl 已抽到 ./geometry.ts
// ============ 懒加载 ============
// 为单个 .blur-img 容器初始化高清图懒加载。
// IO 进入视口后把 data-src 写入 src加载完成加 is-loaded 触发 CSS 淡入。
function initLazyLoad(container: Element): void {
var raw = container.querySelector(".blur-img-full");
const raw = container.querySelector(".blur-img-full");
if (!(raw instanceof HTMLImageElement)) return;
// 用 const + 显式类型锁住窄化结果var 在闭包内会被放宽回 Element | null
// 导致 onFullLoaded/IntersectionObserver 回调里访问 .style/.src 报错。
var fullImg: HTMLImageElement = raw;
const fullImg: HTMLImageElement = raw;
if (container.getAttribute("data-blur-init")) return;
container.setAttribute("data-blur-init", "true");
var rawSrc = fullImg.getAttribute("data-src");
if (!rawSrc) return;
// 同上const 锁住 string 窄化,避免闭包内放宽回 string | null。
var fullSrc: string = rawSrc;
const fullSrc = fullImg.getAttribute("data-src");
if (!fullSrc) return;
var onFullLoaded = function (): void {
const onFullLoaded = (): void => {
// 给容器加 is-loadedCSS 据此显式隐藏 placeholder。
// 直接把 full 层 opacity 设为 1清掉 transition不依赖 CSS 的 opacity
// 过渡:合成层重绘时机不稳定,可能导致 full 层卡在 opacity:0直到一次
@ -96,14 +89,14 @@ export {};
}
if ("IntersectionObserver" in window) {
var io = new IntersectionObserver(
function (entries: IntersectionObserverEntry[]): void {
entries.forEach(function (entry): void {
const io = new IntersectionObserver(
(entries: IntersectionObserverEntry[]): void => {
for (const entry of entries) {
if (entry.isIntersecting) {
fullImg.src = fullSrc;
io.unobserve(container);
}
});
}
},
{ rootMargin: "200px" }
);
@ -118,12 +111,11 @@ export {};
// 收集所有 selectors命中的 .blur-img 节点。
// gallery: 正文图组成图集singles: 带 lightbox-single class 的单张图(如封面)。
function collectImages(roots: Element[]): { gallery: HTMLElement[]; singles: HTMLElement[] } {
var gallery: HTMLElement[] = [];
var singles: HTMLElement[] = [];
for (var i = 0; i < roots.length; i++) {
var nodes = roots[i].querySelectorAll(".blur-img");
for (var j = 0; j < nodes.length; j++) {
var n = nodes[j];
const gallery: HTMLElement[] = [];
const singles: HTMLElement[] = [];
for (const root of roots) {
const nodes = root.querySelectorAll(".blur-img");
for (const n of nodes) {
if (!(n instanceof HTMLElement)) continue;
if (n.classList.contains("lightbox-single")) {
singles.push(n);
@ -132,31 +124,31 @@ export {};
}
}
}
return { gallery: gallery, singles: singles };
return { gallery, singles };
}
// ============ 灯箱状态与开/关 ============
// 当前灯箱状态(同时只允许一个灯箱)。
var state: LightboxState | null = null;
let state: LightboxState | null = null;
function openLightbox(originNode: HTMLElement, gallery: HTMLElement[], index: number | null): void {
if (state) closeLightbox(true);
var fullImgEl = originNode.querySelector(".blur-img-full");
const fullImgEl = originNode.querySelector(".blur-img-full");
if (!(fullImgEl instanceof HTMLImageElement)) return;
var dataSrc = fullImgEl.getAttribute("data-src") || "";
var origSrc = originalUrl(dataSrc);
var altText = fullImgEl.getAttribute("alt") || "";
var isSingle =
const dataSrc = fullImgEl.getAttribute("data-src") || "";
const origSrc = originalUrl(dataSrc);
const altText = fullImgEl.getAttribute("alt") || "";
const isSingle =
originNode.classList.contains("lightbox-single") ||
gallery.length === 0;
var vw = window.innerWidth;
var vh = window.innerHeight;
const vw = window.innerWidth;
const vh = window.innerHeight;
// 建 DOM
var overlay = document.createElement("div");
const overlay = document.createElement("div");
overlay.className = "lightbox-overlay";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
@ -166,7 +158,7 @@ export {};
// start() 执行前显示全黑背景造成闪烁。start() 的渐变会把 opacity 升到 1。
overlay.style.opacity = "0";
var img = document.createElement("img");
const img = document.createElement("img");
img.className = "lightbox-img";
img.setAttribute("alt", altText);
// 加载前先占 0 尺寸,避免原图(可能数千 px在加载期间撑大文档
@ -174,22 +166,22 @@ export {};
img.style.width = "0px";
img.style.height = "0px";
var caption = document.createElement("figcaption");
const caption = document.createElement("figcaption");
caption.className = "lightbox-caption";
caption.textContent = altText;
if (!altText) caption.style.display = "none";
var counter = document.createElement("div");
const counter = document.createElement("div");
counter.className = "lightbox-counter";
if (isSingle || gallery.length === 0) {
counter.style.display = "none";
} else {
counter.textContent = (index ?? 0) + 1 + " / " + gallery.length;
counter.textContent = `${(index ?? 0) + 1} / ${gallery.length}`;
}
// 图集模式(>1 张)才加左右导航箭头;单张不显示。
var prevBtn: HTMLButtonElement | null = null;
var nextBtn: HTMLButtonElement | null = null;
let prevBtn: HTMLButtonElement | null = null;
let nextBtn: HTMLButtonElement | null = null;
if (!isSingle && gallery.length > 1) {
prevBtn = document.createElement("button");
prevBtn.className = "lightbox-nav lightbox-prev";
@ -212,19 +204,19 @@ export {};
document.body.appendChild(overlay);
state = {
overlay: overlay,
img: img,
caption: caption,
counter: counter,
prevBtn: prevBtn,
nextBtn: nextBtn,
originNode: originNode,
gallery: gallery,
index: index,
isSingle: isSingle,
overlay,
img,
caption,
counter,
prevBtn,
nextBtn,
originNode,
gallery,
index,
isSingle,
openScrollY: window.scrollY,
origSrc: origSrc,
altText: altText,
origSrc,
altText,
closing: false,
reduced: prefersReducedMotion(),
scrollHandler: null,
@ -237,26 +229,26 @@ export {};
bindInteractions();
// 图片加载后再做动画naturalW/H 要等加载)
var start = function (): void {
const start = (): void => {
if (!state) return; // 加载前可能已被关闭
var naturalW = img.naturalWidth || img.clientWidth || 1;
var naturalH = img.naturalHeight || img.clientHeight || 1;
var originRect = rectOf(originNode);
const naturalW = img.naturalWidth || img.clientWidth || 1;
const naturalH = img.naturalHeight || img.clientHeight || 1;
const originRect = rectOf(originNode);
// 基准 = originRect文章里图片的实际尺寸
// img 的布局尺寸固定为 originRecttransform 的 scale 相对它缩放:
// 首帧文章位置scale=1居中态 scale=target.w/originRect.w。
// 这样无论 target 比 originRect 大或小,动画都是「从文章图原样连续缩放」,
// 视觉上是原地展开不会像「从外面飞来」。灯箱图尺寸恒为视口最大fitCentered
var target = fitCentered(naturalW, naturalH, vw, vh);
var baseW = originRect.w;
var baseH = originRect.h;
const target = fitCentered(naturalW, naturalH, vw, vh);
const baseW = originRect.w;
const baseH = originRect.h;
// 存基准与目标,供关闭/滚动关闭复用同一对(保证 scale 连续)。
state.target = target;
state.baseW = baseW;
state.baseH = baseH;
img.style.width = baseW + "px";
img.style.height = baseH + "px";
img.style.width = `${baseW}px`;
img.style.height = `${baseH}px`;
// reduced-motion直接淡入居中
if (state.reduced) {
@ -266,7 +258,7 @@ export {};
img.style.top = "0";
overlay.style.opacity = "0";
// 下一帧淡入
requestAnimationFrame(function (): void {
requestAnimationFrame((): void => {
if (!state) return;
overlay.style.transition = "opacity 200ms ease-out";
img.style.transition = "opacity 200ms ease-out";
@ -288,9 +280,9 @@ export {};
void img.offsetHeight;
// double-rAF第一帧绘制首帧无动画第二帧才启动 transition 到居中。
requestAnimationFrame(function (): void {
requestAnimationFrame((): void => {
if (!state) return;
requestAnimationFrame(function (): void {
requestAnimationFrame((): void => {
if (!state) return;
img.style.transition =
"transform 250ms ease-out, opacity 250ms ease-out";
@ -315,12 +307,12 @@ export {};
state.closing = true;
cleanupInteractions();
var s = state;
const s = state;
// 基准 = originRect 尺寸与打开时一致scale 相对它缩放。
var baseW = s.baseW || (s.target ? s.target.w : 1);
var baseH = s.baseH || (s.target ? s.target.h : 1);
var originRect = rectOf(s.originNode); // 实时读,处理期间滚动过的情况
const baseW = s.baseW || (s.target ? s.target.w : 1);
const baseH = s.baseH || (s.target ? s.target.h : 1);
const originRect = rectOf(s.originNode); // 实时读,处理期间滚动过的情况
if (s.reduced || immediate) {
removeOverlay();
@ -335,17 +327,24 @@ export {};
s.img.style.opacity = "0";
s.overlay.style.opacity = "0";
var done = function (): void {
const done = (): void => {
removeOverlay();
};
// 250ms 兜底,避免 transitionend 不触发
var timer = setTimeout(done, 280);
s.img.addEventListener("transitionend", function (): void { clearTimeout(timer); done(); }, { once: true });
const timer = setTimeout(done, 280);
s.img.addEventListener(
"transitionend",
(): void => {
clearTimeout(timer);
done();
},
{ once: true }
);
}
function removeOverlay(): void {
if (!state) return;
var prev = state.originNode;
const prev = state.originNode;
if (state.overlay && state.overlay.parentNode) {
state.overlay.parentNode.removeChild(state.overlay);
}
@ -354,7 +353,7 @@ export {};
// 用 preventScroll 抑制 focus() 默认的 scrollIntoView 行为——否则关闭灯箱后
// 页面会自动滚动把原图完整纳入视口(用户只点了一半露出的图时尤其明显)。
if (prev) {
var f = prev.querySelector(".blur-img-full");
const f = prev.querySelector(".blur-img-full");
if (f instanceof HTMLImageElement) {
f.setAttribute("tabindex", "-1");
f.focus({ preventScroll: true });
@ -365,28 +364,29 @@ export {};
// ============ 图集切换 ============
// 图集切换淡入淡出不飞行。newIndex 循环(首尾衔接)。
function gotoIndex(newIndex: number): void {
function gotoIndex(rawIndex: number): void {
if (!state || state.isSingle) return;
var s = state;
const s = state;
if (!s.gallery || s.gallery.length === 0) return;
let newIndex = rawIndex;
if (newIndex < 0) newIndex = s.gallery.length - 1;
if (newIndex >= s.gallery.length) newIndex = 0;
if (newIndex === s.index) return;
var newNode = s.gallery[newIndex];
var fullImgEl = newNode.querySelector(".blur-img-full");
const newNode = s.gallery[newIndex];
const fullImgEl = newNode.querySelector(".blur-img-full");
if (!(fullImgEl instanceof HTMLImageElement)) return;
var origSrc = originalUrl(fullImgEl.getAttribute("data-src") || "");
var altText = fullImgEl.getAttribute("alt") || "";
const origSrc = originalUrl(fullImgEl.getAttribute("data-src") || "");
const altText = fullImgEl.getAttribute("alt") || "";
// 淡出当前图
s.img.style.transition = "opacity 150ms ease-out";
s.img.style.opacity = "0";
// 150ms 后换图淡入
var swap = function (): void {
const swap = (): void => {
if (!state) return; // 切换中可能已关闭
var fade = function (): void {
const fade = (): void => {
if (!state) return;
s.img.style.transition = "opacity 150ms ease-out";
s.img.style.opacity = "1";
@ -401,7 +401,7 @@ export {};
}
s.caption.textContent = altText;
s.caption.style.display = altText ? "" : "none";
s.counter.textContent = newIndex + 1 + " / " + s.gallery.length;
s.counter.textContent = `${newIndex + 1} / ${s.gallery.length}`;
// 更新 originNode 为新图,使后续关闭/滚动关闭飞回新图位置
s.originNode = newNode;
s.index = newIndex;
@ -413,34 +413,34 @@ export {};
// ============ 交互绑定 ============
function bindInteractions(): void {
var s = state;
const s = state;
if (!s) return;
// 点背景关闭(点图片本身不关,因箭头在图上、避免误关)
s.overlay.addEventListener("click", function (ev: MouseEvent): void {
s.overlay.addEventListener("click", (ev: MouseEvent): void => {
if (state && ev.target === state.overlay) closeLightbox(false);
});
// 滚动驱动关闭:任何 scroll 都触发,用 scrollY 偏移算进度。
// 关键:逐帧读 originNode 实时 rect文章滚多少图就回多少。
s.scrollHandler = function (): void {
s.scrollHandler = (): void => {
if (!state) return;
var st = state;
const st = state;
if (st.closing) return;
var dy = Math.abs(window.scrollY - st.openScrollY);
const dy = Math.abs(window.scrollY - st.openScrollY);
if (st.reduced) {
// reduced-motion立即关
closeLightbox(true);
return;
}
var target = st.target;
var baseW = st.baseW || (target ? target.w : 1);
var baseH = st.baseH || (target ? target.h : 1);
var originRect = rectOf(st.originNode);
// 在 originRect 与居中 target 之间按 progress 线性插值
var progress = Math.min(dy / 120, 1);
const target = st.target;
const baseW = st.baseW || (target ? target.w : 1);
const baseH = st.baseH || (target ? target.h : 1);
const originRect = rectOf(st.originNode);
if (!target) return; // 无 target 时不插值(忠实原 JS 的兜底语义)
var cur: Rect = {
// 在 originRect 与居中 target 之间按 progress 线性插值
const progress = Math.min(dy / 120, 1);
const cur: Rect = {
x: target.x + (originRect.x - target.x) * progress,
y: target.y + (originRect.y - target.y) * progress,
w: target.w + (originRect.w - target.w) * progress,
@ -460,7 +460,7 @@ export {};
window.addEventListener("scroll", s.scrollHandler, { passive: true });
// 键盘Esc 关;图集模式 ←→ 切换
s.keyHandler = function (ev: KeyboardEvent): void {
s.keyHandler = (ev: KeyboardEvent): void => {
if (!state) return;
if (ev.key === "Escape") {
closeLightbox(false);
@ -477,19 +477,15 @@ export {};
document.addEventListener("keydown", s.keyHandler);
// 图集导航箭头点击stopPropagation 防止冒泡到 overlay 触发关闭)
if (s.prevBtn) {
s.prevBtn.addEventListener("click", function (ev: MouseEvent): void {
s.prevBtn?.addEventListener("click", (ev: MouseEvent): void => {
ev.stopPropagation();
if (state) gotoIndex((state.index ?? 0) - 1);
});
}
if (s.nextBtn) {
s.nextBtn.addEventListener("click", function (ev: MouseEvent): void {
s.nextBtn?.addEventListener("click", (ev: MouseEvent): void => {
ev.stopPropagation();
if (state) gotoIndex((state.index ?? 0) + 1);
});
}
}
function cleanupInteractions(): void {
if (!state) return;
@ -505,40 +501,36 @@ export {};
// ============ 初始化入口 ============
window.__initLightbox = function (selectors: string | string[]): void {
window.__initLightbox = (selectors: string | string[]): void => {
// selectors 可以是字符串、字符串数组
var sels = Array.isArray(selectors) ? selectors : [selectors];
var roots: Element[] = [];
for (var i = 0; i < sels.length; i++) {
var found = document.querySelectorAll(sels[i]);
for (var j = 0; j < found.length; j++) roots.push(found[j]);
const sels = Array.isArray(selectors) ? selectors : [selectors];
const roots: Element[] = [];
for (const sel of sels) {
const found = document.querySelectorAll(sel);
for (const el of found) roots.push(el);
}
// 先对所有图片做懒加载(图集与单张都做)
var collected = collectImages(roots);
var everyone = collected.gallery.concat(collected.singles);
for (var k = 0; k < everyone.length; k++) {
initLazyLoad(everyone[k]);
const collected = collectImages(roots);
for (const node of collected.gallery.concat(collected.singles)) {
initLazyLoad(node);
}
// 正文图:带 index
var gallery = collected.gallery;
for (var g = 0; g < gallery.length; g++) {
(function (node: HTMLElement, idx: number): void {
node.addEventListener("click", function (e: MouseEvent): void {
// 正文图:带 index。for..of + const 天然捕获每次迭代的 idx
// 无需旧 IIFE 包装(旧 var 循环闭包必须立即执行函数固定变量)。
const gallery = collected.gallery;
gallery.forEach((node, idx) => {
node.addEventListener("click", (e: MouseEvent) => {
e.preventDefault();
openLightbox(node, gallery, idx);
});
})(gallery[g], g);
}
});
// 单张图封面index = nullgallery 传空数组表示单张
for (var si = 0; si < collected.singles.length; si++) {
(function (node: HTMLElement): void {
node.addEventListener("click", function (e: MouseEvent): void {
for (const node of collected.singles) {
node.addEventListener("click", (e: MouseEvent) => {
e.preventDefault();
openLightbox(node, [], null);
});
})(collected.singles[si]);
}
};
@ -551,4 +543,3 @@ export {};
if (Array.isArray(window.__lightboxSelectors)) {
window.__initLightbox(window.__lightboxSelectors);
}
})();