From 37d14a22d545fdb38a7849b3185df2e0615e2d28 Mon Sep 17 00:00:00 2001 From: freefire Date: Tue, 1 Sep 2026 07:53:39 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=8F=91=E7=89=88=E5=90=8E=E8=BD=AF?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E6=A3=80=E6=B5=8B=E5=85=A5=E5=8F=A3=E5=A3=B3?= =?UTF-8?q?=E5=B9=B6=E5=9C=A8=20chunk=20404=20=E6=97=B6=E7=A1=AC=E5=88=B7?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免 Logo/下拉刷新仍跑旧 index.js,以及站内消息预取失败弹出英文 toast。 Co-authored-by: Cursor --- embed_static/embed.go | 29 ++++++++++++++- embed_static/spa_meta.go | 5 ++- frontend/src/main.tsx | 7 ++++ frontend/src/utils/chunkLoad.ts | 42 +++++++++++++++++++++ frontend/src/utils/prefetchRoute.ts | 57 +++++++++++++++++++---------- frontend/src/utils/softRefresh.ts | 17 +++++++-- frontend/src/utils/spaTransition.ts | 3 ++ 7 files changed, 133 insertions(+), 27 deletions(-) diff --git a/embed_static/embed.go b/embed_static/embed.go index d632e97..85d50ae 100644 --- a/embed_static/embed.go +++ b/embed_static/embed.go @@ -40,8 +40,9 @@ func SetupEmbed(r *gin.Engine) error { if sub, err := fs.Sub(staticFS, "static/spa/assets"); err == nil { fileServer := http.StripPrefix("/assets", http.FileServer(http.FS(sub))) r.GET("/assets/*filepath", func(c *gin.Context) { - // hashed 资源可长期缓存;发版后文件名变更,旧 URL 自然 404 - c.Header("Cache-Control", "public, max-age=31536000, immutable") + // 仅 200 写 immutable,避免中间层把旧 chunk 的 404 长期缓存 + w := &cacheOnOKWriter{ResponseWriter: c.Writer, cacheControl: "public, max-age=31536000, immutable"} + c.Writer = w fileServer.ServeHTTP(c.Writer, c.Request) }) } @@ -57,6 +58,30 @@ func SetupEmbed(r *gin.Engine) error { return nil } +// cacheOnOKWriter 仅在最终状态码为 200 时写入长期 Cache-Control +type cacheOnOKWriter struct { + gin.ResponseWriter + cacheControl string + wroteHeader bool +} + +func (w *cacheOnOKWriter) WriteHeader(code int) { + if !w.wroteHeader { + w.wroteHeader = true + if code == http.StatusOK { + w.Header().Set("Cache-Control", w.cacheControl) + } + } + w.ResponseWriter.WriteHeader(code) +} + +func (w *cacheOnOKWriter) Write(b []byte) (int, error) { + if !w.wroteHeader { + w.WriteHeader(http.StatusOK) + } + return w.ResponseWriter.Write(b) +} + // ServeSPA 返回 React SPA 入口(仅注入站点默认标题) func ServeSPA(c *gin.Context) { ServeSPAWithMeta(c, nil) diff --git a/embed_static/spa_meta.go b/embed_static/spa_meta.go index b597aa0..a843107 100644 --- a/embed_static/spa_meta.go +++ b/embed_static/spa_meta.go @@ -43,8 +43,9 @@ func ServeSPAWithMeta(c *gin.Context, meta *SPAPageMeta) { return } data = applySPAPageMeta(data, meta) - // 入口 HTML 禁止长期缓存,否则发版后仍引用旧 chunk 哈希 - c.Header("Cache-Control", "no-cache") + // 入口 HTML 禁止缓存,否则发版后仍引用旧 chunk 哈希(部分反代对 no-cache 仍会存) + c.Header("Cache-Control", "no-cache, no-store, must-revalidate") + c.Header("Pragma", "no-cache") c.Data(status, "text/html; charset=utf-8", data) } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 1ab8fac..796c71e 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,11 +4,18 @@ import { applyTheme, getStoredTheme } from './utils/theme'; import App from './App'; import { consumeHomeBoot } from './utils/homeBoot'; import { beginHomeHydrate } from './utils/homeHydrate'; +import { reloadForStaleChunk } from './utils/chunkLoad'; import { ensureColdBootReady, isMainLayoutPath } from './utils/prefetchRoute'; applyTheme(getStoredTheme()); consumeHomeBoot(); +// Vite modulepreload / 动态 import 失败(发版后旧 hashed URL 404) +window.addEventListener('vite:preloadError', (event) => { + event.preventDefault(); + reloadForStaleChunk(); +}); + function hasSSRHome(): boolean { return !!document.querySelector('#root .ssr-home'); } diff --git a/frontend/src/utils/chunkLoad.ts b/frontend/src/utils/chunkLoad.ts index 70cde73..5ee9d2d 100644 --- a/frontend/src/utils/chunkLoad.ts +++ b/frontend/src/utils/chunkLoad.ts @@ -2,6 +2,22 @@ const RELOAD_AT_KEY = 'j13:chunk-reload-at'; const RELOAD_COOLDOWN_MS = 15_000; +/** 从 HTML 中提取入口 module script(Vite 产物:/assets/index-*.js) */ +function extractEntryScriptSrc(html: string): string | null { + const re = /]*\stype=["']module["'][^>]*\ssrc=["']([^"']+)["'][^>]*>/i; + const m = html.match(re); + if (m?.[1]) return m[1]; + // src 在 type 之前 + const re2 = /]*\ssrc=["']([^"']+)["'][^>]*\stype=["']module["'][^>]*>/i; + return html.match(re2)?.[1] ?? null; +} + +/** 当前文档已加载的入口 script src */ +export function getDocumentEntryScriptSrc(): string | null { + const el = document.querySelector('script[type="module"][src]'); + return el?.getAttribute('src') ?? null; +} + /** 判断是否为「发版后旧 JS chunk 失效」类错误 */ export function isChunkLoadError(error: unknown): boolean { const msg = error instanceof Error @@ -32,3 +48,29 @@ export function reloadForStaleChunk(): boolean { window.location.reload(); return true; } + +/** + * 软刷新前对比入口 HTML 中的 index-*.js 哈希。 + * 发版后当前页 chunk 仍在内存,裸 import() 不会重新拉文件,必须主动检测。 + * @returns 是否已触发硬刷新(调用方应停止后续软刷新) + */ +export async function reloadIfShellStale(): Promise { + const current = getDocumentEntryScriptSrc(); + if (!current) return false; + + try { + const res = await fetch(`${window.location.pathname}${window.location.search}`, { + cache: 'no-store', + headers: { Accept: 'text/html' }, + credentials: 'same-origin', + }); + if (!res.ok) return false; + const html = await res.text(); + const next = extractEntryScriptSrc(html); + if (!next || next === current) return false; + return reloadForStaleChunk(); + } catch { + // 网络失败:跳过,继续软刷新 + return false; + } +} diff --git a/frontend/src/utils/prefetchRoute.ts b/frontend/src/utils/prefetchRoute.ts index 4fe8f1c..37f03fd 100644 --- a/frontend/src/utils/prefetchRoute.ts +++ b/frontend/src/utils/prefetchRoute.ts @@ -26,6 +26,7 @@ import { setCachedRecentUsers, setCachedTags, } from './layoutCache'; +import { isChunkLoadError, reloadForStaleChunk } from './chunkLoad'; import { parsePermalinkID, parsePermalinkSlug } from './permalink'; import { getSessionSnapshot, setSessionSnapshot } from './sessionPageCache'; @@ -57,27 +58,45 @@ function resolveUrl(to: To): URL { /** 预加载对应路由的 lazy chunk(与 App.tsx lazyWithRetry 对齐) */ function preloadChunk(pathname: string): Promise { + let load: Promise; if (pathname === '/' || /^\/board\//.test(pathname)) { - return import('../pages/HomePage'); + load = import('../pages/HomePage'); + } else if (/^\/post\/[^/]+\/edit$/.test(pathname) || pathname === '/compose') { + load = import('../pages/ComposePage'); + } else if (/^\/post\//.test(pathname)) { + load = import('../pages/PostDetailPage'); + } else if (pathname === '/profile') { + load = import('../pages/ProfilePage'); + } else if (/^\/user\//.test(pathname)) { + load = import('../pages/UserProfilePage'); + } else if (pathname === '/favorites') { + load = import('../pages/FavoritesPage'); + } else if (pathname === '/projects') { + load = import('../pages/ProjectsPage'); + } else if (pathname === '/links') { + load = import('../pages/LinksPage'); + } else if (pathname === '/showcase') { + load = import('../pages/ShowcasePage'); + } else if (pathname === '/messages') { + load = import('../pages/MessagesPage'); + } else if (/^\/page\//.test(pathname)) { + load = import('../pages/SitePageView'); + } else if (pathname === '/login') { + load = import('../pages/LoginPage'); + } else if (pathname === '/register') { + load = import('../pages/RegisterPage'); + } else if (pathname === '/forgot-password') { + load = import('../pages/ForgotPasswordPage'); + } else { + return Promise.resolve(); } - if (/^\/post\/[^/]+\/edit$/.test(pathname) || pathname === '/compose') { - return import('../pages/ComposePage'); - } - if (/^\/post\//.test(pathname)) { - return import('../pages/PostDetailPage'); - } - if (pathname === '/profile') return import('../pages/ProfilePage'); - if (/^\/user\//.test(pathname)) return import('../pages/UserProfilePage'); - if (pathname === '/favorites') return import('../pages/FavoritesPage'); - if (pathname === '/projects') return import('../pages/ProjectsPage'); - if (pathname === '/links') return import('../pages/LinksPage'); - if (pathname === '/showcase') return import('../pages/ShowcasePage'); - if (pathname === '/messages') return import('../pages/MessagesPage'); - if (/^\/page\//.test(pathname)) return import('../pages/SitePageView'); - if (pathname === '/login') return import('../pages/LoginPage'); - if (pathname === '/register') return import('../pages/RegisterPage'); - if (pathname === '/forgot-password') return import('../pages/ForgotPasswordPage'); - return Promise.resolve(); + // 发版后旧 hashed URL 404:硬刷新,挂起 Promise 避免落到 toast + return load.catch((err: unknown) => { + if (isChunkLoadError(err) && reloadForStaleChunk()) { + return new Promise(() => {}); + } + throw err; + }); } async function prefetchFeed(url: URL, force: boolean): Promise { diff --git a/frontend/src/utils/softRefresh.ts b/frontend/src/utils/softRefresh.ts index 85bb4a1..95ef923 100644 --- a/frontend/src/utils/softRefresh.ts +++ b/frontend/src/utils/softRefresh.ts @@ -1,3 +1,4 @@ +import { isChunkLoadError, reloadForStaleChunk, reloadIfShellStale } from './chunkLoad'; import { prefetchLayoutShell, prefetchRoute } from './prefetchRoute'; import { doneTransition, startTransition } from './spaTransition'; @@ -10,20 +11,28 @@ export type SoftRefreshOpts = { }; /** - * 软刷新当前页:预热齐套后派发一次 commit。 + * 软刷新当前页:先检测入口壳是否过期,再预热齐套后派发一次 commit。 * `progress: true` 时走顶栏进度条。 */ export async function softRefreshCurrentPage(to?: string, opts?: SoftRefreshOpts): Promise { const path = to ?? `${window.location.pathname}${window.location.search}`; const id = opts?.progress ? startTransition() : undefined; try { + // 发版后旧壳仍在内存:先对比入口 script,过期则硬刷新 + if (await reloadIfShellStale()) { + return; + } await Promise.all([ prefetchRoute(path, { force: true }), prefetchLayoutShell({ force: true }), ]); - } catch { - // 仍派发 commit,让界面有机会用已有缓存自愈 + } catch (e: unknown) { + if (isChunkLoadError(e) && reloadForStaleChunk()) { + return; + } + // 其它错误仍派发 commit,让界面有机会用已有缓存自愈 + } finally { + if (id != null) doneTransition(id); } window.dispatchEvent(new Event(PAGE_SOFT_REFRESH_COMMIT_EVENT)); - if (id != null) doneTransition(id); } diff --git a/frontend/src/utils/spaTransition.ts b/frontend/src/utils/spaTransition.ts index 91feab7..c065b87 100644 --- a/frontend/src/utils/spaTransition.ts +++ b/frontend/src/utils/spaTransition.ts @@ -1,5 +1,6 @@ import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom'; import { notify } from '@/lib/notify'; +import { isChunkLoadError, reloadForStaleChunk } from './chunkLoad'; import { prefetchRoute } from './prefetchRoute'; type Listener = (active: boolean) => void; @@ -100,6 +101,8 @@ export async function transitionTo( nav(target, navOpts); } catch (e: unknown) { if (!silent && mySeq !== seq) return; + // 发版后 chunk 404:整页刷新,不 toast 英文原错 + if (isChunkLoadError(e) && reloadForStaleChunk()) return; notify.error(e instanceof Error ? e.message : '加载失败'); } finally { if (id != null) {