浏览器插件-避坑与开发技巧

本篇是浏览器插件(browser extension)系列的"经验篇"。它假设你已经掌握 浏览器插件-基础概念与架构模型(/liu-lan-qi-cha-jian-ji-chu-gai-nian-yu-jia-gou-mo-xing/) 中的组件模型与 浏览器插件-中级开发指南(/liu-lan-qi-cha-jian-zhong-ji-kai-fa-zhi-nan-manifest-v3-vite-vue/) 中的通信、存储、权限知识,聚焦于把"能跑"的插件做成"可上架、可维护、可信任"的产品。 全文分为四部分:高频深坑详解(现象/原因/解决三段式)、

分享

[!quote] 文档信息

本篇是浏览器插件(browser extension)系列的"经验篇"。它假设你已经掌握 浏览器插件-基础概念与架构模型 中的组件模型与 浏览器插件-中级开发指南 中的通信、存储、权限知识,聚焦于把"能跑"的插件做成"可上架、可维护、可信任"的产品。

全文分为四部分:高频深坑详解(现象/原因/解决三段式)、开发提效技巧、社区标准与上架规范、优雅设计原则。所有政策表述以 2026-06-06 核实的 Chrome Web Store 官方文档为准。


一、高频深坑详解

每个坑用「现象」「原因」「解决」三段式呈现,并给出可直接套用的代码。

坑 1:Service Worker 全局变量与定时器失效

现象:在后台脚本(Service Worker,简称 SW)顶层声明一个计数器 let count = 0,每次收到消息 count++,但过几十秒后再触发,count 又变回 0。setTimeout/setInterval 到点不执行。

原因:MV3 的后台是 Service Worker,不是 MV2 那种常驻的 background page。SW 是事件驱动的,空闲约 30 秒后会被浏览器终止(terminate),下次有事件时重新启动一个全新的执行环境。顶层变量随之清零;基于 setTimeout 的长延时回调会随 SW 被杀而丢失。

解决:所有需要跨事件存活的状态写入 chrome.storage(持久化数据源),定时任务改用 chrome.alarms(alarm 由浏览器托管,可唤醒 SW)。

// background.js(service worker)

// 错误:依赖顶层变量做持久状态,SW 重启后丢失
let count = 0
chrome.runtime.onMessage.addListener((msg) => {
  if (msg.type === "tick") count++ // SW 被杀后归零
})

// 正确:状态落盘到 chrome.storage
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  if (msg.type === "tick") {
    chrome.storage.local.get({ count: 0 }, ({ count }) => {
      chrome.storage.local.set({ count: count + 1 }, () => {
        sendResponse({ count: count + 1 })
      })
    })
    return true // 见坑 3:异步 sendResponse 必须 return true
  }
})

// 错误:长延时 setTimeout 会随 SW 终止而丢失
setTimeout(() => doCleanup(), 5 * 60 * 1000)

// 正确:用 chrome.alarms,浏览器到点唤醒 SW
chrome.alarms.create("cleanup", { delayInMinutes: 5 })
chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === "cleanup") doCleanup()
})

[!warning] alarm 最小粒度
Chrome 对打包发布的扩展,alarms 周期最小为 30 秒(periodInMinutes: 0.5)。早期版本曾限制为 1 分钟,开发时设更小值会被静默抬高到下限。

坑 2:内容脚本与页面 JS 处于隔离世界,访问不到页面变量

现象:内容脚本(content script)里 window.__APP_STATE__ 永远是 undefined,但在页面的 DevTools 控制台里能正常打印。给页面的全局函数打补丁也对页面无效。

原因:内容脚本默认运行在「隔离世界」(isolated world)。它与页面共享同一个 DOM,但拥有独立的 JavaScript 环境(独立的 window、独立的原型链)。这是出于安全设计,防止页面脚本篡改扩展逻辑、也防止扩展污染页面。

解决:根据需求二选一。读取页面变量、Hook 页面函数,需要把代码注入「主世界」(main world)。

// 方式一:manifest.json 直接声明主世界内容脚本(MV3 支持 world 字段)
{
  "content_scripts": [
    {
      "matches": ["https://example.com/*"],
      "js": ["main-world.js"],
      "world": "MAIN"      // 运行在页面同一环境,可读 window.__APP_STATE__
    },
    {
      "matches": ["https://example.com/*"],
      "js": ["isolated.js"],
      "world": "ISOLATED"  // 默认值,扩展私有环境
    }
  ]
}
// 方式二:从隔离世界用 chrome.scripting 动态注入主世界
import "./types"

