☰
uniapp内嵌iframe双向通信:renderjs与postMessage
2026/10/1 5:01:02 网站建设 项目流程

iframe 在 uniapp 里算是个“半隐形”的能力——官方组件列表里没有它,文档也很少正面提,但在实际项目里,需要把一个已经写好的 HTML 页面塞进 App 的情况太常见了:后台管理系统导出的报表页、第三方数据大屏、老旧系统里的富文本编辑器、活动页 H5 等等。这些页面往往不是用 Vue 写的,重写成本极高,这时候内嵌 iframe 就是最省事的方案。麻烦的地方不在“嵌进去”,而在“嵌进去之后怎么说话”——uniapp 侧要能把参数传给 HTML,HTML 里点了按钮要能把结果回传给 uniapp,两边还得保证时序不乱、消息不丢。这篇就把我从第一次踩坑到后来形成固定套路的过程完整写一遍,包含 renderjs 的真实写法、三端能力差异、消息协议设计、以及一堆当时卡了我半天的排查经验。只要你会基本的 Vue 和 JS,跟着走一遍就能跑通。

1. 先想清楚:为什么要在 uniapp 里内嵌 iframe

1.1 三个真实场景,决定了这个方案值不值得用

我遇到的第一个场景是复用存量 HTML 页面。公司早几年做了一套基于原生 JS + jQuery 的数据展示页,跑在浏览器里好几年了,业务方要求搬到 App 里。重新用 uniapp 写一遍?光是那几个 ECharts 图表和自定义表格的交互逻辑,保守估计要两周,而且做出来效果还不一定一致。直接内嵌,一天搞定。

第二个场景是外部内容与主应用解耦。有些页面内容更新频率很高,比如活动落地页、公告详情页,运营希望改完直接上线不用发版。把这类页面放在服务端,App 里用 iframe 加载,发版压力就没了。

第三个场景是隔离样式和全局变量污染。这一点经常被忽略。iframe 天然有独立的 document 和 window,里面的 CSS、全局变量、甚至Array.prototype被改了,都不会污染主应用。我们有个项目接入了第三方提供的可视化编辑器,它上来就改了一堆全局样式,最后就是靠 iframe 隔离解决的。

反过来说,不该用 iframe 的情况也得说清楚:页面需要跟原生能力深度交互(比如调摄像头、扫一扫、蓝牙)的,老老实实用原生页面写;页面需要跟 App 主页面频繁同步状态、一秒钟通信几十次的,iframe 的消息通道会成为瓶颈;页面本身就是你们自己写的 Vue 页面,那还不如做成 uniapp 的子页面或者组件。

1.2 三端能力底表:App / H5 / 小程序到底谁支持

这是最容易踩坑的地方,我见过太多人在小程序里找 iframe,找了一天没找到。先把结论摆出来:

运行端iframe 可用性底层原因通信手段
App(Android/iOS)可用,但必须走 renderjs页面运行在 webview 里,视图层可以操作真实 DOMpostMessage + renderjs 桥接
H5可用,直接用 DOM 或 renderjs就是浏览器环境postMessage
各家小程序不可用小程序没有 DOM,只有自绘的组件树只能用 web-view 组件,能力受限

App 端的关键在于「视图层」和「逻辑层」是分离的。你的 Vue 代码跑在逻辑层,它操作不了 DOM;而 iframe 是个 DOM 元素,必须由视图层创建。renderjs 就是官方给出的这个口子,让一段代码跑在视图层里,能拿到document和window。

小程序端还有个更微妙的地方:web-view组件确实能加载 HTML,但它和 iframe 完全是两回事。web-view 里的页面和小程序之间只能通过 URL 参数单向传值,或者依赖官方约定的 postMessage 机制,而且加载的域名必须在后台配置白名单。如果项目要求覆盖小程序,得提前跟产品说清楚,这块要么改需求,要么准备两套实现。

1.3 通信方案选型:四种通道的取舍

知道了能通信,接下来是选哪种方式。我把实际用过的四种列一下:

方案一:postMessage。这是最正统的。父页面iframe.contentWindow.postMessage(msg, '*'),子页面window.parent.postMessage(msg, '*'),两边都监听message事件。优点是标准、跨域可用、异步不阻塞;缺点是消息是异步的,没有返回值,请求响应模型要自己实现。

方案二:直接调用函数。子页面里window.parent.someFn(data),父页面里iframe.contentWindow.innerFn(data)。优点是同步、直接、写起来爽;缺点是必须在同源前提下,而且 App 端的父窗口是视图层的 window,你挂在上面的函数逻辑层根本看不见,跨过 renderjs 这一层还是要靠 callMethod,等于白折腾。结论是只在 H5 端、同源的情况下可以考虑,通用方案里不要用。

方案三:改 URL / hash。通过给 iframe 换 src 的 hash 来传参,子页面监听hashchange。这招很老,好处是能穿透各种限制,坏处是每次传值都会触发导航、有历史记录残留、传大数据基本没法用。我只在极端受限的环境下用过。

方案四:共享存储。localStorage 加 storage 事件,或者干脆用原生插件传。前者在 App 端两个 webview 之间未必共享,后者成本太高。

最后我固定用的是方案一为主、方案二为辅:所有正式通信走 postMessage,只有在 H5 端做紧急兼容、需要同步取返回值的时候才临时用函数调用。

2. 嵌入之前的准备工作:目录结构、manifest 与 HTML 骨架

2.1 项目目录与 HTML 文件放哪儿

这一步看着简单,实际上坑不少。HTML 文件必须放在会被打包进 App 资源目录的位置,也就是static目录下。我习惯这么组织:

项目根目录 ├── static │ └── inner │ ├── index.html │ ├── css │ └── js ├── pages │ └── container │ └── container.vue └── manifest.json

注意static目录下的文件是原样拷贝的,不会被 webpack 处理,所以里面的相对路径引用必须自己保证正确。我建议 HTML 内部引用 CSS、JS 一律用相对路径,别用/xxx这种以根开头的绝对路径——App 端本地文件的根目录跟你想象的不一样。

引用时的路径写法,H5 端和 App 端略有差异:

  • H5 端打包后:static/inner/index.html或者/static/inner/index.html都能用,取决于你的部署路径。
  • App 端:优先用相对路径static/inner/index.html。

如果 App 端死活加载不出来,先别怀疑代码,用真机连上调试,把iframe.src打印出来看实际解析成了什么绝对路径,这一步能省掉大量瞎猜。

2.2 manifest 里那些容易漏的配置

manifest 里跟这个方案直接相关的项其实不多,但漏了会很难受。

App 端的「模块权限配置」:如果你的 HTML 里要用到网络请求,确保勾了对应的网络权限,Android 端还要确认targetSdkVersion对应的网络安全策略没有把明文 HTTP 拦掉——很多老系统导出的页面还在用 http,被拦了就是白屏,而且控制台可能不给明显报错。

App 端的 webview 内核选择:Android 上建议开webView相关的 X5 或者系统内核配置项,具体选项跟着 uniapp 版本走。有些老内核不支持 ES6 的部分语法,HTML 侧代码写得太新就直接报错白屏。

H5 端的 publicPath:如果部署在子路径下,manifest.json里的h5.router.base和h5.publicPath要一起配,否则 iframe 的相对路径会解析错位置。

离线打包:如果你走的是离线打包(把 uniapp 项目导入原生工程),static目录的拷贝规则要自己确认,有些模板不会自动把整个 static 目录塞进 assets,需要手动加进资源清单。这个坑我踩过一次,线上包体里根本没有那个 HTML 文件。

另外补一句,2024 年以后不少平台开始用uts 插件替代部分原生能力,但 iframe 这块暂时还没有 uts 化的必要,renderjs 依然是主力,别被各种新名词带偏。

2.3 HTML 侧的最小骨架与滚动条处理

