基於 Vue 和 TS 的 Web 移動端項目實戰心得

  • 2019 年 10 月 28 日
  • 筆記

作者:mcuking(杭州個推)

來源:https://juejin.im/post/5d759f706fb9a06afa32adec

筆者在公司用 web 技術開發移動端應用已經有一年多的時間了,開始主要以 vue 技術棧配合 native 為主,目前演進成 vue + react native 技術架構,vue 主要負責開發 OA 業務,比如報銷、出差、crm 等等,react native 主要負責即時通訊部分,是在 mattermost-mobile[1] 的基礎上修改的(mattermost 是一個開源的即時通訊方案)。

因為公司在這方面沒有太多技術沉澱,所以在開發期間遇到了很多坑,經過一年多的技術攻克積累,最終形成了這套比較完善的解決方案,總結出來希望能夠幫助到大家,尤其是對一些中小公司這方面經驗不足的(PS: 大公司估計有他們自己的一套方案了)。

好了廢話不多說,先亮下這個庫的 GitHub 地址,後面還會不斷完善,歡迎 star:

mobile-web-best-practice[2]

移動端 web 最佳實踐,基於 vue-cli3[3] 搭建的 typescript[4] 項目,可以用於 hybrid 應用或者純 webapp 開發。以下大部分內容同樣適用於 react[5] 等前端框架。

其中有三個點尚在完善中:領域驅動設計(DDD)應用、微前端、性能監控,後續完成後會以單獨的文章發出來。其中性能監控還沒有太好的選擇,類似錯誤監控 sentry 那種開源免費而且功能強大的工具,如果有人知道的麻煩告知下。文中難免有些錯誤或者更好的方案,也歡迎不吝賜教。

目錄

  • 組件庫[6]
  • JSBridge[7]
  • 路由堆棧管理(模擬原生 APP 導航)[8]
  • 請求數據快取[9]
  • 構建時預渲染[10]
  • Webpack 策略[11]
    • 基礎庫抽離[12]
  • 手勢庫[13]
  • 樣式適配[14]
  • 表單校驗[15]
  • 阻止原生返回事件[16]
  • 通過 UA 獲取設備資訊[17]
  • mock 數據[18]
  • 調試控制台[19]
  • 抓包工具[20]
  • 異常監控平台[21]
  • 常見問題[22]

組件庫

vant[23]

vux[24]

mint-ui[25]

cube-ui[26]

vue 移動端組件庫目前主要就是上面羅列的這幾個庫,本項目使用的是有贊前端團隊開源的 vant。

vant 官方目前已經支援自定義樣式主題,基本原理就是在 less-loader[27] 編譯 less[28] 文件到 css 文件過程中,利用 less 提供的 modifyVars[29] 對 less 變數進行修改,本項目也採用了該方式,具體配置請查看相關文檔:

訂製主題[30]

推薦一篇介紹各個組件庫特點的文章:

Vue 常用組件庫的比較分析(移動端)[31]

JSBridge

DSBridge-IOS[32]

DSBridge-Android[33]

WebViewJavascriptBridge[34]

混合應用中一般都是通過 webview 載入網頁,而當網頁要獲取設備能力(例如調用攝影機、本地日曆等)或者 native 需要調用網頁里的方法,就需要通過 JSBridge 進行通訊。

開源社區中有很多功能強大的 JSBridge,例如上面列舉的庫。本項目基於保持 iOS android 平台介面統一原因,採用了 DSBridge,各位可以選擇適合自己項目的工具。

本項目以 h5 調用 native 提供的同步日曆介面為例,演示如何在 dsbridge 基礎上進行兩端通訊的。下面是兩端的關鍵程式碼摘要:

Android端同步日曆核心程式碼,具體程式碼請查看與本項目配套的Android項目 mobile-web-best-practice-container[35]

public class JsApi {      /**       * 同步日曆介面       * msg 格式如下:       * ...       */      @JavascriptInterface      public void syncCalendar(Object msg, CompletionHandler<Integer> handler) {          try {              JSONObject obj = new JSONObject(msg.toString());              String id = obj.getString("id");              String title = obj.getString("title");              String location = obj.getString("location");              long startTime = obj.getLong("startTime");              long endTime = obj.getLong("endTime");              JSONArray earlyRemindTime = obj.getJSONArray("alarm");              String res = CalendarReminderUtils.addCalendarEvent(id, title, location, startTime, endTime, earlyRemindTime);              handler.complete(Integer.valueOf(res));          } catch (Exception e) {              e.printStackTrace();              handler.complete(6005);          }      }  }  

h5 端同步日曆核心程式碼(通過裝飾器來限制調用介面的平台)