async function injectMainWorld(tabId) {
  await chrome.scripting.executeScript({
    target: { tabId },
    world: "MAIN", // 注入到页面真实环境
    func: () => {
      // 这里能访问页面真实的 window
      window.postMessage({ source: "ext", state: window.__APP_STATE__ }, "*")
    },
  })
}

// 隔离世界监听主世界通过 postMessage 回传的数据(跨世界只能靠 DOM 事件 / postMessage)
window.addEventListener("message", (e) => {
  if (e.source === window && e.data?.source === "ext") {
    console.log("拿到页面状态:", e.data.state)
  }
})

[!note] 跨世界通信只能走 DOM 通道
主世界与隔离世界无法直接调用对方函数,只能通过 window.postMessage 或自定义 DOM 事件(CustomEvent)传递可序列化数据。主世界脚本没有 chrome.* API 权限,需要把数据回传给隔离世界再转发到后台。

坑 3:sendResponse 异步丢失(缺少 return true)

现象:消息监听里做了异步操作(fetchstorage 回调、await),在异步完成后调用 sendResponse,但发送方的回调永远拿不到响应,控制台偶现 "message port closed before a response was received"。

原因chrome.runtime.onMessage 监听器同步返回后,消息通道默认立即关闭。要保持通道开放以便稍后异步响应,监听器必须同步返回 trueasync 函数返回的是 Promise(真值但不是 true),不能用来声明这一点。

解决:监听器写成普通函数,内部做异步,结尾 return true;或用 Promise 链。

// 错误:async 监听器返回 Promise,Chrome 不识别,通道提前关闭
chrome.runtime.onMessage.addListener(async (msg, sender, sendResponse) => {
  const data = await fetch(msg.url).then((r) => r.json())
  sendResponse(data) // 大概率发不出去
})

// 正确:同步返回 true,保持通道开放
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type !== "fetchJson") return false
  fetch(msg.url)
    .then((r) => r.json())
    .then((data) => sendResponse({ ok: true, data }))
    .catch((err) => sendResponse({ ok: false, error: String(err) }))
  return true // 关键:声明"我会异步响应"
})

[!tip] 更优雅的方案:迁移到 Promise 风格
较新的 Chrome 版本支持监听器返回 Promise 作为响应(无需 sendResponse),但为兼容性与跨浏览器一致,封装统一的消息层(见技巧 2)比裸用 sendResponse 更可靠。

坑 4:Extension context invalidated(重载后旧内容脚本)

现象:开发时改了代码、在 chrome://extensions 点了重载,已打开的页面里旧内容脚本一调用 chrome.runtime.sendMessage 就抛 Uncaught Error: Extension context invalidated.

原因:重载扩展会销毁旧的扩展上下文,但已注入页面的旧内容脚本仍残留在 DOM 中运行。它持有的 chrome.runtime 句柄已失效,任何调用都会抛错。生产环境中用户更新扩展、或扩展崩溃重启时同样会发生。

解决:调用前判活,捕获该错误并优雅降级(提示用户刷新页面);监听 SW 失联事件。

// content-script.js

// 判断扩展上下文是否仍有效
function isContextValid() {
  try {
    // chrome.runtime.id 在上下文失效后会变为 undefined
    return Boolean(chrome.runtime?.id)
  } catch {
    return false
  }
}

async function safeSendMessage(message) {
  if (!isContextValid()) {
    showReloadHint() // 优雅降级:提示用户刷新页面
    return
  }
  try {
    return await chrome.runtime.sendMessage(message)
  } catch (err) {
    // 上下文在调用瞬间失效
    if (String(err).includes("context invalidated")) {
      showReloadHint()
      return
    }
    throw err
  }
}

function showReloadHint() {
  console.warn("[ext] 扩展已更新,请刷新本页面以恢复功能。")
}

坑 5:Vue Router history 模式在插件页白屏

现象:用 Vue/React 写的弹出页(popup)或选项页(options page),开发服务器里正常,打包加载进扩展后路由跳转白屏,地址栏出现 chrome-extension://<id>/popup.html#/settings 解析失败或刷新 404。

原因:HTML5 history 模式依赖服务器对所有路径回退到 index.html。扩展页面通过 chrome-extension:// 协议从本地文件加载,没有服务器做 fallback,深层路径找不到对应文件。

解决:扩展内的 SPA 一律用 hash 模式(hash 路由不触碰协议层路径)。

