首页 / 文章

为前后缓存设计:资格条件、恢复与状态

了解浏览器的前进/后退缓存如何恢复整页内容,是什么在暗中阻碍这一过程,以及如何通过 pageshow 和 pagehide 来处理已恢复的状态。

发布者:REACTAPP.TOP阅读约 14 分钟3106 词

来源: The Browser Feature Most Frontend Developers Completely Ignore

当用户点击“上一步”时,大多数应用程序会默默地重新执行浏览器本可以跳过的操作:再次获取HTML、运行所有脚本、重建DOM并重新加载数据。后退/前进缓存(bfcache)的存在正是为避免这种情况,然而日常代码常常在无人察觉的情况下禁用它,或错误处理它恢复的页面。本指南将解释bfcache与普通缓存的差异、哪些习惯会阻碍它的工作、如何检测并应对恢复的页面,以及如何在自己的设备上验证其功能。

为何历史导航值得特别关注

性能优化通常侧重于首次访问场景。团队们会压缩图片、拆分代码包、预加载字体、减少初始化开销、优化API调用并关注最大内容绘制时间。所有这些措施固然重要,但它们默认每个页面访问都是从空白标签页和全新输入的URL开始的。

人们浏览网页并非如此。购物者会打开一个商品,然后返回结果页面,再打开另一个商品,接着又返回。读者会点击文章中的链接,之后回来继续阅读完文章。还有人会重新访问未填完的表格。在各类浏览器中,后退和前进按钮都是使用频率最高的控件,这类操作十分常见,并非什么特别的情况。

浏览器通过bfcache功能优化了这种浏览模式。当用户离开时,浏览器不会立即删除页面,而是会在内存中保留整个文档的快照。当用户通过会话历史返回时,浏览器可以直接显示该快照,而无需重复进行网络请求、HTML解析、脚本执行、数据加载和渲染等操作。虽然各主流浏览器都实现了某种形式的这一功能,但具体的适用规则和实现细节存在差异。

从用户的角度来看,成功的恢复几乎感觉是瞬间完成的。滚动位置保持不变,DOM结构完好无损,表单字段仍保留原有数据,JavaScript堆内存也继续正常工作。但问题有两方面:许多团队从不检查他们的页面是否符合条件,而那些假设每次访问都是全新页面的代码,在旧页面重新加载时就会出现异常行为。

因此,bfcache不仅仅能提升速度,它还展现了更真实的页面生命周期:页面可能在“存活”状态下进入休眠状态,之后再次唤醒时无需重复运行启动代码。

bfcache与HTTP缓存的区别

“缓存”这个通用词汇容易造成混淆。人们很容易认为bfcache只是重复使用之前下载的CSS、脚本、图片或API响应。那其实是HTTP缓存,它的运作粒度要细得多。

HTTP缓存存储的是各个独立的响应。返回某个页面时,浏览器可以跳过部分下载操作,但文档本身仍需重新构建:解析HTML、执行脚本、启动框架、创建DOM结构、重新建立订阅关系并发送新的请求。

bfcache会保存整个页面,包括其DOM结构和JavaScript堆内存。当用户离开当前页面时,脚本执行可以被暂停;当他们通过历史记录返回时,浏览器会直接显示已保存的页面并继续执行其中的脚本。正因如此,bfcache命中时的效率甚至高于那些主要依赖HTTP缓存、经过精心优化的页面重新加载:需要完成的工作量要少得多。

这改变了我们思考导航方式的方式。在恢复 bfcache 状态时,正常的启动流程并不一定会再次执行。模块级变量仍可保留其先前的值,DOM 也会反映用户离开前所做的操作。定时器及其他运行时任务会从暂停处继续执行,而非从头开始创建。

那些认为返回页面就一定意味着要完全重新加载的代码,因此可能会错过更新、在屏幕上保留过时的状态,或在尝试设置已存在的元素时产生重复。应当采用的思维模式很简单:浏览器并非重新渲染旧的 HTML 响应,而是唤醒了被暂停的页面。

几乎无需新界面即可实现的重大优势

大多数性能提升都需要付出一定的代价:重新设计的组件、新的加载状态、服务器端渲染,或是不同的数据获取策略。bfcache的不同之处在于浏览器已经实现了这一机制。

你的任务不是构建缓存,而是避免阻碍浏览器使用其现有的缓存机制,并让你的页面生命周期能够与恢复功能相兼容。

