沐鳴娛樂_利用Proxy,如何優雅實現JSBridge模塊化封裝

背景

關於jsBridge的一些基礎知識,在網絡上有很多文章可以參考:

《H5與Native交互之JSBridge技術》

《JSBridge的原理》

最近公司在做一個項目,通過把我們自己的Webview植入第三方APP,然後我們的業務全部通過H5實現。至於為什麼不直接用第三方APP WebView,主要是身處金融行業,需要做一些風控相關功能。

由於是Hybrid APP的性質,所以web與Native的通信是無法避免的;而為什麼我要封裝jsBridge,主要在於下面兩點:

公司APP的JSBridge提供了數據的序列化和全局函數的注入,而我們這次由於包大小考慮,這一塊需要H5自己來實現;

原生提供的接口協議太多,記住麻煩;

回調的寫法不太人性化,期望Promise;

由於本次項目只涉及到Andriod,所以沒有關於ios的處理,但我自認為他們只是協議的不同,Web的處理可以相同。

原理淺談

看上圖的通信實現(圖片來源於文章開頭的文章),簡單說一下通信過程;

Webview加載時會將原生提供的JSBridge方法注入到window對象上,比如:window.JSBridge.getDeviceInfo就是原生提供的可以讀取一些設備標識信息的接口;

H5通過window調用原生接口,基本都需要傳參,比如這次處理成功或則處理失敗的結果回調的,還有一些參數設置,拿上面給的方法來舉例:

window.JSBridge.getDeviceInfo({
  token: '*&^%$$#*',
  onOk(data) {
    save(data);
  },
  onError(error) {
    console.log(error.message);
  }
});

原生響應H5的調用成功或失敗后,就執行H5傳遞過來的回調函數;

過程結束;

看上面的通信過程,貌似很簡單。但這裏面存在一些協議的問題:

首先H5與原生端的通信消息,是只支持字符串的,如果要傳JS對象,那就先序列化;

序列化帶來的後果又是,對象中的函數就無法傳遞;

而就算函數傳過去了,也是存在問題的,由於安全的限制,webview和js的執行沒有在一個容器中,回調這種局部函數是找不到的,所以是需要將回調函數註冊到全局;

所以下面就來解決這些問題

一步一步的具體實現

接口協議封裝

什麼意思喃?看下面的圖:

由於APP端協議及分包問題, 存在多個Bridge, 比如MBDevice、MBControl、MBFinance,上面列出來的只是一小部分,對於web來說記憶這些接口是一件很費事的事;還有就是以前我調APP的JSBridge, 總有下面這樣的代碼:

window.JSBridge && window.JSBridge.getDeviceInfo && window.JSBridge.getDeviceInfo({ ... })

至於上面,所以加了一層封裝,實現的核心就是Proxy和Map,具體實現看下面的偽代碼:

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後面再展開
  },
};
export default new Proxy(MBSDK, handler);

基於上面的封裝,調用時,代碼就是下面這樣

sdk.setMaxTime({
      maxTime: 10,
    }).then(() => {
      console.log('設置成功');
    }, () => {
      window.alert('調用失敗');
    });

序列化與回調註冊

上面已經列了為什麼需要回調函數全局註冊和序列化,這裏主要說一下實現原理,總得來說分兩步;

回調函數剝離,全局註冊;

參數序列化;

回調函數剝離和參數序列化

其實很好實現,直接展開運算符搞定:

  const { onOk, onError, ...others } = params; // 回調函數剝離
  const str = JSON.stringify(others); // 參數序列化

函數全局註冊

看了很多文章的一些實現,思路基本一致,比如下面這樣

window.bridgeCallbacks = {};
const callBacks = window.bridgeCallbacks;
const { onOk, onError, ...others } = params; // 回調函數剝離

const callbackId = generateId(); // 產生一個唯一的隨機數Id

callBacks[`success_${callbackId}`] = onOk;
callBacks[`onError${callbackId}`] = onError;

others.success = `window.bridgeCallbacks.success_${callbackId}`
// ....
// 調用jdk代碼

這是一種很容易想到的問題,但卻存在一些問題,比如:

bridgeCallbacks全局會註冊很多屬性,因為Native調用並沒有清理,而onOk這種很多時候是一個閉包,由於有引用,最後導致的問題就是內存泄露;

