有个 bug 我修了一版,测下来完全没效果,日志里每一单都打同一句「未找到 React onChange」。

现象是这样:宿主页面有个富文本描述框,扩展往里写内容——提交没问题,但只要关掉页面再回来,草稿里那一段就没了。查下来原因是宿主只在关页那一刻写一次草稿,而且写的是 React state,不读 DOM、不读隐藏的 textarea。扩展只改了 DOM,那段内容从来没进过 React state。

修法很直白:找到组件的 onChange,把同一份内容推进 state(这个 bug 的完整经过在富文本编辑器有三个源)。我在 DevTools 的 Console 里试过,一次就成功了。然后写进扩展——每一单都失败。

原因不在代码。DevTools 的 Console 默认跑在页面主世界,而扩展的内容脚本跑在隔离世界。 同一段代码,换个世界就拿不到东西了。

这是 Chrome 扩展里最容易写出「实验通过、上线无效」的地方。

三个世界

一个装了扩展的标签页里,同时有三套 JavaScript 执行环境:

宿主页面的 DOM页面 JS 对象chrome.* API
主世界(页面自己的脚本)✅✅❌
隔离世界(内容脚本默认)✅❌✅
扩展页面(Service Worker / 设置页 / 侧栏)❌❌✅

关键在中间那一列。隔离世界和主世界共享同一棵 DOM 树,但不共享 JS 堆。

document.querySelector('form') 在两个世界里拿到的是同一个 DOM 节点——但每个世界看到的是自己的一层包装对象。页面脚本挂在那个节点上的普通 JS 属性,隔离世界完全看不见。

React 恰好就把 fiber 挂在 DOM 节点的 expando 属性上:

Object.getOwnPropertyNames(node).find(k => k.startsWith('__reactFiber$'))
// 主世界:'__reactFiber$xxxxxxxx'
// 隔离世界:undefined —— 不是「找不到这个节点」,是「这个节点上没有这个属性」

同样看不见的还有页面挂在 window 上的一切:富文本编辑器实例、SDK 单例、埋点对象、前端框架的全局钩子。你在 Console 里敲 window.tinymce 有东西,在内容脚本里敲就是 undefined。

反过来也成立:主世界没有 chrome.*。在主世界里直接 chrome.runtime.connect(...) 会报 Cannot read properties of undefined——这个我们也实测踩过。

它会制造两种方向相反的误判

方向一:实验通过,上线无效。

你在 Console 里验证一个想法,成功了;写进扩展,静默失败。因为 Console 默认在主世界。

这个方向还算好查——失败是显性的,日志会告诉你「没找到」。

方向二:功能已经生效,但你以为没生效。

扩展的日志打在隔离世界,DevTools 的 Console 默认只显示主世界。于是你会看到一个极具误导性的画面:功能明明跑通了,Console 里一条扩展日志都没有。

我们实测过一次:在主世界打两个 marker,把一次扩展调用夹在中间,两个 marker 之间空无一物。当时的第一反应是「改动没生效 / 扩展没重载」,绕了两轮才想起是世界的问题。

要看隔离世界的日志,得在 Console 顶部的 top ▾ 下拉里切执行上下文。

但更可靠的做法是别看日志、直接验产物:

// 日志是「过程的旁证」,产物才是「结果本身」
(await __extTools.invoke('collectContext', {})).data.turns.length

由此引出一条写给别人的规矩:验证步骤别写成「看日志里有没有 X」——对方换个执行上下文就什么都看不到,然后会非常合理地推断成「功能没生效」。要写就写「跑这条命令,看返回里有没有 X」。

解法:自己造一座桥

MV3 允许把内容脚本声明进主世界(world: "MAIN"),但那样它就没有 chrome.* 了。所以真正的解法是两边各放一个,中间用 DOM 事件通信:

  • 主世界那份负责一切「只有主世界能做的事」——读 fiber、调页面全局对象;
  • 隔离世界那份负责一切「只有隔离世界能做的事」——存储、消息、网络、权限判断;
  • 两边共享 DOM,于是 DOM 事件就是天然的信道。

manifest:两份脚本,只差一个字段

src/manifest.ts
content_scripts: [
{
matches: ["https://app.example.com/*"],
js: ["src/bridge/main-world.ts"],
run_at: "document_end",
all_frames: true, // 目标表单可能渲在任意同源 iframe 里
match_about_blank: true, // srcdoc / about:blank 的 frame 也要覆盖
world: "MAIN", // ★ 关键:不写就还是隔离世界,等于没造桥
},
{
matches: ["https://app.example.com/*"],
js: ["src/content/index.ts"],
run_at: "document_end",
all_frames: true,
match_about_blank: true,
// 不写 world → 默认隔离世界
},
],

