实现路由首屏图片等待门闩
This commit is contained in:
233
src/routing/RouteImageReadyGate.tsx
Normal file
233
src/routing/RouteImageReadyGate.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
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_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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由首屏图片门闩:业务页面先真实挂载但不可见,
|
||||
* 等当前 DOM 中已发现的图片全部 settled 后再一次性显示。
|
||||
*/
|
||||
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 [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
startTimeRef.current = window.performance.now();
|
||||
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) => {
|
||||
clearRevealTimer();
|
||||
|
||||
const elapsed = window.performance.now() - startTimeRef.current;
|
||||
const delay = Math.max(
|
||||
IMAGE_GATE_QUIET_MS,
|
||||
IMAGE_GATE_MIN_VISIBLE_WAIT_MS - elapsed,
|
||||
);
|
||||
|
||||
revealTimerRef.current = window.setTimeout(() => {
|
||||
if (disposed || version !== scanVersionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingUrls = collectRouteImageUrls(root).filter(
|
||||
(url) => !settledImageUrls.has(url),
|
||||
);
|
||||
if (pendingUrls.length > 0) {
|
||||
scheduleScan();
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
scheduleReveal(version);
|
||||
return;
|
||||
}
|
||||
|
||||
// 已进入页面但新 DOM 批量挂载图片时,先回到等待态,避免图片逐张闪入。
|
||||
setReady(false);
|
||||
void Promise.allSettled(pendingUrls.map(preloadImageUrl)).then(() => {
|
||||
if (disposed || version !== scanVersionRef.current) {
|
||||
return;
|
||||
}
|
||||
scheduleScan();
|
||||
});
|
||||
}
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (disposed) {
|
||||
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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user