Chrome Extension debugger 与 CDP 的能力边界

Chrome Extension 里有一个看起来很少用、但能力很强的权限:debugger

它的作用可以理解为:让扩展通过 Chrome DevTools Protocol(CDP)连接到浏览器调试后端。扩展一旦拿到这个权限,就能对目标标签页发送一部分 DevTools 协议命令。

这类能力平时不应该随手用。它会触发明显的权限警告,调用时浏览器还会出现「某某扩展 started debugging this browser」一类提示。但在一些普通内容脚本做不到的场景里,它反而是更接近问题本质的工具。

这篇文章想讲清楚四件事:

  • chrome.debugger 到底是什么。
  • 它能做哪些普通脚本做不到的事。
  • 为什么飞书 canvas 表格复制这种场景,最后会落到 CDP。
  • 真要使用时,应该怎么把风险压到最低。

chrome.debugger 是 CDP 的扩展入口

Chrome 官方文档chrome.debugger 的定位很直接:它是 Chrome 远程调试协议的另一种传输方式。扩展可以 attach 到一个或多个标签页,用它观察网络交互、调试 JavaScript、修改 DOM 和 CSS,并通过 sendCommand 向目标发送 CDP 命令。

CDP 本身是 Chrome DevTools Protocol,DevTools、Puppeteer、Playwright 这类工具背后也会用到类似的浏览器调试协议。它把浏览器能力拆成一个个 domain,例如 InputRuntimeDOMCSSNetworkPageEmulation。这些 domain 下文会单独说明,这里先把它们理解成「按浏览器能力分组的协议命名空间」即可。

CDP domain 可以理解成 DevTools 协议里的能力分组。比如 Input 处理输入事件,Network 处理网络观测,Runtime 处理页面 JavaScript 运行时。调用时通常写成 Domain.method,例如 Input.dispatchKeyEvent

普通扩展代码通常站在 Web 页面或扩展 API 这一层工作:

1
2
3
4
5
Popup / Service Worker / Content Script

Chrome Extension API / DOM API

网页自身逻辑

debugger 则多接近一层浏览器调试后端:

1
2
3
4
5
6
7
8
9
Popup / Service Worker

chrome.debugger

Chrome DevTools Protocol

浏览器调试后端

目标页面、渲染、网络、输入、运行时

这个差异决定了它既有用,也危险。

  • Chrome debugger API 说明了 chrome.debugger 的定位、权限声明、target、attach / detach / sendCommand 这些基础 API。
  • Chrome DevTools Protocol 是 CDP 的官方协议入口,能看到每个 domain 的方法和参数。

它开放的是受限 DevTools 能力

为了安全,chrome.debugger 并不开放全部 CDP domain。Chrome 官方文档 单独列了扩展可以访问的 domain,包括 AccessibilityAuditsCacheStorageConsoleCSSDatabaseDebuggerDOMDOMDebuggerDOMSnapshotEmulationFetchIOInputInspectorLogNetworkOverlayPagePerformanceProfilerRuntimeStorageTargetTracingWebAudioWebAuthn 等。

只看名字就能感受到它的覆盖面:输入、运行时、DOM、CSS、网络、页面、环境模拟、性能追踪、存储缓存都在里面。

实际评估时,可以按几类能力理解。本节里的 CDP 代码都只保留关键调用形状,省略了 attach、detach、callback 返回值读取和错误处理;完整调用方式放在后面的「调用方式」一节。

输入和用户动作

Input.* 负责模拟键盘、鼠标、触摸等输入。典型关键词是:真实快捷键、复制、粘贴、鼠标、拖拽、触摸、canvas、复杂编辑器、虚拟表格、非标准选区。

这类能力和普通脚本派发事件的差别很大。普通脚本通常会写:

1
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'c', metaKey: true }));

或者:

1
document.execCommand('copy');

问题在于,这些操作仍然发生在页面脚本层。根据 MDN 对 Event.isTrusted 的说明,通过 dispatchEvent() 派发的事件会被标记为非可信事件。复杂 Web 应用完全可以不把这类合成事件当成真实用户输入。

CDP 的 Input.dispatchKeyEvent 走的是浏览器调试层的输入命令。它支持 modifiers,其中 Meta/Command4Ctrl2;也支持随键盘事件发送编辑命令,例如 copyselectAll 这一类和编辑器相关的命令。

迁移到 debugger 后,写法变成向浏览器输入层发送命令:

