Android 和 Webview 如何相互 sayHello(一)


本系列文章一共有兩篇:主要來說解 webview 和客戶端的交互。 本篇爲第一篇:Android 和 webview 的交互 後續一篇是:IOS 和 webview 的交互 如需得到最新的內容,能夠關注微信公衆號:前端小吉米javascript

在移動時代 Web 的開發方式逐漸從 PC 適配時代轉向 Hybird 的 Webview。之前,咱們只須要了解一下 PC Chrome 提供的幾個操做行爲,好比 DOM、BOM、頁面 window.location 跳轉等等。你的一切行爲都是直接和 瀏覽器打交道,只要規規矩矩的按照 W3C/MDN 上面的文檔開發便可。好比,我須要你實現一個截屏的需求,後面一查文檔,發現 API 不支持,無法作,直接打回~前端

後面,你開始作 Hybird APP,產品又提了這個截屏的需求,你查了一下文檔,發現 API 仍是不支持,可是,客戶端同窗就在邊上,你一拍大腿說,老鐵,給我一個截屏的 jsbridge 沒問題吧?java

對於 PC Web 和 Hybird App 來講,給 HTML5 開發者最直觀的感覺就是,之前 PC 上一些底層基礎功能,你能夠直接在 App 裏面,配合客戶端直接使用。除了這一點還有一些其它的區別點,好比:react

  • 使用 window.location,並不能必定能實現跳轉
  • unload 事件並不必定會觸發
  • 302/301 重定向問題會讓客戶端同窗崩潰
  • https 證書問題 log,只能從客戶端同窗那取
  • 客戶端能夠直接拿到你的 cookie
  • UA 的定製須要客戶端來手動設置
  • ServiceWorker 開不開還得問客戶端
  • 不一而足...

這裏,將從一個 Web 開發者的角度觸發,仔細探尋一下 Webview 開發下,Web 開發者將碰見哪些問題,瞭解和 客戶端 交互的底層原理。本系列文章將分別介紹一下在 Android 和 IOS 系統下,開發 Hybird APP 大體流程和其中的須要注意、優化的地方。android

本文主要介紹的是 Android 下 Webview 的開發。git

tl;dr

本文主要從 H5 開發者的角度來簡單講解一下在 Hybird 開發過程當中遇到的相關問題和對應的解決方案。github

  • android 兩種調用 H5 的方式
  • javascript 調用 android 方式的對比
  • jsbridge.js 文件的起源
  • android 如何 inject JS 文件
  • 客戶端對於 webview 的性能優化

Anriod 開發 Webview 基礎

Webview 在 Android 裏面其實就是一個組件而已,它能夠像其餘的 Android 組件同樣在 screen 中定位佈局。對比於 HTML5 開發來講,能夠類比爲一個 Div,也就是說,webview 能夠重疊 webview,同一個 screen 能夠展現多個 webview 內容。web

<RelativeLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:showIn="@layout/activity_main">
    <WebView
        android:id="@+id/webview"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
</RelativeLayout>
複製代碼

上面就是一個簡單的 webview-activity 定義。順便提一下:activity 是 Android 開發的一個很是重要的概念,至關於 Router 中的一個子頁面。因此說,你新打開的 webview 的樣式和佈局,都須要經過客戶端發版本才能更新的。好比,微信的 webview-acitivit 和 手Q 的 webview-activity 是兩個徹底不同的 activity.編程

手Q
微信

在定製特有的 acitvity 以後,對於一個可用的 webivew,還須要對 webview 作相關的配置。整個流程圖爲:json

image.png-55.5kB

參考實際代碼爲:

// activity 的 onCreate 事件中
WebView webView = (WebView) findViewById(R.id.webview);
webView.setWebViewClient(defaultViewClient);
webView.setWebChromeClient(mChromeClient);

// 設置 webSettings
WebSettings webSettings = webView.getSettings();
webSettings.setJavaScriptEnabled(true);
webSettings.setAllowContentAccess(true);
webSettings.setDatabaseEnabled(true);
webSettings.setDomStorageEnabled(true);


// 初始化完畢以後,就能夠直接調用 loadUrl,來加載頁面
webView.loadUrl(url);
複製代碼

