浏览器插件 高级开发指南(Manifest V3)

本文面向已掌握 Manifest V3 基础、要做复杂、高性能、生产级插件的工程师,聚焦深水区:Service Worker(服务工作线程,简称 SW)生命周期深控、Offscreen Document(屏外文档)、declarativeNetRequest(声明式网络请求,简称 DNR)高级规则、性能与大数据存储、身份认证、Native Messaging(原生消息)、WASM(WebAssembly)加载、安全加固、并发竞态、自动化测试与 CI/CD。基础概念见 浏览器插件-基础概念与架构模型(/liu-lan-qi-cha-jian-ji-chu-

分享

官方文档:

适用版本: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 都是错误的。

唤醒与回收机制(精确时序)

行为 触发条件 来源
启动 安装/更新时 installchrome.runtime.onInstalledactivate;浏览器启动时仅触发 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 而非 setIntervalsetInterval 在 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,见 manifest background.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 合法枚举值:TESTINGAUDIO_PLAYBACKIFRAME_SCRIPTINGDOM_SCRAPINGBLOBSDOM_PARSERUSER_MEDIADISPLAY_MEDIAWEB_RTCCLIPBOARDLOCAL_STORAGEWORKERSBATTERY_STATUSMATCH_MEDIAGEOLOCATION

生命周期: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_framesub_framescriptimagexmlhttprequeststylesheetfontmediawebsocket
initiatorDomains / excludedInitiatorDomains 发起请求的页面域名
requestDomains / excludedRequestDomains 目标请求域名
requestMethods / excludedRequestMethods getpost 等(小写)
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"] }
}

operationset / remove / append。只有部分头(如 cookieuser-agentacceptcache-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' },
  }],
});

规则优先级解析

  1. 先比 priority,大者胜。
  2. priority 时按 action 优先级:allow / allowAllRequests > block > upgradeScheme > redirect
  3. 跨扩展同 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-browsersinon-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 并行发布、互不阻塞。


最佳实践

  1. 顶层同步注册所有事件监听器,异步逻辑下沉到处理函数。SW 唤醒会重跑脚本,延迟注册会丢事件。监听器同步注册保证唤醒即就绪:

    chrome.runtime.onMessage.addListener(handle); // 顶层
    async function handle(m, s, send) { const c = await chrome.storage.local.get('c'); send(c); return true; }
    
  2. 用 alarms + 事件驱动替代保活与 setInterval。让 SW 自由回收,按需唤醒,省内存省电、契合 MV3 设计。只有维持长连接推送等极少场景才用官方支持的续命方式:

    chrome.alarms.create('sync', { periodInMinutes: 5 });
    chrome.alarms.onAlarm.addListener((a) => a.name === 'sync' && syncData());
    
  3. 跨上下文读改写用 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 });
    });
    
  4. storage / 消息批量化,高频事件防抖。每次 IPC 都有成本,聚合写入与合并发送大幅降低跨进程开销:

    await chrome.storage.local.set(Object.fromEntries(items.map((i) => [i.id, i])));
    
  5. 所有 onMessage 校验 sender,所有 HTML 渲染消毒。校验 sender.id 与来源域名防伪造,用 textContent 或 DOMPurify 防 XSS:

    if (sender.id !== chrome.runtime.id) return;
    el.innerHTML = DOMPurify.sanitize(html);
    
  6. 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' });
    
  7. 最小权限 + 按需申请。用 optional_permissionschrome.permissions.request() 在用户操作时申请,host_permissions 精确到域名,降低审核与攻击面:

    const granted = await chrome.permissions.request({ origins: ['https://api.example.com/*'] });
    

