Chrome Extension 里缓存已观察数据该放哪里

最近给一个 Jenkins 页面写 Chrome Extension,最开始只是想把构建历史表格整理得清楚一点:谁触发、什么时候触发、用了什么参数、关联了哪些 Git 变更。

这次 Jenkins 部署的页面只显示最近 7 条构建,新的构建一出现,最老那条就掉出窗口;旧构建 URL 也不一定还能打开。这是当前页面和保留配置的约束,不是 Jenkins 一律只能保存 7 条。扩展不能恢复服务端已经清理掉的数据,但可以记住自己已经看过的构建。

于是问题变成了:这份「已观察构建记录」应该存在哪里?

候选项很多:页面 localStorage、chrome.storage.local、IndexedDB、storage.session、内存变量,甚至还有 storage.sync、Cache Storage、OPFS 这类容易被顺手想到的选项。它们都能存东西,但归属、可见性、生命周期和查询能力完全不一样。

这个 Jenkins enhancer 最后选了 chrome.storage.local,同时需要让用户看清缓存来源,并提供查看和管理缓存的入口。

先看这份数据属于谁

这份记录由扩展从已打开的 Jenkins 页面采集,是一份「扩展观察日志」。它记录本机看过什么,与服务端保留哪些构建是两套生命周期。

这个归属判断会直接影响存储选择:

  • 如果数据属于页面业务本身,页面 localStorage 很自然。
  • 如果数据属于扩展能力本身,chrome.storage.local 更自然。
  • 如果数据已经变成大量结构化历史,需要索引、分页和复杂查询,IndexedDB 才开始有明显优势。

Chrome 官方 chrome.storage 文档把它描述成 extension-specific 的持久化方式,并且明确 content scripts 默认可以访问。官方同一页也提醒,content script 里用 Web Storage 会共享宿主页存储。因此,content script 直接写 window.localStorage,写进去的是 Jenkins 这个站点的 origin,而不是扩展自己的 origin。

同一份缓存,存储 API 决定它归谁

页面 localStorage 的优势是真的

一开始我对页面 localStorage 有点偏见,觉得它像是在「污染页面」。但这个理由在当前场景里并不够扎实。这个扩展本来就只服务 Jenkins 的两个站点,写一份带前缀的缓存 key,并不会立刻造成工程灾难。

而且页面 localStorage 有一个很真实的优势:透明。

用户打开 Jenkins 页面的 DevTools,在 Application 面板里就能看到 localStorage。如果缓存里出现了奇怪数据,用户可以自己确认,也可以自己删掉。这种可见性对内部工具很重要。尤其这个缓存是为了弥补 Jenkins UI 的缺口,如果它自己又变成一个不可见的小黑盒,体验上会打折。

MDN 的 localStorage 文档说明它按 document origin 存储,且跨浏览器会话保留。放到 Jenkins enhancer 里,会带来这些结果:

  • 测试环境和正式环境会天然分开,因为它们是不同 origin。
  • 数据跟 Jenkins 站点数据在一起,用户清理站点数据时会一起清掉。
  • Jenkins 页面自己的脚本也能看到这些 key。
  • 值只能是字符串,复杂对象需要自己 JSON.stringify() 和 JSON.parse()。
  • API 是同步的,少量数据问题不大,大量读写会阻塞主线程。

缓存只服务某个站点、数据量小、调试透明度优先,并且不需要跨扩展页面共享时,页面 localStorage 很合适。

这次我希望把构建缓存和 Dashboard 收藏、最近访问、缓存清理入口放进同一套扩展设置,所以选了扩展存储。页面 origin 与扩展 ID,决定的是两种不同的管理范围。

chrome.storage.local 更像扩展自己的缓存

chrome.storage.local 的优势在于归属清楚。它绑定扩展 ID,而不是 Jenkins 页面 origin;content script、popup、options page、service worker 都可以围绕同一份扩展数据工作。官方文档还列了几个对扩展很友好的特性:异步读写、JSON 可序列化对象、onChanged 监听、getBytesInUse() 统计空间、setAccessLevel() 控制 content script 访问级别。

