uni-app 内嵌 web-view 双向通信实战:登录态注入、扫码回调与返回传值的完整链路

2026-09-19 0 497

手上有个保险类 App,主体是 uni-app 打包的,但运营那边经常要上一些活动页,比如抽奖、答题领券、老带新。这些页面都是 H5,做完直接丢在 CDN 上,需求是嵌进 App 里能正常用。

不用 web-view 其实也行,活动页做成 uni-app 页面就好。但问题是运营用的那套 H5 是外包团队做的,甲方要求原样复用,改一次就要重新走一遍设计评审,走不起。所以最后只能选 web-view 方案。

一开始想得很简单:把 H5 扔进去,能看页面就行。真正做下去才发现,活动页需要的东西一个都不能少——得知道用户是谁(登录态)、得能调起扫码(比如线下扫码领奖)、领完奖品得能回到 App 原生页面并且刷新列表。这三件事每一件都要通一次通信链路,而且每一件都有各自的坑。

一、先把 web-view 的边界摸清楚

在动手写通信之前,有几个硬性约束必须先接受,否则方案设计会走偏。

小程序端的 web-view 是独占的。一个页面里只能有一个 web-view,而且它默认撑满整个可视区域,你在它上面盖个自定义导航栏、盖个返回按钮都盖不住,它永远是层级最高的。想加悬浮按钮只能把按钮放进 H5 里。App 端相对宽松一些,web-view 是个原生组件,可以被其他元素覆盖,但性能不如纯 H5 页面。

通信是异步的,一旦发出就收不回。

web-view 里的 H5 和宿主之间是两套完全独立的运行环境。小程序端最明显——H5 跑在 webview 内核里,宿主逻辑跑在 JSCore 里,两边内存完全隔离。App 端虽然都是 JS,但默认也是隔离的(除非开 v8 共享引擎)。这决定了所有通信都是消息式的,没有所谓的「同步调用」。H5 调 uni.scanCode(),拿到的是回调,回调触发的时机不可预知。

H5 里的 uni 对象不是原生 uni-app 里的那个。

这一点新人最容易误解。H5 页面里能用的 uni 对象是通过 uni.webview.js 注入的,它只包含一部分 API,主要是跳转、消息、存储(部分)。像 uni.requestuni.getSystemInfo 这些并不一定可用,具体要看基础库版本。用之前必须查一下官方那张兼容表,别凭感觉写。

二、H5 侧的初始化:UniAppJSBridgeReady 不能省

通信的第一步是让 H5 认识宿主。做法是在 H5 页面的 head 里引入官方的桥接文件。

<script type="text/javascript" src="https://unpkg.com/@dcloudio/uni-webview-js@0.0.3/index.js"></script>

官方文档里其实更建议把这个文件下载到自己的 CDN 上,因为 unpkg 在国内访问偶尔会抖,活动页加载不出来这种事故挺难看的。

引入之后,代码不能立刻就用 uni.postMessage。原因是这个脚本在页面里执行的时候,宿主可能还没把消息通道准备好。官方给了个专门的事件:

document.addEventListener('UniAppJSBridgeReady', function () {
  // 到这里才能安全地调用 uni 的 API
  uni.postMessage({
    data: {
      action: 'ready',
      payload: {}
    }
  });
});

这个事件只会触发一次,而且触发时机不确定。如果 H5 的初始化逻辑写得比较靠前,很可能事件触发的时候你的逻辑还没注册;反过来如果注册得晚,脚本可能已经跑完了,事件不会重播。所以不能直接写 addEventListener 就完事,得做一个兼容处理:

const bridge = {
  ready: false,
  queue: [],
  handlers: {},

  init() {
    document.addEventListener('UniAppJSBridgeReady', () => {
      this.ready = true;
      this.flush();
    });

    // 兜底:如果在页面里能直接拿到 uni 且有 postMessage,认为桥已就绪
    // 有些基础库版本不会派发 UniAppJSBridgeReady
    if (typeof window.uni !== 'undefined' && typeof window.uni.postMessage === 'function') {
      this.ready = true;
      this.flush();
    }
  },

  // 未就绪之前先把消息排进队列,就绪后一次性发出去
  send(message) {
    if (!this.ready) {
      this.queue.push(message);
      return;
    }
    window.uni.postMessage({ data: message });
  },

  flush() {
    while (this.queue.length) {
      const msg = this.queue.shift();
      window.uni.postMessage({ data: msg });
    }
  }
};

bridge.init();

这段桥接代码本身不长,但省了很多事。只要 H5 里所有的消息都走 bridge.send(),就不用每次都检查宿主是否就绪。

