扣丁書屋

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

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

作者:mucking| 來源:掘金 https://juejin.im/post/5d759f706fb9a06afa32adec

筆者在公司用 web 技術開發移動端應用已經有一年多的時間了,開始主要以 vue 技術棧配合 native 為主,目前演進成 vue + react native 技術架構,vue 主要負責開發 OA 業務,比如報銷、出差、crm 等等,react native 主要負責即時通信部分,是在 mattermost-mobile的基礎上修改的(mattermost 是一個開源的即時通訊方案)。 因為公司在這方面沒有太多技術沉淀,所以在開發期間遇到了很多坑,經過一年多的技術攻克積累,最終形成了這套比較完善的解決方案,總結出來希望能夠幫助到大家,尤其是對一些中小公司這方面經驗不足的(PS: 大公司估計有他們自己的一套方案了)。

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

mobile-web-best-practice

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

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

目錄

  • 組件庫

  • JSBridge

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

  • 請求數據緩存

  • 構建時預渲染

  • Webpack 策略

  • 基礎庫抽離

  • 手勢庫

  • 樣式適配

  • 表單校驗

  • 阻止原生返回事件

  • 通過 UA 獲取設備信息

  • mock 數據

  • 調試控制臺

  • 抓包工具

  • 異常監控平臺

  • 常見問題

組件庫

vant

vux

mint-ui

cube-ui

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 來運行整個網站,再開啟多個無頭瀏覽器(例如 Puppeteer、Phantomjs 等無頭瀏覽器技術)去請求項目中所有的路由,當請求的網頁渲染到第一個需要預渲染的頁面時(需提前配置需要預渲染頁面的路由),會主動拋出一個事件,該事件由無頭瀏覽器截獲,然后將此時的頁面內容生成一個 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[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 加載失敗的鉤子監控網頁崩潰等。

如何監控網頁崩潰?

最后,因為部署到線上的代碼一般都是經過壓縮混淆的,如果沒有上傳 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 寫入慢以及易丟失

    現象:

    原因: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 會丟失

  5. input 標簽在部分安卓 webview 上無法實現上傳圖片功能

因為 Android 的版本碎片問題,很多版本的 WebView 都對喚起函數有不同的支持。我們需要重寫 WebChromeClient 下的 openFileChooser()(5.0 及以上系統回調 onShowFileChooser())。我們通過 Intent 在 openFileChooser()中喚起系統相機和支持 Intent 的相關 app。

相關文章:【Android】WebView 的 input 上傳照片的兼容問題 68. input 標簽在 iOS 上喚起軟鍵盤,鍵盤收回后頁面不回落(部分情況頁面看上去已經回落,實際結構并未回落)

input 焦點失焦后,ios 軟鍵盤收起,但沒有觸發 window resize,導致實際頁面 dom 仍然被鍵盤頂上去--錯位。解決辦法:全局監聽 input 失焦事件,當觸發事件后,將 body 的 scrollTop 設置為 0。

  document.addEventListener('focusout', () => {
  document.body.scrollTop = 0;
});
  1. 喚起軟鍵盤后會遮擋輸入框

當 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);
  }
});
  1. 喚起鍵盤后 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';
  }
});
  1. 點擊網頁輸入框會導致網頁放大通過 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"
/>
  1. 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

最多閱讀

為Electron程序添加運行時日志 3年以前  |  14736次閱讀
Node.js下通過配置host訪問URL 3年以前  |  4753次閱讀
js動態創建類和實例化 3年以前  |  3891次閱讀
wordpress標簽頁的制作 3年以前  |  3723次閱讀
初探 React 組件 3年以前  |  3703次閱讀
用 esbuild 讓你的構建壓縮性能翻倍 2年以前  |  3676次閱讀
500行PHP代碼搞定富文本安全過濾 3年以前  |  3594次閱讀
10 種跨域解決方案(附終極方案) 2年以前  |  3476次閱讀
22個HTML5的初級技巧 3年以前  |  3473次閱讀
MBTI免費在線測試 3年以前  |  3422次閱讀
使用 SRI 增強 localStorage 代碼安全 3年以前  |  3351次閱讀
淺談瀏覽器的原生拖拽事件 3年以前  |  3347次閱讀
CSS清除浮動 3年以前  |  3336次閱讀
第三版主題上線 3年以前  |  3274次閱讀
利用服務器返回header來傳輸數據 3年以前  |  3248次閱讀

手機掃碼閱讀
18禁止午夜福利体验区,人与动人物xxxx毛片人与狍,色男人窝网站聚色窝
<蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <文本链> <文本链> <文本链> <文本链> <文本链> <文本链>