Chrome 扩展开发完全指南:从入门到精通

Cosolar 5 阅读 前端技术架构

基于 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 V3 snippets 插件是主流选择
  • 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 规则

加载未打包扩展进行开发

开发阶段无需打包,直接加载本地目录即可:

  1. 打开 chrome://extensions/
  2. 开启右上角「开发者模式」
  3. 点击「加载已解压的扩展程序」,选择你的项目目录

修改代码后,回到该页面点击扩展卡片上的刷新按钮即可重新加载。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_actionpage_action 两个概念,MV3 将它们合并为统一的 actiondefault_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_framestrue 时会注入到所有 iframe 中。

权限与主机权限分离

MV3 将权限分为两类,这是安全模型的核心改进:

  • permissions:扩展能力权限,如 storagetabsnotifications
  • host_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);
});

第六步:加载并测试

  1. 打开 chrome://extensions/,开启开发者模式
  2. 点击「加载已解压的扩展程序」,选择 page-info 目录
  3. 打开任意网页,点击工具栏上的扩展图标
  4. Popup 会显示当前页面的标题和 URL,点击按钮可复制

第一次运行成功后,你已经掌握了扩展开发的基本闭环:写 manifest、写各组件代码、加载调试。后续章节将逐一深入每个组件和 API。

Service Worker 深入

Service Worker 是 MV3 扩展的「大脑」,负责所有不需要 DOM 的后台逻辑。它替代了 MV2 的常驻 background page,最大的区别在于生命周期:Service Worker 在事件触发时启动,处理完毕空闲后会被 Chrome 主动终止。这种设计能节省内存,但也要求开发者转变思维。

生命周期与事件驱动模型

Service Worker 会在以下情况启动:

  • 收到 chrome.runtime.onInstalledonStartup 等扩展级事件
  • Content Script 或 Popup 通过消息唤醒它
  • 注册的 chrome.alarms 定时器触发
  • 用户执行了 chrome.commands 定义的快捷键
  • 网络请求匹配了 declarativeNetRequest 规则(带回调时)

启动后,只要还在处理事件,Service Worker 就保持活跃。事件处理完毕约 30 秒后(具体时长由 Chrome 决定),它会被终止,所有内存中的全局变量随之消失。下次事件到来时,Service Worker 从头执行顶层代码。

这意味着两条铁律:

  1. 不要用全局变量保存需要跨事件保留的状态,改用 chrome.storage
  2. 事件监听必须在顶层同步注册,不能放在异步函数里,否则 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.myFunctionundefined

// 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.runtimesendMessageonMessageidgetURL
  • chrome.storage(需要对应权限)
  • chrome.i18n(国际化)

不能直接调用 chrome.tabschrome.alarmschrome.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 本质上都是普通的 HTML 页面,运行在扩展上下文中,能访问完整的 chrome.* API。它们的区别在于使用场景:Popup 是点击工具栏图标时弹出的临时小窗,Options 是用户从扩展管理页进入的设置界面。

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 指定匹配哪些类型的资源。

动态规则:用代码添加

需要运行时调整规则时,用 updateDynamicRulesupdateSessionRules

// 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 排序:

  1. allow / allowAllRequests 规则优先,匹配则放行
  2. 然后是 block 规则
  3. 接着是 redirect / upgradeScheme
  4. 最后是 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 控制菜单在什么场景下出现,可选值包括 pageselectionlinkimagevideoaudio 等。

键盘快捷键 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 更精细,遵循最小权限原则。

权限分类

类别 示例 授予时机 是否影响审核
普通权限 storagealarmscontextMenus 安装时自动授予 影响小
可选权限 tabsbookmarks 用户主动授权 需说明用途
主机权限 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.localsyncsession 的内容,无需写代码。

热重载开发体验

每次改代码都要手动点刷新很烦。可以在 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);
        }
      });
    });
  });
}

或者使用社区的热重载工具,如 crxjsvite-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,以便用户收藏。数据仅存储在本地,不上传任何服务器。」

审核流程

提交后进入审核队列,通常需要几天到几周。审核包括:

  1. 自动扫描:恶意代码检测、权限滥用检查
  2. 人工审核:功能与描述是否一致、权限是否合理、隐私合规
  3. 反复修改:如果被拒,根据反馈修改后重新提交

审核期间扩展状态为「审核中」,无法被用户安装。通过后会变为「已发布」,立即对用户可见。

版本更新

每次发布新版本,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.storagechrome.scriptingdeclarativeNetRequest 这几个核心 API,加上对权限模型的理解,就能应对绝大多数扩展需求。

从本文的第一个 Hello World 到最后的网页收藏标注器,你已经走完了从入门到能独立开发产品的路径。真正深入还需要在实践中遇到具体问题、查阅官方 API 文档、阅读优秀开源扩展的源码。Chrome 扩展的官方文档(developer.chrome.com/docs/extensions)是最权威的参考,遇到 API 细节疑问时应以它为准。

扩展生态仍在演进,Side Panel、Offscreen API 等新能力还在不断加入。保持对官方更新日志的关注,能让你的扩展用上最新的能力。