背景与问题现象
在近期开发的一个医学类项目中,我负责一个数据预览页面的前端开发。由于安全和数据时效性要求,这个地方采用了一种"免登录数据隔离"的架构设计:
-
数据传递: 预览页面由后台系统通过
window.open打开,并通过postMessage将患者的临床数据(clinic-patient-data)传递给前台页面,前台接收后先暂存入localStorage中进行缓存,然后再进行渲染页面。 -
时效保护: 因为数据有做缓存嘛,所以默认情况下,刷新页面的话,虽然postMessage不会再发送数据了,但是由于我的本地缓存已经存储了,所有可以直接使用。为了防止,缓存过期以后用户依旧手动刷新,导致页面渲染兜底的默认选项,我在前台部分做了检测------如果检测到用户执行了"手动刷新",且本地缓存已被清空,则视为"预览数据过期",直接弹窗提示并强行关闭页面。(
在这个地方的时候,我认为用户既然手动刷新了,说明页面已经渲染出来了,数据已经接收到并且缓存了,如果后面判断的时候,缓存中没有数据了,就说明过期了,这个地方如果没有接收到数据的话,我设置了超时保护,会触发别的提醒)
核心判定代码如下:
ts
// 检测是否为页面刷新const isReloadRef = useRef(false);
if (typeof window !== "undefined" && window.performance) {
const navEntry = performance.getEntriesByType?.("navigation")?.[0] as PerformanceNavigationTiming | undefined;
isReloadRef.current = navEntry?.type === "reload"; // 依赖 Performance API 判定刷新
}
useEffect(() => {
if (!isExternalMode || !isReloadRef.current) return;
const stored = localStorage.getItem("clinic-patient-data");
if (!stored) {
// 过期保护
Modal.warning({
title: "提示",
content: "预览数据已过期,请重新打开预览链接",
okText: "关闭",
onOk: () => window.close(),
});
}
}, [isExternalMode]);
遇到的诡异 Bug:
在非无痕(正常)模式下打开该页面一切正常。然而,当使用无痕浏览器(Incognito Mode)首次打开该网页时(该网页目前部署在 HTTP 环境下),浏览器会弹出标准的"非安全连接,是否继续访问"的警告。点击"继续访问"进入页面后,页面竟然瞬间触发了上述的"数据过期"逻辑,弹窗并强行关闭。
这个地方的逻辑本来就是,后台打开一个新标签页,然后传递数据给前台(为了确保前台收到,封装了类似TCP的握手机制,这里就不过多阐述了),前台的话接收到数据的话,postMessage已经传递完了,就不会再进行传递了,如果这个时候拿着这个创建的新的标签的地址去别的浏览器访问的话,那肯定收不到数据了啊,也没有缓存这种的,本来我已经做了防护了,也就是超时保护嘛,但是被测试出来有个BUG了,场景还相对比较刁钻了,谁家好人会拿这个预览的链接去做分析,还专门用无痕,而且正常项目上线的话,肯定都是Https的场景啊了,也就不会触发我这个BUG,但是没办法啊,人家提了,我就得兼容啊。
探索、实验与定位过程
为了揪出原因,我没有盲目修改代码,而是针对无痕模式 、网络协议(HTTP/HTTPS)以及上下文环境展开了多组控制变量实验:
实验一:直接复制 URL 跨模式访问
-
操作: 在正常模式下打开后台并生成预览页(正常),随后将预览页的 URL 直接复制到别的的浏览器中打开(非无痕)。
-
现象: 直接触发"超时保护"。
-
推论: 预料之内。因为直接复制 URL 属于"孤立冷启动",没有父窗口给它
postMessage传数,数据本身就是空的,应该触发超时保护啊。
实验二:直接复制 URL 跨模式访问(无痕)
-
操作: 在正常模式下打开后台并生成预览页(正常),随后将预览页的 URL 直接复制到无痕浏览器中打开。
-
现象: 触发"数据过期"。
-
推论: 预料之外,正常来说应该触发超时保护的。因为直接复制 URL 属于"孤立冷启动",没有父窗口给它
postMessage传数,数据本身就是空的,应该触发超时保护啊。对比了一下,因为是http嘛,这个地方会出现一个提醒,让我是否继续访问,我点击继续访问的话,就这样了,所以初步猜测,是因为这个导致的,我的判定手动刷新的代码判定为此操作为重新刷新了。
实验三:无痕模式下链路完整测试
-
操作: 在无痕模式里先登录后台,由后台正常触发
window.open打开前台预览页。此时浏览器依然弹出了 HTTP 安全警告。 -
现象: 点击"继续访问"后,页面竟然正常了,没有触发过期!
-
推论: 说明
postMessage本身在无痕模式下是可以正常握手并传递数据的。如果传递了数据的话,也就不会走超时保护和过期检查的逻辑了。
实验四:探究无痕模式"第一个网站"的冷启动规律(关键转折点)
为了彻底摸清为什么有时触发、有时不触发,我设计了最核心的对照组实验:
-
测试点 A: 将前台预览页作为无痕模式窗口打开的第一个 网站 → 必定触发数据过期。
-
测试点 B: 先在无痕模式里打开任意一个其他的 HTTP 网站(允许其通过安全警告),再在当前窗口加载前台预览页 → 完全正常(不触发过期)。
-
测试点 C: 先在无痕模式里打开一个 HTTPS 的网站(如百度/GitHub),新开标签页再加载前台预览页 → 依然触发数据过期。
原因分析与底层机理
结合上述实验,我彻底锁定了问题的根源。这其实是无痕模式的隐私隔离、浏览器的 HTTPS-First 安全策略、以及 Performance API 状态判定三者撞车引发的"完美误判风暴"。
核心机理拆解:
- 网络栈冷启动与系统级拦截:
当预览页作为无痕模式的"第一个网站"打开时,浏览器为其初始化一个全新的网络进程。由于是 HTTP 网站,现代 Chromium 内核为了安全会强制启用 HTTPS-First 机制 ,在页面 JS 还没加载前,就将其强行拦截并展示了内置的安全警告页(类似 chrome://interstitials/)。
- 上下文丢失与 Performance API 误报:
由于这是无痕第一个页面,没有前序历史记录。当你点击"继续访问"时,浏览器是在当前空标签页中,将系统警告页强行替换恢复为你的 HTTP 网页。
-
在 Chromium 内核的处理逻辑中,这种从"系统安全拦截页恢复导航"的行为,在读取
performance.getEntriesByType("navigation")[0].type** 时,会被错误地打上"reload"(刷新)的标记!** -
**无痕模式的数据隔离:**在无痕全新标签页中,
localStorage本就是干净的。
当这两个条件在第一次加载时诡异地同时满足:
-
navEntry?.type === "reload"(浏览器底层策略引发的虚假刷新误报) -
localStorage.getItem(...) === null(首次加载,数据确实还没通过postMessage存下来)
前端代码的过期逻辑被误判放行 ,直接执行了 window.close()。而一旦先访问过其他 HTTP 网站,浏览器记录了当前会话的临时安全白名单,不再弹窗拦截,type 恢复为正常的 "navigate",Bug 也就消失了。
解决方案
找到原因后,解决思路就很清晰了:不能单方面信任 performance.getEntriesByType("navigation") 的单次判定。 既然是为了防止"当前页被刷新导致数据丢失",我们需要引入一个针对"当前标签页生命周期"的可靠辅助状态。
这个地方我选择了 sessionStorage 作为双保险。因为 sessionStorage 的生命周期严格绑定在当前标签页。
-
无论是冷启动还是安全拦截,只要是第一次进来,
sessionStorage必定为空。 -
只有当用户真的在这个标签页里手动点击了刷新 ,
sessionStorage的值才会被保留。
修复后的核心代码:
ts
// 定义一个用于记录数据成功初始化过的 keyconst DATA_INITIALIZED_KEY = "clinic_data_initialized";
const isReloadRef = useRef(false);
if (typeof window !== "undefined" && window.performance) {
const navEntry = performance.getEntriesByType?.("navigation")?.[0] as PerformanceNavigationTiming | undefined;
const browserSaysReload = navEntry?.type === "reload";
// 核心:检查 sessionStorage 里面是否有过成功接收数据的标记const sessionSaysReload = typeof sessionStorage !== "undefined" && !!sessionStorage.getItem(DATA_INITIALIZED_KEY);
// 双重校验:只有当浏览器认为是 reload,且当前标签页确实已经初始化过数据(证明不是第一次冷启动拦截),才认定为真正的刷新
isReloadRef.current = browserSaysReload && sessionSaysReload;
}
useEffect(() => {
if (!isExternalMode) return;
// 只有通过了严格双重锁定的"真实手动刷新",才走过期判定if (isReloadRef.current) {
const stored = localStorage.getItem("clinic-patient-data");
if (!stored) {
dispatch({ type: "SET_LOADING", payload: false });
Modal.warning({
title: "提示",
content: "预览数据已过期,请重新打开预览链接",
okText: "关闭",
onOk: () => window.close(),
});
return;
}
}
}, [isExternalMode]);
// ====== 🌟 关键:在监听 postMessage 并成功处理数据的地方 ======window.addEventListener("message", (event) => {
// ... 执行你的数据安全校验与 localStorage 写入逻辑 ...// 成功处理并写入数据后,在当前页面的 sessionStorage 盖章if (typeof sessionStorage !== "undefined") {
sessionStorage.setItem(DATA_INITIALIZED_KEY, "true");
}
});
总结与反思
这个 Bug 的排查带给我两点非常深刻的启示:
-
不要盲目信任底层的 API 状态:
performance.navigation.type在绝大多数情况下是准确的,但在跨越浏览器安全沙箱(如 HTTP 升级拦截、OAuth 跨域重定向、第三方授权登录)等边缘场景下,浏览器的底层处理机制可能会使其状态产生扭曲。 -
多维度状态锁定的重要性: 在处理敏感的、具有生命周期的业务逻辑(如防刷新、单次有效连接)时,采用内存状态、
sessionStorage状态与浏览器基础 API 进行多因子联合判定,能让整个前端应用表现得更加健壮。