内容脚本的 matches 决定注入哪些页面,all_frames: true 决定注入几次。

如果目标页面把真正的表单渲在嵌套 iframe 里,你别无选择——不加这个字段,扩展根本够不到那个表单。加了之后,代价是:

你的内容脚本,在这一个标签页里跑了五份。

五份互相不知道对方存在,各有一套模块级变量。这件事会以几种完全不同的面目出现在你面前。

面目一:写成功了,然后被自己人覆盖

有个填单流程,日志明明白白写着 填充结果: { desc: 'ok' },下一步校验却报「缺问题描述」。

查下来的链路是这样的:

页面刚打开时,扩展会往描述框写一份模板骨架。因为编辑器 iframe 可能还没挂好,这个写入带重试——100ms 一次,最多 20 次,条件是「描述为空就写」。

这段逻辑靠一个模块级变量去重:

let autoTriggering = false; // ← 问题就在这一行

五个 frame 各有一份 autoTriggering,谁也拦不住谁。 于是五份重试轮询同时在飞。

然后 AI 把真实描述填进去了。某个还没退出的轮询下一拍醒来,发现「咦描述不是空的吗」——它读到的是自己那一拍之前的判断结果——writeSkeleton() 一执行,把刚填好的描述覆盖成空骨架。

现象精确吻合:

现象为什么
填充函数返回 ok那一刻确实写进去了
另一个字段好好的那个字段的写入没有长轮询
描述空白被在飞的轮询覆盖了
客户也没填上填客户要从描述里解析信息,而描述已经被清空了

有后台轮询或定时器在改同一块 DOM 时,「写成功」的返回值不代表最终状态。 要么置闸让路,要么写完回读确认。

面目二:五个面板,一起抢同一张表单

更贵的一次是批量任务。

工具栏、监控面板、自动回复这些都有 isTopFrame() 守卫,只在顶层帧挂载。但有两个后加的面板漏了——每个 frame 各开一个,各跑一份批量流程,五个实例一起抢同一张表单。

这个 bug 的表现极具迷惑性。用户报某一单失败,我去看日志,发现日志里缺了整整一段:「切换会话」「表单就绪」「拿到表单锁」全都没有,直接跳到最后报错。

我当时的第一反应是「数据层出问题了」,于是去手动验证:同样的参数调一次取数接口,ok: true,数据完整;直接打后端接口,几十条记录都在。接口、参数、解析、登录态全对。

绕了很久才反应过来:那段日志不是「没打」,是打在别的 frame 里了。

失败的那个实例,是另一个 frame 里的副本。它持有的 DOM 引用早就失效了,表单没开成,自然拿不到数据。

排查多 frame 问题,先看日志「缺了什么」,而不是盯着报错本身。 日志缺一整段 = 那是另一个实例在跑。

所以:三类代码,三种处置

const isTopFrame = (): boolean => window === window.top;

这一行判断下面分三类:

① 会「跑流程」、或者只该有一个实例的东西 → 收敛到顶层帧。 面板、工具栏、长任务、轮询调度。非顶层帧需要触发它,就 postMessage 转给顶层。

② 键位监听 → 必须每个 frame 都注册。 焦点可能落在任意一个 iframe 里,只在顶层帧监听的话,用户在表单里按快捷键完全没反应。

这两条不冲突,但很容易为了省事把键位也一起砍掉——然后得到一个「有时候好使有时候不好使」的快捷键。

③ 只有顶层帧能读到的东西 → 顶层帧读一次,结果写进 chrome.storage,其他 frame 读结果。

我们的权限裁决就是这样:判断当前登录的是谁,要读页面右上角那个用户信息区,它只存在于顶层帧的 DOM 里。早期版本是每个 frame 各自裁决,结果 iframe 里一律判成「读不到身份 → 全关」,把表单所在那个 frame 里的功能全误关了。

现在是:顶层帧等用户信息区挂出来 → 裁决一次 → 写 chrome.storage.session → 其他 frame 读它。顺带还省掉了四次重复的网络请求。

共享状态挂哪:top document 的 dataset

模块级变量锁不住多 frame,那锁什么?

挂在「目标所在 document」的 documentElement 上。 各个 frame 解析到的是同一个 document,于是天然共享——不需要消息、不需要序列化,读写都是同步的。

这是一份可以直接用的实现:

