半熟前端

軟體工程師 / 台灣人 / 在前端的路上一邊探索其他領域的可能性

前端

透過 vuex 與 webpack dynamic import 動態載入 module

在最近的專案中用到 vue 來開發,而如果要管理比較複雜的資料流貨狀態,通常都是用 vuex 來當作 Single Truth of Source 的 store。在 vue 裡頭建立 store 時,都是把所有的 module 寫完後,再統一放到 vue 的 root 當中。

export default new Vuex.Store({
  modules: {
    profile,
    users,
    menus,
    list,
    food,
    product,
    todo,
    ...
  },
});

這在一般的中小型專案中沒有什麼問題,不過一旦專案的架構變得越來越大,很容易讓 store 的資料結構變得越來越大且越來越複雜。而且 module 裡頭的 action, mutations 一多,難免會增加不少不必要的 bundle size,也不是所有的 module 都是在 app 初始化之後就要馬上使用到。關於這點 vue 透過了 webpack 的 dynamic import 機制動態載入 component

在 vuex 當中則可以透過 store.registerModule 的方式在有需要的時候才將 module 放進 store,有了這個 API,我們也可以搭配 webpack dynamic import 的機制來減少 bundle size,並且盡可能地讓所有的操作變得簡單,在 app 初始化的時候,我們也只需要放入必要的 module 即可。

今天就來跟大家介紹如何透過 webpack dynamic import 的機制做到動態載入 module。

Webpack Dynamic Import

在開始之前,我們先來講講 webpack dynamic import 的機制。一般而言在 webpack 要做到 code splitting 的方法有

  • 設定多個 entry
  • 透過 SplitChunks 這個 plugin 來拆分出不同的 chunk
  • 透過 dynamic import 的機制引入程式碼

如果我們要在 component 當中動態引入對應的 module 的話,最方便的方法應該是透過 dynamic import 的機制。它能夠讓 import() 變成一個 return Promise 的函數,在 webpack build 的時候,會自動把這些檔案拆分出來變成其他 chunks,例如:

import(/* webpackChunkName: "CreateMenu" */ './pages/NewMenu.js'),

在 webpack build 的時候會把 CreateMenu.js 拆出來變成一個 chunk。

Version: webpack 4.16.3
Time: 112ms
Built at: 2018-08-20 14:49:40
                                   Asset       Size                        Chunks             Chunk Names
                     Profile.c943bf21.js   32.7 KiB                       Profile  [emitted]  Profile

官方提供了撰寫 comment 的方式來決定 chunk name,在 debug 的時候比較清楚目前引入的是哪個 chunk。

要搭配這個機制需要額外設定 babel 的 plugin Syntax Dynamic Import Babel Plugin

知道了 webpack dynamic import 的使用方式後,我們來整合一下 vuex。

引入時機

要透過動態引入 module 的方式,勢必要考慮幾個問題:

  • 在什麼時候引入 module?
  • 如果發生錯誤導致無法載入該如何處理?

什麼時候引入 module?

當 component 可能需要 store 的狀態時載入(廢話)。所以我們可以這樣寫:

// component.vue

export default {
  mounted() {
    import("./modules/menus").then(menus =>
      this.$store.registerModule("menus", menus.default)
    )
  },
  render() {
    // your template
  },
}

看起來很單純,不過很快就會遇到幾個問題:

  • 如果在 render 的時候還沒有載入完成,那麽當 template 當中取用 menus 的資料時,會因為 undefined 而整個爆炸
  • 如果在其他 component 已經載入過了,也會有錯誤 duplicate getter key: menus

為了修正以上的問題,我們稍微修改一下:

// component.vue

export default {
  data: () => ({
    loaded: false,
  }),
  mounted() {
    if (this.$store.state.menus) {
      import("./modules/menus").then(menus => {
        this.$store.registerModule("menus", menus.default)
        this.loaded = true
      })
    } else {
      this.loaded = true
    }
  },
  render() {
    return loaded ? h() : null
  },
}

看其來確實好多了,不過在每個需要 store 資料的 component 當中都做重複的事難免有些麻煩,我們把它拆出來變成一個通用的 HOC component。

