Chrome 扩展开发完全指南:从入门到精通
基于 Manifest V3 标准规范,覆盖架构、核心 API、实战项目与发布全流程。
为什么现在是学习 Chrome 扩展开发的最佳时机
Chrome 扩展是一套运行在浏览器内部的程序,能够修改网页内容、拦截网络请求、管理标签页、与系统剪贴板交互,甚至直接调用原生操作系统功能。全球有超过 30 亿 Chrome 用户,Chrome Web Store 托管了数十万个扩展,覆盖生产力工具、开发辅助、广告拦截、隐私保护等几乎所有领域。
2024 年到 2025 年是 Chrome 扩展生态的一次重大转折。Manifest V2(MV2)在 Chrome 127 开始逐步禁用,并于 Chrome 139(2025 年 6 月)被完全移除,Manifest V3(MV3)成为唯一支持的扩展标准 $TRAE_REF。这意味着所有新开发的扩展必须基于 MV3,旧扩展也必须完成迁移。MV3 带来了更严格的权限模型、基于事件驱动的 Service Worker 架构,以及声明式的网络请求处理机制,在安全性和性能上都有显著提升。
本指南面向从零开始的开发者,也会深入到高级 API 与发布实践。所有代码示例均基于 MV3 标准,可直接加载到 Chrome 中运行。
扩展的核心架构
理解架构是写出可靠扩展的前提。一个 MV3 扩展由若干松耦合的组件构成,每个组件运行在不同的执行环境中,彼此通过消息通信协作。
组件总览
| 组件 | 扐行环境 | 生命周期 | 主要职责 |
|---|---|---|---|
| Service Worker | 扩展独立环境(无 DOM) | 事件驱动,空闲后终止 | 后台逻辑、事件监听、跨标签协调 |
| Content Script | 网页上下文 | 随页面加载/卸载 | 读写网页 DOM、注入样式 |
| Popup | 扩展独立环境(有 DOM) | 点击图标时创建,关闭即销毁 | 展示临时 UI、快捷操作 |
| Options Page | 扩展独立环境(有 DOM) | 用户主动打开 | 配置扩展设置 |
| Side Panel | 扩展独立环境(有 DOM) | 可常驻侧边栏 | 持续展示的信息面板 |
进程与上下文边界
这是新手最容易踩坑的地方。Service Worker、Popup、Options 页面运行在扩展自己的上下文中,能访问完整的 chrome.* API,但无法直接操作网页 DOM。Content Script 运行在网页的上下文中,能操作 DOM,却只能访问有限的 chrome.* API(主要是消息和存储相关)。
它们之间共享同一个扩展存储(chrome.storage),但不能直接访问彼此的变量。跨组件协作必须通过消息通信完成:
┌─────────────────────────────────────────────────────────┐
│ Chrome 扩展进程 │
│ │
│ ┌──────────────┐ chrome.runtime ┌────────────┐ │
│ │ Service │◄────sendMessage────►│ Popup │ │
│ │ Worker │ │ (DOM) │ │
│ │ (无 DOM) │ └────────────┘ │
│ └──────┬───────┘ │
│ │ chrome.tabs.sendMessage │
│ ▼ │
│ ┌──────────────┐ 共享 ┌────────────────────┐ │
│ │ Content │◄─────────►│ chrome.storage │ │
│ │ Script │ storage │ (跨组件共享数据) │ │
│ │ (网页上下文) │ └────────────────────┘ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────┘
│
▼ 能直接读写
┌──────────┐
│ 网页 DOM │
└──────────┘
记住这条核心规则:需要操作 DOM 就用 Content Script,需要调用 chrome API 或长期监听事件就用 Service Worker,需要展示界面就用 Popup 或 Options。
开发环境准备
Chrome 扩展开发的门槛很低,核心只需要一个文本编辑器和 Chrome 浏览器本身。不过合理的工具链能显著提升开发体验。
基础工具
- Chrome 浏览器:建议使用最新稳定版(本指南基于 Chrome 130+ 的能力)
- 代码编辑器:VS Code 配合
Chrome Extension Manifest V3snippets 插件是主流选择 - Chrome DevTools:扩展的每个组件都可以用 DevTools 单独调试
项目结构
一个典型的 MV3 扩展目录结构如下:
my-extension/
├── manifest.json # 扩展配置清单(必需,唯一入口)
├── background.js # Service Worker
├── content/
│ ├── content.js # Content Script
│ └── content.css # 注入网页的样式
├── popup/
│ ├── popup.html # 弹窗界面
│ ├── popup.js
│ └── popup.css
├── options/
│ ├── options.html # 设置页
│ └── options.js
├── icons/
│ ├── icon16.png
│ ├── icon48.png
│ └── icon128.png
└── rules/
└── rules.json # declarativeNetRequest 规则
加载未打包扩展进行开发
开发阶段无需打包,直接加载本地目录即可:
- 打开
chrome://extensions/ - 开启右上角「开发者模式」
- 点击「加载已解压的扩展程序」,选择你的项目目录
修改代码后,回到该页面点击扩展卡片上的刷新按钮即可重新加载。Service Worker 修改后会自动重新注册,Content Script 和 Popup 的变更则需要刷新对应页面或重新打开 Popup。
工程化进阶(可选)
当项目规模增大,可以引入构建工具。社区主流方案是用 Vite 或 Webpack 打包,配合 TypeScript 获得类型提示。一个轻量选择是 vite-plugin-web-extension,它支持热重载和自动管理 manifest。但对于学习阶段,纯手写 JS 文件更利于理解底层机制。
manifest.json 详解
manifest.json 是扩展的唯一入口和配置中心,Chrome 通过它判断扩展的结构、权限和组件。MV3 的 manifest 字段与 MV2 有诸多差异,下面逐块讲解。
完整示例
{
"manifest_version": 3,
"name": "我的扩展",
"version": "1.0.0",
"description": "一个用于学习的示例扩展",
"default_locale": "zh_CN",
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"action": {
"default_popup": "popup/popup.html",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png"
},
"default_title": "点击打开"
},
"background": {
"service_worker": "background.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["https://*.example.com/*"],
"js": ["content/content.js"],
"css": ["content/content.css"],
"run_at": "document_idle"
}
],
"options_page": "options/options.html",
"permissions": [
"storage",
"activeTab",
"tabs",
"alarms",
"notifications",
"contextMenus",
"commands"
],
"host_permissions": [
"https://*.example.com/*"
],
"commands": {
"_execute_action": {
"suggested_key": {
"default": "Ctrl+Shift+Y",
"mac": "Command+Shift+Y"
}
}
}
}
必需字段
| 字段 | 说明 |
|---|---|
manifest_version |
固定为 3,标识使用 MV3 标准 |
name |
扩展名称,显示在商店和管理页面 |
version |
版本号,使用点分数字格式(如 1.0.0),发布更新时必须递增 |
description 虽非强制,但商店上架时必需,建议始终填写。
图标系统
icons 字段定义扩展整体的图标,出现在扩展管理页面和商店。action.default_icon 定义工具栏图标的各个尺寸。Chrome 会根据设备像素密度自动选择合适尺寸,建议至少提供 16、48、128 三个规格。
action 与 browser_action 的变化
MV2 时代有 browser_action 和 page_action 两个概念,MV3 将它们合并为统一的 action。default_popup 指定点击图标时弹出的小窗口。
background 的根本变化
这是 MV3 最关键的改动之一。MV2 的 background.scripts 是常驻的后台页面,MV3 改为 background.service_worker:
"background": {
"service_worker": "background.js",
"type": "module"
}
"type": "module" 让 Service Worker 支持 ES Module 语法(import/export),推荐开启。Service Worker 是事件驱动的,没有持久内存,空闲一段时间后会被 Chrome 终止,下次有事件时再重新启动。这意味着不能用全局变量保存状态,必须依赖 chrome.storage 等持久化方案。
content_scripts 配置
"content_scripts": [
{
"matches": ["https://*.example.com/*"],
"js": ["content/content.js"],
"css": ["content/content.css"],
"run_at": "document_idle",
"all_frames": false,
"exclude_matches": ["*://*/*foo*"]
}
]
run_at 控制注入时机,可选 document_idle(默认,页面加载完成后)、document_start(DOM 构建前)、document_end(DOM 完成但资源未加载完)。all_frames 为 true 时会注入到所有 iframe 中。
权限与主机权限分离
MV3 将权限分为两类,这是安全模型的核心改进:
permissions:扩展能力权限,如storage、tabs、notificationshost_permissions:主机访问权限,声明扩展能访问哪些网站的请求和内容
MV2 中主机权限写在 permissions 数组里,用户安装时一次性授予。MV3 将其分离,使得主机权限可以按需申请、运行时动态申请,降低了权限滥用风险。
第一个扩展:页面信息读取器
理论讲够了,现在动手写一个能实际运行的扩展。这个扩展的功能是:点击工具栏图标弹出 Popup,显示当前标签页的标题和 URL,并提供一个按钮把 URL 复制到剪贴板。
第一步:创建项目结构
page-info/
├── manifest.json
├── popup/
│ ├── popup.html
│ └── popup.js
├── background.js
└── icons/
├── icon16.png
├── icon48.png
└── icon128.png
图标可以暂时用任意 PNG 占位。如果手头没有,可以用一个纯色方块。
第二步:编写 manifest.json
{
"manifest_version": 3,
"name": "页面信息读取器",
"version": "1.0.0",
"description": "点击图标查看当前页面的标题和 URL",
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"action": {
"default_popup": "popup/popup.html",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon48.png"
}
},
"permissions": ["activeTab", "clipboardWrite"],
"background": {
"service_worker": "background.js"
}
}
activeTab 是一个特殊权限:用户主动点击扩展图标或执行快捷键时,临时授予当前标签页的访问权,无需预先声明主机权限。这比请求所有网站权限更安全,也更容易通过审核。
第三步:编写 Popup 界面
popup/popup.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
body {
width: 320px;
font-family: system-ui, sans-serif;
padding: 16px;
margin: 0;
}
.info-block {
margin-bottom: 12px;
}
.label {
color: #888;
font-size: 12px;
margin-bottom: 4px;
}
.value {
font-size: 14px;
word-break: break-all;
}
button {
width: 100%;
padding: 8px;
background: #1a73e8;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #1557b0;
}
#status {
color: #1a73e8;
font-size: 12px;
text-align: center;
margin-top: 8px;
min-height: 16px;
}
</style>
</head>
<body>
<div class="info-block">
<div class="label">页面标题</div>
<div class="value" id="title">加载中...</div>
</div>
<div class="info-block">
<div class="label">页面地址</div>
<div class="value" id="url">加载中...</div>
</div>
<button id="copyBtn">复制 URL</button>
<div id="status"></div>
<script src="popup.js"></script>
</body>
</html>
第四步:编写 Popup 逻辑
popup/popup.js:
// 获取当前活动标签页的信息
async function getCurrentTab() {
const queryOptions = { active: true, currentWindow: true };
const [tab] = await chrome.tabs.query(queryOptions);
return tab;
}
// 初始化 Popup 界面
async function init() {
const tab = await getCurrentTab();
document.getElementById('title').textContent = tab.title || '(无标题)';
document.getElementById('url').textContent = tab.url || '(无地址)';
}
// 复制 URL 到剪贴板
document.getElementById('copyBtn').addEventListener('click', async () => {
const tab = await getCurrentTab();
try {
await navigator.clipboard.writeText(tab.url);
document.getElementById('status').textContent = '已复制到剪贴板';
} catch (err) {
// 某些情况下 clipboard API 不可用,回退方案
document.getElementById('status').textContent = '复制失败:' + err.message;
}
});
init();
第五步:编写 Service Worker
background.js:
// 监听扩展安装事件
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
console.log('扩展已安装,版本:', chrome.runtime.getManifest().version);
}
});
// 监听标签页切换(演示事件监听能力)
chrome.tabs.onActivated.addListener((activeInfo) => {
console.log('用户切换到标签页:', activeInfo.tabId);
});
第六步:加载并测试
- 打开
chrome://extensions/,开启开发者模式 - 点击「加载已解压的扩展程序」,选择
page-info目录 - 打开任意网页,点击工具栏上的扩展图标
- Popup 会显示当前页面的标题和 URL,点击按钮可复制
第一次运行成功后,你已经掌握了扩展开发的基本闭环:写 manifest、写各组件代码、加载调试。后续章节将逐一深入每个组件和 API。
Service Worker 深入
Service Worker 是 MV3 扩展的「大脑」,负责所有不需要 DOM 的后台逻辑。它替代了 MV2 的常驻 background page,最大的区别在于生命周期:Service Worker 在事件触发时启动,处理完毕空闲后会被 Chrome 主动终止。这种设计能节省内存,但也要求开发者转变思维。
生命周期与事件驱动模型
Service Worker 会在以下情况启动:
- 收到
chrome.runtime.onInstalled、onStartup等扩展级事件 - Content Script 或 Popup 通过消息唤醒它
- 注册的
chrome.alarms定时器触发 - 用户执行了
chrome.commands定义的快捷键 - 网络请求匹配了
declarativeNetRequest规则(带回调时)
启动后,只要还在处理事件,Service Worker 就保持活跃。事件处理完毕约 30 秒后(具体时长由 Chrome 决定),它会被终止,所有内存中的全局变量随之消失。下次事件到来时,Service Worker 从头执行顶层代码。
这意味着两条铁律:
- 不要用全局变量保存需要跨事件保留的状态,改用
chrome.storage - 事件监听必须在顶层同步注册,不能放在异步函数里,否则 Service Worker 重新启动后监听器不存在
// background.js
// ✅ 正确:顶层同步注册监听器
chrome.runtime.onInstalled.addListener(handleInstalled);
chrome.alarms.onAlarm.addListener(handleAlarm);
// ❌ 错误:异步注册,重启后丢失
// async function init() {
// await something();
// chrome.alarms.onAlarm.addListener(handleAlarm); // 可能永远不触发
// }
// init();
function handleInstalled(details) {
console.log('安装原因:', details.reason);
// install / update / chrome_update / shared_module_update
}
function handleAlarm(alarm) {
console.log('闹钟触发:', alarm.name);
}
处理异步与持久化
当你需要在事件处理中进行异步操作,并希望 Service Worker 不要在操作完成前被终止,可以使用 chrome.runtime.onMessage 监听器返回 true,或者用 async 函数让 Chrome 等待 Promise。
对于耗时任务,最佳实践是用 chrome.alarms 而非 setTimeout/setInterval。因为 Service Worker 被终止后,定时器会丢失,而 alarms 是持久化的,到时间会唤醒 Service Worker。
// background.js
// 创建定时器(最小间隔 1 分钟,MV3 限制)
chrome.alarms.create('daily-check', {
periodInMinutes: 1440 // 每天
});
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === 'daily-check') {
// 从存储读取上次状态,避免依赖内存
const { lastCheck } = await chrome.storage.local.get('lastCheck');
const now = Date.now();
if (!lastCheck || now - lastCheck > 12 * 60 * 60 * 1000) {
await doDailyWork();
await chrome.storage.local.set({ lastCheck: now });
}
}
});
async function doDailyWork() {
// 执行实际工作
}
常用 Service Worker 事件
| 事件 | 触发时机 | 典型用途 |
|---|---|---|
runtime.onInstalled |
安装、更新、Chrome 更新 | 初始化数据、打开引导页 |
runtime.onStartup |
浏览器启动 | 恢复运行时状态 |
tabs.onCreated / onUpdated / onRemoved |
标签页变化 | 标签管理、同步状态 |
tabs.onActivated |
切换活动标签 | 追踪当前页面 |
storage.onChanged |
存储数据变化 | 跨组件同步配置 |
alarms.onAlarm |
定时器触发 | 周期性任务 |
commands.onCommand |
快捷键按下 | 快速操作 |
contextMenus.onClicked |
右键菜单点击 | 上下文操作 |
chrome.alarms 的最小间隔限制
MV3 中,无论传入多小的 periodInMinutes,实际最小周期都被限制为 1 分钟(未打包的开发扩展可以更短)。如果你需要秒级精度,alarms 不是合适的选择,应该考虑在 Service Worker 活跃期间用 setTimeout 配合短期任务。
Content Scripts 深入
Content Script 是扩展中唯一能直接接触网页 DOM 的组件。它运行在一个隔离的世界(isolated world)中:与网页共享 DOM,但拥有独立的 JavaScript 环境,网页的变量、函数不会污染 Content Script,反之亦然。
隔离世界机制
隔离世界带来一个直接后果:Content Script 能读取和修改网页 DOM,但无法直接调用网页自己定义的 JavaScript 函数。如果网页定义了 window.myFunction,Content Script 里的 window.myFunction 是 undefined。
// content.js
// ✅ 能操作 DOM
const title = document.querySelector('h1');
title.style.color = 'red';
// ❌ 网页的变量不可见
// 假设网页执行了 window.appData = { user: 'Alice' }
console.log(window.appData); // undefined(隔离)
如果确实需要访问网页的 JavaScript 上下文,可以通过注入 <script> 标签的方式,让代码在网页的主世界执行:
// content.js
// 向主世界注入脚本
const script = document.createElement('script');
script.textContent = `
// 这段代码运行在网页上下文中,能访问 window.appData
window.postMessage({ type: 'FROM_PAGE', data: window.appData }, '*');
`;
(document.head || document.documentElement).appendChild(script);
script.remove();
// 接收网页上下文传回的数据
window.addEventListener('message', (event) => {
if (event.source !== window) return;
if (event.data.type === 'FROM_PAGE') {
console.log('收到网页数据:', event.data.data);
}
});
声明式注入与编程式注入
manifest 中的 content_scripts 是声明式注入,扩展加载时就确定注入哪些网站。这种方式简单但不够灵活。更强大的做法是用 chrome.scripting API 在运行时按需注入:
// manifest.json
{
"permissions": ["scripting", "activeTab"],
"host_permissions": ["https://*.example.com/*"]
}
// background.js(或 popup.js)
// 编程式注入:用户点击按钮时才注入
chrome.scripting.executeScript({
target: { tabId: targetTabId },
func: () => {
// 这段代码在目标页面执行
document.body.style.backgroundColor = '#fef3c7';
return document.title;
}
}).then((results) => {
console.log('注入结果:', results[0].result);
});
chrome.scripting.executeScript 既能注入函数(func),也能注入文件(files)。注入函数时还可以通过 args 传参:
chrome.scripting.executeScript({
target: { tabId: tabId },
func: highlightText,
args: ['关键词', 'yellow']
});
// 这个函数会被序列化后注入,所以不能引用外部闭包变量
function highlightText(keyword, color) {
const regex = new RegExp(keyword, 'gi');
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
null
);
// ... 高亮逻辑
}
Content Script 的能力边界
Content Script 能访问的 chrome.* API 很有限,主要是:
chrome.runtime(sendMessage、onMessage、id、getURL)chrome.storage(需要对应权限)chrome.i18n(国际化)
它不能直接调用 chrome.tabs、chrome.alarms、chrome.notifications 等 API。需要这些能力时,必须通过消息把请求转发给 Service Worker 处理。
注入 CSS
除了 JS,还能注入样式来改变网页外观:
chrome.scripting.insertCSS({
target: { tabId: tabId },
css: `
.ad-banner { display: none !important; }
body { font-size: 16px !important; }
`
});
也可以在 manifest 中静态声明 CSS 注入,适合样式固定的场景。
Popup 与 Options 页面
Popup 和 Options 本质上都是普通的 HTML 页面,运行在扩展上下文中,能访问完整的 chrome.* API。它们的区别在于使用场景:Popup 是点击工具栏图标时弹出的临时小窗,Options 是用户从扩展管理页进入的设置界面。
Popup 的生命周期特性
Popup 有一个关键特性:它只在打开时存在,关闭即销毁。每次打开 Popup,HTML 和 JS 都会重新加载,所有内存状态归零。因此 Popup 的初始化逻辑每次都会执行,不要指望 Popup 关闭前保存的变量还能保留。
// popup.js
// 每次打开 Popup 都会执行这里
document.addEventListener('DOMContentLoaded', async () => {
// 从持久存储恢复状态,而不是依赖内存
const { settings } = await chrome.storage.sync.get('settings');
renderUI(settings);
});
Popup 关闭时,正在执行的异步操作会被中断。如果你启动了一个需要完成的任务,不要在 Popup 里执行,应该把任务交给 Service Worker:
// popup.js
document.getElementById('startTask').addEventListener('click', async () => {
// 把任务委托给 Service Worker,即使 Popup 关闭也能继续
await chrome.runtime.sendMessage({ type: 'START_LONG_TASK' });
window.close(); // 主动关闭 Popup
});
Options 页面的两种形式
Options 页面可以是整页(options_page)或嵌入式(options_ui):
// 整页方式:在新标签页打开
"options_page": "options/options.html"
// 嵌入方式:嵌入到扩展管理页内(支持 Chrome 风格)
"options_ui": {
"page": "options/options.html",
"open_in_tab": false
}
options_ui 配合 open_in_tab: false 时,设置页会以嵌入式卡片出现在扩展详情页,体验更原生。如果设置项较多或需要更大空间,用 open_in_tab: true 让它在新标签页打开。
用 chrome.storage.sync 同步用户设置
chrome.storage.sync 会自动在用户登录的多个设备间同步数据(前提是开启了 Chrome 同步),非常适合存用户偏好:
// options.js
const form = document.getElementById('settingsForm');
// 加载现有设置
chrome.storage.sync.get({
theme: 'light',
fontSize: 14,
autoSave: true
}, (items) => {
form.theme.value = items.theme;
form.fontSize.value = items.fontSize;
form.autoSave.checked = items.autoSave;
});
// 保存设置
form.addEventListener('change', () => {
chrome.storage.sync.set({
theme: form.theme.value,
fontSize: Number(form.fontSize.value),
autoSave: form.autoSave.checked
});
});
storage.sync 的写入有频率限制(每分钟最多 120 次,每天最多 1800 次),单个项的最大体积约 8KB。对于大数据,用 storage.local。
Side Panel:常驻侧边栏
MV3 引入了 Side Panel API,允许扩展在浏览器侧边栏显示一个常驻面板,不像 Popup 那样关闭即销毁。这适合做翻译助手、笔记、实时数据看板等需要持续展示的功能。
// manifest.json
{
"permissions": ["sidePanel"]
}
// background.js
// 开启侧边栏
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true })
.catch((err) => console.error(err));
// manifest.json 中声明 side panel 页面
{
"side_panel": {
"default_path": "sidepanel/sidepanel.html"
}
}
Side Panel 会随浏览器窗口保持打开,跨标签页切换时也能持续存在,是 Popup 的有力补充。
消息通信机制
扩展的各个组件运行在隔离的上下文中,彼此无法直接调用函数或共享变量。Chrome 提供了几种通信方式,选对方式能让架构清晰许多。
一次性消息:sendMessage / onMessage
这是最常用的通信模式。发送方调用 sendMessage,接收方通过 onMessage 监听,可以同步或异步返回结果。
Popup / Content Script → Service Worker:
// popup.js(发送方)
const response = await chrome.runtime.sendMessage({
type: 'GET_PAGE_DATA',
tabId: currentTabId
});
console.log('Service Worker 返回:', response);
// background.js(接收方)
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GET_PAGE_DATA') {
// 异步处理:返回 true 让 Chrome 保持消息通道打开
handleGetPageData(message.tabId).then((data) => {
sendResponse({ success: true, data });
});
return true; // 关键:表示将异步调用 sendResponse
}
});
async function handleGetPageData(tabId) {
const tab = await chrome.tabs.get(tabId);
return { title: tab.title, url: tab.url };
}
异步返回时,监听器必须 return true,否则 Chrome 会立即关闭消息通道,sendResponse 的调用会失效。这是新手最常遇到的坑之一。
Service Worker → Content Script:
Service Worker 主动向某个标签页的 Content Script 发消息,需要指定 tabId:
// background.js
chrome.tabs.sendMessage(tabId, { type: 'HIGHLIGHT', keyword: 'Chrome' })
.then((response) => {
console.log('Content Script 回应:', response);
});
// content.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'HIGHLIGHT') {
const count = doHighlight(message.keyword);
sendResponse({ highlighted: count });
}
});
长连接:connect / onConnect
一次性消息适合「问一句答一句」的场景。如果需要持续的双向通信(比如 Content Script 实时上报网页变化),长连接更合适。
// content.js(建立连接)
const port = chrome.runtime.connect({ name: 'page-monitor' });
port.postMessage({ type: 'PAGE_LOADED', url: location.href });
// 监听网页变化,持续上报
const observer = new MutationObserver(() => {
port.postMessage({ type: 'DOM_CHANGED', count: document.querySelectorAll('*').length });
});
observer.observe(document.body, { childList: true, subtree: true });
// 接收 Service Worker 的指令
port.onMessage.addListener((msg) => {
if (msg.type === 'STOP_MONITORING') {
observer.disconnect();
}
});
// background.js(接收连接)
chrome.runtime.onConnect.addListener((port) => {
if (port.name === 'page-monitor') {
port.onMessage.addListener((msg) => {
if (msg.type === 'DOM_CHANGED') {
console.log('页面元素数量:', msg.count);
}
});
// 30 秒后通知停止
setTimeout(() => {
port.postMessage({ type: 'STOP_MONITORING' });
}, 30000);
}
});
注意长连接在 Service Worker 被终止时会断开。Content Script 应该处理 port.onDisconnect,在 Service Worker 重启后重新建立连接。
跨扩展通信
扩展之间也能通信,但需要目标扩展在 manifest 中声明 externally_connectable:
// 扩展 B 的 manifest.json
{
"externally_connectable": {
"ids": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] // 扩展 A 的 ID
}
}
// 扩展 A 向扩展 B 发消息
const extensionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
chrome.runtime.sendMessage(extensionId, { type: 'PING' }, (response) => {
console.log('收到扩展 B 回应:', response);
});
// 扩展 B 接收
chrome.runtime.onMessageExternal.addListener((msg, sender, sendResponse) => {
if (msg.type === 'PING') {
sendResponse({ type: 'PONG' });
}
});
消息通信的常见陷阱
| 问题 | 原因 | 解决方案 |
|---|---|---|
sendResponse 不生效 |
异步处理但未 return true |
监听器返回 true |
Could not establish connection |
接收方未加载或未注册监听 | 检查 Content Script 是否已注入目标页 |
| 多个监听器都响应 | 多个 onMessage 监听同一消息 |
第一个调用 sendResponse 的生效,注意顺序 |
| 消息发给了自己 | Popup 和 Service Worker 都监听 runtime.onMessage |
用 sender 区分来源 |
用 storage 替代部分消息
很多情况下,组件间共享数据不必用消息,直接用 chrome.storage 更简单。一方写入,另一方通过 storage.onChanged 监听变化:
// content.js:写入数据
chrome.storage.local.set({ pageInfo: { title: document.title, url: location.href } });
// popup.js:监听变化,自动更新
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes.pageInfo) {
renderPageInfo(changes.pageInfo.newValue);
}
});
这种模式解耦了组件,写入方不需要知道谁在读取,特别适合配置同步场景。
Storage API 详解
chrome.storage 是 MV3 扩展持久化数据的核心方式。由于 Service Worker 没有持久内存,几乎所有需要保留的状态都要依赖它。
三种存储区域
| 区域 | 容量 | 同步 | 适用场景 |
|---|---|---|---|
chrome.storage.local |
约 10MB(可申请无限) | 不同步 | 大数据、缓存、临时状态 |
chrome.storage.sync |
约 100KB(每项 8KB) | 跨设备同步 | 用户偏好、设置 |
chrome.storage.session |
约 10MB | 不同步,内存级 | Service Worker 生命周期内的临时数据 |
storage.session 是 MV3 新增的,数据存在内存中,浏览器关闭后消失,但能在 Service Worker 重启之间保持(因为它独立于 Service Worker 进程)。适合存放不想持久化但需要在 SW 重启后恢复的临时数据。
基本 CRUD 操作
所有存储区域都用相同的 API,且都支持 Promise:
// 写入
await chrome.storage.local.set({ key: 'value', count: 42 });
// 读取(支持默认值)
const { key = 'default', count = 0 } = await chrome.storage.local.get(['key', 'count']);
// 读取全部
const all = await chrome.storage.local.get(null);
// 删除
await chrome.storage.local.remove('key');
// 清空
await chrome.storage.local.clear();
监听变化
storage.onChanged 在任何组件修改存储时都会触发,是实现跨组件响应式更新的利器:
chrome.storage.onChanged.addListener((changes, areaName) => {
for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
console.log(`[${areaName}] ${key}: ${oldValue} → ${newValue}`);
}
});
存储结构设计建议
不要把所有数据塞进一个巨大的对象。按用途分键存储,便于局部更新和监听:
// 推荐:分键存储
{
settings: { theme: 'dark', fontSize: 14 },
statistics: { pagesVisited: 1024, timeSpent: 36000 },
cache: { 'https://example.com': { ... } }
}
// 不推荐:巨型对象
{
everything: { settings: {...}, statistics: {...}, cache: {...} }
}
巨型对象每次更新都要整体读写,既慢又容易触发频率限制。分键存储后,更新某个部分只需读写对应的键。
突破容量限制
storage.local 默认约 10MB。如果需要更大空间,在 manifest 中声明 unlimitedStorage 权限:
"permissions": ["storage", "unlimitedStorage"]
注意 unlimitedStorage 会让你的扩展在安装时提示用户「存储空间无限制」,可能影响安装转化率。仅在确实需要时申请。
网络请求处理与 declarativeNetRequest
MV3 对网络请求处理做了根本性改变。MV2 时代的 chrome.webRequest(blocking 模式)允许扩展在 JS 中拦截和修改任意请求,这带来了性能和安全问题。MV3 改用声明式的 chrome.declarativeNetRequest(DNR),扩展只需声明匹配规则和动作,Chrome 在原生层完成拦截,扩展的 JS 代码根本看不到请求内容 $TRAE_REF。
DNR 与 webRequest 的本质区别
| 特性 | webRequest (MV2) | declarativeNetRequest (MV3) |
|---|---|---|
| 处理方式 | JS 回调拦截 | 原生规则匹配 |
| 能否查看请求体 | 能 | 不能(隐私保护) |
| 性能 | 每个请求都经过 JS | 原生层快速匹配 |
| 动态修改 | 任意修改 | 预定义动作(block/redirect/modifyHeaders) |
| 适用场景 | 需要动态检查请求内容 | 广告拦截、规则化过滤 |
如果你需要根据请求内容做动态判断,DNR 无法满足,只能用非阻塞的 webRequest 做观察(无法阻止)。
静态规则:用 JSON 文件声明
静态规则放在单独的 JSON 文件中,在 manifest 里引用:
// manifest.json
{
"permissions": ["declarativeNetRequest"],
"declarative_net_request": {
"rule_resources": [
{
"id": "ruleset_1",
"enabled": true,
"path": "rules/rules.json"
}
]
}
}
// rules/rules.json
[
{
"id": 1,
"priority": 1,
"action": { "type": "block" },
"condition": {
"urlFilter": "||doubleclick.net^",
"resourceTypes": ["script", "image", "xmlhttprequest"]
}
},
{
"id": 2,
"priority": 1,
"action": { "type": "redirect", "redirect": { "url": "https://example.com/blocked.html" } },
"condition": {
"urlFilter": "||ads.example.com^",
"resourceTypes": ["main_frame"]
}
}
]
urlFilter 使用类似 Adblock Plus 的语法:|| 匹配域名边界,^ 匹配分隔符。resourceTypes 指定匹配哪些类型的资源。
动态规则:用代码添加
需要运行时调整规则时,用 updateDynamicRules 和 updateSessionRules:
// background.js
// 添加动态规则
await chrome.declarativeNetRequest.updateDynamicRules({
addRules: [
{
id: 1001,
priority: 2,
action: {
type: 'redirect',
redirect: { extensionPath: '/blocked.html' }
},
condition: {
urlFilter: '*://*.tracking-site.com/*',
resourceTypes: ['main_frame']
}
}
],
removeRuleIds: [1001] // 先移除同 ID 的旧规则
});
// 查询当前生效的规则
const rules = await chrome.declarativeNetRequest.getDynamicRules();
console.log('当前动态规则数:', rules.length);
updateDynamicRules 添加的规则持久化(浏览器重启后保留),updateSessionRules 添加的规则仅在当前会话有效。
修改请求头
DNR 可以修改请求和响应的头部,常用于注入自定义 Header 或移除追踪 Cookie:
{
"id": 3,
"priority": 1,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{ "header": "X-Custom-Header", "operation": "set", "value": "my-extension" },
{ "header": "Cookie", "operation": "remove" }
]
},
"condition": {
"urlFilter": "*://api.example.com/*",
"resourceTypes": ["xmlhttprequest"]
}
}
modifyHeaders 支持三种操作:set(设置/覆盖)、append(追加)、remove(删除)。
规则优先级与冲突处理
当多条规则匹配同一个请求时,Chrome 按 action 类型和 priority 排序:
allow/allowAllRequests规则优先,匹配则放行- 然后是
block规则 - 接着是
redirect/upgradeScheme - 最后是
modifyHeaders
同一类型内按 priority 数值大的优先。理解这个顺序能避免「我加了 block 规则却没生效」的困惑——可能被更高优先级的 allow 规则覆盖了。
仍然可用的 webRequest(非阻塞)
MV3 中 webRequest API 仍然存在,但只支持观察模式("webRequest" 权限),不能阻塞或修改请求。适合做请求日志、分析等只读用途:
// background.js
chrome.webRequest.onBeforeRequest.addListener(
(details) => {
console.log('请求发起:', details.url);
},
{ urls: ['<all_urls>'] }
);
fetch 与跨域请求
扩展自己的上下文(Service Worker、Popup)中发起 fetch 请求时,受 host_permissions 约束。声明了对应主机权限后,扩展能绕过网页的 CORS 限制直接请求:
// manifest.json
{
"host_permissions": ["https://api.github.com/*"]
}
// background.js
// 因为声明了 host_permissions,这里不受 CORS 限制
const res = await fetch('https://api.github.com/repos/microsoft/vscode');
const data = await res.json();
console.log('Star 数:', data.stargazers_count);
Content Script 中的 fetch 仍然受网页自身的 CORS 策略约束,因为它运行在网页上下文。需要跨域请求时,应把请求转发给 Service Worker 处理。
浏览器集成 API
扩展的强大之处在于能与浏览器的各项功能深度集成。这一节介绍几个高频使用的集成 API。
右键菜单 contextMenus
通过右键菜单,用户可以在选中文字、图片或链接时快速触发扩展功能。菜单项需要在 Service Worker 中注册:
// background.js
// 安装时创建菜单(只能创建一次)
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: 'search-selection',
title: '用扩展搜索「%s」',
contexts: ['selection'] // 仅当选中文字时显示
});
chrome.contextMenus.create({
id: 'save-image',
title: '保存图片到收藏',
contexts: ['image']
});
// 创建子菜单分组
chrome.contextMenus.create({
id: 'share-group',
title: '分享到',
contexts: ['page', 'link']
});
chrome.contextMenus.create({
id: 'share-twitter',
parentId: 'share-group',
title: 'Twitter',
contexts: ['page']
});
});
// 处理点击
chrome.contextMenus.onClicked.addListener((info, tab) => {
if (info.menuItemId === 'search-selection') {
// info.selectionText 是选中的文字
chrome.tabs.create({
url: `https://www.google.com/search?q=${encodeURIComponent(info.selectionText)}`
});
} else if (info.menuItemId === 'save-image') {
// info.srcUrl 是图片地址
saveImageToCollection(info.srcUrl, tab);
}
});
%s 是占位符,会被替换为用户选中的文字。contexts 控制菜单在什么场景下出现,可选值包括 page、selection、link、image、video、audio 等。
键盘快捷键 commands
commands API 让用户用快捷键触发扩展功能,无需点击图标。命令在 manifest 中声明:
// manifest.json
{
"permissions": ["commands"],
"commands": {
"_execute_action": {
"suggested_key": { "default": "Ctrl+Shift+Y", "mac": "Command+Shift+Y" },
"description": "打开 Popup"
},
"highlight-selection": {
"suggested_key": { "default": "Ctrl+Shift+H", "mac": "Command+Shift+H" },
"description": "高亮选中文字"
}
}
}
_execute_action 是保留命令名,绑定后会用快捷键打开 Popup。自定义命令通过 commands.onCommand 监听:
// background.js
chrome.commands.onCommand.addListener(async (command) => {
if (command === 'highlight-selection') {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const selection = window.getSelection().toString();
if (selection) {
document.execCommand('hiliteColor', false, 'yellow');
}
}
});
}
});
用户可以在 chrome://extensions/shortcuts 中自定义快捷键,suggested_key 只是建议值。注意快捷键冲突时,后安装的扩展会失败,代码中可用 chrome.commands.getAll() 检查状态。
地址栏 omnibox
omnibox API 让扩展能响应地址栏输入,类似搜索建议的体验。用户输入一个关键字加空格后,扩展接管后续输入:
// manifest.json
{
"omnibox": { "keyword": "mdn" }
}
// background.js
chrome.omnibox.onInputChanged.addListener((text, suggest) => {
// text 是用户在关键字后输入的内容
suggest([
{ content: `${text} Array`, description: '搜索 MDN: ' + text + ' Array' },
{ content: `${text} Promise`, description: '搜索 MDN: ' + text + ' Promise' }
]);
});
chrome.omnibox.onInputEntered.addListener((text) => {
chrome.tabs.create({
url: `https://developer.mozilla.org/zh-CN/search?q=${encodeURIComponent(text)}`
});
});
用户在地址栏输入 mdn 后,扩展会提供建议,回车后打开 MDN 搜索页。
通知 notifications
扩展能向操作系统发送桌面通知:
"permissions": ["notifications"]
// background.js
chrome.notifications.create('task-done', {
type: 'basic',
iconUrl: 'icons/icon128.png',
title: '任务完成',
message: '你的后台任务已经处理完毕',
priority: 2
});
// 点击通知
chrome.notifications.onClicked.addListener((notificationId) => {
chrome.tabs.create({ url: 'https://example.com/results' });
chrome.notifications.clear(notificationId);
});
通知的 priority 范围 -2 到 2,越高越可能显示在系统通知中心顶部。
标签页管理 tabs
chrome.tabs 是扩展最常用的 API 之一,能查询、创建、更新、关闭标签页:
// 查询所有标签页
const tabs = await chrome.tabs.query({});
// 查询当前活动标签
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
// 创建新标签
const newTab = await chrome.tabs.create({
url: 'https://example.com',
active: false // 后台打开
});
// 批量创建多个标签
await chrome.tabs.create({ url: 'https://example.com/1', active: false });
await chrome.tabs.create({ url: 'https://example.com/2', active: false });
// 更新标签(如重新加载)
await chrome.tabs.reload(tabId, { bypassCache: true });
// 关闭标签
await chrome.tabs.remove(tabId);
// 将标签分组(需要 tabGroups 权限)
const groupId = await chrome.tabs.group({ tabIds: [tab1, tab2] });
await chrome.tabGroups.update(groupId, { title: '研究', color: 'blue' });
chrome.action 动态控制
工具栏图标和标题可以在运行时动态修改,用来反映扩展当前状态:
// background.js
async function setActionState(enabled) {
await chrome.action.setIcon({
path: enabled ? 'icons/icon-active.png' : 'icons/icon-inactive.png'
});
await chrome.action.setTitle({
title: enabled ? '已开启' : '已关闭'
});
await chrome.action.setBadgeText({ text: enabled ? 'ON' : 'OFF' });
await chrome.action.setBadgeBackgroundColor({ color: enabled ? '#22c55e' : '#94a3b8' });
}
setBadgeText 在图标右下角显示一个小徽章,常用于显示未读数或开关状态。
权限系统深入
权限设计直接影响扩展能否通过 Chrome Web Store 审核,以及用户是否愿意安装。MV3 的权限模型比 MV2 更精细,遵循最小权限原则。
权限分类
| 类别 | 示例 | 授予时机 | 是否影响审核 |
|---|---|---|---|
| 普通权限 | storage、alarms、contextMenus |
安装时自动授予 | 影响小 |
| 可选权限 | tabs、bookmarks |
用户主动授权 | 需说明用途 |
| 主机权限 | https://*.example.com/* |
安装时或运行时 | 影响较大 |
| activeTab | (特殊) | 用户点击图标时临时授予 | 影响很小 |
可选权限:按需申请
把非必需的权限声明为可选,安装时不要求,运行到对应功能时才向用户申请。这能降低安装门槛,也更容易过审:
// manifest.json
{
"permissions": ["storage"],
"optional_permissions": ["tabs", "bookmarks"],
"host_permissions": ["https://*.example.com/*"],
"optional_host_permissions": ["https://*.github.com/*"]
}
// 运行时申请权限
async function requestTabsPermission() {
const granted = await chrome.permissions.request({
permissions: ['tabs']
});
if (granted) {
// 用户同意,现在可以使用 tabs API
startTabMonitoring();
} else {
showPermissionDeniedMessage();
}
}
// 检查是否已有权限
const hasPermission = await chrome.permissions.contains({
permissions: ['tabs']
});
权限申请弹窗必须由用户手势触发(如点击按钮),不能在页面加载时自动弹出。
activeTab 的设计哲学
activeTab 是最应该优先使用的权限。它不要求预先声明主机权限,而是在用户主动与扩展交互(点击图标、快捷键、右键菜单)的瞬间,临时授予当前标签页的访问权。标签页切换或刷新后权限自动失效。
这种设计让扩展能在「不需要访问所有网站」的前提下工作,审核时会标记为低风险,用户安装时也不会看到可怕的全域权限警告。
// 推荐:用 activeTab 替代广泛的主机权限
{
"permissions": ["activeTab", "scripting"]
// 而不是 "host_permissions": ["<all_urls>"]
}
权限与审核风险
Chrome Web Store 审核会重点关注以下高风险权限组合:
<all_urls>主机权限:能访问所有网站,需要充分理由tabs配合主机权限:能读取所有标签页内容clipboardWrite/clipboardRead:访问剪贴板declarativeNetRequest配合广泛主机权限:能拦截所有流量
申请这些权限时,扩展描述和隐私政策必须清楚说明用途。能用 activeTab 解决的就不要用 <all_urls>。
实战项目:网页收藏标注器
现在把前面学的内容整合起来,做一个完整的扩展。功能是:用户在任意网页选中文本,通过右键菜单或快捷键将选中的内容连同页面信息保存为收藏,并在 Popup 中查看所有收藏,支持搜索和删除。
项目结构
web-clipper/
├── manifest.json
├── background.js
├── content/
│ └── content.js
├── popup/
│ ├── popup.html
│ ├── popup.js
│ └── popup.css
└── icons/
├── icon16.png
├── icon48.png
└── icon128.png
manifest.json
{
"manifest_version": 3,
"name": "网页收藏标注器",
"version": "1.0.0",
"description": "选中网页文本一键收藏,随时回看",
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"action": {
"default_popup": "popup/popup.html",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon48.png"
}
},
"background": {
"service_worker": "background.js",
"type": "module"
},
"permissions": ["storage", "contextMenus", "commands", "activeTab", "scripting"],
"commands": {
"clip-selection": {
"suggested_key": { "default": "Ctrl+Shift+S", "mac": "Command+Shift+S" },
"description": "收藏选中的文本"
}
}
}
这里只用 activeTab 而不用主机权限,因为收藏操作都由用户主动触发(右键或快捷键),符合 activeTab 的使用场景。
background.js:核心逻辑
// 监听安装,创建右键菜单
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: 'clip-selection',
title: '收藏选中内容',
contexts: ['selection']
});
});
// 右键菜单点击
chrome.contextMenus.onClicked.addListener((info, tab) => {
if (info.menuItemId === 'clip-selection') {
saveClip(info.selectionText, tab);
}
});
// 快捷键触发
chrome.commands.onCommand.addListener(async (command) => {
if (command === 'clip-selection') {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
// 注入函数获取选中文本
const [result] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => window.getSelection().toString()
});
if (result.result) {
saveClip(result.result, tab);
showBadgeNotification();
}
}
});
// 保存收藏到 storage
async function saveClip(text, tab) {
const clip = {
id: Date.now().toString(),
text: text.slice(0, 1000), // 限制长度
title: tab.title,
url: tab.url,
createdAt: new Date().toISOString()
};
const { clips = [] } = await chrome.storage.local.get('clips');
clips.unshift(clip); // 最新的在前
// 限制最多保存 500 条
if (clips.length > 500) clips.length = 500;
await chrome.storage.local.set({ clips });
// 更新图标徽章显示总数
chrome.action.setBadgeText({ text: String(clips.length) });
chrome.action.setBadgeBackgroundColor({ color: '#1a73e8' });
}
// 收藏成功后短暂提示
function showBadgeNotification() {
chrome.action.setBadgeText({ text: '✓' });
chrome.action.setBadgeBackgroundColor({ color: '#22c55e' });
setTimeout(async () => {
const { clips = [] } = await chrome.storage.local.get('clips');
chrome.action.setBadgeText({
text: clips.length > 0 ? String(clips.length) : ''
});
chrome.action.setBadgeBackgroundColor({ color: '#1a73e8' });
}, 1500);
}
// 浏览器启动时恢复徽章
chrome.runtime.onStartup.addListener(async () => {
const { clips = [] } = await chrome.storage.local.get('clips');
if (clips.length > 0) {
chrome.action.setBadgeText({ text: String(clips.length) });
chrome.action.setBadgeBackgroundColor({ color: '#1a73e8' });
}
});
// 监听存储变化,跨组件同步徽章
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes.clips) {
const count = changes.clips.newValue?.length || 0;
chrome.action.setBadgeText({ text: count > 0 ? String(count) : '' });
}
});
popup/popup.html:收藏列表界面
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="header">
<h1>我的收藏</h1>
<input type="search" id="search" placeholder="搜索收藏内容...">
</div>
<div id="clips-list"></div>
<div id="empty" class="empty" hidden>还没有收藏,选中网页文字后右键或按 Ctrl+Shift+S</div>
<script src="popup.js"></script>
</body>
</html>
popup/popup.css
body {
width: 380px;
max-height: 500px;
font-family: system-ui, sans-serif;
margin: 0;
display: flex;
flex-direction: column;
}
.header {
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
position: sticky;
top: 0;
background: #fff;
}
.header h1 {
font-size: 16px;
margin: 0 0 8px;
}
#search {
width: 100%;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
box-sizing: border-box;
font-size: 13px;
}
#clips-list {
overflow-y: auto;
flex: 1;
}
.clip-item {
padding: 12px 16px;
border-bottom: 1px solid #f3f4f6;
cursor: pointer;
}
.clip-item:hover {
background: #f9fafb;
}
.clip-text {
font-size: 13px;
color: #1f2937;
margin-bottom: 4px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.clip-meta {
font-size: 11px;
color: #9ca3af;
display: flex;
justify-content: space-between;
align-items: center;
}
.clip-source {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 220px;
}
.delete-btn {
background: none;
border: none;
color: #ef4444;
cursor: pointer;
font-size: 11px;
padding: 2px 6px;
}
.empty {
padding: 40px 20px;
text-align: center;
color: #9ca3af;
font-size: 13px;
}
popup/popup.js:列表渲染与交互
let allClips = [];
document.addEventListener('DOMContentLoaded', loadClips);
async function loadClips() {
const { clips = [] } = await chrome.storage.local.get('clips');
allClips = clips;
renderClips(allClips);
}
function renderClips(clips) {
const list = document.getElementById('clips-list');
const empty = document.getElementById('empty');
if (clips.length === 0) {
list.innerHTML = '';
empty.hidden = false;
return;
}
empty.hidden = true;
list.innerHTML = clips.map(clip => `
<div class="clip-item" data-id="${clip.id}">
<div class="clip-text">${escapeHtml(clip.text)}</div>
<div class="clip-meta">
<span class="clip-source" title="${escapeHtml(clip.url)}">
${escapeHtml(clip.title || clip.url)}
</span>
<button class="delete-btn" data-delete="${clip.id}">删除</button>
</div>
</div>
`).join('');
// 点击条目打开原网页
list.querySelectorAll('.clip-item').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.dataset.delete) return;
const clip = clips.find(c => c.id === item.dataset.id);
if (clip) chrome.tabs.create({ url: clip.url });
});
});
// 删除按钮
list.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
await deleteClip(btn.dataset.delete);
});
});
}
async function deleteClip(id) {
const { clips = [] } = await chrome.storage.local.get('clips');
const filtered = clips.filter(c => c.id !== id);
await chrome.storage.local.set({ clips: filtered });
allClips = filtered;
renderClips(filterBySearch(allClips));
}
// 搜索过滤
document.getElementById('search').addEventListener('input', (e) => {
const filtered = filterBySearch(allClips, e.target.value);
renderClips(filtered);
});
function filterBySearch(clips, keyword = '') {
if (!keyword) return clips;
const lower = keyword.toLowerCase();
return clips.filter(c =>
c.text.toLowerCase().includes(lower) ||
(c.title || '').toLowerCase().includes(lower)
);
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
这个项目综合运用了什么
| 技术 | 在项目中的体现 |
|---|---|
| activeTab + scripting | 快捷键触发时编程式注入获取选中文本 |
| contextMenus | 右键菜单收藏 |
| commands | 键盘快捷键 |
| storage.local | 持久化收藏列表 |
| storage.onChanged | Service Worker 和 Popup 间同步徽章 |
| action.setBadgeText | 图标徽章显示收藏数量 |
| runtime.onStartup | 浏览器重启后恢复徽章状态 |
这个扩展大约 200 行代码,已经是一个可用的产品。它展示了 MV3 扩展的典型架构:Service Worker 处理事件和业务逻辑,Popup 负责展示,storage 作为数据中枢,各组件通过事件和存储松耦合协作。
调试技巧
扩展的每个组件都有独立的调试入口,掌握这些技巧能大幅缩短排错时间。
调试 Service Worker
在 chrome://extensions/ 页面,找到你的扩展卡片,点击「Service Worker」链接会打开一个专门的 DevTools 窗口。这里的 Console 显示 Service Worker 的日志,Sources 面板可以打断点。
一个常见困扰是 Service Worker 频繁终止,断点调试到一半就断了。可以在 DevTools 的 Application 面板勾选「Service Workers → Keep running」,让它在调试期间不被终止。
调试 Popup
Popup 比较特殊:它关闭后 DevTools 也会关闭。调试方法是右键点击工具栏上的扩展图标,选择「审查弹出内容」,这样打开的 DevTools 会和 Popup 一起存在。注意如果你在 DevTools 中切换到别的面板,Popup 可能失焦关闭。
更稳妥的方式是在 Popup 的 HTML 里临时加一句 debugger;,或者在代码中故意不关闭 Popup。
调试 Content Script
Content Script 运行在网页中,直接用网页的 DevTools 调试。打开网页的 DevTools,在 Console 面板顶部的上下文选择器(默认显示 top)中切换到你的扩展 Content Script 的上下文,就能看到它的 console 输出并执行命令。Sources 面板中 Content Script 的代码会出现在 chrome-extension://<id>/ 路径下。
调试 declarativeNetRequest 规则
DNR 规则不生效时很难排查,因为 JS 代码看不到匹配过程。可以在 manifest 中添加 declarativeNetRequestFeedback 权限,然后监听匹配事件:
chrome.declarativeNetRequest.onRuleMatchedDebug.addListener((info) => {
console.log('规则匹配:', info.ruleId, '请求:', info.request.url);
});
这个 API 仅在未打包的开发扩展中可用,发布版本无效。它能把每条匹配的规则和对应请求打印出来,是排查规则问题的利器。
查看扩展存储数据
DevTools 的 Application 面板 → Storage → Extension storage 区域可以直接查看和编辑 chrome.storage.local、sync、session 的内容,无需写代码。
热重载开发体验
每次改代码都要手动点刷新很烦。可以在 Service Worker 中加一段自动重载逻辑(仅开发环境):
// 仅开发时使用
if (chrome.runtime.getManifest().version === '0.0.0') {
chrome.runtime.onInstalled.addListener(() => {
chrome.tabs.query({}, (tabs) => {
tabs.forEach((tab) => {
if (tab.url?.startsWith('http')) {
chrome.tabs.reload(tab.id);
}
});
});
});
}
或者使用社区的热重载工具,如 crxjs 或 vite-plugin-web-extension,它们能监听文件变化自动重载扩展和受影响的页面。
测试策略
扩展的测试比普通 Web 应用复杂,因为涉及多个隔离的执行环境。建议分层测试。
单元测试:纯逻辑
把不依赖 chrome.* API 的纯函数抽离出来,用 Jest 或 Vitest 常规测试。比如收藏器的过滤函数、数据格式化函数:
// utils.js — 可单独测试的纯函数
export function filterBySearch(clips, keyword) {
if (!keyword) return clips;
const lower = keyword.toLowerCase();
return clips.filter(c =>
c.text.toLowerCase().includes(lower)
);
}
Mock chrome API 测试
依赖 chrome.* 的逻辑可以用 mock 框架模拟 API。sinon-chrome 是常用的 chrome API mock 库:
import chrome from 'sinon-chrome';
// 替换全局 chrome 对象
global.chrome = chrome;
import { saveClip } from './background.js';
test('saveClip 应该写入 storage', async () => {
chrome.storage.local.get.yields({ clips: [] });
chrome.storage.local.set.yields();
await saveClip('测试文本', { title: '测试页', url: 'https://test.com' });
expect(chrome.storage.local.set.calledOnce).toBe(true);
const arg = chrome.storage.local.set.firstCall.args[0];
expect(arg.clips[0].text).toBe('测试文本');
});
E2E 测试:Puppeteer
端到端测试可以用 Puppeteer 加载真实扩展并模拟用户操作。Puppeteer 支持以加载扩展的方式启动 Chrome:
const puppeteer = require('puppeteer');
const browser = await puppeteer.launch({
headless: false, // 扩展测试需要非 headless 模式
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`
]
});
// 测试扩展是否正确加载
const targets = browser.targets();
const serviceWorkerTarget = targets.find(
t => t.type() === 'service_worker'
);
// ... 模拟操作并断言
打包与发布
开发完成后,需要打包并提交到 Chrome Web Store。
打包扩展
Chrome 扩展本质上是一个包含所有文件的目录,打包就是把它压缩成 ZIP。注意不要把 node_modules、.git 等无关文件打进包里。
# 在项目根目录执行
zip -r my-extension.zip . \
-x "node_modules/*" \
-x ".git/*" \
-x "*.md" \
-x "tests/*"
ZIP 的根目录应该直接是 manifest.json 等文件,不能多套一层目录。
注册开发者账号
发布到 Chrome Web Store 需要一次性支付 5 美元的开发者注册费(使用 Google 账号和信用卡)。注册后可以在 Chrome Web Store Developer Dashboard 上传扩展。
上传与填写信息
上传 ZIP 包后,需要填写以下信息:
- 商店描述:详细说明功能,审核会参考
- 截图:至少 1 张,建议 1280x800 或 640x400
- 宣传图:可选,但有助于商店展示
- 类别与语言
- 隐私政策 URL:如果扩展收集任何用户数据则必需
- 权限说明:逐项解释为什么需要每个权限
权限 justification 要点
审核中最常被拒的就是权限说明不清。对于每个非普通权限,要写明:
- 这个权限具体用来做什么
- 为什么没有更小权限的替代方案
- 数据是否上传到服务器
例如 tabs 权限的说明应写:「用于读取当前标签页的标题和 URL,以便用户收藏。数据仅存储在本地,不上传任何服务器。」
审核流程
提交后进入审核队列,通常需要几天到几周。审核包括:
- 自动扫描:恶意代码检测、权限滥用检查
- 人工审核:功能与描述是否一致、权限是否合理、隐私合规
- 反复修改:如果被拒,根据反馈修改后重新提交
审核期间扩展状态为「审核中」,无法被用户安装。通过后会变为「已发布」,立即对用户可见。
版本更新
每次发布新版本,manifest 中的 version 必须递增。Chrome Web Store 不允许上传相同版本号的包。建议使用语义化版本号(主版本.次版本.修订号),并在商店描述中写明更新内容。
最佳实践与性能优化
Service Worker 保活策略
Service Worker 会被频繁终止,但有些操作需要它保持活跃。合法的保活方式是确保在事件处理期间有未完成的异步操作。Chrome 会在 chrome.runtime.onMessage 的异步响应期间、fetch 请求期间、chrome.alarms 触发期间保持 SW 活跃。
不要用 setInterval 轮询来保活,这是被明确禁止的,可能导致扩展被拒。如果需要长时间运行的任务,考虑用 offscreen API 创建一个离屏文档来承载。
减少存储读写
chrome.storage 的读写是异步且有开销的,频繁操作会影响性能。批量写入优于多次单写:
// ❌ 多次写入
await chrome.storage.local.set({ a: 1 });
await chrome.storage.local.set({ b: 2 });
await chrome.storage.local.set({ c: 3 });
// ✅ 一次批量写入
await chrome.storage.local.set({ a: 1, b: 2, c: 3 });
Content Script 的性能
Content Script 运行在用户的网页中,性能问题会直接影响网页体验:
- 避免在
document_start注入大量脚本,会阻塞页面渲染 - 用
MutationObserver监听 DOM 变化时设置合理的防抖,避免频繁回调 - 注入的 CSS 尽量精简,避免用
*通配符 - 不要在 Content Script 中做耗时计算,交给 Service Worker
安全实践
- 永远不要用
innerHTML拼接用户数据或网页内容,用textContent或转义 chrome.scripting.executeScript注入的函数不要引用外部变量,通过args传参- 远程代码执行在 MV3 中被完全禁止,所有逻辑必须打包在扩展内
- 敏感数据不要存在
storage.local中明文,必要时加密
国际化
用 chrome.i18n 支持多语言。创建 _locales 目录,每个语言一个子目录:
_locales/
├── zh_CN/
│ └── messages.json
├── en/
│ └── messages.json
// _locales/zh_CN/messages.json
{
"extName": { "message": "网页收藏标注器" },
"extDescription": { "message": "选中网页文本一键收藏" }
}
manifest 中用 __MSG_extName__ 引用,代码中用 chrome.i18n.getMessage('extName')。声明 default_locale 后,Chrome 会根据浏览器语言自动选择。
从 MV2 迁移到 MV3
如果你的旧扩展还在 MV2,必须迁移。以下是关键的迁移点。
manifest 字段变更
- "manifest_version": 2,
+ "manifest_version": 3,
// background 改为 service worker
- "background": {
- "scripts": ["background.js"],
- "persistent": true
- }
+ "background": {
+ "service_worker": "background.js"
+ }
// action 替代 browser_action / page_action
- "browser_action": { ... }
+ "action": { ... }
// 主机权限从 permissions 分离
- "permissions": ["storage", "https://*.example.com/*"]
+ "permissions": ["storage"],
+ "host_permissions": ["https://*.example.com/*"]
background page → service worker
这是最大的工作量。常驻 background page 的代码需要适配事件驱动模型:
- 移除全局状态变量,改用
chrome.storage - 把
setInterval换成chrome.alarms - 确保 DOM API 调用全部移除(Service Worker 无 DOM)
XMLHttpRequest换成fetch
webRequest blocking → declarativeNetRequest
如果旧扩展用了 blocking webRequest 拦截请求,需要重新设计为 DNR 规则。无法 1:1 迁移的场景(如需要读取请求体动态判断),需要寻找替代方案或调整产品逻辑。
移除远程代码
MV2 时代常见的从 CDN 动态加载脚本的做法(fetch 远程 JS 然后 eval)在 MV3 中被禁止。所有代码必须打包在扩展内。如果依赖第三方库,打包进扩展。
常见问题
Service Worker 的全局变量经常丢失怎么办?
这是 MV3 的正常行为。Service Worker 空闲后会被终止,重启后全局变量归零。需要持久化的数据用 chrome.storage.local,需要跨重启恢复的运行时状态用 chrome.storage.session。
为什么 Content Script 的 fetch 请求报 CORS 错误?
Content Script 运行在网页上下文,受网页自身的 CORS 策略约束。需要跨域请求时,把请求转发给 Service Worker,由它在扩展上下文中发起 fetch(前提是声明了对应 host_permissions)。
Popup 里 window.close() 后异步任务还在跑吗?
不会。Popup 关闭后其 JS 执行环境立即销毁,所有异步操作中止。需要持续运行的任务应交给 Service Worker。
chrome.tabs.executeScript 报错找不到方法?
tabs.executeScript 是 MV2 的 API,MV3 中已移除。改用 chrome.scripting.executeScript,注意需要 scripting 权限,且参数结构不同(用 target: { tabId } 而非直接传 tabId)。
扩展更新后旧版用户的数据会丢失吗?
不会。chrome.storage 的数据与扩展绑定,更新版本后数据保留。但如果改变了存储的数据结构,需要写迁移逻辑:在 runtime.onInstalled 中检测 reason === 'update',读取旧结构数据并转换为新结构。
declarativeNetRequest 的规则数量有限制吗?
有。动态规则最多 30000 条,会话规则最多 5000 条。静态规则集每个最多 66000 条,且同时启用的静态规则集有限制(通常 50 个)。对于广告拦截这类需要海量规则的场景,需要合理组织规则集并按需启用。
如何在 Content Script 中使用第三方库(如 jQuery)?
在 manifest 的 content_scripts.js 数组中按顺序列出,先列库再列你的脚本:
"js": ["lib/jquery.min.js", "content/content.js"]
它们会按顺序注入到同一个隔离世界,Content Script 能直接使用 $。不过现代扩展开发建议尽量用原生 API,减少依赖体积。
结语
Chrome 扩展开发的核心在于理解组件边界与通信模型。Service Worker、Content Script、Popup 三者各司其职,通过消息和存储协作,这套架构在 MV3 中已经稳定。掌握 chrome.storage、chrome.scripting、declarativeNetRequest 这几个核心 API,加上对权限模型的理解,就能应对绝大多数扩展需求。
从本文的第一个 Hello World 到最后的网页收藏标注器,你已经走完了从入门到能独立开发产品的路径。真正深入还需要在实践中遇到具体问题、查阅官方 API 文档、阅读优秀开源扩展的源码。Chrome 扩展的官方文档(developer.chrome.com/docs/extensions)是最权威的参考,遇到 API 细节疑问时应以它为准。
扩展生态仍在演进,Side Panel、Offscreen API 等新能力还在不断加入。保持对官方更新日志的关注,能让你的扩展用上最新的能力。