1
2
3
4
5
6
7
8
9
10
11
12
chrome.debugger.sendCommand(
{ tabId },
'Input.dispatchKeyEvent',
{
type: 'rawKeyDown',
key: 'c',
code: 'KeyC',
windowsVirtualKeyCode: 67,
modifiers: 4,
commands: ['copy']
}
);

页面运行时

Runtime.*Console.*Debugger.* 负责页面 JavaScript 运行时、Console、断点和执行上下文。

这类能力适合处理:

  • 要在页面上下文里执行一段表达式。
  • 要读取复杂框架运行态。
  • 要监听 Console 输出或异常。
  • 要判断一个跨 iframe / worker 的执行上下文在哪里。

普通 chrome.scripting.executeScript 也能往页面注入脚本,但 CDP 的运行时能力更接近 DevTools Console。能力越接近 DevTools,越要注意边界:不能拿它去读取和任务无关的敏感数据,也不要把它当成绕过页面正常权限和产品边界的工具。

普通扩展 API 更像「注入一段函数到页面」:

1
2
3
4
chrome.scripting.executeScript({
target: { tabId },
func: () => window.location.href
});

CDP 的 Runtime.evaluate 更像 DevTools Console 里的表达式执行:

1
2
3
4
5
6
7
8
chrome.debugger.sendCommand(
{ tabId },
'Runtime.evaluate',
{
expression: 'window.location.href',
returnByValue: true
}
);

DOM、CSS 和布局

DOM.*CSS.*DOMSnapshot.*Overlay.* 对应 DevTools Elements 面板附近的能力。它们适合用来检查 DOM 树、样式规则、节点快照、布局盒模型、元素高亮和渲染定位。

如果只是 document.querySelector() 能解决的问题,没有必要升级到 debugger。但如果问题发生在 Shadow DOM、复杂 iframe、虚拟渲染、样式来源难以定位,或者要复用 DevTools 的节点高亮和快照能力,CDP 可以作为候选方案。

普通脚本通常直接读 DOM:

1
2
3
4
chrome.scripting.executeScript({
target: { tabId },
func: () => document.querySelector('.submit')?.getBoundingClientRect()
});

CDP 可以先拿 DOM 树,再用 node id 继续查询样式、盒模型或高亮节点:

1
2
3
4
5
6
chrome.debugger.sendCommand({ tabId }, 'DOM.getDocument', { depth: 1 });
chrome.debugger.sendCommand({ tabId }, 'DOM.querySelector', {
nodeId: documentNodeId,
selector: '.submit'
});
chrome.debugger.sendCommand({ tabId }, 'DOM.getBoxModel', { nodeId });

网络和接口

Network.*Fetch.*IO.* 对应网络观察、请求拦截、响应读取和流式内容处理。

它们适合处理:

  • 观察页面实际发出的请求。
  • 查看 headers、status、response、preflight、缓存状态。
  • 在调试工具里对接口做 mock 或拦截。
  • 下载或读取 DevTools 网络层看到的内容。

Chrome Extension 本身也有 declarativeNetRequestwebRequest 等网络相关 API。选择时要看需求目标:如果是长期、稳定、面向发布的网络规则,优先考虑标准扩展 API;如果是内部调试、临时观测、需要贴近 DevTools 的 Network 视角,CDP 更直接。

这部分能力很强,使用边界也要更严。不要长期监听无关站点,不要采集用户敏感流量,不要把内部调试工具做成默认后台监控。

普通扩展能力更适合声明式规则或稳定拦截:

1
2
3
4
chrome.declarativeNetRequest.updateDynamicRules({
addRules: [rule],
removeRuleIds: [rule.id]
});

CDP 更接近 DevTools Network。它可以打开网络观测,再从 chrome.debugger.onEvent 里接收网络事件:

1
2
3
4
5
6
7
8
9
chrome.debugger.sendCommand({ tabId }, 'Network.enable');

chrome.debugger.onEvent.addListener((source, method, params) => {
if (source.tabId !== tabId || method !== 'Network.responseReceived') {
return;
}

console.log(params.response.url, params.response.status);
});

页面控制、target 和 frame

Page.*Target.* 负责页面导航、reload、生命周期、frame tree、截图、PDF、target 管理等能力。

Chrome debugger 文档 提到,一个 tab 里可能有多个 execution context,跨进程 iframe 还可能成为新的 target。Chrome 125 以后,chrome.debugger 支持 flat sessions,可以在同一个调试会话里定位子 target。

这意味着:如果问题发生在 iframe、worker、跨域嵌套页面里,不能只问「我 attach 了 tab 吗」,还要问「命令发给了哪个 target / session」。

