From f43295b47110f858b333e1b23708f26fe0ace3fb Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 25 Apr 2026 23:31:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=B7=AF=E7=94=B1=E9=A6=96?= =?UTF-8?q?=E5=B1=8F=E5=9B=BE=E7=89=87=E7=AD=89=E5=BE=85=E9=97=A8=E9=97=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ROUTE_IMAGE_READY_GATE_2026-04-25.md | 25 ++ src/main.tsx | 8 +- src/routing/RouteImageReadyGate.test.ts | 44 ++++ src/routing/RouteImageReadyGate.tsx | 233 ++++++++++++++++++ src/routing/routeImageReadyGateUtils.ts | 111 +++++++++ 5 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 docs/technical/ROUTE_IMAGE_READY_GATE_2026-04-25.md create mode 100644 src/routing/RouteImageReadyGate.test.ts create mode 100644 src/routing/RouteImageReadyGate.tsx create mode 100644 src/routing/routeImageReadyGateUtils.ts diff --git a/docs/technical/ROUTE_IMAGE_READY_GATE_2026-04-25.md b/docs/technical/ROUTE_IMAGE_READY_GATE_2026-04-25.md new file mode 100644 index 00000000..ddb9277d --- /dev/null +++ b/docs/technical/ROUTE_IMAGE_READY_GATE_2026-04-25.md @@ -0,0 +1,25 @@ +# 路由首屏图片等待门闩 2026-04-25 + +## 背景 + +平台首页、作品详情、拼图运行态等页面会在首屏展示作品封面、角色图、场景背景图或拼图原图。此前页面主体可能先进入,图片随后分批出现,移动端尤其容易看到背景图闪入、封面占位与真实图片切换。 + +## 落地约束 + +1. 入口路由组件加载完成后,页面主体先挂载但保持不可见,继续让业务数据请求、图片请求和布局计算正常发生。 +2. 门闩扫描当前路由根节点内的 ``、`src/currentSrc` 和 CSS `background-image / border-image-source` 中的 `url(...)`。 +3. 扫描到的图片统一通过 `Image()` 预加载;图片成功、失败或超时都视为 settled,避免单张异常图片阻塞整站进入。 +4. 所有已发现图片 settled 且 DOM 短暂稳定后,再一次性显示页面主体。 +5. 门闩只负责前端呈现时机,不承接作品封面选择、资产读链路、玩法逻辑或后端数据裁决。 + +## 体验规则 + +- 等待态继续复用 `RouteLoadingScreen`,只显示简短加载文案,不在 UI 中追加规则说明。 +- 页面主体隐藏时使用 `visibility: hidden`,不能用 `display: none`,否则浏览器可能不触发布局与图片加载。 +- 图片加载失败不直接改写业务 UI;后续仍由原页面的兜底图、占位图或错误态处理。 + +## 涉及文件 + +- `src/routing/RouteImageReadyGate.tsx` +- `src/routing/RouteImageReadyGate.test.ts` +- `src/main.tsx` diff --git a/src/main.tsx b/src/main.tsx index 36380773..8728ced9 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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; @@ -29,7 +30,12 @@ const RouteComponent = route.Component; root.render( }> - + + + , ); diff --git a/src/routing/RouteImageReadyGate.test.ts b/src/routing/RouteImageReadyGate.test.ts new file mode 100644 index 00000000..fd1bf04f --- /dev/null +++ b/src/routing/RouteImageReadyGate.test.ts @@ -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 = ` + +
+
+ `; + + 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, + ]); + }); +}); diff --git a/src/routing/RouteImageReadyGate.tsx b/src/routing/RouteImageReadyGate.tsx new file mode 100644 index 00000000..2613c2c8 --- /dev/null +++ b/src/routing/RouteImageReadyGate.tsx @@ -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(); +const imagePreloadTasks = new Map>(); + +function preloadImageUrl(url: string) { + const existingTask = imagePreloadTasks.get(url); + if (existingTask) { + return existingTask; + } + + const task = new Promise((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(null); + const startTimeRef = useRef(0); + const scanTimerRef = useRef(null); + const revealTimerRef = useRef(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 ( + <> +
+ {children} +
+ {!ready ? ( +
+ +
+ ) : null} + + ); +} diff --git a/src/routing/routeImageReadyGateUtils.ts b/src/routing/routeImageReadyGateUtils.ts new file mode 100644 index 00000000..4b9b290c --- /dev/null +++ b/src/routing/routeImageReadyGateUtils.ts @@ -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, 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(); + const elements = [root, ...Array.from(root.querySelectorAll('*'))]; + + root.querySelectorAll('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); +}