全部作品

Udesk 系列 · 示范代码

主世界桥

让扩展的内容脚本拿到页面 JS 对象 —— 请求 / 响应式跨世界桥,含 React fiber 与页面全局两个用例

安装 复制即用 · 零依赖 · 两个文件 次浏览

主世界桥

Chrome 扩展的内容脚本跑在隔离世界:它和页面共享同一棵 DOM,但不共享 JS 堆。页面脚本挂在 DOM 节点上的属性(React fiber)、挂在 window 上的对象(编辑器实例、SDK),隔离世界一个都读不到。

这两个文件是一座桥:主世界放一个应答端,隔离世界放一个调用端,中间用 DOM 事件通信。

  • 零依赖,两个文件,TypeScript(去掉类型标注就是可用的 JS)
  • 广播到所有同源 frame,取首个应答——目标节点在哪个 iframe 里都能找到
  • 请求 id 配对、超时可分档、错误原样回传(不让调用方靠超时去猜失败原因)
  • 同步 / 异步 handler 都支持,调用方不必知道区别

📖 原理、两种方向的误判、以及桥的六条纪律 → 内容脚本的三个世界

怎么用

1. manifest 里注册两份脚本,只差一个 world 字段:

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 → 默认隔离世界
},
],

2. 在隔离世界里调用:

import { readFiberProp, callPageGlobal, bridgeAlive } from './bridge/client';
// 用例 1:读某个 DOM 节点所属 React 组件 props 里的字段
const id = await readFiberProp<string>('form', 'record.customerId');
// 用例 2:调页面自己的全局方法(this 会自动绑到宿主对象上)
const r = await callPageGlobal('editor.setContent', '<p>hello</p>');
if (!r.ok) console.warn('页面全局调用失败:', r.error);
// 降级判断:桥在不在?比等一次超时快得多
if (!(await bridgeAlive())) { /* 走不依赖主世界的兜底路径 */ }

3. 要加自己的能力,就往 main-world.ts 的 handlers 表里加一项——它是桥的全部「业务面」。

⚠ 主世界那一侧是页面可见、可改、可调的,永远不可信。 所有权限判断、存储、网络都留在隔离世界那一侧。在 main-world.ts 里写「如果有权限才执行」等于没写。

验证

两个文件都过了 tsc --strict,协议层另有 8 项往返测试(存在性探针、深层 fiber 读取、未命中返回 null 而非超时、this 绑定、未知方法与 handler 抛错的错误回传、10 并发 id 配对不串、无人应答按时超时)。

代码

