浏览器插件 - 设计模式与优雅架构

本文是浏览器插件系列的「架构篇」。前面几篇讲清楚了 MV3 有哪些能力、技术栈怎么搭、坑在哪里;这一篇只回答一个问题:当插件从「几百行脚本」长成「几千行多上下文工程」时,代码如何保持优雅、可测、可演进。 What(是什么):浏览器插件的架构设计,是在 MV3 强制的多上下文(multi-context)隔离模型之上,建立清晰的分层与通信契约。MV3 把代码拆进了相互隔离的进程:Service Worker(后台,随时被杀)、Content Script(注入页面、与宿主 DOM 共存)、Popup / Options(短生命周期 UI 页面)、DevTo

分享

参考来源:

适用版本: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 的地方。

这样每层都能独立替换与测试,新增功能时改动局限在单层内。


最佳实践

  1. 消息契约单一来源(ProtocolMap)。所有消息名与载荷类型集中在 shared/types/messages.ts,收发两端都从同一份类型推导。改一个消息的入参,编译器立刻在所有调用点报错,杜绝「消息类型漂移」。

    // 改这一行,sendMessage 和 onMessage 两端同时报类型错
    export interface ProtocolMap { saveBookmark(data: BookmarkInput): Bookmark }
    
  2. storage 是唯一数据源,全局变量只做缓存。任何需要跨 SW 休眠存活的状态都落 storage;SW 内的变量仅作单次唤醒周期内的临时缓存,唤醒入口先从 storage 重建。

    // SW 唤醒时重建缓存,而非依赖上次的内存
    let cache: Settings | null = null
    async function getSettings() { return (cache ??= await settingsRepo.get()) }
    
  3. 存储访问全部走 Repository。业务代码里不出现裸字符串 key 和裸 chrome.storage.set。集中处理默认值与迁移后,新增字段对老用户自动有值,避免 undefined 满天飞。

    await settingsRepo.patch({ theme: 'dark' })   // 而非 chrome.storage.local.set
    
  4. 跨进程调用一律加超时与重试。MV3 的 SW 冷启动会让首次消息失败,封装的 request() 统一重试唤醒并设超时,避免 UI 永久 pending。

    const data = await request('getPageMeta', undefined, { timeoutMs: 3000, retry: 2 })
    
  5. 领域逻辑与浏览器 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))
    }
    
  6. 内容脚本 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 改掉。
  • 原因:直接 appendChildbody,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 并发但互不影响(见事件广播章节)。

参见

阅读更多

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