歡迎來(lái)到(dào)深圳市博易美科技有限公司網站(zhàn)!
人(rén)
已閱讀(dú)

基于Vue和(hé)TS的(de)Web移σβ動端發開(kāi)項目實戰心得(de)

來(lái)源:lexintech.com     α'§  發布時(shí)間(jiān):2019-09-27
筆(bǐ)者在公司用(yòng) web 技(jì)術(shù)≥∞β開(kāi)發移動端應用(yòng)已經有♠↑(yǒu)一(yī)年(nián)多(duō)的(de)時(shí)間(≈πjiān)了(le),開(kāi)始主要(yào)以 vue 技(jì)術(s✘↓hù)棧配合 native 為(wèi)主,目前演進成 vue ★γ★ + react native 技(jìλ←)術(shù)架構,vue 主要(yào)負責開(kāi)發 OA$‌ 業(yè)務,比如(rú)報(bào)銷、出差、c®‍'rm 等等,react native 主要(yào)負責即時(shíε✘&®)通(tōng)信部分(fēn),是(shì)在 mattermost-mobile[1] 的(de)基礎上(shàng)修改的(de)(matteγ¶rmost 是(shì)一(yī)個(gè)開(kāi)源®§✘↔的(de)即時(shí)通(tōng)訊方案)。

因為(wèi)公司在這(zhè)方面沒有(yǒu)太多(d≥☆×uō)技(jì)術(shù)沉澱,所以在開(kāi)發期間(ji€≈☆ān)遇到(dào)了(le)很(hěn)多α♠•β(duō)坑,經過一(yī)年(nián)多(duō)的(de)技(jì π)術(shù)攻克積累,最終形成了(le)這(zhè)套比較完善的(d​‌ε✔e)解決方案,總結出來(lái)希望能(néng)夠幫助到≈  ≠(dào)大(dà)家(jiā),尤其是(shì)對(d≈ uì)一(yī)些(xiē)中小(xiǎo)公司這(zh±¥×↑è)方面經驗不(bù)足的(de)(PS: 大(dà)公司估計(←¥< jì)有(yǒu)他(tā)們自(zì)己的(de‍©)一(yī)套方案了(le))。

好(hǎo)了(le)廢話(huà)不(bù)多(duō)說(shuōπ ),先亮(liàng)下(xià)這(zhè)個∏¶(gè)庫的(de) GitHub 地(dì)址,後面還(hái)會(h$β→ uì)不(bù)斷完善,歡迎 star:

mobile-web-best-practice[2]

移動端 web 最佳實踐,基于 vue-cli3[3] 搭建的(de) typescript[4] 項目,可(kě)以用(yòng)↕±Ω于 hybrid 應用(yòng)或者純 weba₩☆®pp 開(kāi)發。以下(xià)大(dà)部分(fēn)內(nèi)®↕♥容同樣适用(yòng)于 react[5] 等前端框架。