正因如此,它成为许多团队可用的最具效的优化手段之一。从内存中恢复的页面可以避免重复传输以及常规导航所需的大部分处理工作。在设备与网络速度较慢的情况下,其优势最为显著;即便在高性能机器上,重新构建页面与直接显示页面之间也存在明显差异。

它不仅保留时间信息,还保留用户的使用体验。想象一下,有人在阅读一篇长文章时打开了相关链接,之后又返回原文。传统的页面重新加载需要先重建整个页面,再尝试恢复滚动位置,而此时页面布局往往已经发生变化。而 bfcache 恢复功能则能将用户几乎精确地带回原来的位置。

产品列表更能体现这一优势。有人通过四五个筛选条件缩小目录范围,逐项查看长列表中的内容,选中某一款产品后再次返回。如果列表能立即以相同状态呈现,用户的操作体验就会显得连贯流畅。但如果页面需要重新加载、重新获取数据、清除筛选条件或自动跳回第一行,那么无论首次加载的性能指标有多好,该网站给人的感觉都会很不稳定。

性能不仅关乎用户进入应用程序的速度,还体现在产品在用户切换页面时能为他们节省多少重复操作。

卸载习惯如何悄然抵消这些优势

bfcache被忽视的部分原因在于,当页面不符合条件时并不会出现任何明显的故障。浏览器只会进行常规的历史记录导航,所有功能依然正常,只是用户无法实现即时恢复。

unload事件是造成这一问题的典型元凶。旧代码会利用它来发送分析数据、释放资源或告知服务器页面正在关闭,其依据是导航总会销毁页面,因此有必要给予最后一次清理机会。

该理论已不再符合现实情况。页面可能会因 bfcache 而被暂停而非删除,而且 unload 方法长期以来都不可靠,尤其是在移动设备上,它常常根本不会被触发。不同浏览器的行为存在差异,但在某些浏览器中注册 unload 监听器可能会使页面无法被纳入 bfcache。目前的平台指南强烈建议避免使用 unload,并推荐采用符合现代模式的生命周期事件。在撰写本文时,Chrome 也在逐步废弃 unload,请查阅最新的浏览器文档以了解最新状况。

监听器甚至不必来自你自己的代码。分析脚本、广告标签、支持聊天小部件以及一些旧版本的依赖项有时会内部注册这样的监听器。由于它没有可见的影响,因此能够通过代码审查,但却可能取消每次访问该页面时的浏览器级优化。

