浏览器插件 中级开发指南(Manifest V3 + Vite + Vue)
本文面向已掌握 Manifest V3 基础(manifest 结构、Service Worker 后台、消息通信)、需要落地真实功能的开发者。每个功能模块给出可直接复用的完整代码,技术栈统一为 Vite + Vue 3 + Pinia,构建假定使用 @crxjs/vite-plugin 或等价的多入口配置(产出 manifest.json 与各 HTML 入口)。 后文所有示例基于以下目录结构。src/manifest.ts 导出 manifest 对象,由构建插件生成最终 manifest.json。 声明式注入(manifest content_s
官方文档:
- 内容脚本 https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts
- scripting API https://developer.chrome.com/docs/extensions/reference/api/scripting
- declarativeNetRequest https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest
- sidePanel https://developer.chrome.com/docs/extensions/reference/api/sidePanel
- devtools https://developer.chrome.com/docs/extensions/reference/api/devtools
- i18n https://developer.chrome.com/docs/extensions/reference/api/i18n
- notifications https://developer.chrome.com/docs/extensions/reference/api/notifications
适用版本:Manifest V3 / Chrome 138+(部分 API 标注更高最低版本)
核实日期:2026-06-06
本文面向已掌握 Manifest V3 基础(manifest 结构、Service Worker 后台、消息通信)、需要落地真实功能的开发者。每个功能模块给出可直接复用的完整代码,技术栈统一为 Vite + Vue 3 + Pinia,构建假定使用 @crxjs/vite-plugin 或等价的多入口配置(产出 manifest.json 与各 HTML 入口)。
工程结构约定
后文所有示例基于以下目录结构。src/manifest.ts 导出 manifest 对象,由构建插件生成最终 manifest.json。
extension/
├── src/
│ ├── manifest.ts # manifest 定义
│ ├── background/
│ │ └── index.ts # Service Worker 入口
│ ├── content/
│ │ ├── index.ts # 内容脚本(ISOLATED world)
│ │ ├── main-world.ts # 注入页面 MAIN world 的脚本
│ │ └── App.vue # 挂到页面的 Vue UI
│ ├── popup/ # 弹窗 SPA(Vue + Router + Pinia)
│ ├── options/ # 设置页 SPA
│ ├── sidepanel/ # 侧边栏 SPA
│ ├── devtools/ # DevTools 页与面板
│ ├── stores/ # Pinia stores
│ └── i18n/ # t() helper
├── _locales/
│ ├── zh_CN/messages.json
│ └── en/messages.json
├── rules/
│ └── static-rules.json # declarativeNetRequest 静态规则
└── vite.config.ts
内容脚本进阶
声明式注入 vs 动态注入
声明式注入(manifest content_scripts)在匹配页面加载时自动执行,适合稳定的、对所有匹配站点生效的脚本。动态注入(chrome.scripting)在运行时按需注入,适合"点击图标才注入""根据用户设置决定是否注入"等场景。
manifest 声明式注入片段:
// src/manifest.ts
export default {
manifest_version: 3,
name: "__MSG_extName__",
default_locale: "zh_CN",
version: "1.0.0",
permissions: ["scripting", "activeTab", "storage", "tabs"],
host_permissions: ["https://*/*"],
content_scripts: [
{
matches: ["https://*.example.com/*"],
js: ["src/content/index.ts"],
run_at: "document_idle", // 注入时机,见下表
all_frames: false // 是否注入所有 iframe
}
]
} satisfies chrome.runtime.ManifestV3
运行时动态注入(在 Service Worker 或 popup 中调用):
// 点击图标时才向当前标签页注入
chrome.action.onClicked.addListener(async (tab) => {
if (!tab.id) return
await chrome.scripting.executeScript({
target: { tabId: tab.id, allFrames: false },
files: ["src/content/index.ts"]
})
})
// 直接注入函数并传参(func + args,args 必须 JSON 可序列化)
async function highlightKeyword(tabId: number, keyword: string) {
await chrome.scripting.executeScript({
target: { tabId },
func: (kw: string) => {
// 此函数在页面的 ISOLATED world 执行
document.body.innerHTML = document.body.innerHTML.replaceAll(
kw,
`<mark>${kw}</mark>`
)
},
args: [keyword] // 通过 args 传入,不能闭包捕获外层变量
})
}
executeScript 关键字段:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
target.tabId |
number | 必填 | 目标标签页 ID |
target.allFrames |
boolean | false | 注入所有帧 |
target.frameIds |
number[] | — | 指定帧 ID,与 allFrames 互斥 |
func |
Function | — | 要执行的函数,与 files 互斥 |
args |
any[] | — | 传给 func 的参数,须 JSON 可序列化 |
files |
string[] | — | 注入的脚本文件路径(相对扩展根) |
world |
string | "ISOLATED" | 执行世界,"ISOLATED" 或 "MAIN" |
injectImmediately |
boolean | false | 不等待页面加载立即注入 |
run_at 时机选择
| 取值 | 时机 | 适用场景 |
|---|---|---|
document_start |
DOM 构建前、CSS 加载前 | 抢在页面脚本前 hook、注入 MAIN world 拦截 |
document_end |
DOM 构建完成、子资源未必加载完 | 操作 DOM 结构,不依赖图片/样式 |
document_idle(默认) |
DOM 完成且页面空闲(介于 end 与 window.onload 之间) | 常规 UI 注入、读取页面数据 |
需要在页面自身脚本执行前介入(例如改写 window.fetch)时,必须用 document_start 且注入到 MAIN world。
all_frames 与 iframe
all_frames: true 会把脚本注入页面的每个同源/跨源 iframe。配合 match_about_blank 可注入 about:blank 帧。注入到所有帧时务必在脚本里判断 window.top === window.self,避免在子帧重复挂载 UI。
// src/content/index.ts
if (window.top !== window.self) {
// 错误:在每个 iframe 都挂载浮层会导致页面出现多个 UI
// 仅在顶层帧挂载主 UI,子帧只做数据采集
} else {
mountFloatingUI()
}
注入 MAIN world 桥接页面变量
内容脚本默认运行在 ISOLATED world(隔离环境),与页面共享 DOM 但不共享 JS 变量与原型。要读取页面 window.__APP_STATE__ 这类变量,需把脚本注入 MAIN world,再用 window.postMessage 把数据传回 ISOLATED world。
声明式注入 MAIN world(manifest):
// src/manifest.ts 的 content_scripts 增加一条
{
matches: ["https://*.example.com/*"],
js: ["src/content/main-world.ts"],
run_at: "document_start",
world: "MAIN" // 关键:运行在页面主世界,可访问页面变量
}
MAIN world 脚本:读取页面变量并通过 postMessage 发出。
// src/content/main-world.ts —— 运行在页面 MAIN world
;(function bridgePageVariables() {
// 此处可直接访问页面全局变量
const sendState = () => {
window.postMessage(
{
source: "MY_EXT_MAIN", // 自定义来源标识,用于过滤
type: "PAGE_STATE",
payload: (window as any).__APP_STATE__ ?? null
},
window.location.origin // 限定 targetOrigin,避免泄露给第三方
)
}
sendState()
// 也可 hook 页面方法,变更时再发一次
const origPush = history.pushState
history.pushState = function (...args) {
const r = origPush.apply(this, args as any)
sendState()
return r
}
})()
ISOLATED world 脚本:接收消息并转发给后台。
// src/content/index.ts —— 运行在 ISOLATED world
window.addEventListener("message", (event) => {
// 错误:不校验 origin 与 source 会让任意页面脚本伪造消息
if (event.source !== window) return
if (event.origin !== window.location.origin) return
const data = event.data
if (data?.source !== "MY_EXT_MAIN" || data.type !== "PAGE_STATE") return
chrome.runtime.sendMessage({ type: "PAGE_STATE", payload: data.payload })
})
通信方向小结:MAIN world 与 ISOLATED world 之间只能走 window.postMessage(共享同一个 window);ISOLATED world 与 Service Worker 之间走 chrome.runtime.sendMessage。
在内容脚本里挂载 Vue UI(Shadow DOM 隔离)
直接把 Vue 应用挂到页面 DOM 会被页面 CSS 污染,也会污染页面。用 Shadow DOM(影子 DOM)做样式隔离:创建宿主元素,挂 shadowRoot,把 Vue 与样式都放进去。
// src/content/index.ts
import { createApp } from "vue"
import App from "./App.vue"
// 以 ?inline 引入编译后的 CSS 字符串(Vite 支持),注入 shadow root
import styleText from "./App.css?inline"
function mountFloatingUI() {
const host = document.createElement("div")
host.id = "my-ext-root"
host.style.cssText =
"position:fixed;z-index:2147483647;top:80px;right:24px;" // 最高层级
document.documentElement.appendChild(host)
const shadow = host.attachShadow({ mode: "open" })
// 把样式注入 shadow root,页面样式无法穿透进来
const style = document.createElement("style")
style.textContent = styleText
shadow.appendChild(style)
const mountPoint = document.createElement("div")
shadow.appendChild(mountPoint)
const app = createApp(App)
app.mount(mountPoint) // Vue 挂到 shadow root 内部
return { host, app }
}
if (window.top === window.self) {
mountFloatingUI()
}
App.vue 实现一个跟随页面滚动定位的浮层,并把样式打包成独立 CSS(供上面 ?inline 引入):
<!-- src/content/App.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue"
const visible = ref(true)
const top = ref(80)
function onScroll() {
// 浮层随页面滚动保持在视口固定位置(这里 host 已 fixed,演示动态偏移)
top.value = 80 + Math.min(window.scrollY * 0.02, 40)
}
onMounted(() => window.addEventListener("scroll", onScroll, { passive: true }))
onUnmounted(() => window.removeEventListener("scroll", onScroll))
</script>
<template>
<div v-if="visible" class="panel" :style="{ marginTop: top - 80 + 'px' }">
<header class="panel__bar">
助手面板
<button class="panel__close" @click="visible = false">×</button>
</header>
<slot>面板内容</slot>
</div>
</template>
<style>
/* 这些样式被打进 App.css,再以 ?inline 注入 shadow root */
.panel {
width: 280px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
font: 14px/1.5 system-ui, sans-serif;
color: #111;
}
.panel__bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
font-weight: 600;
}
.panel__close {
border: 0;
background: none;
cursor: pointer;
font-size: 18px;
}
</style>
定位策略:宿主元素用 position: fixed 配合最高 z-index(2147483647 为 32 位有符号最大值)保证浮在所有页面内容之上。若需要锚定到页面某个元素并随其滚动,则用 position: absolute + getBoundingClientRect() 在滚动事件里更新坐标。
popup 与 options 的 Vue 工程化
路由(hash 模式)
扩展页面以 chrome-extension://<id>/popup.html 加载,必须用 hash 模式路由,history 模式刷新会 404。
// src/popup/router.ts
import { createRouter, createWebHashHistory } from "vue-router"
const routes = [
{ path: "/", component: () => import("./views/Home.vue") },
{ path: "/detail/:id", component: () => import("./views/Detail.vue") },
{ path: "/settings", component: () => import("./views/Settings.vue") }
]
export const router = createRouter({
history: createWebHashHistory(), // 必须 hash 模式
routes
})
// src/popup/main.ts
import { createApp } from "vue"
import { createPinia } from "pinia"
import App from "./App.vue"
import { router } from "./router"
createApp(App).use(createPinia()).use(router).mount("#app")
Pinia + chrome.storage 持久化
把 Pinia 状态同步到 chrome.storage.local,让 popup 关闭再打开仍保留,也能与后台、内容脚本共享。
// src/stores/settings.ts
import { defineStore } from "pinia"
import { ref, watch } from "vue"
export interface Settings {
enabled: boolean
theme: "light" | "dark"
apiBase: string
}
const STORAGE_KEY = "settings"
const DEFAULTS: Settings = { enabled: true, theme: "light", apiBase: "" }
export const useSettingsStore = defineStore("settings", () => {
const settings = ref<Settings>({ ...DEFAULTS })
const loaded = ref(false)
async function load() {
const res = await chrome.storage.local.get(STORAGE_KEY)
settings.value = { ...DEFAULTS, ...(res[STORAGE_KEY] ?? {}) }
loaded.value = true
}
async function save() {
await chrome.storage.local.set({ [STORAGE_KEY]: { ...settings.value } })
}
// 加载完成后,状态变更自动落盘
watch(
settings,
() => {
if (loaded.value) save()
},
{ deep: true }
)
// 监听其他上下文(options 页、后台)的修改,保持多端同步
chrome.storage.onChanged.addListener((changes, area) => {
if (area === "local" && changes[STORAGE_KEY]) {
settings.value = { ...DEFAULTS, ...changes[STORAGE_KEY].newValue }
}
})
return { settings, loaded, load, save }
})
表单保存设置(options 页)
<!-- src/options/views/Settings.vue -->
<script setup lang="ts">
import { onMounted } from "vue"
import { useSettingsStore } from "@/stores/settings"
import { storeToRefs } from "pinia"
const store = useSettingsStore()
const { settings } = storeToRefs(store)
onMounted(store.load) // 进入页面先拉取
// 由于 store 内 watch 自动落盘,表单双向绑定即等于"实时保存"
</script>
<template>
<form @submit.prevent>
<label>
<input type="checkbox" v-model="settings.enabled" /> 启用扩展
</label>
<label>
主题
<select v-model="settings.theme">
<option value="light">浅色</option>
<option value="dark">深色</option>
</select>
</label>
<label>
API 地址
<input type="url" v-model="settings.apiBase" placeholder="https://..." />
</label>
</form>
</template>
国际化 i18n
_locales 目录与 messages.json
有 _locales 目录时,manifest 必须声明 default_locale。manifest 自身的文案用 __MSG_name__。
_locales/
├── zh_CN/messages.json
└── en/messages.json
// _locales/zh_CN/messages.json
{
"extName": { "message": "我的助手", "description": "扩展名称" },
"popupTitle": { "message": "快捷操作" },
"greet": {
"message": "你好,$NAME$,今天是 $DATE$",
"placeholders": {
"name": { "content": "$1", "example": "小明" },
"date": { "content": "$2" }
}
}
}
// _locales/en/messages.json
{
"extName": { "message": "My Assistant" },
"popupTitle": { "message": "Quick Actions" },
"greet": {
"message": "Hello $NAME$, today is $DATE$",
"placeholders": {
"name": { "content": "$1" },
"date": { "content": "$2" }
}
}
}
messages.json 条目字段:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
message |
string | 是 | 译文,$NAME$ 引用 placeholder |
description |
string | 否 | 给译者的说明 |
placeholders |
object | 否 | 占位符表 |
placeholders.<n>.content |
string | 是 | 替换内容,$1..$9 对应 getMessage 第二参数 |
placeholders.<n>.example |
string | 否 | 示例值 |
chrome.i18n.getMessage 与 Vue t() helper
// src/i18n/index.ts
export function t(key: string, subs?: string | string[]): string {
return chrome.i18n.getMessage(key, subs) || key // 缺失时回退到 key,便于排查
}
export const uiLanguage = () => chrome.i18n.getUILanguage() // 如 "zh-CN"
// 注册为全局属性,模板里用 $t
import type { App } from "vue"
export function installI18n(app: App) {
app.config.globalProperties.$t = t
}
<!-- 在组件中使用 -->
<script setup lang="ts">
import { t } from "@/i18n"
const hello = t("greet", ["小明", "2026-06-06"])
</script>
<template>
<h1>{{ t("popupTitle") }}</h1>
<p>{{ hello }}</p>
</template>
默认语言回退顺序:先按用户 UI 语言(如 en_GB)找,找不到回退到基础语言(en),再回退到 manifest 的 default_locale。因此 default_locale 对应的 messages.json 必须包含全部 key。
右键菜单 contextMenus
contextMenus 必须在 manifest 声明 "contextMenus" 权限,并在 Service Worker 的 onInstalled 中创建(SW 会休眠,重新创建会因 id 重复报错,故放在 onInstalled 一次性创建)。
// src/background/index.ts
chrome.runtime.onInstalled.addListener(() => {
// 顶层菜单(多级菜单的父项)
chrome.contextMenus.create({
id: "tools",
title: chrome.i18n.getMessage("extName"),
contexts: ["all"]
})
// 选中文本时出现的子菜单
chrome.contextMenus.create({
id: "search-selection",
parentId: "tools",
title: '搜索 "%s"', // %s 自动替换为选中文本
contexts: ["selection"]
})
// 针对链接
chrome.contextMenus.create({
id: "copy-link",
parentId: "tools",
title: "复制链接地址",
contexts: ["link"]
})
// 针对图片
chrome.contextMenus.create({
id: "download-image",
parentId: "tools",
title: "下载此图片",
contexts: ["image"]
})
})
chrome.contextMenus.onClicked.addListener((info, tab) => {
switch (info.menuItemId) {
case "search-selection":
chrome.tabs.create({
url: "https://www.google.com/search?q=" +
encodeURIComponent(info.selectionText ?? "")
})
break
case "copy-link":
// info.linkUrl 为被右键的链接
console.log("link:", info.linkUrl)
break
case "download-image":
if (info.srcUrl) chrome.downloads.download({ url: info.srcUrl })
break
}
})
contexts 常用取值:all、page、selection、link、image、video、audio、editable、action。按 contexts 区分可让同一菜单只在特定目标上出现。
键盘快捷键 commands
manifest 声明 commands,_execute_action 为保留命令(打开 popup),其余为自定义命令,在后台监听 chrome.commands.onCommand。
// src/manifest.ts 片段
commands: {
_execute_action: {
suggested_key: { default: "Ctrl+Shift+Y", mac: "Command+Shift+Y" },
description: "打开弹窗"
},
"toggle-panel": {
suggested_key: { default: "Ctrl+Shift+U", mac: "Command+Shift+U" },
description: "切换页面浮层"
}
}
// src/background/index.ts
chrome.commands.onCommand.addListener(async (command) => {
if (command === "toggle-panel") {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
if (tab?.id) {
// 向内容脚本发消息,由其切换浮层显隐,实现与 UI 联动
chrome.tabs.sendMessage(tab.id, { type: "TOGGLE_PANEL" })
}
}
})
与 popup 联动:_execute_action 由浏览器直接打开 popup,无需监听。popup 打开后可读取 storage 决定展示内容。自定义命令限制最多 4 个建议快捷键,用户可在 chrome://extensions/shortcuts 重新绑定。
侧边栏 side panel
侧边栏(side panel,Chrome 114+)提供常驻于浏览器侧的 UI。manifest 声明 "sidePanel" 权限与默认页面。
// src/manifest.ts 片段
permissions: ["sidePanel", "tabs"],
side_panel: { default_path: "src/sidepanel/index.html" }
sidePanel 关键方法:
| 方法 | 参数 | 说明 |
|---|---|---|
setOptions |
{ tabId?, path?, enabled } |
全局或按标签页配置面板 |
setPanelBehavior |
{ openPanelOnActionClick } |
点击图标时打开面板 |
getOptions |
{ tabId? } |
读取当前配置 |
open |
{ tabId? | windowId? } |
程序化打开(需用户手势,Chrome 116+) |
按站点启用:监听 tabs.onUpdated,匹配域名才 enabled: true。
// src/background/index.ts
const ENABLED_HOST = "example.com"
chrome.runtime.onInstalled.addListener(() => {
// 点击扩展图标即打开侧边栏
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true })
})
chrome.tabs.onUpdated.addListener(async (tabId, info, tab) => {
if (!tab.url) return
const isTarget = new URL(tab.url).hostname.endsWith(ENABLED_HOST)
await chrome.sidePanel.setOptions({
tabId,
path: "src/sidepanel/index.html",
enabled: isTarget // 非目标站点禁用该标签页的面板
})
})
与内容脚本联动:侧边栏是独立扩展页面,与内容脚本之间通过后台或 chrome.runtime 长连接通信。
// 侧边栏页面建立长连接,接收内容脚本经后台转发的数据
const port = chrome.runtime.connect({ name: "sidepanel" })
port.onMessage.addListener((msg) => {
if (msg.type === "PAGE_STATE") {
// 渲染来自页面的实时数据
}
})
omnibox 地址栏关键词
omnibox 让用户在地址栏输入关键词后按空格进入扩展输入模式。
// src/manifest.ts 片段
omnibox: { keyword: "ex" } // 地址栏输入 "ex " 触发
// src/background/index.ts
chrome.omnibox.onInputChanged.addListener((text, suggest) => {
suggest([
{ content: `search ${text}`, description: `搜索:<match>${text}</match>` },
{ content: `open ${text}`, description: `打开页面:${text}` }
])
})
chrome.omnibox.onInputEntered.addListener((text, disposition) => {
const url = "https://www.example.com/?q=" + encodeURIComponent(text)
// disposition: currentTab / newForegroundTab / newBackgroundTab
if (disposition === "currentTab") {
chrome.tabs.update({ url })
} else {
chrome.tabs.create({ url })
}
})
description 支持 <match>、<dim>、<url> 标签做高亮,文本需 XML 转义。
DevTools 扩展
DevTools 扩展由 devtools_page 入口加载,在其中调用 chrome.devtools.panels.create 创建自定义面板。devtools_page 自身不可见,只用于注册。
// src/manifest.ts 片段
devtools_page: "src/devtools/devtools.html"
<!-- src/devtools/devtools.html -->
<!doctype html>
<html>
<head><meta charset="utf-8" /></head>
<body><script type="module" src="./devtools.ts"></script></body>
</html>
// src/devtools/devtools.ts —— 注册面板与监听网络
chrome.devtools.panels.create(
"我的面板", // 标签标题
"icons/panel-128.png", // 图标
"src/devtools/panel.html", // 面板内容页(可放 Vue 应用)
(panel) => {
panel.onShown.addListener(() => {
// 面板被打开
})
}
)
// 在被检查页面执行表达式,拿回结果
function evalInPage(expr: string) {
return new Promise((resolve, reject) => {
chrome.devtools.inspectedWindow.eval(expr, (result, error) => {
if (error) reject(error)
else resolve(result)
})
})
}
// 监听被检查页面的网络请求
chrome.devtools.network.onRequestFinished.addListener((request) => {
// request 为 HAR 条目,getContent 取响应体
request.getContent((body) => {
if (request.request.url.includes("/api/")) {
console.log("API:", request.request.url, body?.slice(0, 200))
}
})
})
// 取被检查标签页 ID(面板页里向后台请求该页数据时用)
const inspectedTabId = chrome.devtools.inspectedWindow.tabId
面板页(panel.html)可挂载完整 Vue 应用,通过 chrome.devtools.inspectedWindow.eval 读取页面状态,通过 chrome.devtools.network 聚合接口调用。
通知 notifications
notifications 需声明权限。基础通知、带按钮、进度条三类。
// src/manifest.ts 片段
permissions: ["notifications"]
// 基础通知
chrome.notifications.create("basic-1", {
type: "basic",
iconUrl: chrome.runtime.getURL("icons/128.png"), // 必填,须用完整 URL
title: "任务完成",
message: "数据已同步"
})
// 带按钮
chrome.notifications.create("with-buttons", {
type: "basic",
iconUrl: chrome.runtime.getURL("icons/128.png"),
title: "发现更新",
message: "是否立即查看?",
buttons: [{ title: "查看" }, { title: "忽略" }]
})
chrome.notifications.onButtonClicked.addListener((id, btnIndex) => {
if (id === "with-buttons" && btnIndex === 0) {
chrome.tabs.create({ url: "https://www.example.com/changelog" })
}
})
// 进度通知(type: "progress",progress 0..100)
let p = 0
chrome.notifications.create("progress-1", {
type: "progress",
iconUrl: chrome.runtime.getURL("icons/128.png"),
title: "下载中",
message: "正在下载文件",
progress: 0
})
const timer = setInterval(() => {
p += 20
chrome.notifications.update("progress-1", { progress: p })
if (p >= 100) clearInterval(timer)
}, 500)
网络请求拦截 declarativeNetRequest
declarativeNetRequest(DNR)以声明式规则拦截/重定向/改 header,规则在浏览器内核执行,不暴露请求内容给扩展。分静态规则(打包进扩展的 JSON 文件)与动态规则(运行时增删)。
静态规则文件
// src/manifest.ts 片段
permissions: ["declarativeNetRequest"],
host_permissions: ["https://*/*"],
declarative_net_request: {
rule_resources: [
{ id: "ruleset_1", enabled: true, path: "rules/static-rules.json" }
]
}
// rules/static-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://cdn.example.com/lib.js" }
},
"condition": {
"urlFilter": "||old-cdn.com/lib.js",
"resourceTypes": ["script"]
}
},
{
"id": 3,
"priority": 1,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{ "header": "User-Agent", "operation": "set", "value": "Mozilla/5.0 (CustomBot)" },
{ "header": "X-From-Ext", "operation": "set", "value": "1" }
]
},
"condition": {
"requestDomains": ["api.example.com"],
"resourceTypes": ["xmlhttprequest"]
}
}
]
规则结构字段:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
id |
number | 必填 | 规则唯一 ID(≥1) |
priority |
number | 1 | 优先级(≥1),高者先匹配 |
action.type |
string | 必填 | block / redirect / allow / allowAllRequests / upgradeScheme / modifyHeaders |
action.redirect.url |
string | — | 重定向目标,redirect 时用 |
action.requestHeaders |
object[] | — | 改请求头,含 header/operation/value |
action.responseHeaders |
object[] | — | 改响应头 |
condition.urlFilter |
string | — | URL 匹配模式(|| 锚定域名) |
condition.regexFilter |
string | — | 正则匹配 |
condition.resourceTypes |
string[] | — | 资源类型过滤 |
condition.requestDomains |
string[] | — | 请求域名 |
condition.initiatorDomains |
string[] | — | 发起方域名 |
operation 取值:set(设置/覆盖)、append(追加)、remove(删除)。改 User-Agent 即用 modifyHeaders 的 requestHeaders + set。
动态规则
运行时用 updateDynamicRules 增删,addRules 新增、removeRuleIds 删除。
// 用户开启"屏蔽某域名"时动态加规则
async function blockDomain(domain: string) {
const id = 1000 + Math.abs(hashCode(domain)) % 10000
await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: [id], // 先删同 id,避免重复报错
addRules: [
{
id,
priority: 1,
action: { type: "block" },
condition: { requestDomains: [domain], resourceTypes: ["main_frame", "sub_frame"] }
}
]
})
}
function hashCode(s: string) {
let h = 0
for (let i = 0; i < s.length; i++) h = (h << 5) - h + s.charCodeAt(i)
return h
}
// 查看当前动态规则
const rules = await chrome.declarativeNetRequest.getDynamicRules()
限制:动态规则上限 30000 条(其中 unsafe 规则 5000)、会话规则 5000、正则规则每类 1000;静态规则集最多声明 100 个、同时启用 50 个。
跨域请求:后台 SW fetch 绕过页面 CORS
页面内容脚本受页面 CORS 约束,跨域请求会被拦截。把请求放到 Service Worker 里发,扩展凭 host_permissions 拥有对应域名的跨域权限,不受页面 CORS 限制。
// src/manifest.ts 片段
host_permissions: ["https://api.thirdparty.com/*"] // 声明可跨域访问的域名
// src/background/index.ts —— 后台代发跨域请求
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg.type === "FETCH") {
fetch(msg.url, msg.init)
.then((r) => r.json())
.then((data) => sendResponse({ ok: true, data }))
.catch((e) => sendResponse({ ok: false, error: String(e) }))
return true // 关键:异步 sendResponse 必须 return true 保持通道
}
})
// 内容脚本 / popup 侧调用
const res = await chrome.runtime.sendMessage({
type: "FETCH",
url: "https://api.thirdparty.com/data",
init: { headers: { Authorization: "Bearer xxx" } }
})
host_permissions 的作用:它授予扩展对列出域名的跨域 fetch 权限和内容脚本注入权限。后台 fetch 能绕过页面 CORS,正是因为请求以扩展身份发出,而扩展对该域名有 host 权限。
徽章/图标状态机
按标签页状态切换 action 图标与角标(badge)。Badge 文本最多约 4 字符,需用 tabId 限定为单标签页状态。
// src/background/index.ts
type TabState = "off" | "active" | "error"
const BADGE: Record<TabState, { text: string; color: string; icon: string }> = {
off: { text: "", color: "#888", icon: "icons/gray-128.png" },
active: { text: "ON", color: "#16a34a", icon: "icons/green-128.png" },
error: { text: "!", color: "#dc2626", icon: "icons/red-128.png" }
}
async function setTabState(tabId: number, state: TabState) {
const s = BADGE[state]
await chrome.action.setBadgeText({ tabId, text: s.text })
await chrome.action.setBadgeBackgroundColor({ tabId, color: s.color })
await chrome.action.setIcon({ tabId, path: { 128: s.icon } })
// 错误:不传 tabId 会改全局状态,切到别的标签页也变
}
chrome.tabs.onUpdated.addListener((tabId, info, tab) => {
if (info.status === "complete" && tab.url?.startsWith("https://example.com")) {
setTabState(tabId, "active")
}
})
数据持久化进阶
storage 分区使用策略
| 分区 | 容量 | 生命周期 | 适用 |
|---|---|---|---|
storage.local |
约 10MB(可申请 unlimitedStorage 提升) | 持久 | 大量本地数据、缓存 |
storage.sync |
约 100KB(单项 8KB) | 随账号云同步 | 用户偏好设置 |
storage.session |
约 10MB | 浏览器会话内(SW 重启保留,关浏览器清空) | 临时 token、运行态 |
storage.managed |
— | 只读,由企业策略下发 | 受管配置 |
策略:用户设置放 sync(多设备一致);缓存、日志、大对象放 local;敏感临时数据放 session(不落盘磁盘)。
await chrome.storage.sync.set({ theme: "dark" }) // 跟随账号
await chrome.storage.session.set({ token: "tmp" }) // 关浏览器即清
IndexedDB 存大数据
storage.local 不适合存放数 MB 的结构化数据或需要索引查询的场景,用 IndexedDB。Service Worker 与扩展页面均可访问。
// src/background/db.ts
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open("ext-db", 1)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains("records")) {
const store = db.createObjectStore("records", { keyPath: "id" })
store.createIndex("byHost", "host", { unique: false })
}
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
export async function putRecord(rec: { id: string; host: string; data: unknown }) {
const db = await openDB()
return new Promise<void>((resolve, reject) => {
const tx = db.transaction("records", "readwrite")
tx.objectStore("records").put(rec)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
配置导入导出
把设置序列化为 JSON 文件下载(导出),用 file input 读回(导入)。
// 导出:在 options 页生成下载
async function exportSettings() {
const all = await chrome.storage.local.get(null) // null 取全部
const blob = new Blob([JSON.stringify(all, null, 2)], {
type: "application/json"
})
const url = URL.createObjectURL(blob)
await chrome.downloads.download({ url, filename: "ext-settings.json" })
setTimeout(() => URL.revokeObjectURL(url), 5000)
}
// 导入:读文件写回 storage
async function importSettings(file: File) {
const text = await file.text()
const obj = JSON.parse(text)
await chrome.storage.local.set(obj)
}
最佳实践
-
内容脚本 UI 一律走 Shadow DOM 隔离。 直接挂到页面 DOM 会双向污染样式。用
attachShadow({ mode: "open" })并把编译后 CSS 以?inline注入 shadow root,确保扩展样式与页面样式互不影响。const shadow = host.attachShadow({ mode: "open" }) shadow.appendChild(Object.assign(document.createElement("style"), { textContent: styleText })) -
跨域请求统一收口到 Service Worker。 内容脚本与 popup 不直接 fetch 第三方接口,全部经后台代发,集中管理鉴权、重试与 host_permissions,避免页面 CORS 与凭据散落。
const r = await chrome.runtime.sendMessage({ type: "FETCH", url }) -
状态相关的图标/角标必须带 tabId。 不带 tabId 的
setBadgeText/setIcon是全局状态,切标签页会串台。按标签页维护独立状态机。chrome.action.setBadgeText({ tabId, text: "ON" }) -
contextMenus 在 onInstalled 创建一次。 Service Worker 会休眠重启,若在顶层或事件里反复
create会因 id 重复抛错。集中放onInstalled。chrome.runtime.onInstalled.addListener(() => chrome.contextMenus.create({ id, title, contexts })) -
default_locale 的 messages.json 必须最全。 i18n 回退最终落到 default_locale,缺 key 会显示空串。封装
t()缺失时回退到 key,便于发现漏译。export const t = (k: string, s?: string[]) => chrome.i18n.getMessage(k, s) || k -
优先用 declarativeNetRequest 而非 webRequest 阻塞。 MV3 已移除阻塞式 webRequest,DNR 由内核执行性能更好、更隐私。静态规则放打包文件,用户可变规则用
updateDynamicRules,并先removeRuleIds再addRules。 -
大数据用 IndexedDB,偏好用 storage.sync。 不要把 MB 级数据塞进 storage.local 反复整存整取;需索引查询、增量写入时用 IndexedDB。用户偏好放 sync 实现多设备同步。
常见陷阱
-
MAIN world 脚本读不到 chrome API。
- 现象:在
world: "MAIN"的脚本里调用chrome.runtime.sendMessage报undefined。 - 原因:MAIN world 运行在页面环境,没有扩展 API,只有 DOM 与页面变量。
- 解决:MAIN world 只负责读页面变量并
window.postMessage;扩展 API 调用放在 ISOLATED world 的内容脚本里接收后转发。
- 现象:在
-
后台 fetch 跨域仍被拦截。
- 现象:在 Service Worker 里 fetch 第三方域名报 CORS 或网络错误。
- 原因:未在
host_permissions声明该域名,扩展没有对应跨域权限。 - 解决:在 manifest
host_permissions加入目标域名(如https://api.thirdparty.com/*),重新加载扩展。
-
异步 sendResponse 收不到回复。
- 现象:
chrome.runtime.sendMessage的 Promise 永远 pending 或拿到 undefined。 - 原因:
onMessage监听器里做异步操作后调用sendResponse,但没return true,消息通道被同步关闭。 - 解决:监听器内有异步逻辑时必须
return true保持通道开启,待异步完成再sendResponse。
- 现象:
-
扩展页面用 history 模式路由刷新 404。
- 现象:popup/options 用
createWebHistory刷新或直达子路由白屏。 - 原因:
chrome-extension://协议下没有服务端做 history fallback。 - 解决:改用
createWebHashHistory,所有扩展内 SPA 一律 hash 模式。
- 现象:popup/options 用
-
动态注入的脚本重复执行。
- 现象:同一标签页多次
executeScript导致 UI 重复挂载。 - 原因:每次注入都重新执行,未做幂等判断。
- 解决:注入前检查标记元素(如
document.getElementById("my-ext-root")),或用registerContentScripts注册并以id去重,存在即跳过。
- 现象:同一标签页多次