创见博客
跨源 iframe 通信实战:用探测脚本 + postMessage 把内部信息送回父页面
七崽爱吃小饼干2026/09/21阅读 0

上一篇《DOM 与源码映射调研》解决的是「怎么知道一个元素来自哪行源码」。那是页面内部的事。

但真实工具里还有一个前置问题:那个页面根本不在你的页面里,它跑在一个 iframe 里,而且是跨源的。

于是整条链路变成:

text
父页面(你的工具)
  └─ iframe(http://localhost:3000,别人的 dev server)
        └─ 用户在里面的页面上点了一个按钮
             我们怎么知道?点的是哪个元素?它对应哪行源码?

这篇讲这条链路怎么打通:探测脚本 + postMessage。包括协议怎么设计、脚本怎么进 iframe、以及那些不写会出事的安全校验。

一、先把边界画清楚:同源策略挡了什么

大多数人第一次尝试都会写这段代码,然后失败:

js
const iframe = document.querySelector('iframe')

iframe.contentDocument              // null
iframe.contentWindow.document       // 抛 SecurityError
iframe.contentWindow.document.body  // 更不可能

只要 iframe 的源和父页面不同,浏览器的同源策略就会把内部文档整个遮住:

操作同源跨源
拿到 iframe.contentWindow 这个对象✅✅(只是个代理对象)
读 contentDocument / contentWindow.document✅❌ null 或抛错
读内部 DOM、算 getBoundingClientRect✅❌
调内部页面的函数、读它的变量✅❌
iframe.contentWindow.postMessage()✅✅
接收内部页面的 postMessage✅✅

最后两行是关键:

同源策略禁掉的是「直接访问」,没有禁掉「发消息」。

postMessage 是浏览器专门为跨文档通信留的合法通道。所以结论很直接:

想拿到 iframe 内部的信息,采集信息的代码必须在 iframe 内部运行,然后把结果发出来。 父页面在外部隔墙观察,是拿不到任何东西的。

这就是「探测脚本」存在的全部理由。

二、整体方案:三段式

text
① 父页面 ──「开启选取模式」控制指令──▶ 探测脚本
② 探测脚本 ──「用户点了这个元素,信息如下」数据──▶ 父页面
③ 父页面 ──拿到信息,做它该做的事(查源码位置、填输入框…)

两个方向都需要,缺一不可:

  • 父 → 子:父页面要能控制什么时候开始选、什么时候取消。没有这条,脚本无法区分「用户在正常使用页面」和「用户在选择元素」;
  • 子 → 父:数据上行。这条是主要目的。

难点不在协议本身,而在于第一步:脚本怎么进去。先解决这个。

三、脚本怎么进 iframe(最难的一步)

既然父页面读不到跨源的 iframe,那它也没法从外部注入脚本。这是同一堵墙的两面。

可选路径只有三条:

方案做法适用代价
A. 目标页面自己加载在目标项目的 index.html 加一行 <script src="...">,或用 Vite/Next 插件在 dev 时注入你能改目标项目需要改动目标项目;每个项目都要配一次
B. 代理 + HTML 改写父页面不直接嵌 localhost:3000,而是嵌自己宿主的 /preview?url=localhost:3000,由宿主抓取 HTML、注入脚本后再吐出来目标项目零改动需要写一个反向代理;会引入「相对路径 API 打到代理源站」等问题
C. srcdoc / 同源直接 iframe.srcdoc = ...,或嵌同源页面本地 demo、自己写的内容只适合自己造的内容,嵌不了真实项目

方案 B 的注入逻辑大致是:

js
// 宿主侧:抓到目标页的 HTML 后
let html = await (await fetch(targetUrl)).text()

// 1. 修相对路径:让资源请求回到真实 dev server
html = html.replace(/<head([^>]*)>/i, `<head$1><base href="${targetOrigin}/">`)

// 2. 记住真实地址,供脚本上报
html = html.replace(/<head([^>]*)>/i,
  `<head$1><meta name="probe-real-url" content="${targetUrl}">`)

// 3. 注入探测脚本
html = html.replace(/<\/body>/i, `<script src="/probe.js"></script></body>`)

同时要处理几个头,否则注入会静默失效:

  • 剥掉 Content-Security-Policy(script-src 'self' 会拦掉你的脚本)
  • 剥掉 X-Frame-Options 和 CSP 的 frame-ancestors(否则页面根本嵌不进来)
  • 剥掉 Content-Encoding / Content-Length(你改了 body,长度和压缩都变了)

一个必须提前知道的代价:代理模式下,页面里的相对路径请求(fetch('/api/xxx'))会打到你的源站,而不是 dev server。<base> 能救回相对路径的资源加载,救不回所有 API 调用。这是方案 B 的结构性代价,不是 bug。

顺带一提:方案 B 下 iframe 和父页面其实是同源的,contentDocument 可以直接访问。但我依然建议走 postMessage,理由见第八节。

四、协议设计

不要裸发数据。先定一个信封,把所有消息塞进同一个命名空间:

ts
interface Envelope<T = unknown> {
  ns: 'probe'      // 命名空间,避免和页面自身的 postMessage 撞车
  v: 1             // 协议版本
  type: string     // 消息类型
  payload?: T
}

消息表(父 → 子):

typepayload说明
ping—父页面未收到 ready 前的重试握手
pick-mode{ on: boolean }开/关选取模式

消息表(子 → 父):

typepayload说明
ready{ url, origin }脚本就绪,并上报自己的真实地址与源
pick{ …元素信息 }用户选中了一个元素
pick-cancelled—用户按 Esc 取消
error{ message }脚本内部异常,方便父页面提示

为什么必须有 ready 握手、而不能等 iframe 的 load 事件就发指令?

因为 iframe.onload 触发时,你注入的脚本可能还没执行完(脚本可能是 async、可能在 DOMContentLoaded 之后才初始化,或者目标项目是 SPA,路由还没渲染)。父页面在这个时间点 postMessage,消息会直接丢掉——没有队列,没有重发。

可靠做法是从父页面主动重试,直到收到 ready:

js
let ready = false
const timer = setInterval(() => {
  if (ready) return clearInterval(timer)
  post({ type: 'ping' })
}, 300)

五、父页面这一侧

js
const NS = 'probe'
const iframe = document.querySelector('#preview')

let childOrigin = null   // 学到子页面的源之前,只能发 '*'(仅限握手)
let ready = false

// ---------- 发消息 ----------
function post(msg) {
  const target = childOrigin ?? '*'
  iframe.contentWindow.postMessage({ ns: NS, v: 1, ...msg }, target)
}

// ---------- 收消息 ----------
window.addEventListener('message', (event) => {
  const msg = event.data

  // 1. 信封校验
  if (!msg || typeof msg !== 'object' || msg.ns !== NS || msg.v !== 1) return

  // 2. 来源校验:必须来自这个 iframe 的窗口对象
  if (event.source !== iframe.contentWindow) return

  // 3. 源校验:当前只接受 loopback 源(按需收紧/放宽)
  if (!isAllowedOrigin(event.origin)) return

  switch (msg.type) {
    case 'ready':
      childOrigin = event.origin    // 从此只往这个源发
      ready = true
      setPicking(true)
      break

    case 'pick':
      onPick(msg.payload)
      setPicking(false)             // 单次选取后退出
      break

    case 'pick-cancelled':
      setPicking(false)
      break
  }
})

function isAllowedOrigin(origin) {
  if (origin === 'null') return false             // 见第七节:不透明源
  try {
    const { hostname } = new URL(origin)
    return hostname === 'localhost'
      || hostname === '127.0.0.1'
      || hostname === '[::1]'
  } catch { return false }
}

function setPicking(on) {
  post({ type: 'pick-mode', payload: { on } })
}

六、探测脚本这一侧

探测脚本是一个零依赖 IIFE,会被注入到目标页面里,所以:不要用 ESM、不要 import、样式全部内联、变量全部包在闭包里。

js
;(function () {
  // 不在 iframe 里就什么都不做,方便同一份脚本在独立标签页里也不出错
  if (window.parent === window) return

  const NS = 'probe'
  const V = 1

  // 握手阶段还不知道父页面的源,只能用 '*';收到第一条合法消息后锁定
  let parentOrigin = '*'
  const send = (type, payload) =>
    window.parent.postMessage({ ns: NS, v: V, type, payload }, parentOrigin)

  let picking = false
  let target = null      // 当前高亮的元素
  let raf = 0

  // ---------- 高亮层:两个 pointer-events:none 的浮层 ----------
  const box = document.createElement('div')
  const tip = document.createElement('div')
  Object.assign(box.style, {
    position: 'fixed', pointerEvents: 'none', zIndex: 2147483646, display: 'none',
    border: '1px solid #4f7cff', background: 'rgba(79,124,255,.12)', borderRadius: '2px',
  })
  Object.assign(tip.style, {
    position: 'fixed', pointerEvents: 'none', zIndex: 2147483647, display: 'none',
    font: '11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace', color: '#fff',
    background: 'rgba(17,17,17,.92)', padding: '2px 6px', borderRadius: '4px',
    maxWidth: '70vw', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
  })
  document.documentElement.append(box, tip)

  function paint(el) {
    const r = el.getBoundingClientRect()
    box.style.display = 'block'
    box.style.left = r.left + 'px'
    box.style.top = r.top + 'px'
    box.style.width = r.width + 'px'
    box.style.height = r.height + 'px'

    const info = describe(el)
    tip.style.display = 'block'
    tip.style.left = Math.max(4, r.left) + 'px'
    tip.style.top = (r.top > 24 ? r.top - 22 : r.bottom + 4) + 'px'
    tip.textContent = info.source
      ? `${info.source.file}:${info.source.line}:${info.source.column}`
      : `<${info.tag}> ${info.component ?? ''}`.trim()
  }

  function show(el) {
    target = el
    cancelAnimationFrame(raf)
    raf = requestAnimationFrame(() => paint(el))
  }

  function hide() {
    target = null
    box.style.display = 'none'
    tip.style.display = 'none'
  }

  // ---------- 元素信息采集 ----------
  function describe(el) {
    const found = findSource(el)
    const r = el.getBoundingClientRect()
    return {
      tag: el.tagName.toLowerCase(),
      id: el.id || undefined,
      className: typeof el.className === 'string' ? el.className.slice(0, 160) : undefined,
      text: (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 80),
      rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) },
      component: found?.component,
      source: found?.location,
    }
  }

  // 分层探测:见第九节
  function findSource(el) { /* … */ }

  // ---------- 事件 ----------
  function onMove(e) {
    if (!picking) return
    const el = e.target
    if (el instanceof Element && el !== target) show(el)
  }

  // 选取状态下吃掉页面自身的交互:不 preventDefault 的话,
  // 点一个链接会把 iframe 导航走,前面全白干
  function onMouseDown(e) {
    if (!picking) return
    e.preventDefault()
    e.stopImmediatePropagation()
  }

  function onClick(e) {
    if (!picking) return
    e.preventDefault()
    e.stopImmediatePropagation()
    const el = e.target
    if (!(el instanceof Element)) return
    send('pick', describe(el))
    setPicking(false)
  }

  function onKeyDown(e) {
    if (!picking) return
    if (e.key === 'Escape') {
      e.preventDefault()
      e.stopImmediatePropagation()
      send('pick-cancelled')
      setPicking(false)
    }
  }

  // 滚动发生在内层容器时不会冒泡到 window,所以要 capture
  function onScroll() {
    if (picking && target) paint(target)
  }

  function setPicking(on) {
    if (on === picking) return
    picking = on
    if (on) {
      document.addEventListener('mousemove', onMove, true)
      document.addEventListener('mousedown', onMouseDown, true)
      document.addEventListener('click', onClick, true)
      document.addEventListener('keydown', onKeyDown, true)
      window.addEventListener('scroll', onScroll, { capture: true, passive: true })
      document.documentElement.style.cursor = 'crosshair'
    } else {
      document.removeEventListener('mousemove', onMove, true)
      document.removeEventListener('mousedown', onMouseDown, true)
      document.removeEventListener('click', onClick, true)
      document.removeEventListener('keydown', onKeyDown, true)
      window.removeEventListener('scroll', onScroll, true)
      document.documentElement.style.cursor = ''
      hide()
    }
  }

  // ---------- 接收父页面指令 ----------
  window.addEventListener('message', (event) => {
    if (event.source !== window.parent) return
    const msg = event.data
    if (!msg || typeof msg !== 'object' || msg.ns !== NS || msg.v !== V) return

    // 学到父页面的源之后锁死,不再接受其他源的指令
    if (parentOrigin === '*') parentOrigin = event.origin || '*'

    if (msg.type === 'ping') send('ready', { url: location.href, origin: location.origin })
    if (msg.type === 'pick-mode') setPicking(!!msg.payload?.on)
  })

  // ---------- 就绪上报 ----------
  const announce = () => send('ready', { url: location.href, origin: location.origin })
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', announce, { once: true })
  } else {
    announce()
  }
})()

