上周帮朋友排一个混合 App 的 bug:壳是 uni-app,里面加载了一个 H5 活动页,页面底部飘着个“立即报名”按钮,一点就要把手机号、登录 token、页面来源全部传给 App 去调原生支付。结果 H5 那边uni.postMessage调了,App 这边@message就是没反应,最后查出来是消息触发时机的问题。这次就把 UniApp 与 H5 双向通信这件事从头到尾写清楚,包括原理、代码、三端差异和排查链路,给被内嵌网页通信折磨过的同学做个参考。不管你是前端、App 端,还是偶尔要接第三方运营页的“半个全栈”,这篇文章的代码和思路都可以直接抄。
1. 先盘点通信场景:哪些需求逼着你打通 H5 与 App
1.1 为什么会有“壳 + 网页”这种架构
现在的 App 里塞 H5 页面太常见了。运营活动要频繁改版、第三方内容要快速接入、某些低频功能不想跟着 App 发版,最省事的方案就是 App 只提供一个 WebView 容器,把业务页面丢给网页去实现。uni-app 的web-view组件就是干这个的,但问题也随之而来:网页在沙箱里跑,它和 App 之间没有天然的通信渠道。
我见过最典型的项目形态是这样:uni-app 负责主导航、原生能力、支付、登录态管理;H5 负责活动专题、商品详情、用户协议、各种运营页。两边需要互相喊话,而且不是单向的,是双向的。
1.2 双向通道上实际跑着四类消息
把真实业务里常见的消息梳理一遍,大体是下面这四类:
| 方向 | 消息类型 | 典型内容 | 使用频率 |
|---|---|---|---|
| App → H5 | 启动参数 | token、用户信息、页面来源、环境标识 | 每次页面加载必发 |
| App → H5 | 操作结果 | 支付结果、原生弹窗选择结果、权限授予结果 | 按需触发 |
| H5 → App | 页面事件 | 页面加载完成、按钮点击、埋点上报、滚动位置 | 高频 |
| H5 → App | 业务请求 | 打开原生支付、调起分享、请求登录态、关闭页面 | 业务关键路径 |
这四类消息如果都靠“改 URL 参数”或者“服务端中转”,会非常痛苦:URL 参数只能解决启动那一刻的一次性传参,而且长度有限、敏感信息暴露在地址栏里;服务端中转又引入额外的网络延迟和状态同步问题。最直接的方案就是走 WebView 的桥。
1.3 为什么不建议用 URL 参数或页面跳转代替
有人可能会想:App 加载 H5 之前,先把 token 拼到 src 后面,不就能拿到登录态了吗?能拿到,但只能拿到一次。H5 内部用 vue-router 或 React Router 切页面时,WebView 不会重新加载,App 端也感知不到路由变化;反过来,H5 想持续地把操作结果告诉 App,总不能每次都把页面 src 改一遍——改 src 等于整页刷新,用户正在填的表单直接没了。
所以,“双向通信”不是一个可选项,而是壳 + 网页架构里绕不开的基础设施。uni-app 的web-view给了我们两个口子:H5 往 App 发消息用uni.postMessage,App 往 H5 发消息用evalJS。把这两个口子理解透,剩下的都是封装问题。
2. WebView 通信的本质:隔离的浏览器盒子与一座桥
2.1 两个独立的 JS 世界
很多人第一次写web-view时,潜意识里会觉得 H5 的 JS 和 App 里的 JS 处于同一个上下文,可以互相访问。实际上 WebView 是一个被 App 嵌进去的浏览器内核,H5 页面跑在独立的“浏览器盒子”里,它和 App 原生层是两个完全隔离的 JS 世界,没有共享的全局变量,也没有共享的内存。
你在 H5 里定义一个window.App,App 这边的 JS 环境里看不见;反过来,你在 uni-app 页面里定义的变量,H5 那边也摸不着。想要双向通信,必须找一条“边界跨越”的通道。App 端能主动注入脚本执行,H5 端能主动把消息抛给宿主容器,这两个能力拼起来,就是一座桥。
2.2 uni-app 封装好的两条管道
先说比较标准的两个管道:
上行通道:H5 页面引入官方提供的uni.webview.js脚本后,页面里会有一个精简的uni全局对象。H5 调用uni.postMessage({ data: {...} }),消息会抛给宿主容器;uni-app 的web-view组件通过@message事件接收。
下行通道:uni-app 页面里给web-view组件加一个ref,拿到组件实例后调用this.$refs.webview.evalJS("要执行的 JS 代码")。这段 JS 代码会被投递到 H5 页面里去执行,H5 端预先定义一个全局接收函数,就能拿到 App 传来的数据。
上面这两个方向,就是“双向”的全部秘密。没有第三管道,任何花里胡哨的封装,底层都逃不开这两个动作。
2.3 先跑通一个最小闭环
不急着封装,先让消息能通。App 端页面长这样:
<template> <view class="page"> <web-view ref="webview" src="https://your-h5-domain.com/page.html" @load="onWebviewLoad" @message="onWebviewMessage" /> </view> </template> <script> export default { methods: { onWebviewLoad() { // 页面加载完成后再注入,否则 evalJS 会丢消息 this.$refs.webview.evalJS('window.onNativeMessage && window.onNativeMessage({ type: "init", data: "hello from app" })'); }, onWebviewMessage(e) { const list = Array.isArray(e.detail.data) ? e.detail.data : [e.detail.data]; list.forEach((msg) => console.log('H5 发来的消息:', msg)); } } } </script>H5 端页面长这样:
<!-- 记得先引入 uni.webview.js,否则 uni 未定义 --> <script> // 接收 App 下发消息 window.onNativeMessage = function (command) { console.log('来自 App 的消息:', command); }; // 向 App 发送消息 function noticeApp() { uni.postMessage({ data: { type: 'page-click', payload: { page: window.location.href } } }); } </script>这段代码跑通之后,你就能感受到两个关键点:一个是@load之后才能安全调用evalJS;另一个是@message的触发时机没那么“实时”。后面这两个点都会变成高频坑。
3. H5 上行:把 uni.webview.js 注入到页面之后
3.1 这个脚本到底是干什么的
uni.webview.js是官方给 H5 页面准备的一小段桥接脚本。它本身不做什么业务,主要工作是探测当前宿主环境:是 App 的 WebView、是微信小程序、还是普通浏览器,然后注入对应可用的uni对象。
如果 H5 页面没有引入这个脚本,直接调用uni.postMessage会直接报uni is not defined,页面就断了。所以在接入双向通信之前,第一步就是把脚本下载到本地,放到 H5 项目的静态目录,在入口 HTML 里引入。注意不要直接从 node_modules 里抽一个旧版本就完事,它和 uni-app 编译器的版本最好保持匹配,后面会单独说。
3.2 uni.postMessage 的参数约定
H5 端向 App 发消息时,统一这样写:
uni.postMessage({ data: { type: 'pay-success', payload: { orderId: 'xxx', amount: 100 } } });宿主端收到@message时,e.detail.data是数组还是单对象,不同平台有差异。我在 Android、iOS 上分别见过两种形态,最稳妥的取法是把所有消息统一包一层循环处理,而不是直接写e.detail.data[0]就完事。
提示:
data里尽量只放可 JSON 序列化的内容。函数、DOM 节点这些别往桥上传,跨了 JS 世界之后这些对象根本没有意义。
3.3 消息触发时机是最大的隐形坑
这是我最想先讲清楚的一点。在 App 端,早期版本里 H5 调用uni.postMessage之后,消息并不是立刻冒泡到 App 的@message,而是要等 WebView 页面发生“返回/关闭/销毁”时才统一触发。也就是说,H5 调了uni.postMessage,App 这边当前是什么都收不到的,直到用户按返回键回到上一个 uni-app 页面,@message才突然被触发。
这个设计坑过很多人。比如 H5 里点完支付,用户以为 App 端马上会弹个完成后处理,结果必须等返回才生效,业务直接“断片”。解决思路有两个:
第一个是用新版支持的实时发送参数。部分新版本uni.webview.js里,uni.postMessage支持带option.isRealTime:
uni.postMessage({ data: { type: 'pay-success', payload: {} }, option: { isRealTime: true } });但这里要提醒一句:这个参数是否生效,取决于你用的编译器内核和uni.webview.js脚本版本,不是写了就一定实时。我建议你写完之后在回调里加个日志,先验证当前环境是否支持,不要默认它生效。
第二个是不依赖实时参数,用“主动拉取”做兜底。H5 端把待发送消息放进一个队列,App 端按需或定时用evalJS调用 H5 暴露的拉取函数,H5 再把队列里的消息通过上行通道回传。这样即使实时参数不生效,也能在需要时强制同步。
3.4 如果无法用实时参数:主动拉取方案
在 H5 端维护一个消息队列:
(function () { const pendingQueue = []; function sendMessage(type, payload) { const msg = { type, payload, timestamp: Date.now() }; try { uni.postMessage({ data: msg, option: { isRealTime: true } }); } catch (e) { pendingQueue.push(msg); } } window.flushBridgeQueue = function () { if (!pendingQueue.length) return; const items = pendingQueue.splice(0); // 这里仍然走 uni.postMessage,把之前排队的数据一起发出去 items.forEach((msg) => { uni.postMessage({ data: msg, option: { isRealTime: true } }); }); }; window.NativeBridge = { send: sendMessage, flush: window.flushBridgeQueue }; })();App 端在需要同步的节点(比如onShow、支付回调前)调用一次:
this.$refs.webview.evalJS('window.NativeBridge && window.NativeBridge.flush()');用这种方式,即使某些环境不支持实时参数,只要页面还在,消息也能被“踢”出来。虽然不够优雅,但确实能解决业务阻塞。
4. App 下行:用 evalJS 把命令敲进页面
4.1 evalJS 的基本姿势与调用时机
App 向 H5 发消息,核心是evalJS。在 uni-app 里最常见的写法是给web-view加ref,拿到组件实例后调用:
sendToH5(type, data) { const script = `window.NativeBridge && window.NativeBridge.handleNativeCommand(${JSON.stringify({ type, data })})`; this.$refs.webview.evalJS(script); }这里有几个很现实的问题:
首先是调用时机。evalJS必须在 H5 页面加载完成之后执行,否则注入的脚本会静默丢失。所以一定要绑定@load事件,或者在@load之后再发。如果 H5 是单页应用,内部跳路由时 WebView 不会重新加载,@load只触发一次,这个没问题;但如果 H5 做了整页刷新,@load会再次触发,你要注意别在@load里重复注入初始化逻辑。
其次是“能不能拿到返回值”的问题。evalJS的返回值在各个内核上行为不一致,Android 上很多情况下拿不到同步返回值,iOS 上evaluateJavaScript是异步回调。所以我强烈建议:不要指望evalJS返回 H5 的计算结果,而是让 H5 收到命令后,通过上行通道再把结果发回来。这条经验可以帮你少掉一大半头发。
4.2 安全注入:JSON 序列化与脚本转义
直接把 App 数据拼进 JS 字符串里,是高风险动作。如果data里包含用户提交的文本,里面有单引号、反引号、换行、</script>,拼出来的 JS 脚本很可能直接语法报错,甚至变成注入攻击点。
一个相对安全的做法是:
const payload = JSON.stringify({ type, data }) .replace(/</g, '\\u003c'); const script = `window.NativeBridge && window.NativeBridge.handleNativeCommand(${payload})`; this.$refs.webview.evalJS(script);把<替换成\u003c,可以避免</script>这类片段在极端情况下破坏页面结构。养成这个习惯,后面少踩 XSS 的坑。
4.3 设计一个单一入口的 H5 全局函数
不要在 H5 里定义一堆window.appLogin、window.appPayResult之类的散装函数,然后 App 端各种拼函数名。函数名散落得到处都是,后期根本没法维护。我建议 H5 端只暴露一个入口:
window.NativeBridge = { handleNativeCommand(command) { const { type, data, requestId } = command || {}; const handlers = this._listeners[type] || []; handlers.forEach((cb) => { try { cb(data, requestId); } catch (e) { console.error('[bridge:handleNativeCommand] error', e); } }); }, on(type, handler) { if (!this._listeners[type]) this._listeners[type] = []; this._listeners[type].push(handler); }, off(type, handler) { const list = this._listeners[type] || []; const index = list.indexOf(handler); if (index > -1) list.splice(index, 1); }, _listeners: {} };App 端永远只调用这一个函数,业务逻辑分发全部交给 H5 端的事件订阅机制。这样 App 端不用关心 H5 内部有哪些业务模块,双方只要约定好消息协议即可。
5. 落地一个可复用的 Bridge:完整代码与调用示例
5.1 H5 端 NativeBridge 封装
把前面几节的思路整合起来,H5 端我建议直接封装成一个全局对象,对外提供send和on两个核心方法,再加一个handleNativeCommand作为下行入口。
(function () { const listeners = {}; const requestIdMap = {}; function generateRequestId() { return `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } function send(type, payload, responseHandler) { const requestId = generateRequestId(); const message = { type, payload, requestId, timestamp: Date.now() }; if (responseHandler) { requestIdMap[requestId] = responseHandler; } try { uni.postMessage({ data: message, option: { isRealTime: true } }); } catch (e) { // 降级:把消息塞进队列,等 App 来拉取 pendingQueue.push(message); } } function handleNativeCommand(command) { const { type, data, requestId } = command || {}; // 如果是某个请求的响应,优先回调 if (requestId && requestIdMap[requestId]) { const cb = requestIdMap[requestId]; delete requestIdMap[requestId]; try { cb(data); } catch (e) { console.error('[bridge:response]', e); } return; } // 普通事件分发 const cbs = listeners[type] || []; cbs.forEach((cb) => { try { cb(data, requestId); } catch (e) { console.error('[bridge:event]', e); } }); } window.NativeBridge = { send, on(type, cb) { if (!listeners[type]) listeners[type] = []; listeners[type].push(cb); }, off(type, cb) { const list = listeners[type] || []; const idx = list.indexOf(cb); if (idx > -1) list.splice(idx, 1); }, handleNativeCommand }; })();5.2 App 端通信服务封装
App 端单独抽一个工具类或者 mixin,不要在业务页面里到处写this.$refs.webview.evalJS。下面是一个最简版本的通信模块:
const H5Bridge = { methods: { initWebviewBridge(refName = 'webview') { this._bridgeRefName = refName; }, sendToH5(type, data = {}, requestId = '') { const payload = JSON.stringify({ type, data, requestId }) .replace(/</g, '\\u003c'); const script = `window.NativeBridge && window.NativeBridge.handleNativeCommand(${payload})`; const webview = this.$refs[this._bridgeRefName]; if (webview && webview.evalJS) { webview.evalJS(script); } }, handleH5Message(e) { const list = Array.isArray(e.detail.data) ? e.detail.data : [e.detail.data]; list.forEach((msg) => { // 这里统一处理 H5 上行消息 this.handleBridgeEvent(msg); }); } } };使用的时候,页面里的web-view绑定不变:
<web-view ref="webview" :src="url" @load="onWebviewLoad" @message="handleH5Message" />业务代码里只写:
this.sendToH5('switch-theme', { theme: 'dark' });然后 H5 端通过NativeBridge.on('switch-theme', ...)接收。两边代码只跟“消息类型”打交道,不再互相拼函数名。
5.3 请求-响应模式:让两边都能等待回执
很多业务不是单向广播,而是“我发一个请求,你处理完回我一个结果”。比如 H5 向 App 请求当前登录用户信息,App 获取后要回传给 H5。这个场景如果只用send和on,会写出一堆临时变量,很容易错乱。
更好的模式是带requestId的请求-响应。H5 端:
NativeBridge.send('get-user-info', {}, (userInfo) => { console.log('App 返回的用户信息:', userInfo); });App 端收到type === 'get-user-info'后,处理完再通过sendToH5带着同一个requestId回传:
sendToH5('get-user-info-response', { userInfo }, requestId);H5 端handleNativeCommand里看到这个requestId,就会找到之前缓存的回调函数并执行。这样整体代码结构和 HTTP 请求很像,两边的心智模型都是“发请求→等响应”,很好维护。
5.4 消息协议规范建议
双向通信能不能长期维护,协议格式比代码实现更重要。建议统一信封结构:
| 字段 | 类型 | 说明 |
|---|---|---|
| version | number | 协议版本号,便于后续升级兼容 |
| type | string | 消息类型,如get-user-info、pay-result |
| payload | object | 业务数据体 |
| requestId | string | 请求唯一 ID,响应时原样带回 |
| timestamp | number | 时间戳,便于日志追踪 |
所有消息都走这个信封,不搞特殊字段。排障的时候直接按requestId串链路,问题会清晰很多。
6. App、微信小程序、纯网页三端差异对照
6.1 微信小程序 web-view 的限制
如果你的 uni-app 项目最终还要发布成微信小程序,这里会有不少限制,提前知道能省很多事:
- 小程序里的
web-view是全屏组件,层级最高,样式基本不受控制,也没法和页面里的其他元素并列。 - 加载的 H5 必须在微信公众平台配置业务域名,个人主体小程序在这方面限制更多。
- 微信小程序的
web-view不允许通过 ref 调用evalJS,也就是说,App 端那种“下行命令注入”在小程序端走不通。 - H5 里调用
uni.postMessage后,消息不是实时的,而是在特定时机(比如小程序后退、组件销毁、分享)才触发@message。
所以在小程序端,你要提前把“实时下行”的预期降下来。常见折中方案是:H5 页面定时通过上报接口或消息通道拉取最新状态,或者干脆把关键交互放到 H5 内部完成,只在小程序端接收最终结果。
6.2 Android 与 iOS 的 WebView 实现差异
同样一个 uni-app 代码,跑到 Android 和 iOS 内核上,行为会有微妙差别。比较常见的有:
| 差异点 | Android | iOS |
|---|---|---|
evalJS执行 | 偏同步,但返回值往往拿不到 | 异步回调,有完整回调机制 |
消息到达@message | 触发时机跟随版本策略 | 相对更接近实时 |
| H5 里 ES6+ 语法 | 低版本系统 WebView 可能不支持 | 系统 WebView 随系统更新 |
| 混合内容限制 | 对 http 资源限制明显 | ATS 默认限制 http 混合内容 |
我的经验是:涉及支付、登录这类关键消息,不要只依赖一端行为,尽量在上层做一层“确认机制”。比如 H5 发了postMessage之后,App 端收到后回一个received状态,H5 没收到回执就继续重试或提示用户。双向通信要可靠,就要有握手,不能“发完就当成功”。
6.3 uni-app H5 端怎么做内嵌页面通信
另一种容易混淆的情况是:uni-app 本身编译成了 H5 网站,你在普通浏览器页面上再用web-view组件去嵌第三方页面。注意,uni-app 的web-view组件在 H5 端能力很弱,通常不建议依赖它。更通用的做法是直接用 iframe + 浏览器原生的postMessage:
// 宿主页面(uni-app H5) const iframe = document.getElementById('child-frame'); iframe.contentWindow.postMessage({ type: 'init', data: 'hello' }, '*'); window.addEventListener('message', (event) => { if (event.origin !== 'https://child-domain.com') return; console.log('iframe 发来的消息:', event.data); });内嵌页面里对应监听message事件,再通过event.source.postMessage回传。这里用的是浏览器原生机制,和 uni-app 的uni.postMessage是两套东西,别混着用。判断依据也很简单:你的宿主到底是一个 App 壳,还是一个普通网页。
7. 调试与排坑:消息“没送达”时我从哪下手
7.1 把 H5 的 console 拉到 App 端打印
WebView 里的 console 默认不会显示在 App 的日志里,这是调试的一大痛点。最简单的临时方案是让 App 端注入一段 console 重定向脚本,把 H5 的日志通过postMessage传到 App 端。在@load后执行:
const redirectConsole = ` (function() { const originalLog = console.log; console.log = function (...args) { originalLog.apply(console, args); uni.postMessage({ data: { type: '__console', payload: args.map(a => { try { return JSON.stringify(a); } catch(e) { return String(a); } }) }, option: { isRealTime: true } }); }; })(); `; this.$refs.webview.evalJS(redirectConsole);这样做虽然粗糙,但能让你在 App 端日志里直接看到 H5 里的运行状态,排查问题时比盲猜快很多。上线前记得去掉这个注入脚本。
7.2 版本匹配问题:通信脚本与编译器版本
我踩过比较深的一个坑,就是uni.webview.js脚本和 uni-app 编译器版本不匹配。H5 页面里引用的脚本来自某个旧版本,而编译器已经更新到新版本,导致option.isRealTime不生效,甚至某些新消息类型被静默忽略。
处理办法是:每次升级 uni-app 编译器,顺手把 H5 端引用的uni.webview.js一起升级,保持两端版本一致。如果 H5 是长期托管在第三方系统的,没法频繁改版本,那就以旧版本的能力为准,不要在代码里赌新特性一定可用。
7.3 我踩过的三个高频坑与排查顺序
如果你遇到“消息没送达”,可以先按这个顺序排查:
| 现象 | 可能原因 | 验证方式 |
|---|---|---|
H5 调uni.postMessage报uni is not defined | 没引入uni.webview.js | 检查 H5 页面脚本加载 |
App@message一直不触发 | 触发时机没到,或不支持实时参数 | 在 H5 里加日志确认调用;回到上一页看是否触发 |
evalJS没反应 | 页面没load完成,或脚本拼接出错 | 先执行window.alert(1)测试,再换成正式命令 |
evalJS拼接后语法报错 | 数据里有引号、换行、</script> | 用JSON.stringify+ 转义<后再拼接 |
特别提醒:测试evalJS是否生效,不要用alert,因为 WebView 的alert有时会被宿主拦截。你可以先定义一个测试函数,比如window.__bridgeProbe = function(){ return 'ok'; },然后evalJS调用它,再通过上行通道把状态发回来,这种验证方式更可靠。
7.4 日志打点与消息追踪的建议
最后分享一个偏“工程习惯”的建议:给双向通信建立统一的日志规范。每条消息都带上requestId和timestamp,App 端和 H5 端在发送、接收、处理完成三个节点分别打点,日志格式统一为[bridge][send|recv|done][type][requestId]。
这样一旦出问题,日志一拉,整条链路在哪个节点断了,一眼就能看明白。比两个人对着控制台猜“你发了没有”“我收到没有”要高效得多。
我在实际项目里最大的体会是:双向通信的难点不在代码量,而在消息时序和平台差异。你花一个下午把 Bridge 封装好、把协议定清楚、把三端差异摸一遍,后面接再多的业务页面,都只是加两个消息类型的事;反过来,如果一开始就图省事直接散装写,每个新页面都能给你整出新惊喜。先把最小闭环跑通,再谈封装,这条路走下来是最顺的。