浏览器插件 高级开发指南(Manifest V3)
本文面向已掌握 Manifest V3 基础、要做复杂、高性能、生产级插件的工程师,聚焦深水区:Service Worker(服务工作线程,简称 SW)生命周期深控、Offscreen Document(屏外文档)、declarativeNetRequest(声明式网络请求,简称 DNR)高级规则、性能与大数据存储、身份认证、Native Messaging(原生消息)、WASM(WebAssembly)加载、安全加固、并发竞态、自动化测试与 CI/CD。基础概念见 浏览器插件-基础概念与架构模型(/liu-lan-qi-cha-jian-ji-chu-
官方文档:
- Service Worker 生命周期 https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle
- Offscreen API https://developer.chrome.com/docs/extensions/reference/api/offscreen
- declarativeNetRequest https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest
- identity API https://developer.chrome.com/docs/extensions/reference/api/identity
- Native Messaging https://developer.chrome.com/docs/extensions/develop/concepts/native-messaging
- 内容安全策略(CSP)https://developer.chrome.com/docs/extensions/reference/manifest/content-security-policy
适用版本:Manifest V3 / Chrome 138+
核实日期:2026-06-06
本文面向已掌握 Manifest V3 基础、要做复杂、高性能、生产级插件的工程师,聚焦深水区:Service Worker(服务工作线程,简称 SW)生命周期深控、Offscreen Document(屏外文档)、declarativeNetRequest(声明式网络请求,简称 DNR)高级规则、性能与大数据存储、身份认证、Native Messaging(原生消息)、WASM(WebAssembly)加载、安全加固、并发竞态、自动化测试与 CI/CD。基础概念见 浏览器插件-基础概念与架构模型,中级内容见 浏览器插件-中级开发指南。
一、Service Worker 生命周期深控
MV3 SW 与传统 Background Page 的本质差异
MV2 的 background page 是常驻页面,全局变量可长期存活。MV3 的 SW 是事件驱动(event-driven)的短生命周期进程,浏览器随时回收。它没有 DOM、没有 window、没有 localStorage,全局状态会在回收后丢失。任何"我把数据存在内存变量里"的设计在 MV3 都是错误的。
唤醒与回收机制(精确时序)
| 行为 | 触发条件 | 来源 |
|---|---|---|
| 启动 | 安装/更新时 install → chrome.runtime.onInstalled → activate;浏览器启动时仅触发 chrome.runtime.onStartup |
官方 lifecycle 文档 |
| 唤醒 | 收到已注册的事件,或被其它上下文通过消息/API 调用唤醒 | 同上 |
| 回收(空闲) | 30 秒无活动。收到事件或调用任意扩展 API 会重置该计时器(Chrome 110+ 任意 API 调用均重置,不限于事件处理器) | 同上 |
| 回收(超时) | 单个请求处理超过 5 分钟 | 同上 |
| 回收(fetch 超时) | 网络响应超过 30 秒 | 同上 |
| 续命 | Chrome 114+:长生命周期 messaging port 保持 SW 存活;Chrome 116+:WebSocket 消息活动延长存活 | 同上 |
关键事实:计时器只关心"是否有活动"。一次 chrome.storage.local.get() 调用就会把 30 秒计时器重置。SW 没有"快被回收了"的事件可监听,必须假设它随时消失。
顶层同步注册监听器(硬性要求)
SW 被唤醒处理事件时,浏览器会重新执行整个脚本文件。事件监听器必须在脚本顶层同步注册,否则唤醒时事件已经派发、监听器尚未注册,事件丢失。
// background.js(service worker 入口)
// 正确:顶层同步注册,脚本一执行就完成订阅
chrome.runtime.onMessage.addListener(handleMessage);
chrome.alarms.onAlarm.addListener(handleAlarm);
chrome.action.onClicked.addListener(handleActionClick);
// 错误:在异步回调里注册,唤醒时来不及订阅,事件会丢失
chrome.storage.local.get('config', (cfg) => {
// 错误:此处注册的监听器在 SW 冷启动唤醒时尚未就绪
chrome.runtime.onMessage.addListener(handleMessage);
});
async function handleMessage(message, sender, sendResponse) {
// 异步逻辑放进处理函数内部,而非延迟注册
const cfg = await chrome.storage.local.get('config');
sendResponse({ ok: true, theme: cfg.config?.theme });
return true; // 见下文:异步 sendResponse 必须 return true
}
异步消息处理需在监听器中 return true,告诉 Chrome sendResponse 会被异步调用,保持消息通道开启。
用 alarms + 事件驱动替代常驻,不要滥用保活
需要定时任务时,使用 chrome.alarms 而非 setInterval。setInterval 在 SW 回收后失效,且会被空闲计时器视为活动从而异常续命,浪费资源。alarms 最小周期为 30 秒(periodInMinutes 最小 0.5),到点由浏览器唤醒 SW。
// 注册周期任务(幂等,重复 create 会覆盖同名 alarm)
chrome.runtime.onInstalled.addListener(() => {
chrome.alarms.create('sync', { periodInMinutes: 5 });
});
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === 'sync') {
await syncData(); // 处理完后 SW 自然回收,下次到点再唤醒
}
});
保活的正确做法与误区:网络上流行用 setInterval 反复调用 chrome.runtime.getPlatformInfo() 续命,或开一个空 port 心跳。这违背 MV3 设计意图、增加内存与电量开销,且 Chrome 会逐步收紧续命漏洞。只有在真正需要持续运行的场景(如维持 WebSocket 长连接接收推送)才考虑续命,且应使用官方支持的机制:
// 仅当确实需要维持 WebSocket 时——Chrome 116+ 下 WS 消息会延长 SW 存活
let ws;
function connect() {
ws = new WebSocket('wss://push.example.com');
ws.addEventListener('message', (e) => handlePush(JSON.parse(e.data)));
ws.addEventListener('close', () => chrome.alarms.create('reconnect', { delayInMinutes: 0.5 }));
}
chrome.alarms.onAlarm.addListener((a) => { if (a.name === 'reconnect') connect(); });
绝大多数插件不需要保活。把任务拆成由事件/alarm 触发的短任务,让 SW 自由回收,才是高性能 MV3 的正道。
冷启动优化
SW 每次唤醒都重新执行脚本。冷启动慢的常见原因:顶层做了大量同步初始化、import 了庞大的库、启动时一次性读全部 storage。优化手段:
- 顶层只注册监听器,不做重活;初始化逻辑延迟到首个事件触发时执行。
- 用
import()动态加载只在特定路径才需要的模块(配合"type": "module"的 SW,见 manifestbackground.type)。 - 启动读 storage 时只读当前需要的 key,不要
get(null)拉全部。
// manifest.json:SW 启用 ES Module,方可在 SW 内使用静态/动态 import
{
"background": { "service_worker": "background.js", "type": "module" }
}
二、Offscreen Document:在 SW 里用不了 DOM 的解法
SW 无 DOM、无 Audio、无剪贴板、无 DOMParser、无 URL.createObjectURL 对应的部分 Blob 能力。Offscreen Document 是一个不可见的 HTML 文档,提供完整 DOM 环境,由 SW 创建并通过消息通信。
createDocument 的 reasons 与字段
chrome.offscreen.createDocument(parameters): Promise<void>
| 字段 | 类型 | 说明 |
|---|---|---|
url |
string | 扩展内相对路径的 HTML 文件 |
reasons |
Reason[] | 创建理由枚举数组,浏览器据此判定生命周期策略 |
justification |
string | 给审核/用户看的文字说明,必填 |
reasons 合法枚举值:TESTING、AUDIO_PLAYBACK、IFRAME_SCRIPTING、DOM_SCRAPING、BLOBS、DOM_PARSER、USER_MEDIA、DISPLAY_MEDIA、WEB_RTC、CLIPBOARD、LOCAL_STORAGE、WORKERS、BATTERY_STATUS、MATCH_MEDIA、GEOLOCATION。
生命周期:AUDIO_PLAYBACK 在 30 秒无音频播放后自动关闭;其它 reason 不设自动关闭,需手动 closeDocument()。每个 profile 同时只允许存在一个 offscreen document。
防止重复创建(getContexts)
重复创建会抛错。创建前用 chrome.runtime.getContexts()(Chrome 116+)检测:
// offscreen.js 的管理封装(放在 SW 中)
const OFFSCREEN_URL = 'offscreen.html';
let creating; // 用 Promise 串行化,避免并发创建竞态
async function ensureOffscreen() {
const existing = await chrome.runtime.getContexts({
contextTypes: ['OFFSCREEN_DOCUMENT'],
documentUrls: [chrome.runtime.getURL(OFFSCREEN_URL)],
});
if (existing.length > 0) return;
if (creating) {
await creating; // 已有创建在途,等它完成
} else {
creating = chrome.offscreen.createDocument({
url: OFFSCREEN_URL,
reasons: ['DOM_PARSER'],
justification: '解析远端返回的 HTML 片段,SW 中无 DOMParser',
});
await creating;
creating = null;
}
}
与 SW 通信
Offscreen document 仅支持 chrome.runtime 消息 API。典型模式是 SW 发任务、offscreen 用 DOM 处理后回传结果,用 target 字段区分消息归属,避免误处理。
// SW 侧:请求解析 HTML
async function parseHtml(html) {
await ensureOffscreen();
return chrome.runtime.sendMessage({ target: 'offscreen', type: 'parse', html });
}
// offscreen.js:只处理发给自己的消息
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.target !== 'offscreen') return; // 不是给我的,放行给其它监听器
if (msg.type === 'parse') {
const doc = new DOMParser().parseFromString(msg.html, 'text/html');
const titles = [...doc.querySelectorAll('h1')].map((h) => h.textContent);
sendResponse({ titles });
}
return true;
});
剪贴板写入也是常见用途:SW 无 navigator.clipboard,需经 offscreen 文档执行。任务完成后若无后续需要,调用 chrome.offscreen.closeDocument() 释放资源。
三、declarativeNetRequest 复杂规则
DNR 用声明式规则修改/拦截网络请求,替代 MV2 的阻塞式 webRequest。规则在浏览器内部执行,性能高、隐私好,但表达力受限于规则模型。
规则结构
{
"id": 1, // 必填,整数,≥1,同一 ruleset 内唯一
"priority": 1, // 可选,默认 1,越大越优先
"action": { "type": "block" },
"condition": { "urlFilter": "ads", "resourceTypes": ["script"] }
}
action 类型
| type | 说明 | 关键字段 |
|---|---|---|
block |
拦截请求 | — |
redirect |
重定向 | redirect.url / extensionPath / transform / regexSubstitution |
modifyHeaders |
改请求/响应头 | requestHeaders[] / responseHeaders[] |
allow |
放行,覆盖低优先级 block | — |
allowAllRequests |
放行整个框架下所有请求 | 仅限 main_frame/sub_frame |
upgradeScheme |
HTTP 升级为 HTTPS | — |
condition 字段
| 字段 | 说明 |
|---|---|
urlFilter |
通配模式(非完整正则),如 ||example.com^ |
regexFilter |
RE2 正则匹配 URL |
resourceTypes / excludedResourceTypes |
main_frame、sub_frame、script、image、xmlhttprequest、stylesheet、font、media、websocket 等 |
initiatorDomains / excludedInitiatorDomains |
发起请求的页面域名 |
requestDomains / excludedRequestDomains |
目标请求域名 |
requestMethods / excludedRequestMethods |
get、post 等(小写) |
domainType |
firstParty / thirdParty |
responseHeaders / excludedResponseHeaders |
按响应头匹配 |
tabIds / excludedTabIds |
仅 session 规则可用 |
正则规则与 regexSubstitution 重定向
// 用正则捕获组重写跳转目标
{
"id": 101, "priority": 1,
"action": {
"type": "redirect",
"redirect": { "regexSubstitution": "https://cdn.example.com/\\1" }
},
"condition": {
"regexFilter": "^https://origin\\.example\\.com/(.*)$",
"resourceTypes": ["image", "media"]
}
}
modifyHeaders(增删改请求/响应头)
{
"id": 201, "priority": 1,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{ "header": "x-api-token", "operation": "set", "value": "abc123" },
{ "header": "referer", "operation": "remove" }
],
"responseHeaders": [
{ "header": "x-frame-options", "operation": "remove" },
{ "header": "access-control-allow-origin", "operation": "set", "value": "*" }
]
},
"condition": { "requestDomains": ["api.example.com"], "resourceTypes": ["xmlhttprequest"] }
}
operation 取 set / remove / append。只有部分头(如 cookie、user-agent、accept、cache-control 等)支持 append。若某规则对某头执行 append,则更低优先级规则对该头只能继续 append,不能 set/remove。
静态规则 + 动态规则 + 会话规则配合
| 类型 | 管理方式 | 持久性 | 典型用途 |
|---|---|---|---|
| 静态(static) | manifest rule_resources 指向 JSON 文件 |
随扩展更新持久 | 内置规则集(如广告过滤名单) |
| 动态(dynamic) | updateDynamicRules() |
跨会话持久 | 用户自定义、运行时下发 |
| 会话(session) | updateSessionRules() |
浏览器关闭即清空 | 临时/敏感规则、含 tabIds 的规则 |
// manifest.json:静态规则集 + 反馈权限
{
"permissions": ["declarativeNetRequest", "declarativeNetRequestFeedback"],
"host_permissions": ["<all_urls>"],
"declarative_net_request": {
"rule_resources": [
{ "id": "ruleset_ads", "enabled": true, "path": "rules/ads.json" }
]
}
}
// 运行时增删动态规则(原子操作,全成功或全失败)
await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: [1, 2],
addRules: [{
id: 1000, priority: 2,
action: { type: 'block' },
condition: { urlFilter: 'tracker', domainType: 'thirdParty' },
}],
});
规则优先级解析
- 先比
priority,大者胜。 - 同
priority时按 action 优先级:allow/allowAllRequests>block>upgradeScheme>redirect。 - 跨扩展同 action 同 priority 时,最后安装的扩展胜。
调试 matchedRules
// onRuleMatchedDebug 仅对未打包(unpacked)扩展生效,用于本地调试
chrome.declarativeNetRequest.onRuleMatchedDebug.addListener((info) => {
console.log('命中规则', info.rule.ruleId, info.request.url);
});
// getMatchedRules 需 declarativeNetRequestFeedback 权限,回溯已命中规则
const { rulesMatchedInfo } = await chrome.declarativeNetRequest.getMatchedRules({});
// testMatchOutcome:开发期预测某请求会命中哪些规则,无需真正发请求
const outcome = await chrome.declarativeNetRequest.testMatchOutcome({
url: 'https://api.example.com/data', type: 'xmlhttprequest', method: 'get',
});
数值上限(关键):动态+会话规则安全上限 30000、不安全规则下限 5000;静态启用规则集 50 个;静态规则总量保证 30000;每类型正则规则 1000 条。
四、性能优化
内容脚本懒加载 / 按需注入
不要对所有页面静态注入大脚本。用 chrome.scripting.executeScript 在用户触发动作时按需注入,减少对无关页面的开销。
// 错误:manifest 静态 content_scripts 对所有匹配页面无条件注入重脚本,拖慢页面加载
// 正确:用户点击图标时才注入
chrome.action.onClicked.addListener(async (tab) => {
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ['content/heavy-feature.js'], // 仅此刻注入
});
});
registerContentScripts 可在运行时动态注册/注销内容脚本,配合用户配置启停功能模块。
减小 bundle 与代码分割
- 用 Vite/Rollup 的代码分割,把按需功能拆成动态
import()的 chunk。 - tree-shaking 友好的库(如
date-fns按需引入而非引整个moment)。 - SW 用
"type": "module",把冷启动用不到的逻辑延迟动态导入。
// SW 内:仅在需要导出功能时才加载大体积的导出模块
async function handleExport(data) {
const { exportToXlsx } = await import('./export/xlsx.js'); // 动态 chunk
return exportToXlsx(data);
}
避免阻塞页面与防抖大量消息
内容脚本中高频事件(scroll、mousemove、mutation)要防抖/节流,且批量发消息而非逐条发,减少跨进程 IPC 开销。
// 内容脚本:批量收集 + 防抖一次性发送,避免每帧一条消息打爆 SW
let buffer = [];
let timer = null;
function enqueue(event) {
buffer.push(event);
if (timer) return;
timer = setTimeout(() => {
chrome.runtime.sendMessage({ type: 'batch', events: buffer });
buffer = [];
timer = null;
}, 200); // 200ms 合并窗口
}
storage 批量读写
每次 chrome.storage 调用都有 IPC 与序列化成本。批量读写显著优于循环单条。
// 错误:循环里逐条写,N 次 IPC
for (const item of items) await chrome.storage.local.set({ [item.id]: item });
// 正确:聚合成一个对象一次写
const batch = Object.fromEntries(items.map((i) => [i.id, i]));
await chrome.storage.local.set(batch);
长列表虚拟化
popup / options / side panel 渲染上千条数据时用虚拟滚动(如 @tanstack/virtual,见 浏览器插件-中级开发指南),只渲染可视区 DOM,避免一次性插入海量节点导致卡顿。
五、大数据与存储
IndexedDB(idb 库封装)
chrome.storage 适合配置类小数据;结构化大数据、需索引查询的场景用 IndexedDB。原生 API 繁琐,用 idb(Promise 封装)。SW 中可直接使用 IndexedDB。
import { openDB } from 'idb';
const dbPromise = openDB('app-db', 1, {
upgrade(db) {
const store = db.createObjectStore('records', { keyPath: 'id' });
store.createIndex('by-time', 'createdAt'); // 建索引支持范围查询
},
});
export async function putRecords(records) {
const db = await dbPromise;
const tx = db.transaction('records', 'readwrite');
await Promise.all([...records.map((r) => tx.store.put(r)), tx.done]); // 一个事务批量写
}
export async function queryByTimeRange(from, to) {
const db = await dbPromise;
return db.getAllFromIndex('records', 'by-time', IDBKeyRange.bound(from, to));
}
storage 配额管理与 unlimitedStorage
chrome.storage.local 默认约 10 MB(可被 unlimitedStorage 解除)。IndexedDB / CacheStorage 受浏览器配额管理。需要存大量数据时声明 unlimitedStorage 权限,并用 navigator.storage.estimate() 监控用量。
{ "permissions": ["unlimitedStorage"] }
const { usage, quota } = await navigator.storage.estimate();
console.log(`已用 ${(usage / 1048576).toFixed(1)}MB / ${(quota / 1048576).toFixed(0)}MB`);
缓存策略
用 CacheStorage 缓存远端资源(如离线图标、字典),配合版本号失效。SW 中可直接 caches.open()。结合 TTL 字段或基于 alarm 的定期清理避免无限增长。
六、身份认证
chrome.identity.getAuthToken(Google OAuth)
仅适用于 Google 账户,凭据来自 manifest 的 oauth2 键。token 在内存缓存,可放心多次非交互调用。交互式请求应由用户 UI 触发并解释授权用途,勿在启动时无故弹窗。
// manifest.json
{
"permissions": ["identity"],
"oauth2": {
"client_id": "xxxxx.apps.googleusercontent.com",
"scopes": ["https://www.googleapis.com/auth/userinfo.email"]
}
}
function getGoogleToken(interactive) {
return new Promise((resolve, reject) => {
chrome.identity.getAuthToken({ interactive }, (token) => {
if (chrome.runtime.lastError || !token) return reject(chrome.runtime.lastError);
resolve(token);
});
});
}
// token 失效(401)时清除缓存再重取,否则会一直拿到坏 token
async function callApiWithRetry(url) {
let token = await getGoogleToken(false);
let res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (res.status === 401) {
await chrome.identity.removeCachedAuthToken({ token });
token = await getGoogleToken(true);
res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
}
return res.json();
}
clearAllCachedAuthTokens() 用于登出时彻底清空所有缓存 token、账户偏好与授权状态。
launchWebAuthFlow(通用 OAuth2 + PKCE)
非 Google provider 用 launchWebAuthFlow。它打开一个 web 视图,等待重定向到 https://<extension-id>.chromiumapp.org/*,用 chrome.identity.getRedirectURL() 生成该回调地址并在 provider 后台登记。生产环境必须用 PKCE(Proof Key for Code Exchange),不要在前端嵌入 client secret。
function base64url(buf) {
return btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function loginWithPkce() {
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const challenge = base64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)));
const redirectUri = chrome.identity.getRedirectURL(); // 在 provider 处登记此 URI
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.search = new URLSearchParams({
response_type: 'code', client_id: 'CLIENT_ID', redirect_uri: redirectUri,
scope: 'openid profile', code_challenge: challenge, code_challenge_method: 'S256',
state: crypto.randomUUID(),
}).toString();
const redirect = await chrome.identity.launchWebAuthFlow({ url: authUrl.toString(), interactive: true });
const code = new URL(redirect).searchParams.get('code');
// 用 code + verifier 换 token(无 client secret,PKCE 保护)
const tokenRes = await fetch('https://auth.example.com/token', {
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code', code, redirect_uri: redirectUri,
client_id: 'CLIENT_ID', code_verifier: verifier,
}),
});
return tokenRes.json(); // { access_token, refresh_token, ... }
}
token 安全存储与自有后端鉴权
- access token 放
chrome.storage.session(内存级、随浏览器关闭清空),refresh token 等长期凭据若必须落盘,用 Web Crypto 加密后再存(见安全章节)。 - 与自有后端鉴权:插件携带 OAuth token 调后端,后端校验后签发自有短期 JWT,插件后续用 JWT 调业务接口,缩短第三方 token 暴露面。
七、Native Messaging(原生消息)
当插件需要访问本机能力(读写本地文件、调用本地程序、与桌面应用通信)时,用 Native Messaging 与一个本地"native host"进程通过 stdin/stdout 通信。需 nativeMessaging 权限。
连接 API
// 持久连接:双向多次通信
const port = chrome.runtime.connectNative('com.my_company.my_host');
port.onMessage.addListener((msg) => console.log('收到', msg));
port.onDisconnect.addListener(() => console.log('断开', chrome.runtime.lastError));
port.postMessage({ cmd: 'read', path: 'C:/data/x.txt' });
// 单次消息:发一条、收一条
chrome.runtime.sendNativeMessage('com.my_company.my_host', { cmd: 'ping' }, (resp) => {
console.log('响应', resp);
});
native host 清单字段
| 字段 | 说明 |
|---|---|
name |
host 标识,小写字母数字、下划线、点(不能首尾为点) |
description |
简述 |
path |
可执行程序路径(Linux/macOS 绝对路径;Windows 可相对于清单文件) |
type |
固定 "stdio" |
allowed_origins |
允许连接的扩展 ID 列表(chrome-extension://ID/,不支持通配符) |
{
"name": "com.my_company.my_host",
"description": "本地文件桥接",
"path": "C:\\Program Files\\MyHost\\host.exe",
"type": "stdio",
"allowed_origins": ["chrome-extension://abcdefghijklmnoabcdefghijklmnoab/"]
}
清单安装位置(Windows / macOS / Linux)
Windows 通过注册表指向清单文件路径:
HKEY_CURRENT_USER\SOFTWARE\Google\Chrome\NativeMessagingHosts\com.my_company.my_host
(默认值 = 清单 JSON 的绝对路径)
HKEY_LOCAL_MACHINE\SOFTWARE\Google\Chrome\NativeMessagingHosts\com.my_company.my_host
macOS 系统级:/Library/Google/Chrome/NativeMessagingHosts/。Linux 用户级:~/.config/google-chrome/NativeMessagingHosts/。
消息格式与限制
协议:4 字节消息长度(native 字节序)+ JSON 内容。入站消息上限 1 MB,出站上限 64 MiB。native host 实现需先读 4 字节长度,再读对应字节数解析 JSON,回写同理。
# Python native host 读写示例(关键:长度前缀 + 本机字节序)
import sys, json, struct
def read_message():
raw_len = sys.stdin.buffer.read(4)
if not raw_len:
sys.exit(0)
length = struct.unpack('@I', raw_len)[0] # @ = native 字节序
return json.loads(sys.stdin.buffer.read(length).decode('utf-8'))
def send_message(obj):
data = json.dumps(obj).encode('utf-8')
sys.stdout.buffer.write(struct.pack('@I', len(data)))
sys.stdout.buffer.write(data)
sys.stdout.buffer.flush()
while True:
msg = read_message()
send_message({'echo': msg})
八、WASM 在插件中的加载与 CSP 配置
MV3 默认 CSP 禁止 wasm-eval 之外的动态求值。要在插件中实例化 WebAssembly,需在 manifest 的 CSP 中加入 'wasm-unsafe-eval'。
// manifest.json
{
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
}
}
// 把 .wasm 作为扩展资源打包,用 fetch + instantiateStreaming 加载
async function loadWasm() {
const url = chrome.runtime.getURL('engine.wasm');
const { instance } = await WebAssembly.instantiateStreaming(fetch(url), {});
return instance.exports;
}
SW 与 offscreen 均可加载 WASM。.wasm 文件需通过打包工具复制进产物目录,并确保其 MIME 为 application/wasm(扩展内置资源由 Chrome 正确处理)。计算密集型逻辑(加解密、图像处理、压缩)放进 WASM 可显著提速。
九、安全加固
MV3 CSP 详解
MV3 对扩展页面(extension_pages)强制严格 CSP:默认 script-src 'self',禁止内联脚本、eval、远程脚本加载。sandbox 键可为沙箱页面设置更宽松策略。安全要点:
- 不得从远程加载并执行代码(MV3 政策硬性要求),第三方库必须打包进扩展。
'unsafe-inline'、'unsafe-eval'在extension_pages不被允许(仅 WASM 例外用'wasm-unsafe-eval')。
防止内容脚本被页面攻击
内容脚本与页面共享 DOM,但运行在隔离世界(isolated world),变量不互通。然而 DOM 是共享攻击面:页面可篡改 DOM、覆盖原型方法。
// 错误:直接信任页面 DOM 文本并以特权身份转发,页面可注入恶意内容
const text = document.querySelector('#x').innerHTML;
chrome.runtime.sendMessage({ html: text });
// 正确:缓存原始引用、取 textContent、做白名单校验后再传
const { sendMessage } = chrome.runtime; // 缓存,防页面覆盖
const text = document.querySelector('#x')?.textContent ?? '';
if (/^[\w-]{1,64}$/.test(text)) sendMessage.call(chrome.runtime, { token: text });
消息来源校验(sender 校验)
onMessage / onMessageExternal 必须校验 sender,否则任意页面(externally_connectable)或恶意内容脚本可冒充触发特权操作。
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
// 校验来自本扩展、且来自可信内容脚本所在页面
if (sender.id !== chrome.runtime.id) return; // 非本扩展,丢弃
const url = sender.tab?.url ?? '';
if (!url.startsWith('https://app.example.com/')) return; // 域名白名单
// ...处理可信消息
});
// 外部消息(网页直连)单独校验来源 origin
chrome.runtime.onMessageExternal.addListener((msg, sender) => {
if (sender.origin !== 'https://app.example.com') return;
});
避免 XSS(innerHTML / DOMPurify)
// 错误:把不可信数据塞进 innerHTML,构成 XSS
el.innerHTML = userData;
// 正确 1:纯文本用 textContent
el.textContent = userData;
// 正确 2:必须渲染 HTML 时用 DOMPurify 消毒
import DOMPurify from 'dompurify';
el.innerHTML = DOMPurify.sanitize(userHtml, { ALLOWED_TAGS: ['b', 'a', 'p'] });
敏感数据加密
落盘的敏感数据(token、密码)用 Web Crypto AES-GCM 加密;密钥不可硬编码,由用户口令派生(PBKDF2)或存于 storage.session。
async function deriveKey(password, salt) {
const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 200000, hash: 'SHA-256' },
base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'],
);
}
async function encrypt(key, plaintext) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const data = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(plaintext));
return { iv: [...iv], data: [...new Uint8Array(data)] };
}
最小权限
仅声明必需权限;用 optional_permissions + chrome.permissions.request() 在用户触发时按需申请,而非启动即索要 <all_urls>。host_permissions 尽量精确到具体域名。最小权限既降低审核风险,也减少被攻破后的影响面。
十、多上下文并发与竞态
SW、多个标签页内容脚本、popup、options 可能同时读写同一份 storage,产生读改写(read-modify-write)竞态:两个上下文都读到旧值、各自加一、后写覆盖先写,丢失更新。
用 navigator.locks 串行化
navigator.locks(Web Locks API)在 SW、offscreen、页面间共享锁名,可跨上下文串行化临界区。
// 任意上下文调用:同名锁保证读改写原子
async function incrementCounter() {
await navigator.locks.request('counter-lock', async () => {
const { counter = 0 } = await chrome.storage.local.get('counter');
await chrome.storage.local.set({ counter: counter + 1 }); // 临界区内独占
});
}
用队列串行化(单上下文内)
同一 SW 内的高频任务可用 Promise 链队列,确保按序执行,避免交叉。
let chain = Promise.resolve();
function enqueue(task) {
chain = chain.then(task).catch((e) => console.error(e));
return chain;
}
// enqueue(() => doWrite(a)); enqueue(() => doWrite(b)); // 严格顺序执行
chrome.storage 单次 set 本身原子,但"读—改—写"跨多次调用不原子,必须显式加锁或排队。
十一、自动化测试
vitest 单测(mock chrome API)
chrome.* 在测试环境不存在,需 mock。用 @webext-core/fake-browser 或 sinon-chrome 提供假实现。
// vitest.setup.ts
import { fakeBrowser } from '@webext-core/fake-browser';
import { beforeEach, vi } from 'vitest';
vi.stubGlobal('chrome', fakeBrowser); // 全局注入假 chrome
beforeEach(() => fakeBrowser.reset()); // 每个用例前重置状态
// counter.test.ts
import { describe, it, expect } from 'vitest';
import { incrementCounter } from '../src/counter';
describe('counter', () => {
it('读改写后计数加一', async () => {
await chrome.storage.local.set({ counter: 5 });
await incrementCounter();
const { counter } = await chrome.storage.local.get('counter');
expect(counter).toBe(6);
});
});
Playwright 加载已构建插件做 E2E
E2E 需用真实 Chromium 加载已构建产物,验证内容脚本注入、popup 渲染等真实行为。MV3 下需用持久化上下文加载未打包扩展。
// e2e/fixtures.ts
import { test as base, chromium } from '@playwright/test';
import path from 'node:path';
const pathToExtension = path.resolve('dist'); // 已构建产物目录
export const test = base.extend({
context: async ({}, use) => {
const context = await chromium.launchPersistentContext('', {
channel: 'chromium',
args: [
`--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`,
],
});
await use(context);
await context.close();
},
extensionId: async ({ context }, use) => {
let [sw] = context.serviceWorkers();
if (!sw) sw = await context.waitForEvent('serviceworker'); // 等 SW 起来拿 ID
await use(sw.url().split('/')[2]);
},
});
// e2e/popup.spec.ts
import { test } from './fixtures';
import { expect } from '@playwright/test';
test('popup 正常渲染', async ({ page, extensionId }) => {
await page.goto(`chrome-extension://${extensionId}/popup.html`);
await expect(page.getByRole('button', { name: '同步' })).toBeVisible();
});
十二、CI/CD
GitHub Actions 自动构建 + 打 zip + 自动上架
打包产物用 zip;上架用各商店 API。Chrome Web Store 用 chrome-webstore-upload,凭据为 CLIENT_ID / CLIENT_SECRET / REFRESH_TOKEN,存进仓库 Secrets。
# .github/workflows/release.yml
name: release
on:
push:
tags: ['v*']
jobs:
build-and-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'npm' }
- run: npm ci
- run: npm run build # 产物输出到 dist/
- run: npm test # 单测门禁
- name: 打包 zip
run: cd dist && zip -r ../extension.zip . && cd ..
# 上架 Chrome Web Store
- name: Publish to Chrome Web Store
run: |
npx chrome-webstore-upload-cli@3 upload \
--source extension.zip \
--extension-id "$CWS_EXTENSION_ID" \
--client-id "$CWS_CLIENT_ID" \
--client-secret "$CWS_CLIENT_SECRET" \
--refresh-token "$CWS_REFRESH_TOKEN" \
--auto-publish
env:
CWS_EXTENSION_ID: ${{ secrets.CWS_EXTENSION_ID }}
CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }}
CWS_CLIENT_SECRET: ${{ secrets.CWS_CLIENT_SECRET }}
CWS_REFRESH_TOKEN: ${{ secrets.CWS_REFRESH_TOKEN }}
- name: 归档构建产物
uses: actions/upload-artifact@v4
with: { name: extension, path: extension.zip }
Edge Add-ons 用 Partner Center API(PRODUCT_ID + API key 上传同一 zip);Firefox AMO 用 web-ext sign 或 AMO API(JWT_ISSUER + JWT_SECRET)。三店可用同一 zip 产物(注意 Firefox 对 MV3 SW 的差异,必要时输出 background.scripts 兼容版)。各店 Secrets 分别配置,建议拆成独立 job 并行发布、互不阻塞。
最佳实践
-
顶层同步注册所有事件监听器,异步逻辑下沉到处理函数。SW 唤醒会重跑脚本,延迟注册会丢事件。监听器同步注册保证唤醒即就绪:
chrome.runtime.onMessage.addListener(handle); // 顶层 async function handle(m, s, send) { const c = await chrome.storage.local.get('c'); send(c); return true; } -
用 alarms + 事件驱动替代保活与 setInterval。让 SW 自由回收,按需唤醒,省内存省电、契合 MV3 设计。只有维持长连接推送等极少场景才用官方支持的续命方式:
chrome.alarms.create('sync', { periodInMinutes: 5 }); chrome.alarms.onAlarm.addListener((a) => a.name === 'sync' && syncData()); -
跨上下文读改写用 navigator.locks 串行化。SW 与多标签页并发写同一 storage 会丢更新,Web Locks 跨上下文加锁保证原子:
await navigator.locks.request('cfg', async () => { const { n = 0 } = await chrome.storage.local.get('n'); await chrome.storage.local.set({ n: n + 1 }); }); -
storage / 消息批量化,高频事件防抖。每次 IPC 都有成本,聚合写入与合并发送大幅降低跨进程开销:
await chrome.storage.local.set(Object.fromEntries(items.map((i) => [i.id, i]))); -
所有 onMessage 校验 sender,所有 HTML 渲染消毒。校验
sender.id与来源域名防伪造,用textContent或 DOMPurify 防 XSS:if (sender.id !== chrome.runtime.id) return; el.innerHTML = DOMPurify.sanitize(html); -
SW 中无 DOM 的能力一律走 Offscreen Document,并用 getContexts 去重。
DOMParser、剪贴板、Audio等需 offscreen,创建前检测避免重复创建抛错:const c = await chrome.runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] }); if (!c.length) await chrome.offscreen.createDocument({ url, reasons: ['DOM_PARSER'], justification: '解析 HTML' }); -
最小权限 + 按需申请。用
optional_permissions与chrome.permissions.request()在用户操作时申请,host_permissions精确到域名,降低审核与攻击面:const granted = await chrome.permissions.request({ origins: ['https://api.example.com/*'] });
常见陷阱
-
现象:插件运行一段时间后定时任务停了、消息无人响应。
原因:用了setInterval或把状态放在 SW 全局变量里,SW 空闲 30 秒被回收,定时器和内存状态全丢。
解决:定时改用chrome.alarms;状态持久化到chrome.storage/ IndexedDB;监听器顶层同步注册。 -
现象:偶发的"我明明发了消息,但回调拿到 undefined / 通道关闭"。
原因:异步消息处理器没return true,Chrome 认为同步处理完毕,提前关闭了消息通道,sendResponse失效。
解决:在onMessage监听器中需要异步回复时显式return true,并确保sendResponse一定被调用。 -
现象:
chrome.offscreen.createDocument抛 "Only a single offscreen document may be created"。
原因:未检测已有文档就重复创建,或并发创建存在竞态。
解决:创建前用chrome.runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] })检测;用一个creatingPromise 串行化并发创建请求。 -
现象:多个标签页同时操作后,storage 里的计数/列表丢了部分更新。
原因:跨上下文读改写竞态,后写覆盖先写。
解决:用navigator.locks.request()把读改写包成临界区,或在单上下文用 Promise 队列串行化。 -
现象:DNR 规则不生效或命中了不该命中的请求。
原因:priority/ action 优先级理解有误(同 priority 下allow>block>redirect),或regexFilter写法不符合 RE2,或超过正则规则上限。
解决:用testMatchOutcome()预测命中,开发期用未打包扩展开onRuleMatchedDebug观察实际命中,必要时提高目标规则priority。