Chrome Extension(浏览器扩展)是开发者最容易变现的产品形态之一——Chrome Web Store 上排名前 100 的扩展中,超过 40% 是独立开发者的作品,年收入中位数约 5 万美元。但自从 2024 年 Manifest V2 正式停用后,许多开发者发现 V3 的架构范式完全不同:Background Page 变成了 Service Worker,XMLHttpRequest 被 fetch 取代,持久化状态被彻底移除。如果你还在用 V2 的思维写 V3 的代码,大概率会踩进一堆坑里。
本文基于真实项目经验,系统讲解 Manifest V3 的核心架构、通信机制、存储策略和性能优化,所有代码均可直接运行。
📌 **本文定位:**面向有 JavaScript 基础的开发者,不讲基础语法,聚焦 V3 特有的架构差异和生产级实践。如果你需要基础入门,建议先阅读 Chrome 官方文档。
🏗️ 一、Manifest V3 架构核心变化
1.1 从 Background Page 到 Service Worker
Manifest V3 最大的变化就是 Background Script 从持久化页面变成了 Service Worker。这意味着你的后台代码随时可能被浏览器终止,只在需要时被唤醒。
| 特性 | Manifest V2 (已废弃) | Manifest V3 (当前) |
|---|---|---|
| 后台环境 | Background Page(持久化) | Service Worker(事件驱动) |
| DOM 访问 | ✅ 可以访问 window/document |
❌ 无法访问 DOM |
| 网络请求 | XMLHttpRequest |
fetch API |
| 状态持久化 | 内存常驻,直接用变量 | 必须显式存储到 chrome.storage |
| 生命周期 | 安装后一直运行 | 空闲 30 秒后自动终止 |
| Web Accessible | 默认所有页面可访问 | 必须显式声明匹配模式 |
⚠️ **警告:**Service Worker 中的全局变量 不保证持久化。不要用
let state = {}存储状态,Worker 被终止后这些变量会丢失。
1.2 最小 V3 项目结构
一个可工作的 V3 扩展至少需要以下文件:
my-extension/
├── manifest.json # 扩展配置(必须)
├── background.js # Service Worker(后台逻辑)
├── content.js # Content Script(页面注入)
├── popup/
│ ├── popup.html # 弹出窗口 UI
│ └── popup.js # 弹出窗口逻辑
└── icons/
├── icon-16.png
├── icon-48.png
└── icon-128.png
manifest.json 是扩展的核心配置文件,V3 的结构如下:
// manifest.json — V3 最小配置
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"description": "A demo Chrome extension with Manifest V3",
"permissions": [
"storage", // 使用 chrome.storage API
"activeTab", // 访问当前活动标签页
"sidePanel" // 使用 Side Panel API
],
"host_permissions": [
"https://api.example.com/*" // 允许访问的外部 API
],
"background": {
"service_worker": "background.js",
"type": "module" // 支持 ES Module 语法
},
"content_scripts": [
{
"matches": ["https://*.github.com/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
],
"action": {
"default_popup": "popup/popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"side_panel": {
"default_path": "sidepanel.html"
},
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
}
💡 提示:
"type": "module"让你在 Service Worker 中使用import/export语法,强烈推荐开启。但注意,Content Script 不支持 ES Module,需要用打包工具处理。
📡 二、跨上下文通信与数据流
2.1 四种上下文及其通信方式
V3 扩展有四种独立的执行上下文,它们之间无法直接共享变量,必须通过消息传递(Message Passing)通信:
| 上下文 | 运行环境 | DOM 访问 | 持久性 |
|---|---|---|---|
| Service Worker | 浏览器后台 | ❌ 无 | 事件驱动,空闲即终止 |
| Content Script | 网页 DOM 环境 | ✅ 隔离的 DOM | 随页面生命周期 |
| Popup | 独立小窗口 | ✅ 独立 DOM | 打开时存在 |
| Side Panel | 侧边面板 | ✅ 独立 DOM | 长期存在 |
通信规则很简单:
Service Worker ←→ Content Script (chrome.tabs.sendMessage / runtime.onMessage)
Service Worker ←→ Popup (chrome.runtime.sendMessage / runtime.onMessage)
Service Worker ←→ Side Panel (同上)
Content Script ←→ 网页脚本 (window.postMessage / CustomEvent)
2.2 封装消息通信层
原生的 chrome.runtime.sendMessage API 回调地狱严重,且不支持 async/await。下面封装一个类型安全的消息通信层:
// messaging.js — 基于 Promise 的消息通信封装
// 支持 Service Worker、Content Script、Popup 三端通用
/**
* 发送消息(返回 Promise)
* @param {string} type - 消息类型
* @param {object} payload - 消息负载
* @returns {Promise<any>}
*/
function sendMessage(type, payload = {}) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type, payload }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else if (response?.error) {
reject(new Error(response.error));
} else {
resolve(response?.data);
}
});
});
}
/**
* 向指定标签页发送消息
* @param {number} tabId - 标签页 ID
* @param {string} type - 消息类型
* @param {object} payload - 消息负载
*/
function sendToTab(tabId, type, payload = {}) {
return new Promise((resolve, reject) => {
chrome.tabs.sendMessage(tabId, { type, payload }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve(response?.data);
}
});
});
}
/**
* 注册消息处理器(在 Service Worker 中使用)
* @param {string} type - 消息类型
* @param {Function} handler - 处理函数,接收 (payload, sender)
*/
function onMessage(type, handler) {
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type !== type) return false;
// 支持 async handler
Promise.resolve()
.then(() => handler(message.payload, sender))
.then((data) => sendResponse({ data }))
.catch((error) => sendResponse({ error: error.message }));
return true; // 保持消息通道开放(异步响应的关键!)
});
}
// 导出(非 module 环境用全局变量)
if (typeof module !== 'undefined') {
module.exports = { sendMessage, sendToTab, onMessage };
}
⚠️ 警告:
onMessage的回调中 必须return true才能支持异步响应。如果忘记返回true,消息通道会在同步执行结束后关闭,异步操作的结果无法送达。
使用示例——在 Content Script 中请求 Service Worker 执行操作:
// content.js — 在网页中请求后台执行 API 调用
// Content Script 无法直接访问某些 Chrome API,需要通过 Service Worker 中转
async function fetchPageData(url) {
try {
const data = await sendMessage('FETCH_API', { url });
console.log('API 响应:', data);
return data;
} catch (err) {
console.error('请求失败:', err.message);
}
}
// 示例:获取当前页面的 GitHub 仓库信息
const repoInfo = await fetchPageData('https://api.github.com/repos/vuejs/core');
在 Service Worker 中处理:
// background.js — 处理来自 Content Script 的 API 请求
// Service Worker 可以使用 host_permissions 访问外部 API
onMessage('FETCH_API', async ({ url }) => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return await response.json();
});
2.3 Content Script 与网页脚本的通信
Content Script 运行在隔离的 JavaScript 环境中,无法直接访问网页的 JS 变量。需要通过 window.postMessage 桥接:
// content.js — 注入脚本到页面的 main world
// 用于读取网页的全局变量或调用网页的 JS 函数
function injectPageScript(code) {
const script = document.createElement('script');
script.textContent = code;
document.head.appendChild(script);
script.remove();
}
// 向页面注入通信桥
injectPageScript(`
window.addEventListener('message', (event) => {
if (event.data.type === 'FROM_EXTENSION') {
// 执行网页端逻辑
const result = window.someGlobalFunction(event.data.payload);
window.postMessage({ type: 'FROM_PAGE', data: result }, '*');
}
});
`);
// Content Script 监听页面的回复
window.addEventListener('message', (event) => {
if (event.source !== window || event.data.type !== 'FROM_PAGE') return;
console.log('收到页面数据:', event.data.data);
// 转发给 Service Worker
sendMessage('PAGE_DATA_RECEIVED', event.data.data);
});
💡 **提示:**Manifest V3 支持
"world": "MAIN"配置,让 Content Script 直接运行在网页的 JS 环境中,避免postMessage桥接。在manifest.json的content_scripts中添加"world": "MAIN"即可。但要注意,MAINworld 中无法访问chrome.*API。
🗄️ 三、存储策略与状态管理
3.1 三种存储区域对比
chrome.storage 提供三种存储区域,各有适用场景:
| 存储区域 | 容量上限 | 同步到云端 | 适用场景 |
|---|---|---|---|
local |
10 MB | ❌ | 大量本地数据、缓存 |
sync |
100 KB(每项 8 KB) | ✅ 跨设备同步 | 用户偏好设置 |
session |
10 MB | ❌ | 临时会话数据,关闭浏览器即清除 |
📌 记住:
chrome.storage是异步的!不要在 Service Worker 启动时同步读取存储——Worker 可能在存储读取完成前就被终止了。
3.2 封装类型安全的存储层
// storage.js — 封装 chrome.storage 的 Promise 版本
// 支持默认值、类型推导、过期时间
class ExtStorage {
constructor(area = 'local') {
this.storage = chrome.storage[area];
}
/**
* 获取存储值
* @param {string} key - 存储键
* @param {any} defaultValue - 默认值
*/
async get(key, defaultValue = null) {
const result = await this.storage.get(key);
const entry = result[key];
if (!entry) return defaultValue;
// 支持过期时间
if (entry.expiresAt && Date.now() > entry.expiresAt) {
await this.storage.remove(key);
return defaultValue;
}
return entry.value;
}
/**
* 设置存储值
* @param {string} key - 存储键
* @param {any} value - 存储值
* @param {number} ttlSeconds - 过期时间(秒),0 表示永不过期
*/
async set(key, value, ttlSeconds = 0) {
const entry = { value };
if (ttlSeconds > 0) {
entry.expiresAt = Date.now() + ttlSeconds * 1000;
}
await this.storage.set({ [key]: entry });
}
/**
* 批量获取
* @param {string[]} keys - 键数组
*/
async getMultiple(keys) {
const result = await this.storage.get(keys);
const cleaned = {};
for (const [key, entry] of Object.entries(result)) {
if (entry?.expiresAt && Date.now() > entry.expiresAt) {
await this.storage.remove(key);
} else {
cleaned[key] = entry?.value;
}
}
return cleaned;
}
/**
* 监听存储变化
* @param {string} key - 监听的键
* @param {Function} callback - 变化回调
*/
onChange(key, callback) {
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== this.area && changes[key]) {
callback(changes[key].newValue?.value, changes[key].oldValue?.value);
}
});
}
}
// 使用示例
const localStore = new ExtStorage('local');
const syncStore = new ExtStorage('sync');
// 缓存 API 数据(1 小时过期)
await localStore.set('github:repos', reposData, 3600);
const cached = await localStore.get('github:repos');
// 同步用户偏好(跨设备)
await syncStore.set('theme', 'dark');
3.3 Service Worker 中的状态管理模式
由于 Service Worker 随时可能被终止,推荐以下状态管理模式:
// state-manager.js — Service Worker 状态管理
// 模式:内存缓存 + 持久化存储双写
class WorkerStateManager {
#cache = new Map();
#storage = chrome.storage.session;
async get(key) {
// 优先读内存缓存
if (this.#cache.has(key)) {
return this.#cache.get(key);
}
// 降级到存储
const result = await this.#storage.get(key);
const value = result[key];
if (value !== undefined) {
this.#cache.set(key, value);
}
return value;
}
async set(key, value) {
this.#cache.set(key, value);
await this.#storage.set({ [key]: value });
}
// Service Worker 启动时预热缓存
async warmup(keys) {
const result = await this.#storage.get(keys);
for (const [key, value] of Object.entries(result)) {
this.#cache.set(key, value);
}
}
}
⚠️ 警告:
chrome.storage.session在 Manifest V3 中默认不可用,需要在manifest.json的permissions中显式添加"storage"和"unlimitedStorage"(如果需要更大容量)。session存储区在 Chrome 102+ 才支持。
🧩 四、Content Script 高级模式
4.1 动态注入 vs 静态声明
V3 提供两种注入 Content Script 的方式,各有优劣:
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
静态声明 (manifest.json) |
自动注入,无需代码 | URL 匹配不灵活 | 固定网站 |
动态注入 (scripting.executeScript) |
精确控制时机 | 需要 scripting 权限 |
按需注入 |
动态注入的完整示例:
// background.js — 按需注入 Content Script
// 适用于:用户点击扩展图标后才注入,减少不必要的页面开销
// 监听扩展图标点击
chrome.action.onClicked.addListener(async (tab) => {
// 检查是否已注入
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => window.__myExtensionInjected === true,
}).catch(() => [{ result: false }]);
if (results[0]?.result) {
console.log('已注入,跳过');
return;
}
// 注入 CSS
await chrome.scripting.insertCSS({
target: { tabId: tab.id },
files: ['styles/injected.css'],
});
// 注入 JS
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ['content.js'],
});
// 注入后发送初始化消息
await sendToTab(tab.id, 'INIT', { config: await loadConfig() });
});
4.2 Shadow DOM 隔离注入 UI
Content Script 注入的 UI 会被网页的 CSS 影响。使用 Shadow DOM 隔离样式:
// content.js — 使用 Shadow DOM 注入隔离 UI
// 避免网页 CSS 污染扩展的 UI 样式
function injectIsolatedUI() {
const host = document.createElement('div');
host.id = 'my-extension-host';
host.style.cssText = 'all: initial; position: fixed; bottom: 20px; right: 20px; z-index: 2147483647;';
const shadow = host.attachShadow({ mode: 'closed' });
// 样式完全隔离
const style = document.createElement('style');
style.textContent = `
:host { font-family: system-ui, sans-serif; }
.panel {
background: white;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0,0,0,0.15);
padding: 16px;
width: 320px;
font-size: 14px;
color: #333;
}
.panel h3 { margin: 0 0 8px; font-size: 16px; }
button {
background: #2563eb;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
}
button:hover { background: #1d4ed8; }
`;
const panel = document.createElement('div');
panel.className = 'panel';
panel.innerHTML = `
<h3>🔍 扩展面板</h3>
<p>这是一个完全隔离的 UI 组件</p>
<button id="ext-action-btn">执行操作</button>
`;
shadow.appendChild(style);
shadow.appendChild(panel);
document.body.appendChild(host);
// 事件绑定在 shadow 内部
shadow.getElementById('ext-action-btn').addEventListener('click', () => {
chrome.runtime.sendMessage({ type: 'ACTION_CLICKED' });
});
}
injectIsolatedUI();
🔧 五、开发调试与发布流程
5.1 开发调试技巧
| 操作 | 方法 |
|---|---|
| 加载未打包扩展 | chrome://extensions → 开发者模式 → 加载已解压的扩展 |
| 调试 Service Worker | chrome://extensions → 你的扩展 → “Service Worker” 链接 |
| 调试 Content Script | F12 → Sources → Content Scripts 面板 |
| 调试 Popup | 右键扩展图标 → 审查弹出内容 |
| 热重载 | 使用 webextension-polyfill + 文件监听自动重载 |
| 查看存储数据 | chrome://extensions → 你的扩展 → Storage 区域 |
5.2 常见坑点与避坑指南
❌ 错误做法 1:在 Service Worker 中用全局变量存储状态
// ❌ 错误:Worker 被终止后变量丢失
let requestCount = 0;
chrome.runtime.onMessage.addListener((msg, sender, reply) => {
requestCount++; // 某次唤醒后 requestCount 可能重置为 0
reply({ count: requestCount });
});
✅ 正确做法:用 chrome.storage.session 存储临时状态
// ✅ 正确:持久化到 session 存储
chrome.runtime.onMessage.addListener((msg, sender, reply) => {
chrome.storage.session.get('requestCount', (data) => {
const count = (data.requestCount || 0) + 1;
chrome.storage.session.set({ requestCount: count });
reply({ count });
});
return true;
});
❌ 错误做法 2:Content Script 直接操作网页 DOM 的事件
// ❌ 错误:可能与网页自身的事件监听冲突
document.querySelector('#submit-btn').addEventListener('click', () => {
// 扩展逻辑
});
✅ 正确做法:使用事件委托 + 命名空间
// ✅ 正确:使用自定义事件避免冲突
document.addEventListener('click', (e) => {
if (e.target.matches('#submit-btn[data-ext-handled]')) return;
if (e.target.id === 'submit-btn') {
e.target.dataset.extHandled = 'true';
// 扩展逻辑
}
});
❌ 错误做法 3:忘记处理 Service Worker 的异步生命周期
// ❌ 错误:Service Worker 可能在 fetch 完成前被终止
chrome.runtime.onInstalled.addListener(() => {
fetch('https://api.example.com/init').then(r => r.json()).then(data => {
chrome.storage.local.set({ initData: data }); // 可能永远执行不到
});
});
✅ 正确做法:使用 chrome.alarms 或确保异步操作在事件回调内
// ✅ 正确:使用 waitUntil 模式(如果支持)或 chrome.alarms
chrome.runtime.onInstalled.addListener(async () => {
try {
const response = await fetch('https://api.example.com/init');
const data = await response.json();
await chrome.storage.local.set({ initData: data });
} catch (err) {
// 失败时设置 alarm 稍后重试
chrome.alarms.create('retry-init', { delayInMinutes: 1 });
}
});
5.3 性能优化建议
| 优化项 | 策略 | 效果 |
|---|---|---|
| 减少唤醒次数 | 合并事件监听,避免频繁 chrome.alarms |
降低 CPU 占用 |
| 延迟注入 | 使用 document_idle 而非 document_start |
不阻塞页面加载 |
| 存储批量操作 | 使用 storage.set({...}) 一次写入多个键 |
减少 I/O 次数 |
| Content Script 体积 | 用 Rollup/esbuild 打包,移除未使用代码 | 加速注入速度 |
| 权限最小化 | 只申请必要的权限,用 optional_permissions 按需申请 |
提高用户信任度 |
📊 六、完整示例:构建一个网页标注扩展
将前面所有知识整合为一个实用的网页高亮标注扩展:
// background.js — 网页标注扩展的 Service Worker
// 功能:选中文本后高亮并保存,支持跨页面持久化
// 安装时初始化存储
chrome.runtime.onInstalled.addListener(async () => {
await chrome.storage.local.set({ highlights: {} });
console.log('Highlight extension installed');
});
// 处理来自 Content Script 的保存请求
onMessage('SAVE_HIGHLIGHT', async ({ url, text, color }, sender) => {
const highlights = await localStore.get('highlights', {});
const pageKey = new URL(url).pathname;
if (!highlights[pageKey]) {
highlights[pageKey] = [];
}
highlights[pageKey].push({
text,
color: color || '#fde68a',
timestamp: Date.now(),
url,
});
await localStore.set('highlights', highlights);
return { success: true, total: highlights[pageKey].length };
});
// 处理来自 Content Script 的加载请求
onMessage('LOAD_HIGHLIGHTS', async ({ url }) => {
const highlights = await localStore.get('highlights', {});
const pageKey = new URL(url).pathname;
return highlights[pageKey] || [];
});
// 处理来自 Popup 的统计请求
onMessage('GET_STATS', async () => {
const highlights = await localStore.get('highlights', {});
const pages = Object.keys(highlights).length;
const total = Object.values(highlights).reduce((sum, arr) => sum + arr.length, 0);
return { pages, total };
});
⚡ 总结与推荐工具
Manifest V3 的核心心智模型是 「事件驱动 + 状态外置」。忘掉 V2 的持久化思维,拥抱 Service Worker 的瞬时性,是写好 V3 扩展的关键。
| 推荐工具 | 用途 |
|---|---|
| Plasmo | 全功能扩展开发框架,支持 React/Vue,自动处理 manifest |
| CRXJS | Vite 插件,支持 HMR 热重载 |
| webextension-polyfill | 跨浏览器兼容层(Chrome/Firefox/Edge) |
| wxt | 下一代扩展开发框架,支持多浏览器 |
| Chrome Extension CLI | 快速脚手架 |
⚡ **关键结论:**V3 不是 V2 的升级,而是全新的架构范式。掌握 Service Worker 生命周期、消息传递机制和存储策略这三个核心,就能写出高质量的 Chrome Extension。建议使用 Plasmo 或 WXT 框架起步,它们帮你处理了 80% 的架构问题。