普通标签页控制一般用 chrome.tabs

1
chrome.tabs.reload(tabId);

CDP 的 Page.* 适合和页面生命周期、截图或 target 状态放在一起处理:

1
2
3
4
5
chrome.debugger.sendCommand({ tabId }, 'Page.reload', { ignoreCache: true });
chrome.debugger.sendCommand({ tabId }, 'Page.captureScreenshot', {
format: 'png',
fromSurface: true
});

环境模拟

Emulation.* 和部分 Network.* 能模拟 viewport、设备特征、触摸能力、UA、地理位置、时区、语言、网络条件等。

这类能力适合排查「只有某个 WebView / 移动设备 / 特定语言 / 特定网络环境才复现」的问题。它和普通响应式调试不同,因为它能更接近 DevTools 设备模拟层,影响范围也不止 CSS 宽度。

普通页面调试经常只改视口宽度:

1
window.resizeTo(390, 844);

CDP 可以把设备指标、触摸能力、时区和网络条件放在同一套自动化里:

1
2
3
4
5
6
7
8
9
10
chrome.debugger.sendCommand({ tabId }, 'Emulation.setDeviceMetricsOverride', {
width: 390,
height: 844,
deviceScaleFactor: 3,
mobile: true
});

chrome.debugger.sendCommand({ tabId }, 'Emulation.setTimezoneOverride', {
timezoneId: 'Asia/Shanghai'
});

存储、缓存和性能

Storage.*CacheStorage.*Performance.*Profiler.*Tracing.* 可以用于缓存、存储、性能指标、CPU profile、trace 等调试任务。

这里要区分两种需求:

  • 如果只是给本地开发页写一段明确的 localStorage,普通 chrome.scripting.executeScript 更合适。
  • 如果要理解页面缓存、Service Worker、CacheStorage、运行性能和 trace,CDP 才更接近问题发生的位置。

普通 localStorage 写入不需要上 CDP:

1
2
3
4
5
chrome.scripting.executeScript({
target: { tabId },
func: (value) => localStorage.setItem('i18n-demo/i18n', value),
args: [cacheValue]
});

CDP 更适合处理缓存和性能视角的问题:

1
2
3
4
5
6
chrome.debugger.sendCommand({ tabId }, 'Performance.enable');
chrome.debugger.sendCommand({ tabId }, 'Performance.getMetrics');
chrome.debugger.sendCommand({ tabId }, 'Storage.clearDataForOrigin', {
origin: 'http://localhost:8012',
storageTypes: 'cache_storage,indexeddb'
});

它也有明确做不到的事

debugger 很强,但它不是一个「浏览器万能后门」。从设计上看,它仍然是扩展连接某个调试目标(target)后,再向这个目标发送 CDP 命令。这个边界在写内部工具时很容易被忽略,尤其是目标页、localStorage 和跨浏览器这几类需求。

不能脱离 target 操作一个不存在的页面

Chrome debugger 文档 里对 target 的定义是:正在被调试的对象,可以是 tab、iframe 或 worker。扩展调用 sendCommand 时,也需要通过 tabIdtargetIdsessionId 指向某个调试会话。

这意味着,debugger 不是一个可以凭 URL 直接操作任意网页状态的 API。它需要一个实际存在、可以 attach 的浏览器目标。比如要操作 http://localhost:8012,至少要有一个属于这个 origin 的页面或相关 target 存在;否则没有页面上下文,也没有可以执行 JavaScript 或接收 CDP 命令的地方。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 可以:当前 Chrome 里已经有一个 localhost:8012 标签页
chrome.debugger.attach({ tabId }, '1.3', () => {
chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
expression: `localStorage.setItem('i18n-demo/i18n', ${JSON.stringify(cacheValue)})`
});
});

// 不可以:只给一个 URL 字符串,就期待 Chrome 在没有页面的情况下写 localStorage
chrome.debugger.sendCommand(
{ url: 'http://localhost:8012' },
'Runtime.evaluate',
{ expression: 'localStorage.setItem(...)' }
);

如果产品目标是「用户不需要手动打开页面」,更现实的做法不是上 debugger,而是让扩展自己创建一个目标页,例如用 chrome.tabs.create({ url: 'http://localhost:8012', active: false }) 打开一个非激活标签,等页面加载后再用 chrome.scripting.executeScript 或 CDP 写入。这个方案本质上仍然创建了页面,只是把「用户手动打开」变成「工具自动打开」。

不能直接写到另一个浏览器或手机 WebView

