身份

Service Worker 缓存的生命周期与隐私

了解 Service Worker 的注册、安装、激活和更新阶段如何决定每个缓存的归属,以及如何为缓存加版本、清理并在退出登录时清除。

文档中心

想直接进入 身份 文档吗?

这篇文章属于博客内容库。若你要步骤化配置、参考说明和持续更新,请直接进入对应 docs 分区。

为什么 Service Worker 缓存需要明确的负责人

Service Worker 让站点可以从本地存储(通常是 Cache Storage)中响应网络请求。这对离线页面和更快的重复访问很有用,但也意味着一条响应可能比页面、访问,甚至比触发它被保存的账号存活得更久。Service Workers 规范描述了 worker 及其生命周期,MDN 的 CacheStorage 参考则描述了 worker 或页面可以打开的具名缓存。

Cache Storage 归属于源(origin),而不是某个具体的 worker 版本。旧 worker 创建的缓存在新 worker 接管后仍然保留,除非应用代码删除它、浏览器在存储空间紧张时将其逐出,或用户或 Clear-Site-Data 响应清除了站点数据,否则不会有任何东西移除它。逐出的时机因浏览器和版本而异,因此应把它视为兜底机制,而不是清理方案。

生命周期示意图,依次为注册、安装、等待、激活和请求阶段;带版本的缓存在安装时创建,旧缓存在激活时删除,账号缓存在退出登录时清除

因此,实际问题在于归属。对每个缓存,团队应能说明它由哪个 worker 版本创建、哪个发布版本有权删除它,以及它的内容属于哪个用户会话或账号。没有明确负责人的缓存,就是没有人会清理的缓存。

设想服务台上的一台共用平板。工作人员登录、打开仪表板,并在轮班结束时退出登录。如果 worker 在他们工作期间保存了账号专属的 API 响应,下一位打开仪表板的人即使看到界面显示为未登录状态,也可能在浏览器存储中找到这些响应。这种问题在屏幕上没有任何表现,所以很容易被忽略。同样的情形也出现在家用电脑、自助终端,以及随着时间推移被多个账号使用的浏览器配置文件上。

Cache Storage 也只是站点保存状态的其中一处。浏览器的 HTTP 缓存、Cookie、localStorage、IndexedDB 和 Service Worker 注册信息是彼此独立的存储,各有各的清理途径。清除其中一个并不会清除其他的。当审查要确认退出登录后还剩下什么时,应当列出应用使用的每一种存储并逐一检查,而不是根据一份空列表就断定设备是干净的。

过期内容与隐私问题有共同的成因。一个从不清理的缓存,可能在修复之后仍提供过期的脚本,也可能在退出登录之后仍提供过期的账号视图。两种情况下,用户看到的都是服务器已不会再返回的内容。把清理视为发布的一部分,而不是之后再做的任务,就能用同一条规则和同一次审查同时应对这两种风险。

生命周期的各个阶段

页面请求浏览器为某个作用域(scope)注册一个 worker。浏览器下载脚本并执行安装阶段。如果新 worker 安装时较旧的 worker 仍在控制已打开的页面,它会进入等待状态,直到这些页面关闭,或应用选择跳过等待。随后执行激活阶段,从这一刻起,worker 开始处理其所控制页面的 fetch 事件。web.dev 的生命周期指南用示例逐步讲解了这些阶段。

更新取决于脚本本身。当浏览器发现 worker 脚本与已安装的不同,会在旧版本旁边安装新版本,而不是原地替换。浏览器检查脚本是否变化的频率取决于页面导航、功能性事件和浏览器自身的策略,因此发布时不应假设每位用户都会在可预测的时刻拿到新 worker。

这就是新 worker 可能已安装却尚未生效的原因。审查者在服务器上看到新脚本,并不能由此断定它已在为页面提供服务。worker 要么处于激活状态,要么处于等待状态,要么已被替换,检查时应读取这一状态,而不是想当然。相关事件可参阅 Using Service Workers 指南。

有两个调用会改变等待行为,值得慎重决定。跳过等待阶段会让新 worker 立即激活,而接管客户端会让它控制在它之前打开的页面。两者对于小的修复都可能合适,但它们也意味着,一个加载了旧脚本的已打开页面,可能开始收到为新版本准备的缓存所返回的响应。如果旧页面与新缓存不兼容,用户就会看到任何单个版本中都不存在的异常行为。请记录应用是否跳过等待,以及这对已打开的页面有什么影响。

注册作用域同样重要。worker 只控制其作用域内的页面,而同一源的页面共享对同一批缓存条目的访问。因此,由一个 worker 填充的缓存也可能被该 worker 并未控制的页面读取,这是为每个缓存及其负责人命名的又一个理由。