// router.js
// 错误:扩展页面没有服务器 fallback,history 模式刷新即白屏
// import { createRouter, createWebHistory } from "vue-router"
// const router = createRouter({ history: createWebHistory(), routes })

// 正确:扩展内 SPA 使用 hash 模式
import { createRouter, createWebHashHistory } from "vue-router"

const router = createRouter({
  history: createWebHashHistory(),
  routes: [
    { path: "/", component: () => import("./views/Home.vue") },
    { path: "/settings", component: () => import("./views/Settings.vue") },
  ],
})

export default router
// React Router 同理:用 HashRouter
import { HashRouter, Routes, Route } from "react-router-dom"

export function App() {
  return (
    <HashRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </HashRouter>
  )
}

坑 6:manifest 路径错误与 web_accessible_resources 漏声明

现象:内容脚本里 chrome.runtime.getURL("img/icon.png") 得到的 URL 在页面里加载报 net::ERR_BLOCKED_BY_CLIENT 或 404;动态注入页面的脚本/样式拿不到。

原因:两类问题。一是 manifest 中的资源路径必须相对于扩展根目录,写错大小写或前缀会找不到(MV3 不支持以 / 开头的绝对扩展路径在所有字段统一解析)。二是任何要被网页(而非扩展自身页面)访问的资源,必须在 web_accessible_resources 显式声明,否则被拦截。

解决:声明 web_accessible_resources 并限定 matches 收窄暴露面。

{
  "manifest_version": 3,
  "web_accessible_resources": [
    {
      "resources": ["img/icon.png", "injected/main-world.js"],
      "matches": ["https://example.com/*"],
      "use_dynamic_url": true
    }
  ]
}

[!warning] use_dynamic_url 与指纹
不加限制地把资源对 <all_urls> 暴露,等于让任意网页能探测你的扩展是否安装(指纹识别)。用 matches 收窄到必要站点,敏感场景配合 use_dynamic_url: true 让资源 URL 每次会话变化,降低被指纹的概率。

坑 7:host_permissions 过宽导致审核被拒或被下架

现象:提交审核被驳回,理由涉及权限过度(excessive permissions);或上架后因权限与功能不匹配被复审下架。

原因:Chrome Web Store 的「Use of Permissions」政策要求请求实现功能所必需的最窄权限;当多个权限都能实现某功能时,必须选数据/能力访问最小的那个。声明 <all_urls>*://*/* 但实际只服务个别站点,是最常见的被拒原因。

解决:host 权限收窄到具体域名;广域访问改用 activeTab + 用户手势触发,或 optional_host_permissions 运行时按需申请。

{
  // 错误:只服务一个站点却申请全网权限,审核高风险
  // "host_permissions": ["<all_urls>"],

  // 正确:精确到必需的域名
  "host_permissions": ["https://api.example.com/*"],

  // 用户点击图标时临时获得当前标签页权限,无需常驻广域权限
  "permissions": ["activeTab", "scripting"],

  // 其余站点改为运行时按需申请
  "optional_host_permissions": ["https://*.optional-site.com/*"]
}
// 运行时按需申请可选权限(须由用户手势触发,例如点击按钮)
async function requestSiteAccess(origin) {
  const granted = await chrome.permissions.request({ origins: [origin] })
  if (!granted) {
    console.warn("用户拒绝了站点访问权限")
    return false
  }
  return true
}

坑 8:storage.sync 配额与写入频率限制

现象:频繁保存设置时偶发 QUOTA_BYTES_PER_ITEM quota exceededMAX_WRITE_OPERATIONS_PER_MINUTE,部分写入静默失败。

原因chrome.storage.sync 用于跨设备同步,配额严格:总容量约 100KB、单条约 8KB、每分钟写入次数与每小时写入次数都有上限。把大对象或高频日志写进 sync 必然触顶。

解决:大数据/高频数据放 local(容量大得多),sync 只放小而关键的用户偏好;高频写入做防抖(debounce)合并。

// 错误:把大数组高频写入 sync
// chrome.storage.sync.set({ logs: hugeArray }) // 超配额

// 正确:分层存储 + 写入防抖
const persistLocal = debounce((data) => {
  chrome.storage.local.set(data) // 大数据、高频写本地
}, 500)

function saveLog(entry) {
  persistLocal({ lastLog: entry })
}

function saveUserPref(pref) {
  // 小而关键的偏好才进 sync(跨设备同步)
  chrome.storage.sync.set({ theme: pref.theme })
}

