等待推荐页运行态全部资源
推荐页 ready 持续观察运行态图片、背景、音视频和资源 pending 标记 资源换签与玩法图集解析中通过隐藏标记阻止遮罩提前消失 补齐拼图、跳一跳、抓大鹅和敲木鱼运行态资源等待接入 补充推荐页资源等待回归测试和团队文档
This commit is contained in:
@@ -121,6 +121,7 @@ import {
|
||||
LEGAL_DOCUMENTS,
|
||||
type LegalDocumentId,
|
||||
} from '../common/legalDocuments';
|
||||
import { RUNTIME_RESOURCE_PENDING_SELECTOR } from '../common/RuntimeResourcePendingMarker';
|
||||
import {
|
||||
buildCenteredSquareImageCropRect,
|
||||
clampSquareImageCropRect,
|
||||
@@ -267,6 +268,8 @@ const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36;
|
||||
const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180;
|
||||
const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
|
||||
const RECOMMEND_RUNTIME_COVER_MIN_VISIBLE_MS = 520;
|
||||
const RECOMMEND_RUNTIME_RESOURCE_IDLE_MS = 80;
|
||||
const RECOMMEND_RUNTIME_READY_FRAME_COUNT = 2;
|
||||
|
||||
type RecommendResolvedCoverUrlMap = ReadonlyMap<string, string>;
|
||||
|
||||
@@ -438,19 +441,67 @@ function useResolvedRecommendCoverImages(
|
||||
return resolvedCoverUrls;
|
||||
}
|
||||
|
||||
function scheduleRecommendRuntimeReady(
|
||||
signal: AbortSignal,
|
||||
onReady: (value: boolean) => void,
|
||||
) {
|
||||
function scheduleRecommendRuntimeReady(signal: AbortSignal, onReady: () => void) {
|
||||
if (signal.aborted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return window.requestAnimationFrame(() => {
|
||||
if (!signal.aborted) {
|
||||
onReady(true);
|
||||
let animationFrameId: number | null = null;
|
||||
let remainingFrameCount = RECOMMEND_RUNTIME_READY_FRAME_COUNT;
|
||||
const tick = () => {
|
||||
animationFrameId = null;
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
remainingFrameCount -= 1;
|
||||
if (remainingFrameCount <= 0) {
|
||||
onReady();
|
||||
return;
|
||||
}
|
||||
|
||||
animationFrameId = window.requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
animationFrameId = window.requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (animationFrameId !== null) {
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getRecommendRuntimeImageSource(image: HTMLImageElement) {
|
||||
return (
|
||||
image.currentSrc ||
|
||||
image.getAttribute('src') ||
|
||||
image.getAttribute('srcset') ||
|
||||
''
|
||||
).trim();
|
||||
}
|
||||
|
||||
function getRecommendRuntimeMediaSource(media: HTMLMediaElement) {
|
||||
return (media.currentSrc || media.getAttribute('src') || '').trim();
|
||||
}
|
||||
|
||||
function collectRecommendRuntimeBackgroundUrls(root: HTMLElement) {
|
||||
const urls = new Set<string>();
|
||||
root.querySelectorAll<HTMLElement>('[style]').forEach((element) => {
|
||||
const backgroundImage = element.style.backgroundImage;
|
||||
if (!backgroundImage || backgroundImage === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
const pattern = /url\((?:"([^"]*)"|'([^']*)'|([^)]*))\)/giu;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(backgroundImage))) {
|
||||
const url = (match[1] ?? match[2] ?? match[3] ?? '').trim();
|
||||
if (url) {
|
||||
urls.add(url);
|
||||
}
|
||||
}
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
|
||||
function readyRecommendRuntime(
|
||||
@@ -461,63 +512,312 @@ function readyRecommendRuntime(
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const pendingImages = Array.from(root.querySelectorAll('img')).filter(
|
||||
(image) => !image.complete,
|
||||
);
|
||||
const runtimeRoot = root;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let animationFrameId: number | null = null;
|
||||
let scanFrameId: number | null = null;
|
||||
let readyIdleTimeoutId: number | null = null;
|
||||
let pendingRecheckTimeoutId: number | null = null;
|
||||
let readyFrameCleanup: (() => void) | null = null;
|
||||
let settled = false;
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
const pendingImageListeners = new Map<
|
||||
HTMLImageElement,
|
||||
{ src: string; cleanup: () => void }
|
||||
>();
|
||||
const pendingMediaListeners = new Map<
|
||||
HTMLMediaElement,
|
||||
{ src: string; cleanup: () => void }
|
||||
>();
|
||||
const settledImageSources = new WeakMap<HTMLImageElement, string>();
|
||||
const settledMediaSources = new WeakMap<HTMLMediaElement, string>();
|
||||
const loadedBackgroundUrls = new Set<string>();
|
||||
const pendingBackgroundPreloads = new Map<string, () => void>();
|
||||
|
||||
const cancelReadySchedule = () => {
|
||||
if (readyIdleTimeoutId !== null) {
|
||||
window.clearTimeout(readyIdleTimeoutId);
|
||||
readyIdleTimeoutId = null;
|
||||
}
|
||||
if (readyFrameCleanup) {
|
||||
readyFrameCleanup();
|
||||
readyFrameCleanup = null;
|
||||
}
|
||||
};
|
||||
const cancelPendingRecheck = () => {
|
||||
if (pendingRecheckTimeoutId !== null) {
|
||||
window.clearTimeout(pendingRecheckTimeoutId);
|
||||
pendingRecheckTimeoutId = null;
|
||||
}
|
||||
};
|
||||
const finish = (value: boolean) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cancelReadySchedule();
|
||||
cancelPendingRecheck();
|
||||
cleanupCallbacks.splice(0).forEach((cleanup) => cleanup());
|
||||
if (animationFrameId !== null) {
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
if (scanFrameId !== null) {
|
||||
window.cancelAnimationFrame(scanFrameId);
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
const abort = () => finish(false);
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
cleanupCallbacks.push(() => signal.removeEventListener('abort', abort));
|
||||
cleanupCallbacks.push(() => {
|
||||
pendingImageListeners.forEach(({ cleanup }) => cleanup());
|
||||
pendingImageListeners.clear();
|
||||
pendingMediaListeners.forEach(({ cleanup }) => cleanup());
|
||||
pendingMediaListeners.clear();
|
||||
pendingBackgroundPreloads.forEach((cleanup) => cleanup());
|
||||
pendingBackgroundPreloads.clear();
|
||||
});
|
||||
|
||||
if (pendingImages.length === 0) {
|
||||
animationFrameId = scheduleRecommendRuntimeReady(signal, finish);
|
||||
if (animationFrameId === null) {
|
||||
finish(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let remaining = pendingImages.length;
|
||||
const markImageReady = () => {
|
||||
remaining -= 1;
|
||||
if (remaining > 0) {
|
||||
const scheduleScan = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
animationFrameId = scheduleRecommendRuntimeReady(signal, finish);
|
||||
if (animationFrameId === null) {
|
||||
finish(false);
|
||||
cancelReadySchedule();
|
||||
cancelPendingRecheck();
|
||||
if (scanFrameId !== null) {
|
||||
return;
|
||||
}
|
||||
scanFrameId = window.requestAnimationFrame(() => {
|
||||
scanFrameId = null;
|
||||
scanResources();
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleReady = () => {
|
||||
cancelReadySchedule();
|
||||
readyIdleTimeoutId = window.setTimeout(() => {
|
||||
readyIdleTimeoutId = null;
|
||||
readyFrameCleanup = scheduleRecommendRuntimeReady(signal, () =>
|
||||
finish(true),
|
||||
);
|
||||
if (readyFrameCleanup === null) {
|
||||
finish(false);
|
||||
}
|
||||
}, RECOMMEND_RUNTIME_RESOURCE_IDLE_MS);
|
||||
};
|
||||
|
||||
const preloadBackgroundUrl = (url: string) => {
|
||||
if (loadedBackgroundUrls.has(url) || pendingBackgroundPreloads.has(url)) {
|
||||
return;
|
||||
}
|
||||
if (typeof Image === 'undefined') {
|
||||
loadedBackgroundUrls.add(url);
|
||||
return;
|
||||
}
|
||||
|
||||
const image = new Image();
|
||||
const cleanup = () => {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
};
|
||||
const markReady = () => {
|
||||
cleanup();
|
||||
pendingBackgroundPreloads.delete(url);
|
||||
loadedBackgroundUrls.add(url);
|
||||
scheduleScan();
|
||||
};
|
||||
image.decoding = 'async';
|
||||
image.onload = markReady;
|
||||
image.onerror = markReady;
|
||||
pendingBackgroundPreloads.set(url, cleanup);
|
||||
image.src = url;
|
||||
if (image.complete) {
|
||||
markReady();
|
||||
}
|
||||
};
|
||||
|
||||
pendingImages.forEach((image) => {
|
||||
const cleanupImageListeners = () => {
|
||||
image.removeEventListener('load', markImageReady);
|
||||
image.removeEventListener('error', markImageReady);
|
||||
};
|
||||
image.addEventListener('load', markImageReady, { once: true });
|
||||
image.addEventListener('error', markImageReady, { once: true });
|
||||
cleanupCallbacks.push(cleanupImageListeners);
|
||||
|
||||
if (image.complete) {
|
||||
cleanupImageListeners();
|
||||
markImageReady();
|
||||
function scanResources() {
|
||||
if (signal.aborted) {
|
||||
finish(false);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const currentImages = new Set(
|
||||
Array.from(runtimeRoot.querySelectorAll<HTMLImageElement>('img')),
|
||||
);
|
||||
const currentMedia = new Set(
|
||||
Array.from(
|
||||
runtimeRoot.querySelectorAll<HTMLMediaElement>('audio,video'),
|
||||
),
|
||||
);
|
||||
pendingImageListeners.forEach((entry, image) => {
|
||||
const currentSrc = getRecommendRuntimeImageSource(image);
|
||||
if (!currentImages.has(image) || currentSrc !== entry.src || !currentSrc) {
|
||||
entry.cleanup();
|
||||
pendingImageListeners.delete(image);
|
||||
}
|
||||
});
|
||||
pendingMediaListeners.forEach((entry, media) => {
|
||||
const currentSrc = getRecommendRuntimeMediaSource(media);
|
||||
if (!currentMedia.has(media) || currentSrc !== entry.src || !currentSrc) {
|
||||
entry.cleanup();
|
||||
pendingMediaListeners.delete(media);
|
||||
}
|
||||
});
|
||||
|
||||
currentImages.forEach((image) => {
|
||||
const imageSrc = getRecommendRuntimeImageSource(image);
|
||||
const settledImageSrc = settledImageSources.get(image);
|
||||
if (settledImageSrc && settledImageSrc !== imageSrc) {
|
||||
settledImageSources.delete(image);
|
||||
}
|
||||
if (!imageSrc || image.complete) {
|
||||
if (imageSrc && image.complete) {
|
||||
settledImageSources.set(image, imageSrc);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (settledImageSources.get(image) === imageSrc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingEntry = pendingImageListeners.get(image);
|
||||
if (existingEntry?.src === imageSrc) {
|
||||
return;
|
||||
}
|
||||
existingEntry?.cleanup();
|
||||
const markImageReady = () => {
|
||||
const activeEntry = pendingImageListeners.get(image);
|
||||
if (activeEntry?.src !== imageSrc) {
|
||||
return;
|
||||
}
|
||||
settledImageSources.set(image, imageSrc);
|
||||
activeEntry.cleanup();
|
||||
pendingImageListeners.delete(image);
|
||||
scheduleScan();
|
||||
};
|
||||
const cleanupImageListeners = () => {
|
||||
image.removeEventListener('load', markImageReady);
|
||||
image.removeEventListener('error', markImageReady);
|
||||
};
|
||||
image.addEventListener('load', markImageReady, { once: true });
|
||||
image.addEventListener('error', markImageReady, { once: true });
|
||||
pendingImageListeners.set(image, {
|
||||
src: imageSrc,
|
||||
cleanup: cleanupImageListeners,
|
||||
});
|
||||
|
||||
if (image.complete) {
|
||||
markImageReady();
|
||||
}
|
||||
});
|
||||
|
||||
currentMedia.forEach((media) => {
|
||||
const mediaSrc = getRecommendRuntimeMediaSource(media);
|
||||
const settledMediaSrc = settledMediaSources.get(media);
|
||||
const mediaReadyThreshold =
|
||||
typeof HTMLMediaElement !== 'undefined'
|
||||
? HTMLMediaElement.HAVE_CURRENT_DATA
|
||||
: 2;
|
||||
if (settledMediaSrc && settledMediaSrc !== mediaSrc) {
|
||||
settledMediaSources.delete(media);
|
||||
}
|
||||
if (
|
||||
!mediaSrc ||
|
||||
media.readyState >= mediaReadyThreshold ||
|
||||
media.error
|
||||
) {
|
||||
if (mediaSrc && media.readyState >= mediaReadyThreshold) {
|
||||
settledMediaSources.set(media, mediaSrc);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (settledMediaSources.get(media) === mediaSrc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingEntry = pendingMediaListeners.get(media);
|
||||
if (existingEntry?.src === mediaSrc) {
|
||||
return;
|
||||
}
|
||||
existingEntry?.cleanup();
|
||||
const markMediaReady = () => {
|
||||
const activeEntry = pendingMediaListeners.get(media);
|
||||
if (activeEntry?.src !== mediaSrc) {
|
||||
return;
|
||||
}
|
||||
settledMediaSources.set(media, mediaSrc);
|
||||
activeEntry.cleanup();
|
||||
pendingMediaListeners.delete(media);
|
||||
scheduleScan();
|
||||
};
|
||||
const cleanupMediaListeners = () => {
|
||||
media.removeEventListener('loadeddata', markMediaReady);
|
||||
media.removeEventListener('canplaythrough', markMediaReady);
|
||||
media.removeEventListener('error', markMediaReady);
|
||||
};
|
||||
media.addEventListener('loadeddata', markMediaReady, { once: true });
|
||||
media.addEventListener('canplaythrough', markMediaReady, {
|
||||
once: true,
|
||||
});
|
||||
media.addEventListener('error', markMediaReady, { once: true });
|
||||
pendingMediaListeners.set(media, {
|
||||
src: mediaSrc,
|
||||
cleanup: cleanupMediaListeners,
|
||||
});
|
||||
|
||||
if (media.readyState >= mediaReadyThreshold || media.error) {
|
||||
markMediaReady();
|
||||
}
|
||||
});
|
||||
|
||||
const currentBackgroundUrls =
|
||||
collectRecommendRuntimeBackgroundUrls(runtimeRoot);
|
||||
pendingBackgroundPreloads.forEach((cleanup, url) => {
|
||||
if (!currentBackgroundUrls.has(url)) {
|
||||
cleanup();
|
||||
pendingBackgroundPreloads.delete(url);
|
||||
}
|
||||
});
|
||||
currentBackgroundUrls.forEach((url) => preloadBackgroundUrl(url));
|
||||
|
||||
const hasPendingResourceMarker = Boolean(
|
||||
runtimeRoot.querySelector(RUNTIME_RESOURCE_PENDING_SELECTOR),
|
||||
);
|
||||
if (
|
||||
hasPendingResourceMarker ||
|
||||
pendingImageListeners.size > 0 ||
|
||||
pendingMediaListeners.size > 0 ||
|
||||
pendingBackgroundPreloads.size > 0
|
||||
) {
|
||||
cancelReadySchedule();
|
||||
if (pendingRecheckTimeoutId === null) {
|
||||
pendingRecheckTimeoutId = window.setTimeout(() => {
|
||||
pendingRecheckTimeoutId = null;
|
||||
scheduleScan();
|
||||
}, RECOMMEND_RUNTIME_RESOURCE_IDLE_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
cancelPendingRecheck();
|
||||
scheduleReady();
|
||||
}
|
||||
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
const observer = new MutationObserver(scheduleScan);
|
||||
observer.observe(runtimeRoot, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: [
|
||||
'src',
|
||||
'srcset',
|
||||
'style',
|
||||
'data-runtime-resource-pending',
|
||||
],
|
||||
});
|
||||
cleanupCallbacks.push(() => observer.disconnect());
|
||||
}
|
||||
|
||||
scanResources();
|
||||
});
|
||||
}
|
||||
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||
|
||||
Reference in New Issue
Block a user