Udesk 系列 · 示范代码
主世界桥
让扩展的内容脚本拿到页面 JS 对象 —— 请求 / 响应式跨世界桥,含 React fiber 与页面全局两个用例
主世界桥
Chrome 扩展的内容脚本跑在隔离世界:它和页面共享同一棵 DOM,但不共享 JS 堆。页面脚本挂在 DOM 节点上的属性(React fiber)、挂在 window 上的对象(编辑器实例、SDK),隔离世界一个都读不到。
这两个文件是一座桥:主世界放一个应答端,隔离世界放一个调用端,中间用 DOM 事件通信。
- 零依赖,两个文件,TypeScript(去掉类型标注就是可用的 JS)
- 广播到所有同源 frame,取首个应答——目标节点在哪个 iframe 里都能找到
- 请求 id 配对、超时可分档、错误原样回传(不让调用方靠超时去猜失败原因)
- 同步 / 异步 handler 都支持,调用方不必知道区别
📖 原理、两种方向的误判、以及桥的六条纪律 → 内容脚本的三个世界
怎么用
1. manifest 里注册两份脚本,只差一个 world 字段:
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 配对不串、无人应答按时超时)。
代码
/** * 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();/** * 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;}