Chrome 插件开发——进阶

1,208 阅读9分钟

安装、调试与发布

安装

开发中的插件其实就是一个根目录下包含 manifest.json 的文件夹,它不能像 Chrome 商店中的插件那样一键安装,要遵循以下步骤:
  1. 打开插件管理页面 ​chrome://extensions​
  2. 开启 Developer mode
  3. 导入文件夹(点击 Load Unpack 或者直接拖拽至管理面板)
如果对插件有更改,在管理页面点击刷新图标即可。

调试

和普通页面一样,只要找到对应的插件页面运行窗口,就可以打开 DevTools 进行调试,也支持构建工具。插件各页面的运行窗口,可回顾前文「插件页面和脚本」部分。

如果你已经安装的插件代码发生了变化 ,你可以在插件管理页面点击刷新图标手动刷新,或者在插件里调用下面的 API 重新加载插件:

window.chrome.runtime.reload();
值得注意的是,由于 popup 页有失焦即关闭的特点,只能通过右键 inspect 元素或 inspect popup 的方式DevlTools。打开 DevlTools 后,即使 popup 页失焦也不会自动关闭。如果不是必须在弹出的场景下调试,也可在浏览器地址栏中直接输入 popup 页的 URL,像普通的 tab 页一样打开。

Content script 在所依附的 web 页面中调试。打开 web 页面 DevTools,Sources 目录下的 Content scripts 列出了所有注入到当前 web 页面中的 content script。

当插件模块运行出错时,除了 DevTools,你也可以进入插件管理页面,查看 Errors 错误列表。

发布

通过 Chrome 商店 发布插件是首选。如果插件未通过 Chrome 商店审核,则有被 Chrome 浏览器禁用戓删除的风险,通过启用开发者模式以文件夹安装的插件例外。首次发布时,Chrome 商店会为用 OpenSSL 为插件生成 RSA 私钥,再加密这个私钥生成 ID,做为插件的唯一标识符,以后更新时也会保持这个 ID 不变。

Chrome 商店有 3 种发布模式,审核一般需要 3-7 天,或者更长,其严格程度随公开程度递减:

如果确实无法成功发布在 Chrome 商店里,就只能在开发者模式下直接安装插件文件夹,而非打包后的 CRX 文件,因为 Chrome 浏览器不会运行未经审核的 CRX 文件。

更新

发布在 Chrome 商店上的插件支持自动更新。如果你开发的插件有新版本,在商店中修改已有插件,上传新的插件即可,更新也需要审核。Chrome 浏览器定期检查已安装的插件是否有新版本,如果有则自动更新,用户体验最好。你也可以在 manifest.json 中通过 update_url 自定义更新服务,但 windows 不支持。

API 概览

想要对插件支持的功能有一个概览,最直接的方式就是查看 manifest.json 文档

