Cookie、localStorage 与 IndexedDB:状态该存在哪里
按作用域、网络暴露、容量和驱逐对比 Cookie、Web Storage 与 IndexedDB,并为每类状态选择合适的存放位置。
每种存储机制保存什么
页面通常有四个保存状态的地方,它们的差别在于谁能读取数据、数据何时经过网络传输,以及能保留多久。Cookie 是小型的名称与值配对,服务器和页面都可以设置。Web Storage 分为 localStorage 和 sessionStorage 两部分,为脚本保存字符串形式的键值对。IndexedDB 是面向结构化数据的异步事务型数据库。选择哪一种,取决于生命周期和暴露范围,而不是习惯。
Cookie 由 RFC 6265 定义。服务器通过 Set-Cookie 响应头设置 Cookie,脚本也可以通过 document.cookie 设置,浏览器会在后续请求的 Cookie 请求头中附带匹配的 Cookie。Domain、Path、Secure 和 HttpOnly 等属性,以及 MDN 介绍的后来新增的 SameSite 属性,限定了 Cookie 的发送位置以及谁能读取它。带 HttpOnly 标记的 Cookie 无法被页面脚本读取,因此适合保存由服务器签发的会话标识符。MDN 的 Cookie 指南说明了这些属性在当前浏览器中的行为。
Web Storage 由 HTML 标准规定。localStorage 和 sessionStorage 提供相同的同步接口,包括 getItem、setItem、removeItem 和 clear,键和值都是字符串。更复杂的内容,例如对象或列表,必须由应用自行序列化。由于调用是同步的,在主线程上读写大量数据可能延迟渲染,所以 Web Storage 适合保存小的值。
IndexedDB 由 W3C 规定。它在可以建立索引的对象存储中保存结构化值,包括文件和 Blob,并通过事务内的异步请求进行读写。数据库带有版本号,架构变更要在由应用控制的升级步骤中执行。它在 Worker 中同样可用,因此较重的读取不必占用主线程。代价是需要写更多代码、处理更多状态,而不是调用一行 localStorage。
按源划分的存储还包括其他存储项,例如 Cache API 和 Service Worker 注册信息。它们与上述机制并列,在实现了 Storage Standard 的浏览器中适用相同的配额与驱逐规则。因此,一次清理或驱逐可能移除比这里对比的几种存储更多的内容,审阅者不应假定某个存储中的值会比其他存储活得更久。
作用域与网络暴露
作用域是这几种机制差别最大的地方。Web Storage 和 IndexedDB 以源为作用域,也就是协议、主机和端口的组合,所以 https://example.com 和 https://example.com:8443 保存的是不同的数据。Cookie 则以主机和路径为作用域,Cookie 规范不按端口隔离它们;与协议相关的唯一控制是 Secure 属性。SameSite 引入了另一个“站点”概念,即可注册域名,用来决定 Cookie 是否随跨站请求发送。在判断哪些代码能看到某个值时,请把源和站点区分开。
网络暴露由此而来。在这四种机制中,只有 Cookie 会自动附加到 HTTP 请求上,所以每个匹配的请求都会携带它们,无论服务器是否需要这个值。这对服务器每次请求都要读取的会话标识符很有用,但对较大的数据代价很高,因为这些字节会随每个发往该主机的请求一起传输。localStorage、sessionStorage 和 IndexedDB 不会离开浏览器,除非应用代码读取某个值并将其发送出去。这个差别也决定了哪一方能够行动:服务器不运行任何脚本就能读取 Cookie,但完全看不到 Web Storage 或 IndexedDB。
脚本暴露则正好相反。在某个源中运行的任何脚本都可以读取该源的 localStorage、sessionStorage 和 IndexedDB,包括页面引入的第三方脚本,以及因跨站脚本漏洞而被注入的脚本。带 HttpOnly 标记的 Cookie 对脚本不可见,因此更适合存放凭据。保存在 localStorage 中的持有者令牌,可以被渲染该页面的同一份代码读取。请把这一点记录为一项设计取舍;这并不是避免用 Web Storage 保存普通偏好设置的理由。
Cookie 的大小值得单独说明。因为 Cookie 随请求一起传输,Cookie 越积越多会让每个请求变大,浏览器还会对每个主机的 Cookie 大小和数量设定各自的限制。RFC 6265 只要求用户代理支持较低的最小值,所以 Cookie 应当只携带标识符或简短的标志,把较大的数据留给服务器端记录或客户端存储。
嵌入式和第三方上下文又增加了一层。浏览器越来越多地按顶层站点对存储和 Cookie 进行分区,所以嵌入的框架看到的存储桶,可能与同一个源作为顶层页面时看到的不同。具体细节随浏览器和版本而异。关于浏览器存储分区与隐私的文章说明了如何测试这一点。当某项功能依赖嵌入框架内的状态时,请在嵌入位置测试它,不要假定顶层页面的结果同样适用。
生命周期、容量与驱逐
生命周期有两个终点:状态按设计不再可用的时刻,以及浏览器将其移除的时刻。会话 Cookie,即没有 Expires 或 Max-Age 的 Cookie,会保留到浏览器会话结束,这个边界由浏览器定义,有些浏览器还会恢复会话,让它们在重启后继续存在。持久 Cookie 会保留到到期时间,或者直到用户或浏览器将其移除。localStorage 和 IndexedDB 本身没有过期时间,会一直保留,直到脚本、用户或浏览器将其清除。sessionStorage 的存续时间与其顶层浏览上下文相同,大致相当于一个标签页,刷新后仍然存在,但标签页关闭后就消失。
关闭不同对象,可以清楚地看出差别。关闭一个标签页会结束该标签页的 sessionStorage,但会话 Cookie、localStorage 和 IndexedDB 仍然保留。关闭整个浏览器通常会结束会话 Cookie,不过会话恢复可能把它们带回来,同时 localStorage 和 IndexedDB 保持不变。独立打开的、同一个源的第二个标签页共享 Cookie、localStorage 和 IndexedDB,但拥有自己的 sessionStorage(由脚本打开的窗口则以一份副本起步)。当 localStorage 发生变化时,storage 事件还会通知同源的其他文档,标签页之间就是这样保持同步的。
容量也各不相同。Cookie 只能保存较小的值,每个主机的数量也有限。Web Storage 通常允许每个源保存几兆字节,IndexedDB 允许的容量要大得多,但受浏览器根据磁盘总容量推算的配额限制。这些数字取决于浏览器,MDN 关于存储配额与驱逐标准的页面对此说得很清楚。应用应当在运行时读取自己的限额并处理配额错误,而不是把某一个浏览器的情况硬编码为假设。
Storage Standard 补充了设计上最重要的一条规则:默认情况下,持久性只是尽力而为。每个源都有一个存储桶,按照 Storage Standard,浏览器在需要空间时可以清除尽力而为模式的存储桶,把该源的数据作为一个整体删除,并且不承诺事先询问。应用可以调用 navigator.storage.persist() 请求持久存储,浏览器按自己的策略决定是否授予,这一策略可能涉及向用户弹出提示。navigator.storage.estimate() 报告大致的用量和配额,navigator.storage.persisted() 报告存储桶是否为持久模式。请把这三者都当作提示,而不是保证。
驱逐并不是状态消失的唯一原因。用户会清除站点数据,隐私模式会在窗口关闭时丢弃存储,站点可以发送 Clear-Site-Data 响应头,请浏览器清除自己所在源的 Cookie 或存储,浏览器升级或配置文件变更也可能重置配置文件中保存的内容。策略也会随版本变化,所以某一次观察到的行为并不是承诺。规范和 MDN 都把这些行为描述为取决于浏览器,这里的内容也不承诺不同浏览器、版本或隐私模式之间的配额、过期或驱逐完全一致。
已存储的数据也会比写入它的代码活得更久。当某个版本改变了存储值的结构时,旧的条目必须被读取、迁移或丢弃,IndexedDB 的架构变更还需要提升版本号并执行升级步骤。在 localStorage 的值里加入版本字段,作用与此相同。跳过这一步的团队,会在一个回访的浏览器第一次加载由旧版本写入的数据时发现问题。
由于持久性只是尽力而为,应用应当把浏览器存储当作可以重建状态的缓存,除非这些数据是唯一的副本,并且已经告知用户。当某个值缺失时,应用应当回退到有文档说明的默认值,重新从服务器获取状态,或者请用户登录或重新填写草稿。请用错误处理包住读写操作,因为写入可能抛出配额错误,有些上下文会完全拒绝存储,同时要确保首次使用的路径在存储为空时也能工作。
为每类状态选择机制
从状态出发,而不是从 API 出发。对于服务器每次请求都必须读取的会话标识符,使用带 Secure、HttpOnly 和合适 SameSite 值的 Cookie,并设定服务器可以强制执行和撤销的有效期。这一选择由两项取舍驱动:网络暴露和脚本暴露。自动发送以及防止脚本读取的保护,比大小限制更重要,因为标识符本身很小。
对于主题、语言或已关闭的提示这类小的偏好设置,localStorage 通常就够了。这个值是一小段字符串,只有客户端代码需要它,丢失它只会让用户多点一次。如果服务器必须按该偏好渲染第一次响应,Cookie 是更好的位置,因为服务器能看到它;否则就避免把偏好设置放进每个请求。当值应当随标签页一起结束时,使用 sessionStorage,例如不应出现在另一个标签页里的、填写到一半的表单步骤。
对于结构化的离线数据,例如未发送的编辑队列、缓存的记录或文件,请使用 IndexedDB。它能处理更大的数据量、索引和事务,并且可在 Worker 中使用。它的代价是尽力而为的规则:把未发送编辑的唯一副本存在那里的应用,依赖的是一个浏览器可能驱逐的存储桶。请在界面中把这类数据标记为待处理,尽可能同步到服务器,只有数据确有必要时才请求持久存储。
混合设计很常见,也往往是正确的。一个产品可以同时使用带 HttpOnly 的会话 Cookie、保存在 localStorage 中的主题偏好,以及保存在 IndexedDB 中的离线队列,各自按生命周期来选择。设计应当避免的是,把同一个值重复保存在多个位置,却没有规定以哪份副本为准,因为当一份被驱逐或清除而其他份没有时,副本就会产生偏差。为每类状态指定唯一的负责方,并把其他副本视为派生数据。
缺失的值需要谨慎解读。Cookie 缺失或 localStorage 为空,只能告诉应用这个浏览器没有保留或从未收到过该值。它不能可靠地说明用户是谁、这次访问是否为首次访问,或者运行的是哪一类客户端,因此不应把它变成身份或信任判断。用它来决定显示什么或重建什么,涉及人的决定则交给身份验证。这一对比用于设计和审阅你自己应用的存储;读取、复制或替换其他站点保存的状态不在其范围之内。
在多上下文工作流中审阅存储
在多个浏览器上下文中运行同一流程的团队,需要知道每个上下文以什么状态开始。浏览器上下文各自保存独立的 Cookie 和存储,因此一个上下文的状态不会出现在另一个上下文中,这也使账号保持分离。关于多账号浏览器隔离的文章更详细地介绍了这一隔离模型,下面的检查依赖于它。
对审阅者来说,有用的问题是:开始时存在哪些状态,状态缺失时流程会怎么做。Cookie 是团队唯一能在启动时描述为可重复起始状态的一层,这一点见面向多身份工作流的浏览器 Cookie 管理。localStorage 和 IndexedDB 在全新的上下文中通常是空的,随着应用运行才逐步填充,所以依赖它们的测试应当通过应用自身的流程创建这些状态,并记录创建方式。
BotBrowser 文档说明可以在启动时通过 --bot-cookies 标志加载 Cookie(PRO 层级),包括通过 botbrowserFlags 为每个上下文单独导入,并说明每个 BrowserContext 都有自己的存储、Cookie 和会话状态,因此团队可以重复一个有文档说明的 Cookie 起始状态,并保持各身份相互分离。BotBrowser 没有记录预加载 localStorage 或 IndexedDB 的做法,不会更改浏览器的存储规范、配额或驱逐规则,也不能保证目标站点会保留或接受任何已存储的状态。Cookie 管理文档和多账号隔离文档描述了受支持的行为。
请用平实的语言记录一次审阅的结果。一份有用的记录会写明机制、作用域、预期的过期或驱逐行为,以及状态缺失时应用的做法,同时不保存用户内容或机密值。这份记录可以在浏览器大版本更新、存储代码变更或 Cookie 属性变更之后重复执行,并与上一次被接受的结果比较。失败应当指明出问题的边界,例如某个 Cookie 没有被发送,或者某个存储桶被清除,并指定下一步行动的负责人。
Cookie 属性的变更值得单独重复检查一次,因为浏览器会随时间调整 SameSite 的默认值和对第三方的处理。请比较变更前后的流程,并在新配置通过之前保留上一次被接受的配置。
运行存储审阅检查
对流程保存的每一类状态应用这些检查,并为每一项记录通过或失败。
- 对流程使用的每一种机制,即 Cookie、localStorage、sessionStorage 和 IndexedDB,记录写明它是否随 HTTP 请求发送。如果浏览器的网络面板在匹配的请求中显示了 Cookie 请求头,并且在任何请求中都没有出现 Web Storage 或 IndexedDB 的值,则通过。如果某个被认为只留在客户端的值出现在了请求中,则失败。
- 记录写明每一项的作用域:Web Storage 和 IndexedDB 为源,Cookie 为主机和路径。在第二个源上打开同一页面,例如另一个端口或子域名,确认那里看不到 localStorage 和 IndexedDB 的值。如果记录只写了“站点”而没有指明适用的边界,则失败。
- 关闭标签页后重新打开页面,再重启浏览器,并记录每一步之后下列项目中哪些仍然存在:会话 Cookie、持久 Cookie、localStorage 的值、sessionStorage 的值以及 IndexedDB 的记录。请注明浏览器版本,因为会话恢复和策略各不相同。若实际保留的项目与记录中该浏览器版本的生命周期一栏一致则通过,出现任何不一致则不通过。
- 对于会话标识符、小的偏好设置和一份结构化离线数据,记录写明所选的机制,以及促成该选择的生命周期取舍。如果会话标识符存放在脚本可读的存储中却没有记录理由,则失败。
- 通过浏览器的站点数据控制项清除其中一项,然后重新加载页面。如果应用显示其文档中说明的回退方式,即默认值、重新获取或登录提示,并且没有未处理的错误,则通过。如果页面出错,或者代码把缺失的值当作关于用户身份的信息,则失败。
- 把 navigator.storage.persisted() 和 navigator.storage.estimate() 的返回结果记录为观察值。如果在 persisted() 为 false 且已存储的数据被移除时应用仍能工作,则通过。如果应用假定已经获得持久存储,则失败。
- 在浏览器大版本更新、存储代码变更或 Cookie 属性变更之后重复这些检查。在重复运行通过之前,保留上一次被接受的记录。