译:重新思考 Chrome 扩展 DX

原文:https://sun0day.github.io/blog/crx/rethinking-chrome-extension-dx.html
作者:sun0day
译者:ChatGPT 4

最近,我一直在通过 Vite5 开发一个内部 Chrome 扩展程序。起初,我追求尽快完成这个扩展的 MVP,所以并没有把太多的焦点放在扩展工程化上。当这个扩展的功能变得越来越复杂时,我发现扩展 DX 还有很大的改进空间。不幸的是,我看到很少有文章和项目关注扩展 DX 问题。本文将讨论 Chrome 扩展 DX 的一些关键问题。我还启动了一个新的 GitHub 仓库 来解决这些问题,但它还需要很多工作。

热模块替换(HMR)的痛点

目前,您可以按照官方文档的说法,手动重新加载最新的扩展,或者通过 chrome.runtime.reload 原生 API 在脚本中内部重新加载。频繁手动重新加载,尤其在开发过程中重新加载扩展是很痛苦的。至于 chrome.runtime.reload,它不会被执行,直到扩展脚本调用它,因此,我们需要在相关文件发生变化时告诉扩展脚本完全重新加载或替换被操作的模块。

资源加载

不同于通过网络加载普通网页,Chrome 从本地磁盘加载扩展资源。现代打包器开发服务器通常会在内存中处理资源转换,因为这样更高效。这将阻止 Chrome 从它们那里查询资源。

一种解决方案是在运行时从服务器的内存中将这些资源发射到扩展目录。一些其他打包器支持做到这一点,但它们几乎不支持 HMR。

WebSocket 限制

现代打包器通常会在浏览器和开发服务器之间创建一个 WebSocket 通道来处理 HMR 通信。然而,WebSocket 不允许在一些 Chrome 扩展脚本中使用,比如 内容脚本

另一方面,WebSocket 在 后台服务工作器中工作。因此,我们可以使 bundler 创建服务工作器和开发服务器之间的通道。一旦模块发生更改,服务器首先通知服务工作器,然后服务工作器通过 消息传递 API 告诉内容脚本替换旧模块。

流程概览

经过一些魔法之后,Chrome 扩展的 HMR 过程看起来会是这样的:

  1. 开发服务器加载并监视源代码。
  2. 开发服务器构建相关资产到扩展目录。
  3. Chrome 读取整个扩展目录并构建开发服务器与服务工作器之间的 WebSocket 通道。
  4. 当开发服务器监视到变化时,发出新模块到扩展目录,然后通知服务工作器。
  5. 服务工作器发送有关新模块的消息给内容脚本。
  6. 内容脚本更新新模块,无需重新加载。

高级运行时 API

Chrome 为开发者提供了丰富的原生 API。这些原生 API 灵活而原始,您可以组合它们以实现更高级的功能。这里的问题是我们需要更直接的 API 来简化我们的代码。例如,我们可以在服务工作器中运行以下代码来检索匹配 URL 的 cookies。

/* 服务工作器 */
const cookies = await chrome.cookies.getAll({ url })

如果我们想从当前页面上下文检索 cookies,我们需要先获取 cookie 存储区,否则,我们可能会从另一个 Chrome 窗口实例检索 cookies。

/* 服务工作器 */
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
    // 获取所有 cookie 存储区
    const stores = await chrome.cookies.getAllCookieStores();
    // 获取当前页面的 cookie 存储区
    const storeId = stores.find((store) => store.tabIds.includes(sender.tab.id)).id;
    const cookies = await chrome.cookies.getAll({ url: request.url, storeId })
    // 将 cookies 发回内容脚本
    sendResponse(cookies)
})

我们可以将获取 cookie 的逻辑封装进一个 getCookies 函数中,这样代码可以更简洁。

/* service worker */
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
    // 通过一个函数检索 cookie
    const cookies = getCookies(request.url, sender.tab.id)
    // 将 cookie 发送回内容脚本
    sendResponse(cookies)
})

封装可复用逻辑不仅有助于使扩展的代码更加简洁和健壮,还能减少对原生 API 理解的成本。我在扩展开发过程中发现,基于原生 API 有很多可复用的逻辑。我们可以为更复杂的场景设计更好的 API。