主世界这一侧:只做「非它不可」的事

// src/bridge/main-world.ts —— 注入进页面主世界
(() => {
// 幂等守卫:同一份脚本可能被注入多次(SPA 路由、frame 重挂、手动 re-inject)
if ((window as any).__pageBridgeInstalled) return;
(window as any).__pageBridgeInstalled = true;
type Handler = (payload: any) => unknown;
const handlers: Record<string, Handler> = {
/** 用例 1:读挂在 DOM 节点上的 React fiber —— 隔离世界看不见这些属性 */
readFiberProp({ selector, propPath }) {
const node = document.querySelector(selector);
if (!node) return null;
const key = Object.getOwnPropertyNames(node).find(
(k) => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'),
);
if (!key) return null;
// fiber 往上爬,找第一个 memoizedProps 里带目标字段的节点
let cur: any = (node as any)[key];
for (let depth = 0; cur && depth < 80; depth++, cur = cur.return) {
const v = propPath
.split('.')
.reduce((o: any, k: string) => (o == null ? o : o[k]), cur.memoizedProps);
if (v != null) return v;
}
return null;
},
/** 用例 2:调页面自己的全局对象(编辑器实例、SDK、埋点……) */
callPageGlobal({ path, args }) {
const fn = path
.split('.')
.reduce((o: any, k: string) => (o == null ? o : o[k]), window as any);
if (typeof fn !== 'function') return { ok: false, reason: 'not-a-function' };
return { ok: true, value: fn(...(args ?? [])) };
},
};
window.addEventListener('page-bridge:req', (ev) => {
const { id, method, payload } = (ev as CustomEvent).detail ?? {};
if (!id) return;
let res: { id: string; ok: boolean; data?: unknown; error?: string };
try {
const fn = handlers[method];
if (!fn) throw new Error(`unknown method: ${method}`);
res = { id, ok: true, data: fn(payload) };
} catch (e) {
// ★ 错误必须回传。不回传的话,调用方只能等到超时,
// 然后把「这个字段不存在」误判成「桥没装上」。
res = { id, ok: false, error: String((e as Error)?.message ?? e) };
}
window.dispatchEvent(new CustomEvent('page-bridge:res', { detail: res }));
});
})();

隔离世界这一侧:一个带 id 配对和超时的客户端

