202 lines
5.4 KiB
TypeScript
202 lines
5.4 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect } from "react";
|
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
|
|
|
const MIN_DISPLAY_WIDTH = 160;
|
|
const DEFAULT_DISPLAY_WIDTH = 960;
|
|
const MAX_DISPLAY_WIDTH = 2200;
|
|
const OPTIMIZED_MARK = "ossDisplayOptimized";
|
|
const MANUAL_SCOPE_ATTR = "data-image-delivery-scope";
|
|
const BATCH_SIZE = 48;
|
|
|
|
type IdleDeadlineLike = {
|
|
timeRemaining: () => number;
|
|
};
|
|
|
|
type IdleWindow = Window & {
|
|
requestIdleCallback?: (
|
|
callback: (deadline: IdleDeadlineLike) => void,
|
|
options?: { timeout?: number }
|
|
) => number;
|
|
cancelIdleCallback?: (id: number) => void;
|
|
};
|
|
|
|
function isSkippableImageUrl(url: string): boolean {
|
|
const normalized = url.trim().toLowerCase();
|
|
return (
|
|
!normalized ||
|
|
normalized.startsWith("data:") ||
|
|
normalized.startsWith("blob:") ||
|
|
normalized.startsWith("/api/image-proxy") ||
|
|
normalized.includes("x-oss-process=") ||
|
|
/\.(svg|gif|ico)(?:[?#]|$)/i.test(normalized)
|
|
);
|
|
}
|
|
|
|
function getDisplayWidth(image: HTMLImageElement): number {
|
|
const rectWidth = image.getBoundingClientRect().width;
|
|
const width =
|
|
rectWidth ||
|
|
image.clientWidth ||
|
|
Number.parseInt(image.getAttribute("width") || "", 10) ||
|
|
DEFAULT_DISPLAY_WIDTH;
|
|
const dpr = Math.min(2, Math.max(1, window.devicePixelRatio || 1));
|
|
return Math.round(Math.max(MIN_DISPLAY_WIDTH, Math.min(MAX_DISPLAY_WIDTH, width * dpr)));
|
|
}
|
|
|
|
function isInManualDeliveryScope(image: HTMLImageElement): boolean {
|
|
if (
|
|
image.dataset.imageDelivery === "raw" ||
|
|
image.dataset.imageDelivery === "proxy" ||
|
|
image.dataset.imageDelivery === "manual"
|
|
) {
|
|
return true;
|
|
}
|
|
return Boolean(image.closest(`[${MANUAL_SCOPE_ATTR}="manual"]`));
|
|
}
|
|
|
|
function optimizeImageElement(image: HTMLImageElement): void {
|
|
if (isInManualDeliveryScope(image)) {
|
|
return;
|
|
}
|
|
|
|
const currentSrc = image.currentSrc || image.src || image.getAttribute("src") || "";
|
|
if (isSkippableImageUrl(currentSrc)) {
|
|
return;
|
|
}
|
|
|
|
const optimizedUrl = buildImageProxyUrl(currentSrc, {
|
|
width: getDisplayWidth(image),
|
|
quality: 84,
|
|
format: "webp",
|
|
});
|
|
|
|
if (
|
|
!optimizedUrl ||
|
|
optimizedUrl === currentSrc ||
|
|
optimizedUrl.startsWith("/api/image-proxy")
|
|
) {
|
|
return;
|
|
}
|
|
|
|
image.dataset[OPTIMIZED_MARK] = "1";
|
|
image.src = optimizedUrl;
|
|
}
|
|
|
|
function collectImages(root: ParentNode = document): HTMLImageElement[] {
|
|
if (root instanceof HTMLImageElement) {
|
|
return [root];
|
|
}
|
|
if (root instanceof Element && root.closest(`[${MANUAL_SCOPE_ATTR}="manual"]`)) {
|
|
return [];
|
|
}
|
|
return Array.from(root.querySelectorAll("img"));
|
|
}
|
|
|
|
export function ImageDeliveryOptimizer() {
|
|
useEffect(() => {
|
|
const pendingImages = new Set<HTMLImageElement>();
|
|
const idleWindow = window as IdleWindow;
|
|
let scheduledId = 0;
|
|
let scheduledWithIdle = false;
|
|
|
|
const cancelScheduledFlush = () => {
|
|
if (!scheduledId) return;
|
|
if (scheduledWithIdle && typeof idleWindow.cancelIdleCallback === "function") {
|
|
idleWindow.cancelIdleCallback(scheduledId);
|
|
} else {
|
|
cancelAnimationFrame(scheduledId);
|
|
}
|
|
scheduledId = 0;
|
|
scheduledWithIdle = false;
|
|
};
|
|
|
|
const scheduleFlush = () => {
|
|
if (scheduledId) return;
|
|
const flush = (deadline?: IdleDeadlineLike) => {
|
|
scheduledId = 0;
|
|
scheduledWithIdle = false;
|
|
let processed = 0;
|
|
for (const image of Array.from(pendingImages)) {
|
|
pendingImages.delete(image);
|
|
if (image.isConnected) {
|
|
optimizeImageElement(image);
|
|
}
|
|
processed += 1;
|
|
if (
|
|
processed >= BATCH_SIZE ||
|
|
(deadline && deadline.timeRemaining() < 4)
|
|
) {
|
|
break;
|
|
}
|
|
}
|
|
if (pendingImages.size > 0) {
|
|
scheduleFlush();
|
|
}
|
|
};
|
|
|
|
if (typeof idleWindow.requestIdleCallback === "function") {
|
|
scheduledWithIdle = true;
|
|
scheduledId = idleWindow.requestIdleCallback(flush, { timeout: 500 });
|
|
return;
|
|
}
|
|
|
|
scheduledId = requestAnimationFrame(() => {
|
|
flush({ timeRemaining: () => 8 });
|
|
});
|
|
};
|
|
|
|
const scheduleOptimize = (root: ParentNode = document) => {
|
|
for (const image of collectImages(root)) {
|
|
pendingImages.add(image);
|
|
}
|
|
if (pendingImages.size > 0) {
|
|
scheduleFlush();
|
|
}
|
|
};
|
|
|
|
scheduleOptimize();
|
|
|
|
const observer = new MutationObserver((mutations) => {
|
|
for (const mutation of mutations) {
|
|
if (mutation.type === "attributes") {
|
|
const target = mutation.target;
|
|
if (target instanceof HTMLImageElement) {
|
|
pendingImages.add(target);
|
|
scheduleFlush();
|
|
}
|
|
continue;
|
|
}
|
|
|
|
for (const node of mutation.addedNodes) {
|
|
if (node instanceof HTMLImageElement) {
|
|
pendingImages.add(node);
|
|
scheduleFlush();
|
|
} else if (node instanceof Element) {
|
|
scheduleOptimize(node);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
observer.observe(document.body, {
|
|
attributes: true,
|
|
attributeFilter: ["src"],
|
|
childList: true,
|
|
subtree: true,
|
|
});
|
|
|
|
const handleResize = () => scheduleOptimize();
|
|
window.addEventListener("resize", handleResize, { passive: true });
|
|
|
|
return () => {
|
|
cancelScheduledFlush();
|
|
observer.disconnect();
|
|
window.removeEventListener("resize", handleResize);
|
|
};
|
|
}, []);
|
|
|
|
return null;
|
|
}
|