整合營銷服務商

          電腦端+手機端+微信端=數據同步管理

          免費咨詢熱線:

          看完就懂的Hybrid框架設計方案

          看完就懂的Hybrid框架設計方案

          1 前言


          2009年,PhoneGap 以 “橋接 Web 與 iPhone SDK 之間縫隙“的理念橫空出世,讓人驚嘆于 JS 居然可以調用客戶端原生能力。


          隨著移動互聯網的快速發展,跨端需求迫在眉睫。前期 Hybrid 開發理念的萌芽,基于 Webview 的 Hybrid 開發模式開始盛行,WebViewJavascriptBridge 等 JSBridge 框架也開始流行,同時各大 App 也開始自研 Hybrid 框架。


          Webveiw 因為性能問題一直被社區詬病,2015年 FB 推出 RN 類原生框架,在社區激起波瀾。原生渲染、離線、可以和客戶端控件混合等特性非常驚艷。相比較 Webview 開發效率有所降低,但在一些較高性能要求的場景有了用武之地。


          當所有人都認為 Webview 是低性能代表時,2017年微信推出小程序,底層基于 Webview,社區再次激起千層浪,Webview 不慢了,體驗非常好啊!


          似乎 Hybrid 框架走到了盡頭,Webview 有很好的跨端一致性,經過優化也有較好的性能,類原生框架有接近原生的性能,能夠滿足大部分業務場景。


          2017 年 Google 推出了 Flutter,Flutter 的推出并沒有引起大的反響,對于 Google 的技術產品,國內社區也持謹慎態度。但隨著 Flutter 的發展,類 Flutter 框架同時面向前端和終端跨端,其性能與終端幾乎無差異,填補了類原生框架的不足。Flutter 也成為了當下最火熱的 Hybrid 技術。


          Hybrid 框架的發展史也是移動互聯網的盛衰史,移動互聯網淪為了“古典互聯網”,Hybrid 框架這次似乎真的發展到了盡頭。


          但無論如何,“生活”總要繼續。本篇文章探討“基于 Webview,如何在 App 內實現帶離線包能力的 H5”。在當下這個主題似乎有些過時,但 H5 技術以其良好的跨端一致性,長期來看會占據一席之地,希望整理一個較完整的方案,從基本的實現原理到業務具體應用,讓不了解的同學對“離線 H5"有一個較完整的視角。以下 Hybrid 均指基于 Webview 的混合式方案。

          一個 Hybrid 框架有一些重要的組成部分,我們先以一張圖來描述其整體架構,然后再詳盡介紹核心模塊應該如何去設計。


          從架構圖來看,Hybrid 主要由以下模塊組成:

          • JSBridge:它是前端和客戶端通信的基礎,是整套框架的核心之一。
          • Webview 容器:作為 H5 容器,需要提供一些基礎的能力。
          • 離線資源管理:客戶端對本地離線資源的拉取/更新、攔截等策略。
          • 開發調試:開發調試是業務開發的重要組成部分。
          • 離線包管理后臺:離線包版本管理系統。
          • 后臺服務:根據客戶端版本,返回對應版本的離線包。
          • 離線包協議:前端和客戶端約定的離線包協議,前端需要構建出約定的離線包格式。
          • 框架穩定性與安全:白屏檢測,異常處理,異常上報等。


          其中,JSBridge 作為前端和客戶端通信的基礎,是整個框架運作的核心,JSBridge 的設計至關重要,所以我們先分析如何選擇通信方案。


          02 通信方案


          所謂通信,即 JS 可以調用 Native 的能力,Native 也可以直接執行一段 JS 代碼,達到 Native 通知 JS 的目的。那么通信方式有哪些,應該如何選擇?


          備注:目前大部分知名 App 均選擇 WKWebview 作為內核,所以以下方案的選擇也不再考慮 UIWebview,其原因可參考網上的一些文章,這里不做說明。


          2.1 JS -> Native

          在 App 內,JS 做不到的能力就需要借助 Native 去實現,比如分享,獲取系統信息,關閉 Webveiw 等。JS 調用 Native 主要有以下幾種方案:



          方式一:假跳轉 - 同時發送多個請求丟消息、URL有長度限制,當下最不應該選擇的方案。


          所謂“假跳轉”,本質是約定一種協議,客戶端無差別攔截所有請求,正常 URL 放行,符合約定協議的請求攔截,并做出對應的操作。并且攔截下來的 URL 不會導致 Webview 跳轉錯誤地址,因此是無感知的。

          比如:

          // 正常網頁跳轉地址
          const url='https://qq.com/xxx?param=xxx'
          // 約定跳轉 url
          const fakeUrl='scheme://getUserInfo/action?param=xx&callbackid=xx'


          一個 URL 由協議/域名/路徑/參數等組成,我們可以參考這個組成規則,約定一個假的 URL:

          • 協議用于通信標識:客戶端只攔擊該類型的協議。
          • 路徑用于標識客戶端模塊及方法。
          • 參數用于數據傳遞。


          當然,不限于這個規則,任何一種合理的約定都可以讓 JS 和 Native 正常通信。


          網頁中有多種方式可以發起一次請求:


          // 1. A 標簽發起一次
          <a href="scheme://getUserInfo/action?param=xx&callbackid=xx">用戶信息</a>
          // 2. 在JS中創建一個iframe,然后動態插入到 DOM 中
          $('body').append('<iframe src="scheme://getUserInfo/action?param=xx&callbackid=xx"></iframe>');
          // 3. location.href 跳轉
          location.href='scheme://getUserInfo/action?param=xx&callbackid=xx'


          JS 發起請求后,客戶端如何攔截呢?


          安卓:shouldOverrideUrlLoading:


          @Override
          public boolean shouldOverrideUrlLoading(WebView view, String url) {
              // 1 根據url,判斷是否是所需要的攔截的調用 判斷協議/域名
              if (是){
                // 2 取出路徑,確認要發起的native調用的指令是什么
                // 3 取出參數,拿到JS傳過來的數據
                // 4 根據指令調用對應的native方法,傳遞數據
                return true;
              }
              return super.shouldOverrideUrlLoading(view, url);
          }


          iOS的 WKWebView:webView:decidePolicyForNavigationAction:decisionHandler:


          - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
              //1 根據url,判斷是否是所需要的攔截的調用 判斷協議/域名
              if (是){
                // 2 取出路徑,確認要發起的native調用的指令是什么
                // 3 取出參數,拿到JS傳過來的數據
                // 4 根據指令調用對應的native方法,傳遞數據
                // 確認攔截,拒絕WebView繼續發起請求
                  decisionHandler(WKNavigationActionPolicyCancel);
              } else {
                  decisionHandler(WKNavigationActionPolicyAllow);
              }
              return YES;
          }


          前面也提到了,這是當下最不該采用的方式,主要是它有如下兩個致命問題:

          1、同時發起多次跳轉,Webview 會直接丟棄掉第二次跳轉,所以第二條消息會直接被丟棄。

          location.href='scheme://getUserInfo/action?param=111&callbackid=xx'
          
          
          location.href='scheme://getUserInfo/action?param=222&callbackid=xx'

          2、URL 超長:如果 URL 超出系統最長限制了,消息會被截斷,這種情況是不可接受的。


          基于這兩個原因,當下不應該再選擇這種通信方式。


          方式二:彈窗攔截(alert/confirm/prompt)- 無明顯短板,需要序列化參數,支持同步返回數據。


          客戶端可以攔截 JS 這三個方法的調用,JS 側需要選擇一個業務不常用的一個方法,避免和業務發生沖突。


          JS 側發起如下的調用:


          const data={
              module: 'base',
              action:'getUserInfo',
              params:'xxxx',
              callbackId:'xxxx',
          };
          const jsonData=JSON.stringify([data]);
          // 發起調用,可以同步獲取調用結果
          const ret=prompt(jsonData);


          安卓:onJsPrompt 攔截:


          @Override
          public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
              //1 根據傳來的字符串反解出數據,判斷是否是所需要的攔截而非常規H5彈框
              if (是){
                // 2 取出指令參數,確認要發起的native調用的指令是什么
                // 3 取出數據參數,拿到JS傳過來的數據
                // 4 根據指令調用對應的native方法,傳遞數據
                return true;
              }
              return super.onJsPrompt(view, url, message, defaultValue, result);
          }


          iOS WKWebView:webView:runJavaScriptTextInputPanelWithPrompt:balbala 攔截:


          - (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(nullable NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString * _Nullable result))completionHandler{
              // 1 根據傳來的字符串反解出數據,判斷是否是所需要的攔截而非常規H5彈框
              if (是){
                  // 2 取出指令參數,確認要發起的native調用的指令是什么
                  // 3 取出數據參數,拿到JS傳過來的數據
                  // 4 根據指令調用對應的native方法,傳遞數據
                  // 直接返回JS空字符串
                  completionHandler(@"");
              }else{
                  //直接返回JS空字符串
                  completionHandler(@"");
              }
          }


          這種通信方式沒有明顯的短板,而且還支持同步調用獲取結果,唯一的缺點是不支持直接傳遞對象,需要序列化數據,在高頻/大數據量通信的場景可能有一些性能上的損耗。


          方式三:JSContext 注入 - 能力強大,遺憾的是只有 UIWebview 支持。不推薦使用


          //準備要傳給native的數據,包括指令,數據,回調等
          const data={
              module: 'base',
              action:'getUserInfo',
              params:'xxxx',
              callbackId:'xxxx',
          };
          //直接使用這個客戶端注入的函數
          nativeObject.getUserInfo(data);


          由于 WKWebview 不支持,這里不展開討論了。


          方式四:安卓 addJavascriptInterface - 目前推薦的方案,具備 JSContext 注入的所有優點(限安卓 4.2 以上版本)


          安卓可以在 loadUrl 之前 WebView 創建之后,即可配置相關注入功能,注入后 JS 可以直接調用掛載在 nativeObject 上的所有方法:


          // 通過addJavascriptInterface()將Java對象映射到JS對象
          //參數1:Javascript對象名
          //參數2:Java對象名
          mWebView.addJavascriptInterface(new AndroidtoJs(), "nativeObject");


          JS 調用:安卓注入的對象掛載在全局,直接調用接口。


          nativeObject.getUserInfo("js調用了android中的getUserInfo方法");


          這種通信方式的優勢在于,沒有參數的限制,可直接傳對象,無需序列化。同時也支持同步返回結果。


          方式五:WKWebView MessageHandler 注入 - 官方欽點的通信 API,無需 JSON 化傳數據,不丟消息,但不支持同步返回。


          不同于安卓注入到 JS 全局上下文,iOS 只能給注入對象起一個名字(這里已 nativeObject 為例),同時調用方法只能是 postMessage,所以在 JS 端只能是如下調用:


          //準備要傳給native的數據,包括指令,數據,回調等
          const data={
              module: 'base',
              action:'getUserInfo',
              params:'xxxx',
              callbackId:'xxxx',
          };
          //傳遞給客戶端,不支持同步獲取結果
          window.webkit.messageHandlers.nativeObject.postMessage(data)


          客戶端接收處理:


          -(void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message{
              //1 解讀JS傳過來的JSValue  data數據
              NSDictionary *msgBody=message.body;
              //2 取出指令參數,確認要發起的native調用的指令是什么
              //3 取出數據參數,拿到JS傳過來的數據
              //4 根據指令調用對應的native方法,傳遞數據
          }


          從調用方式就可以看出,在 iOS 端不能同步拿到調用接口,天然是異步的。


          最佳方式


          通過以上分析,JS -> Native 當下選擇如下的通信方式是最合適的:

          • iOS:推薦使用 MessageHandler + prompt 攔截兩個方案并存,同時實現異步和同步調用。
          • Android:addJavaScriptInterface 能力強大,使用很方便,當下沒有任何缺點。


          2.2 Native -> JS


          講完了 JS -> Native,Native 如何調用 JS 呢?其實就是客戶端直接執行 JS 代碼,將 JS 代碼(字符串)交給 JS 引擎執行。已有方案如下,根據版本選擇即可:

          1. iOS: evaluatingJavaScript。
          2. 安卓: 其實 2 個區別不大,使用方法差異也不大:
            • 4.4 以上 evaluatingJavaScript。
            • 4.4 以下 loadUrl。


          具體是如何調用的呢?假設 JS 上下文存在如下的全局函數。


          function calljs(data){
              console.log(JSON.parse(data)) 
              //1 識別客戶端傳來的數據
              //2 對數據進行分析,從而調用或執行其他邏輯  
          }


          客戶端想要調用這個函數,需要字符串拼接出 JS 代碼,并帶上要傳遞的數據:


          //不展開了,data是一個字典,把字典序列化
          NSString *paramsString=[self _serializeMessageData:data];
          NSString* javascriptCommand=[NSString stringWithFormat:@"calljs('%@');", paramsString];
          //要求必須在主線程執行JS
          if ([[NSThread currentThread] isMainThread]) {
              [self.webView evaluateJavaScript:javascriptCommand completionHandler:nil];
          } else {
              __strong typeof(self)strongSelf=self;
              dispatch_sync(dispatch_get_main_queue(), ^{
                  [strongSelf.webView evaluateJavaScript:javascriptCommand completionHandler:nil];
              });
          }


          客戶端最終拼接出的代碼其實只有一行,當然無論多么復雜的 JS 代碼都可以用這種方式讓 Webview 執行:


          calljs('{data:xxx,data2:xxx}');


          安卓 4.4 以下沒有 evaluatingJavaScript,只有 loadUrl,但其執行方式和 evaluatingJavaScript 沒有本質的差異,其調用方式如下:


          mWebView.loadUrl("javascript:calljs(\'{data:xxx,data2:xxx}\')");


          通過直接執行代碼的方式,就達到了 Native 數據向 JS 傳遞的目的。


          2.3 JSBridge SDK 設計


          確定了底層通信 API,我們還需要設計一套 SDK 來處理兩端的通信,SDK 要滿足以下要求:


          1. 平臺無關:兩端的通信機制是有差異的,但對上層業務來說不需要關心這些差異;SDK 是純 JS 邏輯的封裝,和上層使用的業務框架無關(Vue / React 等均支持)
          2. 易用性:接入簡單,通過 npm 安裝后即可使用;有一定語義化的封裝,比如查詢設備信息,可以直接調用 sdk.getSystemInfo,而不用先去建立底層的通信;API 同時支持 Promsie / Callback 兩種調用風格等
          3. 可擴展:SDK 除了要有良好的模塊劃分,還需要可擴展,為后續功能迭代打下基礎


          我們通過三個具體的使用場景來思考如何設計 JSBridge SDK:

          場景一:JS 查詢設備信息

          這種場景本質是 JS 調用 Native 的一個函數,Native 收到請求后,把數據回傳給 JS。整個過程分為 JS -> Native、Native -> JS 兩個階段,其調用流程如下:


          Native -> JS 時,涉及到 Webview 調用 JS 的全局函數,為了避免暴露過多全局變量,設計時我們只暴露全局唯一對象,然后再將相關的方法掛載在這個對象上。核心代碼如下:


          const invokeMap=new Map();
          let invokeId=0;
          
          
          class BridgeNameSpace {
            /**
             * 調用Native功能
             * @param eventName - 事件名稱
             * @param params - 通訊數據
             * @param callback - 回調函數
             */
            invoke=(eventName, params, callback)=> {
              invokeId +=1;
              invokeMap.set(invokeId, callback);
          
          
              if (isAndroid) {
                window.BridgeNameSpace.invokeHandler(eventName, params, invokeId);
              } else {
                window.webkit.messageHandlers.invokeHandler.postMessage({
                  event: eventName,
                  params,
                  callbackId: invokeId,
                });
              }
            };
            
            /**
             * 調用Native功能
             * @param eventName - 事件名稱
             * @param params - 通訊數據
             * @param callback - 回調函數
             */
            invokeSync(eventName, params, callback) {
              invokeId +=1;
              invokeMap.set(invokeId, callback);
          
          
              if (isAndroid) {
                window.BridgeNameSpace.invokeHandler(eventName, params, invokeId);
              } else {      // 將消息體直接JSON字符串化,調用 Prompt(),并且可以直接拿到返回值        
                const result=prompt(JSON.stringify(params));
                return result;
              }
            }
            
            /**
             * Native將invoke結果返回給js的回調句柄
             * @param id - callbackId
             * @param params - 通訊數據
             */
            invokeCallbackHandler=(id, params)=> {
              const fn=invokeMap.get(id);
              if (typeof fn==='function') {
                fn(params);
              }
              invokeMap.delete(id);
            };
            
            getSystemInfo(callback) {
              const promsie=new Promise((resolve, reject)=> {
                this.invoke('getSystemInfo', {}, (res)=> {
                  if (res.status==='success') {
                    resolve(res);
                  } else {
                    reject(res);
                  }
                });
              });
          
          
              if (callback) {
                return promsie.then(callback).catch(callback);
              }
          
          
              return promsie;
            }
          }
          
          
          window.BridgeNameSpace=new BridgeNameSpace();


          整個流程分為以下幾個調用步驟:

          1. JS 調用 invoke,生成一個唯一的 callbackId,將 callbackId 和 callback 注冊到全局變量 invokeMap 中。
          2. iOS 端,JS 將參數通過 MessageHandler 傳遞給 Native;安卓通過 Interface 注入的方式,JS 可以直接調用 Native 的方法。
          3. Native 執行業務邏輯,并調用回調函數 BridgeNameSpace.invokeCallbackHandler。
          4. 通過調用時生成的唯一的 callbackId, 從 invokeMap 中找到最初發起調用的 JS callback,執行并回傳數據。


          業務方調用支持 Promise 和 callback 兩種調用風格:


          BridgeNameSpace.getSystemInfo()
              .then(res=> {
                  console.log(res);
              })
              .catch(err=> {
                  console.log(err);
              });
          
          
          BridgeNameSpace.getSystemInfo((res)=> {
              console.log(res);
          });


          場景二:當 Webview 可見時,JS 捕獲這個時機來做相應的業務邏輯


          這里只涉及 Native 單向通知到 JS,是標準的發布訂閱模式。Native 和 JS 側約定好事件名,JS 側提前注冊事件,當事件發生時,Native 主動調用 JS。核心實現如下:


          const publishMap={};
          
          
          class BridgeNameSpace {
            /**
             * 訂閱 Native 事件
             * @param eventName - 事件名
             * @param callback - 回調函數
             */
             subscribe=(eventName, callback)=> {
              if (!publishMap[eventName]) {
                publishMap[eventName]=[];
              }
              
              const oldEvents=publishMap[eventName];
              publishMap[eventName]=oldEvents.concat(callback);
            };
            /**
             * Native將publish結果返回給js的回調句柄
             * @param eventName - 事件名
             * @param params - 調用參數
             */
            subscribeCallbackHandler=(eventName, params)=> {
              const cbs=publishMap[eventName] || [];
              if (cbs.length) {
                cbs.forEach((cb)=> cb(params));
              }
            };
            
            /**
             * ??可?通知
             */
            onPageVisible(callback) {
              this.subscribe(
                'onPageVisible',
                callback,
              );
            }
          }


          業務方訂閱示例:


          BridgeNameSpace.onPageInvisible(()=> {});


          不同于 JS 主動調用 Native 函數,訂閱不能直接拿到結果,所有沒有 Promise 調用風格,只能是 callback 形式。實際在設計 API 時,可以從命名上做一些區分,比如訂閱類型的函數都以 onXX 開頭。同時,映射表也由單獨 publishMap 來維護。


          場景三:打開了兩個 Webview 頁面 A B,B 頁面向 A 頁面傳遞一些數據


          對于 JS 來說,只能獲取到當前 Webview 上下文,單純通過 JS 是不能感知到其他 Webview 存在的。所以兩個 Webveiw 之間要通信,需要借助 Native 做中轉,其通信模型如下:
          (一個 App 內在使用多套框架時,不同框架之間通信也可以基于這個模型)



          Webview 之間通信分為三個步驟:

          1. Webview A 訂閱事件,不同于場景二的訂閱模式,訂閱結果需要維護在 Native,所以這里需要有一次 JS -> Native 調用。
          2. Webview B 發起通知,先通知到 Native,這里也有一次 JS -> Native 調用。
          3. Native 收到通知后,發起一次廣播,之前所有注冊過的 Webview 都會收到通知,這里有一次 Native -> JS 調用。


          那么如何來設計這個通信模型呢?

          1. JS -> Native 訂閱其實就是一次基本的 JS -> Native 函數調用,這里需要約定一個特定的事件名。
          2. JS -> Native 通知同理,也需要約定一個特定的事件名。
          3. Native -> JS 廣播,是類似于 invokeCallbackHandler、subscribeCallbackHandler 的回調調用,我們也用一個 notifyMap 來維護這個映射關系。


          const notifyMap=new Map();
          
          
          class BridgeNameSpace {
            /**
             * 混合式框架向Native發送通知 notify
             * @param eventName - 事件名,命名空間為當前包
             * @param params - 參數對象,由通知業務自己定義
             * @param callback - 回調函數,回調是否通知成功
             */
            notify=(eventName, params, callback)=> {
              this.invoke('notify', { event: eventName, params }, callback);
            };
          
          
            /**
             * webview 事件處理函數,可與notify配合使用
             * 事件訂閱方法,可對本應用及跨應用事件進行訂閱
             * @param {String} eventName
             * @param {Function} callback
             */
            subscribeNotify=(eventName, callback)=> {
              this.invoke('subscribeNotification', { event: eventName }, (res)=> {
                if (res.status==='success') {
                  notifyMap.set(eventName, callback);
                } else {
                  callback(res);
                }
              });
            };
          
          
            /**
             * Native將notify結果返回給js的回調句柄
             * @param eventName - 事件名
             * @param params - 調用參數
             */
            notifyCallbackHandler=(eventName, params)=> {
              const fn=notifyMap.get(eventName);
              if ('function'===typeof fn) {
                fn(params);
              } else {
                notifyMap.delete(eventName);
              }
            };
          }


          業務代碼調用示例:


          // Webview A 訂閱
          BridgeNameSpace.subscribeNotify(
            'QSOverlayPlayerBackClick', 
            (res)=> {
              console.log(res);
            }
          );
          
          
          // Webview B 通知
          BridgeNameSpace.notify(
            'QSOverlayPlayerBackClick',
            { test: 'a' },
            (res)=> {
              if (res.status==='success') {      console.log('通知成功');
               }
            }
          );


          第三種場景算是較復雜的場景,實際業務中也較常用,需要兩個或多個 Webview 來配合使用。


          總結


          實際在設計時,還有一些細節上的考量,可根據實際情況做一些規范化要求:


          1. 不同環境的兼容適配(比如瀏覽器、微信、不同的 App 訪問等)。
          2. 按模塊職責進行劃分,比如基礎、路由、網絡、UI 等。
          3. 規范函數命名:Native 回調均命名為 xxCallbackHandler、不支持 promise 風格調用的函數均已 onXX 開頭。


          核心設計思路主要是基于底層通信模型,上層做語義化的封裝,按模塊職責進行劃分,進而達到易用、易管理等目的。

          03 離線包方案

          對于 H5 來說,大量時間消耗在網絡請求,資源下載階段,如果 Native 在加載 H5 時,直接從本地讀取資源,再配合緩存數據,就可以大大提升 H5 的首屏速度。

          對于前端來說,我們希望直接把 HTML/JS/CSS/Image 等資源直接部署到 CDN,任何地方直接通過 https://domain.com/path/index.html 訪問,在 App 內訪問具備離線能力,普通瀏覽器則是在線訪問。

          該如何實現呢?這里的關鍵在于如何關聯訪問地址和本地的離線包資源。前端項目構建后,除了將資源部署到 CDN,還需要將構建產物打包成zip包,上傳到離線包管理平臺,通過唯一 pid 來標識,在 App 內訪問時帶上 pid=xxx,Webview 優先從本地離線資源目錄查找相關資源,找到了直接返回,找不到則在線訪問。整體流程如下:

          這里面還有非常多的細節:zip 內文件是什么樣的格式、管理平臺如何管理離線包、App 如何更新/加載離線包。下面我們介紹一種可能的方案。

          3.1 離線包構建

          這里以前端 SPA 項目為例,Vue/React 應用構建產物一般是如下格式:


          build
          ├── index.html
          └── static
              ├── css
              │   ├── main.f855e6bc.css
              ├── js
              │   ├── 787.d4aba7ab.chunk.js
              │   ├── main.8381e2a9.js
              └── img
                  └── arrow.80454996.svg


          通常部署時,我們會把 js/css/img 等資源部署到 CDN,通過設置 publicPath,index.html 中引用的地址會是 cdn 地址。


          不同框架構建產物格式會有些許的差別,這種差異對 App 來說是不可接受的,我們需要約定一種統一的離線包格式,只要符合這個約定的 zip 包,都可以是離線包。這個約定我們稱之為離線包協議

          3.1.1 離線包協議

          我們約定,一個離線包包含如下的關鍵文件:

          1. page-frame.html,頁面的入口文件。
          2. config.json 頁面配置文件,包含 Webview 容器的一些配置項,下面會單獨介紹。
          3. 其他 js/css/img 等資源路徑不作要求,因為構建時會自動處理好文件引用路徑(即使有設置 publicPath,路徑中也只是多了publicPath 一層路徑)。
          zip
          └── page-frame.html
          ├── config.json
          ├── css
          │   ├── main.f855e6bc.css
          ├── js
          │   ├── 787.d4aba7ab.chunk.js
          │   ├── main.8381e2a9.js
          └── img
              └── arrow.80454996.svg


          像 Vue-cli、Webpack 等構建工具一般來說都提供了構建 hook,可以在構建完成時,將構建產物修改為符合離線包協議的產物,再進行打包。

          3.1.2 包配置數據

          上面提到,每個離線包有一個 config.json 文件,里面有一些 Webview 容器相關的配置項,那具體有什么配置呢?

          Webview 本身有一些基本的屬性,比如主題色,是否透明,是否使用 Native 導航頭(為了統一 App 風格,大部分頁面使用 Native導航頭;有時設計為了追求全屏效果,又需要隱藏 Native 導航頭),有時同一個包的不同頁面有不同的風格,需要單獨配置。所以整個配置分為 global 和 pages 兩部分,pages 的配置優先級高于 global。

          {
            "global": {
              "showNavigationBar": false,
              "themes": {
                "black": {
                  "backgroundColor": "#0a0c0e"
                },
                "white": {
                  "backgroundColor": "#FFFFFF"
                }
              }
            },
            "pages": {
              "index": {
                "showNavigationBar": false
              },
              "detail": {
                "showNavigationBar": true,
                "themes": {}    }
            }
          }


          實際配置項可根據業務場景進行設計。

          3.1.3 單工程單包/多包

          現代前端 SPA 應用通常都很復雜,將所有構建產物打包成一個離線包不具備通用性,有時需要將部分產物打進離線包,有時需要將一個項目工程構建出多個離線包。這里提供一種打包思路:

          項目增加一個構建配置文件,配置文件描述了每個頁面的離線包配置信息,還有很重要的一點,需要控制離線包的大小,每個頁面對應的離線包不能包含其他頁面的代碼,需要有“tree shake”掉非當前頁面代碼的能力。

          實際構建時需要根據一定的規則,比如根據頁面路由來決定當前頁面包含哪些代碼。這種方案會侵入到打包流程,可能需要通過 loader 和規則來做一些刪除代碼的工作,相對來說會復雜一些。但本身來說一個項目工程構建出多個離線包就是一個相對復雜的事,需要根據實際情況來設計打包流程。

          [{
              name: 'https://domain-one.com/path/page-frame.html',
              test: function(options) {
                const {
                  path
                }=options;
          
          
                return /NewsTZBD/i.test(path);
              },
              config: {
                global: {
                  showNavigationBar: false,
                  themes: {
                    panda: {
                      backgroundColor: "#f5f6fa",
                    },
                    black: {
                      backgroundColor: "#12161f",
                    },
                    blue: {
                      backgroundColor: "#f5f6fa",
                    }
                  },
                },
                pages: {
                  index: {
                    showNavigationBar: false,
                  },
                },
              },
            }, {
              name: 'https://domain-two.com/path/page-frame.html',
              config: {},
          }]

          3.2 離線包管理


          講完了離線包的構建,離線包后續如何管理/更新/使用是關鍵的一環,下面分三個部分來介紹。

          3.2.1 離線包版本

          離線包每次發布后,都會生成一條記錄,有一些基本的屬性來標識本條記錄:

          1. pid:和頁面訪問地址一一對應。
          2. verify_code:pid 和訪問地址的加密校驗碼,訪問帶 pid 的 url 時,需要做一些安全校驗。
          3. pkg_md5:離線包 md5 值,用于校驗離線包本身是否被篡改。
          4. gray_rule:灰度規則。
          5. pkg_url:離線包 cdn 地址。
          6. sdk: 依賴的 App 最低版本,和 app 版本有一一對應的關系。
          7. status:發布狀態(未發布、灰度發布、全量)。
          8. comment:本次發布描述。
          9. author: 發布人。

          3.2.2 離線包更新

          對于離線包的使用一般有這樣的一些訴求:

          1. 最新離線包:離線包更新盡可能快
          2. 資源離線化:盡可能使用本地資源
          3. 高命中率:重要的模塊,通過預下載,可以大大提高離線包命中率

          要滿足以上訴求,核心是控制離線包的更新時機。

          離線包的下載分為兩部分:離線包配置表管理和離線包下載。整體流程如下:

          App 啟動時,會去拉取一個離線包配置表,配置表記錄了當前 App 版本對應的所有最新離線包,主要包含以下信息:

          1. 離線包優先級。
          2. 離線包 CDN 地址。
          3. 離線包校驗參數。

          為了保證及時拉取到最新的離線包版本,配置表有一些更新時機:

          1. App 啟動時。
          2. N 分鐘內 App 激活更新。

          離線包的預下載主要依賴配置表,在合適的時機,如 App 首頁渲染完成后,提前下載高優先級離線包。

          除了預下載離線包,非高優離線包首次訪問時,優先在線訪問,同時啟動異步加載。當然根據業務需求,提供下載指定離線包的 API,業務側可以在合適的時機提前下載。

          3.2.3 訪問頁面

          在 App 如何打開一個 H5 頁面呢,打開頁面會經歷哪些步驟,和普通瀏覽器打開 H5 有哪些差別?


          不同于 SPA 應用,App 內頁面堆棧需要符合 App 規范,我們仍然可以按 SPA hash 路由的方式來渲染頁面,但每個路由對應一個新開的 Webview,在頁面回退時其實是關閉了當前 Webview。

          新開 Webview 需要調用 Native 能力,標準的 Native 函數調用,可以如下來調用:

          class BridgeNameSpace {
            /**
             * @params{Object} params 傳遞數據 { url, p_showNav}
             * params.url
             */
            navigateTo(params) {
              this.invoke('navigateTo', params, ()=> {
              // 
              })
            }
          }
          
          
          const url='https://domain.com/path/index.html?pid=xxx#/index';
          
          
          BridgeNameSpace.navigateTo({
            p_url: url,
            p_showNav: true,
          });

          整個 H5 打開流程還需要一些額外的安全校驗:

          1. 域名校驗,不支持非白名單內的域名。
          2. 離線包 md5 校驗,防止包被篡改。
          3. verify_code 校驗當前訪問地址和 pid 是否匹配。

          安全校驗失敗時可以采取一定的安全策略:比如合法域名的 H5 直接在線訪問,非白名單域名增加安全提醒等。

          前面提到,通常 H5 在打包時會設置 publicPath,這些資源是引用的 CDN 地址,我們同樣希望這些資源能使用本地資源。

          在 iOS 中可以使用 WKURLSchemeHandler 進行攔截,Native 攔截到地址后,需要解析出文件名(前端 js 、css 等資源通常帶了 md5 值,可以唯一標識),然后根據文件名去本地查找,如果找到了可直接返回。需要注意的是,這個 API 需要 iOS 11+ 以上才支持。

          3.3 版本控制

          每個離線包都需要知道最小支持 App 版本,JS 調用的 JSBridge 方法,需要對應版本的 App 去實現,所以版本控制非常重要。版本控制分為兩部分:

          1. 離線包構建時需要明確支持的最高 App 版本,版本信息可以放到項目工程配置文件里。
          2. App 在拉取配置文件/拉取單個離線包時,后臺根據當前 App 版本及灰度規則返回正確的離線包。

          在設計時,離線包版本通過一個虛擬的版本號(這里表示為 SDK@ver)來對應 App 版本,這樣好處是 SDK 可以映射不同端 App 版本(iOS、Android、鴻蒙 App 版本號不一致),App 版本和 SDK 版本號解耦。


          以如下場景為例:

          一個 H5 頁面,分別對應三個 App 版本均部署了離線包,其中對應 App@10.1.0 的離線包處于灰度狀態。

          當我們用 App@10.1.0 去拉取離線包時,應該返回什么版本呢?

          1. 首先 sdk2.3.0 對應的離線包不能返回,因為它們要求最小支持 App 版本是 10.2.0,一旦返回了可能導致有些 API 調用失敗,App@10.1.0 上沒有對應的實現。
          2. 如果命中了灰度,則返回 sdk@2.2.9 下的離線包版本 1。
          3. 如果未命中灰度,則返回 sdk@2.2.8 下的離線包版本 1,JSBridge SDK 通常是向下兼容的,低版本離線包調用的 JSBridge API 高版本的 App 都支持。


          不同版本的 App 去拉取離線包時,從最高支持的 App 版本依次往下匹配離線包,直到找到最新的離線包版本。


          04 容器基礎能力


          為了更高效地進行業務開發,Webview 容器還需要提供一些基礎能力:

          1. Native UI 組件:Toast、Loading。
          2. 內嵌 Native 能力:Native Header、分享面板、下拉刷新。

          4.1 Native UI 組件

          通常來說,前端有自己的 UI 組件庫,希望做到“一碼多端”。但 App 和 H5 有較大的體驗差異,部分基礎組件,前端和 Native 不容易對齊,如 Toast、Loading,可以通過 JSBridge 直接調用 Native 組件:

          class BridgeNameSpace {
            /**
             * 顯示toast
             * @param {String} position 彈出位置,center(中間),top(頂部)
             * @param {String} text 要提示的?字
             */
            showToast(position, text, callback) {
              this.invoke('showToast', { position, text }, callback);
            },
            
            /**
             * loading view控制 loadingBar
             * @param {String} action: show/hide, 控制顯示/隱藏
             */
            loadingBar(action, callback) {
              this.invoke('loadingBar', { action }, callback);
            }
          }

          4.2 內嵌 Native 能力

          一個典型的頁面通常由這些部分組成:頁頭+刷新區域+主內容區+分享面板等。我們以它來剖析如何規劃前端和 Native 的職責。

          4.2.1 頁頭

          主流容器的頁頭均使用 Native Header 來實現,比如微信、美團、百度等,這么做可能有以下考慮:

        1. 統一 App 風格,做到一致的交互體驗。
          1. JS 異常導致白屏時,防止 App 陷入假死狀態,Native Header 可以控制頁面后退。

          不使用 Native Header 的好處,其實就是提供最大限度的靈活性,整個頁面都可以由 JS 來實現。同時部分業務場景,在設計上有特殊要求,需要做到“全屏“的效果。


          對于平臺化的 App ,基礎組件一旦依賴 Native,響應速度會變得非常慢,需要根據實際情況來做權衡:統一使用 Native Header,或者主要場景使用 Native Header,同時放開配置化能力,業務可以決定是否使用。


          使用 Native Header 也可以做到一定程度的配置化,能夠滿足大部分的業務場景。將整個 Header 分為三個區域:



          1. 左邊區域比較簡單,只有一個返回按鈕,關閉當前 Webview。
          2. 標題部分,可以設置標題和子標題,注意需要控制和 document.title 的關系。
          3. 功能區:可以設置分享、字體控件等入口。


          實際實現時,可以根據業務需要,設計靈活的配置參數:


          class BridgeNameSpace {
            setHeaderConfig(config, callback) {
              this.invoke('setHeaderConfig', { 
                title: config.title,
                subTitle: config.subTitle,
                right: [{
                  actionName: 'font',
                }, {
                  actionName: 'share',
                  // 可傳入圖標,沒有使用系統默認的
                  icon: '',
                }]
               }, callback);
            },
            /**
            * 監聽按鈕點擊事件
            */
            onHeaderButtonClick(callback) {
              this.on('onHeaderButtonClick', callback);
            }
          }


          4.2.2 刷新區域




          上下拉刷新是一個常見的功能,一般包含:刷新動畫、提示文案兩部分。


          這里最核心的問題是,在 App 內我們希望有統一的交互體驗,盡管前端有自己的刷新控件,但主刷新控件包含一定復雜度的動畫,前端很難和 Native 動畫做到統一,所以最好直接使用 Native 控件。通過約定 API 來達到使用 Native 控件的目的:


          class BridgeNameSpace {
          /**
             * 啟用下拉刷新(默認關閉),前端仍然可以決定是否使用 Native 刷新控件
             * @param {Boolean} enabled 下拉刷新開啟標識
             * @param callback
             */
            enablePullDownRefresh(enabled, callback) {
              this.invoke('enablePullDownRefresh', { enabled }, callback);
            },
            /**
             * 下拉刷新,通過 API 調用即可觸發,和手動刷新一致
             * @function startPullDownRefresh
             */
            startPullDownRefresh(callback) {
              this.invoke('startPullDownRefresh', {}, callback);
            }
            
            /**
             * 下拉刷新完成調用,將收起下拉刷新條
             */
            stopPullDownRefresh(callback) {
              this.invoke('stopPullDownRefresh', {}, callback);
            },
            /**
             * 下拉刷新觸發通知
             * @param {Function} callback 回調函數
             */
            onPullDownRefresh(callback) {
              this.on('onPullDownRefresh', callback);
            }
          }


          4.2.3 主內容區



          主內容區其實沒有什么爭議,完全用 JS 來實現。涉及到 Native 能力的部分,通過 JSBridge 來調用即可。


          4.2.4 分享面板




          分享面板有其特殊性,一般來說呼起分享面板要有全局的遮罩(蓋住前端+Native內容),這就必須通過 Native 來實現。一些 Native 頁面也有分享功能,兩端可以復用邏輯。


          不同的業務場景,面板呈現的內容不同。在兼顧動態化和易用性有如下的設計思路:


          1. 常用的功能點,比如分享到微信、QQ,我們考慮封裝到 Native 模塊內部,直接通過 API 調用即可,方便業務快速接入使用。
          2. 不常用的功能模塊(比如復制鏈接、設置皮膚等),通過傳入參數控制,做到靈活配置化。


          05 開發調試


          一個離線包從開發到正式發布,不同階段有不同的訴求:


          1. 開發階段:開發階段能夠熱更新,實時查看改動效果,突出快。
          2. 發布前:測試環境、預發布環境充分驗證,需要環境切換能力。
          3. 正式發布:驗證最終效果是否符合預期,需要環境切換能力。


          下面我們用兩部分來講解如何做的。


          5.1 本地開發


          混合式開發和 H5 開發并沒有太大的區別,唯一區別是調用 JSBridge 時,需要用真機進行調試。


          首先在 App 上實現一個調試界面,主要包含以下功能:


          1. 掃碼:可以掃任意的 http(s) 協議地址,可以是 CDN 地址,也可以是同網段的 ip 地址。
          2. 輸入框:支持手動輸入 URL。
          3. 打開按鈕:打開輸入框里面的地址。
          4. 導航開關:打開的頁面是否展示 Native Header。



          本地開發時,讓手機和電腦在同網段,真機掃碼訪問電腦本機服務地址即可(例如:ip:port/index.html#/index)。前端開發框架一般都具備熱更新能力,這種方式和在電腦上開發沒有本質區別。


          打開頁面后,因為在 App 環境內,H5 可直接調用 JSBridge ,非常方便。


          5.2 在線更新


          所謂在線更新,是指 H5 打包成離線包,上傳到管理平臺后,App 通過后臺接口拉取離線包,而不是直接訪問 H5 地址。


          對 App 來說,需要有多套環境:開發、測試、預發布、正式,離線包管理平臺需要有對應的環境嗎?其實不然,我們最好解耦兩者的關系。


          離線包管理平臺只有兩套環境:

          1. 測試環境:對應 App 開發、測試、預發布等非正式環境。
          2. 正式環境:對應正式環境。


          業務代碼中內置各環境對應 API 地址,運行時通過 JSBridge 獲取 App 當前的環境配置,這樣的好處是,離線包管理平臺不用關心 App 有幾套環境,兩套環境僅僅是為了測試、正式包的隔離。


          5.3 測試環境多版本問題


          默認情況下,App 只會更新當前版本對應最新離線包。同一個 App 版本,當某個離線包涉及多個需求并行開發時,測試沖突怎么辦?這里面臨的問題是,多人部署多個版本的離線包,相互存在覆蓋。


          這里提供一種解決方案:在離線包管理后臺,每個版本的離線包都有一個二維碼,App 掃碼后可以下載并使用該版本離線包。



          06 穩定性與安全


          這部分內容不再詳細闡述,主要介紹一下應該從哪些方面去考慮框架的穩定性與安全。


          6.1 資源校驗


          1. 資源安全性檢測:檢查離線包是否有被篡改,可以是包維度的檢查,也可以是針對具體的資源文件。
          2. 域名白名單:App 內加載的所有 H5 檢查域名是否是白名單之內。非白名單內的用戶限制調用 JSBridge,并做好相應的安全提示。


          6.2 穩定性方面


          打開一個頁面,有一些關鍵節點:下載離線包、解壓縮離線包、加載頁面、頁面渲染等。

          實際在生產中,我們發現每個節點都可能失敗。所以在整個流程中,有必要對每個節點做好容錯和監控,分析具體原因,進行長期的優化。


          6.3 安全容器


          1. 在一些特殊的業務場景,比如證券交易,容器需要限制不滿足合規要求的操作。
          2. 像微信小程序一樣,限制使用瀏覽器 API。


          07 番外篇


          Q:除了介紹 Hybrid 開發的原理,當下研究 Webview 還有哪些意義?

          A:同樣基于 Webview,微信小程序基于「管控」和「體驗」,設計了雙線程模型+離線包的架構,讓 Webview 體驗煥發新生。還有人說 Webview JS 組件和客戶端不能混排,但微信小程序通過同層渲染的方式解決了這個問題。脫離 JSBridge,上層應用還有很多種玩法,了解基本原理才能走得更遠。


          Q:技術的價值?

          A:近兩年一直在思考技術的價值,似乎做了什么,似乎什么也沒做。潛意識中,我希望在某個平平無奇的日子里,想到一個點子,做點不一樣的東西。就像小程序一樣,只是多加了一層webview,竟撐起萬億市值。


          08 總結


          讓我想起了六年前的一次面試,面試官問 JS 代碼在 Native 層到底如何執行,執行結果是如何回傳給 JS 的。臣妾做不到啊!現在我終于可以大膽的說,我不僅搞懂了,還知道如何設計一套框架。

          本篇文章的完成,離不開前人經驗的總結,甚至有部分代碼是直接參考,由于水平有限,歡迎多多交流指正。

          本篇文章的完成,離不開前人經驗的總結,甚至有部分代碼是直接參考,以下是主要參考鏈接:

          移動 H5 首屏秒開優化方案探討:https://blog.cnbang.net/tech/3477/

          70%以上業務由H5開發,手機QQ Hybrid 的架構如何優化演進?:https://mp.weixin.qq.com/s/evzDnTsHrAr2b9jcevwBzA

          合應用程序增長的最大原因是,您只需編寫一次混合移動應用程序,即可在每個平臺上運行它,而無需付出任何額外的努力。通過使用移動HTML5 UI框架提供的組件,將應用程序設計為常規Web應用程序,即適合不同屏幕尺寸的自適應Web應用程序。通過諸如Cordova(PhoneGap)提供的原生JavaScript API可以訪問諸如Camera或Accelerometer之類的原生硬件組件。然后,您的應用程序可以編譯到特定于平臺的版本包并發布到應用商店。


          Ionic

          在過去的幾年中,ionic確立了自己在混合移動應用程序開發領域的領導者的地位。Ionic團隊通過適應最新趨勢,在競爭對手中一直保持著快速穩定的更新。Ionic是免費使用和開源的,其生態系統已經變得相當龐大,您可以輕松地從社區中找到大量的開發資源,以立即開始使用。

          Ionic框架集成了Angular,這讓它的開發可以非常高效。與大多數其他混合應用程序框架一樣,Ionic也利用Cordova將其本地化為iOS,Android,Windows Phone和其他平臺。

          Ionic框架是可維護和可伸縮的,使用簡潔易讀的標記,并隨附高度移動優化的CSS(由Sass提供支持),HTML和js組件庫。它還具有豐富的工具和手勢,以確保輕松開發交互式應用程序。



          Onsen UI

          Onsen UI相對較新,但卻給Ionic帶來了激烈的競爭。它是開源的。Onsen UI的大多數UI組件也使用Angular指令和Topcoat框架。

          對于jQuery愛好者,它附帶了基于jQuery的組件。可以選擇不使用Angular而是使用jquery來構建混合應用程序。Onsen UI具有大量現成可用的組件,開箱即用的響應能力使您可以構建應用程序的移動,平板和桌面版本,允許您使用HTML5和JavaScript編寫應用程序并通過Phonegap和Cordova推送到本地化。

          Onsen UI帶來的易用性,靈活性,語義標記和性能給我個人留下了深刻的印象。

          Sencha Touch

          Sencha Touch是企業級產品,用于使用HTML5和JavaScript構建跨平臺的端到端移動Web應用程序。您可以將其稱為商業領域(主要是企業)中移動應用程序開發平臺的老大哥。Sencha有與Sencha Touch緊密配合的多種產品,其中大多數產品價格不菲。

          對于個人開發人員和自由職業者而言,Onsen UI將是一個更好的選擇,但對于企業而言,Sencha Touch可以輕松帶路。

          ExtJS是流行的JavaScript框架之一,它是Sencha touch平臺的核心,該平臺可幫助創建具有接近本機體驗的高性能應用程序。Sencha Touch可以打包用于所有主要平臺(包括iOS,android,Windows Phone和Blackberry)的具有原生外觀的小部件。Sencha還具有可拖放的HTML5可視化應用程序構建器,其中包含大量隨時可用的模板。還可以構建自定義組件并將其添加到庫中,以在各個應用程序之間重用。

          Kendo UI

          Kendo UI是用于使用HTML5,JavaScript和CSS構建任何類型的Web應用程序或混合移動應用程序的另一個框架。它高度依賴jQuery,并附帶了70多種隨時可以使用的jQuery小部件。Kendo UI也允許與Angular集成,并支持Bootstrap UI框架。


          Kendo UI還包含內置的即用型主題包,包括具有材質設計外觀和感覺的主題。還有一個主題生成器可用于創建自定義主題,這是Kendo UI框架的最大優勢之一。

          Kendo UI由Telerik維護和支持,并帶有開源和商業許可選項。當然,開放源代碼在功能上會受到一些限制,并且您不會獲得專門的技術支持。

          像Sencha一樣,Kendo UI在企業中也很受歡迎。它擁有龐大的客戶群,包括索尼,美國航空航天局,微軟,沃爾沃和東芝等公司。


          Framework 7

          Framework 7在iOS混合應用和移動應用程序開發中處于領先地位。雖然Framework 7不提供對構建跨平臺混合應用程序的支持。但這絕對是iOS混合應用程序開發的最佳,功能最豐富的框架。

          Framework 7的主要優點在于,它使開發人員能夠僅使用CSS,JavaScript和HTML來構建iOS應用程序,這是Web開發人員已經知道的技術。Framework 7非常接近本機iOS應用程序,甚至提供了開箱即用后退等功能。還有許多其他可供使用的UI元素,包括列表視圖,媒體列表,側面板,模式等,這些元素可以按原樣使用,而無需使用JavaScript。

          Famo.us

          Famo.us是另一個強大的html5應用程序開發框架,目標是在混合應用程序中提供近乎本機的體驗。famo.us和其他混合html5框架之間的主要區別在于,它更多地關注圖形渲染,2d和3d,因此更適合游戲開發。

          Trigger.IO

          Trigger.io有自己的平臺,可以彌補html5開發與本機訪問之間的差距。他們聲稱它產生的混合應用程序的性能要比基于Cordova的應用程序好得多。

          Trigger.io使用偽造的JavaScript API訪問像加速度計和攝像機等本地函數。

          Flutter

          從Github上來看,目前Flutter活躍用戶正在高速增長。Flutter的文檔、資源也越來越豐富,開發過程中遇到的很多問題都可以在Stackoverflow或其github issue中找到答案。

          現在Google正在大力推廣Flutter,Flutter的作者中很多人都是來自Chromium團隊,并且github上活躍度很高。另一個角度,從今年上半年Flutter頻繁的版本發布也可以看出Google對Flutter的投入的資源不小,所以在官方技術支持這方面,大可不必擔心。

          Flutter的熱重載可幫助開發者快速地進行測試、構建UI、添加功能并更快地修復錯誤。在iOS和Android模擬器或真機上可以實現毫秒級熱重載,并且不會丟失狀態。這真的很棒,相信我,如果你是一名原生開發者,體驗了Flutter開發流后,很可能就不想重新回去做原生了,畢竟很少有人不吐槽原生開發的編譯速度。

          React Native

          React Native產出的并不是“網頁應用”, 或者說“HTML5應用”,又或者“混合應用”。 最終產品是一個真正的移動應用,從使用感受上和用Objective-C或Java編寫的應用相比幾乎是無法區分的。 React Native所使用的基礎UI組件和原生應用完全一致。 你要做的就是把這些基礎組件使用JavaScript和React的方式組合起來。

          React Native完美兼容使用Objective-C、Java或是Swift編寫的組件。 如果你需要針對應用的某一部分特別優化,中途換用原生代碼編寫也很容易。 想要應用的一部分用原生,一部分用React Native也完全沒問題 —— Facebook的應用就是這么做的。

          uni-app

          uni-app 是一個使用 Vue.js 開發所有前端應用的框架,開發者編寫一套代碼,可發布到iOS、Android、H5、以及各種小程序(微信/支付寶/百度/頭條/QQ/釘釘)等多個平臺。

          案例多,插件豐富,App端支持weex原生渲染,可支撐更流暢的用戶體驗。


          總結

          混生道路千萬條,條條都是不歸路。雖然混生在開發效率和便捷性上有著超乎想象的能力,但是親生的就是親生的,這個是永遠不會改變的。

          我這里并不是說混生就不行,而是說各有各的好,有舍才有得,你舍棄了一些東西,必然上天會給你一些東西作為補償,有些東西只有親身經歷過才知道究竟好不好,否則道聽途說和親眼所見未必都是真的。

          著現代 CSS 技術的發展,CSS 新特性被越來越多的瀏覽器所支持,本文主要講述使用純 CSS 實現3D導航欄的步驟,并對其中用到的關鍵樣式做一個解析。

          1.整體效果

          2.實現方案

          實現方案將從一個基礎的樣式寫起,然后逐漸添加響應的偽元素來實現不同的邊,實現3D效果。與此同時,實現的過程中還給導航設置了動畫,方便鼠標 hover 的時候有個更好地用戶體驗。

          2.1.基礎框架編寫

          小懶首先通過 html:5 快速創建 html5 頁面基礎框架,然后通過 schema div[class=container]>ul[class=navlist]>(li>a[href=#])*5 快速創建導航 html 框架。同時給基礎框架增加了基礎樣式,樣式中我們使用了現代 CSS 的一些特性,比如 CSS 元素嵌套(插入鏈接)、CSS 自定義屬性等新的特性。

          <style>
            :root {
              --color: #4855B0;
            }
            body { margin: 0; padding: 0}
            .container {
              display: flex;
              justify-content: center;
              align-items: center;
              padding: 20px;
              .navlist {
                list-style: none;
                padding: 0;
                & li {
                  & a {
                    display: block;
                    padding: 15px 25px;
                    background-color: var(--color);
                    color: #fff;
                    text-decoration: none;
                    &:hover {
                      --color: #f00;
                      left: -20px;
                    }
                  }
                }
              }
            }
          </style>
          <!--div[class=container]>ul[class=navlist]>(li>a[href=#])*5-->
          <div class="container">
            <ul class="navlist">
              <li><a href="#">首頁</a></li>
              <li><a href="#">用戶管理</a></li>
              <li><a href="#">菜單管理</a></li>
              <li><a href="#">日志管理</a></li>
              <li><a href="#">權限管理</a></li>
            </ul>
          </div>
          

          效果如下:

          2.2.3D效果實現

          為了實現 3D 效果,需要旋轉對各面做傾斜變化,正面需要Y軸傾斜 -15deg,側面需要Y軸傾斜 45deg,頂面需要X傾斜 45deg。側面和頂面我們使用 CSS 偽元素 ::before::after 來實現。在CSS 實現中背景顏色我們使用 color-mix 顏色混合函數來自動計算背景顏色。

          // 正面
          a {
            transform: skewY(-15deg);
          }
          a {
            &::before {
              position: absolute;
              left: -20px;
              bottom: 10px;
              width: 20px;
              height: 100%;
              content: "";
              background-color: color-mix(in srgb, var(--color), black 20%);
              transform: skewY(45deg);
              transition: background-color 200ms;
            }
            &::after {
              position: absolute;
              left: -10px;
              top: -20px;
              width: 100%;
              height: 20px;
              content: "";
              background-color: color-mix(in srgb, var(--color), black 20%);
              transform: skewX(45deg);
            }
          }
          

          效果圖如下:

          從上面效果圖可以看到,3D效果已經實現,但是頂面和正面的層級還是有點問題,以至于效果看著比較別扭,我們再整體調試一節中將調試細節。請注意:color-mix 函數雖然得到大多數現代瀏覽器的支持,但是在生成環境中請謹慎使用。

          2.3.整體調試

          1)首先對導航的各項做了層級定義:

          & li {
            &:nth-child(1) {
              & a {
                z-index: 5;
              }
            }
            &:nth-child(2) {
              & a {
                z-index: 4;
              }
            }
            &:nth-child(3) {
              & a {
                z-index: 3;
              }
            }
            &:nth-child(4) {
              & a {
                z-index: 2;
              }
            }
            &:nth-child(5) {
              & a {
                z-index: 1;
              }
            }
          }
          
          1. 對頂面的偽元素設置層級
          &::after {
            z-index: -1;
          }
          
          1. 給各面定義動畫
          & a {
            transition: left 200ms, background-color 200ms;
            
            &::before {
              transition: background-color 200ms;
            }
            &::after {
              transition: background-color 200ms;
            }
          }
          

          4)整體實現代碼

          <style>
            :root {
              --color: #4855B0;
            }
            .container {
              display: flex;
              justify-content: center;
              align-items: center;
              padding-top: 150px;
              .navlist {
                list-style: none;
                padding: 0;
                transform: skewY(-15deg);
                & li {
                  &:nth-child(1) {
                    & a {
                      z-index: 5;
                    }
                  }
                  &:nth-child(2) {
                    & a {
                      z-index: 4;
                    }
                  }
                  &:nth-child(3) {
                    & a {
                      z-index: 3;
                    }
                  }
                  &:nth-child(4) {
                    & a {
                      z-index: 2;
                    }
                  }
                  &:nth-child(5) {
                    & a {
                      z-index: 1;
                    }
                  }
                  & a {
                    position: relative;
                    left: 0;
                    display: block;
                    padding: 15px 25px;
                    background-color: var(--color);
                    color: #fff;
                    text-decoration: none;
                    transition: left 200ms, background-color 200ms;
                    
                    &::before {
                      position: absolute;
                      left: -20px;
                      bottom: 10px;
                      width: 20px;
                      height: 100%;
                      content: "";
                      background-color: color-mix(in srgb, var(--color), black 20%);
                      transform: skewY(45deg);
                      transition: background-color 200ms;
                    }
                    &::after {
                      position: absolute;
                      left: -10px;
                      top: -20px;
                      width: 100%;
                      height: 20px;
                      content: "";
                      background-color: color-mix(in srgb, var(--color), black 20%);
                      transform: skewX(45deg);
                      transition: background-color 200ms;
                      z-index: -1;
                    }
                    &:hover {
                      --color: #f00;
                      left: -20px;
                    }
                  }
                }
              }
            }
          </style>
          <div class="container">
            <ul class="navlist">
              <li><a href="#">首頁</a></li>
              <li><a href="#">用戶管理</a></li>
              <li><a href="#">菜單管理</a></li>
              <li><a href="#">日志管理</a></li>
              <li><a href="#">權限管理</a></li>
            </ul>
          </div>
          

          3.總結

          現代 CSS 賦予了現代開發者更多的能力,之前需要使用 JavaScript 來解決的業務需求,現在可以通過純 CSS 來實現了,這對開發者是一大利好。有句話能用CSS能實現的,盡量不要用 JavaScript 來實現。


          主站蜘蛛池模板: 免费萌白酱国产一区二区| 亚洲Av永久无码精品一区二区| 日本一区二区三区日本免费| 国产成人欧美一区二区三区| 波多野结衣电影区一区二区三区 | 搜日本一区二区三区免费高清视频 | 精品国产一区二区三区香蕉事| 成人精品一区二区户外勾搭野战| 一色一伦一区二区三区 | 国产成人一区二区在线不卡| 国产成人无码精品一区在线观看| 精品久久一区二区三区| 日本一区二区三区爆乳| 一区二区三区无码高清| 午夜福利av无码一区二区| 亚洲AV香蕉一区区二区三区 | 久久精品国产一区二区电影| 亚洲国产日韩一区高清在线 | 国产精品视频一区国模私拍| 性色AV 一区二区三区| 国产精品丝袜一区二区三区| 亚洲综合一区无码精品| 亚洲av区一区二区三| 久久精品午夜一区二区福利| 视频一区在线免费观看| 亚洲美女视频一区二区三区 | 无码人妻精品一区二区三区东京热| 国产精品一区二区久久精品| 国产91精品一区二区麻豆亚洲| 乱子伦一区二区三区| 一区二区三区免费视频网站 | 日本中文一区二区三区亚洲| 精品人妻码一区二区三区| 亚洲国产成人精品久久久国产成人一区二区三区综 | 国产福利电影一区二区三区,亚洲国模精品一区 | 亚洲AV无码一区二区乱子仑| 国产一区美女视频| 精品一区二区在线观看| 亚无码乱人伦一区二区| 中文字幕一区二区三区免费视频| 精品亚洲AV无码一区二区三区 |