浏览器插件开发完全指南(Vite + Vue + Manifest V3)
浏览器插件(Browser Extension)是运行在浏览器内、用 HTML/CSS/JavaScript 编写的小程序,能读取和修改网页、增加浏览器 UI、在后台常驻处理事件。它解决的核心问题是:在不修改网站源码的前提下,向浏览器和任意网页注入自定义功能——广告拦截、密码管理、翻译、爬虫辅助、开发者工具增强都属于这一类。 为什么用 Vite + Vue 而不是裸写:插件本质是多个独立 HTML 入口(popup、options、side panel)加上后台脚本和内容脚本的组合。裸写需要手动维护构建、手动复制静态资源、改一行代码要重新加载整个插件。V
官方文档:
- Chrome Extensions:https://developer.chrome.com/docs/extensions
- MDN WebExtensions:https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions
- CRXJS Vite Plugin:https://crxjs.dev
适用版本:Manifest V3 / Chrome 138+ /
@crxjs/vite-plugin2.4.0 / Vite 6.x / Vue 3.5.x(2026-06-06 核实)
概述
浏览器插件(Browser Extension)是运行在浏览器内、用 HTML/CSS/JavaScript 编写的小程序,能读取和修改网页、增加浏览器 UI、在后台常驻处理事件。它解决的核心问题是:在不修改网站源码的前提下,向浏览器和任意网页注入自定义功能——广告拦截、密码管理、翻译、爬虫辅助、开发者工具增强都属于这一类。
为什么用 Vite + Vue 而不是裸写:插件本质是多个独立 HTML 入口(popup、options、side panel)加上后台脚本和内容脚本的组合。裸写需要手动维护构建、手动复制静态资源、改一行代码要重新加载整个插件。Vite 提供按入口打包和极快的冷启动,Vue 负责 popup/options 这类有状态的 UI;而 @crxjs/vite-plugin(下称 CRXJS)把二者粘合起来,自动从 manifest.json 推导构建入口,并为内容脚本和 HTML 页面提供真正的 HMR(热更新)。对比替代方案:webpack + web-ext 配置繁琐、HMR 弱;官方的 chrome.runtime 裸开发没有模块化;WXT 是更高层的全家桶框架(基于 Vite,约定优于配置),如果想要零配置可选 WXT,而 CRXJS 更贴近原生 Vite、可控性更强。
什么时候适合:需要常驻浏览器、跨网页、调用浏览器特权 API(标签页、书签、网络请求拦截、剪贴板)的场景。不适合:纯展示型工具(直接做网页更省事)、需要绕过浏览器沙箱的低层操作(插件仍受沙箱限制)、对 Safari 强依赖(Safari 用独立的 App Extension 体系,需 Xcode 转换)。
核心概念
一个 Manifest V3 插件由若干相互隔离的执行上下文组成,它们各自运行在不同的环境里,只能通过消息或 chrome.storage 通信,不能直接共享内存。
| 上下文 | 运行环境 | 生命周期 | 能否访问 DOM | 能否调用 chrome API |
|---|---|---|---|---|
| Service Worker(后台) | 独立 Worker 线程,无 DOM | 事件驱动,空闲约 30s 后被回收 | 否 | 几乎全部 |
| Content Script(内容脚本) | 注入到目标网页,与页面共享 DOM | 跟随页面 | 是(页面 DOM) | 仅部分(runtime/storage 等) |
| Popup(弹窗) | 独立 HTML 页面 | 打开时创建,关闭即销毁 | 是(自身 DOM) | 全部 |
| Options(选项页) | 独立 HTML 页面 | 打开时创建 | 是(自身 DOM) | 全部 |
| Side Panel(侧边栏) | 独立 HTML 页面 | 打开时创建,可常驻 | 是(自身 DOM) | 全部 |
| DevTools 页面 | 嵌入开发者工具 | DevTools 打开时 | 是(自身 DOM) | 部分 + devtools.* |
理解三个关键隔离边界:
-
Service Worker 不是持久后台。MV2 的常驻后台页(background page)在 MV3 被换成事件驱动的 Service Worker,它会在空闲时被浏览器杀死,下次事件到来时重新启动。因此不能在 Service Worker 顶层用全局变量保存状态,状态必须落到
chrome.storage。 -
内容脚本运行在"隔离世界"(Isolated World)。它能看到页面的 DOM,但看不到页面 JS 的变量和函数(反之亦然)。两个 JS 上下文共享同一份 DOM,但各有独立的
window。要访问页面自身的 JS 变量,必须把脚本注入到MAINworld(见chrome.scripting)。 -
CSP 限制。MV3 禁止远程代码执行:不能
eval、不能从 CDN 加载并执行远程脚本、content_security_policy中不允许unsafe-eval。所有要执行的代码必须打包进插件。
浏览器
├── Service Worker(background.js) ←─ chrome.runtime.onMessage ─┐
├── Popup(Vue 应用) ←─ chrome.runtime.sendMessage ─┤
├── Options(Vue 应用) │
├── Side Panel(Vue 应用) │
└── 网页 Tab │
└── Content Script(隔离世界) ←─ chrome.tabs.sendMessage ────┘
└── 页面 DOM ↑↓ 可读写
安装与项目搭建(CRXJS + Vite + Vue)
从零创建
# 1. 用 Vite 创建 Vue + TS 项目
npm create vite@latest my-extension -- --template vue-ts
cd my-extension
# 2. 安装 CRXJS(注意 @latest 当前为 2.4.0 稳定版)
npm i -D @crxjs/vite-plugin@latest
# 3. 安装类型定义(提供 chrome.* 的 TS 智能提示)
npm i -D @types/chrome
vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { crx } from '@crxjs/vite-plugin'
import manifest from './manifest.config' // 见下文
export default defineConfig({
plugins: [
vue(),
crx({ manifest }), // crx 必须放在框架插件之后
],
server: {
// HMR 需要固定端口,避免每次刷新端口变化导致内容脚本连不上
port: 5173,
strictPort: true,
hmr: { port: 5173 },
},
})
crx() 的参数:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
manifest |
ManifestV3Export |
必填 | manifest 对象、返回对象的函数,或 defineManifest() 的结果 |
browser |
'chrome' | 'firefox' |
'chrome' |
目标浏览器,影响 HMR 与 polyfill 处理 |
contentScripts.injectCss |
boolean |
true |
是否自动把内容脚本引入的 CSS 注入页面 |
contentScripts.hmrTimeout |
number |
5000 |
内容脚本等待 HMR 更新的超时毫秒数 |
manifest.config.ts(推荐用 TS 而非 JSON)
用 defineManifest 写 manifest 可获得类型校验和从 package.json 读取版本号的能力:
import { defineManifest } from '@crxjs/vite-plugin'
import pkg from './package.json'
export default defineManifest({
manifest_version: 3,
name: 'My Extension',
version: pkg.version, // 复用 package.json 版本,避免两处维护
description: pkg.description,
icons: {
'16': 'icons/icon16.png',
'48': 'icons/icon48.png',
'128': 'icons/icon128.png',
},
action: {
default_popup: 'src/popup/index.html', // CRXJS 直接用源码 HTML 路径
default_icon: 'icons/icon48.png',
},
options_page: 'src/options/index.html',
background: {
service_worker: 'src/background/index.ts',
type: 'module', // 允许 Service Worker 用 ESM import
},
content_scripts: [
{
matches: ['https://*/*'],
js: ['src/content/index.ts'],
run_at: 'document_idle',
},
],
side_panel: { default_path: 'src/sidepanel/index.html' },
permissions: ['storage', 'tabs', 'scripting', 'activeTab'],
host_permissions: ['https://*/*'],
})
关键点:CRXJS 允许在 manifest 里直接引用 src/ 下的源码路径(.ts、.html),它会在构建时替换为打包后的产物路径。这是与原始 Vite 多页应用最大的体验差异。
开发与构建
npm run dev # 启动 HMR 开发服务器,产物输出到 dist/
npm run build # 生产构建,输出到 dist/,用于打包上架
加载到浏览器:打开 chrome://extensions → 开启"开发者模式" → "加载已解压的扩展程序" → 选择 dist/ 目录。npm run dev 模式下改 Vue 组件会即时 HMR;改 manifest 或 background 会触发插件自动重载。
Vue 入口写法(每个 HTML 是一个独立 Vue 应用)
src/popup/index.html:
<!doctype html>
<html>
<head><meta charset="UTF-8" /></head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>
src/popup/main.ts:
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
popup、options、sidepanel 各是一套独立的 createApp,它们不共享 Vue 响应式状态——跨页面共享状态要走 chrome.storage 或消息(见下文实战示例)。
manifest.json 详解
manifest.json 是插件的清单文件,声明元数据、权限和各入口。以下是 Manifest V3 全部常用字段。
必填字段
| 字段 | 类型 | 说明 |
|---|---|---|
manifest_version |
number |
必须为 3;唯一合法值 |
name |
string |
插件名,≤ 75 字符,显示在商店和扩展管理页 |
version |
string |
版本号,1–4 段数字(如 1.0.0.3),每段 0–65535,仅数字和点 |
商店展示字段
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
description |
string |
无 | 简介,≤ 132 字符 |
icons |
object |
无 | 键为尺寸(16/32/48/128),值为 PNG 路径;商店要求至少 128 |
default_locale |
string |
无 | 国际化默认语言(如 en、zh_CN);用 _locales/ 时必填 |
author |
string |
无 | 作者邮箱 |
homepage_url |
string |
无 | 插件主页 |
入口字段
| 字段 | 类型 | 说明 |
|---|---|---|
action |
object |
工具栏图标按钮配置。子字段:default_popup(点击弹出的 HTML)、default_icon、default_title(悬停提示) |
background |
object |
后台脚本。子字段:service_worker(JS 路径,只能一个文件)、type("module" 启用 ESM) |
content_scripts |
array |
内容脚本数组,见下表 |
options_page |
string |
选项页 HTML 路径(在新标签打开) |
options_ui |
object |
选项页配置。子字段:page(HTML 路径)、open_in_tab(boolean,false 则嵌入扩展管理页内显示) |
side_panel |
object |
侧边栏。子字段:default_path(HTML 路径)。需 sidePanel 权限 |
devtools_page |
string |
注入 DevTools 的 HTML 路径 |
chrome_url_overrides |
object |
覆盖浏览器内置页。键:newtab/bookmarks/history,值为 HTML 路径 |
content_scripts 数组中每一项的字段:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
matches |
string[] |
必填 | 匹配的 URL 模式(match pattern),如 ["https://*.example.com/*"]、["<all_urls>"] |
exclude_matches |
string[] |
[] |
排除的 URL 模式 |
js |
string[] |
[] |
注入的 JS 文件,按数组顺序执行 |
css |
string[] |
[] |
注入的 CSS 文件,在页面渲染前应用 |
run_at |
string |
"document_idle" |
注入时机:document_start(DOM 构建前)/ document_end(DOM 完成、资源未必加载)/ document_idle(空闲时,介于 end 和 onload 之间) |
all_frames |
boolean |
false |
是否注入到所有 iframe,而非仅顶层框架 |
match_about_blank |
boolean |
false |
是否注入到 about:blank/about:srcdoc 框架 |
world |
string |
"ISOLATED" |
执行世界:ISOLATED(隔离)/ MAIN(页面主世界,可访问页面 JS) |
权限字段
| 字段 | 类型 | 说明 |
|---|---|---|
permissions |
string[] |
安装时申请的 API 权限,如 "storage"、"tabs"、"scripting"、"alarms"、"contextMenus"、"notifications"、"cookies"、"webNavigation"、"declarativeNetRequest" |
optional_permissions |
string[] |
运行时按需申请的权限,用 chrome.permissions.request() 触发授权弹窗 |
host_permissions |
string[] |
允许访问的网站 URL 模式(MV3 从 permissions 中独立出来),如 ["https://*/*"] |
optional_host_permissions |
string[] |
运行时按需申请的主机权限 |
常见权限含义速查:
| 权限值 | 含义 | 是否触发安装警告 |
|---|---|---|
activeTab |
用户点击插件图标时临时获得当前标签页访问权 | 否(最受推荐) |
storage |
使用 chrome.storage |
否 |
tabs |
读取标签页 url/title/favIconUrl 等敏感属性 |
是 |
scripting |
用 chrome.scripting 动态注入脚本 |
否(实际注入受 host 权限约束) |
host_permissions: <all_urls> |
访问所有网站 | 是(最强警告,审核严格) |
unlimitedStorage |
解除 storage.local 的 10MB 上限 |
否 |
declarativeNetRequest |
声明式拦截/修改网络请求 | 视规则而定 |
资源与安全字段
| 字段 | 类型 | 说明 |
|---|---|---|
web_accessible_resources |
array |
声明可被网页访问的插件内资源。每项含 resources(路径数组)+ matches(允许访问的网页 URL)。MV3 必须显式声明,否则页面无法加载插件内图片/脚本 |
content_security_policy |
object |
子字段 extension_pages(插件页面 CSP)、sandbox(沙箱页 CSP)。MV3 禁止 unsafe-eval 和远程脚本 |
commands |
object |
键盘快捷键。每个命令含 suggested_key(如 { default: "Ctrl+Shift+Y" })和 description。特殊命令 _execute_action 可绑定打开 popup |
externally_connectable |
object |
声明哪些网页/扩展可通过 runtime.sendMessage 与本插件通信。子字段 matches、ids |
minimum_chrome_version |
string |
可安装的最低 Chrome 版本 |
完整 manifest 示例(含快捷键和资源声明):
{
"manifest_version": 3,
"name": "示例插件",
"version": "1.0.0",
"description": "演示完整 manifest 结构",
"icons": { "16": "icons/16.png", "48": "icons/48.png", "128": "icons/128.png" },
"action": { "default_popup": "popup.html", "default_title": "打开面板" },
"background": { "service_worker": "background.js", "type": "module" },
"content_scripts": [
{
"matches": ["https://*.example.com/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
],
"permissions": ["storage", "scripting", "activeTab", "contextMenus"],
"host_permissions": ["https://*.example.com/*"],
"web_accessible_resources": [
{ "resources": ["inject.js", "images/*.png"], "matches": ["https://*.example.com/*"] }
],
"commands": {
"_execute_action": { "suggested_key": { "default": "Ctrl+Shift+Y" } },
"toggle-feature": {
"suggested_key": { "default": "Ctrl+Shift+U" },
"description": "切换主功能"
}
},
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
}
}
Chrome API 详解
所有 chrome.* API 在 MV3 中默认返回 Promise(也兼容回调写法)。本节用 Promise + async/await 风格。Firefox 用 browser.* 命名空间(见跨浏览器节)。
chrome.runtime
插件自身的运行时信息与消息总线,无需任何权限即可使用。
常用成员:
| 成员 | 类型 | 说明 |
|---|---|---|
runtime.id |
string |
插件 ID |
runtime.getURL(path) |
(string) => string |
把插件内相对路径转为 chrome-extension://<id>/path 完整 URL |
runtime.getManifest() |
() => object |
返回解析后的 manifest 对象 |
runtime.sendMessage(message, options?) |
=> Promise<any> |
发送一次性消息给插件内其他上下文(不发给内容脚本) |
runtime.onMessage |
Event |
监听消息,回调 (message, sender, sendResponse) |
runtime.connect(connectInfo?) |
=> Port |
建立长连接端口 |
runtime.onConnect |
Event |
监听长连接 |
runtime.onInstalled |
Event |
安装/更新时触发,回调含 reason(install/update) |
runtime.onStartup |
Event |
浏览器启动时触发 |
runtime.lastError |
object | undefined |
回调风格下读取错误;Promise 风格用 try/catch |
onMessage 回调参数:
| 参数 | 类型 | 说明 |
|---|---|---|
message |
any |
发送方传来的消息(JSON 可序列化) |
sender |
MessageSender |
发送方信息:sender.tab(来自内容脚本时含标签页)、sender.id、sender.url、sender.frameId |
sendResponse |
(response) => void |
回复函数;异步回复必须 return true 保持通道开启 |
// background/index.ts —— 接收消息并异步回复
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'FETCH_DATA') {
// 正确:异步操作时 return true,保持消息通道开启
fetch(msg.url)
.then((r) => r.json())
.then((data) => sendResponse({ ok: true, data }))
.catch((e) => sendResponse({ ok: false, error: String(e) }))
return true
}
// 错误:异步里没 return true,通道会立即关闭,sendResponse 失效
})
// popup 或 content script —— 发送消息(Promise 风格)
const res = await chrome.runtime.sendMessage({ type: 'FETCH_DATA', url: '/api' })
if (res.ok) console.log(res.data)
chrome.tabs
操作浏览器标签页。读取 url/title 等敏感属性需 tabs 权限或对应 host 权限。
| 方法 | 签名 | 说明 |
|---|---|---|
tabs.query(queryInfo) |
=> Promise<Tab[]> |
按条件查标签页 |
tabs.get(tabId) |
=> Promise<Tab> |
按 ID 取标签页 |
tabs.create(createProperties) |
=> Promise<Tab> |
新建标签页 |
tabs.update(tabId?, updateProperties) |
=> Promise<Tab> |
更新(导航、激活等) |
tabs.remove(tabIds) |
=> Promise<void> |
关闭标签页 |
tabs.sendMessage(tabId, message, options?) |
=> Promise<any> |
向指定标签页的内容脚本发消息 |
tabs.captureVisibleTab(windowId?, options?) |
=> Promise<string> |
截图当前可见区域,返回 dataURL |
tabs.onUpdated |
Event |
标签页状态变化(loading/complete、URL 变更) |
tabs.onActivated |
Event |
切换激活标签页 |
tabs.query 的 queryInfo 常用字段:
| 字段 | 类型 | 说明 |
|---|---|---|
active |
boolean |
是否为窗口内激活标签 |
currentWindow |
boolean |
是否在当前窗口 |
url |
string | string[] |
按 URL 模式过滤(需 tabs 或 host 权限) |
status |
string |
"loading" 或 "complete" |
pinned |
boolean |
是否固定 |
// 获取当前激活标签页(最常用模式)
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
if (tab?.id) {
await chrome.tabs.sendMessage(tab.id, { type: 'HIGHLIGHT' })
}
chrome.storage
插件专用的异步持久化存储,所有上下文(含内容脚本)共享,是跨上下文共享状态的首选。需 storage 权限。
四个存储区及配额:
| 存储区 | 作用域 | 配额 | 清除时机 | 典型用途 |
|---|---|---|---|---|
storage.local |
本机 | 10 MB(加 unlimitedStorage 权限后不限) |
卸载插件时 | 大量本地数据、缓存 |
storage.sync |
跨设备同步(登录同一 Google 账号) | 总 100 KB、单项 8 KB、最多 512 项;写入限 120 次/分、1800 次/时 | 卸载时 | 用户设置 |
storage.session |
内存(不落盘) | 10 MB | 浏览器重启、插件重载/更新/禁用 | Service Worker 临时状态 |
storage.managed |
企业策略下发,只读 | — | 由策略控制 | 企业配置 |
每个存储区方法相同(StorageArea 接口):
| 方法 | 签名 | 说明 |
|---|---|---|
get(keys?) |
=> Promise<object> |
keys 可为字符串、数组、含默认值的对象,或 null(取全部) |
set(items) |
=> Promise<void> |
items 为键值对象 |
remove(keys) |
=> Promise<void> |
删除指定键 |
clear() |
=> Promise<void> |
清空该存储区 |
getBytesInUse(keys?) |
=> Promise<number> |
已用字节数 |
setAccessLevel(options) |
=> Promise<void> |
控制内容脚本能否访问(session 区默认对内容脚本不可见) |
// 写入与读取(带默认值)
await chrome.storage.local.set({ theme: 'dark', count: 1 })
const { theme, count } = await chrome.storage.local.get({ theme: 'light', count: 0 })
// 监听变化(任意上下文都能收到,是跨页面同步的关键)
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName === 'local' && changes.theme) {
console.log('主题从', changes.theme.oldValue, '变为', changes.theme.newValue)
}
})
chrome.scripting
MV3 中动态注入脚本/样式的 API(取代 MV2 的 tabs.executeScript)。需 scripting 权限,且对目标页有 host 权限或 activeTab。
| 方法 | 签名 | 说明 |
|---|---|---|
scripting.executeScript(injection) |
=> Promise<InjectionResult[]> |
注入函数或文件,返回每个框架的结果 |
scripting.insertCSS(injection) |
=> Promise<void> |
注入 CSS(字符串或文件) |
scripting.removeCSS(injection) |
=> Promise<void> |
移除之前注入的 CSS(参数需完全匹配) |
scripting.registerContentScripts(scripts) |
=> Promise<void> |
动态注册持久内容脚本 |
scripting.updateContentScripts(scripts) |
=> Promise<void> |
更新已注册脚本 |
scripting.unregisterContentScripts(filter?) |
=> Promise<void> |
注销动态脚本 |
scripting.getRegisteredContentScripts(filter?) |
=> Promise<RegisteredContentScript[]> |
查询已注册脚本 |
executeScript 的 injection 字段:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
target |
object |
必填 | { tabId, frameIds?, allFrames? };allFrames 与 frameIds 互斥 |
func |
Function |
无 | 要注入执行的函数(与 files 二选一)。注意:函数体在页面侧序列化执行,不能闭包捕获外部变量 |
args |
any[] |
[] |
传给 func 的参数,必须 JSON 可序列化 |
files |
string[] |
无 | 要注入的 JS 文件路径 |
world |
string |
"ISOLATED" |
ISOLATED 或 MAIN(访问页面 JS) |
injectImmediately |
boolean |
false |
是否尽快注入而不等待页面就绪 |
// 注入函数并传参,读取返回值
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId },
func: (selector: string) => document.querySelectorAll(selector).length,
args: ['a'], // 通过 args 传入,不能用闭包
})
console.log('页面链接数:', result)
// 错误:func 内引用了外部变量 sel,注入后页面侧拿不到,会报 undefined
const sel = 'a'
await chrome.scripting.executeScript({
target: { tabId },
func: () => document.querySelectorAll(sel).length, // sel 在页面侧未定义
})
chrome.action
控制工具栏图标按钮(徽章、图标、弹窗、启用状态)。无需权限。
| 方法 | 签名 | 说明 |
|---|---|---|
action.setBadgeText(details) |
=> Promise<void> |
设置图标角标文字,如 { text: '5', tabId? } |
action.setBadgeBackgroundColor(details) |
=> Promise<void> |
角标背景色 |
action.setIcon(details) |
=> Promise<void> |
动态换图标 |
action.setPopup(details) |
=> Promise<void> |
动态设置点击弹出的 HTML(设为 '' 则点击触发 onClicked) |
action.setTitle(details) |
=> Promise<void> |
悬停提示文字 |
action.onClicked |
Event |
仅当没有 default_popup 时点击图标才触发 |
// 在角标显示未读数
await chrome.action.setBadgeText({ text: '3' })
await chrome.action.setBadgeBackgroundColor({ color: '#d33' })
chrome.contextMenus
添加右键菜单项。需 contextMenus 权限。
| 方法 | 签名 | 说明 |
|---|---|---|
contextMenus.create(props, cb?) |
=> number | string |
创建菜单项,返回 ID |
contextMenus.update(id, props) |
=> Promise<void> |
更新菜单项 |
contextMenus.remove(id) |
=> Promise<void> |
删除指定项 |
contextMenus.removeAll() |
=> Promise<void> |
删除全部 |
contextMenus.onClicked |
Event |
点击回调 (info, tab) |
create 的 props 常用字段:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
id |
string |
自动生成 | 菜单项 ID(用事件区分时建议手动设) |
title |
string |
无 | 显示文字;%s 会被选中文本替换 |
contexts |
string[] |
["page"] |
出现场景:page/selection/link/image/editable 等 |
parentId |
string | number |
无 | 父菜单 ID(做二级菜单) |
type |
string |
"normal" |
normal/checkbox/radio/separator |
// 必须在 onInstalled 中创建,避免 Service Worker 重启重复创建报错
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: 'search-selection',
title: '搜索 "%s"',
contexts: ['selection'],
})
})
chrome.contextMenus.onClicked.addListener((info, tab) => {
if (info.menuItemId === 'search-selection') {
chrome.tabs.create({ url: `https://www.google.com/search?q=${info.selectionText}` })
}
})
chrome.alarms
定时任务,替代在 Service Worker 中用 setTimeout/setInterval(后者会随 Worker 回收而失效)。需 alarms 权限。
| 方法 | 签名 | 说明 |
|---|---|---|
alarms.create(name?, alarmInfo) |
=> Promise<void> |
创建定时器 |
alarms.get(name?) |
=> Promise<Alarm> |
获取 |
alarms.getAll() |
=> Promise<Alarm[]> |
获取全部 |
alarms.clear(name?) |
=> Promise<boolean> |
清除 |
alarms.onAlarm |
Event |
触发回调 (alarm) |
alarmInfo 字段:
| 字段 | 类型 | 说明 |
|---|---|---|
when |
number |
首次触发的绝对时间戳(毫秒) |
delayInMinutes |
number |
多少分钟后首次触发 |
periodInMinutes |
number |
重复周期(分钟);最小为 1 分钟 |
chrome.runtime.onInstalled.addListener(() => {
chrome.alarms.create('sync', { periodInMinutes: 30 }) // 每 30 分钟
})
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'sync') syncData()
})
chrome.sidePanel
Chrome 114+ 的侧边栏 API。需 sidePanel 权限 + manifest 中 side_panel。
| 方法 | 签名 | 说明 |
|---|---|---|
sidePanel.open(options) |
=> Promise<void> |
打开侧边栏,需在用户手势回调中调用 |
sidePanel.setOptions(options) |
=> Promise<void> |
设置某标签页的侧边栏路径/启用状态 |
sidePanel.setPanelBehavior(behavior) |
=> Promise<void> |
{ openPanelOnActionClick: true } 让点击图标即开侧边栏 |
// 点击工具栏图标直接打开侧边栏
chrome.runtime.onInstalled.addListener(() => {
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true })
})
chrome.declarativeNetRequest
声明式拦截/重定向/修改网络请求,MV3 取代了 MV2 的阻塞式 webRequest(出于性能和隐私,MV3 不再允许阻塞式 webRequest)。需 declarativeNetRequest 权限。
规则用静态 JSON 文件(manifest 中 declarative_net_request.rule_resources 声明)或运行时动态规则:
| 方法 | 签名 | 说明 |
|---|---|---|
declarativeNetRequest.updateDynamicRules(options) |
=> Promise<void> |
增删动态规则 |
declarativeNetRequest.getDynamicRules() |
=> Promise<Rule[]> |
查询动态规则 |
declarativeNetRequest.updateSessionRules(options) |
=> Promise<void> |
会话级规则(不持久) |
// 拦截所有对 ads.example.com 的请求
await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: [1],
addRules: [{
id: 1,
priority: 1,
action: { type: 'block' },
condition: { urlFilter: '||ads.example.com', resourceTypes: ['script', 'image'] },
}],
})
消息通信详解
四种通信路径,按场景选择:
| 场景 | 发送方 | 接收方 | API |
|---|---|---|---|
| popup/options → 后台 | runtime.sendMessage |
后台 runtime.onMessage |
一次性 |
| 后台/popup → 内容脚本 | tabs.sendMessage(tabId, ...) |
内容脚本 runtime.onMessage |
一次性 |
| 内容脚本 → 后台 | runtime.sendMessage |
后台 runtime.onMessage |
一次性 |
| 需要持续双向通信 | runtime.connect / tabs.connect |
runtime.onConnect |
长连接 Port |
| 网页 ↔ 插件 | 网页 runtime.sendMessage(extId, ...) |
runtime.onMessageExternal |
需 externally_connectable |
一次性消息的约束(来自官方):
- 最大消息体 64 MiB。
- 消息用 JSON 序列化(不是 structured clone),不能传函数、DOM、
Map/Set。 - 多个监听器时,只有第一个调用
sendResponse的回复生效。 - 异步回复必须
return true(字面量 true,不是 truthy 值)。Chrome 148+ 也支持监听器返回 Promise。
长连接(Port)适合频繁交互或流式数据:
// 内容脚本侧:建立连接
const port = chrome.runtime.connect({ name: 'stream' })
port.postMessage({ type: 'START' })
port.onMessage.addListener((msg) => console.log('收到', msg))
port.onDisconnect.addListener(() => console.log('连接断开'))
// 后台侧:接收连接
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== 'stream') return
port.onMessage.addListener((msg) => {
if (msg.type === 'START') port.postMessage({ tick: 1 })
})
})
完整示例(End-to-End)
一个真实可跑的插件:popup(Vue)里点按钮 → 让当前页面所有图片加边框 → 计数存到 storage → 角标显示计数。涉及 popup、后台、内容脚本三方协作。
目录结构
src/
├── popup/
│ ├── index.html
│ ├── main.ts
│ └── App.vue
├── background/
│ └── index.ts
└── content/
└── index.ts
manifest.config.ts
manifest.config.ts
import { defineManifest } from '@crxjs/vite-plugin'
export default defineManifest({
manifest_version: 3,
name: '图片高亮器',
version: '1.0.0',
action: { default_popup: 'src/popup/index.html' },
background: { service_worker: 'src/background/index.ts', type: 'module' },
content_scripts: [
{ matches: ['<all_urls>'], js: ['src/content/index.ts'], run_at: 'document_idle' },
],
permissions: ['storage', 'tabs', 'activeTab'],
})
src/popup/App.vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const count = ref(0)
// popup 打开时从 storage 读历史计数
onMounted(async () => {
const { highlightCount = 0 } = await chrome.storage.local.get('highlightCount')
count.value = highlightCount
})
async function highlight() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
if (!tab?.id) return
// 向当前标签页的内容脚本发消息,等待它返回本次高亮的图片数
const res = await chrome.tabs.sendMessage(tab.id, { type: 'HIGHLIGHT' })
count.value = res.total
}
</script>
<template>
<div style="width: 200px; padding: 12px">
<button @click="highlight">高亮本页图片</button>
<p>累计高亮:{{ count }} 张</p>
</div>
</template>
src/content/index.ts
// 内容脚本:运行在页面里,能直接操作 DOM
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg.type === 'HIGHLIGHT') {
const imgs = document.querySelectorAll('img')
imgs.forEach((img) => {
;(img as HTMLElement).style.outline = '3px solid #e11'
})
// 把计数转交后台累加并更新角标
chrome.runtime.sendMessage({ type: 'ADD_COUNT', delta: imgs.length })
sendResponse({ total: imgs.length })
}
// 同步回复,无需 return true
})
src/background/index.ts
// 后台 Service Worker:累计计数、更新角标
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'ADD_COUNT') {
// 状态必须落 storage,不能用全局变量(Worker 会被回收)
chrome.storage.local.get({ highlightCount: 0 }).then(({ highlightCount }) => {
const next = highlightCount + msg.delta
chrome.storage.local.set({ highlightCount: next })
chrome.action.setBadgeText({ text: String(next) })
})
}
})
// 安装时初始化角标
chrome.runtime.onInstalled.addListener(() => {
chrome.action.setBadgeBackgroundColor({ color: '#333' })
})
运行 npm run dev,在 chrome://extensions 加载 dist/,打开任意网页点击插件图标即可看到效果,改 App.vue 会即时 HMR。
跨浏览器开发
Chrome、Edge、Firefox、Opera 都支持 WebExtensions,但有两处差异:API 命名空间和Promise 支持。
- Chrome/Edge:
chrome.*,MV3 已原生返回 Promise。 - Firefox:
browser.*原生返回 Promise;也提供chrome.*但只支持回调。
统一写法用官方 webextension-polyfill:
npm i webextension-polyfill
npm i -D @types/webextension-polyfill
import browser from 'webextension-polyfill'
// 之后统一用 browser.*,在 Chrome 和 Firefox 都返回 Promise
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
Firefox 的额外注意点:
| 差异点 | Chrome | Firefox |
|---|---|---|
| 后台 | service_worker |
MV3 也支持 service_worker(较新版本),旧版用 scripts 数组 |
| 扩展 ID | 自动生成 | 需在 browser_specific_settings.gecko.id 显式声明 |
| 打包 | 直接传 zip | 用 web-ext 工具打包、签名 |
CRXJS 可通过 crx({ manifest, browser: 'firefox' }) 输出 Firefox 兼容产物。若要彻底零心智负担地跨浏览器,可考虑 WXT 框架,它内置多浏览器构建和 browser 全局注入。
最佳实践
用 activeTab 替代宽泛的 host 权限:尽量不申请 <all_urls>。activeTab 在用户点击图标时临时授予当前页访问权,不触发安装警告,商店审核更快。只有确实需要后台无人值守地访问特定站点时才申请 host 权限,并尽量收窄到具体域名。
// 正确:最小权限
"permissions": ["activeTab", "scripting"]
// 错误:无必要地索要全站访问,吓退用户、拖慢审核
"host_permissions": ["<all_urls>"]
Service Worker 中状态一律落 storage:MV3 的 Worker 会在空闲约 30 秒后被回收,顶层变量随之丢失。任何需要跨事件保留的状态都写 chrome.storage(短期高频可用 storage.session)。
// 正确:从 storage 读取
const { token } = await chrome.storage.session.get('token')
// 错误:全局变量在 Worker 重启后变 undefined
let token = '' // 下次事件触发时可能已被回收清空
定时任务用 chrome.alarms 而非 setInterval:Worker 被回收后 setTimeout/setInterval 全部失效;alarms 由浏览器托管,能可靠唤醒 Worker。注意周期最小 1 分钟。
在 onInstalled 中做一次性初始化:创建右键菜单、写入默认配置、建立 alarms 都应放在 runtime.onInstalled 里。直接在 Worker 顶层创建 contextMenus 会因 Worker 反复重启而抛"重复 ID"错误。
消息体保持扁平且可 JSON 序列化:不要试图通过消息传 DOM 节点、函数、Map/Set 或循环引用对象——会静默丢失或报错。需要传复杂结构时先 JSON.parse(JSON.stringify(x)) 验证可序列化。
用 TypeScript + @types/chrome 约束 API:插件 API 面广、易记错参数形状,类型提示能在编译期拦住"tabs.sendMessage 少传 tabId"这类错误。给消息定义联合类型(discriminated union)后,onMessage 里按 msg.type 收窄能彻底消除字段拼写错误。
type Message =
| { type: 'HIGHLIGHT' }
| { type: 'ADD_COUNT'; delta: number }
常见陷阱
陷阱:sendResponse 异步回复收不到
现象: onMessage 里发起 fetch 后调用 sendResponse,但发送方 await sendMessage 拿到的是 undefined,控制台偶尔报 "The message port closed before a response was received"。
原因: 消息监听器同步返回后,Chrome 默认立即关闭回复通道。异步操作(fetch、storage)的回调在通道关闭之后才执行,sendResponse 失效。
解决: 在监听器里 return true(字面量),告诉 Chrome 保持通道开启直到 sendResponse 被调用。
chrome.runtime.onMessage.addListener((msg, _s, sendResponse) => {
fetch(msg.url).then((r) => r.json()).then((d) => sendResponse(d))
return true // 关键:缺这一行回复必丢
})
陷阱:tabs.sendMessage 报 "Could not establish connection. Receiving end does not exist"
现象: 从 popup 用 chrome.tabs.sendMessage 给页面发消息,报"接收端不存在"。
原因: 目标标签页里没有内容脚本在监听。常见于三种情况:(1) 当前页 URL 不匹配 content_scripts.matches;(2) 页面是 chrome://、Chrome 商店、PDF 等受保护页,禁止注入;(3) 内容脚本注册后页面尚未刷新(新装插件不会自动注入到已打开的旧标签页)。
解决: 刷新目标页让内容脚本注入;对受保护页做 URL 白名单判断后再发消息;或改用 chrome.scripting.executeScript 主动注入后再通信,并用 try/catch 兜底。
try {
await chrome.tabs.sendMessage(tab.id, { type: 'PING' })
} catch {
// 内容脚本未就绪,主动注入
await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['content.js'] })
}
陷阱:内容脚本访问不到页面 JS 的变量
现象: 内容脚本里 window.someLibFromPage 是 undefined,但在页面控制台能正常访问。
原因: 内容脚本运行在"隔离世界"(Isolated World),与页面共享 DOM 但不共享 JS 上下文。两者各有独立 window,看不到对方的变量、函数、原型修改。
解决: 把需要触达页面 JS 的脚本注入到 MAIN world(manifest 的 content_scripts.world: "MAIN",或 scripting.executeScript({ world: 'MAIN' }));或者由内容脚本动态创建 <script> 标签注入页面(脚本文件需登记到 web_accessible_resources),再通过 window.postMessage 在两个世界间传数据。
// 内容脚本:注入到页面主世界的脚本(inject.js 须在 web_accessible_resources 声明)
const s = document.createElement('script')
s.src = chrome.runtime.getURL('inject.js')
;(document.head || document.documentElement).appendChild(s)
陷阱:Vue Router 的 history 模式在 popup 里白屏
现象: popup/options 用了 createWebHistory() 的 Vue Router,打开后空白或路由跳转 404。
原因: 插件页面的 URL 是 chrome-extension://<id>/popup.html,HTML5 history 模式依赖服务器对路径回退,而插件页无服务器;刷新或深链接会找不到文件。
解决: 插件内一律用 createWebHashHistory()(hash 路由),URL 变为 popup.html#/path,不依赖服务端回退,刷新也正常。
import { createRouter, createWebHashHistory } from 'vue-router'
const router = createRouter({ history: createWebHashHistory(), routes })
发布到 Chrome Web Store
- 打包:
npm run build后把dist/目录压缩为 zip(注意是压缩目录内容,不要多套一层文件夹)。 - 注册开发者账号:访问 Chrome Web Store Developer Dashboard,一次性缴纳 5 美元注册费。
- 上传与填写:上传 zip,填写名称、描述、分类、至少一张 1280×800 截图、128×128 图标,以及隐私政策(凡申请敏感权限必须提供)。
- 权限说明:每项敏感权限需在表单里说明用途,宽泛权限(如
<all_urls>)会触发人工审核、耗时更久。 - 审核与发布:提交后进入审核,通常数小时到数天。可选"私有""仅限指定人员""公开"发布范围。
- 版本更新:每次更新须提升 manifest
version,重新上传 zip。
Edge 插件发布到 Microsoft Edge Add-ons(免费),Firefox 发布到 AMO(用 web-ext sign 签名)。
参见
- Vite初级指南、Vite中级指南、Vite高级指南 — 构建工具基础与多入口配置
- Vue3入门、Vue3 + TypeScript完全指南 — popup/options 的 Vue 应用编写
- Pinia完全指南 — 插件页面内的状态管理(跨上下文仍需配合 storage)
- Vue Router完全指南 — 配合 hash 模式在插件内做多页导航
- TypeScript完全指南、TypeScript最佳实践 — 消息类型、API 类型约束
- JS模块系统 — Service Worker 的 ESM(
type: "module")背景 - Axios完全指南 — 在后台脚本里发起 HTTP 请求(注意 CORS 与 host 权限)
- WebSocket完全指南 — 后台与服务端的长连接通信