浏览器插件 - 设计模式与优雅架构
本文是浏览器插件系列的「架构篇」。前面几篇讲清楚了 MV3 有哪些能力、技术栈怎么搭、坑在哪里;这一篇只回答一个问题:当插件从「几百行脚本」长成「几千行多上下文工程」时,代码如何保持优雅、可测、可演进。 What(是什么):浏览器插件的架构设计,是在 MV3 强制的多上下文(multi-context)隔离模型之上,建立清晰的分层与通信契约。MV3 把代码拆进了相互隔离的进程:Service Worker(后台,随时被杀)、Content Script(注入页面、与宿主 DOM 共存)、Popup / Options(短生命周期 UI 页面)、DevTo
参考来源:
- @webext-core/messaging 文档 https://webext-core.aklinker1.io/messaging/installation
- @webext-core/proxy-service 文档 https://webext-core.aklinker1.io/proxy-service/defining-services
- @webext-core/storage 文档 https://webext-core.aklinker1.io/storage/installation
- webext-bridge 仓库 https://github.com/serversideup/webext-bridge
- WXT Messaging / Storage 指南 https://wxt.dev/guide/essentials/messaging
- Chrome Extensions Manifest V3 官方文档 https://developer.chrome.com/docs/extensions/develop
适用版本:Manifest V3(Chrome 138+ / Edge / Firefox 128+ WebExtensions)
核实日期:2026-06-06
本文是浏览器插件系列的「架构篇」。前面几篇讲清楚了 MV3 有哪些能力、技术栈怎么搭、坑在哪里;这一篇只回答一个问题:当插件从「几百行脚本」长成「几千行多上下文工程」时,代码如何保持优雅、可测、可演进。
概述
What(是什么):浏览器插件的架构设计,是在 MV3 强制的多上下文(multi-context)隔离模型之上,建立清晰的分层与通信契约。MV3 把代码拆进了相互隔离的进程:Service Worker(后台,随时被杀)、Content Script(注入页面、与宿主 DOM 共存)、Popup / Options(短生命周期 UI 页面)、DevTools 面板。它们不共享内存,只能通过消息和 storage 协作。
Why(为什么需要架构):朴素写法会让每个上下文各自直接调用 chrome.runtime.sendMessage,用裸字符串 type 区分意图,状态散落在各处的全局变量里。Service Worker 一旦被回收,全局变量清零,UI 显示的状态与真实数据脱节;消息类型靠口头约定,改一处忘三处,运行时静默失败。架构的价值在于把「上下文隔离」这个客观约束,转化为「分层边界 + 类型契约 + 单一数据源」的工程纪律。
When(何时引入):单文件 demo 不需要。一旦满足以下任一条件就该上架构:上下文超过两个、消息类型超过五种、需要跨上下文同步状态、团队多人协作、计划长期维护。社区库(@webext-core、webext-bridge、WXT)把这些模式做成了开箱即用的封装,本文既讲手写实现也讲库的落地。
整体分层目标如下:
+-------------------------------------------------------------+
| UI 层 Popup / Options / Content UI (Vue 组件) |
| 只负责渲染与用户交互,不直接碰 chrome.* API |
+-------------------------------------------------------------+
| 通信层 类型安全消息总线 / RPC / Port 连接池 |
| 跨上下文调用的唯一通道 |
+-------------------------------------------------------------+
| 领域服务层 业务逻辑、用例编排(运行在 Service Worker) |
| 不感知"消息从哪来",纯函数 / 类 |
+-------------------------------------------------------------+
| 存储层 Repository 封装 chrome.storage,单一数据源 |
| 类型化、默认值、schema 迁移 |
+-------------------------------------------------------------+
| 适配层 browser 命名空间归一(chrome vs browser) |
+-------------------------------------------------------------+
项目结构组织
按上下文分目录
意图:让目录结构直接映射 MV3 的运行时拓扑,任何人看一眼 src/ 就知道哪段代码跑在哪个进程里。
结构:
src/
├── background/ # Service Worker 入口与后台逻辑
│ ├── index.ts # SW 入口,只做装配(注册监听、启动服务)
│ ├── services/ # 领域服务层(业务逻辑)
│ └── scheduler/ # 后台任务调度
├── content/ # 内容脚本
│ ├── index.ts # 注入入口
│ └── ui/ # 注入到 Shadow DOM 的 Vue 应用
├── popup/ # 弹窗页面(独立 Vue 应用)
├── options/ # 设置页面(独立 Vue 应用)
├── shared/ # 跨上下文公共层(重点)
│ ├── messaging/ # 消息协议定义 + 总线封装
│ ├── storage/ # Repository / DAO
│ ├── types/ # 集中的类型定义
│ ├── domain/ # 与运行时无关的纯领域模型
│ └── browser.ts # 跨浏览器适配层
└── manifest.config.ts # manifest 生成(配合 @crxjs/vite-plugin 或 WXT)
适用场景:所有中大型插件。shared/ 是架构的核心——它是唯一被所有上下文 import 的层,承载类型契约与公共逻辑,且绝不能 import 任何上下文专属代码(否则会把 SW-only 的 API 打进 content script bundle)。
类型集中管理
意图:消息载荷、storage schema、领域模型的类型只定义一次,所有上下文引用同一份,避免「消息类型漂移」。
// shared/types/messages.ts —— 唯一的消息契约来源
export interface ProtocolMap {
// key 是消息名,value 是 (data) => returnType
getPageMeta(): PageMeta
saveBookmark(data: BookmarkInput): Bookmark
syncState(data: AppState): void
}
// shared/types/domain.ts —— 与运行时无关的领域模型
export interface Bookmark {
id: string
url: string
title: string
createdAt: number
}
monorepo 多包结构
当插件附带配套的 Web Dashboard、共享 npm 组件库或多个浏览器目标时,用 monorepo 把「纯领域逻辑」抽成独立包:
packages/
├── core/ # 领域模型 + 业务规则,零浏览器依赖,可单测
├── extension/ # 插件本体,依赖 core
└── dashboard/ # 配套 Web 应用,同样依赖 core
核心收益:core 不依赖任何 chrome.*,可以在 Node 下用 Vitest 直接跑单元测试,这是后台逻辑唯一能脱离浏览器环境测试的前提。
消息通信架构模式
类型安全的消息总线
意图:消除裸 chrome.runtime.sendMessage(type, payload) 的字符串魔法,让消息名、入参、返回值在编译期受类型约束。
结构:
ProtocolMap (类型契约)
│
├──> sendMessage('saveBookmark', input) ─── 编译期校验入参/返回
│
└──> onMessage('saveBookmark', handler) ─── handler 签名自动推导
糟糕设计:
// 反模式:裸字符串 type,载荷是 any,改名无编译报错
chrome.runtime.sendMessage({ type: 'SAVE_BOOKMARK', payload: input }, (res) => {
// res 是 any,背景脚本返回结构改了这里也不会报错
console.log(res.bookmark)
})
优雅设计(手写一个最小总线):
// shared/messaging/bus.ts
import type { ProtocolMap } from '../types/messages'
type Keys = keyof ProtocolMap
// 提取每个消息的参数类型与返回类型
type Params<K extends Keys> = Parameters<ProtocolMap[K]>[0]
type Return<K extends Keys> = ReturnType<ProtocolMap[K]>
interface Envelope<K extends Keys> {
__bus: true
key: K
data: Params<K>
}
export function sendMessage<K extends Keys>(
key: K,
data: Params<K>,
): Promise<Return<K>> {
const envelope: Envelope<K> = { __bus: true, key, data }
return chrome.runtime.sendMessage(envelope) as Promise<Return<K>>
}
export function onMessage<K extends Keys>(
key: K,
handler: (data: Params<K>) => Return<K> | Promise<Return<K>>,
): void {
chrome.runtime.onMessage.addListener((msg: Envelope<K>, _sender, reply) => {
if (!msg?.__bus || msg.key !== key) return
// 返回 true 保持 sendResponse 通道开启,支持异步 handler
Promise.resolve(handler(msg.data)).then(reply)
return true
})
}
用库实现(推荐,省去样板)—— @webext-core/messaging:
// shared/messaging/index.ts
import { defineExtensionMessaging } from '@webext-core/messaging'
import type { ProtocolMap } from '../types/messages'
// 一次定义,导出全上下文通用的收发函数
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>()
// background/index.ts —— 注册 handler,签名由 ProtocolMap 推导
import { onMessage } from '@/shared/messaging'
import { bookmarkService } from './services/bookmark'
onMessage('saveBookmark', ({ data }) => bookmarkService.save(data))
// popup/App.vue 内 —— 调用,input 与返回值全程类型安全
import { sendMessage } from '@/shared/messaging'
const bookmark = await sendMessage('saveBookmark', { url, title })
适用场景:所有需要请求/响应的跨上下文调用。这是其它所有通信模式的基础。
请求/响应封装:超时与错误归一化
意图:chrome.runtime.sendMessage 的两大缺陷——Service Worker 未就绪时调用会抛 Receiving end does not exist,且没有超时机制(接收端不响应则 Promise 永远 pending)。封装一层统一处理。
结构:
caller ──> withTimeout(sendMessage) ──┬── 正常: resolve(data)
├── 超时: reject(TimeoutError)
└── SW 未就绪: 重试一次再 reject
// shared/messaging/request.ts
import { sendMessage } from './index'
import type { ProtocolMap } from '../types/messages'
export class RpcError extends Error {
constructor(
message: string,
readonly code: 'TIMEOUT' | 'NO_RECEIVER' | 'HANDLER_ERROR',
) {
super(message)
}
}
export async function request<K extends keyof ProtocolMap>(
key: K,
data: Parameters<ProtocolMap[K]>[0],
opts: { timeoutMs?: number; retry?: number } = {},
): Promise<ReturnType<ProtocolMap[K]>> {
const { timeoutMs = 5000, retry = 1 } = opts
let lastErr: unknown
for (let attempt = 0; attempt <= retry; attempt++) {
try {
return await Promise.race([
sendMessage(key, data),
new Promise<never>((_, rej) =>
setTimeout(() => rej(new RpcError(`${String(key)} timed out`, 'TIMEOUT')), timeoutMs),
),
])
} catch (e) {
lastErr = e
// SW 冷启动时第一次常失败,退避后重试唤醒
if (String(e).includes('Receiving end does not exist')) {
await new Promise((r) => setTimeout(r, 100 * (attempt + 1)))
continue
}
throw new RpcError(String(e), 'HANDLER_ERROR')
}
}
throw new RpcError(String(lastErr), 'NO_RECEIVER')
}
适用场景:任何 UI 直接依赖后台返回结果的场景;MV3 下 SW 频繁休眠,重试唤醒尤为关键。
事件广播模式
意图:后台状态变化时,一对多通知所有打开的标签页 / Popup / Options,无需各上下文轮询。
结构:
┌──> tab 1 content script
background ───┼──> tab 2 content script (遍历 tabs.query + tabs.sendMessage)
└──> popup (runtime.sendMessage 广播)
// background/broadcast.ts
import type { AppState } from '@/shared/types/domain'
export async function broadcast(event: { type: 'state-changed'; state: AppState }) {
// 1. 通知所有标签页的内容脚本
const tabs = await chrome.tabs.query({})
await Promise.all(
tabs.map((t) =>
t.id != null
? chrome.tabs.sendMessage(t.id, event).catch(() => {
// 反模式纠正:某个 tab 没有内容脚本会 reject,必须吞掉避免连锁失败
})
: Promise.resolve(),
),
)
// 2. 通知 popup/options 等扩展页面
chrome.runtime.sendMessage(event).catch(() => {})
}
适用场景:实时同步开关状态、登录态、配置变更。注意:广播是「尽力而为」,不保证送达(接收端可能未注入或已关闭),关键状态仍应以 storage 为准(见状态管理章节)。
RPC 模式:远程方法调用
意图:让 UI 层像调用本地异步方法一样调用后台服务,彻底隐藏消息细节。这是消息总线之上的更高抽象。
结构:
popup: service.getAll() ──proxy拦截──> 序列化方法名+参数 ──消息──>
background: 反序列化 ──> realService.getAll() ──> 返回值 ──消息──> resolve
用 @webext-core/proxy-service(核实:defineProxyService 返回 [registerService, getService] 元组):
// shared/services/bookmark-repo.ts —— 服务定义(接口 + 工厂)
import type { Bookmark, BookmarkInput } from '@/shared/types/domain'
export interface BookmarkRepo {
getAll(): Promise<Bookmark[]>
save(input: BookmarkInput): Promise<Bookmark>
}
export function createBookmarkRepo(): BookmarkRepo {
return {
async getAll() {
const { bookmarks = [] } = await chrome.storage.local.get('bookmarks')
return bookmarks
},
async save(input) {
const { bookmarks = [] } = await chrome.storage.local.get('bookmarks')
const bm: Bookmark = { id: crypto.randomUUID(), createdAt: Date.now(), ...input }
await chrome.storage.local.set({ bookmarks: [...bookmarks, bm] })
return bm
},
}
}
// shared/services/index.ts —— 一次声明,两端共用
import { defineProxyService } from '@webext-core/proxy-service'
import { createBookmarkRepo } from './bookmark-repo'
export const [registerBookmarkRepo, getBookmarkRepo] =
defineProxyService('BookmarkRepo', createBookmarkRepo)
// background/index.ts —— 真实实例只在后台注册一次
import { registerBookmarkRepo } from '@/shared/services'
registerBookmarkRepo()
// popup/App.vue —— 拿到的是代理,方法调用自动走消息到后台执行
import { getBookmarkRepo } from '@/shared/services'
const repo = getBookmarkRepo()
const list = await repo.getAll() // 实际在 Service Worker 里执行
适用场景:业务方法集中在后台、UI 只是消费者的插件。RPC 把「通信层」对业务代码完全透明化,是分层架构里 UI 层与领域层解耦的关键手段。
Port 长连接:连接池与重连
意图:高频或流式通信(如实时日志、进度推送)用 chrome.runtime.connect 建立长连接 Port,避免每条消息都重新建立通道;MV3 下 SW 会因空闲被回收导致 Port 断开,需自动重连。
结构:
content ──connect()──> Port ──┐
├──> background 维护一个 Port 池(按 tabId)
content ──connect()──> Port ──┘ SW 重启后 onConnect 重新登记
↑
断线检测 onDisconnect ──> 退避重连
// shared/messaging/durable-port.ts —— 带自动重连的客户端 Port
export function createDurablePort(name: string, onMessage: (m: unknown) => void) {
let port: chrome.runtime.Port | null = null
let backoff = 500
function connect() {
port = chrome.runtime.connect({ name })
port.onMessage.addListener(onMessage)
port.onDisconnect.addListener(() => {
port = null
// SW 被回收 / 后台主动断开都会触发,指数退避后重连
setTimeout(connect, backoff)
backoff = Math.min(backoff * 2, 10_000)
})
backoff = 500 // 连接成功重置退避
}
connect()
return {
post: (msg: unknown) => port?.postMessage(msg),
close: () => port?.disconnect(),
}
}
// background/port-pool.ts —— 后台连接池,按 tab 索引便于定向推送
const pool = new Map<number, chrome.runtime.Port>()
chrome.runtime.onConnect.addListener((port) => {
const tabId = port.sender?.tab?.id
if (tabId == null) return
pool.set(tabId, port)
port.onDisconnect.addListener(() => pool.delete(tabId))
})
export function pushTo(tabId: number, msg: unknown) {
pool.get(tabId)?.postMessage(msg)
}
适用场景:进度推送、实时协作、需要服务端主动 push 的场景。普通请求/响应不要用 Port——它会让 SW 因 Port 存活而无法休眠,反而增加资源占用。
状态管理模式
storage 作为单一数据源(SSOT)
意图:MV3 下 Service Worker 全局变量随休眠丢失,因此「真相」必须落在 chrome.storage。所有上下文从 storage 读,写也只写 storage,再靠 storage.onChanged 反向驱动各处 UI 更新。
结构:
write onChanged 广播
UI ───────────> chrome.storage ───────────> 所有上下文响应式更新
(单一数据源) (popup / content / options)
糟糕设计:
// 反模式:把状态存在 SW 全局变量,SW 休眠后清零,UI 读到 undefined
let currentUser: User | null = null
chrome.runtime.onMessage.addListener((m) => {
if (m.type === 'login') currentUser = m.user // 30 秒后 SW 被杀,丢失
})
优雅设计见下面的 Repository 与响应式桥接。
Repository / DAO 模式封装 storage
意图:把裸 chrome.storage.local.get/set 收进一个类型化的仓储类,集中处理默认值、序列化、迁移,业务代码不再出现字符串 key。
结构:
业务代码 ──> SettingsRepo.get() / .patch() ──> chrome.storage.local
▲ 类型化、默认值、key 集中
// shared/storage/settings-repo.ts
export interface Settings {
theme: 'light' | 'dark'
syncEnabled: boolean
version: number
}
const DEFAULTS: Settings = { theme: 'light', syncEnabled: false, version: 1 }
const KEY = 'settings'
export const settingsRepo = {
async get(): Promise<Settings> {
const raw = await chrome.storage.local.get(KEY)
// 合并默认值,保证新增字段对老用户也有值
return { ...DEFAULTS, ...(raw[KEY] as Partial<Settings> | undefined) }
},
async patch(partial: Partial<Settings>): Promise<Settings> {
const next = { ...(await this.get()), ...partial }
await chrome.storage.local.set({ [KEY]: next })
return next
},
onChange(cb: (s: Settings) => void): () => void {
const listener = (changes: Record<string, chrome.storage.StorageChange>, area: string) => {
if (area === 'local' && changes[KEY]) cb({ ...DEFAULTS, ...changes[KEY].newValue })
}
chrome.storage.onChanged.addListener(listener)
return () => chrome.storage.onChanged.removeListener(listener)
},
}
用库简化 —— @webext-core/storage 提供 localStorage 风格的类型安全 API:
import { defineExtensionStorage } from '@webext-core/storage'
interface StorageSchema {
settings: Settings
}
export const extStorage = defineExtensionStorage<StorageSchema>(chrome.storage.local)
// await extStorage.getItem('settings'); await extStorage.setItem('settings', next)
适用场景:所有有持久化状态的插件。Repository 是「存储层」的标准实现,把存储细节与领域逻辑隔离。
跨上下文响应式状态:storage + Vue reactive 桥接
意图:在 Vue 应用里把 storage 数据变成 ref,写 ref 自动落 storage,别的上下文改了 storage 这里自动更新——实现「跨进程的响应式」。
// shared/storage/use-storage.ts —— 通用 composable
import { ref, watch, type Ref, onScopeDispose } from 'vue'
export function useStorageItem<T>(key: string, defaultValue: T): Ref<T> {
const state = ref(defaultValue) as Ref<T>
// 初始化读取
chrome.storage.local.get(key).then((r) => {
if (r[key] !== undefined) state.value = r[key]
})
// 本地写入 -> 落 storage
let writingFromSelf = false
watch(
state,
(val) => {
writingFromSelf = true
chrome.storage.local.set({ [key]: val }).finally(() => (writingFromSelf = false))
},
{ deep: true },
)
// 外部 storage 变更 -> 同步回 ref(跳过自身写入回声)
const listener = (changes: Record<string, chrome.storage.StorageChange>, area: string) => {
if (area === 'local' && changes[key] && !writingFromSelf) {
state.value = changes[key].newValue
}
}
chrome.storage.onChanged.addListener(listener)
onScopeDispose(() => chrome.storage.onChanged.removeListener(listener))
return state
}
<!-- popup/App.vue -->
<script setup lang="ts">
import { useStorageItem } from '@/shared/storage/use-storage'
const theme = useStorageItem<'light' | 'dark'>('theme', 'light')
// 改 theme.value 即落盘,options 页同时刷新
</script>
<template>
<button @click="theme = theme === 'light' ? 'dark' : 'light'">{{ theme }}</button>
</template>
适用场景:Popup 与 Options 需要实时一致、内容脚本 UI 需反映后台状态变化。这是 SSOT 在 UI 层的落地形态。若用 Pinia,可把上述桥接封进一个 store 的 $subscribe + storage 监听(详见 Pinia完全指南)。
状态版本迁移(schema migration)
意图:插件升级时 storage 里残留旧结构的数据,直接读会字段缺失或类型错位。用版本号 + 迁移函数链逐版升级。
// shared/storage/migrate.ts
type Migration = (old: any) => any
const migrations: Record<number, Migration> = {
// 从 v1 升 v2:bookmark.tags 由 string 改为 string[]
2: (s) => ({
...s,
bookmarks: s.bookmarks.map((b: any) => ({
...b,
tags: typeof b.tags === 'string' ? b.tags.split(',') : (b.tags ?? []),
})),
version: 2,
}),
// 从 v2 升 v3:新增 syncEnabled 默认 false
3: (s) => ({ ...s, syncEnabled: false, version: 3 }),
}
const LATEST = 3
export async function migrateStorage(): Promise<void> {
const all = await chrome.storage.local.get(null)
let state = all
let v = (all.version as number) ?? 1
while (v < LATEST) {
v++
state = migrations[v](state) // 逐版应用,不跳版
}
if (state.version !== all.version) await chrome.storage.local.set(state)
}
// background/index.ts —— 在 onInstalled 时执行迁移
chrome.runtime.onInstalled.addListener(({ reason }) => {
if (reason === 'update') migrateStorage()
})
适用场景:任何会迭代数据结构的长期维护插件。关键:迁移函数必须可重入且逐版串联,不能写成「直接转成最新结构」——用户可能从任意旧版本升上来。
依赖注入 / 服务定位器在 SW 中的应用
意图:Service Worker 入口应只做「装配」,不该 new 一堆对象、写一堆 if。用一个简单容器集中创建与提供服务,便于替换实现(如测试时注入 mock storage)。
结构:
container.register('storage', () => realStorage)
container.register('bookmarkRepo', (c) => new BookmarkRepo(c.get('storage')))
▲ 依赖声明式,懒实例化
// shared/di/container.ts —— 极简服务容器(服务定位器)
type Factory<T> = (c: Container) => T
export class Container {
private factories = new Map<string, Factory<unknown>>()
private singletons = new Map<string, unknown>()
register<T>(key: string, factory: Factory<T>): void {
this.factories.set(key, factory)
}
get<T>(key: string): T {
if (!this.singletons.has(key)) {
const f = this.factories.get(key)
if (!f) throw new Error(`Service not registered: ${key}`)
this.singletons.set(key, f(this)) // 懒加载,SW 唤醒才创建
}
return this.singletons.get(key) as T
}
}
// background/index.ts —— 装配
import { Container } from '@/shared/di/container'
import { createBookmarkRepo } from '@/shared/services/bookmark-repo'
const c = new Container()
c.register('bookmarkRepo', () => createBookmarkRepo())
// onMessage handler 内统一从容器取:c.get<BookmarkRepo>('bookmarkRepo')
适用场景:后台服务有多层依赖、需要单测替换实现时。注意 SW 随时被杀,容器是「每次唤醒重建」的,不要在其中存放需要持久化的状态——那是 storage 的职责。
命令模式封装 contextMenus / commands
意图:右键菜单(chrome.contextMenus)与快捷键(chrome.commands)的回调天然分散,用命令模式把每个动作封成一个对象,集中注册、统一分发。
结构:
CommandRegistry
├── { id: 'save-page', title, contexts, execute(ctx) }
├── { id: 'toggle', shortcut, execute(ctx) }
└── dispatch(id, ctx) ──> 对应 command.execute
// background/commands/types.ts
export interface Command {
id: string
title?: string // contextMenus 显示文案
contexts?: chrome.contextMenus.ContextType[]
execute(ctx: { tab?: chrome.tabs.Tab; info?: chrome.contextMenus.OnClickData }): Promise<void>
}
// background/commands/registry.ts
import type { Command } from './types'
const registry = new Map<string, Command>()
export function defineCommand(cmd: Command) {
registry.set(cmd.id, cmd)
}
export function installCommands() {
// 1. 建右键菜单
for (const cmd of registry.values()) {
if (cmd.title) {
chrome.contextMenus.create({ id: cmd.id, title: cmd.title, contexts: cmd.contexts ?? ['page'] })
}
}
// 2. 菜单点击 -> 分发
chrome.contextMenus.onClicked.addListener((info, tab) => {
registry.get(info.menuItemId as string)?.execute({ tab, info })
})
// 3. 快捷键 -> 分发(command id 须与 manifest.commands 对应)
chrome.commands.onCommand.addListener((id, tab) => {
registry.get(id)?.execute({ tab })
})
}
// background/commands/save-page.ts —— 一个命令一个文件,高内聚
import { defineCommand } from './registry'
import { getBookmarkRepo } from '@/shared/services'
defineCommand({
id: 'save-page',
title: '保存当前页面',
contexts: ['page'],
async execute({ tab }) {
if (!tab?.url) return
await getBookmarkRepo().save({ url: tab.url, title: tab.title ?? '' })
},
})
适用场景:菜单项 / 快捷键较多的插件。新增一个动作只写一个命令文件,不动分发逻辑——符合开闭原则。
适配器模式做跨浏览器
意图:Chrome 用 callback 风格 + chrome 命名空间,Firefox/Safari 用 Promise 风格 + browser 命名空间。用适配层归一,业务代码只认一个统一对象。
// shared/browser.ts
// 反模式:业务代码里到处 typeof browser !== 'undefined' ? browser : chrome
// 正确做法:集中归一,全库只 import 这一个
import browserPolyfill from 'webextension-polyfill'
// webextension-polyfill 把 chrome.* 包装成 Promise 风格的 browser.*
export const browser = browserPolyfill
// 之后所有上下文统一:
// import { browser } from '@/shared/browser'
// await browser.storage.local.get('key') // 跨浏览器一致的 Promise API
适用场景:需要同时上架 Chrome Web Store 与 Firefox AMO 的插件。WXT 框架已内置同等能力(统一 browser 全局),用 WXT 时无需自己引 polyfill。
内容脚本 UI 注入模式:Shadow DOM 隔离
意图:往宿主页注入 UI 时,宿主页的 CSS 会污染你的组件,你的 CSS 也可能搞乱宿主页。用 Shadow DOM 建立样式与 DOM 的双向隔离边界,再把 Vue 应用挂载进去。
结构:
host page <body>
└── <div id="my-ext-root"> ← 唯一接触宿主的节点
#shadow-root (closed) ← 样式隔离边界
├── <style>...组件样式...</style>
└── <div id="app"> Vue 应用 </div>
糟糕设计:
// 反模式:直接 append 到 body,样式靠全局类名,被宿主页 reset 覆盖
const el = document.createElement('div')
el.className = 'my-panel' // 宿主页若有同名 .my-panel 就冲突
document.body.appendChild(el)
优雅设计:
// content/ui/mount.ts
import { createApp } from 'vue'
import Panel from './Panel.vue'
// 用 ?inline 让构建工具把 CSS 作为字符串注入,而非 <head>
import styles from './style.css?inline'
export function mountPanel() {
const host = document.createElement('div')
host.id = 'my-ext-root'
document.body.appendChild(host)
// closed 模式:宿主页脚本无法通过 host.shadowRoot 访问,隔离更彻底
const shadow = host.attachShadow({ mode: 'closed' })
// 把组件样式注入 shadow root 内部,不外泄、不被外部覆盖
const styleEl = document.createElement('style')
styleEl.textContent = styles
shadow.appendChild(styleEl)
const appRoot = document.createElement('div')
shadow.appendChild(appRoot)
const app = createApp(Panel)
app.mount(appRoot)
return () => {
app.unmount()
host.remove()
}
}
适用场景:所有往任意第三方页面注入 UI 的内容脚本。注意:Vue 的 Teleport、第三方组件库的弹层若默认挂到 document.body,会逃出 shadow root 丢失样式——需把它们的挂载点显式指向 shadow root 内的容器。
后台任务调度模式:alarms + 任务队列 + 进度持久化
意图:MV3 SW 不能用长 setTimeout/setInterval(休眠即失效),周期任务必须用 chrome.alarms;长任务的进度要存进 storage.session,SW 被杀重启后能续跑。
结构:
chrome.alarms (定时唤醒 SW)
│
▼
读取 storage.session 里的队列与进度
│
▼
处理一批任务 ──> 回写进度 ──> 未完成则保留 alarm,完成则清除
// background/scheduler/queue.ts
interface Job { id: string; payload: unknown; done: boolean }
const QUEUE_KEY = 'job-queue'
async function loadQueue(): Promise<Job[]> {
const r = await chrome.storage.session.get(QUEUE_KEY)
return (r[QUEUE_KEY] as Job[]) ?? []
}
async function saveQueue(q: Job[]) {
await chrome.storage.session.set({ [QUEUE_KEY]: q })
}
export async function enqueue(jobs: Job[]) {
await saveQueue([...(await loadQueue()), ...jobs])
// 周期唤醒处理;alarm 最小周期 1 分钟(MV3 限制)
chrome.alarms.create('process-queue', { periodInMinutes: 1 })
}
// background/index.ts
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name !== 'process-queue') return
const queue = await loadQueue()
const pending = queue.filter((j) => !j.done)
// 每次只处理一批,避免单次 SW 存活时间内做不完
for (const job of pending.slice(0, 10)) {
await handle(job)
job.done = true
await saveQueue(queue) // 每完成一个就回写,SW 被杀也不丢进度
}
if (queue.every((j) => j.done)) chrome.alarms.clear('process-queue')
})
async function handle(_job: Job) { /* 实际业务 */ }
适用场景:定时同步、批量处理、断点续传式长任务。为什么用 storage.session:进度是临时的、浏览器重启即作废,session 区不落磁盘、性能更好且自动清理;需跨浏览器重启保留则用 storage.local。
分层架构:清晰边界
把前述模式组装成一条完整调用链,看「UI 层 → 通信层 → 领域服务层 → 存储层」如何各守其职:
[UI 层] popup/App.vue
│ 调用 RPC 代理,不知道有"消息"这回事
▼
[通信层] getBookmarkRepo().save(input) (@webext-core/proxy-service 代理)
│ 序列化方法调用,跨进程送到后台
▼
[领域服务层] bookmarkService.save(input) (运行在 SW,纯业务编排)
│ 校验、生成 id、决定写什么,不感知调用来源
▼
[存储层] settingsRepo / bookmarkRepo (封装 chrome.storage)
│ 类型化读写、默认值、迁移
▼
chrome.storage.local → onChanged 广播 → 回到所有 [UI 层] 响应式刷新
边界规则:
- UI 层只 import 通信层的代理 / composable,禁止直接出现
chrome.runtime/chrome.storage。 - 领域服务层是纯 TS,依赖通过参数 / 容器注入,可脱离浏览器单测。
- 存储层是唯一接触
chrome.storage的地方。 - 通信层是唯一接触
chrome.runtime/chrome.tabs消息 API 的地方。
这样每层都能独立替换与测试,新增功能时改动局限在单层内。
最佳实践
-
消息契约单一来源(ProtocolMap)。所有消息名与载荷类型集中在
shared/types/messages.ts,收发两端都从同一份类型推导。改一个消息的入参,编译器立刻在所有调用点报错,杜绝「消息类型漂移」。// 改这一行,sendMessage 和 onMessage 两端同时报类型错 export interface ProtocolMap { saveBookmark(data: BookmarkInput): Bookmark } -
storage 是唯一数据源,全局变量只做缓存。任何需要跨 SW 休眠存活的状态都落 storage;SW 内的变量仅作单次唤醒周期内的临时缓存,唤醒入口先从 storage 重建。
// SW 唤醒时重建缓存,而非依赖上次的内存 let cache: Settings | null = null async function getSettings() { return (cache ??= await settingsRepo.get()) } -
存储访问全部走 Repository。业务代码里不出现裸字符串 key 和裸
chrome.storage.set。集中处理默认值与迁移后,新增字段对老用户自动有值,避免undefined满天飞。await settingsRepo.patch({ theme: 'dark' }) // 而非 chrome.storage.local.set -
跨进程调用一律加超时与重试。MV3 的 SW 冷启动会让首次消息失败,封装的
request()统一重试唤醒并设超时,避免 UI 永久 pending。const data = await request('getPageMeta', undefined, { timeoutMs: 3000, retry: 2 }) -
领域逻辑与浏览器 API 解耦,保证可测。把业务规则写成不依赖
chrome.*的纯函数 / 类(放shared/domain或 monorepo 的core包),通过依赖注入接入存储,使其能在 Node + Vitest 下单测。// 纯函数,无 chrome 依赖,可直接断言 export function dedupeBookmarks(list: Bookmark[]): Bookmark[] { const seen = new Set<string>() return list.filter((b) => !seen.has(b.url) && seen.add(b.url)) } -
内容脚本 UI 必须 Shadow DOM 隔离。注入到任意页面的组件用 shadow root +
?inline注入样式,双向隔离 CSS,杜绝「样式污染宿主页 / 被宿主页污染」两类问题。
常见陷阱
Service Worker 全局状态丢失
- 现象:用户登录后几十秒,Popup 再打开显示未登录;后台变量莫名归零。
- 原因:MV3 的 Service Worker 空闲约 30 秒被回收,所有全局变量随之清空。把状态存在 SW 模块级变量里必然丢失。
- 解决:状态以
chrome.storage为单一数据源(SSOT),SW 全局变量仅作单次唤醒内的缓存,且唤醒时从 storage 重建(见最佳实践 2)。
消息类型漂移
- 现象:重构时把后台 handler 的返回结构从
{ ok }改成{ success },UI 端读res.ok永远undefined,无任何报错。 - 原因:裸
chrome.runtime.sendMessage的消息体是any,类型契约靠口头约定,改一端不影响另一端编译。 - 解决:用 ProtocolMap + 类型安全总线(手写或
@webext-core/messaging),让消息名、入参、返回值在编译期受约束,改契约则所有调用点报错。
内容脚本样式污染
- 现象:注入的面板在某些网站被挤变形、字体颜色错乱;或注入后宿主页某些元素样式被你的全局 CSS 改掉。
- 原因:直接
appendChild到body,CSS 走全局作用域,与宿主页样式互相覆盖。 - 解决:用
attachShadow({ mode: 'closed' })建立隔离边界,组件样式以?inline注入 shadow root 内部;并把第三方弹层组件的 Teleport 目标指向 shadow root 内的容器(见 Shadow DOM 章节)。
alarms 周期任务「丢失」
- 现象:用
setInterval写的定时同步在插件后台「跑一会儿就停」。 - 原因:SW 被回收后
setInterval一并消失,MV3 不保证 SW 常驻。 - 解决:周期任务改用
chrome.alarms(最小周期 1 分钟),长任务进度写storage.session,SW 重启后从持久化进度续跑(见后台任务调度章节)。
广播消息抛错导致连锁失败
- 现象:向所有标签页广播状态时,整个广播流程在某个 tab 处抛
Could not establish connection后中断,其余 tab 收不到。 - 原因:未注入内容脚本的标签页(如
chrome://页、扩展商店页)没有接收端,tabs.sendMessage直接 reject。 - 解决:每个
sendMessage单独.catch(() => {})吞掉无接收端的错误,用Promise.all并发但互不影响(见事件广播章节)。