class NativeMethods {    // 同步到日曆    @p()    public syncCalendar(params: SyncCalendarParams) {      const cb = (errCode: number) => {        const msg = NATIVE_ERROR_CODE_MAP[errCode];          Vue.prototype.$toast(msg);          if (errCode !== 6000) {          this.errorReport(msg, 'syncCalendar', params);        }      };      dsbridge.call('syncCalendar', params, cb);    }      // 調用 native 介面出錯向 sentry 發送錯誤資訊    private errorReport(errorMsg: string, methodName: string, params: any) {      if (window.$sentry) {        const errorInfo: NativeApiErrorInfo = {          error: new Error(errorMsg),          type: 'callNative',          methodName,          params: JSON.stringify(params)        };        window.$sentry.log(errorInfo);      }    }  }    /**   * @param {platforms} - 介面限制的平台   * @return {Function} - 裝飾器   */  function p(platforms = ['android', 'ios']) {    return (target: AnyObject, name: string, descriptor: PropertyDescriptor) => {      if (!platforms.includes(window.$platform)) {        descriptor.value = () => {          return Vue.prototype.$toast(            `當前處在 ${window.$platform} 環境,無法調用介面哦`          );        };      }        return descriptor;    };  }  

另外推薦一個筆者之前寫的一個基於Android平台實現的教學版 JSBridge[36],裡面詳細闡述了如何基於底層介面一步步封裝一個可用的 JSBridge:

JSBridge 實現原理[37]

路由堆棧管理(模擬原生 APP 導航)

vue-page-stack[38]

vue-navigation[39]

vue-stack-router[40]

在使用 h5 開發 app,會經常遇到下面的需求:從列表進入詳情頁,返回後能夠記住當前位置,或者從表單點擊某項進入到其他頁面選擇,然後回到表單頁,需要記住之前表單填寫的數據。可是目前 vue 或 react 框架的路由,均不支援同時存在兩個頁面實例,所以需要路由堆棧進行管理。

其中 vue-page-stack 和 vue-navigation 均受 vue 的 keepalive 啟發,基於 vue-router[41],當進入某個頁面時,會查看當前頁面是否有快取,有快取的話就取出快取,並且清除排在他後面的所有 vnode,沒有快取就是新的頁面,需要存儲或者是 replace 當前頁面,向棧裡面 push 對應的 vnode,從而實現記住頁面狀態的功能。

而邏輯思維前端團隊的 vue-stack-router 則另闢蹊徑,拋開了 vue-router,自己獨立實現了路由管理,相較於 vue-router,主要是支援同時可以存活 A 和 B 兩個頁面的實例,或者 A 頁面不同狀態的兩個實例,並支援原生左滑功能。但由於項目還在初期完善,功能還沒有 vue-router 強大,建議持續關注後續動態再做決定是否引入。

本項目使用的是 vue-page-stack,各位可以選擇適合自己項目的工具。同時推薦幾篇相關文章:

【vue-page-stack】Vue 單頁應用導航管理器 正式發布[42]

Vue 社區的路由解決方案:vue-stack-router[43]

請求數據快取

mem[44]

在我們的應用中,會存在一些很少改動的數據,而這些數據有需要從後端獲取,比如公司人員、公司職位分類等,此類數據在很長一段時間時不會改變的,而每次打開頁面或切換頁面時,就重新向後端請求。為了能夠減少不必要請求,加快頁面渲染速度,可以引用 mem 快取庫。

mem 基本原理是通過以接收的函數為 key 創建一個 WeakMap,然後再以函數參數為 key 創建一個 Map,value 就是函數的執行結果,同時將這個 Map 作為剛剛的 WeakMap 的 value 形成嵌套關係,從而實現對同一個函數不同參數進行快取。而且支援傳入 maxAge,即數據的有效期,當某個數據到達有效期後,會自動銷毀,避免記憶體泄漏。

選擇 WeakMap 是因為其相對 Map 保持對鍵名所引用的對象是弱引用,即垃圾回收機制不將該引用考慮在內。只要所引用的對象的其他引用都被清除,垃圾回收機制就會釋放該對象所佔用的記憶體。也就是說,一旦不再需要,WeakMap 裡面的鍵名對象和所對應的鍵值對會自動消失,不用手動刪除引用。

mem 作為高階函數,可以直接接受封裝好的介面請求。但是為了更加直觀簡便,我們可以按照類的形式集成我們的介面函數,然後就可以用裝飾器的方式使用 mem 了(裝飾器只能修飾類和類的類的方法,因為普通函數會存在變數提升)。下面是相關程式碼:

import http from '../http';  import mem from 'mem';    /**   * @param {MemOption} - mem 配置項   * @return {Function} - 裝飾器   */  export default function m(options: AnyObject) {    return (target: AnyObject, name: string, descriptor: PropertyDescriptor) => {      const oldValue = descriptor.value;      descriptor.value = mem(oldValue, options);      return descriptor;    };  }    class Home {    @m({ maxAge: 60 * 1000 })    public async getUnderlingDailyList(      query: ListQuery    ): Promise<{ total: number; list: DailyItem[] }> {      const {        data: { total, list }      } = await http({        method: 'post',        url: '/daily/getList',        data: query      });        return { total, list };    }  }    export default new Home();  

構建時預渲染

針對目前單頁面首屏渲染時間長(需要下載解析 js 文件然後渲染元素並掛載到 id 為 app 的 div 上),SEO 不友好(index.html 的 body 上實際元素只有 id 為 app 的 div 元素,真正的頁面元素都是動態掛載的,搜索引擎的爬蟲無法捕捉到),目前主流解決方案就是服務端渲染(SSR),即從服務端生成組裝好的完整靜態 html 發送到瀏覽器進行展示,但配置較為複雜,一般都會藉助框架,比如 vue 的 nuxt.js[45],react 的 next[46]

其實有一種更簡便的方式–構建時預渲染。顧名思義,就是項目打包構建完成後,啟動一個 Web Server 來運行整個網站,再開啟多個無頭瀏覽器(例如 Puppeteer[47]Phantomjs[48] 等無頭瀏覽器技術)去請求項目中所有的路由,當請求的網頁渲染到第一個需要預渲染的頁面時(需提前配置需要預渲染頁面的路由),會主動拋出一個事件,該事件由無頭瀏覽器截獲,然後將此時的頁面內容生成一個 HTML(包含了 JS 生成的 DOM 結構和 CSS 樣式),保存到打包文件夾中。

根據上面的描述,我們可以其實它本質上就只是快照頁面,不適合過度依賴後端介面的動態頁面,比較適合變化不頻繁的靜態頁面。

實際項目相關工具方面比較推薦 prerender-spa-plugin[49] 這個 webpack 插件,下面是這個插件的原理圖。不過有兩點需要注意:

一個是這個插件需要依賴 Puppeteer,而因為中國網路原因以及本身體積較大,經常下載失敗,不過可以通過 .npmrc 文件指定 Puppeteer 的下載路徑為中國鏡像;

另一個是需要設置路由模式為 history 模式(即基於 html5 提供的 history api 實現的,react 叫 BrowserRouter,vue 叫 history),因為 hash 路由無法對應到實際的物理路由。(即線上渲染時 history 下,如果 form 路由被設置成預渲染,那麼訪問 /form/ 路由時,會直接從服務端返回 form 文件夾下的 index.html,之前打包時就已經預先生成了完整的 HTML 文件 )

本項目已經集成了 prerender-spa-plugin,但由於和 vue-stack-page/vue-navigation 這類路由堆棧管理器一起使用有問題(原因還在查找,如果知道的朋友也可以告知下),所以 prerender 功能是關閉的。

同時推薦幾篇相關文章:

vue 預渲染之 prerender-spa-plugin 解析(一)[50]

使用預渲提升 SPA 應用體驗[51]

Webpack 策略

基礎庫抽離

對於一些基礎庫,例如 vue、moment 等,屬於不經常變化的靜態依賴,一般需要抽離出來以提升每次構建的效率。目前主流方案有兩種:

一種是使用 webpack-dll-plugin[52] 插件,在首次構建時就講這些靜態依賴單獨打包,後續只需引入早已打包好的靜態依賴包即可;

另一種就是外部擴展 Externals[53] 方式,即把不需要打包的靜態資源從構建中剔除,使用 CDN 方式引入。下面是 webpack-dll-plugin 相對 Externals 的缺點:

  1. 需要配置在每次構建時都不參與編譯的靜態依賴,並在首次構建時為它們預編譯出一份 JS 文件(後文將稱其為 lib 文件),每次更新依賴需要手動進行維護,一旦增刪依賴或者變更資源版本忘記更新,就會出現 Error 或者版本錯誤。
  2. 無法接入瀏覽器的新特性 script type="module",對於某些依賴庫提供的原生 ES Modules 的引入方式(比如 vue 的新版引入方式)無法得到支援,沒法更好地適配高版本瀏覽器提供的優良特性以實現更好地性能優化。
  3. 將所有資源預編譯成一份文件,並將這份文件顯式注入項目構建的 HTML 模板中,這樣的做法,在 HTTP1 時代是被推崇的,因為那樣能減少資源的請求數量,但在 HTTP2 時代如果拆成多個 CDN Link,就能夠更充分地利用 HTTP2 的多路復用特性。

不過選擇 Externals 還是需要一個靠譜的 CDN 服務的。

本項目選擇的是 Externals,各位可根據項目需求選擇不同的方案。

更多內容請查看這篇文章(上面觀點來自於這篇文章):

Webpack 優化——將你的構建效率提速翻倍[54]

手勢庫

hammer.js[55]

AlloyFinger[56]

在移動端開發中,一般都需要支援一些手勢,例如拖動(Pan),縮放(Pinch),旋轉(Rotate),滑動(swipe)等。目前已經有很成熟的方案了,例如 hammer.js 和騰訊前端團隊開發的 AlloyFinger 都很不錯。本項目選擇基於 hammer.js 進行二次封裝成 vue 指令集,各位可根據項目需求選擇不同的方案。

下面是二次封裝的關鍵程式碼,其中用到了 webpack 的 require.context 函數來獲取特定模組的上下文,主要用來實現自動化導入模組,比較適用於像 vue 指令這種模組較多的場景:

// 用於導入模組的上下文  export const importAll = (    context: __WebpackModuleApi.RequireContext,    options: ImportAllOptions = {}  ): AnyObject => {    const { useDefault = true, keyTransformFunc, filterFunc } = options;      let keys = context.keys();      if (isFunction(filterFunc)) {      keys = keys.filter(filterFunc);    }      return keys.reduce((acc: AnyObject, curr: string) => {      const key = isFunction(keyTransformFunc) ? keyTransformFunc(curr) : curr;      acc[key] = useDefault ? context(curr).default : context(curr);      return acc;    }, {});  };    // directives 文件夾下的 index.ts  const directvieContext = require.context('./', false, /.ts$/);  const directives = importAll(directvieContext, {    filterFunc: (key: string) => key !== './index.ts',    keyTransformFunc: (key: string) =>      key.replace(/^.//, '').replace(/.ts$/, '')  });    export default {    install(vue: typeof Vue): void {      Object.keys(directives).forEach((key) =>        vue.directive(key, directives[key])      );    }  };    // touch.ts  export default {    bind(el: HTMLElement, binding: DirectiveBinding) {      const hammer: HammerManager = new Hammer(el);      const touch = binding.arg as Touch;      const listener = binding.value as HammerListener;      const modifiers = Object.keys(binding.modifiers);        switch (touch) {        case Touch.Pan:          const panEvent = detectPanEvent(modifiers);          hammer.on(`pan${panEvent}`, listener);          break;        ...      }    }  };  

另外推薦一篇關於 hammer.js 和一篇關於 require.context 的文章:

H5 案例分享:JS 手勢框架 —— Hammer.js[57]

使用 require.context 實現前端工程自動化[58]

樣式適配

postcss-px-to-viewport[59]

Viewport Units Buggyfill[60]

flexible[61]

postcss-pxtorem[62]

Autoprefixer[63]

browserslist[64]

在移動端網頁開發時,樣式適配始終是一個繞不開的問題。對此目前主流方案有 vw 和 rem(當然還有 vw + rem 結合方案,請見下方 rem-vw-layout 倉庫),其實基本原理都是相通的,就是隨著螢幕寬度或字體大小成正比變化。因為原理方面的詳細資料網路上已經有很多了,就不在這裡贅述了。下面主要提供一些這工程方面的工具。

關於 rem,阿里無線前端團隊在 15 年的時候基於 rem 推出了 flexible 方案,以及 postcss 提供的自動轉換 px 到 rem 的插件 postcss-pxtorem。

關於 vw,可以使用 postcss-px-to-viewport 進行自動轉換 px 到 vw。postcss-px-to-viewport 相關配置如下:

"postcss-px-to-viewport": {    viewportWidth: 375, // 視窗的寬度,對應的是我們設計稿的寬度,一般是375    viewportHeight: 667, // 視窗的高度,根據750設備的寬度來指定,一般指定1334,也可以不配置    unitPrecision: 3,  // 指定`px`轉換為視窗單位值的小數位數(很多時候無法整除)    viewportUnit: 'vw', // 指定需要轉換成的視窗單位,建議使用vw    selectorBlackList: ['.ignore', '.hairlines'], // 指定不轉換為視窗單位的類,可以自定義,可以無限添加,建議定義一至兩個通用的類名    minPixelValue: 1, // 小於或等於`1px`不轉換為視窗單位,你也可以設置為你想要的值    mediaQuery: false // 媒體查詢里的單位是否需要轉換單位  }  

下面是 vw 和 rem 的優缺點對比圖:

關於 vw 兼容性問題,目前在移動端 iOS 8 以上以及 Android 4.4 以上獲得支援。如果有兼容更低版本需求的話,可以選擇 viewport 的 pollify 方案,其中比較主流的是 Viewport Units Buggyfill[65]

本方案因不準備兼容低版本,所以直接選擇了 vw 方案,各位可根據項目需求選擇不同的方案。

另外關於設置 css 兼容不同瀏覽器,想必大家都知道 Autoprefixer(vue-cli3 已經默認集成了),那麼如何設置要兼容的範圍呢?推薦使用 browserslist,可以在 .browserslistrc 或者 pacakage.json 中 browserslist 部分設置兼容瀏覽器範圍。因為不止 Autoprefixer,還有 Babel,postcss-preset-env 等工具都會讀取 browserslist 的兼容配置,這樣比較容易使 js css 兼容瀏覽器的範圍保持一致。下面是本項目的 .browserslistrc 配置:

iOS >= 10  //  即 iOS Safari  Android >= 6.0 // 即 Android WebView  last 2 versions // 每個瀏覽器最近的兩個版本  

最後推薦一些移動端樣式適配的資料:

rem-vw-layout[66]

細說移動端 經典的 REM 布局 與 新秀 VW 布局[67]

如何在 Vue 項目中使用 vw 實現移動端適配[68]

表單校驗

async-validator[69]

vee-validate[70]

由於大部分移動端組件庫都不提供表單校驗,因此需要自己封裝。目前比較多的方式就是基於 async-validator 進行二次封裝(elementUI 組件庫提供的表單校驗也是基於 async-validator ),或者使用 vee-validate(一種基於 vue 模板的輕量級校驗框架)進行校驗,各位可根據項目需求選擇不同的方案。

本項目的表單校驗方案是在 async-validator 基礎上進行二次封裝,程式碼如下,原理很簡單,基本滿足需求。如果還有更完善的方案,歡迎提出來。

其中 setRules 方法是將組件中設置的 rules(符合 async-validator 約定的校驗規則)按照需要校驗的數據的名字為 key 轉化一個對象 validator,value 是 async-validator 生成的實例。validator 方法可以接收單個或多個需要校驗的數據的 key,然後就會在 setRules 生成的對象 validator 中尋找 key 對應的 async-validator 實例,最後調用實例的校驗方法。當然也可以不接受參數,那麼就會校驗所有傳入的數據。

import schema from 'async-validator';  ...    class ValidatorUtils {    private data: AnyObject;    private validators: AnyObject;      constructor({ rules = {}, data = {}, cover = true }) {      this.validators = {};      this.data = data;      this.setRules(rules, cover);    }      /**     * 設置校驗規則     * @param rules async-validator 的校驗規則     * @param cover 是否替換舊規則     */    public setRules(rules: ValidateRules, cover: boolean) {      if (cover) {        this.validators = {};      }        Object.keys(rules).forEach((key) => {        this.validators[key] = new schema({ [key]: rules[key] });      });    }      public validate(      dataKey?: string | string[]    ): Promise<ValidateError[] | string | string[] | undefined> {      // 錯誤數組      const err: ValidateError[] = [];        Object.keys(this.validators)        .filter((key) => {          // 若不傳 dataKey 則校驗全部。否則校驗 dataKey 對應的數據(dataKey 可以對應一個(字元串)或多個(數組))          return (            !dataKey ||            (dataKey &&              ((_.isString(dataKey) && dataKey === key) ||                (_.isArray(dataKey) && dataKey.includes(key))))          );        })        .forEach((key) => {          this.validators[key].validate(            { [key]: this.data[key] },            (error: ValidateError[]) => {              if (error) {                err.push(error[0]);              }            }          );        });        if (err.length > 0) {        return Promise.reject(err);      } else {        return Promise.resolve(dataKey);      }    }  }  

阻止原生返回事件

開發中可能會遇到下面這個需求:當頁面彈出一個 popup 或 dialog 組件時,點擊返回鍵時是隱藏彈出的組件而不是返回到上一個頁面。

為了解決這個問題,我們可以從路由棧角度思考。一般彈出組件是不會在路由棧上添加任何記錄,因此我們在彈出組件時,可以在路由棧中 push 一個記錄,為了不讓頁面跳轉,我們可以把跳轉的目標路由設置為當前頁面路由,並加上一個 query 來標記這個組件彈出的狀態。

然後監聽 query 的變化,當點擊彈出組件時,query 中與該彈出組件有關的標記變為 true,則將彈出組件設為顯示;當用戶點擊 native 返回鍵時,路由返回上一個記錄,仍然是當前頁面路由,不過 query 中與該彈出組件有關的標記不再是 true 了,這樣我們就可以把彈出組件設置成隱藏,同時不會返回上一個頁面。相關程式碼如下:

<template>    <van-cell title="幾時入坑"                      is-link                      :value="textData.pitDateStr"                      @click="goToSelect('calendar')" />    <van-popup v-model="showCalendar"                position="right"                :style="{ height: '100%', width: '100%' }">      <Calendar title="選擇入坑時間"                @select="onSelectPitDate" />    </van-popup>  <template/>  <script lang="ts">  ...  export default class Form extends Vue {    private showCalendar = false;    private goToSelect(popupName: string) {      this.$router.push({ name: 'form', query: { [popupName]: 'true' } });    }      private onSelectPitDate(...res: DateObject[]) {      ...      this.$router.go(-1);    }      @Watch('$route.query')    private handlePopup(val: any) {      switch (true) {        case val.calendar && val.calendar === 'true':          this.showCalendar = true;          break;        default:          this.showCalendar = false;          break;      }    }  }  </script>  

通過 UA 獲取設備資訊

在開發 h5 開發時,可能會遇到下面幾種情況:

  1. 開發時都是在瀏覽器進行開發調試的,所以需要避免調用 native 的介面,因為這些介面在瀏覽器環境根本不存在;
  2. 有些情況需要區分所在環境是在 android webview 還是 ios webview,做一些針對特定平台的處理;
  3. 當 h5 版本已經更新,但是客戶端版本並沒有同步更新,那麼如果之間的介面調用發生了改變,就會出現調用出錯。

所以需要一種方式來檢測頁面當前所處設備的平台類型、app 版本、系統版本等,目前比較靠譜的方式是通過 android / ios webview 修改 UserAgent,在原有的基礎上加上特定後綴,然後在網頁就可以通過 UA 獲取設備相關資訊了。當然這種方式的前提是 native 程式碼是可以為此做出改動的。以Android為例關鍵程式碼如下:

Android關鍵程式碼:

// Activity -> onCreate  ...  // 獲取 app 版本  PackageManager packageManager = getPackageManager();  PackageInfo packInfo = null;  try {    // getPackageName()是你當前類的包名,0代表是獲取版本資訊    packInfo = packageManager.getPackageInfo(getPackageName(),0);  } catch (PackageManager.NameNotFoundException e) {    e.printStackTrace();  }  String appVersion = packInfo.versionName;    // 獲取系統版本  String systemVersion = android.os.Build.VERSION.RELEASE;    mWebSettings.setUserAgentString(    mWebSettings.getUserAgentString() + " DSBRIDGE_"  + appVersion + "_" + systemVersion + "_android"  );  

h5 關鍵程式碼:

const initDeviceInfo = () => {    const UA = navigator.userAgent;    const info = UA.match(/s{1}DSBRIDGE[w.]+$/g);    if (info && info.length > 0) {      const infoArray = info[0].split('_');      window.$appVersion = infoArray[1];      window.$systemVersion = infoArray[2];      window.$platform = infoArray[3] as Platform;    } else {      window.$appVersion = undefined;      window.$systemVersion = undefined;      window.$platform = 'browser';    }  };  

mock 數據

Mock[71]

當前後端進度不一致,介面還尚未實現時,為了不影響彼此的進度,此時前後端約定好介面數據格式後,前端就可以使用 mock 數據進行獨立開發了。本項目使用了 Mock 實現前端所需的介面。

調試控制台

eruda[72]

vconsole[73]

在調試方面,本項目使用 eruda 作為手機端調試面板,功能相當於打開 PC 控制台,可以很方便地查看 console, network, cookie, localStorage 等關鍵調試資訊。與之類似地工具還有微信的前端研發團隊開發的 vconsole,各位可以選擇適合自己項目的工具。

關於 eruda 使用,推薦使用 cdn 方式載入,至於什麼時候載入 eruda,可以根據不同項目制定不同策略。示例程式碼如下:

<script>    (function() {      const NO_ERUDA = window.location.protocol === 'https:';      if (NO_ERUDA) return;      const src = 'https://cdn.jsdelivr.net/npm/[email protected]/eruda.min.js';      document.write('<scr' + 'ipt src="' + src + '"></scr' + 'ipt>');      document.write('<scr' + 'ipt>eruda.init();</scr' + 'ipt>');    })();  </script>  

抓包工具

charles[74]

fiddler[75]

雖然有了 eruda 調試工具,但某些情況下仍不能滿足需求,比如現網完全關閉 eruda 等情況。

此時就需要抓包工具,相關工具主要就是上面羅列的這兩個,各位可以選擇適合自己項目的工具。

通過 charles 可以清晰的查看所有請求的資訊(註:https 下抓包需要在手機上配置相關證書)。當然 charles 還有更多強大功能,比例模擬弱網情況,資源映射等。

推薦一篇不錯的 charles 使用教程:

解鎖 Charles 的姿勢[76]

異常監控平台

sentry[77]

移動端網頁相對 PC 端,主要有設備眾多,網路條件各異,調試困難等特點。導致如下問題:

  • 設備兼容或網路異常導致只有部分情況下才出現的 bug,測試無法全面覆蓋
  • 無法獲取出現 bug 的用戶的設備,又不能復現回饋的 bug
  • 部分 bug 只出現幾次,後面無法復現,不能還原事故現場

這時就非常需要一個異常監控平台,將異常實時上傳到平台,並及時通知相關人員。

相關工具有 sentry,fundebug 等,其中 sentry 因為功能強大,支援多平台監控(不僅可以監控前端項目),完全開源,可以私有化部署等特點,而被廣泛採納。

下面是 sentry 在本項目應用時使用的相關配套工具。

sentry 針對 javascript 的 sdk

sentry-javascript[78]

自動上傳 sourcemap 的 webpack 插件

sentry-webpack-plugin[79]

編譯時自動在 try catch 中添加錯誤上報函數的 babel 插件

babel-plugin-try-catch-error-report[80]

補充:

前端的異常主要有以下幾個部分:

  • 靜態資源載入異常
  • 介面異常(包括與後端和 native 的介面)
  • js 報錯
  • 網頁崩潰

其中靜態資源載入失敗,可以通過 window.addEventListener('error', …, true) 在事件捕獲階段獲取,然後篩選出資源載入失敗的錯誤並手動上報錯誤。核心程式碼如下:

// 全局監控資源載入錯誤  window.addEventListener(    'error',    (event) => {      // 過濾 js error      const target = event.target || event.srcElement;      const isElementTarget =        target instanceof HTMLScriptElement ||        target instanceof HTMLLinkElement ||        target instanceof HTMLImageElement;      if (!isElementTarget) {        return false;      }      // 上報資源地址      const url =        (target as HTMLScriptElement | HTMLImageElement).src ||        (target as HTMLLinkElement).href;        this.log({        error: new Error(`ResourceLoadError: ${url}`),        type: 'resource load'      });    },    true  );  

關於服務端介面異常,可以通過在封裝的 http 模組中,全局集成上報錯誤函數(native 介面的錯誤上報類似,可在項目中查看)。核心程式碼如下:

function errorReport(    url: string,    error: string | Error,    requestOptions: AxiosRequestConfig,    response?: AnyObject  ) {    if (window.$sentry) {      const errorInfo: RequestErrorInfo = {        error: typeof error === 'string' ? new Error(error) : error,        type: 'request',        requestUrl: url,        requestOptions: JSON.stringify(requestOptions)      };        if (response) {        errorInfo.response = JSON.stringify(response);      }        window.$sentry.log(errorInfo);    }  }  

關於全局 js 報錯,sentry 針對的前端的 sdk 已經通過 window.onerror 和 window.addEventListener('unhandledrejection', …, false) 進行全局監聽並上報。

需要注意的是其中 window.onerror = (message, source, lineno, colno, error) =>{} 不同於 window.addEventListener('error', …),window.onerror 捕獲的資訊更豐富,包括了錯誤字元串資訊、發生錯誤的 js 文件,錯誤所在的行數、列數、和 Error 對象(其中還會有調用堆棧資訊等)。所以 sentry 會選擇 window.onerror 進行 js 全局監控。

但有一種錯誤是 window.onerror 監聽不到的,那就是 unhandledrejection 錯誤,這個錯誤是當 promise reject 後沒有 catch 住所引起的。當然 sentry 的 sdk 也已經做了監聽。

針對 vue 項目,也可對 errorHandler 鉤子進行全局監聽,react 的話可以通過 componentDidCatch 鉤子,vue 相關程式碼如下:

// 全局監控 Vue errorHandler  Vue.config.errorHandler = (error, vm, info) => {    window.$sentry.log({      error,      type: 'vue errorHandler',      vm,      info    });  };  

但是對於我們業務中,經常會對一些以報錯程式碼使用 try catch,這些錯誤如果沒有在 catch 中向上拋出,是無法通過 window.onerror 捕獲的,針對這種情況,筆者開發了一個 babel 插件 babel-plugin-try-catch-error-report[81],該插件可以在 babel[82] 編譯 js 的過程中,通過在 ast 中查找 catch 節點,然後再 catch 程式碼塊中自動插入錯誤上報函數,可以自定義函數名,和上報的內容(源碼所在文件,行數,列數,調用棧,以及當前 window 屬性,比如當前路由資訊 window.location.href)。相關配置程式碼如下:

if (!IS_DEV) {    plugins.push([      'try-catch-error-report',      {        expression: 'window.$sentry.log',        needFilename: true,        needLineNo: true,        needColumnNo: false,        needContext: true,        exclude: ['node_modules']      }    ]);  }  

針對跨域 js 問題,當載入的不同域的 js 文件時,例如通過 cdn 載入打包後的 js。如果 js 報錯,window.onerror 只能捕獲到 script error,沒有任何有效資訊能幫助我們定位問題。此時就需要我們做一些事情:第一步、服務端需要在返回 js 的返回頭設置 Access-Control-Allow-Origin: *第二部、設置 script 標籤屬性 crossorigin,程式碼如下:

<script src="http://helloworld/main.js" crossorigin></script>  

如果是動態添加的,也可動態設置:

const script = document.createElement('script');  script.crossOrigin = 'anonymous';  script.src = url;  document.body.appendChild(script);  

針對網頁崩潰問題,推薦一個基於 service work 的監控方案,相關文章已列在下面的。如果是 webview 載入網頁,也可以通過 webview 載入失敗的鉤子監控網頁崩潰等。

如何監控網頁崩潰?[83]

最後,因為部署到線上的程式碼一般都是經過壓縮混淆的,如果沒有上傳 sourcemap 的話,是無法定位到具體源碼的,可以現在 項目中添加 .sentryclirc 文件,其中內容可參考本項目的 .sentryclirc,然後通過 sentry-cli (需要全局全裝 sentry-cli 即npm install sentry-cli)命令行工具進行上傳,命令如下:

sentry-cli releases -o 機構名 -p 項目名 files 版本 upload-sourcemaps sourcemap 文件相對位置 --url-prefix js 在線上相對根目錄的位置 --rewrite  // 示例  sentry-cli releases -o mcukingdom -p hello-world files 0.2.1 upload-sourcemaps dist/js --url-prefix '~/js/' --rewrite  

當然官方也提供了 webpack 插件 sentry-webpack-plugin[84],當打包時觸發 webpack 的 after-emit 事件鉤子(即生成資源到 output 目錄之後),插件會自動上傳打包目錄中的 sourcemap 和關聯的 js,相關配置可參考本項目的 vue.config.js 文件。

通常為了安全,是不允許在線上部署 sourcemap 文件的,所以上傳 sourcemap 到 sentry 後,可手動刪除線上 sourcemap 文件。

常見問題

  • iOS WKWebView cookie 寫入慢以及易丟失 現象: 原因:WKWebView 對 NSHTTPCookieStorage 寫入 cookie,不是實時存儲的。從實際的測試中發現,不同的 IOS 版本,延遲的時間還不一樣。同樣,發起請求時,也不是實時讀取,無法做到和 native 同步,導致頁面邏輯出錯。 兩種解決辦法: 各位可以選擇適合自己項目的方式,有更好的處理方式歡迎留言。
    1. 客戶端手動干預一下 cookie 的存儲。將服務響應的 cookie,持久化到本地,在下次 webview 啟動時,讀取本地的 cookie 值,手動再去通過 native 往 webview 寫入。但是偶爾還有 spa 的頁面路由切換的時候丟失 cookie 的問題。
    2. 將 cookie 存儲的 session 持久化到 localSorage,每次請求時都會取 localSorage 存儲的 session,並在請求頭部添加 cookieback 欄位,服務端鑒權時,優先校驗 cookieback 欄位。這樣即使 cookie 丟失或存儲的上一次的 session,都不會有影響。不過這種方式相當於繞開了 cookie 傳輸機制,無法享受 這種機制帶來的安全特性。
    3. iOS 登陸後立即進入網頁,會出現 cookie 獲取不到或獲取的上一次登陸快取的 cookie
    4. 重啟 App 後,cookie 會丟失
  • input 標籤在部分Android webview 上無法實現上傳圖片功能 因為 Android 的版本碎片問題,很多版本的 WebView 都對喚起函數有不同的支援。我們需要重寫 WebChromeClient 下的 openFileChooser()(5.0 及以上系統回調 onShowFileChooser())。我們通過 Intent 在 openFileChooser()中喚起系統相機和支援 Intent 的相關 app。 相關文章:【Android】WebView 的 input 上傳照片的兼容問題[85]
  • input 標籤在 iOS 上喚起軟鍵盤,鍵盤收回後頁面不回落(部分情況頁面看上去已經回落,實際結構並未回落) input 焦點失焦後,ios 軟鍵盤收起,但沒有觸發 window resize,導致實際頁面 dom 仍然被鍵盤頂上去–錯位。解決辦法:全局監聽 input 失焦事件,當觸發事件後,將 body 的 scrollTop 設置為 0。 document.addEventListener('focusout', () => { document.body.scrollTop = 0; });
  • 喚起軟鍵盤後會遮擋輸入框 當 input 或 textarea 獲取焦點後,軟鍵盤會遮擋輸入框。解決辦法:全局監聽 window 的 resize 事件,當觸發事件後,獲取當前 active 的元素並檢驗是否為 input 或 textarea 元素,如果是則調用元素的 scrollIntoViewIfNeeded 即可。 window.addEventListener('resize', () => { // 判斷當前 active 的元素是否為 input 或 textarea if ( document.activeElement!.tagName === 'INPUT' || document.activeElement!.tagName === 'TEXTAREA' ) { setTimeout(() => { // 原生方法,滾動至需要顯示的位置 document.activeElement!.scrollIntoView(); }, 0); } });
  • 喚起鍵盤後 position: fixed;bottom: 0px; 元素被鍵盤頂起 解決辦法:全局監聽 window 的 resize 事件,當觸發事件後,獲取 id 名為 fixed-bottom 的元素(可提前約定好如何區分定位在窗口底部的元素),將其設置成 display: none。鍵盤收回時,則設置成 display: block;。 const clientHeight = document.documentElement.clientHeight; window.addEventListener('resize', () => { const bodyHeight = document.documentElement.clientHeight; const ele = document.getElementById('fixed-bottom'); if (!ele) return; if (clientHeight > bodyHeight) { (ele as HTMLElement).style.display = 'none'; } else { (ele as HTMLElement).style.display = 'block'; } });
  • 點擊網頁輸入框會導致網頁放大通過 viewport 設置 user-scalable=no 即可,(注意:當 user-scalable=no 時,無需設置 minimum-scale=1, maximum-scale=1,因為已經禁止了用戶縮放頁面了,允許的縮放範圍也就不存在了)。程式碼如下: <meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=0,viewport-fit=cover" />
  • webview 通過 loadUrl 載入的頁面運行時卻通過第三方瀏覽器打開,程式碼如下 // 創建一個 Webview Webview webview = (Webview) findViewById(R.id.webView); // 調用 Webview loadUrl webview.loadUrl("http://www.baidu.com/"); 解決辦法:在調用 loadUrl 之前,設置下 WebviewClient 類,當然如果需要也可自己實現 WebviewClient(例如通過攔截 prompt 實現 js 與 native 的通訊) webview.setWebViewClient(new WebViewClient());

參考資料

[1]

mattermost-mobile: https://github.com/mattermost/mattermost-mobile

[2]

mobile-web-best-practice: https://github.com/mcuking/mobile-web-best-practice

[3]

vue-cli3: https://cli.vuejs.org/

[4]

typescript: http://www.typescriptlang.org/

[5]

react: https://reactjs.org/

[6]

組件庫: #組件庫

[7]

JSBridge: #jsbridge

[8]

路由堆棧管理(模擬原生 APP 導航): #路由堆棧管理模擬原生-app-導航

[9]

請求數據快取: #請求數據快取

[10]

構建時預渲染: #構建時預渲染

[11]

Webpack 策略: #webpack-策略

[12]

基礎庫抽離: #基礎庫抽離

[13]

手勢庫: #手勢庫

[14]

樣式適配: #樣式適配

[15]

表單校驗: #表單校驗

[16]

阻止原生返回事件: #阻止原生返回事件

[17]

通過 UA 獲取設備資訊: #通過-ua-獲取設備資訊

[18]

mock 數據: #mock-數據

[19]

調試控制台: #調試控制台

[20]

抓包工具: #抓包工具

[21]

異常監控平台: #異常監控平台

[22]

常見問題: #常見問題

[23]

vant: https://youzan.github.io/vant/#/zh-CN/intro

[24]

vux: https://github.com/airyland/vux

[25]

mint-ui: https://github.com/ElemeFE/mint-ui

[26]

cube-ui: https://github.com/didi/cube-ui

[27]

less-loader: https://github.com/webpack-contrib/less-loader

[28]

less: http://lesscss.org/

[29]

modifyVars: http://lesscss.org/usage/#using-less-in-the-browser-modify-variables

[30]

訂製主題: https://youzan.github.io/vant/#/zh-CN/theme

[31]

Vue 常用組件庫的比較分析(移動端): https://blog.csdn.net/weixin_38633659/article/details/89736656

[32]

DSBridge-IOS: https://github.com/wendux/DSBridge-IOS

[33]

DSBridge-Android: https://github.com/wendux/DSBridge-Android

[34]

WebViewJavascriptBridge: https://github.com/marcuswestin/WebViewJavascriptBridge

[35]

mobile-web-best-practice-container: https://github.com/mcuking/mobile-web-best-practice-container

[36]

JSBridge: https://github.com/mcuking/JSBridge

[37]

JSBridge 實現原理: https://github.com/mcuking/JSBridge

[38]

vue-page-stack: https://github.com/hezhongfeng/vue-page-stack

[39]

vue-navigation: https://github.com/zack24q/vue-navigation

[40]

vue-stack-router: https://github.com/luojilab/vue-stack-router

[41]

vue-router: https://router.vuejs.org/

[42]

【vue-page-stack】Vue 單頁應用導航管理器 正式發布: https://juejin.im/post/5d2ef417f265da1b971aa94f

[43]

Vue 社區的路由解決方案:vue-stack-router: https://juejin.im/post/5d4ce4fd6fb9a06acd450e8c

[44]

mem: https://github.com/sindresorhus/mem

[45]

nuxt.js: https://github.com/nuxt/nuxt.js

[46]

next: https://github.com/zeit/next.js

[47]

Puppeteer: https://github.com/GoogleChrome/puppeteer

[48]

Phantomjs: https://github.com/ariya/phantomjs

[49]

prerender-spa-plugin: https://github.com/chrisvfritz/prerender-spa-plugin

[50]

vue 預渲染之 prerender-spa-plugin 解析(一): https://blog.csdn.net/vv_bug/article/details/84593052

[51]

使用預渲提升 SPA 應用體驗: https://juejin.im/post/5d5fa22ee51d4561de20b5f5

[52]

webpack-dll-plugin: https://webpack.docschina.org/plugins/dll-plugin/

[53]

Externals: https://webpack.docschina.org/configuration/externals/

[54]

Webpack 優化——將你的構建效率提速翻倍: https://juejin.im/post/5d614dc96fb9a06ae3726b3e

[55]

hammer.js: https://github.com/hammerjs/hammer.js

[56]

AlloyFinger: https://github.com/AlloyTeam/AlloyFinger

[57]

H5 案例分享:JS 手勢框架 —— Hammer.js: https://www.h5anli.com/articles/201609/hammerjs.html

[58]

使用 require.context 實現前端工程自動化: https://www.jianshu.com/p/c894ea00dfec

[59]

postcss-px-to-viewport: https://github.com/evrone/postcss-px-to-viewport

[60]

Viewport Units Buggyfill: https://github.com/rodneyrehm/viewport-units-buggyfill

[61]

flexible: https://github.com/amfe/lib-flexible

[62]

postcss-pxtorem: https://github.com/cuth/postcss-pxtorem

[63]

Autoprefixer: https://github.com/postcss/autoprefixer

[64]

browserslist: https://github.com/browserslist/browserslist

[65]

Viewport Units Buggyfill: https://github.com/rodneyrehm/viewport-units-buggyfill

[66]

rem-vw-layout: https://github.com/imwtr/rem-vw-layout

[67]

細說移動端 經典的 REM 布局 與 新秀 VW 布局: https://www.cnblogs.com/imwtr/p/9648233.html

[68]

如何在 Vue 項目中使用 vw 實現移動端適配: https://www.jianshu.com/p/1f1b23f8348f

[69]

async-validator: https://github.com/yiminghe/async-validator

[70]

vee-validate: https://github.com/baianat/vee-validate

[71]

Mock: https://github.com/nuysoft/Mock

[72]

eruda: https://github.com/liriliri/eruda

[73]

vconsole: https://github.com/Tencent/vConsole

[74]

charles: https://www.charlesproxy.com/

[75]

fiddler: https://www.telerik.com/fiddler

[76]

解鎖 Charles 的姿勢: https://juejin.im/post/5a1033d2f265da431f4aa81f

[77]

sentry: https://github.com/getsentry/sentry

[78]

sentry-javascript: https://github.com/getsentry/sentry-javascript

[79]

sentry-webpack-plugin: https://github.com/getsentry/sentry-webpack-plugin

[80]

babel-plugin-try-catch-error-report: https://github.com/mcuking/babel-plugin-try-catch-error-report

[81]

babel-plugin-try-catch-error-report: https://github.com/mcuking/babel-plugin-try-catch-error-report

[82]

babel: https://babeljs.io/

[83]

如何監控網頁崩潰?: https://juejin.im/entry/5be158116fb9a049c6434f4a

[84]

sentry-webpack-plugin: https://github.com/getsentry/sentry-webpack-plugin

[85]

【Android】WebView 的 input 上傳照片的兼容問題: https://juejin.im/post/5a322cdef265da43176a2913