這裏簡單解釋一下上述代碼。webview 自己只是用來做爲打開 H5 頁面的容器,其自己並不能很好的處理頁面之間跳轉或者加載事件等行爲。而 setWebViewClient 和 setWebChromeClient 主要是用來做爲補充使用。具體解釋能夠參考:

  • webview: 僅僅用來渲染和解析頁面
  • webviewClient: 解決頁面跳轉問題,重定向、異步請求發送,https 證書問題。
  • webChromeClient: 處理頁面 console.xx、alert、prompt 的信息、定製化設置頁面的標題、頁面加載進度等。

更多的內容,你們能夠直接參考 Android 官方文檔的 public method 查閱便可。若是對 react 開發有了解的同窗,應該能很容易理解上面 public method 的大體含義。當設置對應的 webview 配置以後,打開一個頁面就很是簡單了,就兩行代碼:

WebView myWebView = (WebView) findViewById(R.id.webview);
myWebView.loadUrl("https://www.example.com");
複製代碼

findViewById 和前端的 document.getElementById 很相似,直接找到對應的 webview 節點,而後利用 loadUrl API 直接打開指定的地址。後面,咱們就主要來介紹一下,android 是如何和 js 進行通訊的。

android 如何和 js 相互通訊

首先,咱們提出這個問題的時候,能夠想想爲何?爲何 android 和 js 之間必定要進行通訊呢?

回想一下日常的 hybird 的開發,咱們一般在前端調用客戶端接口來獲取相關內容:

  • 獲取用戶地理位置
  • 獲取用戶選擇照片的內容(一般返回的是 base64)
  • 拿到靠譜的 visibilityChange 事件
  • 調用客戶端的消息發送接口 加快請求速度,好比騰訊內部的 Webso
  • ...

因此,二者之間的通訊,不只必須,並且很重要。下面咱們來簡單介紹一下 通訊

所謂的通訊,其實更確切的來講就是傳遞消息。不過,這二者之間並非簡單的創建起一個通道,就能夠直接進行通訊。他們之間的通訊方向和方式仍是有些區別的。

  • android => js: 是經過 javascript:window.jsbridge_visibilityChange(xxx) 直接調用 window 裏面綁定的執行函數,若是要傳參的話,是直接轉換成字符串 inline 到函數裏面去。
  • js => android: 簡單來講,就是讓 android 監聽相關的事件。這些事件對應着 JS API 裏面的某些方法,好比 console、alert、prompt 等。

android 調用 js

咱們深刻到 API 層面來看一下,他們之間是如何相互進行調用的:

  • android => js: 方法只有兩個很是簡單
    • 使用 loadUrl("javascript:window.jsbridge_visibilityChange ")
    • API > 19。
mWebView.evaluateJavascript("(function() { return 'this'; })();", new ValueCallback<String>() {
    @Override
    public void onReceiveValue(String s) {
        // 上述定義函數執行完成時,return 的內容
        Log.d("LogName", s); // Prints: "this"
    }
});
複製代碼
  • js => android
    • 調用 android 設置的 JavascriptInterface (4.2 以上才能使用)
    • 經過 WebViewClient.shouldOverrideUrlLoading() 事件攔截對應的調用
    • WebChromeClient.onConsoleMessage() 監聽 js 的 console 觸發內容。
    • WebChromeClient.onJsPrompt 監聽 js 的 prompt 觸發內容。

js => android 的方法比較多,其中比較經常使用的有:WebChromeClient.onJsPrompt、WebViewClient.shouldOverrideUrlLoading、JavascriptInterface。

這裏,咱們着重來說解一下 js 調用 android 的簡單過程。

js 直接調用 android

這裏,咱們分方法來介紹一下上面對應的調用方式。首先是 addJavaScriptInterface。

addJavaScriptInterface

經過 addJavaScriptInterface 方法,能夠直接在 window 上注入一個對象,上面掛載這 JavaScriptInterface 裏面定義的全部方法和內容。

咱們直接看一個 addJavascriptInterface 內容。

# 定義一個 interface 對象
public class WebAppInterface {
    Context mContext;

    /** Instantiate the interface and set the context */
    WebAppInterface(Context c) {
        mContext = c;
    }

    // 能夠直接調用 Android 上面的
    @JavascriptInterface
    public void showToast(String toast) {
        Toast.makeText(mContext, toast, Toast.LENGTH_SHORT).show();
    }
}

