Files
Genarrative/src/routing/RouteImageReadyGate.tsx

231 lines
5.6 KiB
TypeScript

import { type ReactNode, useEffect, useRef, useState } from 'react';
import {
collectRouteImageUrls,
hasCssImageUrlChange,
type ImagePreloadResult,
} from './routeImageReadyGateUtils';
import { RouteLoadingScreen } from './RouteLoadingScreen';
type RouteImageReadyGateProps = {
children: ReactNode;
eyebrow: string;
text: string;
};
const IMAGE_GATE_QUIET_MS = 140;
const IMAGE_GATE_MIN_VISIBLE_WAIT_MS = 260;
const IMAGE_GATE_MAX_BLOCK_MS = 1400;
const IMAGE_PRELOAD_TIMEOUT_MS = 12000;
const settledImageUrls = new Set<string>();
const imagePreloadTasks = new Map<string, Promise<ImagePreloadResult>>();
function preloadImageUrl(url: string) {
const existingTask = imagePreloadTasks.get(url);
if (existingTask) {
return existingTask;
}
const task = new Promise<ImagePreloadResult>((resolve) => {
if (typeof Image === 'undefined') {
resolve({ url, status: 'loaded' });
return;
}
const image = new Image();
let settled = false;
const settle = (status: ImagePreloadResult['status']) => {
if (settled) {
return;
}
settled = true;
window.clearTimeout(timeoutId);
settledImageUrls.add(url);
resolve({ url, status });
};
const timeoutId = window.setTimeout(() => {
settle('timeout');
}, IMAGE_PRELOAD_TIMEOUT_MS);
image.onload = () => settle('loaded');
image.onerror = () => settle('failed');
image.decoding = 'async';
image.src = url;
if (image.complete) {
settle(image.naturalWidth > 0 ? 'loaded' : 'failed');
}
});
imagePreloadTasks.set(url, task);
return task;
}
/**
* 路由首屏图片门闩:业务页面先真实挂载但不可见,
* 只等待短暂稳定窗口,不再把所有图片加载完成作为首屏硬阻塞。
*/
export function RouteImageReadyGate({
children,
eyebrow,
text,
}: RouteImageReadyGateProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const startTimeRef = useRef(0);
const scanTimerRef = useRef<number | null>(null);
const revealTimerRef = useRef<number | null>(null);
const scanVersionRef = useRef(0);
const revealedRef = useRef(false);
const [ready, setReady] = useState(false);
useEffect(() => {
const root = rootRef.current;
if (!root) {
return;
}
let disposed = false;
startTimeRef.current = window.performance.now();
revealedRef.current = false;
setReady(false);
const clearScanTimer = () => {
if (scanTimerRef.current !== null) {
window.clearTimeout(scanTimerRef.current);
scanTimerRef.current = null;
}
};
const clearRevealTimer = () => {
if (revealTimerRef.current !== null) {
window.clearTimeout(revealTimerRef.current);
revealTimerRef.current = null;
}
};
const scheduleScan = () => {
clearScanTimer();
scanTimerRef.current = window.setTimeout(runScan, IMAGE_GATE_QUIET_MS);
};
const scheduleReveal = (version: number) => {
if (revealedRef.current) {
return;
}
clearRevealTimer();
const elapsed = window.performance.now() - startTimeRef.current;
const preferredDelay = Math.max(
IMAGE_GATE_QUIET_MS,
IMAGE_GATE_MIN_VISIBLE_WAIT_MS - elapsed,
);
const maxRemainingDelay = Math.max(0, IMAGE_GATE_MAX_BLOCK_MS - elapsed);
const delay = Math.min(preferredDelay, maxRemainingDelay);
revealTimerRef.current = window.setTimeout(() => {
if (disposed || version !== scanVersionRef.current) {
return;
}
revealedRef.current = true;
setReady(true);
}, delay);
};
function runScan() {
if (disposed) {
return;
}
const version = scanVersionRef.current + 1;
scanVersionRef.current = version;
const pendingUrls = collectRouteImageUrls(root).filter(
(url) => !settledImageUrls.has(url),
);
if (pendingUrls.length > 0) {
// 首屏慢加载的核心约束:图片可预热,但不能无限期阻塞页面主体可见。
pendingUrls.forEach((url) => {
void preloadImageUrl(url);
});
}
scheduleReveal(version);
}
const observer = new MutationObserver((mutations) => {
if (disposed || revealedRef.current) {
return;
}
const shouldRescan = mutations.some((mutation) => {
if (mutation.type === 'childList') {
return (
mutation.addedNodes.length > 0 ||
mutation.removedNodes.length > 0
);
}
if (mutation.type !== 'attributes') {
return false;
}
if (
mutation.attributeName === 'src' ||
mutation.attributeName === 'srcset'
) {
return true;
}
return mutation.attributeName === 'style'
? hasCssImageUrlChange(mutation)
: false;
});
if (!shouldRescan) {
return;
}
clearRevealTimer();
scheduleScan();
});
observer.observe(root, {
attributes: true,
attributeFilter: ['src', 'srcset', 'style'],
attributeOldValue: true,
childList: true,
subtree: true,
});
scheduleScan();
return () => {
disposed = true;
observer.disconnect();
clearScanTimer();
clearRevealTimer();
};
}, []);
return (
<>
<div
ref={rootRef}
aria-hidden={!ready}
style={{
visibility: ready ? 'visible' : 'hidden',
}}
>
{children}
</div>
{!ready ? (
<div className="fixed inset-0 z-[9999]">
<RouteLoadingScreen eyebrow={eyebrow} text={text} />
</div>
) : null}
</>
);
}