export default function createMenuModule(Component, moduleName, dynamicModule) {
  return Vue.component(`dynamicModule-${Component.name || 'Component'}`, {
    data: () => ({
      isLoaded: false,
    }),
    mounted() {
      if (this.$store.state[moduleName]) {
      	dynamicModule
      	.then(module => this.$store.registerModule(moduleName, module.default)) // register module into store
	  }
    },

    render(h) {
      return this.isLoaded ? (
		<Component {...this.$props} />
      ) : null;
    },
  });
}

// MenuList.js
export default createModule(MenuList, import(/* webpackChunkName: Menus */ './modules/menus')); // return a higher order vue component

這樣子一來,就可以安心地在有需要的時候引入對應的 module,而在 component 當中不需要每次都處理惱人的載入邏輯與處理。當然你也可以修改一下參數,讓這個 function 可以接收多個 module。當然要考慮的事情又變多了(怎麼做 module name mapping、多個 promise 處理等),但概念是類似的。

有需要時再載入

剛剛的範例中解決了我們前面提到的問題,不過仔細一看可以發現,如果我們直接將 import() 寫在參數裡頭,好像不管怎樣都一定會發送請求耶!如果可以先查看 store 裡頭有沒有對應的資料再決定要不要載入呢?

所以我們接下來要再修改一下函數,讓 store 可以當作參數傳遞。

export default function createMenuModule(Component, moduleName, loader = () => Promise.resolve()) {
  return Vue.component(`dynamicModule-${Component.name || 'Component'}`, {
    data: () => ({
      isLoaded: false,
    }),
    mounted() {
      if (this.$store.state[moduleName]) {
		loader()
      	.then(module => this.$store.registerModule(moduleName, module.default)) // register module into store
	  }
    },

    render(h) {
      return this.isLoaded ? (
		<Component {...this.$props} />
      ) : null;
    },
  });
}

// MenuList.js
export default createModule(MenuList, 'menus', {
  loader: () => import('./modules/menus'),
})

把最後一個參數當作 function 傳入就好了,當然也可以在 loader 上做更多處理(error handling, error logging, GA…),讓整個 component 更加穩固。如果要做得更仔細一點,第三個參數也可以傳入像是 timeout, LoadingComponent 的機制,讓這個 higher order function 更加實用。(不過大部分的情況下都是希望 module 越快載入越好)

錯誤處理

雖然我們希望 promise 順利載入,天下太平。但實際上有太多因素會影響 module 的載入。大部分是網路不穩或中途離線等等,因此我們需要一個錯誤處理機制。

在 mounted 的時候我們可以利用 catch 來處理錯誤,並且設定一個新的 data 來記錄 error 的資訊。

export default function createMenuModule(Component, moduleName, loader = () => Promise.resolve()) {
  return Vue.component(`DynamicModule-${Component.name || 'Component'}`, {
    data: () => ({
      isLoaded: false,
      error: null,
    }),
    mounted() {
      if (this.$store.state[moduleName]) {
		loader()
      	.then(module => this.$store.registerModule(moduleName, module.default)) // register module into store
        .catch(err => {
            this.error = err
            sendToLoggingService(err);
        })
	  }
    },
    render(h) {
      if (this.error) {
        return h('pre', this.error);
      }
      return this.isLoaded ? (
		<Component {...this.$props} />
      ) : null;
    },
  });
}

// MenuList.js
export default createModule(MenuList, 'menus', {
  loader: () => import('./modules/menus'),
})

當然也可以善用 vue 的 errorCaptured,或是實作一個 ErrorBoundary 元件來記錄這些資訊。

什麼時候要 unregister

什麼時候要 unregister 呢?在前半段當中我們處理了載入的部分,但卸載的話並無法直接在 component unmount 實作,因為我們並不知道是否有其他元件有存取 store 的資料,所以除非是相當確信只會有單一元件使用,不然不要隨意 unregisterModule。

其他

在 react 當中沒有那麼方便,不過有 react-loadable 可以用。但 redux 沒有類似 registerReducer 的 API,必須自己實作。如果有使用像是 redux-observable 或是 redux-saga(動態載入 epic 或是 saga) 的話,也可以透過類似的方式實作。

結論

比起 react 與 redux,vue 的生態系當中對非同步載入的支援更好(更容易實作)。當然引入這樣的機制難免會提高 debug 的複雜度,雖然有效減少了 bundle size。但也要搭配各種機制才能讓整個 app 運行的更加穩固。

本文試著提出在動態載入時可能遇到的問題以及解決方式,希望可以幫助到正為 bundle size 所苦的開發者們。