231 lines
5.6 KiB
TypeScript
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}
|
|
</>
|
|
);
|
|
}
|