【前端基础】Web与Native交互之The JSBridge FAQ

4,306 阅读9分钟

今天我们来简单聊一下JSBridge

为什么要聊JSBridge?

不为什么

好吧,JSBridge虽然也算比较古老了,但关于JSBridge的原理也是一个目前作为一名前端开发人员需要了解掌握的知识。

现如今,在做移动端H5开发时,少不了与Native之间进行交互,这里的Native,包括了传统意义上的App和现如今各种各样的坑王小程序们。 通常,各大公司都会封装一套自己的js sdk,用于提供给Web开发人员,来实现与Native之间的交互,他们可能有自己的名字,但他们统称为JSBridge

网上有很多介绍关于JSBridge原理的文章,充斥着大量OC 或Java代码,对于没有做过移动端Native开发但想了解JSBridge原理的同学来说都不是很友好。 所以,这篇文章主要就来给没有Native开发经验的同学们介绍一下H5页面在WebView中是如何通过JSBridge与Native进行交互的。同时,为了避免见到不必要的Native代码,这里不过多介绍通过JavaScriptCore API来实现交互的方式了,只介绍最初经典的方案。

什么是JSBridge?

简单的来说,JSBridge是一种H5页面与Native之间异步双向的通信的方式,它与我们日常接触到的最常见的HTTP这种通信方式,本质上没什么区别。

JSBridge存在的意义是啥?

为了生活变的更美好

为了体验更好

为了让用户分不清他用的到底是Web App还是Native App

为了效率

搞开发的嘛,肯定都是为了提高工作效率,同时不降低太多用户体验的情况下,复用,复用这个复用那个,统一这个统一那个的。一款App产品要做好几个端,重复的页面扔给H5算了!啥!需要一些奇特的功能?做个bridge接口吧!

JSBridge的通信过程是什么样的?

image

简单来说,这个通信过程与在餐厅吃饭时厨师上菜的流程类似,厨师一般不会亲自为你上菜(土豪私厨请忽略),厨师们每做好一道菜后就会按一下取餐铃,服务员听到铃声后就会过来取餐并为客人上菜。

通常情况下,按一下铃就会有服务员过来把菜取走,有些时候,厨师们可能同时做好了N道菜,按了N次铃,这时,服务员也会很敬业的将这些菜全部打包取走并处理。

那么它是如何实现的?

接下来该进入正题了,我们来看一下JSBridge的基本原理

  • 先从web 向 native发起通信开始:

前面我们了解到JSBridge的本质就是一种通信方式,所以,这里就比较容易理解了,H5调用Native的本质就是请求拦截

当H5的世界想与外面交流时,他只需要(也只能)发送一个请求,比如发送一个简单的GET请求即可。

我们会想到三种发请求的方式:

  1. 使用带有src属性标签发送请求,如iframe...
    const iframe = document.createElement('iframe')
    iframe.src = "xxx"

这种方式也是各大Hybrid框架常用的方式,重复大量发送也不需要担心消息的丢失问题

  1. 使用location.href发送请求
    location.href = "xxx"

这种方式比较适用于一些一次性调用的场景,例如H5中某个操作需要跳转至App的某一个页面,通过这种方式重复发送大量请求会造成请求消息的丢失,只接受最后一次。

  1. 使用Ajax的方式来发送请求
    const url = 'xxx'
    fetch(url, { ... })
        .then()
        .catch()

这种方式写起来比较麻烦,但性能上略好于前两种

著名的Cordova.js使用的方式:

execIframe = document.createElement('iframe')
execIframe.style.display = 'none'
execIframe.src = 'gap://ready'
document.body.appendChild(execIframe)

iOS中的一个叫WebViewJavascriptBridge的库同样使用的是类似的方式:

const messagingIframe = document.createElement('iframe')
messagingIframe.src = 'https://__wvjb_queue_message__'
body.appendChild(messagingIframe)

注意到Cordova的src与下面的WebViewJavascriptBridge中的src不同的地方在于,Cordova使用了自定义URL Scheme的方式,嗯,这种方式用来唤起本地安装的App更常见些。

  • 需要带点参数

多数情况下,我们更希望给Native带一些参数过去,所以接下来,我们来看参数传递的问题

可能你会想到,在发送请求的时候直接把参数放在请求地址后面,通过query的形式拼接起来就可以了,比如像这样:

execIframe.src = 'gap://ready?p1=v1&p2=v2&p3=v3…'

但这种方式存在着一些问题

  • 首先,它的长度是有限的,虽然限制很长,但这终归会是一个隐患

  • 其次,如果是自己公司的App临时做一个jsbridge的接口,确实可以这样写,但是如果作为一个通用的工具来封装,这样去实现的话就与业务耦合的太紧密了。

在典型的JSBridge实现方案中,关于参数的处理是这样实现的:

  • 首先,任何时候,H5中JS需要调用Native时,发送请求的url是固定不变的,比如gap://ready

  • 其次,在window上定义一个全局的数组变量,名叫messageQueue,初始化时为空,当H5需要给Native发送消息时,先创建一个对象,并把所有相关的参数放在这个对象中,然后将这个对象插入messageQueue数组队尾,用代码来解释就是这样:

const messageQueue = []
window.messageQueue = messageQueue

messageQueue.push(JSON.stringify({
	message: 'xxx',
	params: 'xxx'
}))
// 发一个请求,按一下铃,戳一下Native~~
execIframe.src = 'gap://ready'
  • 当native收到gap://ready的请求后,就知道H5有新消息,就会执行一段神奇的代码,进入到WebView中,并将定义在window上的全局变量messageQueue数组中全部数据打包取走,并将messageQueue清空,取走后逐条解析执行。我们用eval函数来充当这段神奇的代码来解释这里的逻辑:
// 当Native拦截到'gap://ready'请求后执行的magic code
const messageQueue = eval('window.messageQueue')
const messages = JSON.parse(messageQueue)
for (const message in messages) {
     doSomeThingWithMessage(message)
     …
}
eval('window.messageQueue = []')

这样,菜就被服务员端走了, 消息就被Native取走了

  • 接下来看看Native如何将处理结果告诉H5

如果餐厅的一个负责任的厨师需要让他的客户快要吃完一道前菜时告诉他,以便他去及时准备主菜,只需要在上菜时放上自己的名片,让客户快吃完的时候把厨师的名片交给服务员就可以了。

同样,如果H5需要Native执行完某一条指令时通知到H5,那么H5只需要在window上准备一个回调函数,在里面做该做的事,并将这个回调函数的名字在上一步创建消息对象时,放进这个对象中:

messageQueue.push(JSON.stringify({
	message: 'xxx',
	params: 'xxx',
	callBackName: 'xxx',
}))

这样,在Native执行完你需要的指令后会再次执行那段神奇的代码进入WebView的世界,执行定义在window上名为callbackName的方法,并把native执行的结果传给这个方法。就像这样:

const messageQueue = eval('window.messageQueue')
const messages = JSON.parse(messageQueue)
for (const message in messages) {
     const result = doSomeThingWithMessage(message)
     eval(`window[${message.callbackName}](${result})`)
     …
}
eval('window.messageQueue = []')

同时,这也就揭露了Native是如何给H5发送消息的,直接执行window上定义好的一个方法即可。

当然,为了代码更规范,保证H5不胡乱的创建callBackName,Native并不是直接执行window上的callbackName方法,而是会调用一个大概叫handleMessageFromNative的方法,这个方法是H5这边提前准备并定义在window上的方法,在这个方法中对消息的处理进行了收口,在里面调用window上的callbackName方法,执行完成后,将callbackName方法从window上删除掉 ,整个流程的代码大概是这样的:

// H5
function handleMessageFromNative (message) {
	if (typeof message.callbackName === 'function') {
		window[callbackName](message.result)
		delete window[callbackName]
	}
}

window.handleMessageFromNative = handleMessageFromNative

// Native
const messageQueue = eval('window.messageQueue')
const messages = JSON.parse(messageQueue)

for (const message in messages) {
     const result = doSomeThingWithMessage(message)
     
     const messageFromNative = JSON.stringify({
        result,
        callbackName: message.callbackName
     })
     
     eval(`window.handleMessageFromNative(${messageFromNative})`)
     …
}
eval('window.messageQueue = []')

这里关于callbackName的生成也有一点规则,感兴趣可以去撸一下相关源码。大概和jsonp的规则类似。

接下来,为了方便他人使用,将以上的流程整理封装完善一下,H5和Native同时暴露两个接口,便成了如下的样子:

// H5与Native同时增加如下两个接口供对方使用:
// ≈ function addEventListener(eventName, callback)
function registerHandler (handlerName, block) {
    window.handlers[handlerName] = block
    …
}

// Web或Native调用对方接口的方式
// ≈ dispatchEvent(eventName, data, callback)
function callHandler (handlerName, message, callback) {
    window.handlers[handlerName](message)
    …
}

这样就可以很方便的使用了,例如要实现一个扫描二维码的功能:

// Native
// 注册了一个扫描二维码的方法
registerHanlder('scanQRCode', () => {
    // ...
    Camera.open().scanQRCode()
    // ...
})
// H5
// 调用扫描二维码的方法
callHanlder('scanQRCode', { type: 'qrcode' }, result => {
    console.log('扫码结果:', result)
})

喜欢的话可以用Promise封装一下:

// 为H5封装好的bridge-sdk.js,在H5中使用
/**
 * 扫描二维码并返回结果
 * ...
 * @memberOf Camera
 * @async
 * @returns {Promise} 可以在then中接受扫码结果`result`,参数为 { code: 'xxxxxx' }
 * ...
 */
export async function scanQRCode () {
    return new Promise((resolve, reject) => {
        callHanlder('scanQRCode', { type: 'qrcode' }, result => {
            console.log('扫码结果:', result)
            resolve(result)
        })
    })
}

好了,以上就是经典的JSBridge的实现方案,看起来非常的简单,且没有兼容性问题。

既然Native有神奇的代码,有没有更彻底些的办法呢?

有!!!Native中有另一个神奇的API,我们暂且称它为defineFunc函数吧,它可以直接将Native的代码注入到H5的载体WebView中,并挂在WebView的window上。

// define 翻译过来大概就是下面的这个意思
function defineFunc (funcName, func) {
    const window = webView.window ... // 通过一些Native的API拿到WebView的window
    window[funcName] = func // 这里的func 是Native的func,执行的是纯Native的代码
}

// Native
defineFunc('callSomeNativeFunction', () => {
    // 这些是由Native的代码翻译成javascript的伪代码
    const file = io.readFile('/path/to/file')
    ...
    // 做一些H5做不到的事情
    file.write('/path/to/file', 'content')
    ...
})

这就是利用如iOS中JavaScriptCore的API来实现交互的原理,安卓也有类似的方式,对系统版本有些许的要求,可以忽略不计。这里就不讨论了。

什么??Native可以随意到WebView中执行代码?这个bug是不是Native乱搞搞出来的?细思极恐啊!

H5:天呐,我们原来活在一个虚拟的世界里!!!在鄙视链的最低端!!

是的!让我想起了《黑客帝国》,啥?没看过?暴露年龄了?

我们生活的世界到底是真实的吗?

关于我们

快狗打车前端团队专注前端技术分享,定期推送高质量文章,欢迎关注点赞。

公众号二维码