Chrome插件实战开发

  在上一篇文章中,我们介绍了Chrome插件的页面如何写,以及各个组件之间是如何来通信的,得到了不少朋友的积极反馈,大家对Chrome插件的相关内容也都比较感兴趣,也存在着相当大的应用市场;本文就结合项目开发中遇到的的一些实际问题,分享一些开发经验。

从V2升级到V3

  上一篇文章写的时间比较早,使用的还是V2版本的插件,而现在Chrome最新的插件版本也来到的V3,而且V2插件也不能继续在Chrome商店里面发布上架了;因此很多朋友吐槽得比较多的就是,上一篇文章中介绍的插件版本太老了;因此本文我们先来看下如何从V2升级到V3,以及两个版本存在着哪些区别。

  首先Chrome浏览器是从88版本开始支持V3,因此开发之前,首先确定一下自己的浏览器版本是否高于这个版本;第一步,就是修改manifest.json文件,将我们的插件版本号从2改到3。

1
2
3
4
5
{
// "manifest_version": 2,
"manifest_version": 3,
// ...
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

注意:这里改的是manifest_version,而不是version字段。

权限配置升级

  在V2版本中,host权限和其他的权限配置一般都统一的放在permissions字段中,而其他一些可选权限则在optional_permissions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// V2
{
...
"permissions": [
"tabs",
"bookmarks",
"https://www.xieyufei.com/",
],
"optional_permissions": [
"unlimitedStorage",
"*://*/*",
]
// ...
}

  permissions列出的权限是插件被安装前所需要的;而optional_permissions列出的一些权限,是插件在安装时不需要的,在安装之后可能会要求的权限。

  在V3版本中,权限配置更加精细化,我们需要把主机权限独立到单独的host_permissionsoptional_host_permissions字段中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// V3
{
...
"permissions": [
"tabs",
"bookmarks"
],
"optional_permissions": [
"unlimitedStorage"
],
"host_permissions": [
"https://www.xieyufei.com/",
],
"optional_host_permissions": [
"*://*/*",
]
// ...
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

web_accessible_resources

  web_accessible_resources字段用来控制外部访问插件中的资源,比如content-script脚本或者popup页面中需要展示展示图片资源;在V2版本中,直接定义一个资源列表,那么所有网站都能访问这些资源了:

1
2
3
4
5
6
7
8
9
10
// V2
{
// ...
"web_accessible_resources": [
"images/*",
"style/extension.css",
"script/extension.js"
],
// ...
}

  而来到V3版本,我们需要配置一个对象数组,对象中通过resources和matches更加精细化的配置了哪些外部网站可以访问哪些资源文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// V3
{
// ...
"web_accessible_resources": [
{
"resources": [
"style/extension.css",
"script/extension.js"
],
"matches": [
"https://*.xieyufei.com/*"
]
}
],
// ...
}

  假设我们有一张图片资源在以下插件目录下:

1
2
3
4
5
extension-files/
manifest.json
content-script.js
images/
banner.png

  我们想让content-script.js来在页面呈现图片的地址,需要在manifest.json声明可以被访问到:

1
2
3
4
5
6
7
8
{
"web_accessible_resources": [
{
"resources": [ "images/banner.png" ],
"matches": [ "*" ]
}
],
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  然后在content-script.js中调用Chrome插件的chrome.runtime.getURL函数来获取图片的地址,图片的地址看起来可能是这样的:

1
chrome-extension://<extension-UUID>/images/banner.png

这里的extension-UUID并不是插件的ID,而是一个随机生成的唯一id。

  我们在匹配资源文件的路径时,面对多个文件匹配,也可以使用通配符:

1
2
3
4
5
6
7
8
{
"web_accessible_resources": [
{
"resources": [ "images/*.png" ],
"matches": [ "*" ]
}
],
}

background后台

  background后台的升级也是Chrome插件更新的重要特性之一,使用了Service Worker替代了原来的Background page。在V2版本中,我们使用background.scripts可以配置多个js,或者使用background.page配置一个后台页面:

1
2
3
4
5
6
7
8
// V2
{
"background": {
"scripts": ["js/script1.js", "js/script2.js"],
// or "page": "background.html"
"persistent": true
},
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  persistent: true指定了脚本一直在后台运行,直到插件被禁用或者卸载,这样就导致占用了大量的内存;因此V3废弃了scripts和page;如果我们还是指定这两者,Chrome就会报下面错误,直接就不让我们运行插件了,

1
2
错误
The "background.scripts" key cannot be used with manifest_version 3. Use the "background.service_worker" key instead. 无法载入清单。

  V3版本升级改用了service_worker字段代替原来scripts和page,确保插件不会一直占用浏览器的资源,仅在需要时才运行,从而节省资源:

1
2
3
4
5
6
7
//V3
{
"background": {
"service_worker": "js/background.js"
// 移除了 "persistent": true
},
}

service_worker字段不是一个数组,只支持字符串格式。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  同时V3版本升级也让background.js支持了模块化开发,我们可以在里面直接import本地的方法,让我们能够不用依赖打包的方式进行模块化开发,使用方式也很简单,在background添加type属性即可:

1
2
3
4
5
6
7
// manifest.json
{
"background": {
"service_worker": "js/background.js",
"type": "module"
},
}

  我们在background.js中就可以使用import导入本地模块:

1
2
3
4
5
6
7
8
9
10
// background.js
import { add } from "./utils.js";
chrome.runtime.onInstalled.addListener(() => {
console.log("测试插件已经安装", add(2, 4));
});

// utils.js
export function add(a, b) {
return a + b;
}

  同时,由于background不再支持page页面配置background.html,因此也无法调用window对象上的XMLHttpRequest来构建ajax请求;也就是说我们不能像V2版本一样,在background.html中使用jQuery的$.ajax来发送请求了,而是需要使用fetch函数来获取接口数据。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  由于service workers是短暂的,在不使用时会终止,这意味着它们在整个浏览器插件运行期间会不断的启动、运行和终止,也就是不稳定的;因此我们可能需要对V2中background.js的代码逻辑进行一些改造,以往我们会习惯将一些数据直接存储到全局变量,比如像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// V2 background.js
let saveUserName = "";

// 其他页面,比如content-script或者popup中存储数据
chrome.runtime.onMessage.addListener(({ type, name }) => {
if (type === "set-name") {
saveUserName = name;
}
});

// 点击popup时展示数据
chrome.action.onClicked.addListener((tab) => {
// 这里saveUserName可能为空字符串
console.log(saveUserName, "saveUserName");
});

  当我们运行项目时发现,全局变量saveUserName在某些情况下获取到的数据变成空字符串,存储的数据直接消失了;笔者在项目调试中刚开始经常会遇到这种神奇的问题,调试的值跟实际的值不一样,随之消失的还有笔者的信心。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

消失了

  因此在V3中,需要对这种全局存储的变量数据进行改造,改造的方式也很简单,就是将数据持久化保存到storage中,需要用到的地方随用随取:

1
2
3
4
5
6
7
8
9
10
11
// V3 service worker
chrome.runtime.onMessage.addListener(({ type, name }) => {
if (type === "set-name") {
chrome.storage.local.set({ name });
}
});

chrome.action.onClicked.addListener(async (tab) => {
const { name } = await chrome.storage.local.get(["name"]);
chrome.tabs.sendMessage(tab.id, { name });
});

actions升级

  有小伙伴也许发现了,我们上面使用了chrome.action.onClicked来注册点击事件,而不是原来的chrome.browserAction.onClicked

  由于历史原因,之前将插件的图标分为pageActionbrowserAction,两者的区别在于browserAction始终都显示,更像我们现在的插件图标逻辑;而pageAction则比较特殊,只有当某些特定的页面打开时才会显示图标。

pageAction

  而V2版本两者的区分界限已经较为模糊了,区别不是很大;但是在manifest.json中配置还是有区分,常用的就是browser_action:

1
2
3
4
5
6
7
// V2
{
"page_action": { ... },
"browser_action": {
"default_popup": "popup.html"
}
}

  升级到V3版本,直接统一为同一个action,不需要再区分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// V3
{
"action": {
"default_title": "插件标题",
"default_popup": "popup.html",
"default_icon": {
"16": "/images/get_started16.png",
"32": "/images/get_started32.png",
},
"icons": {
"16": "/images/get_started16.png",
"32": "/images/get_started32.png",
}
},
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

需要注意的是:如果注册了popup.html的页面,则chrome.action.onClicked点击事件注册后并不会被执行。

  我们在绑定chrome.action事件的地方也需要进行统一:

1
2
3
4
5
6
// V2 
chrome.browserAction.onClicked.addListener(tab => { ... });
chrome.pageAction.onClicked.addListener(tab => { ... });

// V3
chrome.action.onClicked.addListener(tab => { ... });

CSP

  内容安全策略(Content Security Policy,简称CSP),是在manifest.json中配置的,用于限制扩展可以从哪些源加载代码,比如script标签可以从哪些域名地址加载CDN,或者禁止eval()等可能不安全的函数;在V2版本中,默认是一个字符串配置:

1
2
3
4
// V2
{
"content_security_policy": "default-src 'self'"
}

  升级到V3版本,content_security_policy字段依然被保留,支持另外两个属性:extension_pages和sandbox:

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

1
2
3
4
5
6
7
// V3
{
"content_security_policy": {
"extension_pages": "default-src 'self'",
"sandbox": "..."
}
}

  default-src 'self'表示默认所有类型的引用文件(js文件、html文件)都是应该在插件包内的;如果我们想要支持从某个域名地址引入js文件,在V2中我们会看到下面的写法:

1
2
3
4
5
6
7
8
// V2
{
"content_security_policy": "script-src 'self' https://xieyufei.com; object-src 'self'"
}
// 或者支持子域名
{
"content_security_policy": "script-src 'self' https://*.xieyufei.com; object-src 'self'"
}

  但V3中不支持这样的写法,不允许从某个域名地址引入文件。

API调用升级

  我们在调用chrome API的地方,也有一些需要进行升级改造的,比如上面的chrome.action:

1
2
3
4
5
6
// V2 
chrome.browserAction.onClicked.addListener(tab => { ... });
chrome.pageAction.onClicked.addListener(tab => { ... });

// V3
chrome.action.onClicked.addListener(tab => { ... });

  在获取资源地址的时候,也需要将chrome.extension.getURL替换成chrome.runtime.getURL

1
2
3
4
5
// V2
chrome.extension.getURL("images/img.png");

// V3
chrome.runtime.getURL("images/img.png");

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  V3中,执行script-content的api函数executeScript也从tabs,升级到了scripting;因此我们还需要在manifest.json中添加scripting权限才能调用;同时,执行的脚本也从原来的单个文件,变成可以接收多个文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
// V2
chrome.tabs.executeScript(
tab.id,
{
file: 'content-script.js'
}
);

// V3
chrome.scripting.executeScript({
target: {tabId: tab.id},
files: ['content-script.js']
});

  insertCSS()removeCSS()也从tabs升级到了scripting

1
2
3
4
5
6
7
8
9
10
// V2
chrome.tabs.insertCSS(tab.id, injectDetails, () => {
// callback code
});

// V3
const insertPromise = await chrome.scripting.insertCSS({
files: ["style.css"],
target: { tabId: tab.id }
});

service worker异步返回数据

  我们在实际项目中,有时候会需要service worker异步返回一些数据,比如请求接口后返回一些接口数据等:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// content-script.js
chrome.runtime
.sendMessage({
type: 'get-status',
})
.then((res) => {
// 对res处理
})

// background.js
chrome.runtime.onMessage
.addListener(async ({ type }, sender, sendResponse) => {
if (type === 'get-status') {
fetch('XXX/list.json').then(res=>{
sendResponse(res)
});
}
})

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  上面的代码中在content-script.js发送消息到background中,虽然这里我们虽然是在then中返回了res,或者使用async/await;但是很遗憾,在content-script.js接收到的res还是undefined,我们需要对background代码进行改造

1
2
3
4
5
6
7
8
9
10
11
// background.js
chrome.runtime.onMessage
.addListener(async ({ type }, sender, sendResponse) => {
if (type === 'get-status') {
fetch('XXX/list.json').then(res=>{
sendResponse(res)
});
// 这里添加了返回true
return true;
}
})

  在onMessage回调函数里返回true,告诉Chrome我们想要异步发送响应。

插件和原生页面通信问题

  我们有时候会遇到需要插件去和原生Web页面进行通信情况,这里的原生Web页面页面指的并不是content-script.js或者popup.html页面,一般也是我们开发的网站页面;比如在原生Web页面页面中,需要判断是否安装了插件,没有安装插件的话显示下载插件的跳转链接;或者点击原生页面上的某一个按钮,将数据保存到插件中来等等,就需要涉及插件和原生Web页面页面的通信问题。

  这里有几种实现通信的方式,第一种最简单的方式就是通过隐藏的dom节点,比如安装插件后,通过content-script.js在页面上放置一个隐藏的dom,将插件信息放到放到dom节点上,这样的缺点也很明显,只能传输一些简单的数据,且不能进行双向通信。

  第二种方式,通过插件的id,从原生Web页面想插件发送消息,首先需要配置在manifest.json中配置externally_connectable字段,来声明哪些Web页面可以通过这种方式,和插件建立链接:

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

1
2
3
4
5
6
// manifest.json
{
"externally_connectable": {
"matches": ["https://*.fill-you-web-url.com/*"]
},
}

  externally_connectable还可以指定ids字段,用来指定需要通信的其他Chrome插件;配置完成然后就可以在我们的Web页面里添加发送消息的代码了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 插件ID
const extensionId = "iodjapnffldobobfdaoobinimjofgejm";

// 向Chrome扩展发送请求
chrome?.runtime?.sendMessage(
extensionId,
{
type: "pageMsg",
msg: "hello i am from origin",
},
(response) => {
console.log("res data", response);
}
);

  这里如果我们没有配置上面的externally_connectable字段,浏览器是不会在我们的页面上注入chrome.runtime.sendMessage方法的,因此我们需要对这个函数进行异常判断,否则页面就会报错。

1
2
3
4
5
6
7
8
9
10
// background.js 接收原生Web页面消息
chrome.runtime.onMessageExternal.addListener(
(request, sender, sendResponse) => {
if (request.type === "pageMsg") {
sendResponse('res msg');
} else {
sendResponse("received");
}
}
);

  第三种方式,我们可以通过window.postMessage进行通信,window.postMessage一般用在多个页面之间通信,当然,我们的content-script.js和原生Web界面是同源的,更能直接通信了;两者的发送方式和接收方式在代码上都是一样的,这里也不再进行区分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 页面初始化话进行监听
window.addEventListener('message', (ev) => {
if (ev.source != window) {
return;
}
if (ev.data) {
const { type, saveData } = ev.data;
}
})

// 点击发送消息
const clickSend = ()=>{
window.postMessage(
{
type: 'myTestPostMsg',
saveData: {
title: 'XXX',
version: 'QQQ'
},
},
'*'
);
}

  这样我们不需要获取插件的ID也能通信了,不过我们在监听message消息时会看到各种各样插件或者页面之间传递的消息,因此我们对传输数据的命名方式上差异化,可以定义一些独特的前缀,避免和其他页面产生不必要的冲突。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里


本网所有内容文字和图片,版权均属谢小飞所有,任何媒体、网站或个人未经本网协议授权不得转载、链接、转贴或以其他方式复制发布/发表。如需转载请关注公众号【前端壹读】后回复【转载】。