学一下chrome的扩展开发
最近因为看了下 Chrome 里面置入的小模型,顺手又看了下 Chrome 扩展。
之前其实也接触过一些浏览器扩展,但是一直没有特别系统地看过它的结构,感觉好像就是写一个 html,然后再配几个 js 文件就可以了。
但是真正写起来以后,会发现 Chrome 扩展里面有几个不同的运行环境,尤其是 popup 和 content script,如果没有分清楚,后面调用网页内容或者操作页面 DOM 的时候就容易有点混乱。
这次先做一个比较简单的例子,点击扩展按钮以后,给当前页面加一个背景色,再在右下角显示一个提示。
先看下目录结构
代码放在这里:
source/code/chrome-extension-demo
目录结构如下:1
2
3
4
5
6chrome-extension-demo/
├── manifest.json
├── popup.html
├── popup.css
├── popup.js
└── content.js
这几个文件的作用比较简单:1
2
3
4
5manifest.json 扩展的配置文件
popup.html 点击扩展图标后显示的弹窗
popup.js 处理弹窗中的按钮点击
popup.css 弹窗样式
content.js 运行在网页中,操作网页 DOM
首先看下 manifest.json。
manifest.json
1 | { |
这个文件相当于 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/*"
]
popup.html
接着看下弹窗页面。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
<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 来完成。
popup.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
33const 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
4const [tab] = await chrome.tabs.query({
active: true,
currentWindow: true
});
chrome.tabs.query 用来查询浏览器中的标签页。
这里传入了两个条件:1
2active: true
currentWindow: true
表示查询当前窗口中处于激活状态的标签页。
因为返回结果是一个数组,所以这里直接使用数组解构取第一个:1
const [tab] = ...
拿到当前标签页以后,真正重要的是下面这段:1
2
3
4
5
6
7chrome.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
45chrome.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
3if (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
3sendResponse({
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 显示“页面已标记”
popup 和 content script 的区别
这里其实是 Chrome 扩展里最重要的一个概念。
popup.js 和 content.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
3document.querySelector(...)
document.createElement(...)
document.body.appendChild(...)
相关说明可以看 Chrome Content Scripts。
加载扩展
打开:1
chrome://extensions
然后:
- 打开右上角的 Developer mode
- 点击 Load unpacked
- 选择
chrome-extension-demo目录 - 打开一个普通网页
- 点击扩展图标
- 点击“标记当前页面”
如果修改了 manifest.json,需要在扩展管理页面重新加载扩展。
如果修改了 content.js,一般还需要重新加载扩展并刷新网页,因为脚本是在网页加载时注入的。
为什么有些页面不能使用
如果在下面这些页面中点击扩展:1
2
3
4chrome://extensions
chrome://settings
chrome://flags
Chrome Web Store
可能会出现:1
当前页面暂时不能注入脚本
这通常不是代码写错了,而是浏览器内部页面本身不允许扩展注入脚本。
所以这段错误处理还是需要有的:1
2
3
4
5if (chrome.runtime.lastError) {
statusText.textContent =
'当前页面暂时不能注入脚本';
return;
}
如果是异步操作
当前例子里面,content.js 收到消息后马上就返回了结果。
如果中间需要进行异步操作,例如请求接口:1
2
3
4
5
6
7
8
9
10
11
12
13
14chrome.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
5manifest.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
4popup.js
└── tabs.sendMessage
└── content.js
└── 修改 DOM
如果需要通过后台统一处理:1
2
3
4
5
6
7
8popup.js
└── runtime.sendMessage
↓
background.js
└── tabs.sendMessage
↓
content.js
└── 修改网页 DOM
popup.js1
2
3
4
5
6
7document
.getElementById('mark')
.addEventListener('click', () => {
chrome.runtime.sendMessage({
type: 'MARK_CURRENT_TAB'
});
});
background.js1
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
36chrome.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.js1
2
3
4
5
6
7
8
9
10
11
12
13chrome.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
7popup.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
7content.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
5popup
↕
service-worker.js
↕
content script
Service Worker 是事件驱动的,不会一直运行。没有事件时可能被 Chrome 暂停,后续事件到来时再重新启动,因此不能把重要状态只放在全局变量中,应该放到 chrome.storage 中。
另外,MV3 的扩展 Service Worker 本身也不能访问网页 DOM。About extension service workers
这样就大致知道了chrome开发的主体逻辑