fetch 阶段是 worker 决定提供什么内容的地方。仅随版本变化的静态资源适合缓存优先的方式,因为版本标签已经控制了新鲜度。个性化页面和 API 响应通常适合网络优先的方式,其中有些响应根本不应存储。按请求类别分别写出策略,而不是为整个站点制定一条规则,会让之后的清理规则更简单,因为每个类别都对应一个负责人和生存期都已明确的缓存。

已部署的更新可以通过观察得知,而不必猜测。开发者工具会列出已注册的 worker、其脚本地址,以及是否有更新的版本在等待。发布之后,在全新的上下文中加载应用,查看该面板,并把状态记入发布记录。如果显示有等待中的 worker,请决定是等待页面关闭、提示用户重新加载,还是跳过等待,并把这一决定写下来。

带版本的缓存与激活时清理

为每个缓存起一个带版本标签的名称,例如用一个前缀表示应用外壳,后面跟上发布标签。在安装阶段创建并填充新的带版本缓存,因为此时新 worker 正在准备自己的资源,而旧 worker 仍在从旧缓存为页面提供服务。

缓存不仅要按版本区分,也要按用途区分。应用外壳文件的缓存、图片的缓存和账号数据的缓存有不同的生存期。外壳缓存在每次发布时被替换,图片缓存可以按时间或数量裁剪,账号缓存则随会话结束而结束。把它们放在不同名称的缓存中,清理规则就能正确处理每一个,审查者也无需打开单个条目就能回答哪个缓存保存了什么。

在激活阶段删除旧缓存。激活是旧 worker 第一次不再被需要的时刻,过早删除它的缓存可能破坏仍在运行的页面。常见的规则是列出缓存名称,保留与当前发布版本匹配的名称,删除其余的。把这条规则写下来,以便审查时可以测试。

要谨慎确定清理规则可以删除哪些名称。Cache Storage 由整个源共享,因此缓存名称列表中可能包含同一源上其他代码创建的缓存,例如用于其他功能的旧 worker。请把删除范围限制在以你自己的前缀开头的名称。还要记住,在安装阶段填充新缓存时若有请求失败,可能导致新 worker 根本无法完成安装,此时旧 worker 及其缓存仍留在原处。发布之后请检查 worker 的状态,而不要假定安装已经完成。

Cache Storage 自身不会应用 HTTP 的新鲜度规则。条目会一直保留,直到代码将其删除,因此即使服务器端已修复,过期的响应仍可能继续出现。把版本标签保留在缓存名称中,将会变化的内容放到新名称之下,并让激活规则删除上一个缓存。

不要用 worker 去拦截、改写或保留超出页面运行所需的数据。这套生命周期的目标是更小、更可预测的存储,而不是一个保留无人能看到的响应的隐藏层。让负责运营站点的人能够看到清理过程。

让清理代码保持简短并经过测试。一段列出名称、按前缀过滤并删除旧版本的例程,短到足以在审查中通读。请在已经积累了多个旧版本的上下文中运行它,因为只在全新安装上测试的例程,永远遇不到它存在的真正原因。测试期间,把它删除的名称输出到开发者控制台,以便之后与缓存列表对照。

退出登录、切换账号与 Clear-Site-Data

有些响应只属于某个已登录用户。保存这些响应的缓存并不是无害的离线资源:在共享设备上退出登录或切换账号后,下一位用户可能看到上一个账号保存的内容。最好不要缓存经过身份验证的或账号专属的响应。如果某个功能需要离线使用它们,请把缓存限定在该账号范围内,并在会话结束时删除。

退出登录各步骤的顺序很重要。先结束服务器端会话,再删除账号缓存,然后清除页面在内存中保存的状态。如果应用打开了多个标签页,请通知其他标签页账号已变更,例如通过广播消息,这样仍然开着的标签页就不会继续显示上一个账号的缓存数据。请在打开两个标签页的情况下测试这一流程,因为只用一个标签页的测试会遗漏这种情形。

退出登录时,应用可以在页面代码中删除该账号的缓存,并通知 worker 丢弃内存中的状态。Clear-Site-Data 规范还定义了一个响应头,其指令可以请求浏览器清除该源的缓存或已存储的数据,其中 storage 指令涵盖该源中脚本可访问的存储,并注销其 Service Worker;规范并未点名列出 Cache Storage。各浏览器对指令的支持和确切覆盖范围不同,因此请在用户实际使用的每种浏览器中确认结果,而不是依赖响应头的名称。