// src/bridge/client.ts —— 跑在隔离世界
/** 桥是同步执行的,正常一帧内就回。超时只兜底「全场无人应答」 */
const TIMEOUT_SYNC = 500;
export function callPage<T>(
method: string,
payload?: unknown,
timeoutMs = TIMEOUT_SYNC,
): Promise<T | null> {
return new Promise((resolve) => {
const id = `${method}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const wins = collectSameOriginWindows();
let done = false;
const onRes = (ev: Event) => {
const d = (ev as CustomEvent).detail;
if (!d || d.id !== id) return; // ★ 必须按 id 配对,否则并发请求会串答案
finish(d.ok ? (d.data as T) : null);
};
const finish = (v: T | null) => {
if (done) return; // ★ 广播取首个应答,后到的丢弃
done = true;
for (const w of wins) {
try { w.removeEventListener('page-bridge:res', onRes); } catch { /* 跨域 */ }
}
resolve(v);
};
for (const w of wins) {
try { w.addEventListener('page-bridge:res', onRes); } catch { /* 跨域 */ }
}
for (const w of wins) {
try {
w.dispatchEvent(new CustomEvent('page-bridge:req', { detail: { id, method, payload } }));
} catch { /* 跨域 frame,忽略 */ }
}
setTimeout(() => finish(null), timeoutMs);
});
}
/** 本 frame + top + 所有同源子 frame —— 目标节点可能渲在任意一个里 */
function collectSameOriginWindows(): Window[] {
const out: Window[] = [];
const seen = new Set<Window>();
const push = (w?: Window | null) => {
if (w && !seen.has(w)) { seen.add(w); out.push(w); }
};
push(window);
try { push(window.top); } catch { /* top 跨域,读不到 */ }
try {
const walk = (w: Window) => {
push(w);
for (let i = 0; i < w.frames.length; i++) {
try { walk(w.frames[i]); } catch { /* 跨域子 frame,跳过 */ }
}
};
if (window.top) walk(window.top);
} catch { /* ignore */ }
return out;
}

调用侧就很普通了:

const customerId = await callPage<string>('readFiberProp', {
selector: 'form',
propPath: 'record.customerId',
});

桥的六条纪律

这几条全是真吃过亏换来的。

1. detail 里只放纯数据

跨世界传的对象会被克隆。函数、DOM 节点、类实例都过不去,Map / Set 之类也别指望原样到达。只放字符串、数字、布尔和普通对象数组。

这个约束反过来是好事:它强迫你把桥的接口设计成「请求 → 纯数据结果」,而不是把一个活对象丢过去让对面随便调。

2. 必须按请求 id 配对

桥是广播式的——你不知道目标在哪个 frame,所以同一个请求发给所有同源 frame,谁有谁答。没有 id 配对的话,两个并发请求会互相吃掉对方的响应,症状是偶发的、无法稳定复现的错值。

3. 超时要按真实分布定,不能一个常数打天下

我们的调试桥最初用 8 秒统一超时,因为所有调用都是纯本地的、毫秒级。后来有一条链路后面挂了 LLM——最多四轮、每轮往返可达 25 秒——8 秒是必然超时。

要命的是:那是桥的超时,不是链路失败。链路其实在正常跑,只是没人等它了。排查时极易误判成「编排挂了」。

所以现在是两档:

const TIMEOUT_SYNC = 500; // 桥内同步执行
const TIMEOUT_ASYNC = 150_000; // 桥后面挂了异步链路(LLM / 网络)

4. 注入脚本必须幂等

SPA 路由切换、frame 重挂、手动重新注入,都可能让同一份脚本跑第二遍。没有 __installed 守卫的话,监听器会叠加,一个请求收到多份响应。

5. 用 postMessage 的话,记得校验来源

如果你的桥走 window.postMessage(比如想在 Console 里暴露一个调试入口),必须校验:

window.addEventListener('message', (ev) => {
const d = ev.data;
if (!d || d.__extBridgeRes !== true || ev.source !== window) return; // ★
// ...
});

否则任意 iframe 或页面脚本都能伪造响应。

6. 主世界那一侧永远是不可信的

这是最重要的一条。主世界的代码,页面自己能看见、能改、能调。

所以我们那个挂在主世界的调试入口 window.__extTools,只是一个转发代理——它把调用 postMessage 给隔离世界,所有的权限判断和安全闸都留在隔离世界执行。主世界那份一行判断都没有。

一旦你在主世界写了「如果有权限就执行」,那就等于没写。

顺带一提:主世界连日志都打不了

项目里有一套统一的日志层,带 scope 前缀,受一个开关控制,开关读的是 chrome.storage。

主世界没有 chrome.storage。 日志层在那里读不到开关,会退回默认值(关闭)。

于是全项目唯一一句保留下来的裸 console.log,就在主世界的桥里:

// ⚠ 本条故意不走统一日志层(全项目唯一的例外):
// 这里是主世界,没有 chrome.storage —— 日志层读不到开关会退回默认的「关」,
// 而这条恰恰是告诉人「调试接口已就绪」的提示,默认不显示就失去意义。
console.log('[ext][bridge] 主世界调试接口已就绪');

这种例外一定要在代码里写清楚为什么,否则下一个做日志规范化的人(很可能是三个月后的你)会顺手把它也改掉。

第三个世界

前面一直在讲主世界和隔离世界,还有第三个:扩展自己的页面——Service Worker、设置页、Chrome 原生侧栏。

它们跑在 chrome-extension:// 源上,有完整的 chrome.* 权限,但够不到宿主页面的 DOM。一个字符都读不到。

所以侧栏里的 AI 助手要读页面上的会话内容,路径是这样的:

侧栏(扩展页面)
→ chrome.runtime 消息
→ 内容脚本(隔离世界)
→ DOM 事件
→ 桥(主世界)→ 读 fiber

四段路,三次跨界。听起来很绕,但每一段的职责都是清楚的,而且每一段都在它唯一能做这件事的地方。

一句话版本

共享 DOM,不共享 JS。

需要 DOM 就随便读;需要页面 JS 对象就过桥;需要 chrome.* 就回隔离世界。

判断题永远只有一道:「我现在要拿的这个东西,是页面自己造的,还是浏览器给扩展的?」

碰 fiber、碰 window.<页面全局>、碰任何前端框架内部结构——一律先想桥。在 Console 里试通了不算数。


完整代码:/showcase/main-world-bridge —— 两个文件、零依赖,附「读 React fiber」和「调页面全局对象」两个用例,过了 tsc --strict 与 8 项协议往返测试。