# 在 webview 實例裏面添加該 interface
mWebView.addJavascriptInterface(new JavaScriptInterface(), "jsinterface");
複製代碼

而後,咱們能夠直接在 js 代碼裏面調用對象上掛載的 API。

var String = window.jsinterface.showToast("update");
複製代碼

不過,該方法在 4.2 版本以前存在嚴重的安全漏洞--利用 Java 反射機制,直接能直接執行敏感的 android 權限。詳細能夠參考 addJavascriptInterface 遠程指定代碼漏洞。因此,你須要在指定的方法上面加上 @JavascriptInterface 裝飾符。

對於這種方式,客戶端同窗是很是承認並且推崇的。由於不須要和其它複雜的方法耦合在一塊兒,使用起來乾淨整潔。不過,有個問題是,4.2 一下的版本不能使用。

對於比使用其它的,好比經過 shouldOverrideUrlLoading 來處理的方法,這種方法實現的效率更高,更有效率。可是,一旦考慮的低版本,就不得不對於同一份 jsbridge 實現兩次,因此這對於客戶端就像是 Achilles' Heel。

onJsPrompt

使用 onJsprompt 的邏輯很簡單,經過直接監聽 WebChromeClient.onJsPrompt 事件,設置好對應協議的內容便可。jsPrompt 在 Web 中對應的行爲是彈出一個框,裏面有用戶的輸入框和肯定、取消按鈕。

image.png-17.1kB

具體代碼以下:

mWebView.setWebChromeClient(new WebChromeClient() {
    /**
     * msg: 是經過 prompt(msg) 裏面的內容
     * 
     */
    @Override
    public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
  
        Uri uri = Uri.parse(message);
        
        boolean handle = message.startsWith("jsbridge:");

        if(handle){
            result.confirm("trigger"); // 有客戶端直接返回結果,不會弔起 input 
            return true;
        }

        return super.onJsPrompt(view, url, message, defaultValue, result);
    }
});
複製代碼

而後,咱們只要在 webview 裏面直接使用 prompt 調用便可。

function jsbridgePrompt(url){
    if(url) {
        nativeReady = false;
        var jsoncommand = JSON.stringify(url);
        var _temp = prompt(jsoncommand,'');
        return true;
    } else {
        // there is invalid jsbridge url
        return false;
    }
}
複製代碼

效率低下的 shouldOverrideUrlLoading

上面兩種方法調用很是簡單,不須要再對應方法裏面額外耦合一些其它處理邏輯。另外,還有一種調用方式,是直接用來監聽頁面的請求來作相應處理的 -- WebViewClient.shouldOverrideUrlLoading。

這種方式在 Android 裏面用起來比較複雜,不只須要處理對應的 302/301 跳轉,還須要作相關 webview 的權限處理。雖然,調用處理是在主線程中完成的,可是裏面代碼複雜度和實現效率比起來是沒法和上面兩種方法相比的。

這裏對 shouldOverrideUrlLoading 方法進行簡單的介紹一下。shouldOverrideUrlLoading 通常只對於 a 標籤的跳轉和 HTML 的請求有相關的響應。可是,有個問題,咱們怎樣去構造這樣的請求?

對於 a 標籤來講,若是沒有用戶的手動行爲,你是沒法觸發 onclick 事件的。因此,這裏能夠考慮使用構造 iframe 請求來實現類 shouldOverrideUrlLoading 的請求。這裏提供一個最簡版本:

const fakeInvokeSchema = (url, errHandler) => {
  let iframe = document.createElement('iframe');

  let onload = function () {
    // 若是 shouldOverrideUrlLoading 沒有很好的捕獲而且取消 iframe 請求,則會直接執行 iframe 的 onload 事件
    if (typeof errHandler === 'function') {
      errHandler("trigger faile", url);
    }


  };
  iframe.src = url;
  iframe.onload = onload;
  (document.body || document.documentElement).appendChild(iframe);

  // 觸發完成後移除,減小頁面的渲染。
  setTimeout(function () {
    iframe && iframe.parentNode && iframe.parentNode.removeChild(iframe);
  }, 0);
}
複製代碼