存储问题

chrome.storage 旨在客户端浏览器中存储特定于扩展的数据。它通常用于跨扩展组件、标签页、窗口乃至设备共享数据。尽管如此,它还是有几个缺点让我感到困扰。

数据同步

从存储中同步数据并立即反映到 UI 上是很常见的,尤其是在某些 UI 框架中。与其他客户端存储(localStoragesessionStorage 等)不同,Chrome StorageAreaStorage 有不同的实现(类型定义、‘change’ 事件等),因此,我们需要为特定 UI 框架中的 chrome.storage 封装一个 useStorage 风格的钩子。例如:

/* react hooks
 * @param key {string} 存储数据键
 * @param defaultValue {T} 存储数据的默认值
 * @returns {[T, (nextValue: T) => void]} 返回当前存储的数据值及其设置器
 */
function useChromeStorage<T>(key: string, defaultValue: T): [T, (nextValue: T) => void]

一个更低级别的 API 来观察存储数据的变化可以是:

/* react 钩子
 * @param key {string} 存储数据的键
 * @param listener {(newValue: T) => void} 存储数据变更回调
 * @returns void
 */
function listenStorage<T>(key: string, listener: (newValue: T) => void ): void

这让我们回到前一节的主题,“我们需要更高级的 API”。

数据验证

向存储中写入脏数据或错误数据时有发生。这些数据(特别是存储在客户端的)引起的错误通常很难调试和修复。为了防止错误数据意外写入存储,最好在执行写操作之前强烈验证数据。我们可以使用一些第三方库,如 joi 来进行数据模式验证。

数据调试

除非你将它们记录到控制台,否则没有办法通过 Chrome 开发者工具查看 chrome.storage 中存储的数据。在扩展脚本之间交换的信息也存在同样的问题。这对我们调试代码不友好。我们可以像 redux-logger 那样记录数据或消息信息。只要在开发模式下发生存储数据变更或消息传递,调试 API 将进行记录。

更严格的 lint

在 ESLint 中打开 env.webextensions 以防 ESLint 无法识别原生 API。

{
 "env": {
    "webextensions": true
  }
}

在 ESLint 中预设原生 API 还不够,我们还需要更多规则来帮助我们发现一些潜在的运行时错误。以下是一些有助于编写健壮代码的规则。

no-permission

当我们使用原生 API 时,必须先在 manifest.json 中声明其权限,否则,当扩展调用这个 API 时会发生错误。为了避免在运行时出现这个错误,我们可以通过 ESLint 在编码时检测它。

version-mismatch

由于 Manifest V3 在 Chrome 88 或更高版本中得到了广泛支持,Manifest V2 的 API 将逐渐被弃用或重构。如果我们在 V3 上下文中使用 V2 的 API,version-mismatch 规则将直接提示一个 ESLint 错误。

no-unavailable-api

不同的上下文对原生 API 有不同的可访问性。例如,正如文档所述,我们只能在内容脚本中使用部分原生 API。对于扩展新手开发者来说,这种类型的错误可能会让人困惑,他们必须使用 Google 搜索或回顾扩展文档来找出为什么原生 API 是 undefined

no-unhandled-message

当扩展变得越来越复杂时,我们需要确保发送者和接收者正确处理消息。no-unhandled-message 规则将检测接收方是否设置了消息处理程序,以防消息未被正确处理。

通过添加这些和更多规则,我们可以更容易地编写健壮的代码。

扩展入门

我将要谈论的最后一个问题是扩展入门。入门是一种工具,可以快速初始化一个扩展应用并管理其项目开发。一个好的入门可以灵活适应不同的扩展组件、脚本、语言和 UI 框架,同时保持底层打包器、包管理器和 CI/CD 的稳定性。我们可以从 Vite 学到这一点,并将上述提到的能力集成进去。

结论

本文讨论了 Chrome 扩展 DX 的一些问题和解决方案,由于篇幅限制,还有很多问题(例如“消息标准”和“检查模式”)未在此提及。奇怪的是,我看到很少有文章和项目试图解决这些问题。我希望将来能找到它们的最佳实践,如果你对改进 Chrome 扩展 DX 有任何想法,可以在上述提到的仓库留下问题或开始讨论。