---
title: "主世界桥"
url: "https://turingblog.org/notes/patterns/main-world-bridge"
date: 2026-09-22
tags: ["Chrome扩展","MV3","TypeScript"]
description: "让扩展的内容脚本拿到页面 JS 对象 —— 请求 / 响应式跨世界桥，含 React fiber 与页面全局两个用例"
insight: "让扩展的内容脚本拿到页面 JS 对象 —— 请求 / 响应式跨世界桥，含 React fiber 与页面全局两个用例"
problem: "扩展的内容脚本跑在隔离世界，拿不到页面脚本挂在 DOM 节点上的 JS 对象（React fiber、页面全局变量等）。"
---

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

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

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

> 📖 原理、两种方向的误判、以及桥的六条纪律 → [内容脚本的三个世界](/blog/mv3-three-worlds)

## 怎么用

**1. manifest 里注册两份脚本，只差一个 `world` 字段：**

```ts
// 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. 在隔离世界里调用：**

```ts
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 配对不串、无人应答按时超时）。

## 代码

```ts
// 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();
```

```ts
// 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](/blog/mv3-three-worlds)
