最近 openclaw 盛行,关注点都在这上面了,各个技术社区满屏都是 Prompt、Agent,看久了已经变得"审美疲劳"了。可能是浏览前端文章的比较少,加上最近研究 python 就没怎么发现到一些适合写的技术点,刚好项目有一些以前未熟悉(各类宿主环境 - 跨端通信)就学了一下,找了一些资料看了看,现在整理一下。
网上虽然有很多 JSBridge 的文章,但往往只针对单端。在实际的一些项目里,同一套 H5 往往要"一套代码,处处运行"。基于 Vue3 移动端 H5,梳理环境识别、统一桥接层、iOS WKWebView / Android Uni WebView / 微信小程序 web-view / 普通 H5 的通信方式,涉及的有 window.webkit、uni.postMessage、jweixin、window.location,不熟的可以上网看看资料。
一、为什么要做"统一桥接层"?
"Write once, run anywhere" 对于纯展示型 H5 是成立的。但只要涉及到业务交互,比如:调起原生登录、保存图片到相册、修改系统状态栏颜色、分享到朋友圈,浏览器标准的 Web API 根本无能为力。
此时,H5 的宿主环境通常有这几种:
- 自有 iOS App(底层是 WKWebView)
- 自有 Android / 鸿蒙 App(可能是原生 WebView,也可能是 UniApp 壳子嵌套)
- 微信小程序 (底层是
<web-view>组件) - 微信内置浏览器(公众号 H5,依赖 JSSDK)
- 纯外部浏览器(Safari、Chrome 等)
如果让前端业务组件直接去写 if (isIOS) {...} else if (isWechat) {...},代码不出三个月就会变成一座屎山。因此,我们需要一层 Adapter(适配器) ,业务侧只管调用 bridge.toLogin(),具体怎么发消息,交给桥接层内部去分发。
二、环境探测:一切通信的前提
要精准分发,首先要知道自己在哪。主流的做法是依托 window.navigator.userAgent(配合端上约定的特殊标识)。
typescript
export const getEnvironment = () => {
const ua = navigator.userAgent.toLowerCase();
if (ua.includes('micromessenger')) {
if (ua.includes('miniprogram') || window.__wxjs_environment === 'miniprogram') {
return 'miniprogram'; // 微信小程序
}
return 'wechat'; // 微信内置浏览器(公众号)
}
// 假设 Android 端是用 UniApp 打包的,通常会有 Html5Plus 标识
if (ua.includes('html5plus') || ua.includes('uni-app')) return 'android_uni';
// 假设自有 iOS 原生开发,约定了特殊的 UA 后缀,如 "MyApp/iOS"
if (ua.includes('iphone') && ua.includes('myapp')) return 'ios_native';
// 假设鸿蒙端约定了 OpenHarmony 标识
if (ua.includes('openharmony')) return 'harmony';
return 'h5'; // 兜底:纯普通浏览器
};
踩坑提示 :UA 是可以被伪造的。环境判断仅作为"前端体验分支"的依据。涉及发券、支付等核心安全逻辑,必须由服务端通过 Token/Cookie 进行最终鉴权。
三、各端通信底层原理与官方规范
知其然也知其所以然,先来扒一扒各个环境底层的通信机制。
1. iOS (WKWebView)
参考苹果官方文档,现代 iOS 均使用 WKWebView。前端向原生发消息的标准写法是:
javascript
window.webkit.messageHandlers.<约定好的方法名>.postMessage(<参数>)
- 特性 :只能前端单向调用原生。原生如果想回传结果,只能通过
evaluateJavaScript直接执行一段全局挂载的 JS 函数(比如window.onLoginSuccess(token))。 - 注意 :
postMessage的参数类型有限制,强烈建议前端统一JSON.stringify传字符串,iOS 端再解析,避免特殊字符导致闪退或静默失败。
2. Android / Harmony (以 UniApp WebView 为例)
如果客户端是用 UniApp 套壳,或者引入了类似的 JSBridge SDK,根据 DCloud 官方文档,必须先动态引入 uni.webview.js(建议按需动态加载,单独封装)。
javascript
// 如果考虑极端情况原生注入极慢情况下,必须在 UniAppJSBridgeReady 后才进行调用,正常可以直接加载源,有兴趣可以看源码最后一行触发document.dispatchEvent(new CustomEvent('UniAppJSBridgeReady'));
document.addEventListener('UniAppJSBridgeReady', function() {
uni.postMessage({
data: {
action: 'toLogin', // 动作名
payload: { ... } // 业务参数
}
});
});
- 特性 :相比于原生 Android 的
addJavascriptInterface(挂载全局对象),postMessage模式更符合现代前端通信规范,数据结构更统一。
3. 微信小程序 (web-view)
参考 微信官方文档,H5 端需要引入 JSSDK(jweixin),使用 wx.miniProgram 命名空间。
javascript
wx.miniProgram.postMessage({ data: { foo: 'bar' } })
wx.miniProgram.navigateTo({ url: '/pages/login/login' })
- 巨大天坑 :小程序的
postMessage绝对不是实时触发的! 文档明确指出,只有在 小程序后退、组件销毁、分享 时,宿主才能收到消息。所以它绝不能用来做同步的 RPC 调用(比如实时获取地理位置)。 - 替代方案 :如果是跳页面,直接用
wx.miniProgram.navigateTo;如果是传参,直接拼在 URL 后面让小程序去解析。
4. 微信内嵌 H5 (公众号 JSSDK)
这块大家应该最熟,不熟自己再去网上找找资料,前端调后端接口拿 signature,执行 wx.config。
主要用于:自定义分享卡片、扫一扫、微信支付等等。
四、封装 useBridge 进行统一管理
了解了各端差异,那么就可以设计一个门面(Facade)。屏蔽掉这些恶心的判断逻辑。
typescript
// src/hooks/useBridge.ts
import { getEnvironment } from '@/utils/env';
export function useBridge() {
const env = getEnvironment();
// 1. 登录能力封装
const toLogin = () => {
switch (env) {
case 'ios_native':
// 调用 iOS 原生登录页
window.webkit?.messageHandlers?.iOSLoginIn?.postMessage('');
break;
case 'android_uni':
// 通知 UniApp 宿主弹登录
uni.postMessage({ data: { action: 'toLogin' } });
break;
case 'miniprogram':
// 跳转到小程序自己的登录页面,并把当前 H5 链接带过去,方便回跳
const currentUrl = encodeURIComponent(window.location.href);
wx.miniProgram.redirectTo({ url: `/pages/login/index?webview=${currentUrl}` });
break;
case 'h5':
default:
// 纯 H5 环境,跳转到统一的 M 站登录页
window.location.href = `https://m.yoursite.com/login?redirect=${encodeURIComponent(location.href)}`;
break;
}
};
// 2. 跳转原生页面封装
// 业务侧不需要关心各端的路由差异,全传进来,内部按环境取用
const openNativePage = ({ ios_url, android_url, mp_url }) => {
if (env === 'ios_native' && ios_url) {
window.webkit?.messageHandlers?.iOSOpenPage?.postMessage(ios_url);
} else if (env === 'android_uni' && android_url) {
uni.postMessage({ data: { action: 'openPage', url: android_url } });
} else if (env === 'miniprogram' && mp_url) {
wx.miniProgram.navigateTo({ url: mp_url });
} else {
console.warn('当前环境不支持或未提供对应 URL');
}
};
return { toLogin, openNativePage /*, share, openCamera, etc. */ };
}
在业务组件里
vue
<script setup lang="ts">
import { useBridge } from '@/hooks/useBridge'
const bridge = useBridge()
const handleBuy = () => {
if (!isLogined) {
bridge.toLogin()
return
}
// 打开不同端的支付聚合页
bridge.openNativePage({
ios_url: 'PayViewController?source=h5',
android_url: '/pages/pay/index?source=h5',
mp_url: '/packagePay/pages/checkout/index'
})
}
</script>
五、打通业务闭环:以 Axios 401 拦截器为例
Bridge 写好了,怎么融入现有的业务流?最经典的场景就是 Token 失效后的无缝重登录。
在 src/utils/request.ts 中:
typescript
import axios from 'axios';
import { useBridge } from '@/hooks/useBridge';
import { useUserStore } from '@/store/user';
const instance = axios.create({ /* ... */ });
const bridge = useBridge();
instance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// 1. 清理本地前端状态
const userStore = useUserStore();
userStore.clearToken();
// 2. 召唤神龙:触发跨端登录
bridge.toLogin();
// 3. 挂起当前请求或抛出错误
return Promise.reject(new Error('未登录或登录已失效'));
}
return Promise.reject(error);
}
);
通过这种设计,前端业务请求完全不需要关心自己在哪。401 一触发,如果在 App 里,App 会自动弹出原生登录框(体验最佳);如果在微信里,就会跳转到小程序的授权页。
六、血与泪的实践建议(避坑指南)
做跨端 H5 很容易扯皮,因为出了问题往往不知道是前端没发出去,还是客户端没拦截到。分享几个经验:
- 协议一定要文档化/枚举化
不要在代码里到处写魔法字符串(Magic Strings)。把动作定义成 Enum,比如BridgeAction.TO_LOGIN,前后端、客户端三端对齐一份在线文档。 - 永远要写兜底逻辑(Fallback)
哪怕你的 H5 99% 的流量都在 App 内,也要考虑被分享到浏览器外打开的情况。如果env === 'h5',对于必须要 App 才能用的功能,请老老实实给个 Toast 提示:"请在 App 内使用此功能",或者做一个拉起 App 的引导(DeepLink / Scheme)。 - 巧用请求头传递环境标识
在 Axios 请求拦截器里,把getSource()塞到 Header(比如x-client-source)里。后端可以通过这个字段做接口隔离、埋点统计,甚至专门给某些坑爹环境做特殊的下发逻辑。 - 关于调试
结语
技术圈风向总是变来变去,但无论大模型多火,只要用户还需要在手机上点点按按,H5 跨端容器技术就是一门避不开的基本功。
如果有关于某个端更具体的通信问题,或者以上有不足的地方,欢迎在评论区一起交流~