fix(web):修复前端缓存导致点击页面没反应
This commit is contained in:
1 parent
d2e3794e3a
commit
08c885aa6c
4 files changed
+278
-7
No files matched your search
+87
-7
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user