{
  "manifest_version": 2,
  "name": "My Extension",
  "version": "versionString",

  "default_locale": "en",
  "description": "A plain text description",
  "icons": {...},

  "browser_action": {...},
  "page_action": {...},

  "action": ...,
  "author": ...,
  "automation": ...,
  "background": {
    "persistent": false,
    "service_worker":
  },
  "chrome_settings_overrides": {...},
  "chrome_url_overrides": {...},
  "commands": {...},
  "content_capabilities": ...,
  "content_scripts": [{...}],
  "content_security_policy": "policyString",
  "converted_from_user_script": ...,
  "current_locale": ...,
  "declarative_net_request": ...,
  "devtools_page": "devtools.html",
  "differential_fingerprint": ...,
  "event_rules": [{...}],
  "externally_connectable": {
    "matches": ["*://*.example.com/*"]
  },
  "file_browser_handlers": [...],
  "file_system_provider_capabilities": {
    "configurable": true,
    "multiple_mounts": true,
    "source": "network"
  },
  "homepage_url": "http://path/to/homepage",
  "host_permissions": ...,
  "import": [{"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}],
  "incognito": "spanning, split, or not_allowed",
  "input_components": ...,
  "key": "publicKey",
  "minimum_chrome_version": "versionString",
  "nacl_modules": [...],
  "natively_connectable": ...,
  "oauth2": ...,
  "offline_enabled": true,
  "omnibox": {
    "keyword": "aString"
  },
  "optional_permissions": ["tabs"],
  "options_page": "options.html",
  "options_ui": {
    "chrome_style": true,
    "page": "options.html"
  },
  "permissions": ["tabs"],
  "platforms": ...,
  "replacement_web_app": ...,
  "requirements": {...},
  "sandbox": [...],
  "short_name": "Short Name",
  "signature": ...,
  "spellcheck": ...,
  "storage": {
    "managed_schema": "schema.json"
  },
  "system_indicator": ...,
  "tts_engine": {...},
  "update_url": "http://path/to/updateInfo.xml",
  "version_name": "aString",
  "web_accessible_resources": [...]
}

由于大部分插件 API 都需在 manifest.json 中的 permissions 里声明,我们还可以通过 permissions 支持的选项来发现更多 API。Permissions 文档 里列出了 69 个选项,我们随机选取了一些做为示例:

​"alarms"​
Gives your extension access to the chrome.alarms API.
​"bookmarks"​
Gives your extension access to the chrome.bookmarks API.
​"clipboardRead"​
Required if the extension or app uses ​document.execCommand('paste')​.
​"contextMenus"​
Gives your extension access to the chrome.contextMenus API.
​"cookies"​
Gives your extension access to the chrome.cookies API.
​"declarativeNetRequest"​
Gives your extension access to the chrome.declarativeNetRequestAPI.
​"desktopCapture"​
Gives your extension access to the chrome.desktopCapture API.
​"notifications"​
Gives your extension access to the chrome.notifications API.
​"sessions"​
Gives your extension access to the chrome.sessions API.
​"storage"​
Gives your extension access to the chrome.storage API.
​"system.cpu"​
Gives your extension access to the chrome.system.cpu API.
​"webRequest"​
Gives your extension access to the chrome.webRequest API.

并非所有插件模块都能使用这些 API。如果 API 文档里没有明示 API 的使用范围,最实用的方法就是直接尝试。如果配了 permissions 却仍然不能使用 API,一般就是受使用范围的限制。Content script 就只能访问少量插件 API,但可以通过消息传递机制让父插件的 background 页去执行。

最常用的是通信 API,这部分我们将在下一节「消息传递」中详细阐述。至于其它 API 用法(如利用 chrome.runtime.getManifest 获取插件版本信息),遇到具体的场景时再去网络上寻找答案。

通信机制

插件的不同模块构成了一个整体,它们之间免不了相互通信,协作完成插件的功能。我们先通过下图对插件消息传递方式有一个总体认识,具体涉及的 API 后面再解释。

消息传送

消息内容可以是任意类型的 JSON 对象,每一方都可以监听从另一方发送出来的消息。监听方,也就是消息接收者,也可以对消息产生响应。如果有多个消息接收者对同一个消息产生了响应,消息发送方只会收到最先发出的响应。
消息传送是最常用的通信方式。由于运行环境不同,插件页面和 Content script 之间,只能采用这种方。实际上,消息传送并不局限于这种场景,插件其它页面之间也常用这种方式互相通信。如果知道某个插件的 ID,插件和插件之间还能进行消息传送。
那么插件能与 web 页面进行通信吗?答案是能。由于 content script 和 web 页面在同一个进程中,二者可以借助 window.postMessage 通信。有了 content script 作为桥梁,web 页面就可以间接地和插件的其它模块进行通信。插件和 web 页面也能直接通信,像插件与插件之间一样。但这需要 web 页面知道插件的 ID,还要在插件中设置 externally_connectable,不灵活。

一次性消息

大多数场景下,你要发送的消息都是一次性的,不需要保持会话。可以使用 runtime.sendMessage 或 tabs.sendMessage,这是最简单的方式,插件的其它模块都会收到这则消息。

发送消息时,根据接收对象使用不同的 API:

// 发送消息至插件页面(不包括 Content script)
chrome.runtime.sendMessage({greeting: "hello"}, function(response) {
  console.log(response.farewell);
});

// 发送消息至当前 Web 页面的 Content script
chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
  chrome.tabs.sendMessage(tabs[0].id, {greeting: "hello"}, function(response) {
    console.log(response.farewell);
  });
});
监听消息时则使用同一个 API:
// 同步响应
chrome.runtime.onMessage.addListener(
  function(request, sender, sendResponse) {
    if (request.greeting == "hello")
      sendResponse({farewell: "goodbye"});
  });

// 异步响应,需要返回 true,否则 sendResponse 会被回收
chrome.runtime.onMessage.addListener(
  function(request, sender, sendResponse) {
    if (request.greeting == "hello") {
       setTimeout(() => {
          sendResponse({farewell: "goodbye"});
       }, 200);
       return true;
    }
});

保持会话

插件中的消息实际上都是通过通道(Port)进行传输的,你可以调用 runtime.connect 来建立长时间的双向会话。如果给会话指定了 name,还能区分不同的会话。