生命周期也更符合「插件缓存」:storage.local 存在本机,扩展更新、reload、Chrome 重启都不应该清掉;扩展卸载时会清掉。官方文档写到,storage.local 默认上限是 10 MB,Chrome 113 及以前是 5 MB,需要更大空间时可以申请 unlimitedStorage。

这里有一个本地开发边界:chrome.storage.local 跟扩展 ID 绑定。如果换目录重新 Load unpacked,或者没有稳定的开发 ID,新扩展可能读不到旧扩展的数据。Chrome manifest key 文档专门解释了如何在开发阶段保持一致的 extension ID。真正要长期分发时,这件事应该提前定好。

透明度这点以前确实弱一些。现在 Chrome 已经补上了入口:Chrome DevTools 从 Chrome 132 开始支持查看和修改 extension storage。在页面 DevTools 的 Application 面板里,可以展开 Extension Storage,查看运行在当前页面上的扩展存储。这个入口仍然不如页面 localStorage 直觉,但已经不是完全看不到。

这个场景里,当前实现简化后大概是这样的:

const createCacheKey = (context: JenkinsPageContext) => {
  const url = new URL(context.href);
  return `${CACHE_KEY_PREFIX}:${context.environment}:${url.origin}:${createJobCacheScope(context.href)}`;
};

const saveObservedBuildRows = async (context: JenkinsPageContext, rows: BuildHistoryRow[]) => {
  const key = createCacheKey(context);
  const cachedRows = await loadCachedBuildRows(context);
  const nextRows = mergeAndLimit(rows, cachedRows);

  await chrome.storage.local.set({ [key]: nextRows });
  return nextRows;
};

缓存 key 里有三层含义:

  • environment:测试环境和正式环境隔离。
  • origin:不同 Jenkins 站点隔离。
  • job scope:同一个 job 从直达 URL 或 view 列表进入时共享缓存。

这比页面 localStorage 多了一点实现成本,但也换来一个好处:隔离和共享都由扩展主动定义,而不是完全交给页面 origin。

IndexedDB 是数据库,不是默认缓存

如果只看 Web 平台能力,web.dev 的 Storage for the web会给出很明确的推荐:资源用 Cache Storage,文件内容用 OPFS,其他数据优先 IndexedDB。MDN 的存储配额文档也把 IndexedDB 放在「大型结构化数据、可索引查询」的位置。

这套建议放在普通 Web App 里很合理。IndexedDB 支持更大的数据量、事务、object store、索引和结构化克隆。存几万条记录、按时间范围查、按 job / branch / appName 建索引,IndexedDB 会比把一个大数组塞进 chrome.storage.local 更合适。

但在 Chrome Extension content script 里,还要多看一层 context。Chrome 的 Storage and cookies 文档说明,扩展自己的存储会在 extension origin 下共享;content script 里调用 Web 平台存储 API 时访问的是宿主页数据。IndexedDB、Cache Storage 这类 Web 平台存储在 extension service worker、extension page、offscreen document 里可以作为扩展自己的存储使用,但 content script 里直接调用时,心智上不能把它当作扩展统一数据库。

所以在当前场景里认真使用扩展自己的 IndexedDB,比较稳的形态会是:

content script 通过扩展上下文访问扩展数据库

对每个 job 只缓存几十条记录的需求,这条链路引入的 schema 版本、事务、消息通信和错误恢复成本偏高。等记录量和索引查询需求增长后,再承担这套数据库维护更划算。

Stack Overflow 上也有类似讨论:chrome.storage.local 和 IndexedDB 的选择主要取决于数据形状。社区里常见的判断是:简单 JSON 和少量状态用 chrome.storage.local,大数组、复杂对象、索引查询和高频局部更新再考虑 IndexedDB。这个讨论不能替代官方文档,但它很好地补上了工程经验层面的取舍。

其他选项可以很快排掉

chrome.storage.session 适合临时状态。官方文档说明它在扩展 reload、禁用、更新和浏览器重启时都会清掉。用它记录「本次页面生命周期内已经处理过哪些 DOM 节点」可以,用它弥补 Jenkins 历史窗口不合适。