Chrome Extension 运行在哪个浏览器配置里,它能访问的 tab 和调试目标就属于哪个浏览器配置。用户如果在另一个 Chrome Profile、Safari、手机浏览器、App WebView 或 vConsole 里打开了目标页,当前桌面 Chrome 扩展不能跨过去 attach,也不能直接写入那边的 localStorage

这种场景的推荐处理方式,是把「写入计划」生成成一段自包含控制台脚本,让用户复制到目标环境执行。脚本运行在哪个页面,就写入哪个页面自己的 localStorage

1
2
3
4
5
6
7
8
const script = buildConsoleWriteScript({
overrides: selectedOverrides,
selectedLocale: 'zh_CN',
cacheTime: 7 * 24 * 60 * 60 * 1000,
writeMode: 'append'
});

await navigator.clipboard.writeText(script);

这条旁路听起来没有自动化那么漂亮,但边界非常清楚:扩展负责从飞书表格生成正确数据,目标浏览器负责在自己的页面上下文里执行写入。对手机 vConsole、App WebView、非当前浏览器这类环境来说,这比伪装成「扩展已经写过去了」更可靠。

Storage.* 不等于随便 set localStorage

CDP 里确实有一个 DOMStorage domain,它提供 DOMStorage.setDOMStorageItem 这类方法,用于查询和修改 DOM storage。不过,Chrome Extension 的 chrome.debugger 不是完整 CDP 通道。Chrome 官方列出的可用 domain 里有 Storage,但没有列出 DOMStorage

这两个 domain 也不是一回事。Storage.* 更偏向站点存储管理、配额、CacheStorage / IndexedDB 追踪和清理,例如 Storage.clearDataForOrigin 可以按 origin 清理某些类型的存储;DOMStorage.* 才是直接面向 localStorage / sessionStorage key-value 的协议。

所以,在 Chrome Extension 里遇到「给当前打开的本地页写一段 localStorage」这种需求,最简单、最稳的方案仍然是注入页面上下文执行普通 Web API:

1
2
3
4
5
6
7
chrome.scripting.executeScript({
target: { tabId },
args: [cacheValue],
func: (value) => {
localStorage.setItem('i18n-demo/i18n', value);
}
});

只有当需求已经落到浏览器调试层,例如要观察缓存、清理多类站点存储、追踪 CacheStorage / IndexedDB 变化,或者和 Network / Runtime / Page 调试一起联动时,Storage.* 才更有价值。

localStorage 的边界仍然是 origin

localStorage 本身也不是全浏览器共享空间。根据 MDN 对 localStorage 的说明,它的数据和 document 的协议相关;http://example.comhttps://example.com 会拿到不同的存储对象。更完整地说,origin 通常由 scheme、host 和 port 共同决定,所以 http://localhost:8012http://127.0.0.1:8012https://localhost:8012 是不同的存储边界。

这件事和 debugger 无关。即使用 CDP 或 Runtime.evaluate 执行 localStorage.setItem(),写入的也只是当前执行上下文所属 origin 的 localStorage。如果工具 UI 上写着「写入 8012」,就应该提前告诉用户具体会写入哪个 8012 页面;如果打开了多个候选页,也应该把候选页列出来,避免用户以为写到了手机或另一个浏览器。

用户可以中断调试会话

debugger 还不是一个可以稳定长期占用的连接。官方类型里,onDetach 的 reason 至少包括 target_closedcanceled_by_user。用户关闭目标 tab、打开 DevTools、关闭浏览器提示条,或者浏览器因为其他原因结束调试会话,扩展都要能恢复状态。

1
2
3
4
5
6
7
8
chrome.debugger.onDetach.addListener((source, reason) => {
if (source.tabId !== currentDebuggingTabId) {
return;
}

currentDebuggingTabId = undefined;
setStatus(reason === 'canceled_by_user' ? '调试已被用户结束' : '调试目标已关闭');
});

写工具时要把它当成一次短暂动作,而不是长期连接。attach 后立刻完成必要命令,随后 detach;UI 上也不要假设调试会话永远还在。

一个真实案例:为什么飞书 canvas 表格复制要用 CDP

这次遇到的问题是:希望 Chrome Extension 自动读取飞书翻译表格,把表格里的多语言 key、tag 和文案写入本地开发页的 localStorage

开始的直觉是走普通脚本:

  1. 找到飞书页面里的 canvas。
  2. 模拟点击左上角交叉格,让表格进入全选状态。
  3. 调用 document.execCommand('copy') 或派发 copy 事件。
  4. 从剪贴板读取 TSV。

