> ## Content Index
> Fetch the complete content index at: https://blog.vercanti.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# 浏览器插件-避坑与开发技巧
- URL: https://blog.vercanti.com/liu-lan-qi-cha-jian-bi-keng-yu-kai-fa-ji-qiao/
- Published: 2026-08-28T14:35:32.000Z
- Updated: 2026-08-28T14:59:01.000Z
- Description: 本篇是浏览器插件（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/) 中的通信、存储、权限知识，聚焦于把"能跑"的插件做成"可上架、可维护、可信任"的产品。 全文分为四部分：高频深坑详解（现象/原因/解决三段式）、
- Author: yellowdog
- Tags: 前端开发, 浏览器插件开发

> \[!quote\] 文档信息
> 
> - 官方文档：  
>  - Chrome Web Store 开发者政策（Program Policies）：<https://developer.chrome.com/docs/webstore/program-policies>
>  - 政策条款全文（Policies）：<https://developer.chrome.com/docs/webstore/program-policies/policies>
>  - Manifest V2 停用时间线：<https://developer.chrome.com/docs/extensions/develop/migrate/mv2-deprecation-timeline>
>  - 扩展开发文档：<https://developer.chrome.com/docs/extensions>
> - 适用版本：Manifest V3（MV3）
> - 核实日期：2026-06-06

本篇是浏览器插件（browser extension）系列的"经验篇"。它假设你已经掌握 [浏览器插件-基础概念与架构模型](https://blog.vercanti.com/liu-lan-qi-cha-jian-ji-chu-gai-nian-yu-jia-gou-mo-xing/) 中的组件模型与 [浏览器插件-中级开发指南](https://blog.vercanti.com/liu-lan-qi-cha-jian-zhong-ji-kai-fa-zhi-nan-manifest-v3-vite-vue/) 中的通信、存储、权限知识，聚焦于把"能跑"的插件做成"可上架、可维护、可信任"的产品。

全文分为四部分：高频深坑详解（现象/原因/解决三段式）、开发提效技巧、社区标准与上架规范、优雅设计原则。所有政策表述以 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）。

```js
// 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）。

```js
// 方式一：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"  // 默认值，扩展私有环境
    }
  ]
}

```

```js
// 方式二：从隔离世界用 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）

**现象**：消息监听里做了异步操作（`fetch`、`storage` 回调、`await`），在异步完成后调用 `sendResponse`，但发送方的回调永远拿不到响应，控制台偶现 "message port closed before a response was received"。

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

**解决**：监听器写成普通函数，内部做异步，结尾 `return true`；或用 Promise 链。

```js
// 错误：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 失联事件。

```js
// 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 路由不触碰协议层路径）。

```js
// 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

```

```jsx
// 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` 收窄暴露面。

```json
{
  "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` 运行时按需申请。

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

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

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

  // 其余站点改为运行时按需申请
  "optional_host_permissions": ["https://*.optional-site.com/*"]
}

```

```js
// 运行时按需申请可选权限（须由用户手势触发，例如点击按钮）
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 exceeded` 或 `MAX_WRITE_OPERATIONS_PER_MINUTE`，部分写入静默失败。

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

**解决**：大数据/高频数据放 `local`（容量大得多），`sync` 只放小而关键的用户偏好；高频写入做防抖（debounce）合并。

```js
// 错误：把大数组高频写入 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">` 加载第三方库无效；用了 `eval` 或 `new Function` 抛 CSP 错误；内联 `<script>` 不执行。

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

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

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

<!-- 正确：依赖随包打包到本地 -->
<script src="vendor/lib.min.js"></script>

```

```js
// 错误：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://extensions`、`chrome://settings`、Chrome Web Store 页面、`view-source:`、其它扩展页面上，内容脚本不注入、脚本注入 API 报错。

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

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

```js
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 已就绪。

```js
// 错误：假设元素已存在
// 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）。

