浏览器插件开发完全指南(Vite + Vue + Manifest V3)

浏览器插件(Browser Extension)是运行在浏览器内、用 HTML/CSS/JavaScript 编写的小程序,能读取和修改网页、增加浏览器 UI、在后台常驻处理事件。它解决的核心问题是:在不修改网站源码的前提下,向浏览器和任意网页注入自定义功能——广告拦截、密码管理、翻译、爬虫辅助、开发者工具增强都属于这一类。 为什么用 Vite + Vue 而不是裸写:插件本质是多个独立 HTML 入口(popup、options、side panel)加上后台脚本和内容脚本的组合。裸写需要手动维护构建、手动复制静态资源、改一行代码要重新加载整个插件。V

分享

官方文档:

适用版本:Manifest V3 / Chrome 138+ / @crxjs/vite-plugin 2.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.*

理解三个关键隔离边界:

  1. Service Worker 不是持久后台。MV2 的常驻后台页(background page)在 MV3 被换成事件驱动的 Service Worker,它会在空闲时被浏览器杀死,下次事件到来时重新启动。因此不能在 Service Worker 顶层用全局变量保存状态,状态必须落到 chrome.storage

  2. 内容脚本运行在"隔离世界"(Isolated World)。它能看到页面的 DOM,但看不到页面 JS 的变量和函数(反之亦然)。两个 JS 上下文共享同一份 DOM,但各有独立的 window。要访问页面自身的 JS 变量,必须把脚本注入到 MAIN world(见 chrome.scripting)。

  3. 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 国际化默认语言(如 enzh_CN);用 _locales/必填
author string 作者邮箱
homepage_url string 插件主页

入口字段

字段 类型 说明
action object 工具栏图标按钮配置。子字段:default_popup(点击弹出的 HTML)、default_icondefault_title(悬停提示)
background object 后台脚本。子字段:service_worker(JS 路径,只能一个文件)、type"module" 启用 ESM)
content_scripts array 内容脚本数组,见下表
options_page string 选项页 HTML 路径(在新标签打开)
options_ui object 选项页配置。子字段:page(HTML 路径)、open_in_tabbooleanfalse 则嵌入扩展管理页内显示)
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 与本插件通信。子字段 matchesids
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 安装/更新时触发,回调含 reasoninstall/update
runtime.onStartup Event 浏览器启动时触发
runtime.lastError object | undefined 回调风格下读取错误;Promise 风格用 try/catch

onMessage 回调参数:

参数 类型 说明
message any 发送方传来的消息(JSON 可序列化)
sender MessageSender 发送方信息:sender.tab(来自内容脚本时含标签页)、sender.idsender.urlsender.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.queryqueryInfo 常用字段:

字段 类型 说明
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[]> 查询已注册脚本

executeScriptinjection 字段:

字段 类型 默认值 说明
target object 必填 { tabId, frameIds?, allFrames? }allFramesframeIds 互斥
func Function 要注入执行的函数(与 files 二选一)。注意:函数体在页面侧序列化执行,不能闭包捕获外部变量
args any[] [] 传给 func 的参数,必须 JSON 可序列化
files string[] 要注入的 JS 文件路径
world string "ISOLATED" ISOLATEDMAIN(访问页面 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)

createprops 常用字段:

字段 类型 默认值 说明
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.someLibFromPageundefined,但在页面控制台能正常访问。

原因: 内容脚本运行在"隔离世界"(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

  1. 打包npm run build 后把 dist/ 目录压缩为 zip(注意是压缩目录内容,不要多套一层文件夹)。
  2. 注册开发者账号:访问 Chrome Web Store Developer Dashboard,一次性缴纳 5 美元注册费。
  3. 上传与填写:上传 zip,填写名称、描述、分类、至少一张 1280×800 截图、128×128 图标,以及隐私政策(凡申请敏感权限必须提供)。
  4. 权限说明:每项敏感权限需在表单里说明用途,宽泛权限(如 <all_urls>)会触发人工审核、耗时更久。
  5. 审核与发布:提交后进入审核,通常数小时到数天。可选"私有""仅限指定人员""公开"发布范围。
  6. 版本更新:每次更新须提升 manifest version,重新上传 zip。

Edge 插件发布到 Microsoft Edge Add-ons(免费),Firefox 发布到 AMO(用 web-ext sign 签名)。


参见

阅读更多

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