学一下chrome的扩展开发

最近因为看了下 Chrome 里面置入的小模型,顺手又看了下 Chrome 扩展。

之前其实也接触过一些浏览器扩展,但是一直没有特别系统地看过它的结构,感觉好像就是写一个 html,然后再配几个 js 文件就可以了。

但是真正写起来以后,会发现 Chrome 扩展里面有几个不同的运行环境,尤其是 popupcontent script,如果没有分清楚,后面调用网页内容或者操作页面 DOM 的时候就容易有点混乱。

这次先做一个比较简单的例子,点击扩展按钮以后,给当前页面加一个背景色,再在右下角显示一个提示。

先看下目录结构

代码放在这里:

source/code/chrome-extension-demo

目录结构如下:

1
2
3
4
5
6
chrome-extension-demo/
├── manifest.json
├── popup.html
├── popup.css
├── popup.js
└── content.js

这几个文件的作用比较简单:

1
2
3
4
5
manifest.json  扩展的配置文件
popup.html 点击扩展图标后显示的弹窗
popup.js 处理弹窗中的按钮点击
popup.css 弹窗样式
content.js 运行在网页中,操作网页 DOM

首先看下 manifest.json

manifest.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"manifest_version": 3,
"name": "Simple Page Marker",
"version": "1.0.0",
"description": "A tiny Chrome extension demo for marking the current page.",
"action": {
"default_popup": "popup.html"
},
"permissions": [
"activeTab"
],
"content_scripts": [
{
"matches": [
"<all_urls>"
],
"js": [
"content.js"
],
"run_at": "document_idle"
}
]
}

这个文件相当于 Chrome 扩展的入口配置。

manifest_version 就是使用的配置版本,现在一般使用 3

1
2
3
"action": {
"default_popup": "popup.html"
}

这个配置表示点击浏览器右上角的扩展图标以后,打开 popup.html

下面这个是权限:

1
2
3
"permissions": [
"activeTab"
]

activeTab 表示用户主动点击扩展以后,扩展可以临时访问当前的标签页。

这里的“临时”还是比较重要的,它并不是给扩展永久访问所有网站的权限,而是在用户操作扩展以后,允许它访问当前页面。

然后下面是 content_scripts

1
2
3
4
5
6
7
8
9
10
11
"content_scripts": [
{
"matches": [
"<all_urls>"
],
"js": [
"content.js"
],
"run_at": "document_idle"
}
]

这里配置了需要注入的脚本和匹配的网页。

<all_urls> 表示所有普通网页都可以匹配,content.js 会被注入进去。

run_at 表示什么时候注入脚本,这里使用的是 document_idle,也就是页面基本加载完成以后再执行。

官方文档中也比较推荐使用这个时机,因为一般扩展不需要在页面最开始加载的时候就执行。

不过 <all_urls> 的权限比较大,如果是真正发布的扩展,最好改成具体的网站范围,例如:

1
2
3
"matches": [
"https://example.com/*"
]

接着看下弹窗页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Simple Page Marker</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<main class="panel">
<h1>Page Marker</h1>

<p>
点击按钮后,扩展会给当前页面加一个简单标记。
</p>

<button id="mark" type="button">
标记当前页面
</button>

<p id="status" class="status"></p>
</main>

<script src="popup.js"></script>
</body>
</html>

这里就是一个很普通的 HTML 页面。

比较容易混淆的地方是,虽然它看起来也是一个页面,但是这个页面并不是当前打开的网站,而是扩展自己的页面。

比如在 popup.js 中:

1
document.querySelector('#status')

这里的 document 指的是 popup.html

如果我们在这里修改:

1
document.body.style.backgroundColor = 'red';

修改的是扩展弹窗,而不是当前打开的网站。

真正要修改网页,需要通过 content script 来完成。

先看下点击按钮以后的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const markButton = document.getElementById('mark');
const statusText = document.getElementById('status');

markButton.addEventListener('click', async () => {
statusText.textContent = '';

const [tab] = await chrome.tabs.query({
active: true,
currentWindow: true
});

if (!tab || !tab.id) {
statusText.textContent = '没有找到当前标签页';
return;
}

chrome.tabs.sendMessage(
tab.id,
{
type: 'MARK_PAGE'
},
(response) => {
if (chrome.runtime.lastError) {
statusText.textContent =
'当前页面暂时不能注入脚本';
return;
}

statusText.textContent =
response?.ok ? '页面已标记' : '标记失败';
}
);
});

首先是这一段:

1
2
3
4
const [tab] = await chrome.tabs.query({
active: true,
currentWindow: true
});

chrome.tabs.query 用来查询浏览器中的标签页。

这里传入了两个条件:

1
2
active: true
currentWindow: true

表示查询当前窗口中处于激活状态的标签页。

因为返回结果是一个数组,所以这里直接使用数组解构取第一个:

1
const [tab] = ...

拿到当前标签页以后,真正重要的是下面这段:

1
2
3
4
5
6
7
chrome.tabs.sendMessage(
tab.id,
{
type: 'MARK_PAGE'
},
callback
);