三、H5 到 App:postMessage 这条链路

H5 发出的消息,在 App 这边靠 web-view 组件上的 @message 事件接收。

<template>
  <web-view
    :src="webviewUrl"
    @message="onMessage"
    @onPostMessage="onMessage"
  />
</template>

<script setup>
import { ref, onMounted } from 'vue'

const webviewUrl = ref('')

onMounted(() => {
  const token = uni.getStorageSync('token')
  const uid = uni.getStorageSync('uid')

  // 参数必须 encodeURIComponent,否则 token 里的特殊字符会把 URL 搞坏
  const query = [
    `token=${encodeURIComponent(token)}`,
    `uid=${encodeURIComponent(uid)}`,
    `platform=${encodeURIComponent(uni.getSystemInfoSync().platform)}`
  ].join('&')

  webviewUrl.value = `https://act.example.com/lottery/index.html?${query}`
})

function onMessage(e) {
  // 注意:不同端拿到的结构不完全一样
  const detail = e.detail || e
  const list = detail.data
  if (!list || !list.length) return

  // 多数情况下 data 是一个数组,取第一个元素
  const payload = Array.isArray(list) ? list[0] : list
  dispatch(payload)
}

function dispatch({ action, payload }) {
  switch (action) {
    case 'ready':
      // H5 通知宿主已就绪,可以开始注入数据了
      break
    case 'scan':
      handleScan(payload)
      break
    case 'close':
      handleClose(payload)
      break
    default:
      console.warn('未知的 H5 消息:', action)
  }
}
</script>

有一个坑要提前说:@message@onPostMessage 是两个不同的东西。前者是 App 端 web-view 组件的事件名,后者是小程序端 web-view 的事件名(小程序端不叫 onMessage)。同一份代码要兼容两端,就得两个都挂上,然后在处理函数里判断消息结构。

还有一个细节:App 端的消息体不带版本号,小程序端带。小程序的 @message 收到的是 e.detail.data,是个数组;App 端 @message 收到的是 e.detail.data,也是数组。但早期 App 端(uni-app 2.x)有时直接是 e.detail。所以上面代码里有一句 const detail = e.detail || e,就是为这种情况兜底。

postMessage 的数据大小有上限

这是踩过的一个比较疼的坑。活动页做完抽奖要把中奖记录传给 App,一激动就把整个中奖列表 push 过去了,几百条记录。小程序端直接报错,消息发不出去。

各端上限不一致,经验值是:小程序端大概 64KB 左右,App 端相对宽松,但也不建议超过 100KB。而且即使能发过去,序列化和反序列化的开销也会让页面卡一下。

解决办法有两个。第一种是改变消息设计:只传关键标识,具体数据让 App 自己拉

// 不推荐:把整个记录塞进消息里
bridge.send({
  action: 'prize',
  payload: { records: allRecords }   // 可能几百 KB
});

// 推荐:只传一个标识
bridge.send({
  action: 'prize',
  payload: { orderId: 'A2025011308' }   // App 收到后自己调接口拉详情
});

第二种是分片传输,把大消息切成若干片,每片不超过 16KB,加个序号和总数,App 端攒齐再拼。实现不难但要小心丢片和乱序。除非真的必要,我更建议走第一种。

四、App 到 H5:evalJS 和 URL 参数两种注入方式

方向反过来的通信,App 端调 H5 里的方法,靠的是 evalJS。但注意,在 uni-app 里你不能直接拿到底层 webview 对象,得先通过 uni.createWebviewContext 或者页面实例上的 $getAppWebview()

<template>
  <web-view
    :src="webviewUrl"
    @message="onMessage"
    ref="wvRef"
  />
</template>

<script setup>
import { ref, onMounted, getCurrentInstance } from 'vue'

const wvRef = ref(null)
const instance = getCurrentInstance()

// App 端专用:拿到原生 webview 上下文
function getWebviewContext() {
  // #ifdef APP-PLUS
  const currentWebview = instance.proxy.$scope.$getAppWebview()
  return currentWebview.children()[0]
  // #endif
  // #ifndef APP-PLUS
  return null
  // #endif
}

function pushToH5(method, args = {}) {
  // #ifdef APP-PLUS
  const ctx = getWebviewContext()
  if (!ctx) return
  // evalJS 传的参数必须是字符串,对象要先 JSON 序列化
  const script = `window.__native__ && window.__native__.${method}(${JSON.stringify(args)})`
  ctx.evalJS(script)
  // #endif
}