function debounce(fn, wait) {
  let t
  return (...args) => {
    clearTimeout(t)
    t = setTimeout(() => fn(...args), wait)
  }
}
存储区 同步 总容量(约) 单条上限(约) 适用
storage.local 10MB(可申请 unlimitedStorage) 无单条硬限 大数据、缓存、日志
storage.sync 是(跨设备) 100KB 8KB 小而关键的用户偏好
storage.session 否(内存,SW 生命周期) 10MB 临时状态、敏感令牌

坑 9:MV3 CSP 禁止远程代码

现象:在扩展页面里 <script src="https://cdn.example.com/lib.js"> 加载第三方库无效;用了 evalnew Function 抛 CSP 错误;内联 <script> 不执行。

原因:MV3 的内容安全策略(Content Security Policy,CSP)禁止扩展执行远程托管代码(remotely hosted code)。这同时是技术限制与上架红线:Chrome Web Store 政策要求「扩展的完整功能必须能从提交的代码中辨明」。evalnew Function、远程 <script>、内联脚本均被禁止。

解决:所有第三方库改为打包进扩展本地随包发布;动态行为用配置驱动而非远程代码;确需用户脚本能力时用受限的 userScripts API(有专门豁免)。

<!-- 错误:从 CDN 远程加载脚本,被 MV3 CSP 拦截,且违反上架政策 -->
<!-- <script src="https://cdn.jsdelivr.net/npm/lib@1/dist/lib.min.js"></script> -->

<!-- 正确:依赖随包打包到本地 -->
<script src="vendor/lib.min.js"></script>
// 错误:eval / new Function 执行字符串代码,CSP 禁止
// const fn = new Function("return " + userInput)

// 正确:用数据驱动的查表替代动态代码
const handlers = {
  upper: (s) => s.toUpperCase(),
  lower: (s) => s.toLowerCase(),
}
const run = (name, input) => handlers[name]?.(input) ?? input

[!note] WASM 与 user scripts 例外
MV3 允许在 manifest 的 CSP 中显式声明 wasm-unsafe-eval 以使用 WebAssembly。需要让用户运行自定义脚本的扩展(如脚本管理器)可使用 userScripts API,这是官方对远程代码禁令的受限豁免,仍需声明权限并由用户主动开启。

坑 10:内容脚本注入受保护页失败

现象:在 chrome://extensionschrome://settings、Chrome Web Store 页面、view-source:、其它扩展页面上,内容脚本不注入、脚本注入 API 报错。

原因:浏览器禁止扩展向受保护页面(chrome://chrome-extension:// 其它扩展、商店页、PDF 查看器等)注入脚本,属于安全策略,无法绕过。

解决:注入前判断 URL,对受保护页面优雅跳过并给出可见反馈。

function isInjectablePage(url) {
  if (!url) return false
  // 受保护协议/页面一律不可注入
  const blocked = ["chrome://", "chrome-extension://", "edge://", "about:", "view-source:"]
  if (blocked.some((p) => url.startsWith(p))) return false
  // Chrome Web Store 也受保护
  if (url.startsWith("https://chrome.google.com/webstore")) return false
  if (url.startsWith("https://chromewebstore.google.com")) return false
  return true
}

async function tryInject(tab) {
  if (!isInjectablePage(tab.url)) {
    chrome.action.setBadgeText({ tabId: tab.id, text: "—" }) // 优雅降级反馈
    return
  }
  await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["content.js"] })
}

坑 11:时序坑——run_at 与 DOM 未就绪

现象:内容脚本里 document.querySelector(".target") 返回 null,但手动在控制台执行同样代码能拿到元素。SPA 站点尤其明显。

原因:内容脚本默认 run_at: "document_idle",时机介于 DOMContentLoaded 与 window load 之间,但对于客户端渲染(CSR)的单页应用,目标元素由 JS 异步渲染,脚本执行时 DOM 里还没有它。改成 document_start 更早,反而更拿不到。

解决:用 MutationObserver 等待目标节点出现,而不是假设 DOM 已就绪。

// 错误:假设元素已存在
// const el = document.querySelector(".target") // SPA 下常为 null

// 正确:等待元素出现再操作
function waitForElement(selector, timeout = 10000) {
  return new Promise((resolve, reject) => {
    const existing = document.querySelector(selector)
    if (existing) return resolve(existing)

    const observer = new MutationObserver(() => {
      const el = document.querySelector(selector)
      if (el) {
        observer.disconnect()
        resolve(el)
      }
    })
    observer.observe(document.documentElement, { childList: true, subtree: true })

    setTimeout(() => {
      observer.disconnect()
      reject(new Error(`元素 ${selector} 在 ${timeout}ms 内未出现`))
    }, timeout)
  })
}