正常思惟邏輯按照上面這樣處理就沒啥問題了,可是實際每每會給你一巴掌。具體細節能夠參考以下:

  • 若是是 IOS 平臺:
    • 須要先進行 onload 和 src 的綁定,而後再將 iframe append 到 body 裏面,不然會形成連續 api 的調用,會間隔執行成功。
  • 若是是 Android:
    • 則須要先 append 到頁面,而後再綁定 onload 和 src。不然會形成 onload 失敗 和額外觸發一次 about:blank 的 onload 事件。about:black 的現象能夠參考 juejin.com 打開 github 登陸。

listener 監聽回調之謎

經過前文的 android js 的相互調用,咱們大體能瞭解二者以前互相調用的基礎。但在真正實踐當中,jsbridge 的相互調用其實能夠概括爲兩種類型:

  • java call js:
    • with callback
      • once
      • permanent: 好比,用來獲取用戶狀態的變換信息。至關於就是 listener。
    • without callback
  • js call java:
    • with callback
      • once
      • permanent: 好比,用來獲取頁面 visibility 的變動狀態。
    • without callback

這裏,咱們一步一步的來解決(咱們只瞭解 H5 相關的內容),首先簡單瞭解一下 once callback 如何解決。

once callback: 該類的 callback 很是好解決,能夠經過在自定義的 jsbridge 文件裏面經過 _CLIENT_CALLBACK + globalId++ 的方式生成惟一的 callback 方法。對於此類 callback 有時候爲了節省內存在執行完畢後,還須要刪除該 callback

// jsCode call jsbridge
jsbridge.getDeviceId(callback)

// jsbridge.js register once callback
let onceCallback = "__ONCE_CALLBACK__" + globalId++;
window[onceCallback] = function(data){
    callback(data);
    delete(window[onceCallback]);
}
複製代碼

with callback: 這類帶 callback 或者說是帶 listener 的方式,比較難處理,由於客戶端須要一直保留當前的 Listener ,若是 webview 經過 removeListener 移除還須要作相應的操做。另外,客戶端還須要判斷當前的 listener 是否和對應 register 的 webview 一致,不一致還須要銷燬當前註冊的 listener. 不過,具體思路也很簡單,直接經過 jsbridge 將在 window 裏面註冊的函數傳遞給客戶端。

// jsCode call jsbridge
jsbridge.qbVisibilityChange((vis)=>{
    // xxx
})


# 底層的解析代碼爲:
const jsbridge.qbVisibilityChange = function(callback){

        let CALLBACK_Listner = function(param){
            callback(param);
        }
        window["CALLBACK_Listner"] = CALLBACK_Listner;
        prompt(`jsbridge://qq.com/visibility#__callback__=CALLBACK_Listner`);
}
複製代碼

jsbridge.js 文件的起源

上面這些調用代碼,其實都是和業務代碼無關的。你能夠仔細預想一下,若是 H5 須要適配多個 app 的 jsbridge,那麼你須要寫一個 switch/case 的語句。

switch(){
    case xx:
        load('bridgeA.js')
    case xx:
        load('bridgeB.js');
    case xx:
        load('bridgeC.js');
    break;
}
複製代碼

並且若是他們對應的 API 接口名不一致的話,你還須要再包一層進行優化。這也就會致使,你可能會想本身寫一個 jsbridge,將全部不一致的 API 接口名,放到一個函數裏面進行處理。

// WrapBridge.js
jsbridge.visibilityChange = function(cb){
    if(UA.isQQ){
        jsbridge.qqVisibilityChange(cb)
    }else if(UA.isWeChat){
        jsbridge.wxVisibilityChange(cb)
    }
    ...
}
複製代碼

因此,有時候你調用一個 jsbridge 的時候,其實並不知道該方法下面包了多少層。可是,有時候有些 app 爲了解決該 jsbridge.js 侵入業務層業務引入的步驟,選擇使用由客戶端直接侵入加載。

下面咱們來簡單介紹一下,客戶端如何作到直接侵入 webview 加載 jsbridge.js 文件的。

android 侵入 webview 加載 bridge.js

這裏咱們瞭解到若是 java 調用 js 是須要額外引入定製化的 invokeSchame://xxx ,方便提供給 web 進行調用。對於這類定製化需求,須要額外引入 jsbridge.js。這裏通常提供兩種方式來引入 jsbridge.js。一是經過官方文檔的形式,告訴 H5 開發者,在開發以前須要額外引入指定文件。而是直接利用 webview 注入的方式,將指定的 js 文件打進去。

  • 知會 H5 開發額外引入文件:這一般是搭配 hybird 開發使用,一來共同方便,二來也方便 debugger
  • 直接客戶端引入:對於平臺級的應用,經常會用到這種辦法,減小 H5 沒必要要的溝通和複雜度。