服务器响应头有帮助,但不能取代 worker 的逻辑。被标记为不应存储的响应会告诉共享缓存和私有缓存应如何处理,但 Cache Storage 把这个决定交给 worker 代码,由它选择是否把某个响应放入缓存。让 worker 检查它即将存储的内容,并默认让账号专属的响应不进入长期缓存。一份简短而明确的可缓存路径清单,比一长串例外更容易审查。

切换账号与退出登录同样需要重视,因为浏览器配置文件不变,而用户变了。在第二个账号加载之前,先清除或限定第一个账号的缓存,然后读取缓存列表加以确认。读取列表是比相信清理代码已执行更有力的信号。

共用设备的用户应当有一种看得见的方式来清除已存储的数据。如果应用在离线状态下保存账号数据,请在退出登录时或设置中提供一个清晰的控件来删除这些数据,并用通俗的话说明会删除什么。共用设备的用户无法自己检查 Cache Storage,所以应用是唯一能提供这一选择的地方。

对用户也要把这条界限讲清楚。即使没有任何跟踪器参与,一个看似完成的退出登录若悄悄把账号的响应留在设备上,也是隐私缺陷。关于浏览器如何分隔状态的更多背景,请参阅存储分区与隐私和 Cookie 管理。

按上下文测试缓存行为

BotBrowser 支持为每个 BrowserContext 分配独立的存储和会话状态,在某个上下文中创建的 Service Worker 会继承该上下文的指纹,因此团队可以针对每个身份分别测试 worker 的缓存生命周期。BotBrowser 不会编写、版本化或清除应用的 Service Worker 缓存,也不能让过期或泄露数据的缓存设计变得安全;缓存命名、激活时清理和退出登录时清除仍然是应用自身的责任。

上下文隔离之所以有用,是因为同一个站点可以在使用不同账号的两个上下文中加载,而每个上下文会显示各自的注册信息和缓存列表。如果第二个上下文出现了由第一个上下文创建的条目,说明应用存在作用域问题,而不是浏览器问题。多账号隔离文档描述了按上下文划分的存储模型,多账号浏览器隔离则说明了团队如何规划这件事。

一种实用的测试使用两个上下文。在第一个上下文中,用一个账号登录,加载应用,让 worker 完成安装,并打开几个页面。记录注册状态和缓存名称。在第二个上下文中,用另一个账号登录,并记录相同的信息。两份列表不应共享账号专属的条目。然后在第一个上下文中退出登录,再次读取它的缓存列表。最后确认第二个上下文仍保留着它自己的条目,也就是说一个上下文中的清理没有影响到另一个。

让记录保持简短且可重复。写下浏览器版本、上下文名称、worker 脚本版本、更新前后的缓存列表、退出登录后的缓存列表,以及 worker 的状态。不要把响应正文、令牌或客户数据粘贴进记录。一份带有通过或未通过结果的简短缓存名称清单,足以支撑一次发布审查,也能让下一个人在下次部署之后重复同样的检查。

在对产品重要的每个上下文中运行同样的生命周期审查,并按上下文分别记录结果。不要把一个上下文的通过结果套用到另一个上下文。

执行缓存生命周期检查

在浏览器开发者工具中或用一段简短脚本运行这些检查,并为每个缓存记录通过或未通过。

  1. 说出 worker 使用的生命周期阶段(注册、安装、等待、激活、fetch)。如果带版本的缓存在安装处理程序中创建、在激活处理程序中删除,则通过。如果清理发生在安装阶段,则未通过。
  2. 列出缓存名称、版本标签和负责人。如果每个名称都对应一个 worker 版本和一个数据类别,则通过。任何无人能解释的名称均未通过。
  3. 写下激活时清理规则和退出登录规则。如果两者都说明了保留什么、删除什么,则通过。
  4. 新 worker 激活后,再次列出缓存。如果没有任何旧版本缓存残留,则通过。如果仍有旧名称,则未通过。worker 仍在等待时,请把旧缓存记为待处理。
  5. 更新后检查 worker 的状态。如果新 worker 处于激活状态,或被记录为等待中,则通过。如果审查者未读取状态就假定它已生效,则未通过。
  6. 找出保存经过身份验证的或账号专属响应的缓存。如果它们限定在账号范围内,并在退出登录或切换账号后消失,则通过。如果它们被当作普通离线资源对待,则未通过。
  7. 在每个浏览器上下文中重复检查 1 到 6,并为每个上下文分别记录结果。

来源

#Service Worker#缓存#浏览器隐私#浏览器上下文

让 BotBrowser 从研究走向生产

先用这些指南理解模型,再进入跨平台验证、隔离上下文和面向规模化的浏览器部署。