问题出在第 3 步。页面看起来已经选中了表格,但剪贴板没有变。调试日志能看到 execCommand('copy') 返回成功,也能看到点击目标是 canvas,但最终剪贴板仍然是旧内容。

这说明「命令被浏览器接受」和「飞书表格真的把选区写进剪贴板」是两件事。

飞书表格主体是 canvas,表格单元格、选区、复制内容都不一定是标准 DOM Selection。它很可能有自己的一套内部状态和复制管线。脚本合成的 click、keydown、copy 事件能让 UI 看起来动了,但不一定能触发内部复制逻辑。

用 CDP 后,流程变成:

  1. 扩展 attach 到当前飞书 tab。
  2. 先用普通脚本点击 canvas 左上角,让表格进入全选状态。
  3. 通过 Input.dispatchKeyEvent 发送 Command + Ccopy 编辑命令。
  4. 立刻 detach。
  5. 再读取剪贴板。

这次能成功,关键在于 CDP 触发了更接近真实用户快捷键的浏览器输入链路,让飞书自己的复制管线把 TSV 写进剪贴板。扩展本身没有直接读取飞书表格内部数据。

这个区别很重要。合理使用 debugger 的方向,通常是让页面按真实交互路径自己完成动作;直接绕开业务逻辑读取内部数据,会让工具边界变得危险。

关键代码可以拆成两段。第一段仍然用普通脚本完成页面内定位和点击,因为「选中哪个表格」属于页面内动作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const selectFeishuCanvasTable = () => {
const canvas = document.querySelector<HTMLCanvasElement>('canvas.faster-single-canvas');

if (!canvas) {
return { isCanvasFound: false };
}

const rect = canvas.getBoundingClientRect();
const point = {
x: rect.left + 18,
y: rect.top + 18
};

const eventOptions = {
bubbles: true,
cancelable: true,
clientX: point.x,
clientY: point.y,
button: 0,
buttons: 1
};

canvas.dispatchEvent(new MouseEvent('mousedown', eventOptions));
canvas.dispatchEvent(new MouseEvent('mouseup', { ...eventOptions, buttons: 0 }));
canvas.dispatchEvent(new MouseEvent('click', { ...eventOptions, buttons: 0 }));

return { isCanvasFound: true, point };
};

第二段用 CDP 发复制快捷键,因为「让飞书把当前表格选区写进剪贴板」依赖浏览器输入层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
chrome.scripting.executeScript(
{
target: { tabId },
func: selectFeishuCanvasTable
},
() => {
chrome.debugger.attach({ tabId }, '1.3', () => {
chrome.debugger.sendCommand(
{ tabId },
'Input.dispatchKeyEvent',
{
type: 'rawKeyDown',
key: 'c',
code: 'KeyC',
windowsVirtualKeyCode: 67,
modifiers: 4,
commands: ['copy']
},
() => chrome.debugger.detach({ tabId })
);
});
}
);

这段代码只保留了关键动作,真实实现还需要补 chrome.runtime.lastError 检查、keyUp、剪贴板前后对比和 finally 式 detach。它的重点是说明两层分工:页面内能做的仍用普通脚本,普通脚本触发不了的浏览器输入链路再交给 CDP。

调用方式

扩展要使用 chrome.debugger,首先要在 manifest 里声明权限:

1
2
3
4
{
"manifest_version": 3,
"permissions": ["debugger", "scripting", "activeTab"]
}

最原始的调用方式是三步:attach 目标、sendCommand、detach。下面用原生 callback 写法展示完整结构,读者可以直接对照 Chrome API 文档。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const target = { tabId };

chrome.debugger.attach(target, '1.3', () => {
const attachError = chrome.runtime.lastError;

if (attachError) {
console.error(attachError.message);
return;
}

chrome.debugger.sendCommand(
target,
'Input.dispatchKeyEvent',
{
type: 'rawKeyDown',
key: 'c',
code: 'KeyC',
windowsVirtualKeyCode: 67,
modifiers: 4,
commands: ['copy']
},
() => {
const keyDownError = chrome.runtime.lastError;

chrome.debugger.sendCommand(
target,
'Input.dispatchKeyEvent',
{
type: 'keyUp',
key: 'c',
code: 'KeyC',
windowsVirtualKeyCode: 67,
modifiers: 4
},
() => {
const keyUpError = chrome.runtime.lastError;

chrome.debugger.detach(target, () => {
const detachError = chrome.runtime.lastError;

[keyDownError, keyUpError, detachError].filter(Boolean).forEach((error) => {
console.error(error!.message);
});
});
}
);
}
);
});