建立会话:

var 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"});
});
注:与一次性消息类似,如果连接对象是 content script ,则应使用 chrome.tabs.connect,指定具体web 页面中的 content script。
调用 connect() 建立会话时,会触发 runtime.onConnect 事件,以监听会话:
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."});
  });
});

直接调用

同时运行在父插件进程中的页面,可以直接使用对方的全局变量,无需经过消息传递。获取父插件页面的引用,有以下两种方式:

  1. chrome.extension.getViews() —— 默认获取父插件中运行的所有页面,可指定页面类型
  2. chrome.extension.getBackgroundPage() —— 获取父插件的 background 页面
下例中,popup 页面直接访问了 background 页面定义的全局变量:
// background.js
const bgName = 'test';

let counter = 0;
const generateId = () => ++ counter;

// popup.js
const bg = chrome.extension.getBackgroundPage();
console.log(bg.bgName); // 输出 test
console.log(bg.generateId()); // 输出 1

实践总结

监控 web 页面 xhr 请求

由于 content script 的运行环境和 web 页面是隔离的,所以它们发出的 xhr 请求也是隔离的,彼此无法感知。但是别忘了,content script 可以改变 web 页面的 DOM 元素,而 script 标签也是 DOM 元素的一种,所以你可以通过插入 script 脚本的方式重写 web 页面的 xhr 函数。类似地,通过注入 script 元素,你能获取 web 页面的其它全局变量,或者 React 组件的数据。
// content_script.js
// 改写原生的 xhr 代码
const scriptContent = `(function() {
    var proxied = window.XMLHttpRequest.prototype.open;
    window.XMLHttpRequest.prototype.open = function() {
        // 将请求参数传送给 content script
        window.postMessage({type: 'openXhr', args: arguments}, '*');
        return proxied.apply(this, [].slice.call(arguments));
    };
})()`;

// 以 script 标签形式注入 web 页面
const head = document.getElementsByTagName('head')[0];
if (head) {
  const script = document.createElement('script');
  script.type = 'text/javascript';
  script.text = scriptContent;
  head.appendChild(script);
  head.removeChild(script); // 注入脚本后立即删除,以防 web 页面检测出来
}

// 监听 web 页面消息
window.addEventListener('message', e => {
  if(e.data.type === 'openXhr') {
    ...
  }
});

Try...catch 似乎不生效

考虑这样一个场景,按 tabId 获取页面的插件 API,若 tabId 对应的页面已关闭,就会产生错误。这是一个异步方法,通过 callback 的方式返回值,try-catch 无法捕获运行时错误。它也不是 promise,且不提供额外参数来捕获错误。
// background.js

try {
  chrome.tabs.get(tabId, (tab) => {
   ...
  });
} catch (e) {
  ...
}
异步函数的错误信息在保存 chrome.runtime.lastError 里,你只需要**读取**它的值即可。在回调函数里读 取 lastError 的值,就能让控制台和错误日志干净一点——虽然你可能并不关心到底是什么引起了错误。
// background.js

chrome.tabs.get(tabId, (tab) => {
 if(chrome.runtime.lastError) {
  ...
 }
 ...
});

如何实现不重装也能自动更新插件 

没有用户喜欢重新安装。如果是从 Chrome 商店安装的插件,浏览器会定期自动更新。如果你的插件没有发布在 Chrome 商店上,也能实现自动更新,而且无需审核,立即更新。 简单地说,就是把插件做为一个「壳子」,插件模块加载时,查询最近 webpack 打包时生成的 chunkMap, 以 xhr 请求的形式获取最新 JS 内容,然后用 eval 执行 JS 脚本。实现的时候,还应该考虑异常处理和缓存优化。 如果你更改了配置文件 manifest.json,只有手动重装或通过 Chrome 商店更新才能生效。 

发起跨域请求

在 web 页面发起跨域请求会受到浏览器的限制,content script 也不例外。插件其它模块则没有这个限制,可以发起自动携带 cookie 的跨域请求。如果需要的话,以消息传递的方式,让 background 页面代劳即可。

插件这么强大,不敢随便安装了

确实,「如无必要,勿增插件」。在安装插件前,Chrome 会提示用户该插件所需要的权限,这个提示值得仔细阅读。

参考资料

Chrome 插件官方教程:https://developer.chrome.com/extensions/getstarted
插件示例集合:https://developer.chrome.com/extensions/samples
Chromium 源码分析:https://blog.csdn.net/Luoshengyang/article/details/52465364