尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

JSBridge原理与工程实践:打通WebView与Native的双向通信

JSBridge原理与工程实践:打通WebView与Native的双向通信 移动端混合开发做到一定年头几乎都会遇到同一个问题H5页面需要调摄像头、要原生Token、要唤起分享面板可JavaScript和Native代码一个活在WebView的沙箱里一个活在自己的运行时里两边语言不通、环境隔离怎么让它们像同一个App里的两个模块一样互相调来调去JSBridgeJavaScript Bridge就是干这个的。这篇文章会把JSBridge的底层原理、通信链路、设计取舍和实际工程里的坑一次讲透适合从没写过Bridge的初入行者也适合用第三方库但始终没弄明白内部机制的前端或客户端开发。看完你不仅能说清楚原理还能自己动手写一版能上线的Bridge。1. JSBridge的底层定位混合应用大楼里那条内部通道想理解JSBridge先得跳出代码层面想一件事WebView和Native代码运行在两个完全隔离的世界里。H5页面里的JavaScript跑在WebView的JavaScript引擎里它只能操作DOM、发网络请求、用少量浏览器API而摄像头、相册、震动、蓝牙、通讯录、支付SDK这些能力全在Android/iOS的Native层手里JavaScript碰都碰不到。反过来Native层可以控制WebView的加载、跳转、注入脚本但也没办法直接调用页面里的业务函数——哪怕能强行执行一段JavaScript也不知道页面里已经定义了什么方法更不知道业务想要什么数据。JSBridge的意义就是在这两个世界之间建立一条双向的、带协议的通道让Web能请求Native的能力让Native能主动给Web发消息。这条通道不只是一个简单的互相调函数它背后需要一整套约定消息用什么格式异步还是同步参数怎么序列化回调怎么回到发起方异常怎么传这些约定合在一起才是一个完整可用的JSBridge。1.1 先看一个最典型的场景H5要原生Token我拿实际业务举个例子。公司有个内嵌在App里的H5商城用户登录态由Native持有H5每次请求下单接口都需要带用户的Token。H5不能自己调登录接口因为登录逻辑在原生App里可能是扫码登录、可能是指纹。于是H5要做的事是// H5侧期望的调用方式 const token await NativeBridge.call(getToken, { forceRefresh: false })这一步调用的背后发生了什么如果WebView是Android多数团队会直接选JavascriptInterface注入方式如果是iOSWKWebView时代通常走WKScriptMessageHandler。但无论哪条路都可以抽象成同一条链路Web把我要调getToken这个意图告诉NativeNative执行逻辑后把结果还给WebWeb侧的Promise被resolve。这个告诉和还给的过程就是JSBridge的全部本体。下面第三节我会把链路拆到代码级别。1.2 别把JSBridge当成一个具体文件它是整套协议很多初次接触的同学会问JSBridge是不是就是一个bridge.js文件答案是那只是Web侧的SDK。一个完整的JSBridge至少包含三部分——Web侧SDK、Native侧Bridge管理器、两端约定的消息协议。协议用JSON、字符串还是数组取决于实现但必须能表达四个核心信息要调的方法名、传给Native的参数、本次调用的唯一ID、回调的走向。所以复盘的时候别只看H5那几百行代码。真正的复杂度在Native侧如何正确解析、分发、回调以及两端在异步情况下如何对得上每一笔调用。2. Web调用Native的通道剖析注入、拦截、还是注入加拦截Web侧发起调用从实现机制上基本只有三条路Native向WebView注入全局JavaScript对象、拦截WebView的URL请求、拦截JavaScript的对话框函数。下面逐个拆。2.1 全局注入APIAndroid的addJavascriptInterface与iOS的拦截器Android上最直接的方式是Native把某个Java对象暴露给Web// Android侧 webView.addJavascriptInterface(new NativeBridge(), NativeBridge); class NativeBridge { JavascriptInterface public String getToken(boolean forceRefresh) { // 返回JSON字符串 } }Web侧就多了一个全局的NativeBridge对象可以直接同步调用const json window.NativeBridge.getToken(false)iOS的WKWebView不让你直接把OC/Swift对象暴露给JS但提供了另一套等价物——WKScriptMessageHandler配合postMessage实现类似效果。Web侧写法window.webkit.messageHandlers.NativeBridge.postMessage({ method: getToken, params: { forceRefresh: false } })Native侧注册// iOS侧Swift简化示例 let config WKWebViewConfiguration() config.userContentController.add(self, name: NativeBridge) func userContentController( _ userContentController: WKUserContentController, didReceive message: WKScriptMessage ) { guard message.name NativeBridge else { return } let body message.body as? [String: Any] // body[method] 分发处理 }这条路的核心逻辑是Native侧注册一个入口Web侧直接调用这个入口参数天然走序列化通道不需要拼URL、不需要做字符串转义是最舒服、最推荐的Web→Native通道。2.2 URL拦截早期方案与兼容性之王这套方案比注入出现得更早核心思路是Web侧发起一个特殊协议的自定义URL跳转Native侧拦截WebView的请求事件解析出方法名和参数。// Web侧 location.href myapp://getToken?forceRefreshfalseAndroid用shouldOverrideUrlLoading拦截iOS用decidePolicyForNavigationAction拦截。Web只是发起一个跳转Native在拦截时检查scheme等于myapp://才处理否则放行。这个方案最大的问题是用location.href发请求连续快速调用时很容易丢消息——WebView对同一时刻的导航请求会选择忽略而且URL长度有限、特殊字符需要编码、中文参数容易出错。所以很多成熟框架包括早期Cordova的解决方案是在页面上偷偷创建一个隐藏的iframe用iframe.src去触发URL请求比location.href稳定得多。做技术选型时我的建议很明确有全局注入条件就优先注入URL拦截只作为兼容降级方案除非你维护的WebView内核太老、不支持注入。2.3 对话框拦截prompt桥现在基本不进新项目了这套机制利用onJsPrompt回调Web侧调用prompt()Native侧在回调里拿到字符串解析后执行逻辑再返回结果prompt()的返回值能同步带回给Web。它曾经是Android上少数能同步返回结果的方案兼容性很好但缺点同样明显——Web页面里如果真有业务代码用了prompt会互相干扰而且在多数现代WebView里这属于非正经用法维护起来容易被后面接手的同事骂。除非你要兼容的系统WebView版本太低、没有更好的注入途径否则不建议新项目用。2.4 主推组合注入为主、拦截兜底在实际工程里我见过比较稳的组合方案是Android高版本走addJavascriptInterface低版本API 17以下自动降级到prompt桥iOS从WKWebView开始统一走scriptMessageHandlerUIWebView的场景保留iframe拦截。前端SDK封装同一套APINative侧做统一的Channel管理和分发Web侧代码不感知底层通道是什么。3. Native反向调用Web另一扇门的开启方式Web调用Native讲完了但Bridge是双向的。Native也经常需要主动通知Web比如登录状态变化、支付结果回传、定位权限变化这个时候不能等Web来拉Native得主动推给页面。3.1 核心机制在WebView上执行JavaScriptNative调用Web本质就一句话让WebView去执行一段JavaScript代码。Android用evaluateJavascriptiOS用evaluateJavaScript。// Android侧 webView.evaluateJavascript(window.__handleNativeEvent(login_status_changed, success), null)// iOS侧 webView.evaluateJavaScript( window.__handleNativeEvent(login_status_changed, success), completionHandler: nil )表面看只是执行一段JS但这里有一个很容易忽略的关键点执行环境是WebView当前页面的全局作用域。如果页面还没加载完成或者跳转到了一个新页面你的代码会被执行到错误的环境里甚至因为找不到函数而报错。这也就引出了Native调用Web时最需要设计的两个问题调用时机和回调函数传递。3.2 回调函数是怎么传过桥的先做个灵魂拷问Web调用Native的getTokenNative执行完拿到Token后它是怎么知道该把Token还给谁的答案是依靠调用时携带的一个唯一标识。请求侧每次调用生成一个全局唯一的ID// Web侧发送前的封装 const callId ${Date.now()}_${Math.random().toString(36).slice(2)}把callId、方法名、参数一起发给Native。Native处理完后返回一个包含callId和结果的消息。Web侧SDK在初始化时维护一个Mapwindow.__bridgeCallbacks {} // id - { resolve, reject } // 收到Native回包 function handleNativeResponse(payload) { const callback window.__bridgeCallbacks[payload.callId] if (callback) { payload.success ? callback.resolve(payload.data) : callback.reject(payload.error) delete window.__bridgeCallbacks[payload.callId] } }这就是JSBridge处理异步的核心套路和HTTP请求里用requestId匹配响应是一个道理。Native调用Web时也要走同样的协议只不过角色互换Native发起调用时生成callIdWeb执行完把结果交给一个公共函数Native侧再根据ID找到对应的回调闭包去执行。3.3 页面加载时序Native调Web最容易翻车的地方上面提到执行环境问题这里展开具体场景。Native在WebView刚初始化时立刻调用evaluateJavascript此时页面还没加载完全局函数window.__handleNativeEvent不存在调用直接失败。业界通用做法是维护一条消息队列Native侧发消息时先检查WebView是否ready不ready就放进队列Web侧在页面加载完成后通过Bridge主动通知Native我准备好了Native再把队列里的消息一次性flush出去。你别小看这一步很多线上问题最后排查出来都是Native太早调了Web而Web认为已经准备好了其实没准备好。另外页面里如果用了history.pushState这类SPA路由WebView页面本身并没有重新加载但JS全局环境还是同一个一般不影响Bridge注册可如果做了整页刷新所有JS变量重置Native侧的Web是否ready标记就得相应重置。一旦引入了动态页面状态就要做好onLoad全量重新注册的预案。4. 从零搭一个可用的JSBridge关键设计与核心实现原理说完了我来带你把一个生产级的Bridge核心代码走一遍。我们不搞花哨直接给出能落地的设计。4.1 消息协议设计用JSON还是数组协议是整个Bridge的地基。我见过最省事的做法是定义一个统一的消息模型{ bridge: app, method: getToken, params: { forceRefresh: false }, callId: 1710000000000_abc123, type: request }Native回包{ bridge: app, method: getToken, callId: 1710000000000_abc123, type: response, success: true, data: { token: xxx, expiresIn: 7200 } }type字段用来区分是请求还是响应。bridge字段用来做命名空间隔离万一以后同一个WebView要接两套Bridge比如内部客服SDK和自己的业务Bridge不至于方法名冲突。callId是前面讲的请求唯一标识。4.2 Web侧SDK的核心封装Web侧SDK对外暴露两个能力call调Native和registerHandler注册给Native调用的函数。核心骨架如下// bridge.js class Bridge { constructor() { this.callbackMap new Map() this.nativeHandlerMap new Map() this.messageQueue [] this.isReady false this.callId 0 this._setupNativeMessageListener() } // Web - Native 调用 call(method, params {}, timeout 10000) { return new Promise((resolve, reject) { const callId cb_${this.callId}_${Date.now()} this.callbackMap.set(callId, { resolve, reject }) // 超时兜底防止Native无响应导致Promise卡死 setTimeout(() { if (this.callbackMap.has(callId)) { this.callbackMap.delete(callId) reject(new Error(bridge call ${method} timeout)) } }, timeout) this._postMessage({ type: request, method: method, params: params || {}, callId: callId }) }) } // 注册给Native调用的函数 registerHandler(name, handler) { this.nativeHandlerMap.set(name, handler) } // Native层主动调Web时走的入口 _handleRequest(payload) { const handler this.nativeHandlerMap.get(payload.method) if (!handler) { this._postMessage({ type: response, callId: payload.callId, success: false, error: { code: NO_HANDLER, message: handler ${payload.method} not found } }) return } Promise.resolve(handler(payload.params)).then( data { this._postMessage({ type: response, callId: payload.callId, success: true, data: data }) }, err { this._postMessage({ type: response, callId: payload.callId, success: false, error: { code: HANDLER_ERROR, message: String(err err.message || err) } }) } ) } // 处理Native回包 _handleResponse(payload) { const callback this.callbackMap.get(payload.callId) if (!callback) return this.callbackMap.delete(payload.callId) payload.success ? callback.resolve(payload.data) : callback.reject(payload.error) } }这套SDK你看着简单但已经包含了三个关键设计超时兜底、Promise化、统一协议。超时兜底特别重要——Native端出bug不回调时Web侧的Promise永远挂起用户操作会卡死在loading上有了超时至少能走失败分支。4.3 Native侧分发器的实现要点Native侧要写一个对称的分发器Android示例// NativeBridgeDispatcher.java简化 public class NativeBridgeDispatcher { private final MapString, NativeMethodHandler handlerMap new HashMap(); private final MapString, Callback pendingCalls new HashMap(); interface NativeMethodHandler { void handle(JSONObject params, Callback callback); } interface Callback { void onSuccess(JSONObject data); void onError(int code, String message); } void registerMethod(String name, NativeMethodHandler handler) { handlerMap.put(name, handler); } // 从Web侧收到消息typerequest void onRequest(String json) { JSONObject msg new JSONObject(json); String callId msg.getString(callId); String method msg.getString(method); NativeMethodHandler handler handlerMap.get(method); if (handler null) { sendResponse(callId, false, buildError(NO_HANDLER), null); return; } handler.handle(msg.optJSONObject(params), new Callback() { Override public void onSuccess(JSONObject data) { sendResponse(callId, true, null, data); } Override public void onError(int code, String message) { sendResponse(callId, false, buildError(code, message), null); } }); } private void sendResponse(String callId, boolean success, JSONObject error, JSONObject data) { JSONObject res new JSONObject(); res.put(type, response); res.put(callId, callId); res.put(success, success); if (error ! null) res.put(error, error); if (data ! null) res.put(data, data); evaluateJs(window.__bridge.handleResponse( res.toString() )); } }Android里发起调Web侧的函数时也要生成自己的callId存在pendingCalls里等Web侧回包后通过handleResponse的callId找到对应Callback来调用。4.4 通道适配层注入接口下的消息透传前面协议层是跟平台无关的真正需要平台差异化的只是怎么把消息从Web透传到Native和怎么把Native的JS执行串到消息入口。所以很多成熟框架会把通道再包装成一小层Android注入通道addJavascriptInterface暴露一个postMessage方法Web侧调用window.NativeBridge.postMessage(jsonString)把消息送进去iOS WKWebView通道window.webkit.messageHandlers.Bridge.postMessage(json)送进去降级通道iframe URL带?msgencodeURIComponent(json)发出去这一层就十几行代码但一定要和协议层、分发层解耦这样换内核、换平台时才不用重写Bridge逻辑。5. 实际线上踩过的坑调试、时序、兼容一个都没少这段全是真金白银换来的经验。Bridge本身代码不难难的是它在WebView这种复杂环境里跑得不炸。5.1 参数序列化字符串里的特殊字符会咬人Web侧传给Native的如果是普通对象JSON.stringify后没问题。可如果你的参数里带HTML标签、带换行符、带单双引号走URL拦截通道时一次性把你心态打崩。即使走注入通道iOS的WKScriptMessage在把JS对象序列化到Native侧时对超大字符串、特殊字符的处理也有各种边界情况。建议所有跨Bridge的通信统一用JSON字符串包裹并且在校验层强制参数最大长度比如单条消息不超过100KB。一旦超过阈值拒绝发送并上报错误不要等Native侧解析出undefined再排查。5.2 Bridge ready底层最常见的事故源头很多线上bug最后定位到同一个根因H5页面一加载就立刻调用Native方法但Native侧的Bridge管理器还没初始化完毕或者Native侧主动调用Web时Web的SDK还没执行到注册监听器那行代码。我的做法是两端都加上就绪状态机Native侧bridgeReady标记在WebView初始化完成 Bridge通道注入成功后才置trueWeb侧SDK启动时如果检测到Native通道存在就把缓存的待发消息逐个发出否则先入队。页面加载完后主动调一次call(bridgeReady)让Native侧知道Web侧SDK已就绪这里有个细节检测Native通道是否存在要写一个容错函数。比如Android里window.NativeBridge这个对象创建之后页面里还要确认它不是undefined才能调用避免加白名单加错导致SDK代码还没注入。5.3 调试混合调用别再只会alert了很多同学调试Bridge时习惯在H5里写alert看数据在WebView里这体验极差。两个更靠谱的手段Native侧接一个debug开关把Bridge收到的每条原始消息和发出的每条消息都打印到日志文件或远程日志平台Web侧SDK支持verbose模式bridge.config({ debug: true })后所有过桥的消息用console.log输出配合Android的Chrome Inspect或iOS的Safari Develop模式直接看WebView控制台配合这两点能快速定位是消息发出去没收到、收到了没回包还是回包了前端没匹配上callId。这三个问题占了Bridge故障的八成。5.4 内存泄漏callbackMap不清理是隐形炸弹Web侧SDK里的callbackMap、Native侧pendingCalls如果不做超时清理页面是SPA时还好说一旦页面刷新重建Map里堆着大量永远等不到回包的Promise引用内存会一点点涨上去。这也是我反复强调超时兜底的原因——超时不只是用户体验兜底还是内存安全兜底。每次Promise reject之后一定要delete掉对应的callId不能只超时不清理。6. 安全与边界Bridge一旦打开就该设好安检Bridge等于是在WebView的沙箱上开了一扇门门开多大、谁有钥匙直接决定App的安全水位。6.1 注入面控制别把整个Native对象都暴露出去Android的addJavascriptInterface有个历史教训早期不限制方法级别Web页面里可以反射调用Java类的公共方法酿成过著名的WebView漏洞。现在的规范是只暴露一个postMessage方法不暴露任何业务Native对象所有能力走统一分发。iOS虽然用的是scriptMessageHandler但也要注意addScriptMessageHandler的name要有前缀隔离防止和页面里业务代码重名。6.2 白名单与鉴权不是所有页面都能调所有方法Bridge里的方法按业务分级某些方法只能由特定域名下的页面调用。Native侧做分发前先检查WebView当前页面的URL的scheme、host是否在白名单里。如果WebView允许加载任意网页而你的Bridge又暴露了读取通讯录的Native能力那等于把隐私数据全送给外部网页了。注意白名单校验必须在Native侧做不能只靠Web侧SDK限制。因为Web侧代码可以被任何人改你以为页面上只调了白名单方法别人完全可以打开调试器改掉。6.3 数据校验把Native接口当成后端API对待每次Bridge调用Native分发器要像处理HTTP请求一样对待参数类型对不对缺失必填字段没有参数是否超过长度返回值是不是合法JSON凡是不符合协议的消息统一走错误回包不要静默丢弃。静默丢弃会导致Web侧Promise永远挂起又回到那个超时兜底的坑。6.4 敏感能力二次确认涉及支付、删除数据、访问敏感系统能力的方法建议在Native侧做二次确认对话框或者在业务层做单独的风控SDK。Bridge是能力通道不是业务守卫真正的权限判断要下沉到业务模块里去不要全堆在Bridge分发器这一层。7. 站在工程视角重新审视JSBridge的选型做了这么多App里的Bridge之后我的体感是凡是把Bridge当成一个文件/一个库接入的项目后面多多少少都出过事凡是把Bridge当成一个协议、两端实现、一套监控的项目都活得比较久。具体到选型我简单给个参考矩阵对比项自研Bridge第三方框架如WebViewJavascriptBridge/DSBridge协议可控性高完全按业务定制中受框架设计约束成本前期开发3-5天维护靠团队接入快但遇到特殊WebView要自己改源码调试手段可深度定制的日志依赖框架自带能力安全性可严格白名单审计看框架的权限模型长期维护需要专人熟悉社区活则稳社区凉则自己扛如果你问我的意见小团队、业务急、功能简单用成熟框架大团队、桥接面广、有安全合规要求值得自研因为协议的演进速度、问题定位效率、和业务深度耦合的定制能力都会成为核心竞争力。但不管选哪条路原理永远是一样的一条有协议的双向通道一套能对得上账的异步消息一段经得起时序和异常考验的实现。把这三点刻在脑子里再去看任何JSBridge的源码或文档都会觉得清清楚楚。
返回列表