```js
// 错误：直接用 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：弹出页/选项页支持组件级热替换，改后台或内容脚本时自动重载扩展。

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

# 或 CRXJS（基于 Vite）
npm i -D @crxjs/vite-plugin vite

```

```js
// 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`、错误无人处理。封装一层类型安全的消息总线，集中处理这些问题。

```ts
// 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-polyfill` 把 `chrome.*` 包装成符合 W3C 草案的 `browser.*` Promise API，让同一份代码在 Chrome、Edge、Firefox 上行为一致。

```bash
npm i webextension-polyfill
npm i -D @types/webextension-polyfill

```

```ts
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 文件。

```ts
// 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`，配合后台从远端拉取的配置（注意：拉的是**数据**不是代码，不违反远程代码禁令），实现按用户百分比灰度、紧急关闭问题功能。

```ts
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.*`，让组件不依赖真实运行环境。

```ts
// 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  |
| 检查弹出页             | 打开弹出页后右键「检查」                              |

```json
{
  "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 项目，参考 [浏览器插件-高级开发指南](https://blog.vercanti.com/liu-lan-qi-cha-jian-gao-ji-kai-fa-zhi-nan-manifest-v3/) 中的迁移要点（后台改 Service Worker、`webRequest` 改 `declarativeNetRequest`、移除远程代码）。

### 版本号与语义化版本

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

```json
{
  "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

```

```json
// _locales/zh_CN/messages.json
{
  "extName": { "message": "我的扩展" },
  "extDesc": { "message": "一个单一用途的示例扩展" }
}

```

```json
// 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

```

命名惯例：消息类型用动词短语（`fetchJson`、`toggleFeature`）；storage 键用命名空间前缀（`pref:theme`、`cache:lastSync`）避免冲突；扩展名简洁、不堆砌关键词。

### 用户隐私与数据收集合规（GDPR 思路）

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

---

## 四、优雅设计原则（综合）

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

```ts
// 优雅设计示例：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 特别注意事项：

```ts
// 接入 Sentry 的 MV3 注意事项
import * as Sentry from "@sentry/browser"

Sentry.init({
  dsn: "https://example@o0.ingest.sentry.io/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 true`**：`onMessage` 里做异步却不同步返回 `true`，发送方永远收不到响应。优先用封装好的 Promise 消息层。
- **权限"先占着"心态**：为未实现功能预留权限、用 `<all_urls>` 图省事，是审核被拒头号原因。最小权限 + 运行时按需申请。
- **混淆"远程数据"与"远程代码"**：拉 JSON 配置做 feature flag 合规，拉可执行脚本/`eval` 违规且被 CSP 拦截。
- **假设 DOM 已就绪**：CSR 站点用 `querySelector` 直取常为 `null`，应以 `MutationObserver` 等待节点出现。
- **扩展页用 history 路由**：`chrome-extension://` 无服务器 fallback，SPA 必须用 hash 路由。

---

## 参见

- [浏览器插件开发完全指南](https://blog.vercanti.com/liu-lan-qi-cha-jian-kai-fa-wan-quan-zhi-nan-vite-vue-manifest-v3/)
- [浏览器插件-基础概念与架构模型](https://blog.vercanti.com/liu-lan-qi-cha-jian-ji-chu-gai-nian-yu-jia-gou-mo-xing/)
- [浏览器插件-设计模式与优雅架构](https://blog.vercanti.com/liu-lan-qi-cha-jian-she-ji-mo-shi-yu-you-ya-jia-gou/)
- [浏览器插件-中级开发指南](https://blog.vercanti.com/liu-lan-qi-cha-jian-zhong-ji-kai-fa-zhi-nan-manifest-v3-vite-vue/)
- [浏览器插件-高级开发指南](https://blog.vercanti.com/liu-lan-qi-cha-jian-gao-ji-kai-fa-zhi-nan-manifest-v3/)
- [浏览器插件-调试与排错手册](https://blog.vercanti.com/liu-lan-qi-cha-jian-diao-shi-yu-pai-cuo-shou-ce/)