浏览器扩展:新的疆域
您可能听说过 浏览器扩展 - 也许您自己也写过。Firefox 中构建扩展的技术已现代化以支持 Web 标准,这也是 Firefox Quantum 将成为有史以来最快速和最稳定版本的原因之一。
使用新的 WebExtensions API 构建的扩展与所有现代浏览器兼容,这意味着您可以编写一个代码库,该代码库可以在多个浏览器中运行,就像您编写网站一样。
今天,我将谈论我从使用 WebExtensions API 编写我的第一个扩展中学到的知识 - 具体来说,我认为浏览器扩展和传统 Web 应用程序之间最大的概念差异(也是最常见的开发者陷阱)是什么。我将用一些来自我开发 Lightbeam 的经验中的实际示例和技巧来说明。
什么是 Lightbeam?
Lightbeam - 以前是一个传统插件 - 是一款隐私浏览器扩展,它可以将您访问的网站与可能跟踪您的第三方之间的连接可视化。它的工作原理是监听、捕获、存储并最终显示您在浏览网络时每个网站发出的请求。
什么是浏览器扩展?
浏览器扩展 使您能够编写使用熟悉的前端技术拥有浏览器超能力的 Web 应用程序。
传统 Web 应用程序受到浏览器 沙箱 的限制:脚本只能以单个网页的权限运行,而浏览器扩展脚本可以以浏览器的某些权限运行。这也许是浏览器扩展和传统 Web 应用程序之间最大的区别。例如,如果 Lightbeam 是一个传统的 Web 应用程序,它只能看到自己的请求;然而,作为一个浏览器扩展,它可以看到所有网站发出的所有请求。
陷阱
直到我们在实际中遇到这个问题,我们的团队才完全意识到这一点:我们试图在应用程序的 index.html
文档中使用 <script>
标签包含所谓的 后台脚本 用于存储。在我们的案例中,我们错误地假设我们可以通过这种方式从存储中获取数据来更新我们的可视化页面。实际上,我们无意中加载了此存储脚本的两个实例,一个在显示可视化的页面中使用 <script>
标签,另一个通过在我们的浏览器扩展的 清单 文件中包含相同的脚本,这两个实例没有同步。您可以想象,存在错误,很多错误。
虽然 MDN 确实试图解释这些脚本彼此之间的区别,但对于来自 Web 开发背景的人来说,浏览器扩展可能有点复杂。在这里,我们将讨论实际意义,希望可以免除未来的浏览器扩展开发人员的这种挫折!
那么所有这些脚本之间有什么区别?
浏览器扩展有两种独特的脚本: 内容脚本 和 后台脚本,它们与我们所熟知的页面脚本一起运行。
内容脚本
内容脚本通过浏览器扩展的 清单 文件或通过 tabs
WebExtensions API 使用 tabs.executeScript()
加载。
由于我们在 Lightbeam 中没有使用内容脚本,因此以下是如何使用另一个浏览器扩展 Codesy 的清单文件加载内容脚本的示例。
"content_scripts": [
{
"all_frames": false,
"js": [
"js/jquery-3.2.0.min.js",
"js/issue.js"
],
"matches": [
"*://*.github.com/*"
]
}
],
从清单中可以看出,我们要求将一组指定的内容脚本(jquery-3.2.0.min.js
和 issue.js
)注入到与一组 URL(任何 github.com
URL)匹配
的任何文档中。
内容脚本在特定网页的上下文中运行 - 换句话说,它们在具有匹配 URL 的选项卡加载时执行,并在该选项卡关闭时停止。
内容脚本与扩展的页面和脚本不共享相同的来源。相反,它们使用沙箱机制加载到窗口中,并且有权访问和修改大多数选项卡中加载的网页的 DOM(关于:* 页面是一个明显的例外)。需要注意的是,由于内容脚本与网页脚本隔离,因此它们无法访问相同的范围。因此,内容脚本使用 DOM 的“干净视图”。这确保了内容脚本使用的所有内置 JavaScript 方法都不会被任何网站的页面脚本覆盖。
除了能够读取页面的 DOM 之外,内容脚本还可以有限地访问 WebExtensions API。
内容脚本有很多用途。例如,Codesy 使用它的 issue.js
内容脚本将 <iframe>
元素插入到 GitHub 页面中。反过来,这个 <iframe>
加载一个包含表单的 Codesy 页面,用户可以填写并提交以使用 Codesy 服务。内容脚本也可以将脚本元素直接注入到页面的 DOM 中,就好像页面本身加载了脚本一样 - 一个常见的用例是与内容脚本沙箱中不可用的事件进行交互。注入到页面的脚本无法访问浏览器 WebExtensions API(它们与网页加载的任何其他脚本相同)。
后台脚本与扩展页面脚本
现在我们已经解决了内容脚本,让我们来谈谈 Lightbeam!
在 Lightbeam 中,大多数内容作为从扩展中加载的网页运行。此页面中的脚本(由于没有更好的术语,我将称之为“扩展页面脚本”)运行 UI,包括可视化。当用户按下浏览器工具栏中的 Lightbeam 图标时,此页面会在选项卡中加载,并在用户关闭选项卡之前一直运行。
除了此页面之外,我们还使用后台脚本。安装扩展时会自动加载后台脚本。在 Lightbeam 中,后台脚本捕获、过滤和存储 Lightbeam 的可视化所使用的请求数据。
虽然扩展页面脚本和后台脚本都可以访问 WebExtensions API(它们共享相同的 moz-extension://
来源),但它们在许多其他方面有所不同。
包含
以下是如何在浏览器扩展中包含扩展页面脚本。
<script src="js/lightbeam.js" type="text/javascript"></script>
换句话说,浏览器扩展的扩展页面脚本与在网页上下文中运行的普通页面脚本非常相似。显着区别在于扩展页面脚本可以访问 WebExtensions API。
相比之下,您可以通过将后台脚本添加到扩展的清单文件中来将后台脚本包含在浏览器扩展中。
"background": {
"scripts": [
"js/store.js"
]
}
生命周期
扩展页面脚本在应用程序的上下文中运行:它们在扩展页面加载时加载,并在扩展页面关闭时持续存在。
相比之下,后台脚本在浏览器上下文中运行。它们在扩展安装时加载,并在扩展禁用或卸载之前持续存在,与任何特定页面或浏览器窗口的生命周期无关。
范围
考虑到这些不同的上下文和生命周期,扩展页面脚本和后台脚本不共享相同的全局范围也就不足为奇了。换句话说,您不能直接从扩展页面脚本调用后台脚本方法,反之亦然。谢天谢地,有一个 WebExtensions API 可以做到这一点!
如何在不同类型的脚本之间进行通信
我们使用通过 runtime WebExtensions API 进行的异步消息传递来在我们的扩展页面脚本和后台脚本之间进行通信。
为了说明这一点,让我们逐步了解 Lightbeam 的“重置数据”功能的每个步骤。
从宏观上讲,当用户单击“重置数据”按钮时,Lightbeam 的所有数据都将从存储中删除,并且应用程序将重新加载以更新 UI 中的可视化。
在我们的 lightbeam.js
扩展页面脚本中,我们
- 向重置按钮添加
click
事件处理程序 - 单击重置按钮时
- 清除存储中的数据
- 重新加载页面
// lightbeam.js
const resetData = document.getElementById('reset-data-button');
// 1. Add a ‘click’ event handler to the reset button
resetData.addEventListener('click', async () => {
// 2. When the reset button is clicked:
// 2.a. Reset the data in storage
await storeChild.reset();
// 2.b. Reload the page
window.location.reload();
});
storeChild
是另一个扩展页面脚本,它向 store
后台脚本传递一条消息以清除我们所有数据。我们稍后会回到 storeChild
,但现在让我们先谈谈 store
中需要发生的事情。
为了让 store
接收来自任何扩展页面脚本的消息,它必须监听一条消息,因此让我们使用 runtime
WebExtensions API 在 store
中设置一个 onMessage
监听器。
在我们的 store.js
后台脚本中,我们
- 添加
onMessage
监听器 - 收到消息时
- 清除存储中的数据
// store.js background script
// 1. Add an `onMessage` listener
browser.runtime.onMessage.addListener(async () => {
// 2. When the message is received
// 2.a. Clear the data in storage
await this.reset();
});
async reset() {
return await this.db.websites.clear();
},
现在我们已经处理了 lightbeam.js
扩展页面脚本和 store.js
后台脚本,让我们来讨论 storeChild
在其中的作用。
关注点分离
回顾一下,我们的 Lightbeam 扩展页面脚本监听“重置数据”按钮上的 click
事件,调用 storeChild.reset()
,然后重新加载应用程序。storeChild
是一个扩展页面脚本,它使用 runtime
WebExtensions API 向 store
后台脚本发送“重置”消息。您可能想知道为什么我们不能直接在 lightbeam.js
和 store.js
之间进行通信。简短的回答是,虽然我们可以做到,但我们希望遵循被称为“关注点分离”的软件设计原则。
基本上,我们希望我们的 Lightbeam 扩展页面脚本 lightbeam.js
只处理与 UI 相关的功能。同样地,我们希望我们的 store.js
后台脚本只处理存储功能。(当然,我们必须将后台脚本用于存储,以便网络数据在会话之间保持一致!)。那么,最好设置一个中间件 storeChild
,它承担在 lightbeam.js
和 store.js
之间进行通信的单独的职责。
为了完成我们“重置数据”功能的链路,我们需要在storeChild.js
中将lightbeam.js
中的reset
调用转发到store.js
,方法是向store.js
发送消息。由于reset
只是我们想要从store.js
后台脚本访问的众多潜在方法之一,因此我们将storeChild
配置为store
的代理对象。
什么是代理对象?
storeChild.js
执行的主要任务之一是代表lightbeam.js
扩展页面脚本调用store.js
方法,例如reset
。在Lightbeam中,reset
只是我们想要从扩展页面脚本访问的许多store.js
方法之一。我们不想在storeChild.js
中重复store.js
中的每个方法,因此希望能够将这些调用泛化。这就是代理对象的用武之地!
const storeChildObject = {
parentMessage(method, ...args) {
return browser.runtime.sendMessage({
type: 'storeCall',
method,
args
});
}
// ...other methods
};
const storeChild = new Proxy(storeChildObject, {
get(target, prop) {
if (target[prop] === undefined) {
return async function(...args) {
return await this.parentMessage(prop, ...args);
};
} else {
return target[prop];
}
}
});
代理对象对于浏览器扩展非常有用,因为它允许我们遵循软件设计原则:“不要重复自己”。
在Lightbeam中,storeChild
在扩展页面上下文中充当store
的代理对象。这意味着当lightbeam.js
扩展页面脚本需要调用store.js
方法(例如store.reset
)时,由于它无法直接访问该方法,因此它将改为调用storeChild.reset
(它可以直接访问)。我们不会在storeChild
中重复reset
方法,而是设置了一个代理对象。因此,如果storeChild
没有特定方法,它会将该方法调用和任何参数通过消息传递传递给store
。
web-ext
CLI
现在我们已经讨论了最重要的,也是可以说最令人困惑的浏览器扩展概念,以及如何将这些知识应用到实际中,我鼓励你编写自己的浏览器扩展!在你开始之前,让我提供最后一条建议。
你可能已经熟悉实时重载开发工具,在这种情况下,你会很高兴听到浏览器扩展也存在这样的工具!
web-ext
是由 Mozilla 创建并积极开发的,非常有用的浏览器扩展 CLI。
除了众多有用的功能外,web-ext
还允许你
- 在本地进行开发和测试,并实时重载。
- 指定要运行浏览器扩展的 Firefox 版本。
- 当你准备发布时,将你的浏览器扩展导出为XPI。
我们接下来该做什么?
Web正处于激动人心的时刻,我们预计随着浏览器扩展的互操作性不断提高,它们会变得更加流行。了解这些概念并使用这些技术和工具确实帮助我们的团队创建了迄今为止最现代的 Lightbeam,我们希望它也能帮助你!
鸣谢
感谢 Paul Theriault、Jonathan Kingston、Luke Crouch 和 Princiya Sequeira 对本文的审阅。
关于 Bianca Danforth
Bianca 是 Mozilla 的一名网络开发人员,她编写浏览器扩展来测试 Firefox 的新想法和功能。在构建 Web 应用程序之前,她曾构建步进电机和科学展品。她只是喜欢构建东西!
4 条评论