ToolBox
返回博客列表
企业微信 iframe 打开还是旧版本?如何解决企业微信内部浏览器缓存问题

企业微信 iframe 打开还是旧版本?如何解决企业微信内部浏览器缓存问题

企业微信 iframe 里打开 H5 总拿到旧版本,根子是 index.html 被 WebView 缓存。用编译时注入的版本号做基准,配 URL 参数绕过缓存加 sessionStorage 防抖,服务端再补 no-cache,发布即更新。

2026年8月27日11 分钟键盘漫笔 · 前端缓存 · iframe · 企业微信 · Vite

前段时间我们在一个企业微信审批 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,体验就完整了。这套方案跑下来,现在每次发布,用户都能稳定拿到新代码。

首发于公众号「键盘漫笔」