手上有个保险类 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.request、uni.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 消息。
流程就变成了:
- H5 加载完成,发
ready - App 收到
ready,把需要的数据通过evalJS推过去 - H5 拿到数据做初始化渲染
- 后续用户操作,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:paid、prize: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 方法,参数命名和后端接口对不上,来回改了三轮才通。这种沟通成本,靠一个文档一个约定就能省掉,真的是值得。

