实现路由首屏图片等待门闩

This commit is contained in:
2026-04-25 23:31:42 +08:00
parent 8171fc59b0
commit f43295b471
5 changed files with 420 additions and 1 deletions

View File

@@ -6,6 +6,7 @@ import {StrictMode, Suspense} from 'react';
import {createRoot} from 'react-dom/client';
import {resolveAppRoute} from './routing/appRoutes';
import {RouteImageReadyGate} from './routing/RouteImageReadyGate';
import {RouteLoadingScreen} from './routing/RouteLoadingScreen';
type AppRoot = ReturnType<typeof createRoot>;
@@ -29,7 +30,12 @@ const RouteComponent = route.Component;
root.render(
<StrictMode>
<Suspense fallback={<RouteLoadingScreen eyebrow={route.loadingEyebrow} text={route.loadingText} />}>
<RouteComponent {...(route.componentProps ?? {})} />
<RouteImageReadyGate
eyebrow={route.loadingEyebrow}
text={route.loadingText}
>
<RouteComponent {...(route.componentProps ?? {})} />
</RouteImageReadyGate>
</Suspense>
</StrictMode>,
);

View File

@@ -0,0 +1,44 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import {
collectRouteImageUrls,
extractCssImageUrls,
normalizePreloadImageUrl,
} from './routeImageReadyGateUtils';
describe('RouteImageReadyGate image url helpers', () => {
it('extracts urls from layered CSS image values', () => {
expect(
extractCssImageUrls(
'linear-gradient(#000,#111), url("/hero.png"), url("icons/card.webp")',
),
).toEqual(['/hero.png', 'icons/card.webp']);
});
it('normalizes preloadable urls against the current document', () => {
expect(normalizePreloadImageUrl('/cover.png')).toBe(
new URL('/cover.png', document.baseURI).href,
);
expect(normalizePreloadImageUrl('data:image/png;base64,abc')).toBe(
'data:image/png;base64,abc',
);
expect(normalizePreloadImageUrl('')).toBeNull();
});
it('collects img and CSS background urls from a route root', () => {
const root = document.createElement('section');
root.innerHTML = `
<img src="/images/card.png" />
<div style='background-image: url("/images/bg.webp")'></div>
<div style='border-image-source: url("/ui/frame.png")'></div>
`;
expect(collectRouteImageUrls(root)).toEqual([
new URL('/images/card.png', document.baseURI).href,
new URL('/images/bg.webp', document.baseURI).href,
new URL('/ui/frame.png', document.baseURI).href,
]);
});
});

View 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}
</>
);
}

View File

@@ -0,0 +1,111 @@
const CSS_IMAGE_URL_PATTERN =
/url\(\s*(?:"([^"]+)"|'([^']+)'|([^'")]+))\s*\)/gu;
export type ImagePreloadResult = {
url: string;
status: 'loaded' | 'failed' | 'timeout';
};
export function extractCssImageUrls(value: string) {
const urls: string[] = [];
CSS_IMAGE_URL_PATTERN.lastIndex = 0;
let match = CSS_IMAGE_URL_PATTERN.exec(value);
while (match) {
const rawUrl = match[1] ?? match[2] ?? match[3] ?? '';
const normalizedRawUrl = rawUrl.trim();
if (normalizedRawUrl) {
urls.push(normalizedRawUrl);
}
match = CSS_IMAGE_URL_PATTERN.exec(value);
}
return urls;
}
export function normalizePreloadImageUrl(rawUrl: string) {
const trimmedUrl = rawUrl.trim();
if (!trimmedUrl || trimmedUrl === 'none' || trimmedUrl.startsWith('#')) {
return null;
}
if (
trimmedUrl.startsWith('data:') ||
trimmedUrl.startsWith('blob:') ||
trimmedUrl.startsWith('http://') ||
trimmedUrl.startsWith('https://')
) {
return trimmedUrl;
}
const baseUrl =
typeof document === 'undefined'
? 'http://localhost/'
: document.baseURI;
try {
return new URL(trimmedUrl, baseUrl).href;
} catch {
return null;
}
}
function addNormalizedImageUrl(urls: Set<string>, rawUrl: string | null) {
if (!rawUrl) {
return;
}
const normalizedUrl = normalizePreloadImageUrl(rawUrl);
if (normalizedUrl) {
urls.add(normalizedUrl);
}
}
export function hasCssImageUrlChange(mutation: MutationRecord) {
if (!(mutation.target instanceof HTMLElement)) {
return false;
}
const previousUrls = mutation.oldValue
? extractCssImageUrls(mutation.oldValue)
: [];
const currentUrls = [
...extractCssImageUrls(mutation.target.style.backgroundImage),
...extractCssImageUrls(mutation.target.style.borderImageSource),
...extractCssImageUrls(mutation.target.style.listStyleImage),
];
if (previousUrls.length !== currentUrls.length) {
return true;
}
return currentUrls.some((url, index) => url !== previousUrls[index]);
}
export function collectRouteImageUrls(root: HTMLElement) {
const urls = new Set<string>();
const elements = [root, ...Array.from(root.querySelectorAll<HTMLElement>('*'))];
root.querySelectorAll<HTMLImageElement>('img').forEach((image) => {
addNormalizedImageUrl(urls, image.currentSrc);
addNormalizedImageUrl(urls, image.getAttribute('src'));
});
elements.forEach((element) => {
const computedStyle = window.getComputedStyle(element);
[
element.style.backgroundImage,
element.style.borderImageSource,
element.style.listStyleImage,
computedStyle.backgroundImage,
computedStyle.borderImageSource,
computedStyle.listStyleImage,
].forEach((cssImageValue) => {
extractCssImageUrls(cssImageValue).forEach((url) => {
addNormalizedImageUrl(urls, url);
});
});
});
return Array.from(urls);
}