src/shared/cross-frame-gate.ts
/** data-ext-busy / data-ext-busy-cnt —— 闸的两个 key */
const ATTR = 'extBusy';
const CNT = 'extBusyCnt';
/** 兜底过期:防异常未解闸永久卡死。按最坏一次流程的真实耗时定,不是拍脑袋。 */
const MAX_MS = 300_000;
/**
* 闸挂在「目标所在 document」的 documentElement 上。
* 各 frame 解析到的是同一个 document,于是天然跨 frame 共享——
* 模块级变量做不到这一点:它每个 frame 各一份。
*/
function gateHost(): HTMLElement | null {
try {
const doc = document.querySelector('form')?.ownerDocument ?? document;
return doc.documentElement;
} catch {
return null;
}
}
export function isBusy(): boolean {
const host = gateHost();
if (!host) return false;
const at = Number(host.dataset[ATTR] ?? 0);
if (!at) return false;
if (Date.now() - at > MAX_MS) {
delete host.dataset[ATTR];
// ★ 计数必须一起清:残留的计数会让后续解闸永远减不到 0,闸再也放不开
delete host.dataset[CNT];
return false;
}
return true;
}
/**
* 置闸 / 解闸。**可重入(引用计数)**。
*
* 用布尔会出事:嵌套调用时内层跑完一解闸,把外层的闸一起解掉了。
* 计数:置 +1、解 -1,**归零才真正放行**。
*/
export function setBusy(on: boolean): void {
const host = gateHost();
if (!host) return;
const cur = Number(host.dataset[CNT] ?? 0);
if (on) {
host.dataset[CNT] = String(cur + 1);
host.dataset[ATTR] = String(Date.now());
return;
}
const next = Math.max(0, cur - 1);
if (next === 0) {
delete host.dataset[CNT];
delete host.dataset[ATTR];
} else {
host.dataset[CNT] = String(next);
// 内层解了但外层还在 → 刷新时间戳,别让过期兜底把外层的闸提前掐掉
host.dataset[ATTR] = String(Date.now());
}
}
/** 包一层,保证异常路径也解闸 */
export async function withBusy<T>(fn: () => Promise<T>): Promise<T> {
setBusy(true);
try {
return await fn();
} finally {
setBusy(false);
}
}

用法:

// 长流程外层
await withBusy(async () => {
await openForm();
await fillByAI(); // 内层可能又置一次闸,可重入
await verifyAndSubmit();
});
// 各 frame 的骨架轮询里
if (isBusy()) return; // 让路,包括别的 frame 里在飞的那些

三个细节都是踩出来的

① 必须可重入。 这道闸有两层调用者:外层覆盖「开表单 → 调模型 → 填写 → 校验 → 提交」整段,内层只覆盖「填字段」那一小段。

用布尔的话,内层跑完一解闸,把外层的闸一起解掉了——于是外层剩下的校验窗口又暴露给那些轮询,重演第一个故事。改成引用计数,归零才真正放行。

② 过期兜底的时间要按最坏情况的真实耗时定。 最初设的 90 秒,后来闸覆盖了整段批量流程——单次模型调用超时 60 秒 × 4 次重试 + 退避 + 限流冷却——合计可以超过 90 秒。过期太早,中途放行轮询,正好把要防的事情放进来了。改成 5 分钟:仍然远小于「卡死」的量级,而且每次置解闸都会刷新时间戳。

③ 过期时必须把计数一起清掉。 只清时间戳的话,残留的计数会让后续每次解闸都减不到 0,这道闸就再也放不开了。

还有一点:日志会翻五倍

每个 frame 都打一份启动日志,Console 里瞬间五条一模一样的行。

我们的做法是把启动日志降到 debug 级(默认关闭),需要确认加载了哪个构建时再拨开关。但保留区分:

log.debug(BUILD_TAG, isTopFrame() ? '(top)' : `(iframe ${location.pathname})`);

这一句在排查多 frame 问题时价值很高——它直接告诉你「现在说话的是谁」。日志里如果同一条出现五次,你立刻知道该去加 isTopFrame() 守卫了。

小结

all_frames: true 不是一个开关,是一个并发模型的改变。加上它之后:

  • 模块级变量 = 每 frame 一份,不是全局状态;
  • 任何「只该有一个」的东西都要显式收敛;
  • 任何「必须到处都有」的东西(键位)要显式保留;
  • 共享状态挂 top document,且要考虑可重入与过期;
  • 看日志先看「缺了什么」。

最难受的地方在于:这类 bug 在你自己机器上很难复现。frame 数量、加载时序、哪个 frame 先跑完,每次都不一样。所以与其等它复现,不如开工时就把上面这几条当成默认姿势。