這裏,簡單介紹一下,客戶端如何引入 JS 文件,並保證其可以生效。通常狀況下,客戶端注入的時機應該是在 DomContentLoaded 事件以後,保證不會阻塞相關的內容和事件。反映到 webviewClient 裏面的事件也就是:

  • onPageStarted
  • onPageFinished

最保險的方式,是直接在 onPageFinished 事件裏面注入 JS 文件. 下面是一個僞代碼,直接在全局裏面執行一個函數。

webView.setWebViewClient(new WebViewClient(){
    @Override
    public void onPageFinished(WebView view, String url) {
        super.onPageFinished(view, url);
        webView.loadUrl(
            "javascript:(function() { " +
                "var element = document.getElementById('hplogo');"
                + "element.parentNode.removeChild(element);" +
            "})()");
    }
});

複製代碼

若是仔細思考一下就會發現,當前的 webview 是否值得注入,即,判斷 webview 的有效性,一般咱們認爲以下 webview 是有效的:

  • 重定向完畢後,最新打開穩定的 webview
  • 已經打開的 webview ,而且沒有被注入過

通常處理作法是直接在 webview 的 onPageFinished 事件裏面直接注入 jsbridge 文件。固然,若是你的文件簡單能夠直接根據上面,寫在 inline 裏面,可是通常狀況下,通常會抽離成一個單獨的 js 文件。這裏,能夠直接將 jsbridge 文件轉換成 base64 編碼,而後利用 window.atob 直接解析便可。這其實和解析圖片有些相似。這樣的好處是能夠直接外帶文件,但壞處是增長 js 的解析時間。具體以下代碼:

@Override
       public void onPageFinished(WebView view, String url) {
          super.onPageFinished(view, url);

          injectScriptFile(view, "js/script.js"); // 注入外鏈代碼

          // test if the script was loaded
          view.loadUrl("javascript:setTimeout(test(), 500)");
       }

       private void injectScriptFile(WebView view, String scriptFile) {
          InputStream input;
          try {
            // 通常會直接在初始化時,將該 js 文件預讀爲 base64
             input = getAssets().open(scriptFile);
             byte[] buffer = new byte[input.available()];
             input.read(buffer);
             input.close();
             String encoded = Base64.encodeToString(buffer, Base64.NO_WRAP);

             view.loadUrl("javascript:(function() {" +
                          "var parent = document.getElementsByTagName('head').item(0);" +
                          "var script = document.createElement('script');" +
                          "script.type = 'text/javascript';" +
             // 將 base64 轉成 string 代碼
                          "script.innerHTML = window.atob('" + encoded + "');" +
                          "parent.appendChild(script)" +
                          "})()");
          } catch (IOException e) {
             // TODO Auto-generated catch block
             e.printStackTrace();
          }
       }
複製代碼

具體參考:standalone js

前面我也告誡過你們:

教科書式的解決辦法,啥也解決不了

客戶端通常選擇侵入的時機一般會選在 onPageFinished 中,這已是最簡單的了。可是,因爲重定向的問題,又讓實現方法變得不那麼優雅。

webview 重定向解決辦法

如今最關鍵的是如何判斷當前打開的 webview 是有效果的?

打開一個網頁有兩個辦法:

  • webivew 自身控制:點擊 a 標籤直接跳轉、經過 window.location 直接修改
  • 調用WebView的loadUrl()方法

和 URL 打開相關的三個事件有:

  • shouldOverrideUrlLoading(): 攔截頁面的 GET/POST 請求,注意 HTML 其實就是一個簡易 GET 請求 一樣也會攔截。
  • onPageStarted():頁面開始加載時,會直接觸發
  • onPageFinished(): 頁面加載完成時會觸發。當請求重定向地址,而且成功返回結果時,也會觸發該事件
  • onProgressChanged: 主要是用來計算頁面加載的進度,會在 onPageStarted 和 onPageFinished 之間觸發屢次,一般是 20-50-80-100 這樣的次數。另外,在重定向加載時,也會屢次觸發該函數。

