Appearance
iframe:显式跨窗口 RPC
@axewo/fin-micro/iframe 提供独立、双向的 postMessage 请求响应端点,不依赖 Vue,也不会读取全局 window。它适合给明确受信任的父子窗口暴露少量业务方法。
它不是 AppBridge 的透明远程代理,不是页签路由、导航队列或页面缓存代理。 ready、状态订阅、会话订阅、事件总线、PageSDK、函数守卫和 keep-alive 不会自动跨窗口工作。如果只是 Wujie 注入的同窗口业务桥,优先阅读 宿主通信,不必额外套一层 RPC。
1. API 与边界
ts
import {
createIframeRpc,
exposeHostCapabilities,
IframeRpcError,
} from '@axewo/fin-micro/iframe'
import type {
IframeRpc,
IframeRpcOptions,
IframeRequestOptions,
IframeHandlerContext,
IframeRpcHandler,
IframeRpcErrorCode,
} from '@axewo/fin-micro/iframe'端点接口:
ts
interface IframeRpc {
request<T = unknown>(
method: string,
args: unknown[],
options?: { timeoutMs?: number; signal?: AbortSignal },
): Promise<T>
expose(
method: string,
handler: (args: unknown[], context: { readonly signal: AbortSignal }) =>
unknown | Promise<unknown>,
): () => void
dispose(): void
}request的第二参数是参数数组,不是展开参数。返回值泛型只作本地静态断言,不校验远端结果。expose每个方法只能有一个 handler;重复注册、名称无效或 handler 非函数同步抛TypeError。返回幂等注销函数,只移除该注册,不取消已执行的 handler。- 双方都可
expose和request,不限定父调用子。 - 方法名、
channel、sessionId都要求字符串长度在1~128;不要以为模块会校验业务命名规则。 dispose()幂等,释放本端监听、计时器、待完成请求和入站 signal,不负责卸载 iframe 或关闭窗口。
2. 必须显式约定连接参数
| 配置 | 父端示例 | 子端示例 |
|---|---|---|
localWindow | 父页面的 window | iframe 内的 window |
remoteWindow | 对应 iframe.contentWindow | window.parent |
remoteOrigin | 子页面精确 origin | 父页面精确 origin |
channel | 双方相同的业务通道字符串 | 与父端相同 |
sessionId | 每次连接新建并显式交给子端 | 使用父端本次传入的值 |
接收消息时同时检查 event.source、event.origin、协议标识、版本、channel、sessionId 和消息结构。不同条件不能相互替代。
remoteOrigin 必须严格等于 URL 解析后的 HTTP(S) origin,例如 http://localhost:5174。以下均不接受:*、null、https://example.com/、带路径/query/hash 的 URL、非 HTTP(S) 协议,以及与规范化 origin 不相等的文本。即使 / 看起来无害,也不是精确 origin 字符串。
模块没有自动发现、握手、重连或 session 生成。双方必须在发送业务请求前安装端点和方法。不要复用同一窗口对、同一 channel/session 创建多个端点,否则同一消息可能被多次处理。新挂载或重连应生成新 session,并释放旧端点。
3. 可复制的双端示例
假设父页面运行在 http://localhost:5173,子页面运行在 http://localhost:5174。子页面 /iframe.html 的入口加载下面的 child.ts。实际部署时同时替换两个固定 origin,不要从任意消息中学习信任对象。
这个例子用 URL query 传递非秘密的连接 sessionId,channel 与允许的父源均固定在代码中。sessionId 不是认证密钥,不能用来替代登录态或服务端授权。不要把 Token 或其他凭据加入这个 URL。
父页面 parent.ts
ts
import { createIframeRpc, IframeRpcError } from '@axewo/fin-micro/iframe'
const CHILD_ORIGIN = 'http://localhost:5174'
const CHANNEL = 'records-demo'
export function mountChild(container: HTMLElement): () => void {
const iframe = document.createElement('iframe')
iframe.title = '记录子页面'
// 先挂载空 iframe,以取得真实的 WindowProxy,再为目标页面配置 src。
container.append(iframe)
const remoteWindow = iframe.contentWindow
if (!remoteWindow) {
iframe.remove()
throw new Error('无法取得 iframe 窗口')
}
const sessionId = crypto.randomUUID()
const rpc = createIframeRpc({
localWindow: window,
remoteWindow,
remoteOrigin: CHILD_ORIGIN,
channel: CHANNEL,
sessionId,
timeoutMs: 10_000,
handlerTimeoutMs: 10_000,
maxPending: 20,
})
const controller = new AbortController()
let disposed = false
let ready = false
// ready 是本例自行定义的业务方法,不是库自带握手。
const unexposeReady = rpc.expose('demo.ready', args => {
if (args.length !== 0) throw new Error('ready 不接受参数')
if (!ready) {
ready = true
// 子端在请求 ready 前已经注册 demo.echo,因此此时可反向请求。
void rpc.request<{ text: string }>(
'demo.echo',
['来自父页面的消息'],
{ timeoutMs: 5_000, signal: controller.signal },
).then(result => {
if (typeof result?.text !== 'string') throw new Error('echo 返回值无效')
if (!disposed) console.info('子端回应', result.text)
}).catch(reportError)
}
return { accepted: true }
})
const unexposeRead = rpc.expose('records.read', async (args, { signal }) => {
const [id] = args
if (args.length !== 1 || typeof id !== 'string' || !id.trim()) {
throw new Error('记录参数无效')
}
// 此处的相对 URL 由父页面解释,必须由服务端继续校验身份和权限。
const response = await fetch(`/api/records/${encodeURIComponent(id)}`, { signal })
if (!response.ok) throw new Error('读取记录失败')
const result: unknown = await response.json()
if (!result || typeof result !== 'object' ||
!('id' in result) || typeof result.id !== 'string' ||
!('title' in result) || typeof result.title !== 'string') {
throw new Error('接口结果无效')
}
if (signal.aborted) throw new Error('调用已经取消')
return { id: result.id, title: result.title }
})
// 监听器及父端方法先安装,再加载子页面;仅设置 onload 不能保证子端方法已就绪。
const url = new URL('/iframe.html', CHILD_ORIGIN)
url.searchParams.set('rpcSession', sessionId)
iframe.src = url.href
function reportError(error: unknown) {
if (disposed) return
if (error instanceof IframeRpcError) {
console.warn('iframe 请求失败', error.code)
} else {
console.warn('业务结果校验失败')
}
}
// 组件卸载时调用;本例不自动处理 iframe 自刷新后的重连。
return () => {
if (disposed) return
disposed = true
controller.abort()
unexposeReady()
unexposeRead()
rpc.dispose()
iframe.remove()
}
}
const container = document.querySelector<HTMLElement>('#child-container')
if (!container) throw new Error('请在父页面添加 <div id="child-container"></div>')
const unmount = mountChild(container)
window.addEventListener('pagehide', unmount, { once: true })
// SPA 组件更早卸载时也需调用 unmount()。子页面 child.ts
ts
import { createIframeRpc, IframeRpcError } from '@axewo/fin-micro/iframe'
const PARENT_ORIGIN = 'http://localhost:5173'
const CHANNEL = 'records-demo'
if (window.parent === window) throw new Error('此页面必须在可信父页面 iframe 内运行')
const sessionId = new URL(window.location.href).searchParams.get('rpcSession')
if (!sessionId || sessionId.length > 128) throw new Error('缺少有效的本次连接标识')
const rpc = createIframeRpc({
localWindow: window,
remoteWindow: window.parent,
remoteOrigin: PARENT_ORIGIN,
channel: CHANNEL,
sessionId,
timeoutMs: 10_000,
handlerTimeoutMs: 10_000,
maxPending: 20,
})
const controller = new AbortController()
let disposed = false
// 先注册子端方法,再通知父端业务就绪。
const unexposeEcho = rpc.expose('demo.echo', (args, { signal }) => {
if (signal.aborted) throw new Error('调用已取消')
if (args.length !== 1 || typeof args[0] !== 'string') throw new Error('消息参数无效')
return { text: `子页面收到:${args[0]}` }
})
async function start() {
const agreement = await rpc.request<{ accepted: boolean }>(
'demo.ready', [], { signal: controller.signal },
)
if (agreement?.accepted !== true) throw new Error('父页面未确认就绪')
const record = await rpc.request<{ id: string; title: string }>(
'records.read',
['record-1'],
{ timeoutMs: 8_000, signal: controller.signal },
)
// 泛型不是运行时校验,接收方仍校验结果。
if (typeof record?.id !== 'string' || typeof record?.title !== 'string') {
throw new Error('记录返回值无效')
}
if (!disposed) document.body.textContent = record.title
}
void start().catch(error => {
if (disposed) return
if (error instanceof IframeRpcError) {
document.body.textContent = `请求未完成:${error.code}`
} else {
document.body.textContent = '连接或业务数据无效'
}
})
function cleanup() {
if (disposed) return
disposed = true
controller.abort()
unexposeEcho()
rpc.dispose()
}
window.addEventListener('pagehide', cleanup, { once: true })
// 若 iframe 内也有 SPA 组件生命周期,应在所属组件卸载时调用 cleanup()。这些代码展示了真实窗口引用、双向暴露、就绪约定、单次取消与销毁。示例的就绪请求也受超时限制,但父端没有自动重连计时器。iframe 导航、刷新、源改变、加载失败时,应用应销毁旧连接并重新挂载、共享新的 session,而不是自动放宽 origin 检查或重试有副作用的请求。若浏览器恢复了已清理连接的页面,也需显式重建。
4. 只开放指定宿主能力
若父页面已经持有 AppBridge,可以通过 exposeHostCapabilities 将其中一小部分能力绑定到 RPC。bridge 必须由父页面可信闭包选定,不允许读取子端参数里的 appId 再任意获取桥。
ts
import { createMicroHost } from '@axewo/fin-micro/core'
import { exposeHostCapabilities } from '@axewo/fin-micro/iframe'
import type { IframeRpc } from '@axewo/fin-micro/iframe'
interface Capabilities {
'records.read': (id: string) => Promise<{ id: string; title: string }>
}
const host = createMicroHost<Capabilities>([
{ appId: 'records', name: '记录管理', entry: 'http://localhost:5174' },
], {
capabilities: {
'records.read': {
apps: ['records'],
async handler(context, id) {
if (typeof id !== 'string' || !id.trim()) throw new Error('参数无效')
if (!context.isActive()) throw new Error('调用无效')
// 替换成真实业务逻辑,异步请求应使用 context.signal。
return { id, title: '示例记录' }
},
},
},
})
// rpc 是按上一节精确窗口/origin/channel/session 建立的父端端点。
function attachCapabilities(rpc: IframeRpc) {
const trustedBridge = host.bridge('records')
const unexpose = exposeHostCapabilities(rpc, trustedBridge, ['records.read'])
return () => {
unexpose()
rpc.dispose()
// 若 host 由更高层共享,不应在这个局部清理函数中销毁 host。
}
}子端调用仍然是普通 RPC:
ts
const record = await rpc.request<{ id: string; title: string }>(
'records.read', ['record-1'], { timeoutMs: 5_000 },
)桥接适配器只完成以下动作:
- 去重
allowedNames,逐一rpc.expose(name, handler)。 - 将入站
args展开为业务参数,并将 RPC handler 的signal传给bridge.callHostWithOptions(name, { signal }, ...args)。 - 返回注销这些方法的函数;注册途中失败会回滚本次已注册的方法。
它不校验所选名字是否已由宿主注册、不绕过应用白名单、不自动获取页面 SDK。宿主能力白名单仍然生效,宿主自身的默认调用超时也仍生效;两个层级各自有期限,先结束的层级决定调用表现。注销方法不取消已有调用;销毁端点才能中止其入站 signal。
注意错误边界:宿主调用拒绝(例如 HostCallError('FORBIDDEN', ...))在 RPC handler 看来是普通异常,因此子端通常收到 IframeRpcError 的 HANDLER_FAILED,不是远程透传的 HostCallError。若业务需要返回可公开的领域失败原因,应设计并校验普通数据结果(例如判别联合),不要依赖异常对象、stack 或 cause 跨窗口传播。
不要把 bridge 整体作为参数或结果发送,也不要默认把 auth.getAccessToken 暴露给 iframe。即使父窗口中的某个子应用可读 Token,也不等于嵌套窗口自动获得同等信任。
5. 超时、容量与取消
| 项目 | 默认值 | 约束与行为 |
|---|---|---|
timeoutMs | 30_000ms | 端点出站请求默认等待期限 |
单次 request(..., { timeoutMs }) | 端点 timeoutMs | 只覆盖本次请求方等待期限 |
handlerTimeoutMs | 端点 timeoutMs | 本端入站 handler 的独立最长执行期限 |
maxPending | 100 | 正安全整数,分别限制出站 pending 数量和入站执行数量 |
期限要求正有限数且不超过 2_147_483_647。非法端点配置同步抛 TypeError,非法单次请求参数/期限以 TypeError 拒绝。
- 出站超限直接拒绝
LIMIT_EXCEEDED,不排队、不自动重试。序号达到Number.MAX_SAFE_INTEGER也拒绝该错误,需建立新连接生命周期。 - 入站超限向请求方回应
LIMIT_EXCEEDED。出站和入站分别计算,不是两者数量相加共用一个槽池。 - 请求方超时或主动取消,会先结束本地等待,再尽力发送取消消息。对端收到后中止 handler signal 并释放入站槽位。
- handler 到期时中止 signal、释放槽位并尽力返回
TIMEOUT;忽略 signal 的业务 Promise 仍可能继续运行,其迟到结果不再发送。 - 单次请求 timeout 不会通过消息重新配置对端 handlerTimeoutMs。 对端期限独立;正常情况下取消消息可提前中止对端,但不能承诺通知一定到达。
- 没有强制停止 JS、撤销网络写入、回滚、自动重试、离线队列或持久化投递保证。对有副作用的请求使用业务自己的幂等机制。
容量限制只限制同时在途数量;实现没有额外的消息字节数、业务 payload 大小、每秒请求量或 handler 总计算量限制。暴露端应自行控制负载长度与计算量,不要把 maxPending 当作完整限流或资源安全方案。
6. 错误处理
ts
try {
await rpc.request('records.read', ['record-1'])
} catch (error) {
if (error instanceof IframeRpcError) {
switch (error.code) {
case 'CANCELLED':
case 'DISPOSED':
break
default:
console.warn('RPC 未完成', error.code)
}
} else {
// 例如本地请求参数无效时的 TypeError。
throw error
}
}IframeRpcError.code | 含义 |
|---|---|
TIMEOUT | 请求方等待到期,或对端 handler 到期 |
CANCELLED | 本次请求 signal 已取消 |
DISPOSED | 本端已释放,或释放时结束本端正在等待的请求 |
LIMIT_EXCEEDED | 本端/对端在途上限,或本端请求序号耗尽 |
UNKNOWN_METHOD | 对端没有暴露所请求的方法 |
HANDLER_FAILED | 对端 handler 同步抛错或异步拒绝 |
SERIALIZATION_FAILED | 请求/结果无法发送或结构化克隆失败 |
公开错误只有固定 code 与固定对外消息,不透传远端业务异常文本、cause 或原始堆栈。不要用解析 message 的方式分流。创建配置或 expose 的使用错误不是远端业务失败,应在开发阶段修正。
7. 结构化克隆与消息限制
通信使用浏览器 postMessage,不是 JSON 编码,也不是共享对象引用:
- 参数和返回值必须可被结构化克隆。普通数据对象、数组、字符串、布尔、数值等适合作为业务 DTO;其他浏览器可克隆类型应结合目标环境验证。
- 函数、DOM 节点、
Window、包含函数的服务对象、PageSDK/AppBridge等不能作为协议数据发送。不要试图发送AbortSignal来实现取消,取消是独立的协议消息。 - 不提供 transfer list;不要假定
ArrayBuffer会以所有权转移方式发送,也不要依赖自定义类实例的原型、方法和引用身份。 request<T>不做 schema 校验。方法内校验参数,收到结果后校验结果;对资源大小和可公开字段另行约束。- 请求发送失败会本地拒绝
SERIALIZATION_FAILED;结果发送失败时对端尝试回应该错误。错误/取消等兜底消息也可能无法送达,最终依靠请求方超时,不能保证总能拿到错误回包。
内部消息标识为 protocol: 'fin-micro-rpc'、数字 version: 1。这不是应用桥的 '1.0' 协商接口。实现不公开自动版本协商或兼容降级机制,不应手工构造信封冒充正常 request。
8. 销毁、重放与安全检查
dispose() 会:
- 移除本端
message监听。 - 将本端所有未完成请求以
DISPOSED拒绝,并尽力通知对端取消。 - 中止本端所有入站 handler 的 signal,清理期限及方法注册。
它没有“连接已关闭”的断连握手。对端正在等待本端响应的请求不会自动立刻得到 DISPOSED,仍由对端自己的取消或 timeout 兜底。释放后 request 拒绝 DISPOSED,expose 同步抛 DISPOSED;不能复活旧端点。
每个 session 的出站 requestId 是单调递增的正安全整数十进制字符串。接收方只保存入站高水位,忽略重复和旧序号,防重放状态保持常量空间。这不等于跨重连业务去重,更不等于服务端恰好一次提交;新 session 的重试仍需业务幂等。
上线前逐项确认:
remoteWindow是真正目标窗口,不是任意窗口传入的引用;origin 固定且精确。- 父端在加载目标页面前建立监听并暴露方法,子端在声明就绪前暴露自身方法。
- 同一窗口对/channel/session 只有一对端点,重连生成新 session,旧连接明确 dispose。
- origin 是源级边界,不区分同源下的可信/不可信路径;同源页面泄露或 XSS 不会被这个 RPC 自动隔离。
sessionId只是生命周期绑定值,不是秘密凭据;不从未验证的消息接受 origin、appId 或授权名单。- 如配置 sandbox 导致子页面成为不透明 origin(消息 origin 为
null),本模块的精确 HTTP(S) origin 约束不适用;不要用*绕过。应重新设计加载与信任模型,不能为了通信随意削弱隔离。 - 只暴露最小方法集;服务端鉴权、参数校验、资源限制、业务幂等和数据脱敏由业务实现。