其中有(yǒu)三個(gè)點尚在完善中:領域驅動設計(jì$§♦®)(DDD)應用(yòng)、微(wēi)前端、性能(néng)‌π‌≠監控,後續完成後會(huì)以單獨的(de)文(wén)章(zhāng>↓φ)發出來(lái)。其中性能(néng)監控還(hái)沒有β★‍±(yǒu)太好(hǎo)的(de)選擇,類似錯(cuòλ↓‌)誤監控 sentry 那(nà)種開(kāi)源免費(fèi)而且功§‌δα能(néng)強大(dà)的(de)工(gōng)具,如(rú)果有σ α(yǒu)人(rén)知(zhī)道(dào)的(d≠​e)麻煩告知(zhī)下(xià)。文(wén)中難免有(yǒ→'>u)些(xiē)錯(cuò)誤或者更好(h§→ǎo)的(de)方案,也(yě)歡迎不(bù)吝賜教。

目錄

  • 組件(jiàn)庫[6]

  • JSBridge[7]

  • 路(lù)由堆棧管理(lǐ)(模拟原生(shēng) APP×$ 導航)[8]

  • 請(qǐng)求數(shù)據緩存[9]

  • 構建時(shí)預渲染[10]

  • Webpack 策略[11]

    • 基礎庫抽離(lí)[12]

  • 手勢庫[13]

  • 樣式适配[14]

  • 表單校(xiào)驗[15]

  • 阻止原生(shēng)返回事(shì)件(jiàn)[16]

  • 通(tōng)過 UA 獲取設備信息[17]

  • mock 數(shù)據[18]

  • 調試控制(zhì)台[19]

  • 抓包工(gōng)具[20]

  • 異常監控平台[21]

  • 常見(jiàn)問(wèn)題[22]

組件(jiàn)庫

vant[23]

vux[24]

mint-ui[25]

cube-ui[26]

vue 移動端組件(jiàn)庫目前£ 主要(yào)就(jiù)是(shì)上(shàng)面羅列≥↑✘€的(de)這(zhè)幾個(gè)庫,本項目使用(yòng)∞≈的(de)是(shì)有(yǒu)贊前端團隊開(kāi)源的(de) v$×ant。

vant 官方目前已經支持自(zì)定義樣式主題,基本原理(↓÷lǐ)就(jiù)是(shì)在 less-loader[27] 編譯 less[28] 文(wén)件(jiàn)到(dào) css 文(wén)件(★™jiàn)過程中,利用(yòng) less₩✔≈$ 提供的(de) modifyVars[29] 對(duì) less 變量進行(xíng)修改×γ,本項目也(yě)采用(yòng)了(le)該>φ¶≈方式,具體(tǐ)配置請(qǐng)查看(kàn)相(xiàng)關文(δ£δ↑wén)檔:

定制(zhì)主題[30]

推薦一(yī)篇介紹各個(gè)組件(jiàn)庫特點的(de)文(wén♠‍)章(zhāng):

Vue 常用(yòng)組件(jiàn)σ± 庫的(de)比較分(fēn)析(移動端)[31]

JSBridge

DSBridge-IOS[32]

DSBridge-Android[33]

WebViewJavascriptBridge[34]

混合應用(yòng)中一(yī)般都(dōu)是(shì)通(t≤γλōng)過 webview 加載網頁,而當網頁要(yào)獲取™£設備能(néng)力(例如(rú)調用(yòng)攝  ↓像頭、本地(dì)日(rì)曆等)或者 native★∏δσ 需要(yào)調用(yòng)網頁裡(lǐ)的(de)方法,就(¶←↑¥jiù)需要(yào)通(tōng)過 JSBridg¶← e 進行(xíng)通(tōng)信。

開(kāi)源社區(qū)中有(yǒu)很(hě★>n)多(duō)功能(néng)強大(dà)的(de) J§αSBridge,例如(rú)上(shàng)面列舉的(de)"≈λ♣庫。本項目基于保持 iOS android 平台接口統一(yī)<& 原因,采用(yòng)了(le) DSBridge,各位可(÷≈< kě)以選擇适合自(zì)己項目的(de)工(gōng)具。

本項目以 h5 調用(yòng) na ↓♥tive 提供的(de)同步日(rì)曆接口為(wèi)例,演示如(r$✘ú)何在 dsbridge 基礎上(shàn‌¥∏×g)進行(xíng)兩端通(tōng)信λ↓的(de)。下(xià)面是(shì)兩端的(de)關鍵代碼摘要(↔γ✔₽yào):

安卓端同步日(rì)曆核心代碼,具體(tǐ)代碼請↑✔←✘(qǐng)查看(kàn)與本項目配套的(de)安卓項目 mobile-web-best-practice-contai↕≠ner[35]

public class JsApi {
    /**
     * 同步日(rì)曆接口
     * msg↔↓©↑ 格式如(rú)下(xià):
     *£≥©₽ ...
     */
    @JavascriptInterface
    public void syncCalendar(Object msg, CompletionHandl&≤Ω÷er handler) {
        try {
            JSONObject ob¥‍¥←j = new JSONObject(msg.toString());
    β∏♦        String id = obj.getString≤$÷σ("id");
            String title = o✘♦bj.getString("title");
            String location = obj.g​✘$€etString("location");
            long startTime = obj.getLong("startTime");
            long endTime = obj.getLong("endTime");
            JSONArray σα$↑earlyRemindTime = obj.getJ$≤SONArray("alarm");
            String res = Calend÷‌∞≥arReminderUtils.addCalend₽←arEvent(id, title, location,★★ startTime, endTime,"β& earlyRemindTime);
          'π ¶  handler.complete(In€∑®∑teger.valueOf(res));
        } catch (Exception e) {
            e.pr¶∞intStackTrace();
            handler.c‍•omplete(6005);
        }
    }
}

h5 端同步日(rì)曆核心代碼(通(tōngλ₽↑)過裝飾器(qì)來(lái)限制(zhì)調用(yòng)接口的(de←≤)平台)

class NativeMethods {
  // 同步到(dào)日(rì)曆
  @p()
  public syncCalendar(params: SyncCalendarPa↕‌<→rams) {
    const cb = (errCode: number) => {
      const msg = NATIVE_ERROR_CODE_MAP[errCode];
©☆←‌
      Vue.prototype.$toaγ&♥‍st(msg);

      if (errCode !== 6000) {
        this.errorReport(msg, 'syncCalendar', params);
      }
   φ↕ };
    dsbridge.call('syncCalendar', params, cb);
  }

  // 調用(yòng) native 接口出錯(cuò)向 Ω&'λsentry 發送錯(cuò)誤信息
  private errorReport(errorMsg: string, methodName: string, params: any) {
    if (window.$sentry) {
      const errorInfo: NativeApiErrorInfo = {
    ←↔     error: new Error(errorMsg),
        type: 'callNative',
        methodName,
        paφ☆✘♦rams: JSON.stringify(params)
      };
      window.$sentry.log(errorInfo);
    ♣$∞}
  }
}

/**
 * @param {plat∞→‌forms} - 接口限制(zhì)的(de)平台
 * ✘• @return {Function} - 裝飾器(qì)
 */
function p(platforms = ['android', ₩"π÷9;ios']) {
  return (target: AnyObject, name: string, descriptor: PropertyDe•βscriptor) => {
    if (!platforms.includes(window.$platform)) {
      descriptor.va¶§♠γlue = () => {
        return Vue.prototype.$toast(
          `當前處在 ${window.$platform} 環境,無法調用(yòng)接口哦`
        );
      };
    }

    return descriptor;
  };
}

另外(wài)推薦一(yī)個(gè)筆(bǐ±↔¥)者之前寫的(de)一(yī)個(gè)基于安卓平台實現(xiàn)的(≠σ>£de)教學版 JSBridge[36],裡(lǐ)面詳細闡述了(le)如(rú)何基于底層接口一(yī↓€)步步封裝一(yī)個(gè)可(kě)用(yòng)的(de) ¥"∞JSBridge:

JSBridge 實現(xiàn)原理(lǐ)[37]

路(lù)由堆棧管理(lǐ)(模拟原生(shēσ±ng) APP 導航)

vue-page-stack[38]

vue-navigation[39]

vue-stack-router[40]

在使用(yòng) h5 開(kāi)發 app,會(h™δ ₩uì)經常遇到(dào)下(xià)面的(de)需求:從(có♦∏→ng)列表進入詳情頁,返回後能(néng)夠記住當前位置Ωεδ™,或者從(cóng)表單點擊某項進入到(dào)其他(tā)頁面選擇,然後回到★≈(dào)表單頁,需要(yào)記住之前表單填寫的(de)≤φ數(shù)據。可(kě)是(shì)目前 vue 或 react✘∏‌ 框架的(de)路(lù)由,均不(bù)支持同時(‌∞§≥shí)存在兩個(gè)頁面實例,所以需要(yào)路(lù™↔♦¥)由堆棧進行(xíng)管理(lǐ)。

其中 vue-page-stack 和(hé) vue-navδ<∏igation 均受 vue 的(de) keepal​®ive 啓發,基于 vue-router[41],當進入某個(gè)頁面時(shí),會←★∑<(huì)查看(kàn)當前頁面是(shì)否有(yǒu)緩存,有↕ α©(yǒu)緩存的(de)話(huà)就(jiù)  ‌σ取出緩存,并且清除排在他(tā)後面的(de)所有(yǒu)¶ ±✔ vnode,沒有(yǒu)緩存就(jiùπ≈∏)是(shì)新的(de)頁面,需要(yφ✔ào)存儲或者是(shì) replace 當前頁面,向棧裡φ♠(lǐ)面 push 對(duì)應的(de) vnode,從(cóng)而↑€¥實現(xiàn)記住頁面狀态的(de)功能(néng)。

而邏輯思維前端團隊的(de) vue-stack-r↓λ✔§outer 則另辟蹊徑,抛開(kāi)了(le) vue-router,‍∞≤✔自(zì)己獨立實現(xiàn)了(le)路(lù)由管理(lǐ),相γσ(xiàng)較于 vue-router,主要(yào)是(shì)支持同時(¶₽shí)可(kě)以存活 A 和(hé) B 兩個(gè)頁面的✔ α→(de)實例,或者 A 頁面不(bù)同狀态的(de)₹‍兩個(gè)實例,并支持原生(shēng)左滑功能(néng)。但(dànπα♦)由于項目還(hái)在初期完善,功能(néng)還(hái)沒有(yǒu)σ☆£ vue-router 強大(dà),建議(yì)持續關注後續動态再做♣‌(zuò)決定是(shì)否引入。

本項目使用(yòng)的(de)是(shì) vue-page∑ -stack,各位可(kě)以選擇适合自(z±•♠ì)己項目的(de)工(gōng)具。同時(shí)推薦幾篇相(xiàng'±©)關文(wén)章(zhāng):

【vue-page-stack】Vue 單頁應用(yòng)導航管理(∏♦↔lǐ)器(qì) 正式發布[42]

Vue 社區(qū)的(de)路(lù)由π₹解決方案:vue-stack-router[43]

請(qǐng)求數(shù)據緩存

mem[44]

在我們的(de)應用(yòng)中,Ω"$會(huì)存在一(yī)些(xiē)很(hěn)少(shǎo)改動的(‍αde)數(shù)據,而這(zhè)些(xiē)數(shù✔σ≥)據有(yǒu)需要(yào)從(cóng)後端獲取,比如(rú)公司人(γ↔rén)員(yuán)、公司職位分(fēn)類等,此類數(shù≈♣)據在很(hěn)長(cháng)一(yī)段時(shí)間(j∏ ‍iān)時(shí)不(bù)會(huì)改變的(de×™₹‌),而每次打開(kāi)頁面或切換頁面時(shí),≈∏<就(jiù)重新向後端請(qǐng)求。為(wèi)了(le)能(néng)≠‌‌ 夠減少(shǎo)不(bù)必要(yào)請(qǐng)求,‍δ加快(kuài)頁面渲染速度,可(kě)以引用(yòng) mem 緩存庫。

mem 基本原理(lǐ)是(shì)通(tōng)↑ ₽過以接收的(de)函數(shù)為(wèi♠±) key 創建一(yī)個(gè) WeakMap,然後∞↔再以函數(shù)參數(shù)為(wèi) key 創建一(€<yī)個(gè) Map,value 就(jiù)是(shì)™¥₽函數(shù)的(de)執行(xíng)結果,同時(shí)将這(z↕δ ↔hè)個(gè) Map 作(zuò)為(wèi)剛剛的₩♠≥±(de) WeakMap 的(de) value 形≈ 成嵌套關系,從(cóng)而實現(xiàn)對(duì)同一(yī)個(g¥∞è)函數(shù)不(bù)同參數(shù)進φ£π行(xíng)緩存。而且支持傳入 maxAge,即數(shù)據的(✔≥de)有(yǒu)效期,當某個(gè)數(shù)據到(dào)→←♣α達有(yǒu)效期後,會(huì)自(zì)動銷毀,避免內(nèi)存洩漏。↓✔₩

選擇 WeakMap 是(shì)因為(wèi)其相(x'•<iàng)對(duì) Map 保持對(duì) φ鍵名所引用(yòng)的(de)對(duì)象是(shì)弱引用(yòn​λπ​g),即垃圾回收機(jī)制(zhì)不(b$♣×→ù)将該引用(yòng)考慮在內(nèi)。隻要(yào)所引δ¶★用(yòng)的(de)對(duì)象的(de)其他(tā)引用(yòn¥∏↕g)都(dōu)被清除,垃圾回收機(jī)制(z♠<→hì)就(jiù)會(huì)釋放(fàng)該對(duì)¥∞≥Ω象所占用(yòng)的(de)內(nèi♥≤φπ)存。也(yě)就(jiù)是(shì)說(shuō),一(yī)>•α旦不(bù)再需要(yào),WeakMap 裡(lǐ)♣ $&面的(de)鍵名對(duì)象和(hé)所對πφ (duì)應的(de)鍵值對(duì)會(h✘Ωεuì)自(zì)動消失,不(bù)用(yòng)手動删除引用(yò ♥₩ng)。

mem 作(zuò)為(wèi)高(gāo)階函數(shù),可(↓λ↓kě)以直接接受封裝好(hǎo)的(de)αλ接口請(qǐng)求。但(dàn)是(shì)為(wèi)了≥♠§(le)更加直觀簡便,我們可(kě)以按照•↓ (zhào)類的(de)形式集成我們的(de<λ∞≥)接口函數(shù),然後就(jiù)可(kě)以用(yòng)<<♠φ裝飾器(qì)的(de)方式使用(yòng) m≤>em 了(le)(裝飾器(qì)隻能(néng)修飾類和≥£"(hé)類的(de)類的(de)方法,因為(wèi)普通"α(tōng)函數(shù)會(huì)存在變量提升)。下(xià)♣αα 面是(shì)相(xiàng)關代碼:

import http from '../http';
import mem from 'mem';

/**
 * @param {MemOption} - mem 配置項
 φα‍ * @return {Function} - 裝飾器(q→✘ì)
 */
export default function m(options: AnyObject) {
  return (target: AnyObject, name: string, descriptor: PropertyDescrip÷"tor) => {
    const oldValue = descriptor.val♦₩±ue;
    descriptor.valu£↕e = mem(oldValue, options);
    return descriptor;
  };
}

class Home {
  @m({ maxAge: 60 * 1000 })
  public async getUnderlingDailyList(
   ↕✔¥₽ query: ListQuery
  ): Promise<{ total: number; list: DailyItem[] }> {
    const {
      data: { total, lis•€ δt }
    } = await http({
      method: 'post',
      url: '/daily/getList'α<★,
      data: query
    });

    return { total, list };
  }
}

export default new Home();

 

構建時(shí)預渲染

針對(duì)目前單頁面首屏渲染時(shí)間(jiān∏∞≤÷)長(cháng)(需要(yào)下(xià)載解析 js 文(wén)✔₩件(jiàn)然後渲染元素并挂載到(dào) id  ¥<為(wèi) app 的(de) div 上(shàng)),↔ ©×SEO 不(bù)友(yǒu)好(hǎo)(index.₩☆↑html 的(de) body 上(shàng)實Ω 際元素隻有(yǒu) id 為(wèi) app 的(de) div♣→↓✘ 元素,真正的(de)頁面元素都(dōu)是(shì)動态₽↓Ω"挂載的(de),搜索引擎的(de)爬蟲無法捕捉到(dào)),α≈目前主流解決方案就(jiù)是(shì)服務端渲染(≤≠≥SSR),即從(cóng)服務端生(shēng)成組裝好(hǎo)的(de)完™​™ 整靜(jìng)态 html 發送到(dào)✘ ∑浏覽器(qì)進行(xíng)展示,但(dàn)配置↔£±較為(wèi)複雜(zá),一(yī)般都(dōu)會(hu왩α )借助框架,比如(rú) vue 的(de) nuxt.js[45],react 的(de) next[46]

其實有(yǒu)一(yī)種更簡便的(de)方β£βε式--構建時(shí)預渲染。顧名思義,就(ji¥≥↔‍ù)是(shì)項目打包構建完成後,啓動一(yī)個÷‌δ(gè) Web Server 來(lái)運行(xíng)整個(gè)網站(€'δzhàn),再開(kāi)啓多(duō)個(gè)無頭浏覽器(qì)(₩φβ例如(rú) Puppeteer[47]Phantomjs[48] 等無頭浏覽器(qì)技(jì)術(s¥φ♣hù))去(qù)請(qǐng)求項目中所有(yǒu)的(de)路(lù)由¶→ ¶,當請(qǐng)求的(de)網頁渲染到(dào)第一(y£β®☆ī)個(gè)需要(yào)預渲染的(de)頁面時(shí)(需↕£€≥提前配置需要(yào)預渲染頁面的(de)♥✔>路(lù)由),會(huì)主動抛出一(÷​λλyī)個(gè)事(shì)件(jiàn),®×λ>該事(shì)件(jiàn)由無頭浏覽器(qì)截獲,然後将此時(s×≤hí)的(de)頁面內(nèi)容生(shēng)成一(yī)個∏≠♦'(gè) HTML(包含了(le) JS 生(shēng)成的(de) DOM₹  結構和(hé) CSS 樣式),保存到(d"‍≤♥ào)打包文(wén)件(jiàn)夾中§αε✘。

根據上(shàng)面的(de)描述λ♦,我們可(kě)以其實它本質上(shàng)就(jiù)隻φ♣ ✔是(shì)快(kuài)照(zhào)₽₹₩頁面,不(bù)适合過度依賴後端接口的(♠ ✔∏de)動态頁面,比較适合變化(huà)不(bù)頻(pín)繁的✔φ(de)靜(jìng)态頁面。

實際項目相(xiàng)關工(gōng)具方面比較推薦 prerender-spa-plugin<δδβ[49] 這(zhè)個(gè) webpack 插件(ji 'àn),下(xià)面是(shì)這(zhè)個(gè)插≥ ♠件(jiàn)的(de)原理(lǐ)圖。π©不(bù)過有(yǒu)兩點需要(yào)注意:

一(yī)個(gè)是(shì)這(zhè)個(gè)插件( ε≥&jiàn)需要(yào)依賴 Puppet<★¶eer,而因為(wèi)國(guó)內(nèi)網絡原因以及σ"本身(shēn)體(tǐ)積較大(dà),經常‍δ下(xià)載失敗,不(bù)過可(kě)以通•φ✔±(tōng)過 .npmrc 文(wén)件(jiàn)指定 Puppet¥↕ eer 的(de)下(xià)載路(lù)徑為(wèi)國(guó)內(φ nèi)鏡像;

另一(yī)個(gè)是(shì)需要♦ α§(yào)設置路(lù)由模式為(wèi) h‌☆istory 模式(即基于 html5 提供的(de) history apiβ•ε 實現(xiàn)的(de),react 叫 BrowserRouter,"♠δ×vue 叫 history),因為(wèi) hash 路(lù)由無法對(d€↕uì)應到(dào)實際的(de)物(wù)理(lǐ)路(lù)由ασ"™。(即線上(shàng)渲染時(shí) history 下(xi±♥¥§à),如(rú)果 form 路(lù)由&↓被設置成預渲染,那(nà)麽訪問(wèn) /form/ 路(lù)由時(sh♠≠≈í),會(huì)直接從(cóng)服務端返回 form 文(w★€én)件(jiàn)夾下(xià)的(de★÷♠™) index.html,之前打包時(shí)就(jiù)已≥<₽經預先生(shēng)成了(le)完整的(de) HTML π​ 文(wén)件(jiàn) )

本項目已經集成了(le) prerender-±γspa-plugin,但(dàn)由于和(hé) vue-stac☆©≠±k-page/vue-navigation 這(zhè•"∑λ)類路(lù)由堆棧管理(lǐ)器(qì)一(yī)起使用¶λ€≠(yòng)有(yǒu)問(wèn)題(原因還(hái)在查找,如(rú)≠≤>←果知(zhī)道(dào)的(de)朋(pén‌§ g)友(yǒu)也(yě)可(kě)以告知(zhī)下(xià)),所以 p♥≠∏rerender 功能(néng)是(shì)關閉的(♦£÷↕de)。

同時(shí)推薦幾篇相(xiàng)關文(wén)章(zhāng)♣Ω↔:

vue 預渲染之 prerender-spa-pl≠ λugin 解析(一(yī))[50]

使用(yòng)預渲提升 SPA 應用(yòng)♣$體(tǐ)驗[51]

Webpack 策略

基礎庫抽離(lí)

對(duì)于一(yī)些(xiē)基礎庫,例如(rú) vβ∏ue、moment 等,屬于不(bù)經常變化(hu∏ λ♥à)的(de)靜(jìng)态依賴,一(β↕yī)般需要(yào)抽離(lí)出來(láiδ≤)以提升每次構建的(de)效率。目前主流方案有(yǒu)兩種:

一(yī)種是(shì)使用(yònγβ∞g) webpack-dll-plugin[52] 插件(jiàn),在首次構建時(sh↔÷í)就(jiù)講這(zhè)些(xiē)靜(jìng)态依賴✔¶™單獨打包,後續隻需引入早已打包好(hǎo)的(d£♦e)靜(jìng)态依賴包即可(kě);

另一(yī)種就(jiù)是(shì)外(wài)部 ε•✔擴展 Externals[53] 方式,即把不(bù)需要(yào)打包的(de)靜 ©γ(jìng)态資源從(cóng)構建中剔除,使用(yòng) CDN 方式•♣₩引入。下(xià)面是(shì) webpack-dll-pl•™≈ugin 相(xiàng)對(duì) Externalsφ≥> 的(de)缺點:

  1. 需要(yào)配置在每次構建時(shí)都(dōu↕¥)不(bù)參與編譯的(de)靜(jìng≠λ)态依賴,并在首次構建時(shí)為(wèi)它們預編譯ε 出一(yī)份 JS 文(wén)件(jià'€n)(後文(wén)将稱其為(wèi) lib 文(wén​→)件(jiàn)),每次更新依賴需要(yà♣±o)手動進行(xíng)維護,一(yī)旦增删依賴或∞γ者變更資源版本忘記更新,就(jiù)會(huì)出<<♥現(xiàn) Error 或者版本錯(c ∞uò)誤。

  2. 無法接入浏覽器(qì)的(de)新特性 script ty<₩≤£pe="module",對(duì)于某些(xiē'&)依賴庫提供的(de)原生(shēng) ES Modu™αles 的(de)引入方式(比如(rú) vue ε¶ 的(de)新版引入方式)無法得(de)到(dàoλ≈)支持,沒法更好(hǎo)地(dì)适配高(gāo)版本浏覽器(qìΩ&γ)提供的(de)優良特性以實現(xiàn)更好(&Ωhǎo)地(dì)性能(néng)優化(huà)。

  3. 将所有(yǒu)資源預編譯成一(yī)份文(wén)件(ji₽∏↔àn),并将這(zhè)份文(wén)件(jiàn)顯式注入項目構建的(↓§ de) HTML 模闆中,這(zhè)樣的(de)做(zuò¶÷)法,在 HTTP1 時(shí)代是(shì)被推∞‍☆>崇的(de),因為(wèi)那(nà)樣能(néng✘€‌★)減少(shǎo)資源的(de)請(qǐng)求數(shù)量,但>♣&(dàn)在 HTTP2 時(shí)代¥₩ 如(rú)果拆成多(duō)個(gè) CDN Link,就(jiù)能©£∏←(néng)夠更充分(fēn)地(dì)利用(yòng) HTTP2 的(♣₽€de)多(duō)路(lù)複用(yòng)特性。

不(bù)過選擇 Externals 還(hái)是(shì)↓ε©σ需要(yào)一(yī)個(gè)靠譜的(de) CDN ∑'服務的(de)。

本項目選擇的(de)是(shì) Externals,各位可(kě ←)根據項目需求選擇不(bù)同的(de)方案。≥δ

更多(duō)內(nèi)容請(qǐng)查看(kàn)這(zhè)'☆篇文(wén)章(zhāng)(上(shàng)面觀點來(lái)自∑×(zì)于這(zhè)篇文(wén)章(zhāng)):

Webpack 優化(huà)——将你(nǐ)的₹✔(de)構建效率提速翻倍[54]

手勢庫

hammer.js[55]

AlloyFinger[56]

在移動端開(kāi)發中,一(yī)般都(dōu)需要(yào)₹↕δ∞支持一(yī)些(xiē)手勢,例如(rú)拖動(P¥&an),縮放(fàng)(Pinch),旋轉(Rotate),滑動(₽↔≈swipe)等。目前已經有(yǒu)很(hěn)成熟"♠∏的(de)方案了(le),例如(rú) hammer.js λ•​和(hé)騰訊前端團隊開(kāi)發的(de) AlloyFinge ✘∏r 都(dōu)很(hěn)不(bù)錯(cuò)。本項目選♠↑&♥擇基于 hammer.js 進行(xíng)二次封裝成 vue 指¶¶Ω令集,各位可(kě)根據項目需求選擇不(bù)同的(dα™e)方案。

下(xià)面是(shì)二次封裝的(de)關鍵代碼,其中用(yòng)到'♠(dào)了(le) webpack 的(¥↔de) require.context 函數(shù)" ₽來(lái)獲取特定模塊的(de)上(sh§$àng)下(xià)文(wén),主要(yào)用(yòng)來(láσ≠λi)實現(xiàn)自(zì)動化(huà)導入模塊,比較适用(y↑ ✘òng)于像 vue 指令這(zhè)種模塊較多(duō)的(de)場¶‍'"(chǎng)景:

// 用(yòng)于導入模塊的(de)上(shàng)下(xià)™♠文(wén)
export const importAll = (
  context:↓★ __WebpackModuleApi.RequireCo®>β>ntext,
  options: ImportA‌₩llOptions = {}
): AnyObject => {
  const { useDefault = true, keyTransformFunc, filterFunc } = opt©♥ions;

  let keys = context.keys();

  if (isFunction(filterFu ♥‌nc)) {
    keys = keys&↑§.filter(filterFunc);
  γ&™☆}

  return keys.reduce((acc: AnyObject, curr: string) => {
    const key = isFunction(keyTransformFunc) ? ke&∑☆yTransformFunc(curr) : curλ™α↓r;
    acc[key] = use& Default ? context(curr).default ★¶↓: context(curr);
    return acc;
  }, {});
};

// directives 文(wén)件(™₹jiàn)夾下(xià)的(de) ind​±δ∏ex.ts
const directvieContext = require.context('./', false, /.ts$/);
const directives = importAll(directvieContδε♣ext, {
  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, b♥÷©∏inding: 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(mod'<∑ifiers);
        hammer.on(`pan${panEvent}`, listener);
        break;
      ...
    }
€α£♥
  }
};

另外(wài)推薦一(yī)篇關于 hammer.js↕'≤☆ 和(hé)一(yī)篇關于 require.cont→≥×✘ext 的(de)文(wén)章(zhāng≥$₽):

H5 案例分(fēn)享:JS 手勢框架 &mβλdash;— Hammer.js[57]

使用(yòng) require.context 實現(x±☆iàn)前端工(gōng)程自(zì)動化(huà•")[58]

樣式适配

postcss-px-to-viewport[59]

Viewport Units Buggyfill[60]

flexible[61]

postcss-pxtorem[62]

Autoprefixer[63]

browserslist[64]

在移動端網頁開(kāi)發時(shí),樣式适配始終是(sh≥☆≥ì)一(yī)個(gè)繞不(bù)開(kāi✘↕♥$)的(de)問(wèn)題。對(duì)此目前主流方案有(yǒ≈÷u) vw 和(hé) rem(當然還(hái)有(yǒu) ¥₩εvw + rem 結合方案,請(qǐng)見(jiàn) ←&下(xià)方 rem-vw-layout₹♦ 倉庫),其實基本原理(lǐ)都(dōu)是(shì)相(xiàng)通(☆¶'tōng)的(de),就(jiù)是(shì)随著(zhe)屏幕寬度或β ↓字體(tǐ)大(dà)小(xiǎo)成正比變化(huà)。因為(wèi)原σ£理(lǐ)方面的(de)詳細資料網絡上(shàng)已πε↓經有(yǒu)很(hěn)多(duō)了(le×♥),就(jiù)不(bù)在這(zhè)裡α§‍(lǐ)贅述了(le)。下(xià)面主要(yào)提供一(yī)些(xiē×™∑★)這(zhè)工(gōng)程方面的(de)工(gōng)具。

關于 rem,阿裡(lǐ)無線前端團隊在 15 年(ni± αán)的(de)時(shí)候基于 re‍α≈←m 推出了(le) flexible 方案,以及 postcssφ  提供的(de)自(zì)動轉換 px 到(d₩•ào) rem 的(de)插件(jiàn) postcss-pxtore÷✔→m。

關于 vw,可(kě)以使用(yòng) postcss-pxλ‌-to-viewport 進行(xíng)自(←☆≥πzì)動轉換 px 到(dào) vw。∞♣•postcss-px-to-viewport 相(​∞xiàng)關配置如(rú)下(xià):

"postcss-px-to-v±γiewport": {
  viewportWidth: 375, // 視(shì)窗(chuāng)的(dσΩ>e)寬度,對(duì)應的(de)是(shì)我們設計(jì)稿的(de)♦≤寬度,一(yī)般是(shì)375
  viewportHeight: 667, // 視(shì)窗(chuāng)的(σδde)高(gāo)度,根據750設備的(de)寬度來(l∑ ái)指定,一(yī)般指定1334,也(yě)↕‌可(kě)以不(bù)配置
  unitPrecision: 3,  // 指定`px`轉換為(wèi)視(s Ωhì)窗(chuāng)單位值的(de)小≤∞α★(xiǎo)數(shù)位數(shù)(很(hěn)•γ多(duō)時(shí)候無法整除)
  viewportUnit: 'vw', // 指定需要(yào)轉換成的(de)視(shì)窗✘₩‌(chuāng)單位,建議(yì)使用(yòng)vw ✔•
  selectorBlackList: ['.ignore', '.hairlines'], // 指定不(bù)轉換為(wèi)視(shì)窗(chαδφuāng)單位的(de)類,可(kě)以自(zì)定義,可(↓‍kě)以無限添加,建議(yì)定義一(yī↕₩")至兩個(gè)通(tōng)用(yòng)的(de)類名γ★ 
  minPixelValue: 1, // 小(xiǎo)于或等于`1px`不(bù)轉換為(wèi)Ω£★φ視(shì)窗(chuāng)單位,你(nǐ)也(yě™≤ )可(kě)以設置為(wèi)你(nǐ)想要(​®•≥yào)的(de)值
  mediaQuery: false // 媒體(tǐ)查詢裡(lǐ)的(de)單位是(shì)否需要ε↕(yào)轉換單位
}

下(xià)面是(shì) vw 和(hé) rem₩↔§ 的(de)優缺點對(duì)比圖:

關于 vw 兼容性問(wèn)題,目前在移動端 iOS φ♦★≠8 以上(shàng)以及 Android 4.4 以上(shà‌✔☆ng)獲得(de)支持。如(rú)果有( ‌ yǒu)兼容更低(dī)版本需求的(de)話(huà),££‍可(kě)以選擇 viewport 的(de) pollify$♠ 方案,其中比較主流的(de)是(shì)&nδ×bsp;Viewport Units Buggyfill[65]

本方案因不(bù)準備兼容低(dī)版本,所以直接選<₽®擇了(le) vw 方案,各位可(kě)根據項目需‍←求選擇不(bù)同的(de)方案。

另外(wài)關于設置 css 兼容不(bù)同浏覽器(qì),δ φ想必大(dà)家(jiā)都(dōu)知(zhī)道(dào) Auto φprefixer(vue-cli3 已經默認集成了(≤☆↕≤le)),那(nà)麽如(rú)何設置要 Ω<←(yào)兼容的(de)範圍呢(ne)?推薦使用(yòng) browse↓"→rslist,可(kě)以在 .browsersl‌‍‌©istrc 或者 pacakage.json 中 browsers$☆ ♣list 部分(fēn)設置兼容浏覽器(qì)範圍。因為 ±§¶(wèi)不(bù)止 Autoprefixer,還(hái)↕€★'有(yǒu) Babel,postcss-preset-env₽∑♦✔ 等工(gōng)具都(dōu)會(huì)讀(dú)取 browsersl§ ist 的(de)兼容配置,這(zhè)樣比較容易使 js c♣∑&ss 兼容浏覽器(qì)的(de)範圍保持一(yī)緻。下(xià♠₹∏Ω)面是(shì)本項目的(de) .browserslistrc 配置♠λ•β:

iOS >= 10  //  即 iOS Safari
Android >= 6.0 // 即 Android WebView
last 2 versions // 每個(gè)浏覽器(qì)最近(jìn×♦'σ)的(de)兩個(gè)版本

最後推薦一(yī)些(xiē)移動端樣式适配的(≠∏¶✘de)資料:

rem-vw-layout[66]

細說(shuō)移動端 經典的(de) REM 布局λ® 與 新秀 VW 布局[67]

如(rú)何在 Vue 項目中使用(yòng) vw 實現(xiàn)移↑>動端适配[68]

表單校(xiào)驗

async-validator[69]

vee-validate[70]

由于大(dà)部分(fēn)移動端組♠☆∏ε件(jiàn)庫都(dōu)不(bù)提供表單校(xiào)驗,因此需要(y←♠↕ào)自(zì)己封裝。目前比較多(duō)•©的(de)方式就(jiù)是(shì)基于 a∞♦♠$sync-validator 進行(xíng)¶‌二次封裝(elementUI 組件(jiàn)庫提供的φ★(de)表單校(xiào)驗也(yě)是(shì)基于 async-δγvalidator ),或者使用(yòng) vee-vali¥π<γdate(一(yī)種基于 vue 模闆的(de)輕量級∞✘Ω§校(xiào)驗框架)進行(xíng)校Ω★™(xiào)驗,各位可(kě)根據項目需求選擇不(bù)同的(dγ★​σe)方案。

本項目的(de)表單校(xiào)驗方案是(shì)在 async γ®-validator 基礎上(shàng)進行(xíng)二次封裝←♠₩∑,代碼如(rú)下(xià),原理(lǐ)很(hěn£♣π∞)簡單,基本滿足需求。如(rú)果還(há‌≠<‌i)有(yǒu)更完善的(de)方案,歡迎提出來(lái)。

其中 setRules 方法是(shì)将ε'¥<組件(jiàn)中設置的(de) rules(符合 async-vali"♣dator 約定的(de)校(xiào)≤βγ驗規則)按照(zhào)需要(yào)校(xiào)驗的(de)數(shù  )據的(de)名字為(wèi) key 轉化(huà)一(yī)∞¶ε↕個(gè)對(duì)象 validator,σ≈value 是(shì) async-validator 生γ¥£∞(shēng)成的(de)實例。validatπ ☆∞or 方法可(kě)以接收單個(gè)或多(duōλ$)個(gè)需要(yào)校(xiào)驗的(de)數(shùφ¥)據的(de) key,然後就(jiù)會(huì)在 setRules  σ生(shēng)成的(de)對(duì)象 validator 中↑ε&₩尋找 key 對(duì)應的(de) asy<π≥<nc-validator 實例,最後調用(yòng§•)實例的(de)校(xiào)驗方法。當然也(yě)可(kě) ♦以不(bù)接受參數(shù),那(nà)麽就(j↑$iù)會(huì)校(xiào)驗所有(yǒu)傳入的™✔¥(de)數(shù)據。

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);
≠∑
  }

  /**
   * 設置校(xiào)驗規則
 ₹₩γ  * @param rules async-va§σlidator 的(de)校(xiào)驗規則
   * @param c≤¶over 是(shì)否替換舊(jiù)規則
   */
  public setRules(rules: ValidateRuleβ↕↔s, 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> {
    // 錯(cuò)誤數(shù)組
    const err: ValidateError[] =<¥↑↔ [];

    Object.keys(this.validators)
      .filter((key) => {
        // 若不(bù)傳 dataKey 則校(xiào)驗全部。否則校($‌&xiào)驗 dataKey 對(duì)™&♠♣應的(de)數(shù)據(dataKey 可(kě)以對(duì)應一 ÷(yī)個(gè)(字符串)或多(duō)個'★‌≠(gè)(數(shù)組))
        return (
          !dataKey ||
         ≤✔∑® (dataKey &&
            (‌∑∑(_.isString(dataKey) &‌≈& dataKey === key) ||
           λ    (_.isArray(dataK↔σ★ey) && dataKey.includes('λkey))))
        );
      })✔β¶δ
      .forEach((key) => {
        this.validators[key].validate↔‍↔(
          { [key]: this.data[key] },
          (error: ValidateError[]) => {
            if (error) {
              err.pus®✘→h(error[0]);
            }
'£↕φ          }
        );δ☆₩
      });

    if (err.length > 0) {
      return Promise.reject(err);
    } else {
      return Promise.resolve(dataKey);
    }π<∞
  }
}

阻止原生(shēng)返回事(shì)件(jiàn)✔♠&♥

開(kāi)發中可(kě)能(néng)會(h‌∏uì)遇到(dào)下(xià)面這(zhè)個(gè)需求:當頁面彈出☆'₩ε一(yī)個(gè) popup 或 dialog 組件(♠✔jiàn)時(shí),點擊返回鍵時(shí)是(s¥₹hì)隐藏彈出的(de)組件(jiàn)而不(bù)是(shλ≤λì)返回到(dào)上(shàng)一(σ←♦€yī)個(gè)頁面。

為(wèi)了(le)解決這(zhè)個(gè)問(wèΩλn)題,我們可(kě)以從(cóng)路(lù)由棧角度思α♣₽&考。一(yī)般彈出組件(jiàn)是(shì)不(bù)會(huì)在路(l'≤ù)由棧上(shàng)添加任何記錄,因此我們在彈出組件(jiàn)時(s'↕"hí),可(kě)以在路(lù)由棧中 push 一≠₽∏(yī)個(gè)記錄,為(wèi)了(le)≤‍↕♦不(bù)讓頁面跳(tiào)轉,我們可(kγ§ ‌ě)以把跳(tiào)轉的(de)目标路(lù)由設置₩→為(wèi)當前頁面路(lù)由,并加上(shàng)♥≥×一(yī)個(gè) query 來(lái)标記這(zhè)個​§‌(gè)組件(jiàn)彈出的(de)狀态。

然後監聽(tīng) query 的(de)變化(huà),當點擊彈出組&★"ε件(jiàn)時(shí),query 中與該彈出α•±組件(jiàn)有(yǒu)關的(de)标記變為(✔>±wèi) true,則将彈出組件(jiàn)設為(wèi)顯示;當用(yòn∏ g)戶點擊 native 返回鍵時(shí),路(lù)由返回上(shàng±☆)一(yī)個(gè)記錄,仍然是(shì)當前頁面路(lù)由,不(bù)過✔§∑ query 中與該彈出組件(jiàn)有(yǒu)™₩關的(de)标記不(bù)再是(shì) true♦β 了(le),這(zhè)樣我們就(jiù)可(≤©kě)以把彈出組件(jiàn)設置成隐藏,同時(shí)不(b×♣®∑ù)會(huì)返回上(shàng)一(yī)個(gè)頁面。