七、安全检查清单

postMessage 的历史漏洞基本都出在「收消息时太信任对方」。这几条不是可选项。

1. 发消息永远不要用 '*'(除了握手的第一次)

'*' 意味着「任何当前/未来的文档都可以收到这条消息」。如果 iframe 中途被导航到别的站点,你的控制指令会直接发给那个站点。正确做法是收到 ready 后从 event.origin 学到真实源并锁定,之后一直用它。

2. 收消息必须同时校验 event.origin 和 event.source

js
if (event.source !== iframe.contentWindow) return   // 挡住同页面的其他 iframe
if (!isAllowedOrigin(event.origin)) return          // 挡住别的站点

只校验 origin 不够:页面上可能有多个 iframe,或者攻击者能用 window.open 拿到你的引用。event.source 的身份比对是关键一道。

3. 不要信任 payload

上行的元素信息、尤其是文件路径,是来自被预览页面的数据。父页面(或宿主侧)必须再校验一遍:路径要归一化、要确认落在工作区内、不能有路径穿越。把「iframe 说了什么」当成用户输入,而不是可信输入。

4. 小心不透明源(origin 为 'null')

这些情况下 event.origin 会是字符串 "null":

  • iframe 带 sandbox 但没有 allow-same-origin
  • srcdoc 且没有 allow-same-origin
  • data: URL

