歡迎來到深圳市來科信科技有限公司網站!

        已閱讀

        基于Vue和TS的Web移動端發開項目實戰心得

        來源:www.bqtao.cn ?? ?? 發布時間:2019-09-27
        筆者在公司用 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 基礎上進行兩端通信的。下面是兩端的關鍵代碼摘要:

        安卓端同步日歷核心代碼,具體代碼請查看與本項目配套的安卓項目 mobile-web-best-practice-container[35]

        public class JsApi {
            /**
             * 同步日歷接口
             * msg 格式如下:
             * ...
             */
            @JavascriptInterface
            public void syncCalendar(Object msg, CompletionHandler 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;
          };
        }
        

        另外推薦一個筆者之前寫的一個基于安卓平臺實現的教學版 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[]
          ): Promisestring | 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 了,這樣我們就可以把彈出組件設置成隱藏,同時不會返回上一個頁面。

        APP開發 網站開發 產品設計 微信公眾號 APP開發公司 用戶體驗 APP運營 微信小程序 產品經理 網站設計