waitForElement(".target").then((el) => el.classList.add("ext-highlight"))

坑 12:跨浏览器 chrome 与 browser 命名空间、Promise 差异

现象:在 Firefox 加载同一份代码,chrome.storage 时有时无;Chrome 里习惯回调风格,到 Firefox 又支持 Promise,写法不统一导致维护混乱。

原因:Chrome 使用 chrome.* 命名空间,历史上以回调风格为主(新版逐步支持 Promise);Firefox 使用 browser.* 命名空间,且原生返回 Promise。两套 API 形态差异导致代码无法直接复用。

解决:引入 webextension-polyfill,统一用 browser.* + Promise 风格,一套代码多浏览器(见技巧 3)。

// 错误:直接用 chrome 回调风格,Firefox 行为不一致
// chrome.storage.local.get("key", (res) => { ... })

// 正确:用 polyfill 统一为 browser.* + Promise
import browser from "webextension-polyfill"

async function readKey() {
  const { key } = await browser.storage.local.get("key") // Chrome / Firefox 一致
  return key
}

二、开发提效技巧

技巧 1:热重载工作流(HMR / 自动重载)

裸写扩展每改一行就手动去 chrome://extensions 点重载、再刷新页面,效率极低。现代脚手架(CRXJS Vite 插件、WXT)提供 HMR:弹出页/选项页支持组件级热替换,改后台或内容脚本时自动重载扩展。

# WXT:约定式目录 + 内置 HMR 与多浏览器构建
npm create wxt@latest my-extension
cd my-extension
npm run dev          # 启动开发服务器,自动重载

# 或 CRXJS(基于 Vite)
npm i -D @crxjs/vite-plugin vite
// vite.config.js(CRXJS 示例)
import { defineConfig } from "vite"
import { crx } from "@crxjs/vite-plugin"
import manifest from "./manifest.json" assert { type: "json" }

export default defineConfig({
  plugins: [crx({ manifest })], // 提供 HMR 与 manifest 处理
})

技巧 2:类型安全的消息封装与统一错误处理

裸用 sendMessage 容易把消息类型写错、忘记 return true、错误无人处理。封装一层类型安全的消息总线,集中处理这些问题。

// messaging.ts
import browser from "webextension-polyfill"

// 定义消息协议:键为消息名,值为 [请求类型, 响应类型]
interface Protocol {
  fetchJson: [{ url: string }, { data: unknown }]
  getCount: [void, { count: number }]
}

type Result<T> = { ok: true; data: T } | { ok: false; error: string }

// 类型安全的发送端
export async function send<K extends keyof Protocol>(
  type: K,
  payload: Protocol[K][0]
): Promise<Result<Protocol[K][1]>> {
  try {
    const res = await browser.runtime.sendMessage({ type, payload })
    return { ok: true, data: res as Protocol[K][1] }
  } catch (err) {
    return { ok: false, error: err instanceof Error ? err.message : String(err) }
  }
}

// 类型安全的注册端,自动处理异步与错误
type Handlers = { [K in keyof Protocol]?: (p: Protocol[K][0]) => Promise<Protocol[K][1]> }

export function registerHandlers(handlers: Handlers) {
  browser.runtime.onMessage.addListener((msg: any) => {
    const handler = handlers[msg.type as keyof Protocol]
    if (!handler) return undefined
    // 返回 Promise,polyfill 会正确保持通道(无需手写 return true)
    return handler(msg.payload).catch((err) => {
      console.error(`[msg:${msg.type}]`, err)
      throw err
    })
  })
}

技巧 3:用 webextension-polyfill 一套代码多浏览器

Mozilla 维护的 webextension-polyfillchrome.* 包装成符合 W3C 草案的 browser.* Promise API,让同一份代码在 Chrome、Edge、Firefox 上行为一致。

npm i webextension-polyfill
npm i -D @types/webextension-polyfill
import browser from "webextension-polyfill"

// 所有 API 统一 Promise 化,跨浏览器一致
const tabs = await browser.tabs.query({ active: true, currentWindow: true })
await browser.storage.sync.set({ theme: "dark" })

技巧 4:环境变量区分 dev/prod 与动态 manifest