"null" 的语义是「不透明源」,没有任何办法判断它是谁。如果你在 isAllowedOrigin 里放行 'null',等于放行任意站点。而且此时你也无法用具体 targetOrigin 回复,只能回 '*',双向都失去校验能力。

结论:预览受信任的本地项目时,不要加 sandbox。非要加就用 sandbox="allow-scripts allow-forms allow-same-origin",并接受相应的风险。

5. 嵌入本身可能被拒

如果目标页返回了 X-Frame-Options: DENY 或 CSP 的 frame-ancestors 'none',iframe 会白屏。代理模式可以剥掉这两个头,直连模式无解(只能在 DevTools 控制台看到报错)。

6. CSP 会拦掉你的注入脚本

目标页若有 script-src 'self',你注入的 <script src="/probe.js"> 会被静默拦截,表现为「脚本没生效但也不报错」。代理模式剥头,方案 A 则要在目标项目里显式允许。

八、同源了,还需要 postMessage 吗?

方案 B(代理)下父页面和 iframe 是同源的,contentDocument 直接可读。理论上你可以完全不用 postMessage,直接绑事件:

js
iframe.contentDocument.addEventListener('click', handler)

但建议不要这么做。 三个理由:

  1. 架构会被锁死。将来换成直连(跨源),或者目标页加了 sandbox,这套代码全部作废;而 postMessage 版本一行都不用改;
  2. 耦合了内部 DOM 结构。父页面开始依赖 iframe 内部的选择器、节点时序,脆得很;
  3. 一致的心智模型。同一套协议在三种注入方案下都成立,调试时只需要看消息日志。