chrome.storage.sync 适合用户设置,不适合本地构建历史缓存。它有约 100 KB 总量和 8 KB 单项限制,还会跟随 Chrome 账号同步。Jenkins 构建记录是机器本地观察结果,不应该默认同步到另一台机器。

chrome.storage.managed 是企业策略配置,读多写少,甚至对扩展本身是只读。它可以用来给全员下发配置,不适合存用户本地观察结果。

Cache Storage 适合 HTTP request / response 缓存,OPFS 适合文件型数据。它们都不是「几行构建摘要」的第一选择。

Cookies 更不合适。符合发送条件的 cookie 会放进请求头,既增加请求体积,也把本来只用于本地 UI 的构建摘要带到网络层。

内存变量则只适合当前页面运行期。刷新页面、重新注入 content script 或关闭浏览器都会丢。它可以配合防抖、避免重复渲染,但不能作为历史缓存。

第三方生态怎么处理这个问题

第三方框架也能反映开发者心智。Plasmo 的 Storage API 文档把扩展持久化封装成一个跨 background service worker、content script、extension page 的 Storage 抽象,并支持选择 local / sync 等 storage area。它还提供把指定 key 复制到 Web localStorage 的能力。

这套设计默认把状态放在扩展存储里;只有确实需要页面可见、页面共享或和网页脚本协作时,才显式桥接到 localStorage。

Stack Overflow 上关于 window.localStorage 和 chrome.storage.local 的经典讨论也基本是这个方向:选择取决于扩展要做什么。localStorage 的域名隔离和 DevTools 可见性是真优势;chrome.storage.local 的扩展级中心化、content script 可用、跨页面共享和变化监听,也是真优势。

存储选型首先要确定:这份数据应该跟站点走,还是跟扩展走。

回到这个 Jenkins enhancer

这个缓存有几个约束:

  • 数据来源只来自当前浏览器已经看过的 Jenkins 页面。
  • 缓存不能恢复 Jenkins 从未展示过、已经清理掉的构建。
  • 当前 Jenkins 可见行优先,本地缓存只补掉出窗口的旧行。
  • 运行中构建不缓存,避免保存过期进度和取消链接。
  • 参数值要脱敏,至少隐藏 token、cookie、password、secret、credential 这类字段。
  • 每个 job 只保留有限条数,避免本地无限膨胀。

这组约束决定了 chrome.storage.local 是当前更合适的默认方案。它把缓存归到扩展,能和收藏、最近访问、缓存清理入口共用一套存储,也能在未来从 content script 之外的页面统一管理。

选择扩展存储后,透明度仍需要由产品补足。不能只在实现里写「存在扩展存储里」,还要让用户在 UI 上看清缓存来源和管理入口:

  • 表头显示「当前页面可见 N 条,本地缓存 M 条」。
  • 缓存行标记「本地缓存」。
  • 提供「查看缓存 JSON」入口。
  • 提供「清空当前 job 缓存」入口。
  • 需要时再提供「导出缓存」。

这样普通用户不用知道 DevTools 里 Extension Storage 在哪里,也能知道这些记录来自哪里、能不能信、怎么清掉。

以后怎么判断

以后再遇到 Chrome Extension 存储选型,我会按这几个问题判断:

  1. 数据属于页面还是扩展。页面业务状态优先页面存储;扩展状态优先扩展存储。
  2. 数据是否要跨站点、跨页面、跨扩展上下文共享。需要共享时优先 chrome.storage.local。
  3. 用户是否需要直接在页面 DevTools 里看到。透明度特别重要、且数据只服务当前站点时,页面 localStorage 可以接受。
  4. 数据是否需要索引、分页和局部更新。需要数据库能力时再上扩展 context 里的 IndexedDB。
  5. 数据是否只是当前运行期临时状态。临时状态用内存或 storage.session,不要伪装成持久缓存。

这次选择 chrome.storage.local,依据是数据归属、体量和共享范围。选定存储后,还要把来源标记、查看和清理入口做出来,让用户知道扩展记下了什么,也能自行管理这份记录。