// 扫码回调后,把结果推回 H5
function handleScan() {
  uni.scanCode({
    onlyFromCamera: false,
    success(res) {
      pushToH5('onScanResult', {
        code: res.result,
        type: res.scanType
      })
    },
    fail(err) {
      pushToH5('onScanResult', { error: err.errMsg || 'cancel' })
    }
  })
}
</script>

有几个地方必须解释清楚。

evalJS 只在 App 端存在。

小程序端不支持这种双向注入,宿主只能单向接收消息,不能主动推。所以如果页面必须双向通信,小程序端就得换个玩法——比如 H5 轮询拉取,或者把 H5 的数据依赖全部前置到 URL 参数里。这也是为什么很多活动页在 App 里内容更丰富、在小程序里会精简一部分功能。

evalJS 的时机很关键。

如果你在 onLoad 里就急着调 evalJS,H5 页面可能还没加载完,window.__native__ 还不存在,脚本执行会静默失败——不报错,但 H5 那边收不到任何东西。所以 App 到 H5 的消息必须等 H5 主动说「我准备好了」之后才能发。这就是为什么前面 H5 桥接代码里要发一个 ready 消息。

流程就变成了:

  1. H5 加载完成,发 ready
  2. App 收到 ready,把需要的数据通过 evalJS 推过去
  3. H5 拿到数据做初始化渲染
  4. 后续用户操作,H5 发消息给 App,App 处理完通过 evalJS 把结果推回去

evalJS 的参数只能是字符串。

这是它的本质决定的——它把一段 JS 代码作为字符串塞进 webview 里执行。传对象进来会被 toString[object Object],脚本直接语法错误。所以要么拼字符串,要么用 JSON.stringify。上面代码里就是先 stringify 再拼进模板字符串。

H5 侧要提供一个显式的挂载点。

// H5 页面里
window.__native__ = {
  onScanResult(data) {
    if (data.error) {
      showToast('扫码已取消');
      return;
    }
    document.getElementById('scan-result').textContent = data.code;
    // 后续业务逻辑
  },

  onTokenRefreshed(newToken) {
    localStorage.setItem('token', newToken);
  }
};

命名用 __native__ 这种带双下划线的,是为了避免和业务代码冲突,一眼就能看出来这是给宿主调用的入口。

五、URL 参数注入:最稳但最不灵活的方式

其实通信还有一种最省心的方式:在打开 web-view 之前,把所有 H5 需要的初始数据拼进 URL。这种方式没有时机问题,没有大小问题(只要 URL 长度够),也没有方向限制。

function buildUrl(base, params) {
  const query = Object.keys(params)
    .filter(k => params[k] !== undefined && params[k] !== null)
    .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
    .join('&')
  return query ? `${base}?${query}` : base
}

// 用法
webviewUrl.value = buildUrl('https://act.example.com/lottery/', {
  token: uni.getStorageSync('token'),
  uid: uni.getStorageSync('uid'),
  channel: 'app-ios',
  version: uni.getSystemInfoSync().appVersion
})

注意这里的 URL 长度限制。经验值是各端加起来有 2KB 左右的安全区,超过这个长度 iOS 上可能会截断。所以适合放一些短小精悍的参数,比如 token、uid、版本号,不适合放大对象。

还有一个容易忽略的点:token 放在 URL 里会被 webview 的日志、CDN 的访问日志记录。如果安全要求高,可以传一个一次性的 ticket(短时效验证码),H5 拿到 ticket 后自己去 App 的接口换 token。多一次请求,但安全边界干净得多。

六、返回传值:从 H5 回到原生页面并刷新

活动页做完了,用户领完奖想回到订单列表页,并且希望列表能立刻显示刚领的奖品。这个链路涉及三件事:关闭 web-view、返回上一页、通知上一页刷新。

关闭 web-view 最简单的方式是在 H5 里调 uni.navigateBack

// H5 里
uni.navigateBack({
  delta: 1
});

但这只能回到上一页,没法把「刚才领了什么奖」传回去。所以更好的做法是:H5 先把结果消息发给 App,App 收到后处理完再关闭 web-view。

// H5 里
bridge.send({
  action: 'claimDone',
  payload: { prizeId: 'P10086', prizeName: '10元话费券' }
});

// App 里
function dispatch({ action, payload }) {
  if (action === 'claimDone') {
    // 先把消息通过 eventBus 发出去,让上一页监听
    uni.$emit('prize:claimed', payload)
    // 再关掉当前 web-view 页
    uni.navigateBack({ delta: 1 })
  }
}

上一页的写法是监听这个事件:

<script setup>
import { onLoad, onUnload } from '@dcloudio/uni-app'

