浏览器插件 中级开发指南(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

分享

官方文档:

本文面向已掌握 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-index2147483647 为 32 位有符号最大值)保证浮在所有页面内容之上。若需要锚定到页面某个元素并随其滚动,则用 position: absolute + getBoundingClientRect() 在滚动事件里更新坐标。


路由(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 常用取值:allpageselectionlinkimagevideoaudioeditableaction。按 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 即用 modifyHeadersrequestHeaders + 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)
}

最佳实践

  1. 内容脚本 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 }))
    
  2. 跨域请求统一收口到 Service Worker。 内容脚本与 popup 不直接 fetch 第三方接口,全部经后台代发,集中管理鉴权、重试与 host_permissions,避免页面 CORS 与凭据散落。

    const r = await chrome.runtime.sendMessage({ type: "FETCH", url })
    
  3. 状态相关的图标/角标必须带 tabId。 不带 tabId 的 setBadgeText/setIcon 是全局状态,切标签页会串台。按标签页维护独立状态机。

    chrome.action.setBadgeText({ tabId, text: "ON" })
    
  4. contextMenus 在 onInstalled 创建一次。 Service Worker 会休眠重启,若在顶层或事件里反复 create 会因 id 重复抛错。集中放 onInstalled

    chrome.runtime.onInstalled.addListener(() => chrome.contextMenus.create({ id, title, contexts }))
    
  5. default_locale 的 messages.json 必须最全。 i18n 回退最终落到 default_locale,缺 key 会显示空串。封装 t() 缺失时回退到 key,便于发现漏译。

    export const t = (k: string, s?: string[]) => chrome.i18n.getMessage(k, s) || k
    
  6. 优先用 declarativeNetRequest 而非 webRequest 阻塞。 MV3 已移除阻塞式 webRequest,DNR 由内核执行性能更好、更隐私。静态规则放打包文件,用户可变规则用 updateDynamicRules,并先 removeRuleIdsaddRules

  7. 大数据用 IndexedDB,偏好用 storage.sync。 不要把 MB 级数据塞进 storage.local 反复整存整取;需索引查询、增量写入时用 IndexedDB。用户偏好放 sync 实现多设备同步。


常见陷阱

  1. MAIN world 脚本读不到 chrome API。

    • 现象:在 world: "MAIN" 的脚本里调用 chrome.runtime.sendMessageundefined
    • 原因:MAIN world 运行在页面环境,没有扩展 API,只有 DOM 与页面变量。
    • 解决:MAIN world 只负责读页面变量并 window.postMessage;扩展 API 调用放在 ISOLATED world 的内容脚本里接收后转发。
  2. 后台 fetch 跨域仍被拦截。

    • 现象:在 Service Worker 里 fetch 第三方域名报 CORS 或网络错误。
    • 原因:未在 host_permissions 声明该域名,扩展没有对应跨域权限。
    • 解决:在 manifest host_permissions 加入目标域名(如 https://api.thirdparty.com/*),重新加载扩展。
  3. 异步 sendResponse 收不到回复。

    • 现象:chrome.runtime.sendMessage 的 Promise 永远 pending 或拿到 undefined。
    • 原因:onMessage 监听器里做异步操作后调用 sendResponse,但没 return true,消息通道被同步关闭。
    • 解决:监听器内有异步逻辑时必须 return true 保持通道开启,待异步完成再 sendResponse
  4. 扩展页面用 history 模式路由刷新 404。

    • 现象:popup/options 用 createWebHistory 刷新或直达子路由白屏。
    • 原因:chrome-extension:// 协议下没有服务端做 history fallback。
    • 解决:改用 createWebHashHistory,所有扩展内 SPA 一律 hash 模式。
  5. 动态注入的脚本重复执行。

    • 现象:同一标签页多次 executeScript 导致 UI 重复挂载。
    • 原因:每次注入都重新执行,未做幂等判断。
    • 解决:注入前检查标记元素(如 document.getElementById("my-ext-root")),或用 registerContentScripts 注册并以 id 去重,存在即跳过。

参见

阅读更多

Web 安全基础

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

By yellowdog

HTTP 协议深度指南

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

By yellowdog

系统设计基础

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

By yellowdog

算法思路与模板

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

By yellowdog