/** * 懒加载 chunk / CSS preload 失败后的整页恢复。 * 常见于:部署后旧 hash 失效、静态服务空闲断连、代理 CONNECTION_RESET。 * * 失败后必须整页刷新:旧入口里的 hashed 资源地址不会自行更新, * 仅捕获路由错误而不刷新时,未打开过的页面会一直无法进入。 */ const RELOAD_FLAG = 'app:chunk-reload-ts'; const RELOAD_QUERY = '_chunkreload'; const RELOAD_COOLDOWN_MS = 15000; const CHUNK_ERROR_RE = /Failed to fetch dynamically imported module|Importing a module script failed|Unable to preload CSS|error loading dynamically imported module|Loading CSS chunk|Loading chunk .+ failed|ChunkLoadError|Unable to preload|error loading module|Load failed/i; /** 最近一次路由跳转目标,供 onError 时整页落到正确地址 */ let pendingFullPath = ''; let installed = false; let reloading = false; export function setPendingRoutePath(fullPath = '') { pendingFullPath = fullPath || ''; } function collectErrorText(error, depth = 0) { if (!error || depth > 3) return ''; if (typeof error === 'string') return error; const parts = [ error.message, error.msg, error.name, error.stack, error.error && collectErrorText(error.error, depth + 1), error.reason && collectErrorText(error.reason, depth + 1), error.cause && collectErrorText(error.cause, depth + 1), error.payload && collectErrorText(error.payload, depth + 1), ]; const target = error.target; if (target && (target.src || target.href)) { parts.push(target.src || target.href); } try { parts.push(String(error)); } catch (e) { /* ignore */ } return parts.filter(Boolean).join(' '); } export function isChunkLoadError(error) { const text = collectErrorText(error); if (!text) return false; if (CHUNK_ERROR_RE.test(text)) return true; return /Failed to fetch/i.test(text) && /\.m?js(\?|$|:)/i.test(text); } export function hasReloadQuery(query = {}) { return !!(query && query[RELOAD_QUERY] !== undefined); } export function consumeReloadQuery(query = {}) { if (!hasReloadQuery(query)) return query; const next = { ...query }; delete next[RELOAD_QUERY]; return next; } function buildReloadUrl(targetPath) { const url = new URL(targetPath, window.location.origin); url.searchParams.set(RELOAD_QUERY, String(Date.now())); return url.pathname + url.search + url.hash; } /** * @returns {boolean} 是否已触发刷新(冷却期内返回 false,避免死循环) */ export function reloadForChunkError(targetPath) { if (reloading) return false; const now = Date.now(); const last = Number(sessionStorage.getItem(RELOAD_FLAG) || 0); if (now - last < RELOAD_COOLDOWN_MS) { return false; } reloading = true; sessionStorage.setItem(RELOAD_FLAG, String(now)); const path = targetPath || pendingFullPath || `${window.location.pathname}${window.location.search}${window.location.hash}`; const dest = buildReloadUrl(path); // cache: 'reload' 会回写 HTTP 缓存,降低 index.html 仍指向旧 hash 资源的概率 fetch(`${window.location.pathname}?${RELOAD_QUERY}=${now}`, { cache: 'reload', credentials: 'same-origin', headers: { Pragma: 'no-cache', 'Cache-Control': 'no-cache', }, }) .catch(() => {}) .finally(() => { window.location.replace(dest); }); return true; } export function wrapViewLoader(loader) { if (typeof loader !== 'function') return loader; return () => Promise.resolve() .then(() => loader()) .catch(error => { if (isChunkLoadError(error)) { reloadForChunkError(); } throw error; }); } export function installChunkReload() { if (installed || typeof window === 'undefined') return; installed = true; window.addEventListener('vite:preloadError', event => { if (reloadForChunkError()) { event.preventDefault(); } }); window.addEventListener('unhandledrejection', event => { if (!isChunkLoadError(event.reason)) return; if (reloadForChunkError()) { event.preventDefault(); } }); window.addEventListener( 'error', event => { const el = event.target; const isAssetNode = el && el !== window && (el.tagName === 'SCRIPT' || (el.tagName === 'LINK' && /modulepreload|stylesheet/i.test(el.rel || ''))); if (isAssetNode || isChunkLoadError(event.error || event.message)) { if (reloadForChunkError()) { event.preventDefault(); } } }, true ); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkDeployedAssets(); }); } function checkDeployedAssets() { const el = document.querySelector('script[type="module"][src*="/assets/"]'); if (!el || !el.src) return; fetch(el.src, { method: 'HEAD', cache: 'no-store', credentials: 'same-origin' }) .then(res => { if (res.status === 404) reloadForChunkError(); }) .catch(() => {}); }