发送复制快捷键时,macOS 下 Meta/Command 对应 modifiers: 4;Windows / Linux 下通常是 Ctrl,对应 modifiers: 2

Chrome 的扩展 API 很多仍然是 callback 风格。正式业务里把 attachsendCommanddetach 包成 Promise 会更好,因为可以用 try / finally 保证 detach 一定执行。封装是工程质量问题,不是理解 debugger 的前置条件。

1
2
3
4
5
6
7
await attachDebugger(target);

try {
await sendDebuggerCommand(target, 'Input.dispatchKeyEvent', params);
} finally {
await detachDebugger(target).catch(() => undefined);
}

如果目标 tab 已经被 DevTools 或另一个调试会话占用,attach 可能失败。chrome.debugger.onDetach 也需要关注:官方文档说明,当 tab 关闭或 DevTools 被打开时,调试会话会被浏览器结束。

1
2
3
4
5
6
7
chrome.debugger.onDetach.addListener((source, reason) => {
if (source.tabId !== tabId) {
return;
}

console.log('debugger detached:', reason);
});

无论使用 callback 还是 Promise,detach 都很关键。复制成功、复制失败、中途抛错,最后都要尽快结束调试会话。

浏览器会出现「正在调试」提示

调用 chrome.debugger.attach 后,Chrome / Chromium 会出现一条浏览器级提示,英文环境里常见文案是:

1
"Extension Foo" started debugging this browser

这个提示来自浏览器本身,用来提醒用户发生过调试会话。Chromium 源码里的 IDS_DEV_TOOLS_INFOBAR_LABEL 对应这条文案,并且注释写明:即使 debugger 已经 detach,提示也不会自动消失,直到用户手动关闭;文案不应该暗示调试一定仍在进行,只表达它曾经发生过、也可能仍然存在。

源码里核心形状大概是这样:

1
2
3
<message name="IDS_DEV_TOOLS_INFOBAR_LABEL">
"$1" started debugging this browser
</message>

这条提示会影响用户感知。一个面向普通用户的公开扩展,如果频繁出现「正在调试此浏览器」,会很难解释清楚。内部开发工具、测试工具、调试工具更适合接受这类提示;如果要给普通用户使用,就必须在产品设计里提前解释为什么需要它。

权限警告也很重

Chrome 权限列表 里,debugger 对应的警告包括访问页面 debugger backend,以及读取和更改所有网站上的所有数据。它属于重权限。

Chrome 权限警告指南 也给出通用建议:权限应该和扩展的单一用途相关;能用可选权限就尽量在运行时申请;能用 activeTab 这类更轻权限就不要申请更重权限。

放到 debugger 上,可以得到几个判断:

  • 如果普通 DOM、chrome.scriptingtabsstorage 就能解决,不要用 debugger
  • 如果只是给本地开发页写 localStorage,不要用 debugger
  • 如果是 canvas 表格、复杂编辑器、虚拟列表、非标准选区、复制/输入强依赖原生交互,应该尽早把 CDP 列为候选方案。
  • 如果已经出现「UI 看起来选中,但剪贴板没有变化」「合成事件派发了,但页面内部逻辑没有触发」这类信号,可以先做最小 CDP 验证,不必在 execCommand、DOM Selection 和合成事件里反复试错。

最佳实践

这部分可以参考三类官方资料一起看:权限警告指南 关注用户安装和运行时看到什么,activeTab 文档 关注「用户触发后临时访问当前 tab」这类权限模型,Chrome Web Store Program Policies 则要求权限和用户数据使用与扩展功能匹配,并尽量请求实现功能所需的最窄权限。

只在用户明确触发时 attach

不要在后台静默 attach,也不要打开浏览器就 attach。最好是用户点了某个按钮,扩展明确知道这一次要处理哪个 tab,再短时间 attach。

这次飞书表格读取就是一个比较好的形态:用户打开飞书翻译表,点击 Popup 里的「读取当前表格」,扩展只处理当前飞书 tab。

如果功能可以设计成点击按钮后才生效,就不要把它做成后台自动监听。activeTab 文档 里强调,临时访问当前 tab 需要来自用户调用扩展的动作;这个思路同样适合 debugger 这种高权限能力:让用户动作成为清晰边界。

只 attach 目标 tab

业务代码应该先确认目标 tab 的 URL、host 和任务类型。比如只允许 feishu.cn / larksuite.com 的文档页面触发读取,不要对当前任意页面发送调试命令。

