IndexedDB 生命周期:版本、配额与数据清理
安全地打开并版本化 IndexedDB,在不阻塞其他标签页的前提下升级结构,提前规划配额与驱逐,并在退出登录时清除应用数据。
用显式版本号打开数据库
IndexedDB 数据库有一个包含四个阶段的生命周期,应用代码必须有意识地处理每个阶段:带版本号打开、升级结构、在浏览器管理的配额内运行,以及在数据不应再存在时删除。每个阶段都有报告发生了什么的事件或 API,也都有用户能看到的失败结果。可靠的应用会用显式版本号打开数据库,只在升级步骤中修改结构,在其他标签页请求更新版本时关闭自己的连接,记录空间耗尽时的处理方式,并在退出登录时删除上一个账户的数据库;如果删除被阻塞,则给出有界的结果。
下面的内容只涉及你自己的应用所存储的数据。读取或检查其他站点存储的数据是另一类任务,示例中的数据库名和对象存储名都是虚构的,并非取自真实站点。要了解 IndexedDB 与 Cookie 和 Web Storage 的关系,请阅读 Cookie、localStorage 与 IndexedDB:状态该存在哪里。
入口是 indexedDB.open(name, version)。它返回的是一个打开请求,而不是数据库本身。连接在请求的 success 事件中到达,而任何结构的创建或修改都发生在 upgradeneeded 事件中;当请求的版本高于已存储的版本时,该事件会先触发。如果数据库尚不存在,它会以请求的版本创建,并且 upgradeneeded 会运行一次来构建初始的对象存储。W3C Indexed Database API 定义了这一顺序,MDN 的 IndexedDB 使用指南 用示例逐步说明了它。
显式传入版本号,并把它保存为负责结构的代码中的具名常量。如果省略版本参数,浏览器会以当前版本打开现有数据库,或者以第一个版本创建新数据库,因此代码无法有意地请求一次升级。团队在每次结构变更时递增的整数使历史可以审查:每个编号对应一组已知的对象存储和索引,修改结构的拉取请求也会修改这个常量。
比已存储版本更低的版本同样是一个明确的结果。打开请求会以 VersionError 失败。这种情况出现在用户保留着旧标签页,而更新的标签页已经升级了数据库时,或者在新版本发布后仍运行着旧的缓存代码副本时。把这个错误当作带有提示信息和重新加载操作的应用状态来处理,不要用猜测的版本号循环重试。
在一个地方处理请求的各种结果。success 事件交出连接,error 报告失败,例如 VersionError 或浏览器无法恢复的存储问题,而 blocked 表示在升级等待期间,其他连接仍然保持数据库打开。把打开调用包装成一个返回 promise 的小函数,在返回连接之前给它挂上 versionchange 处理函数,并让应用的其余部分向这个函数索取数据库,而不是自己再打开一个。由一个所有者负责打开和关闭,连接的生命周期就容易推理。
按数据库和对象存储所承载的内容来命名。使用应用选定的稳定数据库名;当同一个源服务多个账户时,在名称中放入不透明的本地账户键,而不是电子邮件地址或其他个人数据。这个名称在开发者工具中可见,也常出现在支持报告里,同时它还是退出登录流程之后用来找到并删除某个账户数据的句柄。
升级结构时不阻塞其他标签页
结构变更只允许在 upgradeneeded 处理函数内进行。在该事件期间,连接持有一个特殊的版本变更事务,只有在其中,代码才能调用 createObjectStore、deleteObjectStore、createIndex 或 deleteIndex。在它之外,这些调用会抛出错误。W3C 规范 为该事件定义了 oldVersion 和 newVersion 值,借助它们,一个处理函数就能把数据库从任何更早的版本推进到最新。
把处理函数写成按旧版本编排的一系列步骤:如果旧版本低于第一个目标,就创建初始存储;如果低于第二个,就添加新索引;依此类推。用户可能从任何更早的版本升级而来,所以每一步都必须能按顺序独立正确运行。把处理函数限定在 IndexedDB 的工作之内。升级事务在没有待处理请求时就会结束,因此在处理函数内等待网络调用或无关的 promise,可能在下一步运行之前就让事务结束。请在打开请求成功之后再获取远程数据,而不是在升级期间。
把现有记录迁移成新的形态是风险最高的部分。在同一个升级事务内用游标遍历旧存储来完成转换,使这一步要么整体提交,要么完全不提交。如果处理函数抛出异常,或对事务调用 abort(),版本保持不变,打开请求会以 AbortError 失败,原来的结构保持完好。让每次迁移保持小规模,并用在应用仍然支持的每个旧版本上创建的数据库来测试,而不只是最新版本。
第二个标签页是升级最容易出错的地方。只有在没有更旧的连接处于打开状态时,IndexedDB 才允许数据库更改版本。当一个标签页请求更高版本时,浏览器会在其他标签页仍然持有的连接上触发 versionchange。MDN 的 IndexedDB 使用指南 指出处理函数应当关闭连接,以便另一个页面可以完成升级。如果它没有关闭,新的打开请求就会触发 blocked 并保持挂起,升级不会运行,用户会看到新标签页一直停在加载状态。
一个好的处理函数做两件事。它立即调用 db.close(),然后告诉用户发生了什么,例如提示应用已在另一个标签页中更新并提供重新加载。关闭连接会把数据库让给正在升级的标签页,而提示信息则解释了这个标签页为什么不再工作。关闭连接之后不要继续使用它,因为在其上发起的新事务会抛出错误。当某个标签页调用 deleteDatabase 时也会触发同一个事件,所以一个处理函数就同时覆盖了升级和删除。
请求升级的标签页也应该处理 blocked。显示一条简短的提示,说明另一个窗口仍以较旧的版本保持打开,让打开请求继续挂起,并在另一个连接关闭后让升级完成。如果应用还运行着会打开该数据库的 Service Worker 或共享 Worker,请把它纳入检查。Worker 是另一种可能让升级持续等待的连接,它同样需要 versionchange 处理。
为配额和驱逐做好规划
浏览器在各个源之间共享有限的磁盘空间,IndexedDB 数据就在这份预算之内。Storage Standard 把源的存储描述为默认的尽力而为:在存储压力下,浏览器可以不经询问就将其移除。MDN 关于配额与驱逐标准的页面说明,限制和驱逐顺序因浏览器而异。容量数字在不同浏览器、设备和版本之间各不相同,所以应用不应依赖一个它无法验证的数值。
有两种结果需要应用给出明确的响应。第一种是写入放不下。当事务因为空间耗尽而无法提交时,失败会以 QuotaExceededError 报告,事务随之中止。要在事务上监听 abort 和 error,而不只是在单个请求上监听,并提前决定用户看到什么。合理的响应包括丢弃价值最低的缓存记录、暂停同步,或请用户释放空间,具体取决于数据的类型。
第二种结果是数据已经不见了。尽力而为的源可能被驱逐,而且执行驱逐的浏览器通常把一个源的数据作为整体移除,而不是一次只移除几条记录。下一次访问会发现数据库是空的,应用会再次以第一个版本打开它。要为这种情况做设计。保留一条标记记录或一个元数据存储,让启动代码能区分全新安装和被驱逐的情况,并在数据在服务器上有副本时从服务器重建。
调用 navigator.storage.estimate() 会返回大致的用量和配额数值,有助于决定何时裁剪缓存;navigator.storage.persist() 则请求浏览器把该源的数据视为持久数据。两者都是 Storage Standard 所描述的估算和请求。浏览器可能拒绝持久化,可能询问用户,也可能不弹出提示就自行决定。请在检查中记录这次请求的结果,而不是假定它已被批准,并为未获批准的情况保留同样的恢复路径。
把每个对象存储的决定记录在一张放在结构代码旁边的小表中。对于每个存储,写下它的所有者、保留规则,以及配额被超出或数据已被驱逐时应用的处理方式。存放用户尚未同步的草稿的存储需要一条警告和一条导出途径。存放缓存的服务器响应的存储只需要重建。存放设置的存储可能需要一个默认值。保留规则也属于同一张表:最近条目的缓存可以在启动时清理,方法是用游标遍历时间戳字段上的索引,并分小批删除早于规定期限的记录,使每个事务保持简短。
在退出登录和切换账户时删除应用数据
退出登录是关于数据的决定,而不只是关于会话的决定。当一个人在共用电脑上退出登录,或一个账户在同一个浏览器配置文件中取代另一个账户时,上一个账户的 IndexedDB 数据会继续保留在同一个源之下的磁盘上,直到应用将其删除。清理流程需要一份明确的待删除清单、一种找到它们的方法,以及在删除无法完成时的明确结果。
维护一份应用所创建的数据库名称的登记表,例如代码中的一个简短列表或一条元数据记录,而不是依赖发现机制。在可用时,indexedDB.databases() 可以列出该源的数据库,适合用于验证步骤,但浏览器的支持情况随时间有所变化,因此在把它作为唯一来源之前,请查看兼容性说明。有了登记表,退出登录就变成一个循环:先关闭本标签页自己的连接,然后对属于即将离开的账户的每个名称调用 indexedDB.deleteDatabase(name)。
这个方法与 open 一样返回一个请求。它的参考页面说明,删除会在打开的连接上触发 versionchange,如果仍有连接保持打开,就会在请求上触发 blocked,并且删除会等到它们关闭。这就是升级一节中的 versionchange 处理函数在这里同样重要的原因,也是退出登录流程如果不先关闭自己的连接就会阻塞自己的原因。给流程一个有界的结果:等待 success;如果 blocked 到来,并且在应用选定的一小段等待时间内没有解决,就记录清理处于待定状态,告知用户,并在下次启动时、读取上一个账户的任何数据之前重试。避免在退出登录界面上出现没有尽头的加载动画。
服务器也可以通过 Clear-Site-Data 响应头请求浏览器清除数据。"storage" 指令涵盖 IndexedDB 以及该源的其他存储,例如 localStorage 和 Service Worker 注册,这意味着它清除的内容多于 IndexedDB,比起在多个账户中移除其中一个,更适合完整的退出登录。响应头参考 指出,它只在安全响应上才会生效,并且各浏览器的支持情况不同。打开的连接可能延迟或限制被清除的内容,所以请把上面描述的应用侧删除作为可靠途径,把这个响应头当作额外的一步。
这两种工具都不能证明用户数据已没有任何副本。服务器记录、备份、其他设备,以及浏览器保存在该源存储之外的任何内容,都是各有保留规则的另外一些问题。应用能够展示的范围更窄:上一个账户的数据库不再出现在浏览器的存储视图中,下一个账户从空白状态开始。请在内部操作手册和展示给用户的任何隐私文字中把这一点说清楚。
账户切换增加了一条规则:在下一个账户读取任何内容之前,先完成删除,或者先对数据划定范围。打开以新账户命名的数据库在构造上是安全的,而为多个账户复用同一个共享数据库,则意味着按账户键进行的清除必须先完成。当账户在用户心里是相互独立的时候,更推荐每个账户使用单独的数据库,因为删除一个数据库比按键过滤记录更容易验证。关于同一清理流程中 Service Worker 的部分,请阅读 Service Worker 缓存的生命周期与隐私。
跨浏览器上下文检查生命周期
当你可以从已知的空白状态出发并观察每个阶段发生时,生命周期会更容易让人信任。两个不共享存储的浏览器上下文可以做到这一点:一个扮演已登录的账户,另一个扮演下一位用户或第二个账户,双方都看不到对方的 IndexedDB。关于多账户浏览器隔离的指南涵盖了这种设置中账户分离的一面。
BotBrowser 文档说明,通过 browser.newContext() 创建的每个 BrowserContext 都有自己的存储、Cookie 和会话状态,因此审查者可以让每条账户路径都从各自独立的 IndexedDB 状态开始,并验证每个上下文的清理行为。BotBrowser 不负责管理、迁移或清除 Web 应用的 IndexedDB 结构或记录,也不能让应用的升级、配额或退出登录逻辑变得正确;这些仍然是应用代码的职责。多账户隔离文档描述了这次检查所依赖的上下文边界。
把浏览器的开发者工具当作共用的观察手段。在基于 Chromium 的浏览器中,Application 面板会列出 IndexedDB 数据库、它们的对象存储和记录,其他浏览器也提供类似的存储视图。每一步之后刷新该视图,就能把“退出登录已删除数据”这样的说法变成第二个人可以亲自观察到的事实。
让检查只围绕你自己的应用。使用你自己创建的测试账户和合成记录,不要把这些步骤用在其他站点存储的数据上。
运行生命周期检查
针对应用的一个构建运行这些检查,并为每一项记录通过或失败。
- 打开与升级:数据库用显式版本号打开,且
createObjectStore和createIndex的调用只出现在upgradeneeded内。提高版本号并重新加载。如果新存储出现在Application > IndexedDB下,且现有记录仍可读取,则通过。如果在升级处理函数之外发现结构调用,或者重新加载后新存储缺失、现有记录无法读取,则失败。 - 第二个标签页:在两个标签页中打开应用,然后在第二个标签页中提高版本号。如果第一个标签页在
versionchange时关闭连接并显示重新加载提示,同时第二个标签页完成升级,则通过。如果第二个标签页停在加载状态,或报告blocked却没有任何提示,则失败。 - 存储审查:对于每个对象存储,审查记录其所有者、保留规则,以及对配额超出和驱逐的响应。在开发者工具中清除该源的数据并重新打开应用。如果每个存储都具备这三项记录,并且应用能检测到空白状态后恢复或显示其文档中说明的提示,则通过。如果某项记录为空,或应用在没有察觉的情况下继续在空数据库上运行,则失败。
- 退出登录清理:退出登录,然后刷新
Application > IndexedDB。如果上一个账户没有任何数据库残留,且下一个账户从空白开始,则通过。如果上一个账户的数据库仍在列表中,则失败。 - 被阻塞的删除:让第二个标签页继续以旧账户保持打开,然后退出登录。如果流程在应用选定的等待时间内结束于一个明确的状态,例如清理待定的提示以及下次启动时的重试,则通过。如果退出登录界面无休止地等待,或下次启动时仍能读取之前的数据,则失败。
- 独立的上下文:启动两个浏览器上下文。确认两个上下文中的
Application > IndexedDB都是空的。然后在第一个中写入一条标记记录,并在第二个中打开同一地址。如果标记在第二个上下文的 IndexedDB 中不存在,则通过。如果它出现了,则失败。
在结构变更、涉及存储代码的发布或浏览器大版本更新之后重复这些检查,并保留最后一次通过的记录,直到新一轮检查通过。