Closed kjj6198 closed 6 years ago
在最近的專案中用到 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 即可。
store.registerModule
今天就來跟大家介紹如何透過 webpack dynamic import 的機制做到動態載入 module。
在開始之前,我們先來講講 webpack dynamic import 的機制。一般而言在 webpack 要做到 code splitting 的方法有
如果我們要在 component 當中動態引入對應的 module 的話,最方便的方法應該是透過 dynamic import 的機制。它能夠讓 import() 變成一個 return Promise 的函數,在 webpack build 的時候,會自動把這些檔案拆分出來變成其他 chunks,例如:
import()
import(/* webpackChunkName: "CreateMenu" */ './pages/Profile.js'),
在 webpack build 的時候會把 Profile.js 拆出來變成一個 chunk。
Profile.js
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 的方式,勢必要考慮幾個問題:
當 component 可能需要 store 的狀態時載入(廢話)。所以我們可以這樣寫:
// component.vue export default { mounted() { import('./modules/menus').then(menus => this.$store.registerModule('menus', menus.default)); }, render() { // your template } }
看起來很單純,不過很快就會遇到幾個問題:
menus
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 元件來記錄這些資訊。
ErrorBoundary
在 react 當中沒有那麼方便,不過有 react-loadable 可以用。但 redux 沒有類似 registerReducer 的 API,必須自己實作。如果有使用像是 redux-observable 或是 redux-saga(動態載入 epic 或是 saga) 的話,也可以透過類似的方式實作。
比起 react 與 redux,vue 的生態系當中對非同步載入的支援更好(更容易實作)。當然引入這樣的機制難免會提高 debug 的複雜度,雖然有效減少了 bundle size。但也要搭配各種機制才能讓整個 app 運行的更加穩固。
本文試著提出在動態載入時可能遇到的問題以及解決方式,希望可以幫助到正為 bundle size 所苦的開發者們。
在最近的專案中用到 vue 來開發,而如果要管理比較複雜的資料流或狀態,通常都是用 vuex 來當作 Single Truth of Source 的 store。在 vue 裡頭建立 store 時,都是把所有的 module 寫完後,再統一放到 vue 的 root 當中。
這在一般的中小型專案中沒有什麼問題,不過一旦專案的架構變得越來越大,很容易讓 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 的方法有
如果我們要在 component 當中動態引入對應的 module 的話,最方便的方法應該是透過 dynamic import 的機制。它能夠讓
import()
變成一個 return Promise 的函數,在 webpack build 的時候,會自動把這些檔案拆分出來變成其他 chunks,例如:在 webpack build 的時候會把
Profile.js
拆出來變成一個 chunk。官方提供了撰寫 comment 的方式來決定 chunk name,在 debug 的時候比較清楚目前引入的是哪個 chunk。
要搭配這個機制需要額外設定 babel 的 plugin Syntax Dynamic Import Babel Plugin。
知道了 webpack dynamic import 的使用方式後,我們來整合一下 vuex。
引入時機
要透過動態引入 module 的方式,勢必要考慮幾個問題:
什麼時候引入 module?
當 component 可能需要 store 的狀態時載入(廢話)。所以我們可以這樣寫:
看起來很單純,不過很快就會遇到幾個問題:
menus
的資料時,會因為 undefined 而整個爆炸duplicate getter key: menus
為了修正以上的問題,我們稍微修改一下:
看其來確實好多了,不過在每個需要 store 資料的 component 當中都做重複的事難免有些麻煩,我們把它拆出來變成一個通用的 HOC component。
這樣子一來,就可以安心地在有需要的時候引入對應的 module,而在 component 當中不需要每次都處理惱人的載入邏輯與處理。當然你也可以修改一下參數,讓這個 function 可以接收多個 module。當然要考慮的事情又變多了(怎麼做 module name mapping、多個 promise 處理等),但概念是類似的。
有需要時再載入
剛剛的範例中解決了我們前面提到的問題,不過仔細一看可以發現,如果我們直接將
import()
寫在參數裡頭,好像不管怎樣都一定會發送請求耶!如果可以先查看 store 裡頭有沒有對應的資料再決定要不要載入呢?。所以我們接下來要再修改一下函數,讓 store 可以當作參數傳遞。
把最後一個參數當作 function 傳入就好了,當然也可以在 loader 上做更多處理(error handling, error logging, GA…),讓整個 component 更加穩固。如果要做得更仔細一點,第三個參數也可以傳入像是 timeout, LoadingComponent 的機制,讓這個 higher order function 更加實用。(不過大部分的情況下都是希望 module 越快載入越好)
錯誤處理
雖然我們希望 promise 順利載入,天下太平。但實際上有太多因素會影響 module 的載入。大部分是網路不穩或中途離線等等,因此我們需要一個錯誤處理機制。
在 mounted 的時候我們可以利用 catch 來處理錯誤,並且設定一個新的 data 來記錄 error 的資訊。
當然也可以善用 vue 的 errorCaptured,或是實作一個
ErrorBoundary
元件來記錄這些資訊。其他
在 react 當中沒有那麼方便,不過有 react-loadable 可以用。但 redux 沒有類似 registerReducer 的 API,必須自己實作。如果有使用像是 redux-observable 或是 redux-saga(動態載入 epic 或是 saga) 的話,也可以透過類似的方式實作。
結論
比起 react 與 redux,vue 的生態系當中對非同步載入的支援更好(更容易實作)。當然引入這樣的機制難免會提高 debug 的複雜度,雖然有效減少了 bundle size。但也要搭配各種機制才能讓整個 app 運行的更加穩固。
本文試著提出在動態載入時可能遇到的問題以及解決方式,希望可以幫助到正為 bundle size 所苦的開發者們。