onLoad(() => {
  // 注意:onLoad 里注册,onUnload 里一定要注销
  uni.$on('prize:claimed', handler)
})

function handler(payload) {
  // 刷新列表,或者把新奖品插入列表顶部
  refreshList()
  uni.showToast({ title: `已领取${payload.prizeName}`, icon: 'none' })
}

onUnload(() => {
  uni.$off('prize:claimed', handler)
})
</script>

这里有个细节特别容易被忽视:uni.$off 一定要带上 handler 引用。如果写成 uni.$off('prize:claimed'),会把这个事件上所有监听器全部清掉。听起来没那么糟,但如果是 A 页面注册、B 页面也注册,B 页面卸载的时候会把 A 页面的监听一起干掉,后面 A 页面就收不到消息了,排查这种问题真的很头疼。

还有一点:如果用户不是通过「领奖完成」返回的,而是自己按物理返回键返回的,claimDone 消息就不会发。这种情况下的返回就不应该触发列表刷新(因为奖品没领)。所以用事件回传的方式比用 onShow 里无条件刷新要精确得多。

七、多端差异,躲不过去的一张表

把这篇文章涉及到的差异整理一下,写代码的时候对照着看。

  • 消息事件名:App 端用 @message,小程序端用 @onPostMessage,两端都要挂
  • 消息结构:App 端 e.detail.data 是数组,小程序端也是数组,但元素内部字段结构不同,不要假设有 id 之类
  • 主动推 H5:App 端可用 evalJS,小程序端不可用
  • web-view 层级:小程序端永远是最高层,App 端可以被覆盖
  • 单页数量:小程序端一个页面只能有一个 web-view,App 端没这个限制
  • URL 长度:iOS 上超过 2KB 有截断风险,Android 相对宽松
  • postMessage 大小:小程序端大约 64KB 上限,超过直接丢,App 端更宽松但不建议超 100KB
  • 域名配置:小程序端需要在后台配置业务域名,App 端不需要(但用 https 更稳)
  • 返回上一页:两端都支持 uni.navigateBack,但 H5 里调用的前提是桥已就绪

八、踩坑清单

  • H5 里不要直接调 uni.postMessage,一定要等 UniAppJSBridgeReady,或者做兜底判断,否则消息会丢
  • URL 里的所有参数都要 encodeURIComponent,token 里带 +/= 的时候不编码必然出问题
  • evalJS 的参数必须字符串化,且必须在 H5 报告就绪之后调用
  • H5 侧给宿主用的入口统一挂到一个命名空间下,比如 window.__native__,别散落在全局
  • postMessage 传大对象前先评估大小,超过 64KB 改用传 ID 的方式
  • uni.$off 一定要带 handler,别图省事只写事件名
  • uni.$emit 是全局的,事件名加上业务前缀,比如 order:paidprize:claimed,减少冲突
  • 返回传值走消息而不是 onShow,避免用户按物理返回键时误触发刷新
  • iOS 的 webview 缓存策略比较激进,H5 更新之后用户可能还看到旧版,URL 上带个版本号参数可解
  • 调试 H5 时用 Safari 的「开发 – 模拟器」或者 Chrome 的 chrome://inspect,不要靠 console.log 猜,效率差十倍
  • App 端 webview 里长按图片可能会弹出系统菜单,不需要的话在 H5 里加 touch-callout: none 之类的处理(不写在行内样式里,写进 H5 自己的样式表)
  • web-view 页面不要用 position: fixed 做底部导航,部分 Android 机型上滚动时会跳,用 flex 布局更稳

九、收尾

做完这个项目之后我最大的感受是,web-view 这套东西的技术门槛不高,但踩坑密度很高。通信机制的每一环本身都不复杂,麻烦在于两端不一致——同一个 API 在 App 和小程序上可能名字不同、结构不同、时机不同。所以写这套代码的时候,思路不能是「先写 App 版再移植到小程序」,而应该一开始就按「两端都可能不一样」来设计,所有差异点都用条件编译包起来,集中到一处,方便以后维护。

另外一个小建议:H5 那边的 bridge.js 最好由 App 团队来写,早期就把接口定好,别让外包团队各自发挥。我们项目第一次上线时,外包那边自己写了一个 callNative 方法,参数命名和后端接口对不上,来回改了三轮才通。这种沟通成本,靠一个文档一个约定就能省掉,真的是值得。

uni-app 内嵌 web-view 双向通信实战:登录态注入、扫码回调与返回传值的完整链路
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 uniapp uni-app 内嵌 web-view 双向通信实战:登录态注入、扫码回调与返回传值的完整链路 https://www.taomawang.com/web/uniapp/2788.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务