fix(web):修复前端缓存导致点击页面没反应

This commit is contained in:
qian committed 2026-08-27 00:08:31 +08:00
1 parent d2e3794e3a
commit 08c885aa6c
4 files changed
+278 -7

No files matched your search

+87 -7
View File
@@ -2,6 +2,8 @@ package cmd
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io/fs"
"net/http"
@@ -521,6 +523,54 @@ func registerFrontendRoutes(s *ghttp.Server) {
})
}
// staticAssetExts 视为静态资源的扩展名集合:此类请求未命中文件时应返回 404
// 而非回退 index.html,避免把 HTML 当 JS/JSON 返回触发浏览器严格 MIME 校验报错。
var staticAssetExts = map[string]struct{}{
".js": {}, ".mjs": {}, ".css": {}, ".wasm": {}, ".map": {},
".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {}, ".webp": {}, ".avif": {},
".svg": {}, ".ico": {}, ".woff": {}, ".woff2": {}, ".ttf": {}, ".otf": {}, ".eot": {},
".mp4": {}, ".webm": {}, ".mp3": {}, ".wav": {}, ".json": {}, ".xml": {}, ".txt": {},
}
// isAssetPath 判断请求路径是否具有静态资源特征(assets 目录或带资源扩展名)。
func isAssetPath(path string) bool {
if strings.HasPrefix(path, "assets/") {
return true
}
ext := strings.ToLower(path[strings.LastIndex(path, "/")+1:])
dot := strings.LastIndex(ext, ".")
if dot <= 0 {
return false
}
_, ok := staticAssetExts[ext[dot:]]
return ok
}
// setStaticCacheHeader 按资源类型设置缓存策略:
// Vite 构建产物(assets/ 下 hash 文件名)永久强缓存;入口文件(index.html 等)要求每次进站重新校验,
// 保证发版后用户总能拿到新入口。
func setStaticCacheHeader(w http.ResponseWriter, path string) {
if strings.HasPrefix(path, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
return
}
w.Header().Set("Cache-Control", "no-cache")
}
// matchesEtag 判断请求头 If-None-Match 是否包含当前指纹(值可能是逗号分隔列表或通配 *)。
func matchesEtag(header, tag string) bool {
if header == "" {
return false
}
for _, part := range strings.Split(header, ",") {
part = strings.TrimSpace(part)
if part == "*" || part == tag {
return true
}
}
return false
}
// spaHandler returns an http.HandlerFunc that serves static files from the
// given filesystem, falling back to index.html for SPA client-side routing.
func spaHandler(root fs.FS, prefix string) http.HandlerFunc {
@@ -529,28 +579,58 @@ func spaHandler(root fs.FS, prefix string) http.HandlerFunc {
fileServer = http.StripPrefix(prefix, fileServer)
}
// 启动时预读入口文件并预计算 SHA-256 指纹作为 ETag:
// index.html 走 no-cache 协商缓存,未重新发布的重复访问返回 304 空响应,
// 既保证发版即生效、又不产生重复下载开销。
indexBytes, indexErr := fs.ReadFile(root, "index.html")
indexTag := ""
if indexErr == nil {
sum := sha256.Sum256(indexBytes)
indexTag = `"` + hex.EncodeToString(sum[:]) + `"`
}
serveIndex := func(w http.ResponseWriter, r *http.Request) {
if indexErr != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("ETag", indexTag)
if matchesEtag(r.Header.Get("If-None-Match"), indexTag) {
w.WriteHeader(http.StatusNotModified)
return
}
w.Write(indexBytes)
}
return func(w http.ResponseWriter, r *http.Request) {
// Resolve the file path within the embedded FS
path := strings.TrimPrefix(r.URL.Path, prefix)
path = strings.TrimPrefix(path, "/")
if path == "" {
path = "index.html"
// 入口文件统一走自建响应(fileServer 无法为 embed FS 提供 Last-Modified/ETag)
if path == "" || path == "index.html" {
serveIndex(w, r)
return
}
// Try to open the file; if it exists, serve it directly
if f, err := root.Open(path); err == nil {
f.Close()
setStaticCacheHeader(w, path)
fileServer.ServeHTTP(w, r)
return
}
// File not found — serve index.html (SPA fallback)
indexBytes, err := fs.ReadFile(root, "index.html")
if err != nil {
// 带静态资源特征的请求(如发版后旧 hash chunk)缺失时返回 404,
// 不回退 SPA,方便前端自愈逻辑识别并整页刷新到新版本。
if isAssetPath(path) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(indexBytes)
// Route not found — serve index.html (SPA fallback)
serveIndex(w, r)
}
}
+141
View File
@@ -0,0 +1,141 @@
package cmd
import (
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
)
// 构造模拟的前端构建产物:入口文件 + 一个带 hash 的静态 chunk
func newSpaTestFS() fs.FS {
return fstest.MapFS{
"index.html": &fstest.MapFile{
Data: []byte("<!doctype html><html><head><script src=\"/assets/index-x9y8z7.js\"></script></head><body></body></html>"),
},
"assets/index-x9y8z7.js": &fstest.MapFile{Data: []byte("console.log('chunk')")},
"favicon.ico": &fstest.MapFile{Data: []byte("ico")},
"screenshots/page-a.webp": &fstest.MapFile{Data: []byte("img")},
}
}
func get(t *testing.T, h http.HandlerFunc, target string, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, target, nil)
for k, v := range headers {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
h(rec, req)
return rec
}
func assertContains(t *testing.T, rec *httptest.ResponseRecorder, key, want string) {
t.Helper()
if got := rec.Header().Get(key); got != want {
t.Errorf("%s = %q, 期望 %q", key, got, want)
}
}
// 路由请求回退 index.html,且带 no-cache 与 ETag
func TestSpaHandlerRouteFallback(t *testing.T) {
h := spaHandler(newSpaTestFS(), "")
rec := get(t, h, "/dashboard", nil)
if rec.Code != http.StatusOK {
t.Fatalf("状态码 = %d, 期望 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "index-x9y8z7.js") {
t.Error("响应体应为 index.html 内容")
}
assertContains(t, rec, "Cache-Control", "no-cache")
if rec.Header().Get("ETag") == "" {
t.Error("index.html 应携带 ETag")
}
}
// 根路径与显式 /index.html 都走同一入口响应
func TestSpaHandlerIndexPaths(t *testing.T) {
h := spaHandler(newSpaTestFS(), "")
for _, target := range []string{"/", "/index.html"} {
rec := get(t, h, target, nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "<!doctype html>") {
t.Errorf("%s 应返回 200 的 index.html,实际 %d", target, rec.Code)
}
}
}
// 未重新发布时重复访问命中 ETag 协商缓存,返回 304 空响应
func TestSpaHandlerEtagNegotiation(t *testing.T) {
h := spaHandler(newSpaTestFS(), "")
first := get(t, h, "/", nil)
tag := first.Header().Get("ETag")
rec := get(t, h, "/dashboard", map[string]string{"If-None-Match": tag})
if rec.Code != http.StatusNotModified {
t.Fatalf("状态码 = %d, 期望 304 Not Modified", rec.Code)
}
if rec.Body.Len() != 0 {
t.Error("304 响应不应携带 body")
}
}
// 带静态资源特征的缺失文件返回 404 而非回退 SPA HTML(发版后旧 hash chunk 场景)
func TestSpaHandlerMissingAssetReturns404(t *testing.T) {
h := spaHandler(newSpaTestFS(), "")
cases := []string{
"/assets/stale-old-hash.js",
"/assets/some.css",
"/assets/vendor.wasm",
"/images/missing.png", // 不存在的资源扩展名
"/manifest.json",
}
for _, target := range cases {
rec := get(t, h, target, nil)
if rec.Code != http.StatusNotFound {
t.Errorf("%s 应返回 404,实际 %d", target, rec.Code)
}
if ct := rec.Header().Get("Content-Type"); strings.Contains(ct, "text/html") {
t.Errorf("%s 的 404 不应是 text/html", target)
}
}
}
// 无扩展名的未知路径仍回退 index.html(深层路由刷新场景)
func TestSpaHandlerUnknownRouteStillFallbacks(t *testing.T) {
h := spaHandler(newSpaTestFS(), "")
rec := get(t, h, "/team/member-detail/42", nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "index") {
t.Fatalf("深层路由应回退 index.html,实际 %d", rec.Code)
}
}
// 存在的 assets 文件命中后强缓存一年;根级非 hash 文件要求协商校验
func TestSpaHandlerCacheHeaders(t *testing.T) {
h := spaHandler(newSpaTestFS(), "")
rec := get(t, h, "/assets/index-x9y8z7.js", nil)
assertContains(t, rec, "Cache-Control", "public, max-age=31536000, immutable")
rec = get(t, h, "/favicon.ico", nil)
assertContains(t, rec, "Cache-Control", "no-cache")
}
// 带 URL 前缀的 admin 子应用:前缀剥离、前缀下的 404 与缓存规则同样生效
func TestSpaHandlerWithPrefix(t *testing.T) {
h := spaHandler(newSpaTestFS(), "/admin")
rec := get(t, h, "/admin/", nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "<!doctype html>") {
t.Fatalf("/admin/ 应返回 index.html,实际 %d", rec.Code)
}
rec = get(t, h, "/admin/assets/missing.js", nil)
if rec.Code != http.StatusNotFound {
t.Errorf("/admin/assets/missing.js 应返回 404,实际 %d", rec.Code)
}
rec = get(t, h, "/admin/assets/index-x9y8z7.js", nil)
assertContains(t, rec, "Cache-Control", "public, max-age=31536000, immutable")
}
+25
View File
@@ -26,6 +26,28 @@ console.log(
'color: #666;',
)
// 构建发版后旧 hash chunk 失效时自动整页刷新加载新版本;标记防止刷新死循环
const CHUNK_RELOAD_KEY = 'spa_stale_chunk_reloaded'
function reloadOnStaleChunk() {
if (!sessionStorage.getItem(CHUNK_RELOAD_KEY)) {
sessionStorage.setItem(CHUNK_RELOAD_KEY, '1')
window.location.reload()
}
}
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault()
reloadOnStaleChunk()
})
router.onError((error) => {
const msg = String(error instanceof Error ? error.message : error)
if (/Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module/i.test(msg)) {
reloadOnStaleChunk()
}
})
const app = createApp(App)
app.config.errorHandler = (error, _instance, info) => {
console.error('[VueError]', info, error)
@@ -34,3 +56,6 @@ app.use(createPinia())
app.use(ArcoVue)
app.use(router)
app.mount('#app')
// 页面成功启动后清除标记,后续每次发版仍可各自动刷新一次
sessionStorage.removeItem(CHUNK_RELOAD_KEY)
+25
View File
@@ -29,6 +29,28 @@ console.log(
'color: #666;',
)
// 构建发版后旧 hash chunk 失效时自动整页刷新加载新版本;标记防止刷新死循环
const CHUNK_RELOAD_KEY = 'spa_stale_chunk_reloaded'
function reloadOnStaleChunk() {
if (!sessionStorage.getItem(CHUNK_RELOAD_KEY)) {
sessionStorage.setItem(CHUNK_RELOAD_KEY, '1')
window.location.reload()
}
}
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault()
reloadOnStaleChunk()
})
router.onError((error) => {
const msg = String(error instanceof Error ? error.message : error)
if (/Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module/i.test(msg)) {
reloadOnStaleChunk()
}
})
const app = createApp(App)
app.config.errorHandler = (error, _instance, info) => {
console.error('[VueError]', info, error)
@@ -38,3 +60,6 @@ app.use(createPinia())
app.use(router)
app.use(naive)
app.mount('#app')
// 页面成功启动后清除标记,后续每次发版仍可各自动刷新一次
sessionStorage.removeItem(CHUNK_RELOAD_KEY)