上一篇《DOM 与源码映射调研》解决的是「怎么知道一个元素来自哪行源码」。那是页面内部的事。
但真实工具里还有一个前置问题:那个页面根本不在你的页面里,它跑在一个 iframe 里,而且是跨源的。
于是整条链路变成:
父页面(你的工具)
└─ iframe(http://localhost:3000,别人的 dev server)
└─ 用户在里面的页面上点了一个按钮
我们怎么知道?点的是哪个元素?它对应哪行源码?
这篇讲这条链路怎么打通:探测脚本 + postMessage。包括协议怎么设计、脚本怎么进 iframe、以及那些不写会出事的安全校验。
一、先把边界画清楚:同源策略挡了什么
大多数人第一次尝试都会写这段代码,然后失败:
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 内部运行,然后把结果发出来。 父页面在外部隔墙观察,是拿不到任何东西的。
这就是「探测脚本」存在的全部理由。
二、整体方案:三段式
① 父页面 ──「开启选取模式」控制指令──▶ 探测脚本
② 探测脚本 ──「用户点了这个元素,信息如下」数据──▶ 父页面
③ 父页面 ──拿到信息,做它该做的事(查源码位置、填输入框…)
两个方向都需要,缺一不可:
- 父 → 子:父页面要能控制什么时候开始选、什么时候取消。没有这条,脚本无法区分「用户在正常使用页面」和「用户在选择元素」;
- 子 → 父:数据上行。这条是主要目的。
难点不在协议本身,而在于第一步:脚本怎么进去。先解决这个。
三、脚本怎么进 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 的注入逻辑大致是:
// 宿主侧:抓到目标页的 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,理由见第八节。
四、协议设计
不要裸发数据。先定一个信封,把所有消息塞进同一个命名空间:
interface Envelope<T = unknown> {
ns: 'probe' // 命名空间,避免和页面自身的 postMessage 撞车
v: 1 // 协议版本
type: string // 消息类型
payload?: T
}
消息表(父 → 子):
| type | payload | 说明 |
|---|---|---|
ping | — | 父页面未收到 ready 前的重试握手 |
pick-mode | { on: boolean } | 开/关选取模式 |
消息表(子 → 父):
| type | payload | 说明 |
|---|---|---|
ready | { url, origin } | 脚本就绪,并上报自己的真实地址与源 |
pick | { …元素信息 } | 用户选中了一个元素 |
pick-cancelled | — | 用户按 Esc 取消 |
error | { message } | 脚本内部异常,方便父页面提示 |
为什么必须有 ready 握手、而不能等 iframe 的 load 事件就发指令?
因为 iframe.onload 触发时,你注入的脚本可能还没执行完(脚本可能是 async、可能在 DOMContentLoaded 之后才初始化,或者目标项目是 SPA,路由还没渲染)。父页面在这个时间点 postMessage,消息会直接丢掉——没有队列,没有重发。
可靠做法是从父页面主动重试,直到收到 ready:
let ready = false
const timer = setInterval(() => {
if (ready) return clearInterval(timer)
post({ type: 'ping' })
}, 300)
五、父页面这一侧
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、样式全部内联、变量全部包在闭包里。
;(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
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-origindata: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,直接绑事件:
iframe.contentDocument.addEventListener('click', handler)
但建议不要这么做。 三个理由:
- 架构会被锁死。将来换成直连(跨源),或者目标页加了
sandbox,这套代码全部作废;而 postMessage 版本一行都不用改; - 耦合了内部 DOM 结构。父页面开始依赖 iframe 内部的选择器、节点时序,脆得很;
- 一致的心智模型。同一套协议在三种注入方案下都成立,调试时只需要看消息日志。
换句话说:postMessage 在这里不是权宜之计,而是接口边界。 探测脚本是一个独立的采集端,父页面是消费端,中间走消息——这个边界划清楚,后面换实现才不会牵一发动全身。
九、把元素信息接成源码位置
通信打通后,最后一步是 findSource(el) 的实现。按可信度从高到低分层,上一篇文章的六种方案在这里落地为一条探测链:
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、纯相对路径)。这一步放在父页面/宿主侧做,因为它才知道工作区根目录在哪。
十、踩坑清单
| 坑 | 现象 | 处理 |
|---|---|---|
父页面直接读 contentDocument | null / 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: DENY | iframe 白屏 | 代理剥头;直连无解 |
代理模式下 /api 打到代理 | 页面数据错乱 | 这是结构代价;必要时改走方案 A |
小结
把这件事压缩成四句话:
- 跨源时父页面拿不到 iframe 内部任何东西,
postMessage是唯一的合法通道; - 所以采集代码必须在 iframe 内部运行,「脚本怎么进去」是第一个要解决的问题,比协议本身重要;
- 协议不要裸发:加命名空间和版本,用
ready握手解决时序,用event.source+event.origin双重校验解决安全; - 能同源访问也照样走 postMessage——它不是绕路的权宜之计,而是探测端和消费端之间的接口边界。
搭好这条通信链路之后,上一篇讲的映射方案才有地方落地:findSource(el) 返回什么,取决于你能改到构建的哪一层。