系列文章可以查看《浏览器扩展程序开发笔记》专栏
Service Workers
在 Manifest V2 版本中,后台页面 background pages 是扩展程序中的一个独立页面,一般设置事件监听,以响应用户的操作,但是它会长期驻留后台影响性能。在 Manifest V3 版本中,后台脚本迁移到 Service Workers 中运行以提供性能,其中有两个特点:
- Service Workers 在执行事件处理函数后终止,并在新的事件触发下重新运行
- 它是一种 JavaScript Worker,无法直接访问 DOM。
💡 Service Worker 是浏览器完全独立于网页运行的脚本。除了以上的特点,还要注意以下的相关事项:
- 它是一种可编程网络代理,让您能够控制页面所发送网络请求的处理方式。
- 它广泛地利用了 promise 进行异步操作。
如果需要使用 service workers 时需要在配置清单 manifest.json
的选项 background.service_worker
中声明注册,属性值指定需要执行的一个 JavaScript 文档的路径(它必须在项目的根目录下)。
{
// ...
"background": {
"service_worker": "background.js"
},
}
后台脚本在 Service Workers 中运行基于监听事件-响应模型来执行操作,因此逻辑代码的编写也应该遵循该模型以优化性能:
-
在事件循环的第一轮中完成事件监听的注册,即将事件监听程序写在后台脚本最顶层的作用域中,而不应该内嵌在其他的逻辑代码中(因为 Service Workers 执行完代码会终止而不会长期驻留,当有事件需要分派时它才再次运行,如果未能在第一次事件轮询中注册监听器,这就无法响应事件)。
// background.js chrome.storage.local.get(["badgeText"], ({ badgeText }) => { chrome.action.setBadgeText({ text: badgeText }); }); // Listener is registered on startup chrome.action.onClicked.addListener(handleActionClick); function handleActionClick() { // ... }
-
由于 Service Workers 生命周期是短期的,如果需要持久化的数据(例如需要将用户输入的内容作为变量),可以使用 Chrome 为扩展程序提供的 Storage API
// background.js chrome.runtime.onMessage.addListener(({ type, name }) => { if (type === "set-name") { chrome.storage.local.set({ name }); } }); chrome.action.onClicked.addListener((tab) => { chrome.storage.local.get(["name"], ({ name }) => { chrome.tabs.sendMessage(tab.id, { name }); }); });
-
在 Manifest V2 版本中使用
setTimeout
或setInterval
实现延迟或定期操作,在 Manifest V3 版本的 Service Workers 中并不可行,因为 Service Worker 并不会长期驻留后台,当它终止时调度程序会注销计时器。我们应该使用 Alarms API 来替代,它用以安排代码定期运行或在未来的指定时间运行。它也需要在后台脚本最顶层的作用域中注册。// background.js chrome.alarms.create({ delayInMinutes: 3 }); // set an alarm, which will dispatch onAlarm event after 3 mins // listen the onAlarm event and react to it chrome.alarms.onAlarm.addListener(() => { chrome.action.setIcon({ path: getRandomIconPath(), }); });
Service Worker 实际上是一个 web worker,在浏览器中可以独立于网页运行,一般网页的执行上下文中都有全局变量 window
,可以通过它访问一些浏览器提供的 API,例如 IndexedDB、cookie、localStorage 等,但在 Service Worker 中没有该对象,因此有诸多限制,例如在该环境中无法访问 DOM,无法发起 XMLHttpRequest(但支持 fetch 功能),以下是应对限制的一些解决方法:
-
由于 Service Workers 无法访问 DOMParser API 以解析 HTML,我们可以通过
chrome.windows.create()
和crhome.tabs.create()
API 创建一个标签页,以提供一个具有 window 对象的环境,或借助一些库(如 jsdom 或 undom)弥补这个缺失。 -
在 Service Workers 中无法播放或捕获多媒体资源,可以通过
chrome.windows.create()
和crhome.tabs.create()
API 创建一个标签页,以提供一个具有 window 对象的环境,然后可以通过消息传递 message passing 在 service worker 中控制页面的多媒体播放。 -
虽然在 Service Worker 中无法访问 DOM,但可以通过
OffscreenCanvas
API 创建一个 canvas。关于 OffscreenCanvas 相关信息可以参考这里。// background.js // for MV3 service workers function buildCanvas(width, height) { const canvas = new OffscreenCanvas(width, height); return canvas; }
Message Passing
参考:
- Message passing
- 关于 Message Passing 官方示例
注入到页面的脚本 content scripts 是在网页运行的,它「脱离」了扩展程序,但可以使用信息传递 message passing 进行沟通(在 web page 的页面脚本 content script 和扩展程序之间),Chrome 除了提供简单的 API 进行一次性的请求-响应通讯,也提供复杂的 API 进行长连接通讯,还可以基于 ID 进行跨扩展程序的通讯。
💡 任何合法的 JSON 格式的数据都可以传递。
💡 甚至可以与本机系统进行通讯。
⚠️ 信息传递过程中需要考虑安全问题:
-
内容脚本 content scripts 更容易遭受恶意网页攻击,应该限制通过 content script 传递过来的信息,可以触发的操作范围
-
当扩展程序与外部资源进行通讯时,应该采取必要的行为避免发生跨站脚本攻击
chrome.tabs.sendMessage(tab.id, {greeting: "hello"}, function(response) { // WARNING! Might be injecting a malicious script! document.getElementById("resp").innerHTML = response.farewell; }); chrome.tabs.sendMessage(tab.id, {greeting: "hello"}, function(response) { // innerText does not let the attacker inject HTML elements. document.getElementById("resp").innerText = response.farewell; }); chrome.tabs.sendMessage(tab.id, {greeting: "hello"}, function(response) { // JSON.parse does not evaluate the attacker's scripts. let resp = JSON.parse(response.farewell); });
一次性请求
使用方法 chrome.runtime.sendMessage()
或方法 chome.tabs.sendMessage()
进行单次请求,这两个方法可以设置回调函数,默认接收返回的响应数据作为参数。
// 在页面脚本 content script 发送信息
chrome.runtime.sendMessage({greeting: "hello"}, function(response) {
console.log(response.farewell);
});
// 在扩展程序发送信息
// 需要先使用 query 获取 tabId,以指定该请求发送给哪个特定的 tab 标签页面
chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
// 发送到当前激活的标签页面
chrome.tabs.sendMessage(tabs[0].id, {greeting: "hello"}, function(response) {
console.log(response.farewell);
});
});
在接收端,植入页面的代码 content script 和扩展程序的后台代码 background script 都一样,需要使用方法 chrome.runtime.onMessage.addListener()
监听相应的事件以捕获请求。事件处理函数接收三个参数,第一参数是接收到的信息;第二个参数是一个对象,它包含这次 message 的发送方的相关信息;第三个参数是一个方法,发送响应内容。
chrome.runtime.onMessage.addListener(function(message, sender, sendResponse) {
console.log(sender.tab ?
"from a content script:" + sender.tab.url :
"from the extension");
if (message.greeting === "hello")
sendResponse({farewell: "goodbye"});
}
);
💡 以上事件处理函数中,发送响应是同步执行的,即接收到 message 触发事件处理函数后,执行到 sendResponse
函数时就会立即发送响应;如果希望异步发送响应(例如需要整合其他异步请求的数据),可以在 onMessage
事件处理函数的最后,显性返回 return true
,告知扩展程序响应会异步执行,这样信息通道 message channel 还会保持打开,直到 sendResponse
被调用。
💡 如果在不同页面设置了多个 onMessage
事件监听器,为了保证只有一次响应,对于每次 message 请求扩展程序整体都只会最多执行一次 sendResponse()
方法,其他事件处理函数中的响应函数都会被忽略。
长连接
可以使用方法 chrome.runtime.connect()
或方法 chrome.tabs.connect()
为内容脚本 content script 和扩展程序之间建立一个长连接(可以为信息通道 channel 设置名称,以区别多个通道)。
使用以上方法创建通道后,会返回一个 runtime.Port
端口对象,其中包括了关于信息通道的相关方法和属性,然后就可以通过该通道发送 portObj.postMessage()
和接收 portObj.onMessage.addListener()
信息。
// 在页面脚本 content script 建立长连接的信息通道
let port = chrome.runtime.connect({name: "knockknock"});
// 通过该端口发送信息
port.postMessage({joke: "Knock knock"});
// 设置事件监听器,通过该端口接收信息,将接收到的信息作为入参
port.onMessage.addListener(function(msg) {
if (msg.question === "Who's there?")
port.postMessage({answer: "Madame"});
else if (msg.question === "Madame who?")
port.postMessage({answer: "Madame... Bovary"});
});
类似地,如果在扩展程序中建立长连接发送消息时,使用方法 chrome.tabs.connect()
,需要指定请求是发送给哪个特定的 tab 标签页。
信息通道是双向的,因此除了发起端创建端口,还需要在接收端使用方法 chrome.runtime.onConnect()
响应通道连接请求(在内容脚本 content script 和扩展程序中一样)。当通道发起端口调用 connect 方法时,接收端的监听器就会调用回调函数,它将相应的 runtime.Port
端口对象作为入参,然后可以使用该端口在通道中发送和接收消息,这样通道两端的接口就可以相互接收和发送信息了。
chrome.runtime.onConnect.addListener(function(port) {
console.assert(port.name === "knockknock");
port.onMessage.addListener(function(msg) {
if (msg.joke === "Knock knock")
port.postMessage({question: "Who's there?"});
else if (msg.answer === "Madame")
port.postMessage({question: "Madame who?"});
else if (msg.answer === "Madame... Bovary")
port.postMessage({question: "I don't get it."});
});
});
端口生命周期
信息通道连接是长期的,但也可能因为以下原因断开:
-
在接收端没有设置监听器
chrome.runtime.onConnect()
无法成功建立通道 -
使用
chrome.tabs.connect()
创建通道时,期望连接的端口所在的页面不存在 -
All frames that received the port (via runtime.onConnect) have unloaded.
-
端口对象调用了方法
chrome.runtime.Port.disconnect()
结束连接💡 如果发起端口调用 connect 后,建立多个接收端口(创建多个信息通道),在其中一个接收端口调用 disconnect 时,则
onDisconnect
事件只会在发起端口触发,其他端口并不会触发。
可以使用方法 port.onDisconnect()
监听该端口的断连事件(其中 port
是端口对象),事件处理函数的入参是该端口对象。
跨扩展程序的通讯
除了在扩展程序内进行信息传递,还可以使用类似的 messaging API 在不同扩展程序间进行通讯。
发送请求信息时,必须提供扩展程序的 ID,以便其他扩展程序判断是否作出响应。使用方法 chrome.runtime.sendMessage(id, message)
发送一次性的请求;使用方法 chrome.runtime.connect(id)
发起通道连接请求,并返回端口对象。
// The ID of the extension we want to talk to.
const laserExtensionId = "abcdefghijklmnoabcdefhijklmnoabc";
// Make a simple request:
chrome.runtime.sendMessage(laserExtensionId, {getTargetData: true},
function(response) {
if (targetInRange(response.targetData))
chrome.runtime.sendMessage(laserExtensionId, {activateLasers: true});
}
);
// Start a long-running conversation:
let port = chrome.runtime.connect(laserExtensionId);
port.postMessage(...);
对于一次性的请求,使用方法 chrome.runtime.onMessageExternal()
进行监听并作出响应;对于长连接,使用方法 chrome.runtime.onConnectExternal()
响应连接,并使用端口对象接收和发送信息。
// For simple requests:
chrome.runtime.onMessageExternal.addListener(
function(request, sender, sendResponse) {
if (sender.id === blocklistedExtension)
return; // don't allow this extension access
else if (request.getTargetData)
sendResponse({targetData: targetData});
else if (request.activateLasers) {
var success = activateLasers();
sendResponse({activateLasers: success});
}
});
// For long-lived connections:
chrome.runtime.onConnectExternal.addListener(function(port) {
port.onMessage.addListener(function(msg) {
// See other examples for sample onMessage handlers.
});
});
网页通讯
类似于跨扩展程序间通讯,一般的网页可以发送信息给扩展程序。而扩展程序需要在配置清单 manifest.json
的选项 externally_connectable.matches
中声明注册,希望与哪些外部网页进行连接(可以使用正则表达式,以支持一系列符合一定规则的网页,但至少包含二级域)
{
// ...
"externally_connectable": {
"matches": ["https://*.example.com/*"]
}
}
在网页使用方法 chrome.runtime.sendMessage()
或方法 chrome.runtime.connect()
发送信息(通过 ID 来指定与哪一个扩展程序进行通讯)
// The ID of the extension we want to talk to.
const editorExtensionId = "abcdefghijklmnoabcdefhijklmnoabc";
// Make a simple request:
chrome.runtime.sendMessage(editorExtensionId, {openUrlInEditor: url},
function(response) {
if (!response.success) handleError(url);
});
在扩展程序使用方法 chrome.runtime.onMessageExternal()
或方法 chrome.runtime.onConnectExternal()
chrome.runtime.onMessageExternal.addListener(
function(request, sender, sendResponse) {
if (sender.url === blocklistedWebsite)
return; // don't allow this web page access
if (request.openUrlInEditor) openUrl(request.openUrlInEditor);
});
💡 当扩展程序与一般的网页通讯时,长连接只能通过网页端发起。
Permissions
参考:
限制扩展程序的权限,不仅可以降低扩展程序被恶意程序利用的可能性;还可以让用户有主动选择权,决定应该给扩展程序授予哪些权限可选。
扩展程序为了可以使用部分 Chrome APIs 和访问外部网页,需要在配置清单 manifest.json
中以显式声明所需的权限,有三种选项以声明不同的权限:
permissions
用于设置必要权限许可 required permissions,所有可以使用的 permissions 字段可以在这里查看,以数组的形式包含多个字段。为了实现扩展程序的基本功能,在安装时询问用户optional_permissions
用于设置可选权限许可 optional permissions,也是以以数组的形式包含多个字段。为了实现某些可选的功能(一般是访问特定的数据或资源),在扩展程序运行时才询问用户,以获取权限许可。host_permissions
专门用于设置扩展程序可以去访问哪些主机的权限,包含一系列用于匹配 url 的正则表达式
然后浏览器会在扩展程序安装时或在运行时,询问用户是否允许它获取相应的权限、访问特定的资源,让用户可以有主动权去保护自己的数据,具体哪些权限会通知用户或是询问让用户主动选择,可以查看这里。
{
// ...
"permissions": [
"tabs",
"bookmarks",
"unlimitedStorage"
],
"optional_permissions": [
"unlimitedStorage"
],
"host_permissions": [
"http://www.blogger.com/",
"http://*.google.com/"
],
}
💡 在配置清单 manifest.json
里所有可以声明的 permissions 字段可以在这里查看,但是部分字段并不能在可选权限 optional_permissions
选项中声明:
debugger
declarativeNetRequest
devtools
experimental
geolocation
mdns
proxy
tts
ttsEngine
wallpaper
除了可以在配置清单中设置可选权限许可,还可以在扩展程序的逻辑代码中,基于用户主动交互(例如在按钮的点击事件的处理函数中)中使用方法 chrome.permissions.request()
动态申请。如果申请的权限会触发警告提示,则会弹出一个权限提示框询问用户许可,并等待结果返回再执行后续的代码
document.querySelector('#my-button').addEventListener('click', (event) => {
// Permissions must be requested from inside a user gesture, like a button's
// click handler.
chrome.permissions.request({
permissions: ['tabs'],
origins: ['https://www.google.com/']
}, (granted) => {
// The callback argument will be true if the user granted the permissions.
if (granted) {
doSomething();
} else {
doSomethingElse();
}
});
});
如果不再需要某项权限时,可以使用方法 chrome.permissions.remove()
删除该权限,如果之后再使用方法 chrome.permissions.request()
动态添加相应的权限,则不必再告知用户
chrome.permissions.remove({
permissions: ['tabs'],
origins: ['https://www.google.com/']
}, (removed) => {
if (removed) {
// The permissions have been removed.
} else {
// The permissions have not been removed (e.g., you tried to remove
// required permissions).
}
});
💡 可以使用方法 chrome.permissions.contains()
查看扩展程序当前是否拥有某项权限,使用方法 chrome.permissions.getAll()
获取扩展程序当前具有的所有权限
chrome.permissions.contains({
permissions: ['tabs'],
origins: ['https://www.google.com/']
}, (result) => {
if (result) {
// The extension has the permissions.
} else {
// The extension doesn't have the permissions.
}
});
💡 有一些权限获取时,会弹出警告提示,你可以在这里查看相关的权限。
为了避免在安装扩展程序时,由于弹出的过多的警告而降低了安装量,应该在配置清单 manifest.json
的选项 permissions
中只声明核心功能所必需的权限,而尽量包含需要弹出警告的权限,而应该将这些权限在选项 optional_permissions
中声明,然后使用交互控件,如按钮或 checkbox,让用户主动激活可选功能,此时才弹出权限警告框,让用户主动选择是否授权,这样的交互体验会更佳。
💡 如果更新扩展程序时,新增了 permissions 会导致扩展程序临时无法激活,需要用户再起允许授权才可以,如果将这些新增的 permissions 放在选项 optional_permissions
则可以避免这个不好的体验。
💡 声明 activeTab
权限可以临时获取许可访问当前激活的标签页,并在当前页面使用 tabs 相关的 API(当导航到其他 url 或关闭当前页面后,该路径的可访问性就失效),一般用以替代 <all_urls>
权限,达到访问任意 url 的当前页面,而且不会在安装扩展程序时弹出警告。
💡 对于在「开发者模式」下「加载已解压的扩展程序」,并不会展示权限警告提示。如果希望查看警告提示的效果,可以相对开发的扩展程序进行打包,具体步骤请参考官方指南。
Storage
扩展程序可以在浏览器中存储和检索数据。
Chrome 浏览器为拓展出现提供数据存储 API chrome.storeage
,该 API 提供类似 localStorage 的功能,但也有一些不同:
- 使用
chrome.storage.sync
相关方法,就可以利用 Chrome 的同步功能,实现同一账户下的扩展程序数据在多个设备之间同步。💡 如果已登录账户的 Chrome 离线时,希望同步存储的数据会先进行本地存储,等待浏览器上线后再进行同步。如果用户在 Chrome 设置中取消了数据同步功能,那么chrome.storage.sync
相关方法的作用和chrome.storage.local
一样 - 批量的读取和写入数据操作是异步执行的,因此与 localStorage 引起的阻塞和串行相比操作更快
- 存储的数据类型可以是对象,而 localStorage 只允许存储字符串
- Enterprise policies configured by the administrator for the extension can be read (using
storage.managed
with a schema). Thestorage.managed
storage is read-only.
💡 有三种不同的存储域 StorageArea
sync
同步存储的数据local
本地存储的数据managed
管理员设置的数据,只读
如果要使用该 API 需要先在配置清单 manifest.json
的选项 permissions
中声明注册权限
{
// ...
"permissions": [
"storage"
],
}
存储扩展程序的数据时,以键值对的形式(数据格式是对象),可以使用方法 chrome.storage.local.set({key: value}, callback())
存储在本地,或使用方法 chrome.storage.sync.set({key: value}, callback())
进行同步存储,相应地分别使用方法 chrome.storage.local.get()
或 chrome.storage.sync.get()
获取对应 key 的数据
// sync
chrome.storage.sync.set({key: value}, function() {
console.log('Value is set to ' + value);
});
chrome.storage.sync.get(['key'], function(result) {
console.log('Value currently is ' + result.key);
});
// local
chrome.storage.local.set({key: value}, function() {
console.log('Value is set to ' + value);
});
chrome.storage.local.get(['key'], function(result) {
console.log('Value currently is ' + result.key);
});
💡 获取 get 数据时,不仅可以传入 key 字符串,也可以传入 keys 数组,以返回对应的一系列数据。如果获取 get 数据时,传入的 key 为 null
则会返回所有存储的数据。
💡 如果想删除某个 key 的数据,可以使用方法 chrome.storage.remove(key, callback())
。如果要清空所有存储的数据,可以使用方法 chrome.storage.clear(callback())
⚠️ 与用户相关机密数据请不要进行存储,因为使用该 API 所存储的数据并不会加密。
该 API 允许存储的数据并不大,对于 sync 同步存储的数据,允许总大小为 100KB,最多存储 512 个数据项,每项大小为 8KB;对于 local 本地存储的数据,允许总大小为 5MB(类似于 localstorage 的存储限制),因此它们一般用作存储、同步扩展程序的设置。
当扩展程序存储的数据发生改变时,会触发 onChange
事件,可以对其进行监听以作出响应
// background.js
chrome.storage.onChanged.addListener(function (changes, namespace) {
for (let [key, { oldValue, newValue }] of Object.entries(changes)) {
console.log(
`Storage key "${key}" in namespace "${namespace}" changed.`,
`Old value was "${oldValue}", new value is "${newValue}".`
);
}
});