常见陷阱

  1. 现象:插件运行一段时间后定时任务停了、消息无人响应。
    原因:用了 setInterval 或把状态放在 SW 全局变量里,SW 空闲 30 秒被回收,定时器和内存状态全丢。
    解决:定时改用 chrome.alarms;状态持久化到 chrome.storage / IndexedDB;监听器顶层同步注册。

  2. 现象:偶发的"我明明发了消息,但回调拿到 undefined / 通道关闭"。
    原因:异步消息处理器没 return true,Chrome 认为同步处理完毕,提前关闭了消息通道,sendResponse 失效。
    解决:在 onMessage 监听器中需要异步回复时显式 return true,并确保 sendResponse 一定被调用。

  3. 现象chrome.offscreen.createDocument 抛 "Only a single offscreen document may be created"。
    原因:未检测已有文档就重复创建,或并发创建存在竞态。
    解决:创建前用 chrome.runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] }) 检测;用一个 creating Promise 串行化并发创建请求。

  4. 现象:多个标签页同时操作后,storage 里的计数/列表丢了部分更新。
    原因:跨上下文读改写竞态,后写覆盖先写。
    解决:用 navigator.locks.request() 把读改写包成临界区,或在单上下文用 Promise 队列串行化。

  5. 现象:DNR 规则不生效或命中了不该命中的请求。
    原因priority / action 优先级理解有误(同 priority 下 allow > block > redirect),或 regexFilter 写法不符合 RE2,或超过正则规则上限。
    解决:用 testMatchOutcome() 预测命中,开发期用未打包扩展开 onRuleMatchedDebug 观察实际命中,必要时提高目标规则 priority


参见

阅读更多

Web 安全基础

1. HTML 转义(服务端渲染必须): 2. CSP(Content Security Policy): 3. HttpOnly Cookie:防止 JS 读取会话 Cookie: 4. 前端框架防护: 攻击者在第三方网站构造一个表单,诱导已登录用户提交,浏览器会自动携带目标站的 Cookie。 触发条件: 1. 用户已登录目标网站(Cookie 有效) 2. 目标 API 仅凭 Cookie 识别用户身份 3. 请求来源未验证 1. CSRF Token(推荐): 2. SameSite Cookie: 3. 验证 Origin/Referer 头:

By yellowdog

HTTP 协议深度指南

HTTP(HyperText Transfer Protocol)是 Web 的基础传输协议,基于 TCP/IP,采用请求/响应模型。 相关文档:Web安全基础(/web-an-quan-ji-chu/) FastAPI完全指南(/fastapi-wan-quan-zhi-nan/) Nginx完全指南(/nginx-wan-quan-zhi-nan/) 幂等性:多次执行相同请求,服务器状态结果相同。PUT /users/1 多次执行结果一致;POST /users 每次创建新资源,非幂等。 浏览器直接从本地缓存读取,不向服务器发送请求。 缓存命中时,状

By yellowdog

系统设计基础

SLA 对照表: 选择建议:无状态服务(Web 层、API 层)优先水平扩展;数据库初期垂直扩展,达到瓶颈后考虑分库分表或读写分离。 缓存穿透(查询不存在的 key,每次都打到 DB): 缓存击穿(热点 key 过期,瞬间大量请求打到 DB): 缓存雪崩(大量 key 同时过期,或缓存服务宕机): 令牌桶 Python 实现: Redis 实现分布式限流(滑动窗口): URL 命名规则: Cursor 分页响应格式: 雪花算法结构(64 bit): 定义:分布式系统不能同时满足以下三个特性: 在分布式环境中 P 是必须保证的,所以实际是 CP vs AP

By yellowdog

算法思路与模板

二分查找要求序列有序,每次将搜索范围缩减一半,时间复杂度 O(log n)。 两个指针从两端向中间收缩,常用于有序数组。 滑动窗口维护一个满足条件的区间 left, right,right 不断向右扩张,条件不满足时收缩 left。 滑动窗口通用框架: 1. 确定"子问题":原问题可以分解为哪些规模更小的同类问题 2. 定义 dpi 或 dpij 的含义,要足够清晰 3. 推导状态转移方程 4. 确定初始状态(边界条件) 5. 确定计算顺序(确保依赖的子问题先计算) 每件物品最多选一次。dpj = 容量为 j 时的最大价值,逆序遍历容量防止重复选取。 每

By yellowdog