前段时间我们在一个企业微信审批 H5 上栽了跟头。项目发布成功了,PC 浏览器打开一切正常,企业微信里通过 iframe 打开,看到的还是旧版本。清缓存、强制刷新、重启应用,折腾一圈才加载到最新代码。第二天用户又反馈没更新,WebView 又把旧页面缓存了。这个问题不是偶发,每次发布必现。
我们的 H5 是一个移动端审批系统,以 iframe 形式嵌套在父应用里,构建工具是 Vite 5 加 Vue 3。这个架构下,发布后用户可能拿到上一版页面,体验很糟糕。我花了两个迭代才把方案打磨稳,今天把完整链路和踩过的坑复盘出来。
问题是什么
发布成功后,PC 浏览器能拿到新版本,企业微信里通过 iframe 打开的还是旧版本,每次发布都这样,清理企业微信缓存也不起作用,但是基本第二天就好了。
不同手机表现还不同,苹果手机基本不出现此类问题,安卓和鸿蒙手机比较普遍,明明已经发版,有些用户看到的还是老版本。
原因出在哪
我查了构建产物。Vite 打出来的文件长这样:
assets/index-abc123.js
assets/index-def456.css文件名带内容 hash。只要"index.html"是新的,就一定会引用新的 JS 和 CSS。所以问题不在静态资源上,在 index.html 本身。
关键链路是这样的:
用户在企业微信中打开
→ 父应用加载 iframe(src 指向 index.html)
→ 浏览器或企业微信 WebView 检查 index.html 的 HTTP 缓存
→ 若缓存未过期(天数取决于服务端配置或浏览器启发式缓存策略)
→ 直接返回旧的 index.html
→ 旧 HTML 引用旧的 hash 文件
→ 用户看到旧版本很明显,就是浏览器还使用了旧版的 index.html,没有下载新的,这跟浏览器的缓存策略有关。那现在的问题就是如何让用户端知道我们发布了版本,然后下载最新版本的问题了。
解决问题的思路
既然问题已经清楚了,那就让代码检测一下当前程序的版本,如果当前版本和最新版本不一致就强制重新加载刷新,这样就能解决浏览器缓存的问题了。
最终方案
方案不复杂,关键是想清楚版本号从哪来。构建的时候,把一个精确到秒的时间戳当版本号,用 Vite 的 define 注入到 JS 里,同时写进 version.json 部署到服务器。页面打开的时候,fetch 一下线上的 version.json,拿服务器上的最新版本号,和 JS 里注入的版本号比对,不一致就强制重新加载刷新。
// vite.config.ts
define: {
__APP_VERSION__: JSON.stringify(version)
}APP_VERSION 是构建的时候写死进 JS 的,代表当前这份代码的版本。它有个好处,旧代码里的这个常量永远是旧值,不会被 localStorage 之类的东西影响。对比逻辑是这样:
__APP_VERSION__ 不等于线上 version.json 的版本
→ 说明当前运行的代码确实过期了 → 刷新
→ 刷新后如果还是旧代码 → __APP_VERSION__ 仍不等于 remote → 还会检测
→ sessionStorage 防抖拦住同会话内二次刷新 → 下次打开再试
→ 文件更新完成后,必然更新
→ 永远不会被锁死最终代码长这样,运行时"版本自检"函数:
// src/utils/version-check.ts
const REFRESHED_KEY = 'szpt-h5:version-refreshed'
export async function checkVersionUpdate(): Promise<void> {
if (import.meta.env.DEV) return
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 3000)
try {
const res = await fetch(`version.json?_t=${Date.now()}`, {
cache: 'no-store',
signal: controller.signal
})
if (!res.ok) return
const { version } = (await res.json()) as { version?: string }
if (!version) return
const currentVersion = `${__APP_VERSION__}`
if (currentVersion === version) return
if (sessionStorage.getItem(REFRESHED_KEY) === version) return
sessionStorage.setItem(REFRESHED_KEY, version)
const url = new URL(window.location.href)
url.searchParams.set('_v', version)
url.searchParams.set('_t', String(Date.now()))
window.location.replace(url.toString())
} catch (e) {
console.warn('[version-check] 自检失败,忽略:', e)
} finally {
clearTimeout(timer)
}
}几个关键点说一下为什么这么写。
fetch 带时间戳参数加 cache: no-store。version.json 本身可能被浏览器缓存,不带这两个东西,自检拿到的可能是旧的 version.json,检测就失真了。时间戳让 URL 每次都变,no-store 让浏览器完全不走缓存,保证拿到线上最新版本。
AbortController 配 3 秒超时。version.json 请求挂在启动阶段,如果服务器卡住或者网络慢,不能让页面一直等。3 秒没回来就放弃,直接进 catch,不影响用户使用。
sessionStorage 做防抖。刷新过一次之后,把这次的目标版本号记到 sessionStorage,同一会话里再检测到同一个版本就不刷了,防止刷新循环。为什么用 sessionStorage 不用 localStorage?因为它关掉页面就清空,下次打开还能正常检测,不会留下永久状态。
location.replace 拼 _v 和 _t 两个参数。版本号变了,URL 就变了,浏览器的缓存 key 跟着变,必然重新请求服务器拿新的 index.html,不再受 304 影响。_t 是当前时间,防止两次刷新 URL 完全一样。用 replace 不用 reload,因为 replace 不会在历史记录里留一个旧页面,用户返回键不会回到旧版本。
import.meta.env.DEV 直接跳过开发环境。本地开发时热更新本来就会变,不需要这套检测,跳过去省事。
构建端配置。version 用精确到秒的时间戳,closeBundle 里写 version.json:
// vite.config.ts(精简)
function versionPlugin(outDir: string, version: string): Plugin {
return {
name: 'szpt-version-json',
apply: 'build',
closeBundle() {
fs.writeFileSync(
path.join(outDir, 'version.json'),
JSON.stringify({ version })
)
}
}
}
export default defineConfig(({ mode }) => {
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
const version = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
return {
plugins: [
versionPlugin(path.join(root, env.VITE_OUT_DIR || 'dist'), version),
],
define: {
__APP_VERSION__: JSON.stringify(version)
}
}
})自检挂在应用启动的早期。比如 main.ts 里调一次 checkVersionUpdate,就够了。
服务端配合
后端增加 nginx 配置,确保万无一失。我们给 Nginx 加了两段:
# index.html 每次重新校验
location ~ ^/.*index\.html$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# 带 hash 的静态资源设强缓存
location ~ ^/assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}服务端配置到位,用户甚至不会感知到刷新。每次打开 iframe 直接拿到最新的 index.html,版本自检发现一致,零开销返回。
最后总结
几点经验。
URL 变化是绕过浏览器缓存最可靠的方式。reload 走的是缓存协商,可能 304。location.replace 换个不同 URL 是全新请求,不会命中缓存。
防御性编程要设置"降级行为"。自检里每一个失败点都必须安静跳过,不影响用户。失败点包括 fetch 异常、存储不可用、JSON 解析失败。
前端的缓存问题能通过前端解决。后端 no-cache 配置是效率最优解,前端"版本自检"是覆盖所有场景的兜底保底。两者不是二选一,而是互补。
iframe 嵌套 H5 发布后看到旧版本,根子在 index.html 被 WebView 缓存,不在带 hash 的静态资源。用编译时注入的 APP_VERSION 当"对比基准",配 URL 参数绕过缓存加 sessionStorage"防抖",服务端再补一段 no-cache,体验就完整了。这套方案跑下来,现在每次发布,用户都能稳定拿到新代码。
首发于公众号「键盘漫笔」