开发版与生产版需要不同的扩展名、图标、权限、日志级别。用构建时变量 + 函数式 manifest 生成,避免手工改 manifest 文件。

// manifest.config.ts(WXT / CRXJS 均支持函数式 manifest)
import { defineManifest } from "@crxjs/vite-plugin"

const isDev = process.env.NODE_ENV !== "production"

export default defineManifest({
  manifest_version: 3,
  name: isDev ? "MyExt (DEV)" : "MyExt",
  version: process.env.npm_package_version, // 从 package.json 取版本,单一来源
  permissions: isDev ? ["storage", "tabs"] : ["storage"], // 开发期可多挂调试权限
  action: { default_icon: isDev ? "icon-dev.png" : "icon.png" },
})

技巧 5:用 storage 做 feature flag 与灰度

把功能开关放进 storage,配合后台从远端拉取的配置(注意:拉的是数据不是代码,不违反远程代码禁令),实现按用户百分比灰度、紧急关闭问题功能。

import browser from "webextension-polyfill"

interface Flags {
  newPanelUI: boolean
  experimentalParser: boolean
}

const DEFAULTS: Flags = { newPanelUI: false, experimentalParser: false }

export async function getFlags(): Promise<Flags> {
  const { flags } = await browser.storage.local.get("flags")
  return { ...DEFAULTS, ...(flags as Partial<Flags>) }
}

// 后台定期拉取远端 JSON 配置(数据,非可执行代码)并按用户分桶灰度
export async function refreshFlags(userBucket: number) {
  const remote = await fetch("https://api.example.com/flags.json").then((r) => r.json())
  const flags: Flags = {
    newPanelUI: userBucket < remote.newPanelUI_rolloutPercent,
    experimentalParser: remote.experimentalParser_enabled,
  }
  await browser.storage.local.set({ flags })
}

技巧 6:快速 mock chrome API 做本地组件开发

弹出页/选项页是普通网页,可以脱离扩展环境用 Vite/Storybook 单独开发。为此 mock 掉 chrome.*,让组件不依赖真实运行环境。

// chrome-mock.ts —— 仅在非扩展环境(如 Storybook、组件 dev 服务器)注入
if (typeof chrome === "undefined" || !chrome.storage) {
  const store: Record<string, unknown> = {}
  ;(globalThis as any).chrome = {
    storage: {
      local: {
        get: async (k: string) => ({ [k]: store[k] }),
        set: async (obj: Record<string, unknown>) => Object.assign(store, obj),
      },
    },
    runtime: { sendMessage: async () => ({ mocked: true }) },
  }
}

技巧 7:善用 chrome://extensions 与快捷键

操作 方法
打开扩展管理页 地址栏输入 chrome://extensions
显示重载按钮 右上角打开「开发者模式」(Developer mode)
固定扩展到工具栏 工具栏拼图图标 → 对目标扩展点图钉
给重载/弹出页绑快捷键 chrome://extensions/shortcuts 设置 commands
检查 Service Worker 扩展卡片上的「Service Worker」链接 → 打开专属 DevTools
检查弹出页 打开弹出页后右键「检查」
{
  "commands": {
    "_execute_action": { "suggested_key": { "default": "Alt+Shift+P" } },
    "toggle-feature": {
      "suggested_key": { "default": "Alt+Shift+T" },
      "description": "切换主功能"
    }
  }
}

三、社区标准与上架规范

Chrome Web Store 政策红线

下表是 2026-06-06 核实的核心政策条款及其要求。违反任一条都可能导致审核被拒或上架后下架。

政策 核心要求 常见违规
单一用途(Single Purpose) 扩展必须有「窄而易懂」的单一用途,不得捆绑无关功能 一个扩展里塞翻译 + 截图 + 广告拦截
最小权限(Use of Permissions) 请求实现功能所必需的最窄权限;多个可选时取访问最小者;不得为未实现功能预留权限 只服务单站却申请 <all_urls>
禁止远程代码(Remotely Hosted Code) 扩展完整功能必须能从提交的代码中辨明;禁止 eval、远程 <script>、内联脚本 从 CDN 加载主逻辑、热更新代码
数据有限使用(Limited Use) 收集的数据只能用于实现单一用途;不得卖给广告商/数据经纪人 把浏览历史卖给第三方分析
隐私政策(Privacy Policy) 处理用户数据须提供准确、最新的隐私政策 URL 留空或链接失效
数据披露与认证(Disclosure Requirements) 在开发者后台如实勾选数据收集类型,并取得用户同意 披露与实际行为不符
用户数据透明 不得欺骗、误导用户;不得用诱导式手段刷量 虚假评分、clickbait 描述