如果涉及 iframe、worker 或跨进程 frame,再根据 CDP target 机制扩展;不要一开始就把逻辑写成宽泛的全页面控制器。

实现上可以先做 allowlist:

1
2
3
4
5
const isAllowedTarget = (url: string) => {
const { hostname } = new URL(url);

return hostname.endsWith('.feishu.cn') || hostname.endsWith('.larksuite.com');
};

这类检查不只是安全防线,也能减少误操作。用户如果在本地开发页、邮箱页或其他无关页面打开 Popup,工具应该提示「请先切到飞书文档标签页」,而不是继续 attach。

只发送必要命令

为了复制表格,只需要 Input.dispatchKeyEvent。不要顺手打开 NetworkRuntimeStorage,更不要为了日志把页面状态、请求响应、存储内容全读出来。

CDP 的能力边界应该按任务收窄,不能按它能做什么无限展开。

一个比较好的实现原则是:每个封装函数只对应一类 CDP 能力。例如 dispatchCopyByDebugger 只发送 Input.dispatchKeyEvent;如果另一个需求要读 Network,就单独写 observeNetworkResponse,不要把两类能力揉进一个万能 debugger helper。

立刻 detach

attach 后一定用 try / finally 包住,并在 finally 里 detach。

1
2
3
4
5
6
7
await attachDebugger(target);

try {
await sendDebuggerCommand(target, 'Input.dispatchKeyEvent', params);
} finally {
await detachDebugger(target).catch(() => undefined);
}

这样即使中间报错,也不会让调试会话继续挂在浏览器上。

同时监听 chrome.debugger.onDetach。如果用户打开 DevTools、关闭 tab,或者浏览器主动结束调试会话,工具需要把内部状态切回未调试状态,不能让 UI 继续显示「处理中」。

1
2
3
4
5
6
chrome.debugger.onDetach.addListener((source) => {
if (source.tabId === currentDebuggingTabId) {
currentDebuggingTabId = undefined;
setStatus('调试会话已结束,可以重新执行');
}
});

把调试协议封装在独立模块

业务代码不应该到处直接写 chrome.debugger.sendCommand。更好的方式是封装成很窄的函数,例如:

  • dispatchCopyByDebugger(tabId)
  • captureCurrentPageByDebugger(tabId)
  • readNetworkResponseByDebugger(tabId, matcher)

每个函数都应该说明它 attach 哪个 target、发送哪些 command、读取哪些数据、什么时候 detach。调用方只表达业务意图,不直接接触大权限 API。

封装层还应该统一处理 chrome.runtime.lastErrordebugger 调用失败时,错误原因通常很关键:可能是权限没授予、target 不存在、tab 已经被其他 DevTools 会话占用,也可能是目标页面不允许当前操作。不要让这些错误静默失败。

权限声明要能解释给用户听

debugger 会带来明显权限警告。公开扩展如果需要它,商店介绍、隐私说明和产品 UI 里都应该解释清楚它用来完成什么用户可见功能。内部工具也应该在 README 或使用说明里写清楚:它会触发浏览器「正在调试」提示,处理完成后会立即 detach。

Chrome 权限警告指南 建议在功能允许时使用 optional permissions,让用户在运行时理解为什么需要额外权限。debugger 是否适合做 optional permission 取决于扩展结构,但「不要在安装时一次性申请所有可能用到的重权限」这个原则仍然成立。

Chrome Web Store Program Policies 也要求请求实现功能所需的最窄权限。debugger 这种权限最好能对应一个非常明确的主功能,而不是作为内部实现便利被顺手加进去。

日志要解释读了什么、写了什么、触发了什么

这类工具出了问题时,最常见的困惑是「看起来成功了,但结果没变」。日志需要回答几个问题:

  • 当前 attach 的 tab 是哪个。
  • 普通脚本复制是否执行过。
  • CDP 输入是否执行过。
  • 剪贴板前后是否变化。
  • 解析出了哪些 key、tag 和语言文案。
  • 最终写入了哪个页面、哪个 localStorage key。

日志优先打在 Popup 自己的 Console 里。目标业务页只返回结构化结果,不要把调试过程塞进业务页面 Console。

日志里也要避免记录过多敏感信息。比如复制表格工具可以记录 key、tag、语言和写入目标,但不应该默认把整张表、所有接口响应、cookie、token 或用户输入内容长期保存下来。

UI 要提前解释提示条