src/bridge/main-world.ts
/**
* main-world.ts —— 注入进「页面主世界」的应答端。
*
* 它负责一切「只有主世界能做的事」:读挂在 DOM 节点上的框架内部数据(React fiber)、
* 调页面自己的全局对象。它**没有** chrome.* 权限,也不该有任何权限判断——
* 页面能看见、能改、能调这份代码,在这里写安全闸等于没写。
*
* manifest 里必须声明 world: "MAIN",否则它还是跑在隔离世界,等于没造桥。
*/
/** 事件名带上产品前缀,避免和宿主页面自己的事件撞车。 */
const REQ = 'page-bridge:req';
const RES = 'page-bridge:res';
interface BridgeReq {
id: string;
method: string;
payload?: unknown;
}
interface BridgeRes {
id: string;
ok: boolean;
data?: unknown;
error?: string;
}
type Handler = (payload: any) => unknown | Promise<unknown>;
/* ------------------------------------------------------------------ *
* 用例 1:读 React fiber
* ------------------------------------------------------------------ */
const FIBER_KEY_PREFIXES = ['__reactFiber$', '__reactInternalInstance$'];
/** 取 DOM 节点上的 fiber。隔离世界里这个函数恒返回 null——那正是要造桥的原因。 */
function readFiber(node: Element): any | null {
for (const k of Object.getOwnPropertyNames(node)) {
for (const p of FIBER_KEY_PREFIXES) {
if (k.startsWith(p)) return (node as any)[k];
}
}
return null;
}
function getPath(root: unknown, path: string): unknown {
return path.split('.').reduce<any>((o, k) => (o == null ? o : o[k]), root);
}
/* ------------------------------------------------------------------ *
* 处理器表:按需增删,这里是桥的全部「业务面」
* ------------------------------------------------------------------ */
const handlers: Record<string, Handler> = {
/**
* 沿 fiber 链向上爬,找第一个 memoizedProps 里带目标路径的节点。
* 组件树有多深就爬多深——但要设上限,别把 bug 变成死循环。
*/
readFiberProp({ selector, propPath, maxDepth = 80 }: {
selector: string;
propPath: string;
maxDepth?: number;
}) {
const node = document.querySelector(selector);
if (!node) return null;
let cur = readFiber(node);
for (let d = 0; cur && d < maxDepth; d++, cur = cur.return) {
const v = getPath(cur.memoizedProps, propPath);
if (v != null) return v;
}
return null;
},
/** 调页面自己的全局对象:编辑器实例、SDK、埋点上报…… */
callPageGlobal({ path, args }: { path: string; args?: unknown[] }) {
const fn = getPath(window, path);
if (typeof fn !== 'function') {
throw new Error(`page global not callable: ${path}`);
}
// 取 fn 的宿主对象当 this,否则形如 editor.setContent 的方法会丢上下文
const dot = path.lastIndexOf('.');
const host = dot < 0 ? window : getPath(window, path.slice(0, dot));
return (fn as (...a: unknown[]) => unknown).apply(host, args ?? []);
},
/** 存在性探针:调用方可以先问一句「你在吗」,比等超时快得多 */
ping() {
return { alive: true, href: location.href };
},
};
/* ------------------------------------------------------------------ *
* 应答循环
* ------------------------------------------------------------------ */
function install(): void {
// 幂等守卫:SPA 路由、frame 重挂、手动重注入都可能让同一份脚本跑第二遍。
// 少了它,监听器会叠加,一个请求收到多份响应。
if ((window as any).__pageBridgeInstalled) return;
(window as any).__pageBridgeInstalled = true;
const reply = (res: BridgeRes) => {
window.dispatchEvent(new CustomEvent(RES, { detail: res }));
};
window.addEventListener(REQ, (ev: Event) => {
const req = (ev as CustomEvent).detail as BridgeReq | undefined;
if (!req || typeof req.id !== 'string') return;
let out: unknown;
try {
const fn = handlers[req.method];
if (!fn) throw new Error(`unknown method: ${req.method}`);
out = fn(req.payload);
} catch (e) {
// 错误必须回传。不回传的话调用方只能等到超时,
// 然后把「这个字段不存在」误判成「桥根本没装上」。
reply({ id: req.id, ok: false, error: String((e as Error)?.message ?? e) });
return;
}
// 同步结果直接回;异步的等 Promise。两种都支持,调用方不必知道区别。
if (out instanceof Promise) {
out.then(
(data) => reply({ id: req.id, ok: true, data }),
(e) => reply({ id: req.id, ok: false, error: String((e as Error)?.message ?? e) }),
);
} else {
reply({ id: req.id, ok: true, data: out });
}
});
}
install();
src/bridge/client.ts
/**
* client.ts —— 跑在扩展「隔离世界」的调用端。
*
* 它有 chrome.* 权限但读不到页面 JS 对象;主世界那份正相反。两边共享同一棵 DOM,
* 所以用 DOM 事件当信道。
*
* 所有权限判断、存储、网络都留在这一侧——主世界那份是页面可见的,不可信。
*/
const REQ = 'page-bridge:req';
const RES = 'page-bridge:res';
/** 桥内同步执行:正常一帧内就回,超时只兜底「全场无人应答」。 */
export const TIMEOUT_SYNC = 500;
/** 桥后面挂了异步链路(网络、模型调用)时用这档——别拿同步的常数去量异步的活。 */
export const TIMEOUT_ASYNC = 150_000;
export type BridgeResult<T> =
| { ok: true; data: T }
| { ok: false; error: string };
export interface CallOptions {
timeoutMs?: number;
/** 只在当前 frame 问,不广播。目标位置确定时用,省掉遍历。 */
currentFrameOnly?: boolean;
}
interface ResDetail {
id: string;
ok: boolean;
data?: unknown;
error?: string;
}
/**
* 向主世界发一次调用。
*
* 默认向「本 frame + top + 所有同源子 frame」广播,取第一个应答——因为你通常
* 不知道目标节点渲在哪个 frame 里。后到的响应直接丢弃。
*/
export function callPage<T = unknown>(
method: string,
payload?: unknown,
opts: CallOptions = {},
): Promise<BridgeResult<T>> {
const timeoutMs = opts.timeoutMs ?? TIMEOUT_SYNC;
return new Promise((resolve) => {
// id 必须唯一:桥是广播式的,没有配对的话并发请求会互相吃掉对方的响应,
// 症状是偶发且无法稳定复现的错值。
const id = `${method}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const wins = opts.currentFrameOnly ? [window] : collectSameOriginWindows();
let done = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = (r: BridgeResult<T>) => {
if (done) return;
done = true;
if (timer !== undefined) clearTimeout(timer);
for (const w of wins) {
try {
w.removeEventListener(RES, onRes);
} catch {
/* frame 已卸载 */
}
}
resolve(r);
};
const onRes = (ev: Event) => {
const d = (ev as CustomEvent).detail as ResDetail | undefined;
if (!d || d.id !== id) return;
finish(d.ok ? { ok: true, data: d.data as T } : { ok: false, error: d.error ?? 'bridge error' });
};
for (const w of wins) {
try {
w.addEventListener(RES, onRes);
} catch {
/* 跨域 frame,忽略 */
}
}
for (const w of wins) {
try {
w.dispatchEvent(new CustomEvent(REQ, { detail: { id, method, payload } }));
} catch {
/* 跨域 frame,忽略 */
}
}
timer = setTimeout(
() => finish({ ok: false, error: `bridge timeout after ${timeoutMs}ms` }),
timeoutMs,
);
});
}
/**
* 本 frame + top + 所有同源子 frame。
*
* 每一步都要 try/catch:跨域 frame 上访问 `.frames` 或 `window.top` 会抛
* SecurityError,一次没接住就整条链路挂掉。
*/
export 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;
}
/* ------------------------------------------------------------------ *
* 两个用例的便捷封装
* ------------------------------------------------------------------ */
/** 用例 1:读某个 DOM 节点所属 React 组件 props 里的字段。 */
export async function readFiberProp<T = unknown>(
selector: string,
propPath: string,
): Promise<T | null> {
const r = await callPage<T>('readFiberProp', { selector, propPath });
return r.ok ? r.data : null;
}
/** 用例 2:调页面自己的全局方法,例如 `editor.setContent`。 */
export async function callPageGlobal<T = unknown>(
path: string,
...args: unknown[]
): Promise<BridgeResult<T>> {
return callPage<T>('callPageGlobal', { path, args });
}
/** 桥在不在?比等一次超时快得多,适合做降级判断。 */
export async function bridgeAlive(): Promise<boolean> {
const r = await callPage<{ alive: boolean }>('ping', undefined, { timeoutMs: 200 });
return r.ok && r.data?.alive === true;
}

原理与踩坑 → 内容脚本的三个世界:为什么 DevTools 里能跑,扩展里就是拿不到 React fiber

ESC