审核被拒常见原因清单

  • 权限超过功能所需(最高频)。
  • 隐私政策缺失或与数据披露不一致。
  • 出现远程代码加载(eval、远程脚本、热更新框架)。
  • 单一用途违规:功能过杂或描述与实际不符。
  • 元数据问题:标题/描述含关键词堆砌、误导性截图、与功能无关的品牌词。
  • 最小化原则违规:申请了代码里根本没用到的权限。
  • 仿冒、抄袭、低质量(与已有扩展功能雷同且无差异化价值)。

[!note] 申诉机会
2025 年起的政策更新规定:每次违规仅有一次申诉机会,判定后不可二次申诉。提交前自查比事后申诉划算得多。

Manifest V3 迁移现状与 MV2 停用时间线

到 2026-06-06,MV2 已对所有渠道用户完全移除,新项目必须用 MV3。下表为官方时间线关键节点。

时间 节点
2022-01 Chrome Web Store 停止接收 Public/Unlisted 的新 MV2 扩展
2022-06 停止接收 Private 可见性的新 MV2 扩展
2024-06-03 Beta/Dev/Canary 扩展管理页开始出现 MV2 弃用警告横幅
2024-10-09 稳定渠道开始逐步停用 MV2 扩展;企业可用 ExtensionManifestV2Availability 策略豁免至 2025-06
2025-03-31 全渠道默认停用 MV2,用户仍可临时重新启用
2025-07-24 Chrome 138:彻底停用,用户无法再重新启用 MV2;Chrome 138 是支持 MV2 的最后版本(配合企业策略)
Chrome 139 移除 ExtensionManifestV2Availability 策略,企业豁免终止,MV2 全面退场

结论:当前所有新开发与维护都应基于 MV3。如仍维护历史 MV2 项目,参考 浏览器插件-高级开发指南 中的迁移要点(后台改 Service Worker、webRequestdeclarativeNetRequest、移除远程代码)。

版本号与语义化版本

Chrome 扩展的 version 字段是 1 到 4 段、点分的非负整数(如 1.2.0.3),每段 0 至 65535。建议遵循语义化版本(Semantic Versioning,SemVer)思路:主版本.次版本.修订号

{
  "version": "2.4.1",
  "version_name": "2.4.1-beta"  // 可选:展示给用户的友好版本名,不参与比较
}
  • 主版本(major):破坏性变更(权限大改、数据结构不兼容)。
  • 次版本(minor):向后兼容的新功能。
  • 修订号(patch):向后兼容的缺陷修复。
  • 上传新版本时,version 必须严格大于商店当前版本,否则被拒。

国际化(i18n)

_locales 目录与 chrome.i18n 做多语言,至少为商店描述与 UI 文案提供本地化。

_locales/
  en/messages.json
  zh_CN/messages.json
// _locales/zh_CN/messages.json
{
  "extName": { "message": "我的扩展" },
  "extDesc": { "message": "一个单一用途的示例扩展" }
}
// manifest.json 引用本地化键
{
  "default_locale": "en",
  "name": "__MSG_extName__",
  "description": "__MSG_extDesc__"
}

无障碍(a11y)基本要求

  • 弹出页/选项页可纯键盘操作,Tab 顺序合理,焦点可见。
  • 图标按钮提供 aria-label;图片提供 alt
  • 颜色对比度满足 WCAG AA(正文 ≥ 4.5:1)。
  • 不依赖颜色单独传达状态(同时用文字/图标)。
  • 尊重 prefers-reduced-motion,可关闭动画。

开源插件目录结构与命名惯例

my-extension/
├── src/
│   ├── background/        # Service Worker
│   ├── content/           # 内容脚本
│   ├── popup/             # 弹出页(SPA,用 hash 路由)
│   ├── options/           # 选项页
│   └── lib/               # 共享:messaging、storage、flags
├── public/
│   ├── _locales/          # i18n
│   └── icons/             # 16/32/48/128 多尺寸图标
├── manifest.config.ts     # 动态 manifest(区分 dev/prod)
├── package.json           # version 单一来源
└── README.md

命名惯例:消息类型用动词短语(fetchJsontoggleFeature);storage 键用命名空间前缀(pref:themecache:lastSync)避免冲突;扩展名简洁、不堆砌关键词。