浏览器的「started debugging this browser」提示是安全提示,不是错误。工具界面或使用说明里最好提前写一句:执行读取时 Chrome 可能显示正在调试提示,这是因为扩展需要通过浏览器调试协议触发真实复制;工具会在操作结束后立即释放调试会话。

这样做能减少用户误解。否则用户第一次看到提示条,很容易以为扩展一直在监控浏览器。

debugger 当成调试层能力,不当成默认扩展能力

debugger 的价值在于它接近浏览器调试层,适合处理普通脚本无法触达的输入、网络、运行时、页面和环境模拟问题。它不应该替代所有普通扩展 API。

一个简单判断是:如果需求关键词里没有真实输入、canvas、复杂编辑器、Network、Runtime、DOM Snapshot、Page lifecycle、Emulation、Tracing 这些调试层信号,大概率先不用 debugger

在做方案评估时,可以先问两个问题:

  • 普通扩展 API 是否已经能稳定完成这个动作。
  • 如果普通 API 失败,失败点是否真的落在浏览器调试层。

只有第二个答案明确时,debugger 才值得进入正式方案。

不要把它当成跨环境写入能力

如果需求是写当前 Chrome 已打开页面的 localStorage,优先用 chrome.tabs.query 找目标 tab,再用 chrome.scripting.executeScript 注入页面上下文。按钮文案要直接提示会写到哪个页面;没有目标页时禁用写入动作,提示用户先打开目标页。

如果需求是写到另一个浏览器、手机 vConsole 或 App WebView,应该生成控制台脚本,让目标环境自己执行。debugger 不能跨浏览器、跨设备、跨 WebView attach,也不能凭 URL 直接写入一个尚未打开的页面。

怎么判断该不该用

可以用这张表做初步判断。

需求信号 优先方案 什么时候考虑 debugger / CDP
读取普通 DOM 文本 chrome.scripting.executeScript DOM 在 Shadow DOM、iframe 或虚拟渲染里,普通选择器不稳定
点击普通按钮、填写表单 内容脚本或页面 API 页面只响应真实键鼠输入,合成事件不触发业务逻辑
复制普通选中文本 Clipboard API / Selection API canvas 表格、复杂编辑器、非标准选区,剪贴板不随合成事件变化
写本地开发页 localStorage chrome.scripting.executeScript,或生成控制台脚本让目标环境自己执行 一般不需要 CDP;如果目标页没打开、在另一个浏览器或手机 WebView,debugger 也不能直接跨过去写
观察或 mock 请求 declarativeNetRequestwebRequest、代理 内部调试需要贴近 DevTools Network、读取特定响应或临时拦截
复现移动端、语言、时区、弱网问题 DevTools / 浏览器设置 / 测试环境 需要在扩展工具里自动化设置 Emulation.* 或网络条件
截图、PDF、页面生命周期 普通截图 API 或浏览器手动操作 需要 CDP Page.* 能力,或要和 target / frame 状态结合
性能 trace、CPU profile DevTools 手动分析 需要工具化采集并复用 Performance / Profiler / Tracing

这张表的作用是提醒判断边界:当问题已经明显落在浏览器调试层时,早一点验证 CDP,可能比在页面脚本层绕很久更省时间。

结论

chrome.debugger 是 Chrome Extension 通向 CDP 的入口。它能做普通内容脚本做不到的一些事,尤其是输入事件、运行时调试、网络观察、页面控制和环境模拟。

这次飞书 canvas 表格复制的问题,关键是触发表格自己的复制管线。普通脚本能让 UI 看起来选中,却不能保证飞书把内容写进剪贴板;CDP 的 Input.dispatchKeyEvent 更接近真实用户快捷键,所以能触发页面内部复制逻辑。

这个经验可以迁移到其他场景:如果需求和 canvas、复杂编辑器、虚拟表格、非标准选区、真实快捷键、Network、Runtime、Emulation 这些关键词有关,应该尽早评估 debugger / CDP;如果只是普通 DOM 操作、标签页管理或本地缓存写入,就继续用更轻的扩展 API。

反过来看,debugger 也有很清楚的边界:它不能脱离 target 操作一个不存在的页面,不能直接写到另一个浏览器或手机 WebView,不能把 Storage.* 当成任意 localStorage.setItem(),也不能绕开 origin 边界。遇到本地多语言缓存写入这类需求,最稳的判断是:当前 Chrome 已打开目标页就注入写入;目标在别的环境里,就生成控制台脚本让目标环境自己执行。

它是一个高权限工具。使用时要让用户明确触发、只处理目标页面、只发送必要命令、立刻 detach,并且把权限和提示条带来的用户感知提前讲清楚。