就算處理了第一步的問題,webview無響應怎麼辦,那回調就會被一直掛起,確少超時響應邏輯

callbackId的唯一性不好保證;

基於以上考慮,我換了一個方案,採用回調隊列,因為APP端說過,回調是按順序的,不會插隊;

class CallHeap {
  constructor() {
    this.okQueue = [];
    this.errorQueue = [];
  }
  success = (args) => {
    // 成對彈出回調:成功時,不止要處理成功的回調,失敗的也要同時彈出,
    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; // 回調函數剝離
  if (onOk || onError) {
      const callKey = transferKey || key; // transferKey || key後面會提到
      // 如果全局未註冊,則先註冊對應的調用域
      if (!callbacks[callKey]) {
        callbacks[callKey] = new CallHeap();
      }
      // 添加回調
      callbacks[callKey].addQueue(onOk, onError);

      others.success = `callBacks.${callKey}.success`;
      others.error = `callBacks.${callKey}.error`;
    }
    // 調用jdk代碼
}

基於以上的實現,就可以保證發起多個Native請求,並保證有序回調;如果成功,成功回調被響應時,響應的失敗回調也會被彈出,因為回調函數式存在數組中的,所以執行完后,引用就不會再存在。

完整實現

看了上面的代碼實現,但核心好像還沒有提及,那就是調用參數的攔截。前面我們用Proxy的get優雅的實現了SDK方法的攔截,這裡會接着採用Proxy的apply方法來攔截方法調用的傳參,直接看代碼吧:

// 結合最上面接口協議封裝的代碼一起看
const applyHandler = {
  apply(target, object, args) {
    // transferKey 用於getFinaceInfo與getDeviceInfo這種數據命名重複的
    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 封裝

前面吹過的牛逼還有兩個沒實現,比如:

promise支持

超時調用

首先來複習一下,怎麼封裝一個支持Promise的setTimeout函數:

function promiseTimeOut(time) {
  return new Promise((resolve, reject) => {
    setTimeout(resolve, time);
  });
}

promiseTimeOut(1000).then(() => {
  console.log('time is ready');
})

如果對上面這個封裝不陌生,那基於回調函數的Promise化就變得簡單了

talk is cheap, show me your code

完整實現:

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這種數據命名重複的
    const [parentKey, key, transferKey] = target();
    // FYX 編程
    const func = (window[parentKey] || {})[key];
    // 設置一個默認的超時參數,支持配置
    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(() => {
        // 超時,主動發起錯誤回調
        window.callBacks[callKey].error({ message: '請求超時' });
      }, 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後面再展開
  },
};

export default new Proxy(MBSDK, handler);

而調用時,基本上,就可以這樣玩了:

sdk.setMaxTime({
      maxTime: 10,
    }).then(() => {
      console.log('設置成功');
    }, () => {
      window.alert('調用失敗');
    });

解惑

- func.call(null, JSON.stringify(params))  // 以前的
+ func && (window[parentKey][key])(JSON.stringify(params)); // 現在的

開始函數的調用是採用func.call來實現的,當時我本地mock過,沒有問題。但在webview中就彈出了下面這樣一個錯誤:

java bridge method can’t be invoked on a non-injected object

經過各種goggle,百度,查到的都是一條關於Andriod的注入漏洞。而至於我這裏通過JS的方式把bridge指向的函數地址,賦值給一個變量名,然後再通過變量名來調用就會報上面這個錯誤,我個人的猜測有兩個:一是協議這樣規定的;二是this指向問題。

如果有知道為什麼的大佬,還請不吝賜教,謝謝。

通過這一次的封裝,自己對Proxy的應用更加熟練了,將所學的知識運用到工作中,不得不說是一件非常愉快的事情。這也是自己第二篇關於ES6深入理解的文章;
第一篇: 重新認識ES6中的Set

原文見:issue , 如有不嚴謹之處,還請及時指正。

站長推薦

1.雲服務推薦: 國內主流雲服務商,各類雲產品的最新活動,優惠券領取。地址:阿里雲騰訊雲華為雲

2.廣告聯盟: 整理了目前主流的廣告聯盟平台,如果你有流量,可以作為參考選擇適合你的平台點擊進入

鏈接: http://www.fly63.com/article/detial/7428