用户隐私与数据收集合规(GDPR 思路)

  • 默认不收集:能在本地处理就不上传。
  • 最小化:只收集实现功能必需的字段。
  • 明确同意:收集前用清晰文案征得用户同意,提供拒绝选项。
  • 可访问/可删除:提供导出与清除数据的入口。
  • 透明:隐私政策说明收集什么、为何收集、是否共享、保留多久。
  • 与商店「数据披露」勾选项严格一致。

四、优雅设计原则(综合)

原则 含义 落地
最小权限 只申请必需权限,能延迟到运行时就用 optional activeTab + optional_host_permissions
单一职责 一个扩展一个用途;模块各司其职 background 管状态,content 管 DOM,UI 只渲染
storage 单一数据源 所有持久状态以 storage 为唯一真相源 UI 订阅 storage.onChanged,不各自缓存
类型安全通信 消息协议用类型约束,错误集中处理 见技巧 2 的 messaging 封装
上下文解耦 各上下文通过消息/storage 协作,不假设对方常驻 SW 随时可能被杀,状态都落盘
渐进增强 核心功能不依赖可选权限,授权后才增强 未授权时降级为只读提示
优雅降级 受保护页、上下文失效时给可见反馈而非崩溃 见坑 4、坑 10
// 优雅设计示例:UI 以 storage 为单一数据源,订阅变更而非自己缓存
import browser from "webextension-polyfill"

function subscribePref<T>(key: string, onChange: (val: T) => void) {
  browser.storage.local.get(key).then((r) => onChange(r[key] as T))
  browser.storage.onChanged.addListener((changes, area) => {
    if (area === "local" && changes[key]) onChange(changes[key].newValue as T)
  })
}

subscribePref<string>("pref:theme", (theme) => {
  document.documentElement.dataset.theme = theme
})

五、上架与维护

发布流程要点

  1. 自查政策:权限最小化、单一用途、隐私政策齐全、无远程代码。
  2. 打包:zip 仅含必要文件(排除 node_modules、源码 map、.git)。
  3. 填写商店元数据:标题、描述、截图(真实功能)、分类、隐私披露。
  4. 上传到开发者后台,付一次性注册费(首次注册账号时)。
  5. 提交审核;首次审核通常需要更长时间。
  6. 审核通过后可选择立即发布或定时发布。

灰度发布与回滚

  • 灰度:开发者后台支持按百分比分批推送新版本(partial rollout),先放量小比例观察。
  • 回滚:发现严重问题时,停止 rollout;商店不支持把已发布版本"降级",需快速发布一个修复版(version 必须更高),紧急情况配合技巧 5 的远端 feature flag 关闭问题功能。

用户反馈与错误监控

  • 在选项页放反馈入口(邮件/表单链接),收集真实问题。
  • 错误监控可接入 Sentry,但有 MV3 特别注意事项:
// 接入 Sentry 的 MV3 注意事项
import * as Sentry from "@sentry/browser"

Sentry.init({
  dsn: "https://[email protected]/0",
  // 注意 1:SDK 必须随包打包(本地依赖),不可远程加载,否则违反远程代码禁令
  // 注意 2:默认的会话/性能追踪可能依赖被 CSP 限制的能力,按需关闭
  autoSessionTracking: false,
  // 注意 3:脱敏,避免上报页面 URL、表单内容等用户隐私
  beforeSend(event) {
    if (event.request) delete event.request.url
    return event
  },
})

[!warning] 监控也要合规
把错误上报到第三方服务等同于"数据传输给第三方",必须在隐私政策中披露,并与商店数据披露勾选一致。默认采集面越小越好。


常见陷阱(提炼小结)

  • 把 Service Worker 当常驻进程:顶层变量、长 setTimeout、内存缓存都会随 SW 终止丢失。状态一律落盘 storage,定时用 alarms
  • 异步响应忘记 return trueonMessage 里做异步却不同步返回 true,发送方永远收不到响应。优先用封装好的 Promise 消息层。
  • 权限"先占着"心态:为未实现功能预留权限、用 <all_urls> 图省事,是审核被拒头号原因。最小权限 + 运行时按需申请。
  • 混淆"远程数据"与"远程代码":拉 JSON 配置做 feature flag 合规,拉可执行脚本/eval 违规且被 CSP 拦截。
  • 假设 DOM 已就绪:CSR 站点用 querySelector 直取常为 null,应以 MutationObserver 等待节点出现。
  • 扩展页用 history 路由chrome-extension:// 无服务器 fallback,SPA 必须用 hash 路由。

参见

阅读更多

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