这里是给当前标签页发送消息。

消息内容只是一个普通的 JavaScript 对象:

1
2
3
{
type: 'MARK_PAGE'
}

可以把它理解成告诉网页里的 content.js

请执行一个叫做 MARK_PAGE 的操作。

扩展的不同部分之间不能直接共享变量,一般就是通过这种消息来通信。

content.js

下面是真正修改网页的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
chrome.runtime.onMessage.addListener(
(message, sender, sendResponse) => {
if (message.type !== 'MARK_PAGE') {
return;
}

document.body.style.backgroundColor = '#fff7cc';

const oldMarker = document.getElementById(
'simple-page-marker'
);

if (oldMarker) {
oldMarker.remove();
}

const marker = document.createElement('div');

marker.id = 'simple-page-marker';
marker.textContent =
'This page was marked by Simple Page Marker.';

Object.assign(marker.style, {
position: 'fixed',
right: '16px',
bottom: '16px',
zIndex: '2147483647',
padding: '10px 12px',
color: '#fff',
background: '#202124',
borderRadius: '6px',
boxShadow: '0 4px 14px rgba(0, 0, 0, 0.24)',
fontSize: '14px',
lineHeight: '1.4',
fontFamily:
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
});

document.body.appendChild(marker);

sendResponse({
ok: true
});
}
);

这一段是监听消息:

1
chrome.runtime.onMessage.addListener(...)

popup.js 发送消息以后,这个监听器就会被调用。

首先判断消息类型:

1
2
3
if (message.type !== 'MARK_PAGE') {
return;
}

因为以后可能还会有很多操作,比如:

1
2
3
{
type: 'GET_SELECTION'
}

或者:

1
2
3
{
type: 'EXTRACT_ARTICLE'
}

所以一般会通过 type 来区分不同的消息。

然后这里:

1
document.body.style.backgroundColor = '#fff7cc';

此时的 document 就不一样了。

因为这段代码运行在网页中,所以这里的 document 是当前网站的 DOM,可以直接修改网页背景。

接着创建一个提示框:

1
const marker = document.createElement('div');

设置一些样式以后:

1
document.body.appendChild(marker);

就把它插入到了当前网页中。

最后:

1
2
3
sendResponse({
ok: true
});

把执行结果返回给 popup.js

于是整个调用链就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
点击 popup 中的按钮

popup.js 找到当前标签页

tabs.sendMessage 发送 MARK_PAGE

content.js 收到消息

修改当前网页 DOM

sendResponse 返回执行结果

popup 显示“页面已标记”

这里其实是 Chrome 扩展里最重要的一个概念。

popup.jscontent.js 虽然都是 JavaScript,但是运行环境不一样。

可以简单看成:

1
2
3
4
5
6
7
8
扩展自己的环境
├── popup.html
├── popup.js
└── service-worker.js

网页环境
└── content.js
└── 当前网页的 document

所以在 popup.js 里面:

1
document.body

拿到的是弹窗页面。

content.js 里面:

1
document.body

拿到的是当前网站页面。

这也是为什么 popup.js 不能直接修改网页,必须通过消息把操作交给 content.js

Chrome 这里还有一个隔离环境的概念。

content script 可以访问网页的 DOM,但是它定义的 JavaScript 变量不会直接和网页脚本混在一起。

比如网页中有一个变量:

1
const userName = 'page';

扩展的 content.js 中也有:

1
const userName = 'extension';

一般不会因为变量名相同就互相覆盖。

不过它们仍然共享网页的 DOM,所以 content.js 还是可以:

1
2
3
document.querySelector(...)
document.createElement(...)
document.body.appendChild(...)

相关说明可以看 Chrome Content Scripts

加载扩展

打开:

1
chrome://extensions

然后:

  1. 打开右上角的 Developer mode
  2. 点击 Load unpacked
  3. 选择 chrome-extension-demo 目录
  4. 打开一个普通网页
  5. 点击扩展图标
  6. 点击“标记当前页面”

如果修改了 manifest.json,需要在扩展管理页面重新加载扩展。

如果修改了 content.js,一般还需要重新加载扩展并刷新网页,因为脚本是在网页加载时注入的。

为什么有些页面不能使用

如果在下面这些页面中点击扩展:

1
2
3
4
chrome://extensions
chrome://settings
chrome://flags
Chrome Web Store

可能会出现:

1
当前页面暂时不能注入脚本

这通常不是代码写错了,而是浏览器内部页面本身不允许扩展注入脚本。

所以这段错误处理还是需要有的:

1
2
3
4
5
if (chrome.runtime.lastError) {
statusText.textContent =
'当前页面暂时不能注入脚本';
return;
}

如果是异步操作

当前例子里面,content.js 收到消息后马上就返回了结果。

如果中间需要进行异步操作,例如请求接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
chrome.runtime.onMessage.addListener(
(message, sender, sendResponse) => {
fetch('https://example.com/api')
.then(response => response.json())
.then(data => {
sendResponse({
ok: true,
data
});
});

return true;
}
);

这里的:

1
return true;

是比较容易漏掉的。

它表示当前消息还不会马上响应,需要保留通信通道,等异步请求结束以后再调用 sendResponse

具体可以看 Chrome Message Passing

activeTab 和 content_scripts

这次在 manifest.json 中同时配置了:

1
2
3
"permissions": [
"activeTab"
]

以及:

1
2
3
4
5
6
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"]
}
]

这两个配置实际作用不完全一样。

content_scripts 负责决定 content.js 自动注入哪些页面。

activeTab 则表示用户主动操作扩展以后,可以临时访问当前标签页。

如果扩展不需要监听所有页面,只在用户点击扩展时执行代码,也可以使用:

1
2
3
4
"permissions": [
"activeTab",
"scripting"
]

然后通过 chrome.scripting.executeScript 动态注入代码。

当前这个例子为了简单,还是使用静态的 content_scripts

最后

Chrome 扩展最开始看起来就是一些 HTML、CSS 和 JavaScript 文件,但是它和普通网页还是有一些区别。

比较重要的是先理解这几个东西:

1
2
3
4
5
manifest.json  负责配置
popup 负责扩展弹窗交互
content script 负责操作网页 DOM
message 负责不同环境之间通信
service worker 负责后台事件和扩展全局逻辑

这次的例子虽然只是给网页换了个背景色,但是从点击按钮到修改网页,已经把 Chrome 扩展最基本的一条链路走通了。

后面如果要做网页翻译、页面摘要、读取选中文本,基本也都是在这个结构上继续扩展。
这里使用content相当于是一个持久化的脚本注入,如果只是临时要抓取一下网页内容
可以直接在popup里调用

1
await chrome.scripting.executeScript

这种相当于是每点一次调一次,那么参数上下文这些每次都要重新传
而通过content的话相当于是有个server注入在网页里
可以通过消息的形式让它做对应的处理
各有优势,主要是学习下chrome扩展的这些机制
而对于之前的mv2版本也是类似的,只不过是之前可以直接注入整个content.js用来执行
所以看起来像是mv2可以直接在popup里执行tab网页的dom操作
实际上并不是,而是通过注入整个content.js来执行的
整体来说还是一样,所以chrome扩展的大致执行机制就是这样
在自己实现的时候可以自行选择方式
而如果通过background.js的方式的话
比如点击 popup 中的按钮,让当前页面变色,有两种方式。
简单方式:

1
2
3
4
popup.js
└── tabs.sendMessage
└── content.js
└── 修改 DOM

如果需要通过后台统一处理:

1
2
3
4
5
6
7
8
popup.js
└── runtime.sendMessage

background.js
└── tabs.sendMessage

content.js
└── 修改网页 DOM

popup.js

1
2
3
4
5
6
7
document
.getElementById('mark')
.addEventListener('click', () => {
chrome.runtime.sendMessage({
type: 'MARK_CURRENT_TAB'
});
});

background.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
chrome.runtime.onMessage.addListener(
(message, sender, sendResponse) => {
if (message.type !== 'MARK_CURRENT_TAB') {
return;
}

chrome.tabs.query(
{
active: true,
currentWindow: true
},
(tabs) => {
const tab = tabs[0];

if (!tab?.id) {
sendResponse({
ok: false
});
return;
}

chrome.tabs.sendMessage(
tab.id,
{
type: 'MARK_PAGE'
},
(response) => {
sendResponse(response);
}
);
}
);

return true;
}
);

content.js

1
2
3
4
5
6
7
8
9
10
11
12
13
chrome.runtime.onMessage.addListener(
(message, sender, sendResponse) => {
if (message.type !== 'MARK_PAGE') {
return;
}

document.body.style.backgroundColor = '#fff7cc';

sendResponse({
ok: true
});
}
);

这里有两次消息传递:

1
2
3
4
5
6
7
popup.js
└── runtime.sendMessage

background.js
└── tabs.sendMessage

content.js

为什么需要 background?
如果只是一个按钮点击操作,实际上不需要 background:

1
popup.js → content.js

已经够了。
但是下面这些场景就比较适合加入 background:

1
2
3
4
5
6
7
用户按快捷键

background.js 收到事件

找到当前标签页

通知 content.js 执行操作

或者

1
2
3
4
5
6
7
content.js 读取网页内容

background.js 请求接口

background.js 保存结果

content.js 更新页面

因为 popup 会随时关闭,不能依赖它长期存在。
MV3 中不再使用传统的 background.js 页面,而是使用扩展 Service Worker:

1
2
3
4
5
6
{
"manifest_version": 3,
"background": {
"service_worker": "service-worker.js"
}
}

调用关系没变

1
2
3
4
5
popup

service-worker.js

content script

Service Worker 是事件驱动的,不会一直运行。没有事件时可能被 Chrome 暂停,后续事件到来时再重新启动,因此不能把重要状态只放在全局变量中,应该放到 chrome.storage 中。
另外,MV3 的扩展 Service Worker 本身也不能访问网页 DOM。About extension service workers
这样就大致知道了chrome开发的主体逻辑