换句话说:postMessage 在这里不是权宜之计,而是接口边界。 探测脚本是一个独立的采集端,父页面是消费端,中间走消息——这个边界划清楚,后面换实现才不会牵一发动全身。

九、把元素信息接成源码位置

通信打通后,最后一步是 findSource(el) 的实现。按可信度从高到低分层,上一篇文章的六种方案在这里落地为一条探测链:

js
function findSource(el) {
  // L0:构建期注入的标记(精确,且和框架无关)
  const marked = el.closest('[data-source-file][data-source-line]')
  if (marked) {
    return {
      location: {
        file: marked.dataset.sourceFile,
        line: Number(marked.dataset.sourceLine),
        column: Number(marked.dataset.sourceColumn ?? 1),
      },
    }
  }

  // L1:React dev 构建的 fiber 调试信息
  const key = Object.keys(el).find((k) => k.startsWith('__reactFiber$'))
  if (key) {
    let fiber = el[key]
    let owner = null
    while (fiber) {
      if (!owner && fiber._debugOwner) owner = fiber._debugOwner
      const s = fiber._debugSource
      if (s && s.fileName) {
        return {
          location: { file: s.fileName, line: s.lineNumber, column: s.columnNumber },
          component: nameOf(owner),
        }
      }
      fiber = fiber._debugOwner ?? fiber.return
    }
  }

  // L1':Vue(文件精确,行号需要另外匹配模板文本)
  const vnode = el.__vueParentComponent
  if (vnode?.type?.__file) {
    return { location: { file: vnode.type.__file, line: 1, column: 1 },
             component: vnode.type.__name ?? vnode.type.name }
  }

  // L4:都失败就如实返回 null,交给父页面用文本/结构特征兜底
  return null
}

