浏览器插件调试与排错手册
本文是"浏览器插件开发"系列的 Debug 篇,聚焦如何对每个上下文(context)打开开发者工具(DevTools)、如何读懂 chrome://extensions 的错误面板、高频错误的准确成因与解决方案,以及一套可落地的系统化排查流程。架构与 API 细节见系列其他篇目(文末"参见")。 MV3 插件由若干彼此隔离的 JavaScript 执行环境(execution context)组成,每个环境有独立的全局对象、独立的 DevTools 入口、独立的 console。调试第一步永远是确认报错发生在哪个上下文,因为同名 API 在不同上下文里
[!info] 文档信息
- 官方文档:
- 调试教程 Debug extensions
- 消息传递 Message passing
- Service Worker 生命周期 Service worker lifecycle
- 错误处理 runtime.lastError
- declarativeNetRequest Debug DNR rules
- 适用版本:Manifest V3(MV3) / Chrome 138+
- 核实日期:2026-06-06
- 技术栈:Vite + Vue 3 + CRXJS(或
@samrum/vite-plugin-web-extension)
本文是"浏览器插件开发"系列的 Debug 篇,聚焦如何对每个上下文(context)打开开发者工具(DevTools)、如何读懂 chrome://extensions 的错误面板、高频错误的准确成因与解决方案,以及一套可落地的系统化排查流程。架构与 API 细节见系列其他篇目(文末"参见")。
一、插件的多上下文调试模型
MV3 插件由若干彼此隔离的 JavaScript 执行环境(execution context)组成,每个环境有独立的全局对象、独立的 DevTools 入口、独立的 console。调试第一步永远是确认报错发生在哪个上下文,因为同名 API 在不同上下文里的可用性与行为并不一致。
| 上下文(context) | 全局对象 | 典型职责 | DevTools 入口 |
|---|---|---|---|
| Service Worker(后台,背景脚本) | self(ServiceWorkerGlobalScope,无 window/document) |
事件中枢、长任务、网络拦截 | chrome://extensions → 插件卡片 → "Service Worker" 链接 |
| Popup(弹窗) | window |
操作面板 UI | 右键插件图标弹窗 → 检查 |
| Options(选项页) | window |
设置页 | 页面内右键 → 检查 |
| Side Panel(侧边栏) | window |
常驻侧栏 UI | 侧栏内右键 → 检查 |
| Content Script(内容脚本) | 与宿主页共享 window(隔离世界 isolated world) |
操作宿主 DOM | 宿主网页 DevTools → Sources → Content scripts |
| DevTools 页面 | window |
扩展 DevTools 面板 | 对 DevTools 窗口再开一层 DevTools |
| Offscreen Document(离屏文档) | window(不可见) |
DOM/剪贴板/音频等 SW 缺失能力 | chrome://extensions → "检查视图" 列出的离屏页 |
1.1 Service Worker
- 打开
chrome://extensions,确认右上角 开发者模式(Developer mode) 已开启。 - 找到目标插件卡片,点击蓝色的 "Service Worker" 链接(无激活时显示 "Service worker (inactive)",点击即唤醒并打开 DevTools)。
- 在打开的 DevTools 中查看
Console日志、在Sources下断点、在Network抓 SW 内fetch。 - 手动唤醒/终止:另一种入口是
chrome-extension://<扩展ID>/manifest.json→ 检查 →Application面板 →Service Workers窗格,里面有Start/Stop/skipWaiting按钮,可手动启停以验证休眠后行为。 - 关键事实:打开 SW 的 DevTools 会让 SW 一直保持存活。要验证"SW 被终止后插件仍正常",必须先关闭这个 DevTools,或在
Service Workers窗格点Stop后再触发事件。
1.2 Popup
- 在浏览器工具栏右键插件图标 → 弹出弹窗后右键 → 检查(Inspect),即可对 popup 开 DevTools。注意弹窗一旦失焦就会关闭,所以要在弹窗上直接右键。
- 也可独立打开:地址栏访问
chrome-extension://<扩展ID>/src/popup/index.html(路径以构建产物为准),此时它作为普通标签页运行,便于稳定调试,但chrome.action相关 API 行为与真实弹窗略有差异。 - 调试 popup 打开瞬间的网络请求:在 DevTools
Network面板内点刷新,可重载 popup 而不关闭 DevTools。
1.3 Options 与 Side Panel
- 二者都是普通 HTML 页面,直接在页面内右键 → 检查即可。
- Options 若以独立标签页打开(
open_in_tab: true)或嵌入在chrome://extensions详情内(嵌入式 options),调试方式相同。
1.4 Content Script
- 打开宿主网页(注入目标页面),按 F12 打开该页面的 DevTools。
- 内容脚本运行在隔离世界,其源码位于
Sources面板 → 左侧Content scripts分组下,按断点调试与普通脚本一致。 Console默认在宿主页主世界(top)执行。要在内容脚本的隔离世界里执行表达式或读取其变量,点击 Console 左上角的上下文下拉框(默认top),切换到<你的插件名>对应的扩展上下文。- 内容脚本的
console.log出现在宿主页的 Console,不在chrome://extensions;只有 runtime error、console.warn、console.error才会被记录到插件的错误面板。
1.5 DevTools 页面
扩展自定义的 DevTools 面板(devtools_page)本身运行在 DevTools 窗口里。调试方法是对 DevTools 再开一层 DevTools:让 DevTools 窗口获得焦点后按 Ctrl+Shift+I(或撤离停靠后右键检查),即可调试 devtools_page 脚本。
二、chrome://extensions 开发者模式功能
| 功能 | 位置 | 用途 |
|---|---|---|
| 开发者模式开关 | 页面右上角 Toggle | 启用"加载已解压""打包"等开发功能 |
| 加载已解压的扩展程序 | 左上角按钮 | 加载本地构建产物目录(dist/) |
| 重新加载(Reload) | 卡片上的 ↻ 图标 | 改完 manifest/SW 后重载,使变更生效 |
| 错误(Errors) | 卡片上的红色 "错误" 按钮 | 查看 SW、popup 等记录的 runtime 错误堆栈;红色表示有未清理错误 |
| 全部清除(Clear all) | 错误面板内 | 清空历史错误;旧错误不会自动消失,需手动清 |
| 检查视图(Inspect views) | 卡片"检查视图"区 | 列出当前所有活动上下文(SW、离屏页等),点击即开对应 DevTools |
| 详细信息(Details) | 卡片按钮 | 查看权限、站点访问、源文件 |
[!warning] 重载范围
改动 manifest.json、Service Worker、声明式规则文件 后必须点 ↻ 重载。仅改内容脚本/popup 的源码时,构建工具的 HMR 通常能热更新,但内容脚本的逻辑变更往往仍需重载插件并刷新宿主页才能彻底生效。
三、错误速查表
错误文本以 Chrome 138+ 控制台/错误面板实际输出为准。下表"原因"列描述根因,"解决"列给可执行动作。
| 错误信息 | 出现位置 | 原因 | 解决 |
|---|---|---|---|
Unchecked runtime.lastError: Could not establish connection. Receiving end does not exist. |
SW/popup 发消息时 | 目标上下文没有 onMessage 监听器:内容脚本未注入该页、SW 未唤醒、发给了不存在的 tab,或时序上接收端尚未就绪 |
确认目标页已注入内容脚本(chrome://extensions 看 host 权限);发送前用 chrome.scripting.executeScript 兜底注入;用安全包装捕获 lastError(见 4.2);对 chrome://、Web Store 等受限页不发消息 |
The message port closed before a response was received. |
sendMessage 回调 |
onMessage 监听器要异步 sendResponse 却没 return true 保持通道;或监听器内抛错、Promise reject;或接收端在响应前卸载 |
异步响应的监听器必须 return true(或返回 Promise);确保所有分支都调用 sendResponse;用 try/catch 包裹监听器逻辑 |
Extension context invalidated. |
内容脚本 | 插件被重载/更新后,旧内容脚本仍残留在已打开的页面里(orphaned),其 chrome.runtime 已失效,再调用任何扩展 API 即报错 |
调用前先检测 chrome.runtime?.id 是否存在(见 4.1);失效时移除监听器/定时器并提示刷新页面;开发期重载后刷新宿主标签页 |
Manifest version 2 is deprecated, and support will be removed in 2024. See https://developer.chrome.com/.../mv2-sunset / 拒绝加载 MV2 |
加载插件时 | manifest 仍为 "manifest_version": 2;Chrome 138 已停止对 MV2 的支持 |
迁移到 MV3:manifest_version: 3、background.service_worker、action、host_permissions 等(见迁移指南) |
Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'" |
popup/options/扩展页 | MV3 扩展页 CSP 默认禁止内联 <script>…</script>、内联事件属性(onclick=)、eval |
把脚本拆到外部 .js 文件并用 <script src> 引入;事件用 addEventListener 绑定;Vue 模板正常,但勿用内联 on* HTML 属性 |
Service worker registration failed. Status code: 15 |
加载/重载插件 | 状态码 15 = 脚本求值阶段抛错:SW 顶层代码访问了 window/document、调用了不存在的 API、import 路径错误、或顶层同步异常 |
点 "Service Worker" 链接看真实堆栈;移除 DOM 依赖;顶层 import 用静态 import(type module)或 importScripts;用 try/catch 包裹定位 |
Service worker registration failed. Status code: 3 |
加载/重载插件 | 状态码 3 = 找不到 SW 脚本文件:background.service_worker 路径与构建产物不符 |
核对 manifest 中路径相对扩展根目录正确;确认构建确实产出了该文件;CRXJS 下让插件自动生成路径 |
Cannot access contents of url "https://...". Extension manifest must request permission to access this host. |
内容脚本注入/scripting/tabs |
未在 host_permissions 或内容脚本 matches 中声明该域;或用户未在"站点访问"中授权 |
在 manifest 添加对应 host_permissions/matches;动态注入前确保有 activeTab 或显式 host 权限;引导用户授予站点访问 |
Unchecked runtime.lastError: <任意消息> |
任意异步 API 回调 | 上一个 chrome.* 异步调用失败但回调里没有读取 chrome.runtime.lastError,Chrome 自动告警 |
在回调首行检查 if (chrome.runtime.lastError) {…};或改用 Promise 形式 await 并 try/catch |
net::ERR_BLOCKED_BY_RESPONSE / 资源 404 / Denying load of chrome-extension://…/foo.png. Resources must be listed in the web_accessible_resources manifest key. |
宿主页加载扩展资源 | 内容脚本/宿主页引用了扩展内文件(图片、字体、注入脚本),但未在 web_accessible_resources 声明 |
在 manifest 的 web_accessible_resources 中以 { resources: [...], matches: [...] } 声明这些文件 |
Uncaught ReferenceError: importScripts is not defined / Cannot use import statement outside a module |
SW | SW 用了 importScripts 但被打包成 ESM;或用了 ESM import 但 manifest 未声明 "type": "module" |
二选一保持一致:要么 background: { service_worker: "sw.js", type: "module" } 配合静态 ESM import;要么用经典脚本 + importScripts()。MV3 SW 不支持顶层动态 import() 拉远程模块 |
Failed to fetch dynamically imported module / 动态 import 失败 |
SW | SW 内 import() 动态导入在某些时机(休眠后)不可靠,或导入了未打包进扩展的资源 |
将依赖静态导入到 SW 顶层由打包器内联;必需的代码分割放到 offscreen 文档执行 |
[vite] failed to connect to websocket / WebSocket connection to 'ws://localhost:5173' failed / HMR 不生效 |
宿主页/popup 控制台 | CRXJS/Vite HMR 的 WS 端口被占用、server.port 与实际不符、HTTPS 站点拦截 ws://、或扩展页 CSP 未放行 dev server |
固定 server.port 与 server.strictPort: true;确认无其他进程占端口;HTTPS 宿主页用 server.hmr 配置或改在 http 页面调试;CRXJS 会注入所需 CSP,升级到匹配 Vite 版本的 CRXJS |
Error: Could not load manifest. / Manifest is not valid JSON |
加载插件 | manifest 语法错误、字段类型错误、引用了不存在的图标 | 用 JSON 校验;核对 icons、action.default_icon 路径存在 |
This request has been blocked; the content must be served over HTTPS. (mixed content) |
内容脚本/popup | 在 HTTPS 上下文请求了 http:// 资源 |
全部改用 https://;SW 内 fetch 同理 |
Permission '<api>' is unknown or URL pattern is malformed. |
加载插件 | permissions 写了 MV3 不存在的权限名,或 host 模式写在 permissions 而非 host_permissions |
校对权限名拼写;host 模式移到 host_permissions |
Cannot read properties of undefined (reading 'onClicked') 等 chrome.* 为 undefined |
SW | 用了该上下文不可用的 API(如 SW 里访问 chrome.action.onClicked 需先声明 action),或权限缺失导致命名空间未注入 |
声明对应 manifest 字段/权限;用前判空;查 API 的可用上下文 |
Refused to load the script '<远程URL>' because it violates ... script-src |
扩展页 | MV3 禁止扩展页加载远程代码 | 远程逻辑改为数据/配置下发,代码全部打包进扩展 |
Storage quota exceeded / QUOTA_BYTES_PER_ITEM quota exceeded |
任意上下文 | chrome.storage.sync 单项超 8KB 或总量超限 |
大数据用 chrome.storage.local(默认更大,可申请 unlimitedStorage);拆分/压缩数据 |
Tabs cannot be edited right now (user may be dragging a tab). |
tabs API | 用户正在拖拽标签时操作 tabs | 捕获错误后重试;避免在拖拽期间批量操作 |
Unchecked runtime.lastError: No tab with id: <n>. |
tabs/scripting | 目标 tab 已关闭或 id 失效 | 操作前 chrome.tabs.get 校验存在;捕获错误忽略已关闭的 tab |
四、诊断代码片段
4.1 检测内容脚本上下文是否失效
内容脚本被插件重载后会变成"孤儿",此时访问 chrome.runtime.id 会抛错或为 undefined。在每次调用扩展 API 前判活。
// content-script.ts —— 上下文有效性探测
/** 判断当前内容脚本是否仍连接到有效的扩展上下文 */
export function isExtensionContextValid(): boolean {
try {
// chrome.runtime 为惰性初始化,用可选链避免读取时抛错
return Boolean(chrome.runtime?.id)
} catch {
// 访问已失效的 runtime 会直接抛 "Extension context invalidated."
return false
}
}
/** 失效时自我清理:移除监听器与定时器,避免后续报错刷屏 */
function teardownOnInvalidContext(cleanup: () => void): void {
const timer = setInterval(() => {
if (!isExtensionContextValid()) {
clearInterval(timer)
cleanup() // 解绑 DOM 事件、断开 MutationObserver、停止定时任务
console.warn('[content] 扩展上下文已失效,请刷新页面以重新注入')
}
}, 2000)
}
4.2 安全的 sendMessage 包装
统一捕获 runtime.lastError,把"接收端不存在""端口关闭"等转成可控的 null 返回,避免 Unchecked 告警刷屏。
// messaging.ts —— 安全消息发送
export interface SafeResult<T> {
ok: boolean
data?: T
error?: string
}
/** 向 SW/其他上下文发消息,永不抛出未捕获错误 */
export function safeSendMessage<T = unknown>(
message: unknown,
): Promise<SafeResult<T>> {
return new Promise((resolve) => {
try {
chrome.runtime.sendMessage(message, (response: T) => {
const err = chrome.runtime.lastError // 必须在回调同步阶段读取
if (err) {
resolve({ ok: false, error: err.message })
return
}
resolve({ ok: true, data: response })
})
} catch (e) {
// 上下文失效时 sendMessage 本身会同步抛错
resolve({ ok: false, error: (e as Error).message })
}
})
}
/** 向指定 tab 的内容脚本发消息,附带兜底注入 */
export async function sendToTab<T = unknown>(
tabId: number,
message: unknown,
): Promise<SafeResult<T>> {
return new Promise((resolve) => {
chrome.tabs.sendMessage(tabId, message, (response: T) => {
const err = chrome.runtime.lastError
resolve(err ? { ok: false, error: err.message } : { ok: true, data: response })
})
})
}
接收端正确写法(异步响应必须 return true):
// service-worker.ts
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg?.type === 'FETCH_USER') {
// 异步分支:返回 true 让消息通道保持开启,直到 sendResponse 被调用
fetchUser(msg.id)
.then((user) => sendResponse({ ok: true, user }))
.catch((e) => sendResponse({ ok: false, error: String(e) })) // 所有分支都要回应
return true // 关键:缺它会触发 "message port closed before a response"
}
// 同步分支无需 return true
})
4.3 统一 logger 封装(按上下文打标签 + 生产关闭)
// logger.ts —— 跨上下文统一日志
type Level = 'debug' | 'info' | 'warn' | 'error'
// 构建期由 Vite 注入;生产构建时为 false,日志自动消失(配合 tree-shaking)
const ENABLED = import.meta.env.DEV
/** 运行时探测当前上下文,给每条日志打标签 */
function detectContext(): string {
if (typeof window === 'undefined') return 'SW' // service worker 无 window
if (location.protocol === 'chrome-extension:') {
if (location.pathname.includes('popup')) return 'POPUP'
if (location.pathname.includes('options')) return 'OPTIONS'
if (location.pathname.includes('sidepanel')) return 'PANEL'
return 'EXT-PAGE'
}
return 'CONTENT' // 注入到宿主页
}
const TAG = detectContext()
function emit(level: Level, ...args: unknown[]): void {
if (!ENABLED && level === 'debug') return
const prefix = `%c[${TAG}]`
const style = level === 'error' ? 'color:#e55' : 'color:#39c'
console[level](prefix, style, ...args)
}
export const logger = {
debug: (...a: unknown[]) => emit('debug', ...a),
info: (...a: unknown[]) => emit('info', ...a),
warn: (...a: unknown[]) => emit('warn', ...a),
error: (...a: unknown[]) => emit('error', ...a),
}
4.4 storage 调试:监听 onChanged 打印 diff
// storage-debug.ts —— 任意上下文均可监听
import { logger } from './logger'
chrome.storage.onChanged.addListener((changes, areaName) => {
for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
logger.debug(`storage[${areaName}] ${key}`, { old: oldValue, new: newValue })
}
})
/** 一次性 dump 某个区域全部内容(在任意上下文 console 直接调用) */
export async function dumpStorage(area: 'local' | 'sync' = 'local') {
const all = await chrome.storage[area].get(null) // null = 取全部
console.table(all)
return all
}
4.5 declarativeNetRequest 规则匹配调试
getMatchedRules 与 onRuleMatchedDebug 仅对已解压扩展且声明 declarativeNetRequestFeedback 权限时可用。getMatchedRules 有配额(10 分钟内最多 20 次,用户手势触发不计入)。
// dnr-debug.ts —— manifest 需含 "declarativeNetRequestFeedback" 权限
import { logger } from './logger'
// 仅未打包扩展可用:每命中一条规则就回调,适合开发期观察
if (chrome.declarativeNetRequest.onRuleMatchedDebug) {
chrome.declarativeNetRequest.onRuleMatchedDebug.addListener((info) => {
logger.debug('DNR matched', {
rule: info.rule, // { rulesetId, ruleId }
url: info.request.url,
type: info.request.type,
})
})
}
/** 把扩展拦截命中数显示为 action 徽章,直观看到规则是否生效 */
chrome.declarativeNetRequest.setExtensionActionOptions({
displayActionCountAsBadgeText: true,
})
/** 主动查询最近匹配的规则 */
export async function queryMatched(tabId?: number) {
const { rulesMatchedInfo } =
await chrome.declarativeNetRequest.getMatchedRules(tabId ? { tabId } : {})
console.table(rulesMatchedInfo.map((m) => ({
ruleId: m.rule.ruleId,
ruleset: m.rule.rulesetId,
ts: new Date(m.timeStamp).toISOString(),
})))
}
[!tip] 收集错误
规则文件(rule_resourcesJSON)语法/字段错误不会进 SW console,而是显示在chrome://extensions卡片的 "错误" 面板("Rule with id N specifies an invalid …")。声明式规则报错优先看那里。
4.6 给消息加 trace 与 sender 检查
// service-worker.ts
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
// sender 用于安全校验:拒绝来源不明的消息
logger.debug('recv', {
type: msg?.type,
fromTab: sender.tab?.id, // 来自内容脚本时有值
fromUrl: sender.url, // 发送方页面 URL
fromExtId: sender.id, // 应等于 chrome.runtime.id
frameId: sender.frameId, // 0 = 主框架
})
if (sender.id !== chrome.runtime.id) {
return // 非本扩展消息,忽略
}
// ……业务分支
})
五、网络调试
- 抓 SW 的 fetch:在 SW 的 DevTools
Network面板查看后台发起的请求。注意 SW 休眠会清空面板,调试时让 DevTools 保持打开以维持存活。 - 抓内容脚本/宿主页请求:在宿主页 DevTools
Network查看,内容脚本发起的fetch以宿主页 origin 出现。 - 声明式拦截调试:用 5.4.5 的
onRuleMatchedDebug+ 徽章计数确认规则是否命中;用getMatchedRules回溯历史命中。 - 被拦截的扩展资源:宿主页 console 出现
Denying load of chrome-extension://…时,去补web_accessible_resources。 - CORS:MV3 下跨域请求由 SW 发起最稳妥(SW 拥有
host_permissions声明域的扩展级 CORS 豁免);内容脚本里的跨域fetch受宿主页 CORS 约束,应改为转发给 SW 代发。
六、Source Map 与 Vite 构建产物调试
- 开启 source map:开发期
vite默认提供 inline source map;生产排错时在build.sourcemap: true(或'hidden')下构建,使chrome://extensions错误面板的堆栈能映射回 Vue/TS 源码。 - 定位真实文件:DevTools
Sources左侧若只看到打包后的assets/xxx.js,确认 source map 已加载(文件名旁有源映射标记),否则断点会落在压缩代码上。 - CRXJS 产物结构:CRXJS 会改写 manifest 路径并为 SW 生成 loader,调试 SW 时入口可能是生成的
service-worker-loader.js,真实逻辑在它 import 的模块里。 - Vue 组件调试:popup/options 是标准 SPA,可正常用 Vue DevTools 扩展;但 Vue DevTools 无法挂到另一个扩展的 popup,需用独立标签页方式(1.2)打开后再连。
- HMR 与内容脚本:内容脚本逻辑改动经常需要重载插件 + 刷新宿主页;样式/Vue 模板类改动通常可 HMR 热更。HMR 失败先查 6 节与错误速查表的 Vite WS 行。
七、复现与隔离
- 最小复现(minimal reproduction):把插件裁剪到只剩触发 bug 的最小 manifest + 一个 SW + 一段脚本,排除无关代码干扰。
- 关掉其他插件:其他扩展可能注入冲突的内容脚本或抢占快捷键/DNR 规则。在
chrome://extensions临时禁用全部其他插件复测。 - 全新 profile:用
chrome --user-data-dir=<临时目录>启动一个干净 profile,排除缓存、已损坏的 storage、企业策略的影响。 - 无痕/访客模式:确认问题是否与已有登录态/Cookie 相关(需在插件详情勾选"在无痕模式下启用")。
- 多 Chrome 通道:在 Stable / Beta / Canary 对比,区分是自身 bug 还是浏览器回归。
八、最佳实践(调试相关)
-
每个上下文统一接入 logger 并打上下文标签。 跨上下文 bug 最难的是分不清日志来自哪里。用 4.3 的
detectContext()自动给[SW]/[CONTENT]/[POPUP]前缀,一眼定位。import { logger } from './logger' logger.info('init done') // 输出 [POPUP] init done -
所有
chrome.*异步调用都走 Promise + try/catch,杜绝裸回调。 既能用await写顺序逻辑,又强制处理lastError,从根上消灭 "Unchecked runtime.lastError"。try { const tab = await chrome.tabs.get(tabId) await chrome.tabs.sendMessage(tab.id!, { type: 'PING' }) } catch (e) { logger.warn('tab 不可达', e) // 已关闭/无内容脚本时安全降级 } -
内容脚本每次跨边界调用前判活,并在失效时自卸载。 开发期插件频繁重载,孤儿脚本会狂刷 "Extension context invalidated"。用 4.1 的
isExtensionContextValid()守门。if (!isExtensionContextValid()) return // 直接跳过,避免抛错 chrome.runtime.sendMessage({ type: 'SYNC' }) -
异步
onMessage监听器永远显式return true(或返回 Promise),且每个分支都回应。 这是 "message port closed" 的唯一根治法。统一封装一个 handler 注册器以免遗漏。chrome.runtime.onMessage.addListener((msg, _s, sendResponse) => { handle(msg).then(sendResponse).catch((e) => sendResponse({ error: String(e) })) return true // 保持通道开启 }) -
生产构建用编译期常量关闭 debug 日志,而非运行时 if。
import.meta.env.DEV在 Vite 生产构建为false,配合 tree-shaking 让debug日志被整段删除,既不泄露内部信息也不占体积。if (import.meta.env.DEV) logger.debug('internal state', state) -
验证 SW 休眠行为时主动 Stop,而非等它自然休眠。 在
Application → Service Workers点Stop,再触发事件,能在几秒内复现"冷启动丢失内存态"类 bug,而不必等 30 秒空闲超时。 -
声明式规则与 manifest 错误先看错误面板,再看 console。 这两类错误不进 SW console,只进
chrome://extensions的"错误"面板;养成改完 manifest/规则先点红色"错误"按钮的习惯。
九、常见陷阱
与第三节错误速查表互补,这里收录不一定报错但行为异常的坑。
-
现象:SW 里
setTimeout/setInterval到点不触发,全局变量莫名归零。
原因:MV3 SW 是事件驱动的,空闲约 30 秒即被终止,定时器与内存态随之销毁;事件再来时是全新实例。
解决:跨唤醒的状态写chrome.storage;定时任务用chrome.alarms(最小周期 30 秒以上)替代setInterval;不要依赖 SW 顶层的可变全局变量。 -
现象:打开 SW 的 DevTools 时一切正常,关掉后插件就出问题。
原因:打开 SW DevTools 会强制 SW 保持存活,掩盖了"休眠后丢状态/监听器没在顶层注册"的 bug。
解决:所有事件监听器(onMessage、onInstalled、onAlarm等)必须在 SW 顶层同步注册,不能放在某个异步回调里;关掉 DevTools 后用真实场景回归测试。 -
现象:
console.log(obj)在 DevTools 里展开看到的值,和实际逻辑用到的值对不上。
原因:DevTools 对对象是惰性求值,展开时读取的是当时的最新状态,而非 log 那一刻的快照;异步代码里对象可能已被改写。
解决:需要快照时打印深拷贝console.log(structuredClone(obj))或JSON.parse(JSON.stringify(obj)),避免被后续 mutation 误导。 -
现象:内容脚本里能
console.log出宿主页变量,但window.someLib永远undefined。
原因:内容脚本运行在隔离世界,与宿主页共享 DOM 但不共享 JS 全局,拿不到页面自己的 JS 变量/库。
解决:需访问页面 JS 时,通过web_accessible_resources注入一段<script>到主世界(main world),或用 MV3 的world: "MAIN"内容脚本,再经window.postMessage与隔离世界通信。 -
现象:popup 里发起的请求/操作"莫名其妙中断",没有任何错误。
原因:popup 失焦即销毁,其 DOM、JS、未完成的fetch/Promise 全部连同上下文一起消失。
解决:长任务交给 SW 执行,popup 只发起并通过storage/消息读结果;调试时用独立标签页(1.2)方式打开 popup 以防自动关闭。
十、系统化排查 Checklist
按顺序执行,多数问题在前几步即可定位。
- 确认上下文:错误发生在 SW / popup / options / 内容脚本 / DevTools 页中的哪个?打开对应 DevTools。
- 读错误面板:
chrome://extensions→ 目标插件 → 红色"错误"按钮,看完整堆栈与触发时间;记下后点"全部清除"再复现,确保看到的是最新错误。 - 确认插件是否最新:改过 manifest/SW/规则文件?点 ↻ 重载;改过内容脚本?重载 + 刷新宿主页。
- 核对权限:报"Cannot access""permission"类 → 检查
host_permissions/permissions/matches/ 站点访问授权。 - 核对资源声明:报资源 blocked/Denying load → 检查
web_accessible_resources。 - 检查 SW 存活与监听器位置:监听器是否在 SW 顶层同步注册?关掉 SW DevTools 后还正常吗?用
Application → Service Workers的 Stop 测冷启动。 - 检查消息链路:发送端用安全包装(4.2)确认是 "receiving end does not exist"(接收端缺失/未注入)还是 "port closed"(接收端没
return true)。 - 检查 storage 一致性:用
dumpStorage()(4.4)确认数据确实写入;用onChanged看 diff 是否符合预期。 - 检查网络层:SW/宿主页 Network 面板看请求是否发出、状态码、CORS;DNR 用徽章计数与
getMatchedRules确认拦截。 - 隔离环境复现:关其他插件、换全新 profile、做最小复现,区分自身 bug 与环境/冲突。
- 核对构建产物:source map 是否生效、CRXJS 生成的入口路径是否正确、HMR WS 是否连上。
- 跨版本/跨机验证:在 Canary 与干净机器复测,排除浏览器回归与本机环境。