做混合开发最绕不开的坎就是 web-view 与网页端之间的双向通信。不管你是要把现有 H5 塞进 App还是在小程序里挂一个活动页只要页面上了 web-view你就得面对“怎么把登录态带进去”“怎么让网页通知外层页面刷新”“怎么在网页里调起 App 的原生能力”这一连串问题。这篇东西我整理了之前在几个项目里反复用到、也反复被坑过的通信方案把 uni-app 的 web-view 组件、网页端 uni 对象、以及 App 端的 plus 通道完整拆一遍适合刚上手 uni-app 的开发者也适合已经写过但被平台差异坑过、想搞清楚底层逻辑的同行。1. 场景拆解到底什么场景需要双向通信1.1 最常见的三类业务场景先别急着看代码把场景想清楚比什么都重要。我在这几年接触过的项目里web-view 需要双向通信的场景基本逃不出这三类。第一类是登录态传递。用户已经在 uni-app 端登录了token 存在本地打开网页时要把 token、用户 ID、部分用户资料传给网页端让网页端直接用这些信息去请求后端接口避免用户重新登录。这个场景通常只需要“单向传递”但往往要卡在时机上——网页还没加载完消息就发过去了结果网页端没收到。第二类是页面操作联动。用户在网页里完成了某个操作比如支付成功、提交订单、填写完表单需要告诉外层 uni-app 页面去刷新本地数据、跳转页面或者关闭当前 web-view。这类场景是典型的“双向通信”网页端主动向 App 端发送数据。很多新手在这里被坑因为小程序端和 App 端的回传机制完全不一样。第三类是原生能力接力。网页端需要调用相机、扫码、定位等原生能力。虽然可以直接用网页的 JS API但稳定性远不如 App 端原生能力。常见的做法是网页端发送消息到 uni-app 层由 uni-app 层调用原生能力拿到结果后再通过 web-view 的命令执行函数把结果返回给网页端。这一类对双向通信的要求最高链路也最长最容易出错。1.2 通信通道对比为什么不是所有方案都通用很多人第一次接触 web-view 通信时以为就是“在网页里调一个方法在外层监听一个事件”这么简单。但实际上这个机制在 App 端、小程序端、H5 端是三个完全不同的实现方式甚至可以说你在小程序里调通的代码搬到 App 端可能直接失效。通信方向App 端微信小程序端H5 端uni-app 向网页传值URL 传参 / evalJSURL 传参 / SelectorQuery 获取 context 后 evalJsURL 传参 / iframe postMessage网页向 uni-app 回传URL Scheme 拦截uni.postMessage messagewindow.postMessage message 事件监听为什么会有这种差异因为 uni-app 本质上是多端编译框架它在不同端上面对的是完全不同的底层能力。小程序端有 web-view 组件的内置消息机制App 端底层是原生 webview只能用 plus 桥接H5 端则是 iframe 的 postMessage 机制。做方案的时候千万不要只针对一种端去写而是要先想清楚你这次要发布到哪些端然后分别适配。2. 核心机制解析web-view 两级页面通信的底层逻辑2.1 uni-app 向网页传值的三条路第一条是 URL 传参。这个最简单web-view 组件的 src 就是一个普通 URL直接在 query 里带上参数即可。它的优点是天然稳定、不用等页面加载完成因为参数是跟着页面 URL 一起到达的缺点是 URL 长度有限制不同端限制不同一般几 K 以内、传中文要 encodeURIComponent、而且想传对象必须先序列化成 JSON 字符串再拼进 URL在网页里再解析回来这一来一回就很容易出编码问题。web-view srchttps://your-domain.com/page.html?tokenABC123userId10086/web-view第二条是 evalJS。这是 App 端最核心的传值手段。原理很简单App 端持有网页所在的 webview 实例然后直接调用 webview 对象上的 evalJS 方法相当于在网页的 JS 环境里执行一段 JS 代码。因为是在网页加载完成之后才能执行所以必须先拿到 webview 实例而且要注意执行时机必须在 load 事件之后。// App端在vue页面中拿到内嵌webview实例 const currentWebview this.$scope.$getAppWebview().children()[0]; // 在网页中已经定义了一个全局函数 currentWebview.evalJS(window.receiveDataFromApp window.receiveDataFromApp(${JSON.stringify(data)}));第三条是 SelectorQuery 的 context 实例。这是小程序端专用。小程序不像 App 端那样能直接拿到原生 webview 实例它提供的是组件上下文实例通过 uni.createSelectorQuery 来获取。onReady() { this.webviewContext uni.createSelectorQuery().select(#webview).context(); } sendDataToWeb() { const data { type: USER_INFO, payload: { token: ABC123 } }; this.webviewContext.evalJs(window.receiveDataFromApp window.receiveDataFromApp(${JSON.stringify(data)})); }注意这里有个细节小程序端的 context 获取必须放到 onReady 里不能放到 onLoad因为页面还没渲染完成时是查不到组件的。而且同一个 web-view 的 context 实例只需要获取一次存到 this 上后续可以反复使用。还有一条容易被忽略的路径nvue 页面里的 web-view。nvue 页面里的 web-view 组件本质上是 weex 的 web 组件获取方法的姿势和 vue 页面又不一样但现在写 nvue 的团队已经越来越少了这里就不展开说了真要用到再单独查文档就行。2.2 网页向 uni-app 回传的两个关键 API网页端回传的核心是uni.postMessage它是 uni-app 在网页端注入的uni对象的全局方法并不是浏览器原生 API。也就是说网页里必须能访问到window.uni这个对象才能用。// 网页端 if (window.uni uni.webView) { uni.postMessage({ data: { type: ORDER_FINISH, payload: { orderId: X001, status: success } } }); }这里必须强调一个很多新手都不知道的细节在小程序端uni.postMessage 这个调用并不会立刻触发外层页面的 message 事件而是会被缓存起来等到特定时机才会派发。什么时机网页向小程序页面跳转、网页刷新、或网页关闭的时候。这个时机的坑我后面会专门讲。外层页面接收消息靠的是 message 事件。在 uni-app 页面里给 web-view 组件加上这个事件监听即可web-view :srcurl messagehandleMessage/web-viewhandleMessage(event) { // 小程序端event.detail.data 是一个数组 // App端若支持event.detail 直接就是数据 const data event.detail.data[0] || event.detail.data; console.log(网页消息来了, data); }至于 App 端uni.postMessage在 vue 页面的 web-view 里其实是不稳定的甚至可以说是不可用的。我之前在 App 端测试时发现 message 事件根本不会被触发。后来查了一下资料也验证了这个现象。所以在 App 端要另寻出路。2.3 必须提前约定的数据协议双向通信最怕的不是技术不会而是网页端发送的数据格式和外层页面期望的格式对不上。我见过太多项目网页那边传了一个字符串过来外层页面的同事以为是 JSON 对象直接data.type拿值结果拿到 undefined排查半天才发现是格式没对齐。所以不管项目多小都建议在动手前先约定一套简单的数据协议。我自己常用的格式是{ type: 事件类型, payload: { 业务数据字段: 值 } }type 是字符串比如LOGIN_SUCCESS、PAY_FINISHED、CLOSE_PAGE用来表示事件类型payload 是对象承载具体业务数据。网页端发送时统一走这个格式外层页面接收后先解析 type再分发处理。为什么要加一层 type因为一个好的通信设计不应该让外层页面每次都要猜网页传来的是什么。把类型和链路前置约定好了后续加新的消息类型只需要在文档里加一行不需要改动框架。我在实际项目里还会顺手写一份消息类型对照表放在项目的 README 里方便不同端的人对齐。3. 实操搭建一个可复用的双向通信模块3.1 网页端 SDK 的封装网页端是通信的“另一端”但网页端往往不是 uni-app 开发者在维护可能是独立的 H5 团队在维护。因此把通信逻辑封装成一个独立的 JS SDK 交给网页团队是最省心的做法。先写一个基础检测逻辑判断当前网页是否运行在 uni-app 的 web-view 环境里// uni-web-sdk.js (function (global) { var isUniApp false; function checkEnv() { if (window.uni uni.webView uni.webView.getEnv) { uni.webView.getEnv(function (res) { isUniApp true; console.log(当前环境uni-app WebView); }); } else { console.log(普通浏览器环境); } } function postMessage(type, payload) { var message { type: type, payload: payload || {} }; if (window.uni uni.postMessage) { uni.postMessage({ data: message }); } else { // 非 uni-app 环境直接 console 输出方便调试 console.warn([uni-web-sdk] 当前不在uni-app环境消息未发送, message); } } // 暴露全局方法 global.UniBridge { postMessage: postMessage, checkEnv: checkEnv }; checkEnv(); })(window);这个 SDK 的好处是网页端只需要记得调用UniBridge.postMessage(ORDER_FINISH, {...})就能发消息完全不用关心外层是 App 还是小程序。SDK 内部帮你把环境差异处理掉了。网页端接收 uni-app 传来的数据我建议在网页里挂一个全局方法这个方法名要固定好和服务端约定一致比如window.receiveDataFromApp。网页团队在使用时只需要在初始化页面时覆盖这个方法window.receiveDataFromApp function (data) { console.log(uni-app 传来的数据, data); // 处理 token、用户信息、业务逻辑 if (data.type USER_INFO) { localStorage.setItem(token, data.payload.token); } };这里有个小技巧不要把接收数据的函数挂在不稳定的对象上就直接挂 window因为它最简单、最可靠而且在 evalJS 里访问 window 肯定没问题。3.2 uni-app 页面接收与发送外层 uni-app 页面这一侧我一般是做成一个 mixin 或者一个独立的工具模块。以下是在一个 vue 页面里的完整实现同时兼容 App 端和小程序端template view web-view idwebview :srcurl messagehandleMessage /web-view /view /templateexport default { data() { return { url: https://your-domain.com/page.html, webviewContext: null }; }, onLoad(options) { // 如果需要向网页传 token 等参数直接拼在 url 上 const token uni.getStorageSync(token); this.url ${this.url}?token${encodeURIComponent(token)}userId10086; }, onReady() { // #ifdef MP-WEIXIN this.webviewContext uni.createSelectorQuery().select(#webview).context(); // #endif }, methods: { handleMessage(event) { let data; // #ifdef MP-WEIXIN data event.detail.data event.detail.data[0]; // #endif // #ifdef APP-PLUS data event.detail.data; // #endif if (!data) return; switch (data.type) { case ORDER_FINISH: this.refreshOrderList(); break; case CLOSE_PAGE: uni.navigateBack(); break; default: console.log(未处理的消息类型, data.type); } }, sendToWeb(data) { // 小程序端通过 context.evalJs // #ifdef MP-WEIXIN if (this.webviewContext) { this.webviewContext.evalJs(window.receiveDataFromApp window.receiveDataFromApp(${JSON.stringify(data)})); } // #endif // App端直接拿 webview 实例 // #ifdef APP-PLUS const currentWebview this.$scope.$getAppWebview().children()[0]; if (currentWebview) { currentWebview.evalJS(window.receiveDataFromApp window.receiveDataFromApp(${JSON.stringify(data)})); } // #endif } } };这里有一个非常重要的点条件编译。// #ifdef MP-WEIXIN和// #ifdef APP-PLUS是 uni-app 的预处理指令编译时只有对应平台才会保留这一段代码。这是 uni-app 多端适配的基石写跨端代码时一定要用好不要在运行时再去判断平台。我之前见过有同事用uni.getSystemInfoSync().platform去判断平台然后在运行时写 if else代码不但跑起来慢在编译阶段还会因为平台没有的 API 而报警告。条件编译是编译期处理的生成的包体积更小逻辑也更清晰。3.3 App 端补充通道URL 拦截前面说过 App 端的 uni.postMessage 不稳定所以在 App 端我通常用 URL Scheme 拦截来做网页向 App 端的回传。原理不复杂网页端跳转一个自定义协议 URLApp 端在 webview 的 URL 拦截回调里解析这个 URL把数据提取出来。先在网页端封装一个跳转方法// 网页端补充方法App端专用小程序端也可用但会被当作跳转提示而触发页面跳转 function postMessageToApp(type, payload) { var message { type: type, payload: payload }; var url uni-communication://message?data encodeURIComponent(JSON.stringify(message)); // 使用 location.href 触发跳转会在 App 端被拦截 location.href url; }App 端 vue 页面中在 webview 实例上挂 URL 拦截// App端在页面加载完后的某个时机执行一次 addAppUrlInterceptor() { // #ifdef APP-PLUS const currentWebview this.$scope.$getAppWebview().children()[0]; if (currentWebview) { currentWebview.overrideUrlLoading({ block: true }, (e) { const url e.url; if (url.startsWith(uni-communication://)) { try { const encodedData url.split(data)[1]; const data JSON.parse(decodeURIComponent(encodedData)); console.log(App端拦截到网页消息, data); this.handleMessage({ detail: { data: data } }); } catch (error) { console.error(解析URL Scheme消息失败, error); } } }); } // #endif }这里的关键参数是block: true它告诉 webview 拦截后不要真的去加载这个 URL否则网页端会被导航到乱码地址。这个逻辑听起来有点绕但它是 App 端和网页之间最可靠的通信方式之一很多原生开发的老项目都是这么干的。有一点要注意overrideUrlLoading这个 API 是 plus 提供的原生能力属于 5 App 的 API所以在 App 端完全可用小程序端不用也不能用。这个也要用条件编译包起来。3.4 小程序端回传与 App 端回传的兼容写法很多项目既要上 App 又要上小程序网页端的发送最好做一个统一的入口在网页内部判断当前环境走不同的发送方式。我上面那个 UniBridge 只用了 uni.postMessage对于 App 端不可用的问题需要加上环境判断function postMessage(type, payload) { var message { type: type, payload: payload || {} }; // 判断是不是 App 环境 if (window.plus) { // 直接走 URL 拦截 postMessageToApp(type, payload); } else if (window.uni uni.postMessage) { uni.postMessage({ data: message }); } else { console.warn([uni-web-sdk] 未知环境发送消息失败, message); } }这里用window.plus判断 App 环境其实有点粗糙因为 plus 对象只在 App 端的 webview 里存在。但实际测试下来这个判断在大多数场景下是够用的。如果你要更严谨用uni.getSystemInfoSync().uniPlatform去判断也可以但要注意有些老版本的 uni-app 没有这个字段。网页端的 SDK 做好这层判断后H5 团队基本上就不用关心端差异了他们只需要调UniBridge.postMessage剩下的交给 SDK。4. 常见问题与排查技巧实录4.1 小程序端收不到数据先看 event.detail.data 是不是数组这是我在微信小程序端踩得最多的一个坑。小程序端的 web-view 组件message事件回调里的event.detail.data是一个数组不是对象。你可能在网页端发送的是uni.postMessage({ data: { type: PAY_FINISHED } });但外层页面拿到的event.detail.data长这样[{ type: PAY_FINISHED }]如果你直接event.detail.data.type那就是 undefined。我之前在这个问题上卡了整整一个下午最后查官方文档才发现数据是包裹在数组里的。所以接收时记得取[0]或者做一层兼容处理const data Array.isArray(event.detail.data) ? event.detail.data[0] : event.detail.data;这里还要注意一个细节如果网页端连续调用了多次uni.postMessage外层页面可能收到一个数组里包含多个对象对应多次调用。这时候如果你的业务只需要最新一次的消息就取数组最后一个元素如果需要全部处理就遍历数组。千万别默认数组里只有一个元素写死取[0]一旦网页端发两次消息第二次就被吞了。4.2 App 端 evalJS 不生效的真相很多人在 App 端用 evalJS 向网页传值发现网页里的方法没有执行。我总结下来主要有三个原因。第一个是取值时机不对。this.$scope.$getAppWebview().children()[0]这段代码必须在 web-view 组件渲染完成之后才能拿到实例。你在 onLoad 里拿大概率拿到 undefined因为页面都还没渲染出来。正确姿势是在 onReady 之后再取或者干脆封装一个重试机制比如 100ms 后还没取到就继续重试直到拿到为止。第二个是网页还没加载完就执行 evalJS。evalJS 的本质是往网页的 JS 上下文里塞一段代码如果网页的 JS 上下文还没建立这段代码就执行不了或者执行了但网页里挂载的方法还没挂上。解决办法是监听 webview 的加载事件或者简单粗暴一点在 evalJS 里加一个延迟执行currentWebview.evalJS( setTimeout(function() { window.receiveDataFromApp window.receiveDataFromApp(${JSON.stringify(data)}); }, 500); );但延迟是个双刃剑。设短了可能还是没加载完设长了影响用户体验。我个人推荐用plus.webview的addEventListener(loaded)事件等网页加载完成再发消息currentWebview.addEventListener(loaded, function () { currentWebview.evalJS(window.receiveDataFromApp( JSON.stringify(data) )); });第三个是 evalJS 方法拼出来的代码有语法错误。比如你在 JS 字符串里拼了 JSON 字符串但 JSON 内容里有特殊字符比如引号、换行结果拼出来的代码变成语法错误evalJS 静默失败控制台也不会报错。解决办法是拼代码前对 data 做一次严格序列化然后放进代码时注意转义。JSON.stringify(JSON.stringify(data))这种双序列化的技巧可以避开很多转义问题因为内层字符串会被自动加上引号转义。4.3 消息丢失与时机问题网页端调用uni.postMessage后外层页面想立刻收到消息但小程序端的机制决定了消息只能在小程序页面收到 web-view 的特定时机时统一派发。这里的“特定时机”官方文档里的描述是网页向小程序 postMessage 时会在特定时机小程序后退、组件销毁、分享触发并收到消息。也就是说如果网页端发送消息后小程序页面还停留在 web-view 当前页消息是不会实时触发的。这在某些业务场景下会在消息链路里造成额外延迟。要解决这个问题有几个思路在关键操作后让网页主动触发一次页面跳转比如location.href带一个标记这样小程序页面会收到被中断的加载事件从而触发消息派发。但这个方案有体验问题不太推荐。如果业务允许可以约定在网页关闭、页面返回时再提交数据这种场景天然符合小程序的消息触发时机。另一个稳妥做法是网页端发送数据的同时把数据冗余写进 URL 的 hash 里或使用 storage 同步外层页面在 onShow 时主动去取不依赖 message。但这样会引入额外复杂度适合特别重要的数据。我的建议是在设计业务时就想清楚消息链路。如果是“网页操作完跳回 uni-app 页面并刷新数据”这种场景天然符合时机如果是“网页内某个按钮要实时同步状态到外层页面”就要慎重评估或者改用 App 端 URL 拦截通道它不受时机限制更实时。4.4 参数传递的编码坑URL 传参和 URL 拦截传参都逃不开编码问题。最常见的是两层编码第一层是把对象序列化成 JSON 字符串第二层是把这个字符串 encodeURIComponent 后再塞进 URL。// 网页端发送 const jsonStr JSON.stringify({ type: PAY_FINISHED, payload: { name: 张三 } }); const encoded encodeURIComponent(jsonStr); const url uni-communication://message?data encoded; // App端解析 const decoded decodeURIComponent(url.split(data)[1]); const data JSON.parse(decoded);为什么需要 encodeURIComponent因为 URL 本身对中文、特殊字符比如 、、有限制不编码会破坏 URL 结构。比如 JSON 里有符号不编码的话URL 会被解析成两个参数数据直接丢失。另一个坑是 URL 长度限制。App 端的 URL 拦截也好小程序端的 web-view 也好URL 都不能无限长。小程序端一般建议 URL 总长度不要超过 2048 字符实际测试不同版本可能不同超出后可能被浏览器截断。如果你要传比较大的数据比如一整张表单、一个列表不建议走 URL建议改用业务层面的存储中转——比如网页端把数据写入 localStorage然后在 URL 里只传一个 key外层页面再去指定存储里取但这套方案跨端实现起来比较麻烦一般也是不得已而为之。4.5 App 端 vue 页面与 nvue 页面的差异前面说的大多是 vue 页面里的做法。但如果你在用 nvue 页面情况又有不同。nvue 页面的 web-view 组件无法直接用this.$scope.$getAppWebview().children()[0]来获取实例——因为这个 API 是 vue 页面的语法糖。nvue 里常用的方式是通过 plus 提供的 API 去拿或者直接创建一个 webview 实例出来单独管理。具体方案要看项目基础但有一个原则我想提醒如果你没有足够理由使用 nvue尽量别为了一个 web-view 场景去上 nvue。nvue 的生态和周边问题比如样式渲染、组件支持比 vue 页面复杂很多为了一个内嵌页面引入整套复杂度不划算。5. 一些经验之外的经验做这个双向通信模块我前后也接触了不少项目最后想聊几句不那么技术但挺重要的体会。第一个建议是把通信模块独立出来不要散落在一个页面里。我见过有项目把通信逻辑堆在业务页面里后来 H5 端改了数据格式开发要全局搜索替换那叫一个酸爽。把通信 SDK、消息类型常量、页面跳转逻辑全部分层后面接入新页面时能省很多事。第二个建议是联调时让网页端本地起服务别用线上链接。开发阶段经常遇到一个问题——网页端改了代码但外层 uni-app 里 src 指向的还是线上地址怎么调都不生效。让网页端把本地服务通过局域网 IP 暴露出来外层 web-view 直接指向这个 IP这样两边改完代码都能立刻看到效果效率翻倍。不过要注意本地 IP 服务要能被手机访问别挂着 localhost 调试一上午那只能在电脑模拟器里看。第三个建议是日志埋点别偷懒。双向通信的问题十有八九是“消息到底有没有发出去”的问题。在网页端的 SDK 里把每次发送的消息、每次接收的命令都打上 console.log在外层页面里也做同样的日志。联调时候发现问题第一件事就是两边日志对齐。我甚至会在关键消息里打上时间戳用时间戳去对比两边日志能快速判断是哪个环节丢了。最后如果你在当前项目里还只是需要在“打开网页时传一个 token”这种简单场景里打转那么用 URL 传参就够了没必要把整套桥接方案都塞进去。通信这个事做简单了是自找清闲做复杂了是自找麻烦。先把需求理清楚再去选方案技术广度是用来解决问题的不是用来炫技的。
阅读完成 · 觉得有帮助?