如何通過(guò)Proxy實(shí)現(xiàn)JSBridge模塊化封裝
最近公司在做一個(gè)項(xiàng)目,通過(guò)把我們自己的Webview植入第三方APP,然后我們的業(yè)務(wù)全部通過(guò)H5實(shí)現(xiàn)。至于為什么不直接用第三方APP WebView,主要是身處金融行業(yè),需要做一些風(fēng)控相關(guān)功能。
由于是Hybrid APP的性質(zhì),所以web與Native的通信是無(wú)法避免的;而為什么我要封裝jsBridge,主要在于下面兩點(diǎn):
公司APP的JSBridge提供了數(shù)據(jù)的序列化和全局函數(shù)的注入,而我們這次由于包大小考慮,這一塊需要H5自己來(lái)實(shí)現(xiàn);
原生提供的接口協(xié)議太多,記住麻煩;
回調(diào)的寫(xiě)法不太人性化,期望Promise;
由于本次項(xiàng)目只涉及到Andriod,所以沒(méi)有關(guān)于ios的處理,但我自認(rèn)為他們只是協(xié)議的不同,Web的處理可以相同。
原理淺談

看上圖的通信實(shí)現(xiàn)(圖片來(lái)源于文章開(kāi)頭的文章),簡(jiǎn)單說(shuō)一下通信過(guò)程;
Webview加載時(shí)會(huì)將原生提供的JSBridge方法注入到window對(duì)象上,比如:window.JSBridge.getDeviceInfo就是原生提供的可以讀取一些設(shè)備標(biāo)識(shí)信息的接口;
H5通過(guò)window調(diào)用原生接口,基本都需要傳參,比如這次處理成功或則處理失敗的結(jié)果回調(diào)的,還有一些參數(shù)設(shè)置,拿上面給的方法來(lái)舉例:
window.JSBridge.getDeviceInfo({
token: '*&^%$$#*',
onOk(data) {
save(data);
},
onError(error) {
console.log(error.message);
}
});
原生響應(yīng)H5的調(diào)用成功或失敗后,就執(zhí)行H5傳遞過(guò)來(lái)的回調(diào)函數(shù);
過(guò)程結(jié)束;
看上面的通信過(guò)程,貌似很簡(jiǎn)單。但這里面存在一些協(xié)議的問(wèn)題:
首先H5與原生端的通信消息,是只支持字符串的,如果要傳JS對(duì)象,那就先序列化;
序列化帶來(lái)的后果又是,對(duì)象中的函數(shù)就無(wú)法傳遞;
而就算函數(shù)傳過(guò)去了,也是存在問(wèn)題的,由于安全的限制,webview和js的執(zhí)行沒(méi)有在一個(gè)容器中,回調(diào)這種局部函數(shù)是找不到的,所以是需要將回調(diào)函數(shù)注冊(cè)到全局;
所以下面就來(lái)解決這些問(wèn)題
一步一步的具體實(shí)現(xiàn)
接口協(xié)議封裝
什么意思喃?看下面的圖:

由于APP端協(xié)議及分包問(wèn)題, 存在多個(gè)Bridge, 比如MBDevice、MBControl、MBFinance,上面列出來(lái)的只是一小部分,對(duì)于web來(lái)說(shuō)記憶這些接口是一件很費(fèi)事的事;還有就是以前我調(diào)APP的JSBridge, 總有下面這樣的代碼:
window.JSBridge && window.JSBridge.getDeviceInfo && window.JSBridge.getDeviceInfo({ ... })
至于上面,所以加了一層封裝,實(shí)現(xiàn)的核心就是Proxy和Map,具體實(shí)現(xiàn)看下面的偽代碼:
const MBSDK = {
};
// sdk 提供的方法白名單
const whiteList = new Map([
['setMaxTime', 'MBVideo'],
['getDeviceInfo', 'MBDevice.getInfo'],
['close', 'MBControl'],
['getFinaceInfo', 'MBFinance.getInfo'],
]);
const handler = {
get(target, key) {
if (!whiteList.has(key)) {
throw new Error('方法不存在');
}
const parentKey = whiteList.get(key);
function callback() {
return [...parentKey.split('.'), key];
}
return new Proxy(callback, applyHandler); // funcHandler后面再展開(kāi)
},
};
export default new Proxy(MBSDK, handler);
基于上面的封裝,調(diào)用時(shí),代碼就是下面這樣
sdk.setMaxTime({
maxTime: 10,
}).then(() => {
console.log('設(shè)置成功');
}, () => {
window.alert('調(diào)用失敗');
});
序列化與回調(diào)注冊(cè)
上面已經(jīng)列了為什么需要回調(diào)函數(shù)全局注冊(cè)和序列化,這里主要說(shuō)一下實(shí)現(xiàn)原理,總得來(lái)說(shuō)分兩步;
回調(diào)函數(shù)剝離,全局注冊(cè);
參數(shù)序列化;
回調(diào)函數(shù)剝離和參數(shù)序列化
其實(shí)很好實(shí)現(xiàn),直接展開(kāi)運(yùn)算符搞定:
const { onOk, onError, ...others } = params; // 回調(diào)函數(shù)剝離
const str = JSON.stringify(others); // 參數(shù)序列化
函數(shù)全局注冊(cè)
看了很多文章的一些實(shí)現(xiàn),思路基本一致,比如下面這樣
window.bridgeCallbacks = {};
const callBacks = window.bridgeCallbacks;
const { onOk, onError, ...others } = params; // 回調(diào)函數(shù)剝離const callbackId = generateId(); // 產(chǎn)生一個(gè)唯一的隨機(jī)數(shù)Id
callBacks[`success_${callbackId}`] = onOk;
callBacks[`onError${callbackId}`] = onError;others.success = `window.bridgeCallbacks.success_${callbackId}`
// ....
// 調(diào)用jdk代碼
這是一種很容易想到的問(wèn)題,但卻存在一些問(wèn)題,比如:
bridgeCallbacks全局會(huì)注冊(cè)很多屬性,因?yàn)镹ative調(diào)用并沒(méi)有清理,而onOk這種很多時(shí)候是一個(gè)閉包,由于有引用,最后導(dǎo)致的問(wèn)題就是內(nèi)存泄露;
就算處理了第一步的問(wèn)題,webview無(wú)響應(yīng)怎么辦,那回調(diào)就會(huì)被一直掛起,確少超時(shí)響應(yīng)邏輯
callbackId的唯一性不好保證;
基于以上考慮,我換了一個(gè)方案,采用回調(diào)隊(duì)列,因?yàn)锳PP端說(shuō)過(guò),回調(diào)是按順序的,不會(huì)插隊(duì);
class CallHeap {
constructor() {
this.okQueue = [];
this.errorQueue = [];
}
success = (args) => {
// 成對(duì)彈出回調(diào):成功時(shí),不止要處理成功的回調(diào),失敗的也要同時(shí)彈出,
const target = this.okQueue.shift();
this.errorQueue.shift();
target && target(args);
}
error = (args) => {
const target = this.errorQueue.shift();
this.okQueue.shift();
target && target(args);
}
addQueue(onOk = Null, onError = Null) {
this.okQueue.push(onOk);
this.errorQueue.push(onError);
}
}
window.bridgeCallbacks = {};
const callBacks = window.bridgeCallbacks;
function applyhandler() {
const { onOk, onError, ...others } = params; // 回調(diào)函數(shù)剝離
if (onOk || onError) {
const callKey = transferKey || key; // transferKey || key后面會(huì)提到
// 如果全局未注冊(cè),則先注冊(cè)對(duì)應(yīng)的調(diào)用域
if (!callbacks[callKey]) {
callbacks[callKey] = new CallHeap();
}
// 添加回調(diào)
callbacks[callKey].addQueue(onOk, onError);
others.success = `callBacks.${callKey}.success`;
others.error = `callBacks.${callKey}.error`;
}
// 調(diào)用jdk代碼
}
基于以上的實(shí)現(xiàn),就可以保證發(fā)起多個(gè)Native請(qǐng)求,并保證有序回調(diào);如果成功,成功回調(diào)被響應(yīng)時(shí),響應(yīng)的失敗回調(diào)也會(huì)被彈出,因?yàn)榛卣{(diào)函數(shù)式存在數(shù)組中的,所以執(zhí)行完后,引用就不會(huì)再存在。
完整實(shí)現(xiàn)
看了上面的代碼實(shí)現(xiàn),但核心好像還沒(méi)有提及,那就是調(diào)用參數(shù)的攔截。前面我們用Proxy的get優(yōu)雅的實(shí)現(xiàn)了SDK方法的攔截,這里會(huì)接著采用Proxy的apply方法來(lái)攔截方法調(diào)用的傳參,直接看代碼吧:
// 結(jié)合最上面接口協(xié)議封裝的代碼一起看
const applyHandler = {
apply(target, object, args) {
// transferKey 用于getFinaceInfo與getDeviceInfo這種數(shù)據(jù)命名重復(fù)的
const [parentKey, key, transferKey] = target();
console.log('res', parentKey, key);
const func = (SDK[parentKey] || {})[key];
const { onOk, onError, ...params } = args[0] || {};
if (onOk || onError) {
const callKey = transferKey || key;
if (!callbacks[callKey]) {
callbacks[callKey] = new CallHeap();
}
callbacks[callKey].addQueue(onOk, onError);
others.success = `callBacks.${callKey}.success`;
others.error = `callBacks.${callKey}.error`;
}
return func && (window[parentKey][key])(JSON.stringify(params));;
}
};
Promise 封裝
前面吹過(guò)的牛逼還有兩個(gè)沒(méi)實(shí)現(xiàn),比如:
promise支持
超時(shí)調(diào)用
首先來(lái)復(fù)習(xí)一下,怎么封裝一個(gè)支持Promise的setTimeout函數(shù):
function promiseTimeOut(time) {
return new Promise((resolve, reject) => {
setTimeout(resolve, time);
});
}
promiseTimeOut(1000).then(() => {
console.log('time is ready');
})
如果對(duì)上面這個(gè)封裝不陌生,那基于回調(diào)函數(shù)的Promise化就變得簡(jiǎn)單了
talk is cheap, show me your code
完整實(shí)現(xiàn):
const MBSDK = {
};
// sdk 提供的方法白名單
const whiteList = new Map([
['setMaxTime', 'MBVideo'],
['getDeviceInfo', 'MBDevice.getInfo'],
['close', 'MBControl'],
['getFinaceInfo', 'MBFinance.getInfo'],
]);
const applyHandler = {
apply(target, object, args) {
// transferKey 用于getFinaceInfo與getDeviceInfo這種數(shù)據(jù)命名重復(fù)的
const [parentKey, key, transferKey] = target();
// FYX 編程
const func = (window[parentKey] || {})[key];
// 設(shè)置一個(gè)默認(rèn)的超時(shí)參數(shù),支持配置
const { timeout = 5000, ...params } = args[0] || {};
return new Promise((resolve, reject) => {
const callKey = transferKey || key;
if (!callbacks[callKey]) {
callbacks[callKey] = new CallHeap();
}
const timeoutId = setTimeout(() => {
// 超時(shí),主動(dòng)發(fā)起錯(cuò)誤回調(diào)
window.callBacks[callKey].error({ message: '請(qǐng)求超時(shí)' });
}, timeout);
callbacks[callKey].addQueue((data) => {
clearTimeout(timeoutId);
resolve(data);
}, (data) => {
clearTimeout(timeoutId);
reject(data);
});
params.success = `callBacks.${callKey}.success`;
params.error = `callBacks.${callKey}.error`;
func && (window[parentKey][key])(JSON.stringify(params));
}).catch((error) => {
console.log('error:', error.message);
});
}
};
const handler = {
get(target, key) {
if (!whiteList.has(key)) {
throw new Error('方法不存在');
}
const parentKey = whiteList.get(key);
function callback() {
return [...parentKey.split('.'), key];
}
return new Proxy(callback, applyHandler); // funcHandler后面再展開(kāi)
},
};
export default new Proxy(MBSDK, handler);
而調(diào)用時(shí),基本上,就可以這樣玩了:
sdk.setMaxTime({
maxTime: 10,
}).then(() => {
console.log('設(shè)置成功');
}, () => {
window.alert('調(diào)用失敗');
});
解惑
- func.call(null, JSON.stringify(params)) // 以前的
+ func && (window[parentKey][key])(JSON.stringify(params)); // 現(xiàn)在的
開(kāi)始函數(shù)的調(diào)用是采用func.call來(lái)實(shí)現(xiàn)的,當(dāng)時(shí)我本地mock過(guò),沒(méi)有問(wèn)題。但在webview中就彈出了下面這樣一個(gè)錯(cuò)誤:
java bridge method can't be invoked on a non-injected object
經(jīng)過(guò)各種goggle,百度,查到的都是一條關(guān)于Andriod的注入漏洞。而至于我這里通過(guò)JS的方式把bridge指向的函數(shù)地址,賦值給一個(gè)變量名,然后再通過(guò)變量名來(lái)調(diào)用就會(huì)報(bào)上面這個(gè)錯(cuò)誤,我個(gè)人的猜測(cè)有兩個(gè):一是協(xié)議這樣規(guī)定的;二是this指向問(wèn)題。
以上就是本文的全部?jī)?nèi)容,希望對(duì)大家的學(xué)習(xí)有所幫助,也希望大家多多支持腳本之家。
相關(guān)文章
js判斷用戶(hù)是輸入的地址請(qǐng)求的路徑(實(shí)例講解)
下面小編就為大家?guī)?lái)一篇js判斷用戶(hù)是輸入的地址請(qǐng)求的路徑(實(shí)例講解)。小編覺(jué)得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2017-07-07
基于勻速運(yùn)動(dòng)的實(shí)例講解(側(cè)邊欄,淡入淡出)
下面小編就為大家?guī)?lái)一篇基于勻速運(yùn)動(dòng)的實(shí)例講解(側(cè)邊欄,淡入淡出)。小編覺(jué)得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2017-10-10
微信打開(kāi)網(wǎng)址添加在瀏覽器中打開(kāi)提示的辦法
這篇文章主要介紹了微信打開(kāi)網(wǎng)址添加在瀏覽器中打開(kāi)提示的辦法,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2019-05-05
后端代碼規(guī)范避免數(shù)組下標(biāo)越界
這篇文章主要為大家介紹了后端開(kāi)發(fā)中的代碼如何規(guī)范避免數(shù)組下標(biāo)越界示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-06-06
csdn 博客中實(shí)現(xiàn)運(yùn)行代碼功能實(shí)現(xiàn)
有時(shí)候因?yàn)閏sdn的博客經(jīng)常處理一些字符,導(dǎo)致代碼很多情況下,都不能正常運(yùn)行,給大家的閱讀帶來(lái)了麻煩,下面是腳本之家編輯簡(jiǎn)單的整理下。2009-08-08

