浏览器扩展架构
了解浏览器扩展的各个元素是如何工作的很重要,但更重要的是理解它们是如何协同工作的。浏览器扩展的架构是独特且多方面的:
它们缺乏集中性,而是由一系列分布式元素构成的网络:后台脚本、内容脚本、弹出窗口和选项页面,以及开发者工具页面。
它们能够管理跨越多个窗口和标签页的复杂浏览器页面基础设施。由于内容脚本可以在多个页面上运行并且是独立加载的,因此它们需要特别的考虑,如多路复用通信通道和更新版本控制。
它们可以在弹出窗口页面、选项页面、开发者工具页面和内容脚本中提供基于网页的用户界面。它们还可以直接与浏览器的原生界面集成,提供诸如键盘快捷键、地址栏(omnibox)和桌面通知等功能。
注意:本章是一个高级别的概述,主要阐述浏览器扩展的各个组成部分如何相互连接和交互。关于API和其他具体细节的内容,本书主要在后续章节中详细介绍。
架构概览
浏览器扩展的架构通过视觉呈现会更容易理解。请考虑以下图示(图4-1),它展示了浏览器扩展的所有组成部分是如何整合在一起的。
让我们来详细分析这里所展示的各个元素及其相互之间的联系,这些元素和联系在上面的图中通过数字进行了标识:
1.弹出页面是一个用户界面组件,通常出现在浏览器的工具栏按钮点击后,它可以利WebExtensions API来执行多种功能,这些功能包括:向浏览器的存储API读写数据,向后台页面发送消息,以及在活动标签页的内容脚本中触发行为。
2.内容脚本可以查看和操纵宿主网页的DOM(文档对象模型),并且能够监听DOM事件。
3.内容脚本被直接注入到网页中,并在一个沙盒化的JavaScript运行时环境中执行。每个具有允许方案(http://, https://, ftp://, file:///)的标签页都有资格被注入内容脚本。
4.内容脚本可以使用WebExtensions API来执行诸如从后台脚本发送和接收指令,以及从页面DOM导出信息等任务。可以通过chrome.tabs.query()来定位单个标签页或标签页子集上的内容脚本。
5.与弹出页面类似,选项页面也通过WebExtensions API与扩展的其他部分进行交互。
6.WebExtensions API是任何Web扩展的连接纽带。它允许任意两个元素之间进行双向通信,并且还可以访问共享存储API。扩展的消息传递协议是一种广播格式,这意味着扩展的任何其他部分都可以监听发送的所有消息。
7.DevTools页面仅能使用WebExtensions API的一个有限子集。
8.后台服务工作线程可以通过WebExtensions API管理扩展的其余部分。这包括在API事件处理程序被触发时向扩展的另一部分发送消息。
9.每当开发者工具界面打开时,devtools页面都会被初始化,并在关闭时被销毁。它主要用于初始化子元素,如面板等。Devtools页面是无头网页。
10.后台服务工作线程是扩展的神经中枢。它经常被用来处理事件、分发消息和执行认证职责。后台服务工作线程具有单例的有用属性;对于任何数量的标签页或窗口,都只有一个服务工作线程在运行。
11.使用Devtools API,devtools页面可以生成多个子页面,这些子页面原生地渲染在浏览器的devtools界面中。这些子页面以面板和侧边栏的形式出现。
12.后台服务工作线程是浏览器扩展中唯一能够可靠处理浏览器事件的元素。像选项页面、弹出页面、内容脚本和devtools页面这样的扩展元素都是瞬态的,因此当它们未运行时可能会错过事件。
13.Devtools页面被授予访问一个补充的Devtools API的权限,该API提供了创建子视图以及检查和调试网页的方法。
14.Omnibox可以在清单文件中启用和配置。当在URL栏中输入一个特定关键词时,会显示一个类似于搜索的特殊界面。此界面会将搜索栏的内容作为omnibox事件分发给扩展,从而使扩展能够提供搜索引擎式的行为。
15.可以启用键盘快捷键来触发诸如打开弹出页面之类的原生行为,或者触发自定义命令事件。
16.可以以编程方式显示原生的桌面通知。这些通知还会触发点击和关闭事件。
17.原生工具栏图标可以以两种方式之一工作:它可以触发弹出页面,或者它可以触发扩展点击事件。
多样性、生命周期和更新
在开发浏览器扩展时,一些关键考虑因素包括在任何给定时间可以存在多少个元素、这些元素的创建和销毁方式,以及它们如何处理扩展的更新。
后台服务工作线程
后台服务工作线程是唯一一个无论打开多少个扩展页面、窗口或标签页都能保证单例行为的扩展元素。当浏览器检测到服务工作线程处于空闲状态时,会将其销毁,并在需要时重新启动(例如,当浏览器检测到带有处理程序的传入事件时)。当扩展更新时,浏览器将重新启动服务工作线程。
注意:有关后台服务工作线程行为的更多详细信息,请参阅“后台脚本”章节。
弹出页面和选项页面
浏览器将确保每个窗口只打开一个弹出页面。但是,如果打开了多个窗口,每个窗口都可以独立地打开一个弹出页面。弹出页面是瞬态的:它们在弹出界面展开时初始化,并在弹出界面关闭时立即销毁。当扩展更新时,浏览器将强制关闭所有打开的弹出页面。
打开选项页面的数量没有限制。但是,如果将清单选项options_ui.open_in_tab
设置为false
,浏览器将确保每个窗口只打开一个模态选项页面。无论是模态形式还是标签页形式,选项页面都具有正常的网页生命周期。当扩展更新时,浏览器将强制关闭所有打开的选项页面。
注意:有关弹出页面和选项页面行为的更多详细信息,请参阅“弹出页面和选项页面”章节。
Devtools Pages(DevTools页面)
每次打开浏览器的DevTools界面时,DevTools页面都会精确地渲染一次。因此,由于每个窗口只能有一个开发者工具界面,所以每个窗口也只能有一个DevTools页面。只要开发者工具界面保持打开状态,这个页面以及它创建的子页面(包括面板和侧边栏)就会持续存在。重要的是,当扩展更新时,这些开发者工具页面不会受到影响,因此它们可能会变得过时。
注意:有关DevTools页面行为的更多详细信息,请参阅“DevTools Pages”章节。
Content Scripts(内容脚本)
内容脚本将根据扩展清单中定义的方式注入到网页中。此外,单个网页中可以注入多个内容脚本。对于一个定义了M个内容脚本的扩展,以及一个打开了N个标签页的浏览器,在任何给定时间运行的内容脚本总数受M x N的限制。内容脚本可以在不同的页面加载事件时注入到页面中,但所有这些事件都大约发生在页面最初加载时。它们的行为与普通网页脚本完全相同,并且将以相同的方式执行。重要的是,当扩展更新时,内容脚本不会受到影响,因此它们可能会变得过时。
注意:有关内容脚本行为的更多详细信息,请参阅“Content Scripts”章节。
浏览器扩展文件服务器
安装扩展后,浏览器将通过一个简单的文件服务器使扩展文件可访问。为了探究这一点,我们将使用一个简单扩展来演示扩展文件服务器的一些概念。文件结构如下:
示例 4-1a. manifest.json
{
"name": "MVX",
"version": "0.0.1",
"manifest_version": 3,
"background": {
"service_worker": "background.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content-script.js"]
}
],
"web_accessible_resources": [
{
"resources": ["fetch-page.js"],
"matches": ["<all_urls>"]
}
]
}
示例 4-1b. background.js
import "./fetch-page.js";
console.log("background.js");
示例 4-1c. content-script.js
console.log("content-script.js");
import(chrome.runtime.getURL("fetch-page.js"));
const el = document.createElement("script");
el.src = chrome.runtime.getURL("fetch-page.js");
document.body.appendChild(el);
示例 4-1d. fetch-page.js
console.log("fetch-page.js");
fetch(chrome.runtime.getURL("extra.html"));
示例 4-1e. extra.html
<!DOCTYPE html>
<html>
<body>
<h1>Extra Page</h1>
<script src="fetch-page.js"></script>
</body>
</html>
花点时间研究一下这个扩展的功能。以下是一些需要注意的点:
- 该扩展会在所有网页上注入
content-script.js
。 - 清单文件将
fetch-page.js
标记为可通过网络访问的资源,所有网页都可以访问它。 content-script.js
尝试以两种方式加载fetch-page.js
:一种是通过动态import
,另一种是通过动态创建<script>
标签来加载。fetch-page.js
会发送一个网络请求来获取extra.html
文件。此文件将在扩展的不同位置被加载和执行,但结果可能有所不同。
为了理解扩展文件服务器的工作原理,我们将逐个文件地分析这个扩展。首先,在Google Chrome中加载此扩展,然后点击扩展卡片中的服务工作线程链接(如图4-2所示),以检查背景服务工作线程的控制台输出。
背景服务工作线程的控制台输出将如图4-3所示。
这表明背景脚本能够成功导入并执行fetch-page.js
脚本。接下来,让我们查看“网络”选项卡,以了解这是如何发生的(如图4-4所示)。
重新加载扩展以触发这些网络请求。您将会看到两个成功的网络请求:一个是用于导入的JS文件,另一个是用于获取的HTML文件。检查JS请求会显示以下内容(如图4-5所示)。
这个网络请求成功地发送了一个GET请求到扩展文件服务器,文件服务器返回了一个200状态码。请注意,URL以chrome-extension://
协议开头。让我们来解析这个URL的各个部分:
chrome-extension://
是URL协议,它告诉Google Chrome这个请求应该被路由到浏览器已安装的扩展中。不同浏览器的协议有所不同:Mozilla Firefox使用moz-extension://
,Microsoft Edge使用extension://
,Opera使用opera://
。pmnbhnikfammdjefkbgngjgojkbgkdnk
是扩展的ID。您的扩展ID将会有所不同。这个ID用于在浏览器内部以及扩展市场发布时唯一标识这个扩展的实例。不同浏览器的ID格式会略有不同:例如,Google Chrome使用一串不间断的小写字母,而Mozilla Firefox使用v4 UUID。fetch-page.js
是URL路径。它将与扩展目录内的文件路径完全匹配。
接下来,检查extra.html
的请求(如图4-6所示)。
比较这两个请求,并注意Access-Control-Allow-Origin
和Cross-Origin-Resource-Policy
头部是否存在。这些头部之所以会自动添加,是因为fetch-page.js
被列在了web_accessible_resources
中。与此相对,观察对extra.html
的请求:您会发现这些头部是缺失的,因为extra.html
并没有被列为可通过网络访问的资源。
注意:您还会发现,extra.html在清单文件中没有被直接或间接引用,但扩展文件服务器仍然很高兴地返回该文件。当从扩展上下文(背景、弹出窗口、扩展协议)发送请求时,对扩展中任何文件的请求都会成功返回该文件。
接下来,让我们复制extra.html请求的URL(您的URL将与上面的截图不同),并在新的浏览器标签页中打开它。页面加载后,打开开发者工具(如图4-7所示)。
提示:一个简单的方法来完成这项操作是右键点击网络请求并选择“在新标签页中打开”。
浏览器正在从扩展文件服务器加载extra.html
,将其渲染为普通网页,并成功加载<script>
标签及其内容(这会导致对extra.html
的重复获取)。这里有一些关键要点需要注意:
- 由于浏览器扩展可以加载和渲染扩展中包含的任何文件,因此它们可以拥有无限数量的网页,包括那些清单中没有明确引用的网页。
- 特殊的
chrome-extension://
协议将请求路由到文件服务器。以这种方式渲染的页面可以在其脚本中使用WebExtensions API,并且还可以发送对扩展目录中任何文件的请求。
与内容脚本的执行相比,这种行为截然不同。请注意,清单被配置为在任何网页上注入内容脚本,因此接下来请将您的浏览器指向一个无活动的测试网站,如blank.org
,并查看控制台输出(图4-8,此处无法直接展示)。
首先,请注意,内容脚本不是通过网络请求来提供的。浏览器不是从扩展文件服务器加载它,而是直接将其注入到页面中。
观察到内容脚本正在尝试以两种不同的方式获取JS文件,并且每种方式都因为不同的原因而抛出错误:
第一次尝试动态导入
fetch-page.js
文件并执行它。动态导入成功是因为fetch-page.js
是一个可通过网络访问的资源。但是,由于这是在内容脚本中执行的,它无法加载HTML文件,因为该HTML文件没有被列为web_accessible_resources
中的可访问资源。这会导致“拒绝加载”错误。第二次尝试创建一个
<script>
元素来加载并执行fetch-page.js
文件。脚本加载成功是因为fetch-page.js
是一个可通过网络访问的资源。但是,加载的脚本的执行上下文没有被授予访问WebExtensions API的权限,因此尝试使用chrome.runtime.getURL
会抛出一个TypeError
。
尽管内容脚本仍然可以访问扩展文件服务器,但其访问权限受到了相当大的限制。
提示:扩展文件服务器是一种灵活的方式,可以使您的扩展表现得更像具有多个视图和路由的网页,但请求路由不支持重定向或自定义404页面等基本功能。
沙盒页面
在manifest v3中,与v2中的内容安全策略(CSP)中允许的值相比,限制要大得多。它新增了禁止在扩展页面中使用以下内容的规则:
- 内联脚本
- 远程加载的代码
eval()
函数- 用户提供的脚本
要使用这些工具,您必须在沙盒中运行一个扩展页面。这样做将允许页面使用上述被禁止的功能,但代价是——页面将失去对所有WebExtensions API的访问权限。
注意:除非另有指定,否则默认的沙盒内容安全策略是 sandbox allow-scripts allow-forms allow-popups allow-modals; script-src 'self' 'unsafe-inline' 'unsafe-eval'; child-src 'self';
。
以下示例将弹出页面定义为沙盒页面。在打开弹出页面时,内联脚本将无问题地执行eval()
函数:
示例 4-2a. manifest.json
{
"name": "MVX",
"version": "0.0.1",
"manifest_version": 3,
"action": {
"default_popup": "popup.html"
},
"sandbox": {
"pages": ["popup.html"]
}
}
示例 4-2b. popup.html
<!DOCTYPE html>
<html>
<body>
<h1>Popup</h1>
<script>
eval(`document.body.innerHTML += '<div>Foobar</div>'`);
</script>
</body>
</html>
总结
在本章中,您了解了浏览器扩展的所有元素如何协同工作的概述。有了这些知识,您现在应该能够分析给定扩展如何传递信息并进行API调用。本章还使您能够更有效地规划如何将浏览器扩展的想法转化为实际代码。您还应该很好地理解浏览器扩展文件服务器如何以各种方式提供文件,以及在声明沙盒页面时所涉及的权衡。
下一章将介绍可以出现在扩展清单中的所有字段,以及每个字段的定义如何控制扩展的行为。