因此,爲了獲得頁面真正加載完畢的 flag,咱們須要仔細瞭解一下在 301/302 時,上述對應事件觸發的流程。這裏就對應了兩種不一樣的打開方式,以及是否存在重定向的 2x2 的選擇。

  • 200 正常一次性直接返回

    • loadUrl 打開
      • onPageStarted()-> onPageFinished()
        • 注意,這裏並不會觸發 shouldOverrideUrlLoading 事件,這個很重要
    • a 標籤,window.location 打開
      • shouldOverrideUrlLoading() => onPageStarted() => onPageFinished()
  • 301/302 重定向返回

    • loadUrl 打開
      • repeat( onPageStarted()->shouldOverrideUrlLoading()->onPageFinished() ) * 重定向次數N => onPageStarted()->onPageFinished()
    • a 標籤,window.location 打開
      • shouldOverrideUrlLoading => repeat( onPageStarted()->shouldOverrideUrlLoading()->onPageFinished() ) * 重定向次數N => onPageStarted()->onPageFinished()

簡單概括一下,在 webview 中新打開頁面,必定會觸發 shouldOverrideUrlLoading。在 native 裏面打開 url,則只會走正常邏輯 (pageStart => onPageFinished ),除非重定向。

也就是凡是在 onPageStarted 和 onPageFinished 之間,觸發了 shouldOverrideUrlLoading 都是重定向,這個就是關鍵點。那麼咱們能夠設置一個 flag 標誌位,記錄此時文檔是否真正的加載完成。基本步驟爲:

  • onPageStarted 裏面,設置一個全局變量 this.loaded = true
  • 在 shouldOverrideUrlLoading,將 this.loaded = false
  • 在 onPageFinished 判斷 this.loaded === true, 是表明當前 webview 已經加載完畢。不是,則表明重定向

webview 的性能優化

衆所周知,webview 的渲染性能在 Android 機上算是差強人意。可是,其自己的性能永遠是沒法和客戶端相提並論的。固然,爲了讓 webview 優化性能更進一步提高,日常作的方案有:

  • 離線包:經過客戶端預先下載 web 的離線包資源,極大的減小 webview 的加載時延。
  • RN/Flutter: 經過 JsBundle 的形式將客戶端組件的 API 進行封裝,將使用代碼解析爲 DSL 樹,由 JsBundle 解析渲染。因爲參照對象徹底是客戶端,因此,若是要將代碼徹底設計爲 H5 代碼來講是很是困難的,特別是實現像 CSS 同樣的佈局語法。
    • 他還有一個致命的劣勢,即,若是存在客戶端組件的更新,必須每次更新底層的解析版本,而後發佈到 Store 裏面並更新。這對於緊急 Bug 和新功能的提審來講影響很是大。

本文後續涉及的內容,只針對於偏向前端的 H5 資源加載優化和渲染優化。

離線包優化

對於 H5 資源加載優化,離線包能夠說是碾壓一切,不過弊端和 RN 差很少。一樣也須要客戶端的聯動,若是發生 bug 只能按照版本的更替進行發佈。僅僅考慮到更新和版本問題來講,離線包確實很渣。However,你仔細想想,離線包機制有 RN 複雜?它會涉及 UI 麼?實現難度大麼?

這個問題,我想應該不須要作太多解釋。首先,離線包僅僅是一個資源管理的邏輯 package,出了問題頂多就是走線上的資源而已。對於這一點來講,離線包機制更勝於 RN、性能更優於 H5。

ServiceWorker webview 內優化

ServiceWorker 其實不只僅只侷限於 H5,對全部用到 網頁開發 來講都意義重大。究其原因主要是他的性能優點,以及可編程性開發。對標於 Android 的四大組件的 Service 來講,ServiceWorker 自己的想象力就能夠理解爲一個駐留 Web 程序以及網絡中間層的代理控制。

但,弊端也不是沒有,主要在於它自身業務邏輯是獨立於當前對應的 Web 項目,須要在項目裏面額外引入一個 sw.js。對於某些新手同窗來講,上手難度仍是有一點,不過影響不大。但對比於離線包機制來講,處理緩存差一點,其餘的應該算是碾壓。

歡迎關注 前端小吉米

相關文章
相關標籤/搜索