先给一份我一直在用的最小骨架,可以直接抄:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"> <meta name="format-detection" content="telephone=no"> <title>内嵌页</title> <style> html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: transparent; -webkit-tap-highlight-color: transparent; -webkit-text-size-adjust: 100%; } body::-webkit-scrollbar { width: 0; height: 0; display: none; } #wrap { padding: 16px; box-sizing: border-box; } </style> </head> <body> <div id="wrap">页面内容</div> <script>/* 业务脚本 */</script> </body> </html>

几个点单独解释一下为什么这么写。

<meta charset="utf-8">必须放在<head>的前 1024 字节内,否则浏览器可能来不及判定编码,中文直接乱码。这不是玄学,是 HTML 解析规范要求的编码嗅探窗口。

viewport里的maximum-scale=1.0, user-scalable=no是为了防止用户双指缩放把布局搞乱,viewport-fit=cover是给全面屏留的,不然内容可能被刘海或者底部横条盖住。

滚动条的处理要分两层:第一层是 iframe 元素本身的scrolling="no"属性和frameborder="0";第二层是 HTML 内部的html, body { overflow: hidden }。这两层都做了,iOS 上大概率还有一条细线,原因是 iframe 内部元素撑高了。这时候再加body::-webkit-scrollbar { display: none }就能彻底看不见。

注意:overflow: hidden加在html上会连带禁掉内部所有滚动,如果 HTML 里本身有个需要滚动的列表,得把滚动交给内部的容器元素,而不是 body。这个细节不注意,内嵌页在手机上会「划不动」。

如果 HTML 页面需要自己滚动而不是撑满固定高度,那就把外部overflow: hidden去掉,改成让 body 自然滚动,同时在 renderjs 里监听内容高度变化。不过我的建议是,App 端尽量让 iframe 内部自己滚,父页面不滚,这样能避开一大堆手势冲突。

3. uniapp 侧:用 renderjs 把 iframe 塞进视图层

3.1 renderjs 是什么,为什么不用 web-view 组件

renderjs 的定位很明确:让一部分代码运行在视图层,能访问真实 DOM。写法上就是在.vue文件里再写一个<script module="xxx" lang="renderjs">块,module的名字自己起,用的时候通过:change:属性名="模块名.方法名"来建立联系。

为什么不用web-view组件?因为它的行为跟 iframe 差别太大了。web-view在小程序里是独立页面级组件,会覆盖整个页面,你没法控制它的位置和尺寸;它跟宿主之间的通信受到严格限制;而且在 App 端的表现也不如 iframe 灵活。我要的是一个能放在页面某个区域里、能随意控制大小、能和周边 Vue 组件协同的容器,那只能是 iframe。

还有一个常见误区:有人想直接在<template>里写<iframe>标签。这在 App 端是不生效的,因为模板最终渲染成的是原生控件树,不是浏览器 DOM,iframe会被当成未知标签丢掉。H5 端倒是能正常工作,但为了代码统一,我还是建议一律用 renderjs 动态创建。

3.2 动态创建 iframe 的完整代码

先看页面结构。核心是两个东西:一个用来承载 iframe 的view容器,一个用来接收逻辑层数据的「数据通道」元素。

<template> <view class="page"> <!-- 数据通道:逻辑层改 outbox,视图层就会触发 pushToIframe --> <view class="bridge" :outbox="outbox" :change:outbox="iframeBridge.pushToIframe" ></view> <!-- iframe 的实际挂载点 --> <view id="iframe-host" class="iframe-host"></view> </view> </template>

这里有个必须强调的细节::change:绑定的属性值变化才会触发视图层方法,如果值没变,方法根本不会执行。所以我在outbox里加了个自增的seq,每次发消息都让它加一,保证数据一定「变了」。这个坑我在项目里栽过一次,当时调试了半天,以为是通信断了,其实是消息压根没发出去。

接下来是逻辑层脚本:

export default { data() { return { outbox: { seq: 0, type: '', payload: null } }; }, onLoad() { // 主动发一次,既是为了确认通道打通,也是为了尽早拿到视图层实例 this.sendToIframe('PING', { from: 'logic' }); }, methods: { // 视图层通过 callMethod 调过来的入口 onHtmlMessage(msg) { console.log('收到内嵌页消息:', JSON.stringify(msg)); if (msg.type === 'READY') { this.sendToIframe('INIT_THEME', { theme: 'dark' }); } if (msg.type === 'USER_CLICK') { uni.showToast({ title: '内嵌页被点了', icon: 'none' }); } }, sendToIframe(type, payload) { this.outbox = { seq: this.outbox.seq + 1, type: type, payload: payload || null }; } } };

再看视图层的 renderjs 块:

export default { data() { return { iframeEl: null, owner: null, ready: false, pending: [] }; }, mounted() { this.createIframe(); window.addEventListener('message', this.onWindowMessage); }, beforeDestroy() { window.removeEventListener('message', this.onWindowMessage); this.iframeEl = null; }, methods: { createIframe() { const host = document.getElementById('iframe-host'); if (!host) return; const iframe = document.createElement('iframe'); iframe.id = 'inner-frame'; iframe.setAttribute('scrolling', 'no'); iframe.setAttribute('frameborder', '0'); iframe.style.cssText = [ 'width:100%', 'height:100%', 'border:0', 'display:block', 'overflow:hidden', 'background:transparent' ].join(';'); iframe.src = 'static/inner/index.html'; iframe.addEventListener('load', () => { this.ready = true; this.flushPending(); }); host.appendChild(iframe); this.iframeEl = iframe; }, onWindowMessage(e) { const data = e.data; if (!data || typeof data !== 'object') return; if (!data.__fromInner) return; const inst = this.owner || this.$ownerInstance; if (inst && inst.callMethod) { inst.callMethod('onHtmlMessage', data); } }, pushToIframe(newVal, oldVal, ownerInstance) { if (ownerInstance) this.owner = ownerInstance; if (!newVal || !newVal.type) return; this.enqueue({ __fromOuter: true, seq: newVal.seq, type: newVal.type, payload: newVal.payload }); }, enqueue(msg) { const win = this.iframeEl && this.iframeEl.contentWindow; if (!this.ready || !win) { this.pending.push(msg); return; } win.postMessage(msg, '*'); }, flushPending() { const list = this.pending.splice(0); list.forEach((m) => this.enqueue(m)); } } };

有几个地方值得单独说。

ownerInstance一定要存起来。它只在:change:触发的函数参数里给,其他方法里拿不到。我在pushToIframe里把它存到this.owner,后面onWindowMessage里就能用它调逻辑层。所以我在onLoad里主动发了一次PING,目的就是尽早把这根线接上。如果业务上不方便主动发,备用方案是this.$ownerInstance,但它的可用性跟版本有关,我一般只当兜底。

load事件至关重要。iframe 的src是异步加载的,页面没加载完你往里面 postMessage,消息就丢了,而且不会报错。我用ready标记加pending队列解决这个问题,所有未就绪的消息先排队,加载完再统一发。这是整个方案里最重要的一个防御措施。

beforeDestroy里一定要解绑 window 的 message 监听。事件监听是挂在视图层 window 上的,不解绑的话,页面切来切去会累积一堆监听器,后期出现「一条消息处理了五次」这种诡异现象,排查起来非常费劲。

3.3 iframe 尺寸自适应与滚动条隐藏

iframe 默认高度是 150px,这个默认样式会让人一脸懵。所以我在cssText里写死了width:100%; height:100%,前提是父容器有明确高度。

.iframe-host的样式这么写:

.page { display: flex; flex-direction: column; height: 100vh; } .bridge { width: 0; height: 0; overflow: hidden; position: absolute; opacity: 0; pointer-events: none; } .iframe-host { flex: 1; position: relative; overflow: hidden; background: #f5f6f8; }

bridge那个元素完全不参与布局,只是个数据挂载点,所以设成 0 尺寸加绝对定位。

如果容器高度不是满屏,而是根据内容算出来的,那就要在逻辑层用uni.createSelectorQuery()量出高度,再通过sendToIframe之外的另一个通道传下去,视图层拿到后改iframe.style.height。这个我在做「半屏弹窗内嵌图表」时用过,逻辑是:逻辑层量高 → 走:change:→ 视图层设 style。注意别在视图层自己量,视图层拿不到 uni 的节点信息 API。

滚动条那部分和 HTML 侧的配合前面说过了,这里补一个 iOS 特有的现象:即使内外都设了overflow: hidden,在 iOS 上快速滑动时 iframe 区域还是可能整体位移一下,视觉上像有橡皮筋效果。解决办法是在iframe-host上加overscroll-behavior: none,某些内核上还要加position: relative; transform: translateZ(0)触发合成层。这几个属性值不值当加,看具体设备,加了不亏。

3.4 消息下发:逻辑层到 iframe 的两跳链路

把链路完整画一遍(文字版):逻辑层 Vue 组件改data.outbox→ 触发视图层pushToIframe→ 视图层iframe.contentWindow.postMessage→ HTML 侧message事件收到。

这里有两跳,每一跳都可能断。第一跳断了的典型表现是:日志里sendToIframe执行了,但pushToIframe没打印。原因通常是数据没「真的变」,比如你反复发同一个对象,或者用Object.assign改了引用但seq没动。第二跳断了的典型表现是pushToIframe打了,HTML 里没反应,通常是 iframe 还没load,或者src加载失败。

我在pushToIframe里一定会打一行console.log('[bridge] to iframe', newVal.type),在onWindowMessage里打console.log('[bridge] from iframe', data.type)。这两行日志基本上能定位 90% 的问题,成本极低,强烈建议保留。

4. HTML 侧:怎么把消息稳稳送回 uniapp

4.1 window.parent.postMessage 的正确姿势

HTML 侧的代码骨架前面给过,这里说一下容易出问题的几个点。

第一个是postMessage 的第二个参数。规范上它是 targetOrigin,用来限制接收方来源,写'*'表示不限制。很多人担心安全,想写具体域名,但在 App 端本地文件环境下,origin 往往是file://或者null,写死了反而发不出去。我的做法是统一用'*'发送,然后在接收端做来源校验,这个下面会讲。

第二个是消息格式。postMessage 支持结构化克隆,理论上能直接传对象,但实际上很多老环境对复杂对象(比如带函数、带 DOM 引用的)支持不好,而且跨 webview 场景下更容易出问题。所以我的规矩是:只传纯 JSON 可序列化的数据,两边约定好字段,其他一律不传。

第三个是parent和top的区别。如果页面被多层嵌套,parent是直接父窗口,top是最顶层。正常情况下用parent,因为你要对话的就是直接宿主。用top在某些平台容器里会指向完全不同的窗口,消息就发飞了。

第四个是发送时机。HTML 一加载完就立刻send('READY'),这是我最推荐的做法。它解决了两个问题:一是告诉宿主「我准备好了,可以发消息了」,二是能顺带把navigator.userAgent之类的环境信息带过去,方便宿主判断内嵌页是不是加载到了预期版本。

4.2 直接调用父窗口函数这条路,以及它为什么危险

在 H5 端同源的情况下,你完全可以在 HTML 里写:

if (window.parent && typeof window.parent.receiveFromInner === 'function') { window.parent.receiveFromInner({ type: 'USER_CLICK' }); }

这行代码能跑,而且在 H5 端确实方便,同步、有返回值。但它在 App 端基本等于废的——父窗口是视图层的 window,你的函数要么挂在视图层,要么挂不到逻辑层上来。

而且就算是在 H5 端,我后来也把它废弃了,原因有三个:

  • 时序不可控。父窗口那个函数可能还没定义,你得写一堆typeof判断和重试逻辑。
  • 异常处理困难。函数内部报错,异常会跨越 iframe 边界传播,堆栈信息看起来很奇怪,排查成本高。
  • 没返回值就不优雅。如果函数有返回值,你会忍不住把它当同步 RPC 用,最后代码变成一颗定时炸弹。

我现在只在一种情况下用函数调用:H5 端需要拿一个同步返回值,比如问宿主当前的主题色。而且我会明确把函数名挂在window上作为「公开 API」,加注释说明仅 H5 可用。

反过来说,父页面直接调子页面函数(iframe.contentWindow.innerFn())也只在 H5 端可行,App 端视图层调子窗口反而没这个问题——因为两边都在视图层里。但为了代码统一,我还是用CALL_FN这种消息类型来做,让 HTML 自己收到消息后去执行对应函数。

4.3 握手协议与请求响应配对设计

消息一多,就必须有协议。我用了几个项目之后,沉淀下来一套很简单的格式:

发送方 → 接收方的消息体:

{ __fromInner: true, // 或者 __fromOuter: true seq: 12, // 单调递增,用于日志排查和去重 type: 'USER_CLICK', // 消息类型,约定好的枚举 payload: { ... }, // 业务数据 ts: 1690000000000 // 时间戳 }

__fromInner和__fromOuter这两个标记非常关键,它们承担了两个职责:一是方向过滤,防止自己发的消息被自己的监听器收回来形成死循环;二是来源校验,只有带正确标记的消息才处理,其他一律丢弃。这个设计帮我挡掉了好几次「消息无限循环把内存吃满」的事故。

有了基础格式,请求响应模型就好办了。需要回值的时候,发起方生成一个唯一的reqId,接收方处理完把同一个reqId带回来,发起方在本地维护一个待响应表:

字段说明示例
reqId请求唯一标识req-1690000000000-7
resolve成功回调函数引用
reject失败回调函数引用
timer超时定时器 ID数值

发起时设一个 5 秒的超时,超时就把 Promise reject 掉并清表。这个超时机制非常有必要,因为跨窗口通信一旦丢消息,是没有底层异常可以捕获的,不设超时就是永久 pending,最后表现为「按钮点了没反应」。

4.4 时序问题:页面没加载完就发消息怎么办

这个问题我在前面提过宿主侧的解法(pending 队列),HTML 侧同样要做。

典型的冲突场景是这样:宿主在onLoad里就要把用户信息推给内嵌页,但这时候 iframe 可能连src都还没开始请求。宿主的队列解决了「宿主到内嵌页」的方向。「内嵌页到宿主」这个方向一般不会有问题,因为内嵌页一加载完就发READY了,宿主此时肯定已经就绪。

但有个例外:如果内嵌页里还有异步初始化(比如要先拉一次接口拿配置),那么READY发出去之后,真正的业务消息可能要几百毫秒后才来。这时候宿主侧如果已经销毁了(用户返回上一页),callMethod就会指向一个不存在的实例。我的处理方式是在onHtmlMessage里做一层防御:

onHtmlMessage(msg) { if (!this._alive) return; // 业务处理 }

在onLoad里把_alive置 true,onUnload里置 false。很土,但很好用。

另外一个更隐蔽的时序坑是热更新和多标签页。H5 端用户在浏览器里开了多个标签页,每个页面里都有一个 iframe,它们都会往自己的parent发消息,互不干扰,这个没问题。但如果有人把消息发到了top,在标签页嵌套的场景下就可能串台。所以再强调一次:用parent,不用top。

5. 一个可复现的完整 Demo:从零跑通双向通信

5.1 文件清单与职责划分

把上面的东西组装成一个能跑的 Demo,一共三个文件:

  • pages/container/container.vue:宿主页面,负责创建 iframe、展示接收到的消息。
  • static/inner/index.html:内嵌页面,包含一个按钮和一个状态区。
  • manifest.json:基础配置,H5 端默认配置即可,App 端注意 webview 相关配置。

功能目标是:宿主启动后自动向内嵌页发一条INIT_THEME;内嵌页收到后改自己的背景色,然后回一条THEME_APPLIED;用户点内嵌页的按钮,回一条USER_CLICK,宿主弹出提示;宿主上有一个按钮,点一下直接调用内嵌页里的一个函数。

5.2 宿主页面的完整代码

<template> <view class="page"> <view class="bar"> <button size="mini" @click="callInnerFn">调用内嵌页函数</button> <text class="log">{{ lastMsg }}</text> </view> <view class="bridge" :outbox="outbox" :change:outbox="iframeBridge.pushToIframe"></view> <view id="iframe-host" class="iframe-host"></view> </view> </template> <script> export default { data() { return { outbox: { seq: 0, type: '', payload: null }, lastMsg: '暂无消息', _alive: false }; }, onLoad() { this._alive = true; this.sendToIframe('PING', { from: 'logic', at: Date.now() }); }, onUnload() { this._alive = false; }, methods: { onHtmlMessage(msg) { if (!this._alive) return; this.lastMsg = msg.type + ' @ ' + new Date(msg.ts).toLocaleTimeString(); if (msg.type === 'READY') { this.sendToIframe('INIT_THEME', { theme: 'dark', accent: '#3b82f6' }); } else if (msg.type === 'USER_CLICK') { uni.showToast({ title: '内嵌页按钮被点击', icon: 'none' }); } else if (msg.type === 'THEME_APPLIED') { uni.showToast({ title: '主题已生效', icon: 'none' }); } }, sendToIframe(type, payload) { this.outbox = { seq: this.outbox.seq + 1, type: type, payload: payload || null }; }, callInnerFn() { this.sendToIframe('CALL_FN', { fn: 'setInnerText', args: ['宿主调用了这个函数'] }); } } }; </script> <style> .page { display: flex; flex-direction: column; height: 100vh; background: #f5f6f8; } .bar { display: flex; align-items: center; padding: 16rpx; gap: 16rpx; } .log { font-size: 24rpx; color: #666; flex: 1; } .bridge { position: absolute; width: 0; height: 0; opacity: 0; pointer-events: none; } .iframe-host { flex: 1; overflow: hidden; position: relative; } </style>

renderjs 部分沿用第 3.2 节的代码,一字不用改。

5.3 内嵌页的完整代码

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"> <title>内嵌页</title> <style> html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; font-family: -apple-system, sans-serif; } body::-webkit-scrollbar { display: none; } body[data-theme="dark"] { background: #1f2430; color: #e6e8eb; } body[data-theme="light"] { background: #ffffff; color: #222; } #wrap { padding: 32px 20px; } .tip { margin-top: 16px; font-size: 14px; line-height: 1.6; opacity: .8; } button { padding: 12px 20px; border: 0; border-radius: 8px; background: #3b82f6; color: #fff; font-size: 15px; } </style> </head> <body>document.addEventListener('focusin', function (e) { setTimeout(function () { e.target.scrollIntoView({ block: 'center', behavior: 'smooth' }); }, 300); });

300ms 这个延迟是给键盘弹出动画留的时间,写 0 的话计算位置时键盘还没弹起来,等于白做。这个数字是试出来的,不同机型略有差异,300 到 400 之间比较稳。

6.3 小程序端的降级方案

如果项目必须覆盖小程序,那前面这套在 App 和 H5 上能用,小程序上得换方案。

小程序的web-view组件能加载 HTML,但必须配置业务域名,而且通信只能靠 URL 参数单向传值。所以我一般这么设计降级逻辑:

  • 宿主用一个单独的页面承载web-view,把要传的参数序列化后拼在 URL 上。
  • 内嵌页读取location.search拿到参数。
  • 内嵌页需要回传数据时,跳到uni.navigateTo约定的一个中间路径,让宿主在这个路径的页面里拿到参数。

这套流程又绕又难维护,所以我的实际建议是小程序端干脆不要内嵌,把那个页面用 uniapp 重写一遍。如果产品不接受,就让产品在小程序端把功能降级成展示,不做交互。

6.4 常见问题速查表

现象最可能的原因处理方式
内嵌区白屏src 路径错误 / 文件未打包打印 src 实际值,用极简 HTML 验证
宿主发了消息内嵌页没反应iframe 未加载完就发送加 pending 队列 + load 事件
pushToIframe 不触发outbox 数据未变化加 seq 自增字段
内嵌页发了消息宿主收不到未保存 ownerInstance首次通信时接收 ownerInstance 参数
消息被处理多次页面销毁未解绑监听beforeDestroy 里 removeEventListener
一条消息触发五次历史监听器累积同上,同时检查是否有重复挂载
滚动条消不掉只做了外层隐藏内层 html/body 加 overflow: hidden
输入框被键盘挡住iframe 未重新计算位置focusin 后延迟 scrollIntoView
App 端通信全断,H5 正常renderjs 未生效确认 script module 写法与 lang="renderjs"
页面反复进入后卡顿消息队列或监听器泄漏检查 onUnload / beforeDestroy 清理

7. 性能与安全:几个容易被忽略的细节

7.1 高频消息的节流与数据体积控制

postMessage 本身是异步的,但并不是没有成本。消息会被序列化、跨进程投递、反序列化,在 App 端还涉及两个 webview 之间的通信。我做过的压测里,一秒钟发几百条小消息,界面开始出现肉眼可见的卡顿,主要是 JS 主线程被序列化和事件分发占满了。

所以高频场景必须节流。我的做法是:

  • 滚动、输入这类高频事件,一律不逐条发。用 100ms 的节流或者 200ms 的防抖,只把最新状态发过去。
  • 批量合并。如果一秒内要发同类型的多条消息,先攒在数组里,定时器触发时一次性发出去。协议里加个batch: true字段区分。
  • 控制数据体积。不要把图片 base64、大段富文本、整个列表数组塞进消息里反复传。这些数据应该通过接口或者缓存传递,消息里只带 ID 和变更标记。

还有一点,别在消息里传 DOM 节点或者带循环引用的对象。结构化克隆算法遇到循环引用会直接抛错,而且错误信息非常不直观,你可能要花半天才能定位到。

7.2 postMessage 的安全边界

写'*'确实让人心里不踏实,所以接收端一定要做校验。

window.addEventListener('message', function (e) { // 只处理带约定标记的消息 if (!e.data || !e.data.__fromOuter) return; // 同源场景下还可以校验来源 // if (e.origin !== location.origin && e.origin !== 'null') return; handle(e.data); });

origin校验这里要注意,App 端本地文件环境下e.origin通常不是常规的 http 地址,可能是'null'或者'file://'。所以校验逻辑要写成白名单,把合法来源都列进去,而不是简单地跟某个固定值比较。

另一个安全隐患是外部页面风险。如果 iframe 加载的是第三方域名,你完全无法保证对方现在和将来会发什么消息过来。所以业务上要加一个「消息类型白名单」,只处理约定好的几种type,其他一律丢弃并打日志。这个习惯救过我一次——某次合作方改版后往页面里塞了一个统计脚本,往父窗口发了一堆莫名其妙的消息,因为白名单机制,主应用一点没受影响。

注意:如果内嵌的是完全不受控的第三方页面,我建议直接放弃内嵌,改用中间服务端做一次内容转换再展示。安全成本和维护成本都不划算。

7.3 版本迭代时的兼容处理

内嵌页和宿主是分开部署的时候(比如 H5 端的 HTML 放在 CDN 上),版本不一致是常态。老宿主配新页面、新宿主配老页面都可能发生。

我的做法是在READY消息里带上内嵌页的协议版本号,宿主拿到后判断:

if (msg.type === 'READY') { const v = msg.payload.protocol || 1; if (v < 2) { // 走老协议分支 } else { // 走新协议 } }

同时宿主往内嵌页发的第一条消息里也带上自己的版本号,让内嵌页自己决定要不要降级。这套双向版本协商写了不到二十行代码,但在后面的三次协议升级里,帮我省掉了大量「用户更新了 App 但内嵌页是缓存的老版本」导致的问题。

最后分享一个我个人用下来最省事的小习惯:把通信相关的代码全部集中到一个文件里,宿主侧一个bridge.js,内嵌页侧一个bridge.js,两边保持结构对称,消息类型用常量对象统一定义。新人接手的时候,只看这两个文件就能理解整个通信机制,不用在业务代码里翻找散落的postMessage调用。这个项目后来陆续又接了三个内嵌页,每个页面接入的时间都没超过一小时。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询