替换unload并非只是重新命名事件而已。合适的替代方案取决于代码的实际用途:

  • 当页面可见性变为隐藏状态时,可以使用navigator.sendBeacon()或经过适当配置的fetch请求(例如使用keepalive选项)来发送分析数据。
  • 关于未保存表单数据的警告可以使用beforeunload,但该监听器应在存在未保存的更改时添加,在更改被保存后移除,而不应在整个页面生命周期内一直存在。
  • 与当前页面相关的资源可以通过与bfcache兼容的生命周期事件来暂停和恢复,具体内容将在后文介绍。
  • 更重要的教训是:离开当前页面并不等同于该页面生命周期的结束。基于这种认知编写的代码既不可靠,运行速度也会慢于应有的水平。

    利用pageshow和pagehide检测恢复状态

    有两个事件贯穿了隐藏状态期间。pagehide在页面因导航而离开屏幕时触发,而pageshow则在导航使页面再次成为当前显示内容时触发,其中包括从bfcache恢复的情况。这两个事件都带有persisted属性:在pageshow事件中,若页面是从bfcache恢复的,则persisted的值为true;在pagehide事件中,该属性则表示浏览器正试图保留该页面。与unload不同,pagehide是被设计为能与bfcache共存的。

    下方的代码片段展示了其基本结构。pageshow处理函数在正常加载时会提前返回,因此只有当之前保存的页面重新加载时才会执行刷新逻辑。pagehide处理函数则会在页面隐藏时暂停实时连接,无论该页面是否被缓存。这些辅助函数仅为您自行实现应用逻辑提供占位。

    window.addEventListener("pageshow", (event) => {
      if (!event.persisted) {
        return;
      }
    
    refreshSessionState();
      refreshTimeSensitiveData();
    });
    
    window.addEventListener("pagehide", () => {
      pauseRealtimeConnections();
    });
    

    请注意它并未做到的一点:它不会重新获取所有数据。每次恢复页面时都重新运行整个数据层会抵消大部分优势。其目标只是刷新在页面处于暂停状态期间其准确性可能发生实际变化的那些信息。

    具体需要刷新的内容取决于页面类型:

    • 新闻首页可能需要获取最新的头条新闻。
    • 账户页面可能需要确认会话并未在另一个标签页中被终止。
  • 结账步骤可能需要重新验证库存和价格。
  • 静态文档的参考页面通常可以完全跳过恢复逻辑。
  • 真正的改进在于,应用程序现在能够区分全新加载与恢复操作。如果没有这种区分,团队通常只能在两个糟糕的选择中做决定:要么从不刷新以承担内容过时的风险,要么重新执行完整初始化从而牺牲大部分速度。了解 bfcache 的应用程序会将恢复视为生命周期中的一个独立阶段,保留仍然有效的内容,并刷新那些不再可信的内容。

    当恢复状态引发正确性问题时

    即时恢复听起来似乎毫无弊端,直到被保留的页面中出现了本不应显示的内容。

    设想这样一位用户:他打开账户控制面板,跳转到另一个页面,从第二个标签页退出登录,最后点击返回。恢复后的界面仍可能显示之前会话的详细信息。浏览器记住同一浏览会话中的页面并不违反任何安全规则,但您的应用程序在允许进一步操作之前可能需要确认该会话是否仍然有效。

    对时间敏感的数据也会引发同样的问题。门票可能是在另一个标签页购买的,记录可能已被同事修改,或者可用时间可能已经结束。恢复后的页面虽然能完整呈现过去的状态,但却可能不再是当前情况下的正确信息来源。

    常见的做法是完全禁用缓存。但这通常过于极端。页面可以在仅刷新需要更新的数据的同时保留其DOM结构和交互状态,而pageshow事件正是进行这种同步处理的理想时机。官方指南明确指出,内容频繁变化的页面应在恢复后获取最新数据,而非完全放弃使用bfcache。

    对安全性要求较高的页面仍需制定明确的缓存和会话策略,而且浏览器对Cache-Control: no-store这类标题的处理方式也在不断变化。例如,Chrome在满足特定条件时已开始允许某些设置为no-store的页面被纳入bfcache。切勿基于“所有浏览器都会以相同方式处理该标题”这一简化的假设来设计功能,应在依赖相关机制之前查阅当前浏览器的官方文档。

    更安全的方法是明确做出决定:

    • 哪些状态可以安全地保持原样;
    • 哪些状态在恢复后必须重新验证;
    • 无论界面当前显示什么,哪些用户操作始终需要在服务器上重新进行授权检查。

    最后这一类最为重要。客户端可以刷新其显示内容,但服务器绝不能依赖恢复后的页面来证明用户仍处于登录状态。

    暂停期间中断长生命周期连接

    WebSockets、IndexedDB 连接、共享锁以及正在处理的请求等开放连接会使得 bfcache 的使用变得复杂,因为浏览器必须冻结当前页面,而其他标签页和文档仍可继续运行。持有共享锁或开放数据库连接的冻结页面可能会妨碍其他仍在运行的页面。不同浏览器的适用规则各不相同,在某些情况下,只要存在开放或活跃的连接就足以使页面无法被缓存。推荐的方案是在页面隐藏时释放或暂停这些资源,待页面重新显示后再谨慎地重新建立它们。

    这并不意味着要移除所有的事件监听器或观察者。那些完全存在于页面内部的监听器,比如DOM监听器,通常可以保持不变而不会造成问题。应重点关注那些会连接到被冻结文档之外的外部系统的组件,或是那些在无人查看页面时本不应处于活跃状态的组件。

    例如,实时仪表板可以在pagehide事件中关闭WebSocket连接,而在页面恢复时再打开新的连接。实现时必须防止出现重复连接,因为正常的启动流程和页面恢复处理程序都可能尝试建立连接;使用一个能够检测连接是否已存在的connect()函数,就是实现这一功能的简单方法。IndexedDB封装层则可以在暂停前关闭数据库连接,下次需要时再按需重新打开。

    这一机制带来的益处不仅限于bfcache。它迫使团队明确每个外部资源的归属、其存在的时间以及文档失效时应采取的措施。这些规范对于后台标签页、导致页面卡住的移动设备以及被中断的会话同样重要。

    单页应用依然能从中受益

    bfcache仅适用于浏览器管理的文档之间的导航操作。通过JavaScript处理的路由变化,由于不会加载新文档,因此不在其作用范围内。通过客户端路由器从/products跳转到/products/42这类操作,仍由单页应用及其框架自行处理。

    即便如此,单页应用依然能获得诸多好处。用户常常会离开当前应用,前往独立的页面、第三方网站、登录服务提供商或完全不同的应用程序,之后再返回。在这种情况下,浏览器可以直接恢复整个应用,而无需再次获取资源包、重新渲染所有组件并重新执行初始化流程。官方指南明确指出了这一优势,同时指出应用内的路由变化不在 bfcache 的控制范围内。

    这些返回操作可能会暴露框架层面的假设:

    • 认为文档每次可见时都会执行根节点初始化;
    • 数据层假设其缓存始终处于空状态;
    • 在旧连接仍在恢复的同时重新建立实时连接;
    • 组件显示的数值在离开时是正确的,但现在可能已经过时。

    没有任何框架能够抽象掉浏览器自身的生命周期。无论你使用 React、Vue、Angular 还是 Svelte,代码依然在浏览器可能保存并恢复的文档中运行。框架虽擅长管理状态,却无法替你判断在暂停后哪些业务数据仍然有效。例如在 React 中,当 bfcache 恢复时组件不会重新挂载,因此在初始化时运行的效果函数也不会再次执行;应用正是通过 pageshow 监听器来知晓时间已经流逝。

    许多前端开发者对所使用框架的挂载与卸载行为了解得远多于浏览器文档的生命周期。bfcache 表明,挂载与卸载仅描述了页面实际发生情况中的一层表现。

    几分钟内测试 bfcache

    bfcache 一直被忽视,部分原因在于很难判断它是否真的在起作用。返回页面的速度可能已经看起来很快了,而网络面板也几乎帮不上忙,因为恢复后的页面会完全跳过常规的请求流程。

    Chrome DevTools 提供了专门用于检测 bfcache 的功能,位于“应用”面板下。运行该功能后,Chrome 会先导航离开再返回页面,随后它会报告页面是否已被恢复。如果页面不符合条件,DevTools 会列出具体的原因,并指出是哪些框架导致了这一问题。

    应将此项检查纳入重要页面的常规性能检测中,尤其是搜索结果、文章、产品列表、控制面板以及用户经常访问的任何页面。同时启用第三方脚本进行检测,因为很多情况下符合条件的限制来自您代码包之外的内容。

    对于现场数据,Performance API 提供的 notRestoredReasons 信息(位于导航时间记录中)可以解释为何实际的历史导航未能被恢复。该功能并非所有浏览器都支持,因此应将其视为逐步完善的监控工具,而非代码可在所有浏览器中依赖的功能。尽管如此它依然很有价值,因为任何本地测试都无法模拟用户使用的所有浏览器扩展、第三方 iframe、已打开的连接、请求头以及浏览器配置。一个更轻量的替代方案是在分析数据中记录 pageshow 事件中的 event.persisted 值,以此来衡量实际的点击率。

    不要指望每次历史记录导航都能命中缓存。浏览器会清除快照以管理内存,而且有些干扰因素是在页面控制范围之外的。现实的目标是消除那些可以避免的干扰,并在缓存恢复时正确处理相关问题。

    总结

    bfcache很容易被忽视,正是因为代码库中没有任何内容会启用它。没有相应的包、组件或配置文件让它看起来像是架构的一部分;浏览器只是试图为用户提供更好的体验。这种隐蔽性有两面性:当它正常工作时,用户几乎无需编写任何代码就能实现近乎即时的历史记录导航;当它被阻止时,网站会变慢但不会出现任何错误;而当缓存恢复处理不当时,由于应用程序认为返回就意味着重新加载,旧的页面状态又会出现。

    针对访问频率最高的页面的实用检查清单:

    • 移除所有unload监听器,包括第三方脚本添加的那些,并将其功能转移到pagehide、visibilitychange或sendBeacon()中。
    • 仅在存在未保存的更改时才添加beforeunload监听器。
    • 在pagehide时关闭或暂停外部连接,并在页面恢复时重新建立连接。
    • 使用pageshow结合event.persisted仅刷新可能过时的数据,同时将授权检查留在服务器端。
    • 在条件允许的情况下,使用开发者工具的缓存测试功能,并跟踪实际页面恢复速率。

    根本性的变化在于人们对页面的认知方式。页面的生命周期不再是从创建到销毁的单一直线过程,而是可能包含初始化、隐藏、冻结、恢复、同步以及继续使用等多个阶段。各团队投入大量精力来加快页面重建的速度,而浏览器则常常可以直接跳过这一重建步骤,将用户原有的页面直接呈现给他们。