function nameOf(fiber) {
  let f = fiber
  while (f) {
    const t = f.type
    if (typeof t === 'function' && (t.displayName || t.name)) return t.displayName || t.name
    if (t && typeof t === 'object' && (t.displayName || t.render?.name)) return t.displayName || t.render.name
    f = f._debugOwner ?? f.return
  }
  return undefined
}

两个约定值得坚持:

  • 探测失败就返回 null,不要猜。 让父页面显式看到一个「未能定位」的结果,比给一个看起来对、其实是搜出来的行号要好得多;
  • 带上置信度。data-source-file 和 _debugSource 是精确的,文本搜索是推断的。UI 上要能区分,别把推断结果装扮成精确结果。

另外别忘了路径归一化:_debugSource.fileName 在不同工具链下长得完全不一样(webpack-internal:///./src/app/page.tsx、/@fs/Users/…、D:/work/app/src/Foo.vue、纯相对路径)。这一步放在父页面/宿主侧做,因为它才知道工作区根目录在哪。

十、踩坑清单

坑现象处理
父页面直接读 contentDocumentnull / SecurityError跨源无解,改走 postMessage
iframe 加载完就发指令指令静默丢失加 ready 握手 + 父侧 ping 重试
点击把 iframe 导航走整个页面刷新,探测中断capture 阶段 preventDefault + stopImmediatePropagation
内层容器滚动后高亮错位高亮框飘在空中scroll 用 { capture: true } 监听并重新定位
mousemove 太频繁页面卡顿requestAnimationFrame 节流 + { passive: true }
页面自身也发了 postMessage收到无关消息、解析报错信封里带 ns 和 v,先校验再处理
任何站点都能伪造 pick数据被注入校验 event.origin 和 event.source
用 '*' 回复控制指令泄漏给第三方站点从 ready 学到源后锁定
sandbox 导致 origin === 'null'源校验失效别给受信任的本地预览加 sandbox
CSP script-src 'self'脚本没执行也不报错代理模式剥 CSP 头
X-Frame-Options: DENYiframe 白屏代理剥头;直连无解
代理模式下 /api 打到代理页面数据错乱这是结构代价;必要时改走方案 A

小结

把这件事压缩成四句话:

  1. 跨源时父页面拿不到 iframe 内部任何东西,postMessage 是唯一的合法通道;
  2. 所以采集代码必须在 iframe 内部运行,「脚本怎么进去」是第一个要解决的问题,比协议本身重要;
  3. 协议不要裸发:加命名空间和版本,用 ready 握手解决时序,用 event.source + event.origin 双重校验解决安全;
  4. 能同源访问也照样走 postMessage——它不是绕路的权宜之计,而是探测端和消费端之间的接口边界。

搭好这条通信链路之后,上一篇讲的映射方案才有地方落地:findSource(el) 返回什么,取决于你能改到构建的哪一层。

评论
0/100