移動 web 最佳實踐(乾貨長文,建議收藏)

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

由於公司在這方面沒有太多技術沉澱,因此在開發期間遇到了不少坑,通過一年多的技術攻克積累,最終造成了這套比較完善的解決方案,總結出來但願可以幫助到你們,尤爲是對一些中小公司這方面經驗不足的(PS: 大公司估計有他們本身的一套方案了)。css

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

mobile-web-best-practice前端

移動端 web 最佳實踐,基於 vue-cli3 搭建的 typescript 項目,能夠用於 hybrid 應用或者純 webapp 開發。如下大部份內容一樣適用於 react 等前端框架。vue

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

目錄

組件庫

vantjava

vuxnode

mint-uireact

cube-uiandroid

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

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

定製主題

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

Vue 經常使用組件庫的比較分析(移動端)

JSBridge

DSBridge-IOS

DSBridge-Android

WebViewJavascriptBridge

混合應用中通常都是經過 webview 加載網頁,而當網頁要獲取設備能力(例如調用攝像頭、本地日曆等)或者 native 須要調用網頁裏的方法,就須要經過 JSBridge 進行通訊。

開源社區中有不少功能強大的 JSBridge,例如上面列舉的庫。本項目基於保持 iOS android 平臺接口統一緣由,採用了 DSBridge,各位能夠選擇適合本身項目的工具。

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

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

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;
  };
}
複製代碼

另外推薦一個筆者以前寫的一個基於安卓平臺實現的教學版 JSBridge,裏面詳細闡述瞭如何基於底層接口一步步封裝一個可用的 JSBridge:

JSBridge 實現原理

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

vue-page-stack

vue-navigation

vue-stack-router

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

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

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

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

【vue-page-stack】Vue 單頁應用導航管理器 正式發佈

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

請求數據緩存

mem

在咱們的應用中,會存在一些不多改動的數據,而這些數據有須要從後端獲取,好比公司人員、公司職位分類等,此類數據在很長一段時間時不會改變的,而每次打開頁面或切換頁面時,就從新向後端請求。爲了可以減小沒必要要請求,加快頁面渲染速度,能夠引用 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,react 的 next

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

根據上面的描述,咱們能夠其實它本質上就只是快照頁面,不適合過分依賴後端接口的動態頁面,比較適合變化不頻繁的靜態頁面。

實際項目相關工具方面比較推薦 prerender-spa-plugin 這個 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 解析(一)

使用預渲提高 SPA 應用體驗

Webpack 策略

基礎庫抽離

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

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

另外一種就是外部擴展 Externals 方式,即把不須要打包的靜態資源從構建中剔除,使用 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 優化——將你的構建效率提速翻倍

手勢庫

hammer.js

AlloyFinger

在移動端開發中,通常都須要支持一些手勢,例如拖動(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

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

樣式適配

postcss-px-to-viewport

Viewport Units Buggyfill

flexible

postcss-pxtorem

Autoprefixer

browserslist

在移動端網頁開發時,樣式適配始終是一個繞不開的問題。對此目前主流方案有 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

本方案因不許備兼容低版本,因此直接選擇了 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

細說移動端 經典的 REM 佈局 與 新秀 VW 佈局

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

表單校驗

async-validator

vee-validate

因爲大部分移動端組件庫都不提供表單校驗,所以須要本身封裝。目前比較多的方式就是基於 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 代碼是能夠爲此作出改動的。以安卓爲例關鍵代碼以下:

安卓關鍵代碼:

// 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

當先後端進度不一致,接口還還沒有實現時,爲了避免影響彼此的進度,此時先後端約定好接口數據格式後,前端就可使用 mock 數據進行獨立開發了。本項目使用了 Mock 實現前端所需的接口。

調試控制檯

eruda

vconsole

在調試方面,本項目使用 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/eruda@1.5.8/eruda.min.js';
    document.write('<scr' + 'ipt src="' + src + '"></scr' + 'ipt>');
    document.write('<scr' + 'ipt>eruda.init();</scr' + 'ipt>');
  })();
</script>
複製代碼

抓包工具

charles

fiddler

雖然有了 eruda 調試工具,但某些狀況下仍不能知足需求,好比現網徹底關閉 eruda 等狀況。

此時就須要抓包工具,相關工具主要就是上面羅列的這兩個,各位能夠選擇適合本身項目的工具。

經過 charles 能夠清晰的查看全部請求的信息(注:https 下抓包須要在手機上配置相關證書)。固然 charles 還有更多強大功能,比例模擬弱網狀況,資源映射等。

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

解鎖 Charles 的姿式

異常監控平臺

sentry

移動端網頁相對 PC 端,主要有設備衆多,網絡條件各異,調試困難等特色。致使以下問題:

  • 設備兼容或網絡異常致使只有部分狀況下才出現的 bug,測試沒法全面覆蓋

  • 沒法獲取出現 bug 的用戶的設備,又不能復現反饋的 bug

  • 部分 bug 只出現幾回,後面沒法復現,不能還原事故現場

這時就很是須要一個異常監控平臺,將異常實時上傳到平臺,並及時通知相關人員。

相關工具備 sentry,fundebug 等,其中 sentry 由於功能強大,支持多平臺監控(不只能夠監控前端項目),徹底開源,能夠私有化部署等特色,而被普遍採納。

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

sentry 針對 javascript 的 sdk

sentry-javascript

自動上傳 sourcemap 的 webpack 插件

sentry-webpack-plugin

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

babel-plugin-try-catch-error-report

補充:

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

  • 靜態資源加載異常

  • 接口異常(包括與後端和 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,該插件能夠在 babel 編譯 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 加載失敗的鉤子監控網頁崩潰等。

如何監控網頁崩潰?

最後,由於部署到線上的代碼通常都是通過壓縮混淆的,若是沒有上傳 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,當打包時觸發 webpack 的 after-emit 事件鉤子(即生成資源到 output 目錄以後),插件會自動上傳打包目錄中的 sourcemap 和關聯的 js,相關配置可參考本項目的 vue.config.js 文件。

一般爲了安全,是不容許在線上部署 sourcemap 文件的,因此上傳 sourcemap 到 sentry 後,可手動刪除線上 sourcemap 文件。

常見問題

  • iOS WKWebView cookie 寫入慢以及易丟失

    現象:

    1. iOS 登錄後當即進入網頁,會出現 cookie 獲取不到或獲取的上一次登錄緩存的 cookie
    2. 重啓 App 後,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 傳輸機制,沒法享受 這種機制帶來的安全特性。

    各位能夠選擇適合本身項目的方式,有更好的處理方式歡迎留言。

  • input 標籤在部分安卓 webview 上沒法實現上傳圖片功能

    由於 Android 的版本碎片問題,不少版本的 WebView 都對喚起函數有不一樣的支持。咱們須要重寫 WebChromeClient 下的 openFileChooser()(5.0 及以上系統回調 onShowFileChooser())。咱們經過 Intent 在 openFileChooser()中喚起系統相機和支持 Intent 的相關 app。

    相關文章: 【Android】WebView 的 input 上傳照片的兼容問題

  • 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());
    複製代碼
相關文章
相關標籤/搜索