扩展程序开发与部署
开发、测试和发布浏览器扩展程序的过程与开发传统网站存在显著差异。其步骤更类似于开发移动应用,而非网页技术。掌握如何有效地开发、发布和分发扩展程序,对于精通该领域至关重要。
注:本章内容以 Google Chrome 为中心,但除 Safari 外,所有主流浏览器在本地开发和发布到应用市场时都提供了几乎相同的设施。
本地开发
你会花费大量时间在本地开发扩展程序,因此熟悉各个部分是如何协同工作的很有必要。在本节中,我们将探讨本地加载的扩展程序是如何工作的,以及你可以通过哪些不同的方式深入了解其内部机制。
提示:对于刚接触扩展程序开发的开发者来说,“浏览器扩展程序入门课程”章节演示了将本地扩展程序加载到浏览器中进行开发的基本流程。
检查你的扩展程序
首先安装这个示例扩展程序:
示例 14-1a. manifest.json
{
"name": "MVX",
"version": "0.0.1",
"manifest_version": 3,
"background": {
"service_worker": "background.js",
"type": "module"
},
"options_ui": {
"open_in_tab": true,
"page": "options.html"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"css": [],
"js": ["content-script.js"]
}
],
"permissions": [
"scripting",
"declarativeNetRequest",
"tabs"
],
"host_permissions": ["<all_urls>"]
}
示例 14-1b. options.html
<!DOCTYPE html>
<html>
<body>
<h1>Options</h1>
<script src="options.js"></script>
</body>
</html>
示例 14-1c. options.js
console.log("Initialized options!");
示例 14-1d. background.js
console.log("Initialized background!");
示例 14-1e. content-script.js
console.log("Content script initialized!");
一旦这个扩展程序在本地加载,你的浏览器会在扩展程序管理视图中添加一个卡片。在 Google Chrome 中,这个页面的 URL 是 chrome://extensions
。一个示例卡片如图 14-1 所示。
从这张卡片上,你可以执行查看详情页面、卸载或禁用扩展程序、查看扩展程序的错误页面以及重新加载扩展程序等操作。
注意:有关重新加载扩展程序的详细信息将在本章后面部分介绍。
点击以打开详情页面后,将会显示类似图14-2的内容。
在这个视图中,有几点需要注意:
此视图为快照:视图内列出的值并非实时数据,除非页面重新加载,否则这些值不会更新。
检查视图(Inspect Views):包含一个链接列表,每个链接都与当前活动的扩展程序视图配对。此列表类似于
chrome.extension.getViews()
返回的值。每个链接都会打开一个针对该特定扩展程序视图的开发者工具窗口。(“视图(views)”这一名称可能会引起混淆,因为此工具还允许你检查后台服务工作线程(background service worker),而后台服务工作线程是没有用户界面的。)权限(Permissions):显示扩展程序如果在生产环境中加载时需要明确请求的权限列表。由于此扩展程序是在开发模式下加载的,因此权限对话框被静默处理(不会弹出)。
来源(Source):指示当前操作系统文件系统中加载扩展程序文件的位置。
检查扩展程序视图
点击“检查视图(Inspect Views)”下的“options”链接将打开相应的开发者工具界面(图14-3)。
在此请注意,控制台正在显示当前标签页控制台输出和后台服务工作线程(background service worker)的日志输出。如果你希望将控制台输出分开查看,开发者控制台允许你选择特定的来源(图14-4)。
检查后台服务工作线程(Background Service Worker)
你也可以直接检查后台服务工作线程。点击“检查视图(Inspect view)”链接后,将会显示类似以下内容(图14-5)。 重要警告:有一件极其重要的事情需要牢记,即这个后台服务工作线程会干扰服务工作线程的生命周期。它会阻止服务工作线程进入空闲状态。
它还会延长服务工作线程在重新加载后的生命周期,导致在调试扩展程序时出现奇怪的行为。当你完成对服务工作线程开发者工具窗口的操作后,请立即关闭它。将其留在后台打开会以不可预测的方式改变本地扩展程序的行为,并使调试变得更加困难。
Chrome 在 chrome://serviceworker-internals/
页面提供了服务工作线程状态和日志输出的实时视图(图14-6)。此页面不会干扰服务工作线程的行为,允许它们进入空闲状态。
检查内容脚本(Content Script)
检查带有内容脚本的网页的控制台时,网页控制台输出和内容脚本控制台输出会混合在一起(图14-7)。 与后台服务工作线程(background service workers)和选项页面(options pages)类似,你也可以对控制台输出进行过滤,仅查看内容脚本的控制台输出。
文件更改
在上述扩展程序详细信息视图中,会指示扩展程序是从文件系统的哪个位置加载的。这是因为扩展程序直接从该目录提供文件!你对源文件所做的任何更改,在扩展程序下次通过网络请求加载这些文件时都会立即反映出来。例如,对 popup.html
的修改会在下次打开弹出窗口时显示出来。但这并不适用于扩展程序仅在安装时使用的文件,如 manifest.json
。对这些文件的更新只有在正式重新加载扩展程序时才会反映出来。
错误监控
在开发过程中监控错误可能会比较棘手。对于具有 HTML 界面的视图,可以通过常规开发者工具控制台监控页面中抛出的错误。扩展程序中抛出的所有错误,无论抛出位置在哪里,都会显示在扩展程序错误视图中(图14-8、14-9和14-10)。
警告:如果服务工作线程(service worker)在事件循环的第一轮中抛出错误,它将无法成功注册。这一点非常重要,因为这意味着它所设置的任何事件处理程序都不会被执行。
扩展程序重新加载
扩展程序的不同部分会在多个时间点被重新加载:
- 扩展程序重新加载:获取
manifest.json
的新副本,更新后台服务工作线程,并关闭所有打开的过时弹出窗口和选项页面。这在manifest.json
发生变化时是必需的。 - 扩展程序页面重新加载:刷新使用
chrome-extension://
协议的任何页面。这在需要反映弹出窗口和选项页面中的 HTML、JS、CSS 和图像更改时是必需的。 - 网页重新加载:刷新任何注入内容脚本的网页。这在需要反映注入的内容脚本更改时是必需的。
- 开发者工具重新加载:关闭并重新打开浏览器的开发者工具界面。这在需要反映开发者工具页面更改时是必需的。
有三种方法可以强制重新加载扩展程序:
- 卸载并重新安装扩展程序
- 在 Chrome 扩展程序页面的相应卡片上点击重新加载图标 ↺
- 使用
chrome.runtime.reload()
或chrome.management.setEnabled()
编程方式重新加载扩展程序
自动化扩展程序测试
由于浏览器扩展程序的组件是由 Web 技术构建的,因此测试它们与测试网页并没有太大区别。流行的工具(如 Jest 和 Puppeteer)可以配置为为你的扩展程序提供自动化测试支持。虽然浏览器扩展程序的一些惯用方面(如弹出窗口和内容脚本)无法直接测试,但通过一些巧妙的方法,你仍然可以编写出强大而有效的测试套件。
注意:本节假设你熟悉 JavaScript 自动化测试。如果不熟悉,Jest 文档有一个面向初学者的良好教程:https://jestjs.io/docs/tutorial-react。
单元测试
由于单元测试不依赖于扩展程序组件在其原生浏览器容器中渲染,因此这些测试的编写方式与常规网页单元测试几乎相同。为了模拟 Chrome 扩展程序 API,sinon-chrome
包非常出色:https://github.com/acvetkov/sinon-chrome。该包允许你以非常清晰的方式模拟扩展程序 API 的回调值。
一个流行的扩展程序单元测试设置是使用 Parcel 作为主要构建工具,React 作为框架,Jest 作为测试运行器。以下示例设置了一个非常简单的扩展程序,其中包含一个从 Chrome 存储 API 读取数据的弹出窗口。示例包括一些单元测试,以确保弹出窗口按预期渲染:
示例 14-2a. manifest.json
{
"name": "MVX",
"version": "0.0.1",
"manifest_version": 3,
"action": {
"default_popup": "index.html"
},
"permissions": ["storage"]
}
示例 14-2b. package.json
{
"scripts": {
"start": "parcel watch manifest.json --host localhost",
"build": "parcel build manifest.json",
"test": "jest --watch"
},
"dependencies": {
"@types/chrome": "^0.0.196",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@babel/preset-env": "^7.18.10",
"@babel/preset-react": "^7.18.6",
"@parcel/config-webextension": "^2.7.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"jest": "^29.0.2",
"jest-environment-jsdom": "^29.0.2",
"parcel": "^2.7.0",
"process": "^0.11.10",
"sinon-chrome": "^3.0.1"
},
"jest": {
"testEnvironment": "jsdom"
}
}
示例 14-2c. index.html
<!DOCTYPE html>
<html>
<body>
<div id="app"></div>
<script type="module" src="index.tsx"></script>
</body>
</html>
示例 14-2d. index.tsx
import React from "react";
import { createRoot } from "react-dom/client";
import { Popup } from "./Popup";
const rootElement = document.getElementById("app");
const root = createRoot(rootElement);
root.render(<Popup />);
示例 14-2e. .parcelrc
{
"extends": "@parcel/config-webextension",
"transformers": {
"*.{js,mjs,jsx,cjs,ts,tsx}": [
"@parcel/transformer-js",
"@parcel/transformer-react-refresh-wrap"
]
}
}
示例 14-2f. .babelrc
{
"presets": [
["@babel/preset-env", { "targets": { "node": "current" } }],
"@babel/preset-react"
]
}
示例 14-2g. Popup.tsx
import React, { useEffect, useState } from "react";
export function Popup() {
const [count, setCount] = useState(null);
useEffect(() => {
chrome.storage.sync.get(["count"], (result) => {
if (typeof result.count !== "number") {
result.count = 0;
}
setCount(result.count);
});
}, []);
if (count != null) {
return <h1>Popup count: {count}</h1>;
}
}
示例 14-2h. Popup.test.js
import { render, screen } from "@testing-library/react";
import React from "react";
import { Popup } from "./Popup";
import chrome from "sinon-chrome";
describe("Popup", () => {
beforeAll(() => {
global.chrome = chrome;
});
test("Reads from the storage api", () => {
render(<Popup />);
expect(chrome.storage.sync.get.withArgs(["count"]).calledOnce).toBe(true);
});
test("Renders a default value", () => {
chrome.storage.sync.get.withArgs(["count"]).yields({ count: undefined });
render(<Popup />);
expect(screen.queryByText("Popup count: 0")).not.toBeNull();
});
test("Renders the storage API value", () => {
chrome.storage.sync.get.withArgs(["count"]).yields({ count: 3 });
render(<Popup />);
expect(screen.queryByText("Popup count: 3")).not.toBeNull();
});
afterAll(() => {
chrome.flush();
});
});
提示:如果你是从头开始设置此示例,运行 yarn install
应该可以使此设置正常工作。
对于已经熟悉典型 React 单元测试的开发者来说,上述内容大部分应该并不陌生。关于此实现的一些注意事项:
- 示例中的大部分内容都是典型的单元测试样板代码。与浏览器扩展程序单元测试唯一相关的部分以粗体显示。
- Parcel 不需要 Babel 进行编译,但 Jest 需要。
.parcelrc
中的额外值用于有条件地仅在单元测试中使用 Babel 作为编译器。 sinon-chrome
包用于检测对存储 API 的调用,但也能够模拟 API 回调中返回的值。
安装所有包后,你应该会发现 npm run start
将启动 Parcel 开发构建,而 npm test
将运行你的单元测试套件(图 14-11)。
集成测试
通过 Puppeteer(https://pptr.dev/)可以对浏览器扩展程序进行集成测试。它允许你以编程方式控制 Chromium 浏览器,包括导航到 URL 和派发用户事件。尽管它无法完美复制浏览器扩展程序渲染视图的所有不同方式,但它是目前为集成测试套件提供支持的最佳工具。
Puppeteer 可以配置为通过 --load-extension
标志加载扩展程序。该标志应指向你机器上包含扩展程序代码的本地目录。你还需要禁用无头模式,因为扩展程序无法在无头浏览器中加载:
const path = `path/to/your/extension`;
const browser = await puppeteer.launch({
// 禁用无头模式
headless: false,
args: [
`--disable-extensions-except=${path}`,
`--load-extension=${path}`
]
});
这将启动一个 Chromium 浏览器,但你需要将浏览器指向扩展程序的 URL。当然,你需要提前知道扩展程序的 ID。一个好的策略是通过清单的 key
字段控制扩展程序 ID:
const page = await browser.newPage();
await page.goto(
'chrome-extension://<extensionID>/popup.html'); // 现在你可以运行测试代码
由于所有扩展程序视图(包括弹出页面)都可以通过直接扩展程序 URL 加载,因此所有扩展程序页面都可以以这种方式进行测试。Puppeteer 可以用于运行任何 Chromium 浏览器,因此集成测试套件可以针对 Google Chrome、Microsoft Edge 或 Opera 等浏览器。
注意:使用 Puppeteer 设置完整的工作集成测试不在本书范围内。下面列出了完整的示例:
附加阅读
设置自动化测试很棘手,坦率地说,也很烦人。以下是一些关于各种扩展程序测试设置的博客文章和文档链接:
- Testing Web Extensions
- jest-webextension-mock
- jest-chrome
- Unit Testing Browser Extensions
- Integration Testing Browser Extensions with Jest
- Automate Chrome Extension Testing
- Complete Guide to Test Chrome Extension with Puppeteer
- Working with Chrome Extensions
发布扩展程序
要将扩展程序发布到 Chrome 网上应用店,你首先需要支付一次性 5 美元的开发者帐户费用。一旦设置好帐户,你就可以提交扩展程序了。扩展程序总是作为扩展程序文件和资源的 zip 文件上传。
商店列表
要发布扩展程序,你需要指定它在 Chrome 网上应用店中应如何呈现给用户。有些项目会自动从清单中提取:
- 扩展程序标题
- 扩展程序摘要
- 图标
其他所有内容都需要你自己提供:
- 描述
- 扩展程序类别
- 资源(包括视频和截图)
- URL(主网站、支持 URL、隐私政策 URL)
注意:这些字段以后可以更改,但每次更改都会使整个扩展程序经历缓慢的手动审查——即使代码没有更改。
隐私实践
在 Chrome 网上应用店中,你需要提供扩展程序的高级描述,以及每个被视为敏感的权限类别(身份、脚本等)的理由。你还需要说明是否以及如何跟踪用户,以及以何种方式跟踪。
审查过程
你的扩展程序在首次提交时将进行手动审查,以确保其符合 Chrome 网上应用店标准。一旦获得批准,它将可供任何人安装!
更新扩展程序
一旦你的扩展程序发布,更新它的过程与初始发布过程大致相同。你将上传一个包含清单中更新版本号的 zip 文件。任何其他权限都需要额外说明。
更新注意事项
一旦你有了现有用户群并希望推出更新,有一些事情你应该记住。
更新延迟
更新不会立即推出。一旦你提交更新,它必须经过审查和批准,用户的浏览器才能安装它。用户的浏览器会每隔几个小时定期发送更新检查,如果有更新可用,则会下载更新。但是,如果扩展程序处于活动状态,浏览器会延迟应用该更新,因此更新发布和安装之间有时会有相当大的延迟。这个延迟可能从几分钟到一周以上不等。
自动禁用
假设你有一批用户安装了你的扩展程序。如果发布了一个需要用户明确批准的新权限的新版本,你的扩展程序将被禁用,直到用户授予该权限。浏览器在扩展程序被禁用时不会弹出对话框,只会显示一个小图标。这意味着在添加额外权限时,你可能会遭受非故意的用户流失。使用可选权限可以解决这个问题。
注意:权限章节中详细介绍了这一点。
取消更新
在撰写本书时,不幸的是,一旦提交更新进行审查,仍然无法取消更新。提交审查时要非常小心,因为错误可能意味着你必须等待数天才能提交修复。
自动化更新发布
与其手动将 zip 文件上传到网页,不如通过 REST API 自动将更新提交到 Chrome 网上应用店。为此,你首先需要获取凭据以验证你的 API 请求。此过程的详细信息可以在这里找到:https://developer.chrome.com/docs/webstore/using_webstore_api/
一旦你有了所需的凭据,你就可以通过 API 管理扩展程序的大部分内容,文档在这里:https://developer.chrome.com/docs/webstore/api_index/
通过命令行与 API 交互很麻烦。相反,使用一个 NPM 包来推送你的更新:https://github.com/simov/chrome-webstore
提示:plasmo 平台有一个很棒的自动提交工具,称为 Browser platform publisher。请参考工具和框架章节了解详细信息。
跟踪用户活动
一旦你有了安装和使用你的扩展程序的用户,你肯定会想知道他们有多少人以及他们在做什么。
仪表板指标
Chrome 开发者仪表板向你显示有关扩展程序的几个重要指标,包括:
- 每日用户数
- 每日展示次数
- 每日安装数
- 每日卸载数
注意:你在 Chrome 网上应用店中看到的每周用户数是上周内检查过你的应用更新的 Chrome 浏览器用户数。这不是安装你的项目的用户数。
分析库
跟踪浏览器扩展程序中的用户活动与跟踪网页活动大致相同。有一些需要注意的地方:
- 内容脚本中的事件应在后台服务工作线程中跟踪。发送分析数据的传出网络请求受广告拦截器和跨源限制的影响。
- 后台脚本中的事件将没有页面 URL。
- 你必须在 manifest v3 中预加载你的分析库脚本。许多分析库(如 Google Analytics)提供动态加载分析脚本的选项——这不再被允许。
提示:广告拦截器将无法阻止从扩展程序页面或后台脚本发送的请求。因此,与网页(其中大量分析流量被阻止)不同,你可以期望浏览器扩展程序具有近乎完美的分析保真度。
设置 Google Analytics
许多扩展程序开发者希望为其浏览器扩展程序使用 Google Analytics(GA)。GA4 使这变得有些棘手,但通过一些配置,这当然是可能的。
设置 ga.js
对于 manifest v3 扩展程序,你需要下载 ga.js 脚本(https://www.googletagmanager.com/gtag/js?id=GA_TRACKING_ID)并将其保存为扩展程序中的本地脚本。
Google Analytics 在决定发送分析 ping 之前会检查活动页面的 URL 协议。如果不是 http 或 https,它将拒绝发送。使用 GA3 脚本时,可以通过 checkProtocolTask
覆盖此行为:
ga('set', 'checkProtocolTask', null);
然而,在 GA4 中,目前似乎没有能力覆盖此行为。如果你希望将 GA4 与扩展程序一起使用,你需要禁用此协议检查。在你的 ga.js 中找到以下行并删除或注释掉它:
"http:" != c && "https:" != c && (N(29), a.abort());
完成此操作后,你应该能够如下设置 Google Analytics:
const script = document.createElement("script");
script.async = true;
script.src = "/ga.js";
document.body.appendChild(script);
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
const GA_ID = "GA_TRACKING_ID";
gtag("js", new Date());
// 禁用自动页面浏览报告
gtag("config", GA_ID, {
send_page_view: false,
});
// 手动发送页面浏览事件
gtag("event", "page_view", {
page_path: window.location.path,
});
这是一个有点取巧的方法,但在本书出版时,这是让 GA4 脚本与浏览器扩展程序配合使用的唯一已知方法。
安装和卸载事件
你可以在用户安装和卸载扩展程序时执行特殊任务。例如,后台脚本可以在用户首次安装扩展程序时执行任务:
background.js
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === chrome.runtime.OnInstalledReason.INSTALL) {
// 此处的任何内容都只会在首次安装时执行
openWelcomePage();
}
});
你无法在卸载时执行代码,但你可以使用 chrome.runtime.setUninstallURL
将用户定向到你选择的 URL:
chrome.runtime.setUninstallURL("https://foobar.com/survey");
此 URL 没有限制,我建议使用它来收集分析数据、进行调查、显示故障排除的联系信息或提供一些可能有助他们重新安装的有用提示。
总结
在本章中,我们讨论了与构建和发布扩展程序相关的多个主题。我们讨论了一系列可用于更好地了解本地扩展程序内部情况的策略。接下来,我们介绍了适用于浏览器扩展程序的不同测试格式。我们介绍了在 Chrome 网上应用店中发布和更新扩展程序的所有重要部分。最后,我们讨论了如何有效地将分析工具集成到你的浏览器扩展程序中。
在下一章中,我们将介绍构建针对多个浏览器的浏览器扩展程序所涉及的所有细节。