题目与适用场景
一个电商结算页在浏览历史导航中出现两种行为。有时点击后退后,原来的 DOM、表单值、滚动位置和 JavaScript 堆几乎瞬间恢复;这份快照可能仍保存旧购物车总额,或另一标签页已经退出的会话。有时点击后退会创建新文档,重新执行常规加载流程。团队一直用“浏览器缓存”概括所有现象,并考虑全局关闭缓存。
请解释往返缓存(back/forward cache,简称 bfcache)与 HTTP 缓存、SPA 路由缓存的区别,并设计一套生命周期与排障方案:
- 分开识别已经确认的 bfcache 恢复和普通文档加载;
- 在允许高影响操作前,重新校验身份、购物车及其他敏感状态;
- 页面隐藏时释放资源,恢复后只重连一次;
- 找到页面、子 frame 和第三方代码中的资格阻塞项;
- 按浏览器版本衡量历史遍历、确认恢复、未命中和阻塞原因;
- 在符合产品安全策略时保留这项性能收益。
本题适用于高级前端、Web 性能、浏览器平台和前端架构面试。它考察候选人能否围绕“被暂停的文档”推理,而不只会背缓存响应头。
面试官考察什么
第一,候选人应画清三种机制。HTTP 缓存保存可复用的响应,遵守请求、鲜度和重新验证规则。bfcache 可能在内存中保存整个跨文档页面,包括 DOM 与 JavaScript 堆,并在会话历史遍历时暂停、恢复。客户端路由器可以在同一文档内的软导航中保存应用数据或 UI。清理其中一种缓存,无法诊断另外两种机制。
第二,候选人需要理解生命周期证据。pageshow 在初次加载时也会触发,单看事件无法证明恢复。pageshow 的 persisted 为 true 时,可以确认文档从 bfcache 这类缓存恢复。pagehide 中的 persisted: true 只表示浏览器打算保留页面;页面可能随后被逐出,也可能再也没有恢复。
第三,优秀答案会把恢复当作正确性边界。定时器和内存值会从之前的时刻继续运行。授权仍由服务端负责,确认恢复后应重新校验敏感状态。实时连接、数据库句柄、观察器与锁可能需要在离开前关闭或断开,返回后重新打开。重连逻辑必须幂等。
第四,候选人应依靠证据排障,避免把一张固定阻塞清单当作答案。资格会随浏览器、版本、frame、API、响应策略、内存压力和导航场景变化。Chrome DevTools 可以执行独立 bfcache 测试并分类阻塞项。支持的环境还可通过 PerformanceNavigationTiming.notRestoredReasons 获得线上原因和 frame 树,但原因字符串可能变化,跨源细节也可能被屏蔽。
最后,候选人应定义可衡量的结果。类型为 back_forward 的历史遍历无法单独证明 bfcache 命中。确认命中来自 pageshow.persisted。未命中是通过历史遍历创建的新文档,应按原因分组,避免猜测。目标是提高安全恢复覆盖率,同时不出现过期会话操作、重复连接和失真的分析事件。
回答前要澄清的问题
- 发生了哪种导航? 是完整文档导航、SPA 软路由、刷新、重新打开标签页,还是同一标签页中的真实后退/前进遍历?
- 哪些状态可以保留? 表单草稿和滚动位置通常有价值;身份、价格、库存、权限和一次性令牌需要权威鲜度规则。
- 响应策略有哪些硬要求? 某些页面包含产品策略禁止保留在页面快照中的数据,这项要求优先于命中率。
- 哪些资源仍处于活动状态? 库存 WebSocket、IndexedDB 连接、Web Locks、媒体采集、观察器和第三方脚本都可能影响正确性或资格。
- 需要支持哪些浏览器与版本? DevTools 输出和
notRestoredReasons都不构成可移植契约,需用真实历史导航测试支持矩阵。 - 页面是否含子 frame? 同源子 frame 可以给出详细阻塞树;跨源 frame 可能只暴露屏蔽信息,需要隔离供应商代码或建立最小复现。
- “过期”如何定义? 明确最大可接受时长,以及重新校验失败时要禁止的操作。
- 分析口径是什么? 先决定一次恢复算页面浏览、导航,还是两者都算,再避免一次恢复上报重复事件。
- 成功门槛是什么? 追踪可观察历史遍历中的确认恢复率、未命中原因、恢复时延、重新校验失败、过期操作拦截数和重复资源数。
30 秒回答框架
“我会先区分 bfcache、HTTP 缓存和路由缓存:bfcache 为浏览器管理的历史导航暂停整个文档。我只用 pageshow.persisted 确认命中,并用 pagehide 做幂等清理,不预设页面一定会恢复。确认恢复后,我先重新校验会话、购物车、价格和权限,再只重连一次实时资源。我会在支持的浏览器中复现未命中,结合 Chrome bfcache 面板、可用的 notRestoredReasons 和分浏览器线上指标定位原因。安全页面尽量获得性能收益,服务端授权和禁止快照策略始终是硬边界。”
分步深入分析
第一步:建立四条可观察路径
先从结果出发,不从响应头出发:
| 路径 | 是否创建新文档 | 主要证据 | 必要处理 | |---|---:|---|---| | 初次访问或链接导航 | 是 | pageshow.persisted === false;导航类型通常为 navigate | 初始化状态与资源 | | 刷新 | 是 | pageshow.persisted === false;导航类型为 reload | 执行常规加载路径 | | 重新加载的历史遍历 | 是 | pageshow.persisted === false;导航类型为 back_forward | 记录 bfcache 未命中并检查原因 | | 已确认的 bfcache 恢复 | 否 | pageshow.persisted === true | 重新校验敏感状态并安全恢复 |
导航类型描述新文档怎样到达,无法确认 bfcache 命中,因为命中会恢复旧文档。它可以识别由历史遍历触发的新加载,从而进入未命中分母。浏览器重启、复制标签页和重新打开标签页会让信号变得模糊,因此线上指标应尽量携带浏览器、版本、路由和场景。
bfcache 与 HTTP 重新验证也有清晰边界。恢复时,浏览器继续运行内存中的页面;像 Cache-Control: no-cache 这样的指令不会在像素出现前触发网络验证。恢复内容必须保持最新时,应用在 pageshow 后执行有针对性的校验。SPA 软导航在同一文档中改变历史和 UI;离开并返回这份文档时,bfcache 才参与其中。
第二步:让生命周期逻辑幂等且可安全恢复
集中管理连接所有权。清理之后可能出现新文档、恢复旧文档,也可能永不返回。初始化会在初次加载以及多次恢复后执行,所以连接和断开都要允许重复调用。
let liveSocket = null;
function connectLiveUpdates() {
if (liveSocket) return;
liveSocket = new WebSocket("wss://shop.example/live-cart");
}
function disconnectLiveUpdates() {
liveSocket?.close();
liveSocket = null;
}
async function revalidateSensitiveState() {
const response = await fetch("/api/session-snapshot", {
cache: "no-store",
credentials: "same-origin",
});
if (response.status === 401) {
location.replace("/login");
return false;
}
if (!response.ok) {
showRetryState();
return false;
}
renderSessionState(await response.json());
return true;
}
window.addEventListener("pagehide", disconnectLiveUpdates);
window.addEventListener("pageshow", async (event) => {
reportNavigation(event.persisted ? "bfcache_restore" : "document_load");
if (event.persisted && !(await revalidateSensitiveState())) return;
connectLiveUpdates();
});示例中的服务端端点仍是权威数据源。恢复后的敏感状态完成校验前,UI 应暂时禁用结算;校验失败应展示重试状态,不能默默信任快照。服务端结算请求仍需重新授权用户,并重新计算价格和库存。
不要用 unload 承担关键清理。这个事件不可靠,也会让某些浏览器中的页面失去资格。页面生命周期清理优先使用 pagehide;需求关注可见性时使用 visibilitychange。Chromium 专属的 freeze 与 resume 不应成为唯一正确性路径。
第三步:从本地复现追到未命中责任方
用确定步骤复现一个路由:直接打开目标页,建立相关状态,通过普通链接进入第二个文档,再点击浏览器后退。对照测试先停用扩展和开发工具,因为它们可能改变生命周期行为;随后使用生产响应头和第三方脚本重复测试。
在 Chrome 中运行 Application 面板的 Back/forward cache 测试。记录结果属于可操作、等待浏览器支持,还是不可操作,并展开 frame 归属。如果出现 unload 监听器,确认注册方来自一方代码还是供应商。如果结果指向响应策略、开放连接、活动事务、锁、媒体 API 或 frame,就缩小到能复现问题的最小真实功能。
在支持的环境中,对由历史遍历重新创建的文档读取 notRestoredReasons。把层级结构和原因值保留为诊断数据,不能把它们写进产品业务分支。不要硬编码原因字符串,不要假设每个浏览器都支持该属性,也不要仅凭 null 判定命中。跨源子项可能因隐私只返回屏蔽原因;可单独测试嵌入供应商,或在受控实验中移除它来建立因果关系。
每次只修一个责任方,重新运行独立测试,再验证完整路由。在 pagehide 关闭 IndexedDB 连接或 WebSocket、移除 unload 处理器、约束第三方 frame 都只是待验证假设,最终以浏览器测试结果为准。
第四步:在线上同时验证正确性与性能
每个 pageshow 只上报一次事件。persisted: true 表示确认恢复。对于新文档加载,附带 Navigation Timing 类型;back_forward 表示一次可观察到的历史遍历未通过 bfcache 恢复当前页。两类事件应互斥。事件可以携带路由类别、浏览器、版本、身份状态和实验组,但不得记录私密页面内容。
至少衡量以下指标:
- 按浏览器和路由分组的确认恢复数除以可观察历史遍历数;
- 未命中数量及可用的阻塞原因类别,包括屏蔽和不支持;
- 恢复到下一次绘制、恢复到敏感状态可交互的时延;
- 会话、权限、购物车、价格和库存重新校验失败;
- 恢复状态未确认时被拦截的结算尝试;
- 多次后退/前进后的重复 socket、观察器、分析事件和定时器副作用;
- 内存回归和用户可见故障,因为命中率不是唯一目标。
测试中应重复历史遍历,在另一标签页退出登录或修改购物车,让一次性令牌过期,让重新校验端点失败,并覆盖跨源 frame。测试范围内的 Chrome、Firefox 与 Safari 版本都要执行。Chrome 本地面板证明一个受控案例,线上遥测证明真实分布,产品断言证明安全性。
高质量示例答案
“瞬间恢复路径来自 bfcache:浏览器保存了整个结算文档与 JavaScript 堆,暂停它,并在会话历史导航中恢复。HTTP 缓存只保存响应,客户端路由器处理同一文档里的软导航。我会先标记路径。pageshow.persisted === true 可以确认恢复。Navigation Timing 类型为 back_forward 的新文档,表示历史遍历没有让当前页从 bfcache 恢复。pagehide.persisted === true 只表示浏览器打算缓存,所以我不会把它记录成命中。”
“在 pagehide 中,我用幂等清理关闭不应保持活动的资源,例如购物车 socket 和开放事务。每次 pageshow 都只确保连接存在一次。确认恢复后,我暂时禁止结算,获取权威会话和购物车快照,更新 UI,已退出时跳转登录。结算 API 仍会检查授权、最新价格与库存,所以恢复的堆永远不能授权购买。分析系统只为恢复计一次事件,不重放全部初次加载副作用。”
“排查未命中时,我会用生产响应头和供应商脚本复现完整文档路径。在 Chrome Application 面板运行 bfcache 测试,展开 frame 归属,每次只处理一个可操作原因。支持的 Chromium 版本可以为未命中的历史加载采集 notRestoredReasons;屏蔽和未知情况会原样保留,产品行为不依赖原因文本。随后测试支持范围内的 Chrome、Firefox 与 Safari,因为资格会随实现和场景变化。”
“发布门槛是确认恢复率提高、返回时延下降,同时测试中的过期会话操作、重复连接和分析重复保持为零。我会按路由和浏览器拆分未命中及重新校验失败。如果产品策略规定某类私密数据不能保留在页面快照中,我会保留限制,转而优化相邻页面,避免用安全性换命中率。”
常见错误与改进
- 把所有返回路径都叫“浏览器缓存” → HTTP 响应、整页快照和路由状态遵守不同规则 → 先命名机制,再收集对应证据。
- 只要触发
pageshow就判定恢复 → 初次文档加载也会触发它 → 只用event.persisted === true确认恢复。 - 把
pagehide.persisted当作未来命中 → 浏览器可能逐出页面或选择其他路径 → 只用它指导安全清理,恢复时再记命中。 - 认定
back_forward等于 bfcache → 它可以描述历史遍历创建的新文档 → 用导航类型识别未命中,用pageshow.persisted识别命中。 - 为了修复旧 UI 而关闭全部缓存 → 生命周期缺陷被掩盖,瞬间导航也消失 → 重新校验敏感字段,并保持服务端授权权威。
- 每次恢复都刷新整页 → 全局刷新放弃优化,失败时还可能循环 → 只更新权威状态,把刷新或跳转留给明确的无效状态。
- 背一张永久阻塞清单 → 各引擎和版本的资格与原因名会变化 → 使用浏览器测试、特性检测和线上证据。
- 在
unload中清理 → 事件不可靠,并可能阻止资格 → 使用pagehide;可见性需求另用可见性事件。 - 在每个生命周期事件中重连 →
pageshow、resume和框架 effect 可能创建重复 socket 或观察器 → 为每类资源设置一个幂等所有者。 - 相信一次 DevTools 通过 → 它不覆盖真实供应商、内存压力、其他浏览器和会话变化 → 组合受控测试、浏览器矩阵、RUM 与对抗性产品流程。
追问
Cache-Control: no-cache 会在 bfcache 恢复前强制验证吗?
不会。bfcache 恢复会继续运行内存中的文档,不通过 HTTP 缓存满足资源请求,所以恢复页面显示前不会执行 HTTP 重新验证指令。用 pageshow.persisted 触发有针对性的鲜度校验。如果策略禁止保留页面快照,应应用相应响应和浏览器策略,同时接受不同实现中的资格差异。
PerformanceNavigationTiming.type === "back_forward" 是命中信号吗?
不是。它告诉新创建的文档:当前文档通过历史遍历到达。已确认的 bfcache 恢复会继续旧文档,应通过 pageshow.persisted === true 识别。新的 back_forward 加载可以计入可观察未命中,但需考虑浏览器重启、重新打开标签页等差异,并据此分组遥测。
notRestoredReasons 缺失、为 null 或被屏蔽时怎么办?
缺失按不支持处理,null 本身证据不足,屏蔽则作为保护隐私的诊断类别。命中仍由页面过渡事件确认。未命中时进行本地复现,查看可用 DevTools 输出,隔离子 frame 和第三方脚本,并保留“未知”分组,避免杜撰原因。
分析系统怎样处理恢复页面?
先确定产品口径。如果恢复算一次页面浏览,就从 pageshow.persisted 上报一个事件,不重放初次加载埋点。添加导航类型字段,便于区分文档加载和恢复。按需重置本次访问范围的性能累计值,并测试反复后退/前进是否重复上报。
已登录结算页应使用 bfcache 吗?
安全策略允许页面快照,且恢复路径能够保证安全时才使用。恢复后重新校验会话和高影响数据,完成前阻止敏感操作,并始终执行服务端授权与价格校验。若私密内容不能通过历史恢复,就执行这项策略,并把 bfcache 优化放在敏感度较低的路由上。