做跨端开发的人迟早会撞上“App 里塞网页”这个需求。某天产品过来说要搞一个活动页用 H5 快速上线你第一反应就是用 web-view 把链接扔进去。做完之后问题来了用户在网页里登录了App 这边完全不知道App 想给网页塞一个用户标识网页死活拿不到网页里点了一下按钮原本应该调起原生分享结果毫无反应。这一串问题本质就是 UniApp 与 H5 双向通信没打通。这篇文章就把 App 与 H5 之间消息通道的几种姿势全部讲透从 URL 参数到 uni.postMessage、从 evalJS 注入到 JSBridge 适配附上完整可复现的代码和踩坑记录。适合正在用 uni-app 做 App 内嵌 H5、并且被消息同步逼疯的开发者。1. 先搞清楚App 里的 H5 到底是谁通信方案不是凭空选的第一步要看 H5 页面是怎么来的。同样是“App 内嵌网页”底层环境不一样能用的 API 完全不一样。1.1 三种 H5 形态我把日常项目里遇到的 H5 分成了三类第一种H5 本身也是 uni-app 项目编译出来的。比如同一套代码App 端和 H5 端都用 uni-app 写App 里用 web-view 去加载编译后的 H5 页面。这种页面天生就有 uni 全局对象直接可以调用uni.postMessage链路最顺。第二种H5 是第三方网页。可能是历史遗留的 Vue / React 项目也可能是纯静态营销页。这种页面打开后没有 uni 对象调用uni.postMessage会直接报错需要额外引入适配脚本或者自己封装桥。第三种本地 HTML。页面打包在 App 的hybrid/html目录下通过web-view加载本地文件。通信机制跟第二种类似但要额外注意本地路径、资源引用和跨域问题。这三种形态在通信层最大的区别只有一个网页里有没有uni对象。有就省事没有就得搭桥。我在接手模拟项目X的时候第一次就把第二种当成第一种处理结果 H5 页面报了一堆uni is not defined排查了半天才反应过来说是普通网页。1.2 双向通信其实有四个独立通道很多人一听到“双向通信”下意识觉得是一条双向管道。实际不是App 与 H5 之间的数据流至少可以拆成四条完全独立的通道App 给 H5 传初始数据URL query 参数App 运行中给 H5 下发指令evalJS 注入并执行网页里的 JSH5 给 App 上报事件uni.postMessage messageH5 需要 App 返回结果通过 URL 跳转、自定义事件回收或 JSBridge 回调每一条通道的技术栈都不一样。你不可能靠一个postMessage打通所有需求这也是很多人看完官方文档还是写不出来的原因——文档只讲了其中一条剩下的要靠项目经验拼出来。1.3 web-view 组件的两个性格在 App 端web-view不是普通组件它本质上是plus.webview创建的一个子 Webview会铺满整个页面。因此它在页面里层级极高普通元素根本盖不住它想在上面放按钮只能靠原生导航栏或者cover-view。这个特性会导致一些布局上的坑后面我会单独列一小节。同时这个子 Webview 跟父页面之间天然存在一套消息机制。uni-app 把这套机制封装成了message事件但封装过程中加了几个限制条件这也是本文最核心的避坑点。2. 通信底层的三个关键认知方案选型之前先把底层机制吃透。很多网上抄来的代码跑不通就是因为缺少这三个认知。2.1 uni.postMessage 的数据是有“延迟”的这是整个通信方案里最容易被忽视、也最坑的一条。在 App 端H5 页面里调用uni.postMessage({ data: {...} })数据并不会立刻飞到 App 的message里。官方定义是数据会在 H5 页面发生back返回、share分享或者web-view组件销毁的时候才统一打包传给 App。换句话说H5 发完消息不返回、不销毁App 端就是收不到。我在第一个项目里就吃过这个亏H5 登录成功后立刻调用了uni.postMessage上报 token然后页面停在原地等 App 处理结果 App 端message一直不触发后台日志干净得吓人。后来才明白必须让 H5 在发完消息后执行uni.navigateBack()或者主动关闭页面消息才会真正送达。这个设计本质上是为了性能考虑Webview 与原生层之间频繁通信有开销所以 uni-app 把多次消息攒起来在关键时机一次性回传。理解了这个初衷你就不会对着文档骂人了。2.2 evalJS 是 App 对 H5 的直通车下行通道里evalJS是 App 端主动向 H5 灌数据的核心手段。它的作用是在子 Webview 的上下文里执行一段 JavaScript 代码可以改 DOM、可以调全局函数、可以触发表单等同于你坐在 App 这边把手伸进网页里操作。但有一个硬限制evalJS执行完 JS 后不返回任何值。你想让它执行window.getToken()然后拿返回值是没有用的。要想拿到计算结果必须让网页先把数据写到某个可观察的位置比如document.title、location.hash或者直接通过uni.postMessage在返回时带出来。这一点在后面案例里会具体展开。2.3 普通网页没有 uni 对象怎么办第三方网页想在 App 里做消息上报标准解法是引入官方提供的uni.webview.js适配脚本。这个脚本会在网页的 window 上挂一个最小可用的uni对象只包含少数几个 Webview 通信相关的 API比如uni.postMessage、uni.navigateBack。引入之后第三方网页就能像 uni-app 项目一样调用uni.postMessage上报数据App 端还是通过message接收这套链路已经有很多项目跑过。注意脚本要放在页面最开始加载并且最好加一层环境判断如果页面在普通浏览器中打开没有plus也没有 Webview 桥脚本要做兼容降级别让页面在浏览器里报错。3. App 向 H5 下发下行通道实操下行通道的诉求很明确App 要把身份信息、配置参数、操作指令交给 H5。常见做法有两种按场景拆开讲。3.1 URL 拼接传参适合页面初始化最直接的方式就是在给web-view的src赋值时把参数拼到 query 里。适合传一些一次性、不可变的基础数据比如 token、uid、来源标识、活动 ID。App 端拼接 URL 的代码const baseUrl https://your-h5-domain.com/activity const query [ token${encodeURIComponent(this.token)}, uid${this.uid}, fromapp ].join() this.pageUrl ${baseUrl}?${query}模板里使用web-view :srcpageUrl messageonH5Message/web-viewH5 端读取参数如果 H5 是 uni-app 项目直接在onLoad(options)里拿onLoad(options) { // options.token / options.uid }如果是普通网页用URLSearchParams解析 location.searchconst params new URLSearchParams(window.location.search) const token params.get(token)这里有两个细节容易出错。第一参数值一定要做encodeURIComponent用户 token 里经常带、、%之类的字符不编码会直接截断或者解出乱码。第二URL 拼接不要用字符串模板硬拼越大的对象越容易踩坑建议统一走 query 数组或拼接函数。3.2 evalJS 动态注入适合运行中指令URL 参数只能解决“页面一打开就知道的数据”。如果 H5 已经在运行App 这边要临时让它切换皮肤、跳转路由、弹出登录框就得用evalJS。第一步App 端拿到子 Webview 实例。在 uni-app 页面onReady之后可以通过当前页面拿到onReady() { // #ifdef APP-PLUS const pages getCurrentPages() const currentPage pages[pages.length - 1] this.webview currentPage.$getAppWebview().children()[0] // #endif }第二步往 H5 里注入执行代码methods: { sendActionToH5(action, payload) { const data JSON.stringify({ action, payload }) // #ifdef APP-PLUS this.webview.evalJS(window.__receiveAppMessage(${data})) // #endif } }H5 侧需要提前把接收函数挂到 window 上// 普通网页或 uni-app H5 都可以这么做 window.__receiveAppMessage function (message) { console.log(收到 App 指令, message) // 按 action 分发处理 }要注意时序evalJS执行时H5 页面必须已经加载完 JS否则会提示函数未定义。稳妥做法是先监听子 Webview 的loaded事件或者延迟到onReady之后 300-500ms 再执行。具体环境不同实测中我通常会在 send 之前加一个setTimeout兜底。3.3 刷新后的状态恢复H5 页面一旦在内部跳转路由或者刷新URL 参数可能被吃掉App 注入的全局变量也会重置。很多项目在这里翻车第一次进入 H5 一切正常刷新一次之后 token 丢失用户被弹回登录页。解决思路是让 H5 把关键数据自己缓存一份。推荐在 H5 端写一个状态恢复模块const APP_STATE_KEY app_initial_state // 收到 App 下发数据时写入本地缓存 window.__receiveAppMessage function (message) { localStorage.setItem(APP_STATE_KEY, JSON.stringify(message)) } // 页面启动时先读缓存 const cached localStorage.getItem(APP_STATE_KEY) if (cached) { const state JSON.parse(cached) // 恢复 App 下发的身份信息等 }App 端重新加载 H5 时也用 URL 参数把会话 ID 传过去H5 拿 ID 去请求接口自动登录。这个“会话 ID 本地缓存”的组合比每次把完整 token 塞在 URL 里更安全也避免 URL 过长被服务端拒绝。4. H5 向 App 上报上行通道实操上行通道是双向通信里最容易出问题的部分核心难点就是官方封装导致的“时机延迟”。下面分场景给出完整链路。4.1 uni-app H5 的 uni.postMessage 标准链路先看最标准的做法。H5 页面在某个业务节点上报uni.postMessage({ data: { action: loginSuccess, token: please-remember-me, userInfo: { name: demo, vip: true } } })App 端模板上监听web-view :srcpageUrl messageonH5Message/web-viewmethods: { onH5Message(e) { // e.detail.data 就是 H5 上报的数据 console.log(H5 上报, e.detail.data) const { action, token } e.detail.data // 处理业务 } }这就是官方文档展示的完整链路看起来人畜无害。但如果你照着写完大概率会得到“收不到消息”的结果原因就是前面说的触发时机。4.2 触发时机的业务设计既然uni.postMessage的数据要等back、share或web-view销毁时才上报那业务设计就必须顺着这个规则来不要逆着来。最省心的模式是H5 操作完成后自动返回。比如登录场景H5 拿到 token 后先存本地再调uni.navigateBack()返回 App 原生页消息随返回动作送达App 端在message里收数据、存 token一气呵成。代码示例// H5 登录成功 uni.setStorageSync(token, res.token) // 上报 App uni.postMessage({ data: { action: loginSuccess, token: res.token } }) // 触发返回消息才会真正送达 uni.navigateBack()如果你的业务不允许 H5 立刻返回那就要换个思路。要么把重要数据写在 H5 的 localStorage 里等下一次自然返回时再补报到 App要么用下一小节讲的主动拉取方案。4.3 第三方网页的 JSBridge 适配第三方网页没有 uni 对象需要两步改造。第一步引入适配脚本第二步再调用uni.postMessage。在网页head里加一行script src/static/uni.webview.js/script然后照常上报// 适配脚本会挂一个最小可用的 uni 对象 uni.postMessage({ data: { action: pageAction, value: buttonClicked } })App 端监听方式完全不变还是message。如果不想引外部脚本也可以自己写一个最小桥核心思路是判断window.plus是否存在存在时把数据传给父级 Webviewif (window.plus) { var parent plus.webview.currentWebview().opener() if (parent) { parent.evalJS(window.__h5Report(${JSON.stringify({ action: xxx, value: yyy })})) } }但 App 端要记得在页面里挂一个全局函数__h5Report去接收。这个方案比官方适配脚本更直接但需要两头写代码建议优先用官方脚本。4.4 即时上报的兜底方案总有产品经理不接受“返回后上报”。如果你确实需要 H5 留在当前页同时 App 能拿到最新状态那就得走 App 主动拉取的路。思路是App 端定时通过evalJS去执行一段 JS把 H5 侧保存的最新消息写到document.title里然后 App 再读取子 Webview 的标题。这属于野路子但能被evalJS无法直接返回值的限制工程上完全可用。// App 端定时拉取 setInterval(() { // #ifdef APP-PLUS this.webview.evalJS( try { var msg window.__latestMessage document.title msg ? JSON.stringify(msg) : } catch(e) {} ) setTimeout(() { const title this.webview.getTitle() if (title title.indexOf({) 0) { const msg JSON.parse(title) console.log(主动拉取到 H5 消息, msg) } }, 200) // #endif }, 2000)H5 侧只需要在状态变化时更新全局变量window.__latestMessage { action: progressChanged, percent: 80 }这个方案我用在某个 H5 上传进度同步场景里效果稳定但只建议在没有更好选择时使用。能引导 H5 返回上报的还是优先走uni.postMessage官方链路。5. 完整案例登录态从 H5 同步回 App把上面的通道组合起来看一个真实业务闭环某商城 App 内嵌一个 H5 活动页用户在 H5 里完成授权登录App 需要拿到 token 并存起来第二次进入活动页时App 再把 token 下发给 H5 做自动登录。5.1 业务场景与拆解这个案例几乎覆盖了双向通信的所有知识点首次进入App 通过 URL 参数把 uid 传给 H5H5 根据 uid 展示不同活动内容登录上报H5 拿到登录 token 后用uni.postMessage上报并自动返回二次进入App 判断本地已有 token通过 URL 参数传给 H5运行中状态用户身份变化时App 用evalJS注入新身份给 H5拆解之后会发现每个环节的通信通道都是独立的初始化走 URL上报走 postMessage返回运行中指令走 evalJS。没有哪一段需要“魔法”。5.2 H5 侧实现假设 H5 是 uni-app 项目编译出来的页面。页面启动时读取 URL 参数onLoad(options) { // 首次进入options.uid 来自 App 拼参 if (options.uid) { this.uid options.uid } // 本地缓存恢复 const cached uni.getStorageSync(app_user) if (cached) { this.token cached.token } }登录成功后上报并返回methods: { async login() { const res await requestLogin() // 存本地缓存 uni.setStorageSync(app_user, { token: res.token, uid: this.uid }) // 上报 App uni.postMessage({ data: { action: loginSuccess, token: res.token, uid: this.uid } }) // 触发返回数据送达 uni.navigateBack() } }另外为了让 App 能动态下发身份信息挂一个全局处理函数window.__receiveAppMessage function (message) { // message { action: updateUser, payload: {...} } // 更新页面状态、调用接口等 }5.3 App 侧实现App 页面初始化时组装 URLdata() { return { pageUrl: } }, onLoad() { // 从全局读 token const token uni.getStorageSync(user_token) const uid uni.getStorageSync(user_uid) const params [uid${uid}, fromapp] if (token) { params.push(token${encodeURIComponent(token)}) } this.pageUrl https://your-h5-domain.com/activity?${params.join()} }模板web-view :srcpageUrl messageonH5Message/web-view接收 H5 上报并处理methods: { onH5Message(e) { const data e.detail.data if (data.action loginSuccess) { uni.setStorageSync(user_token, data.token) uni.setStorageSync(user_uid, data.uid) // 更新全局状态通知其他页面 } } }运行中给 H5 下发指令methods: { updateH5User(userInfo) { // #ifdef APP-PLUS const pages getCurrentPages() const currentPage pages[pages.length - 1] const webview currentPage.$getAppWebview().children()[0] const data JSON.stringify({ action: updateUser, payload: userInfo }) webview.evalJS(window.__receiveAppMessage(${data})) // #endif } }5.4 再次进入页面的态恢复第二次进入 H5 时App 已经把 token 拼在 URL 上。H5 的onLoad拿到 token 后可以直接调接口自动登录也可以把 token 存到本地缓存供后续请求使用onLoad(options) { if (options.token) { uni.setStorageSync(app_user, { token: decodeURIComponent(options.token), uid: options.uid }) this.afterLogin() } }到这一步整个登录态双向同步闭环就完整了。这种“URL 下发 postMessage 上报 缓存恢复”的组合是绝大多数 H5 内嵌场景的通用解。6. 高频问题与大坑实录最后把这些年实际踩过的坑集中列一遍每条都是线上环境真实遇到过的。6.1 message 一直没触达排查顺序从简单到复杂确认 H5 页面有没有uni对象。普通网页没引适配脚本调uni.postMessage会直接报错。确认 H5 是否真的执行了uni.navigateBack()。如果不返回、不分享、不销毁消息会一直攒在 Webview 里。确认web-view的message是否绑定在正确的组件上。有些人把事件写到了别的自定义组件上怎么也触发不了。确认数据里没有undefined或函数类型的字段这类数据在桥接层序列化失败时会被静默丢弃。6.2 evalJS 执行不了或没反应最常见原因是拿 Webview 实例的时机不对。页面还没onReady就去拿children()拿到的是空数组。第二个原因是 H5 的接收函数还没有挂到 window 上evalJS 执行时函数不存在报错会被吞掉。建议在注入前先做一次自检const script if (typeof window.__receiveAppMessage function) { window.__receiveAppMessage(ping) } webview.evalJS(script)如果 H5 没准备好这个ping不会产生任何副作用你可以等loaded事件后再重试。6.3 数据格式与编码问题URL 传参漏掉encodeURIComponenttoken 被截断JSON 序列化时遇到中文手动拼接字符串导致引号错乱uni.postMessage里传嵌套对象某些情况下会被降级成字符串建议统一处理所有跨桥数据一律先JSON.stringify接收端再JSON.parse不要用手动拼字符串的方式传结构化数据。6.4 真机调试的几个土办法App 里内嵌 Webview 的 console 日志在 Xcode 或 Android Studio 里不一定看得到。我常用的三个土办法H5 里引入第三方调试工具日志直接展示在页面上把日志通过uni.postMessage上报给 AppApp 端打印到原生 console用evalJS把 H5 的document.title改成日志内容App 端getTitle()读出来第三个办法看起来笨但在排查白屏问题时特别管用因为这个操作不依赖任何网络和存储。6.5 web-view 覆盖页面元素的问题App 端web-view是全屏原生组件层级最高。页面里写的普通按钮、弹窗、浮层都会被它盖住。解决办法有两个业务上用原生导航栏承载操作按钮导航栏的按钮事件由 App 端处理必须在 H5 页面上展示交互元素时把按钮做到 H5 内部用uni.postMessage通知 App千万不要试图用一个浮在web-view上面的 view 去放按钮真机上一定是被遮挡的。最后再分享一个我的实操习惯从第一个内嵌 H5 项目到现在我形成了一套自己的判断流程接到需求先问三个问题——数据从哪到哪、什么时候传、H5 是什么形态。答完这三个问题通信方案基本就定了。下行走 URL 和 evalJS上行走 postMessage 配合返回动作绝不依赖 postMessage 的即时性。第三方网页优先引适配脚本自己写桥只用在不便引入外部资源的时候。这套流程帮我避免了无数次“文档写得很对但线上就是不跑”的尴尬。如果你现在正卡在某个双向通信的细节上建议先把 H5 的代码独立在浏览器里跑一遍确认 uni 对象存在、postMessage 调用不报错再回 App 端查时机和实例获取。大部分问题都出在这两步的衔接上。