浏览器插件开发必备概念——通用技术

3,907 阅读15分钟

系列文章可以查看《浏览器扩展程序开发笔记》专栏


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 版本中使用 setTimeoutsetInterval 实现延迟或定期操作,在 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 对象的环境,或借助一些库(如 jsdomundom)弥补这个缺失。

  • 在 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

参考:

注入到页面的脚本 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.
  }
});

💡 有一些权限获取时,会弹出警告提示,你可以在这里查看相关的权限

permission-warning.png

为了避免在安装扩展程序时,由于弹出的过多的警告而降低了安装量,应该在配置清单 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). The storage.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}".`
    );
  }
});