Dynamically Loading Modules with Vuex and Webpack Dynamic Import
In recent projects, I’ve been using Vue for development. When managing more complex data flows or state, Vuex is typically used as the single source of truth store. When creating a store in Vue, we usually write all the modules and then put them all together into Vue’s root.
export default new Vuex.Store({
modules: {
profile,
users,
menus,
list,
food,
product,
todo,
...
},
});
This isn’t an issue in typical small to medium-sized projects, but once the project architecture grows larger, it’s easy for the store’s data structure to become increasingly bloated and complex. Furthermore, having many actions and mutations inside modules inevitably adds a lot of unnecessary bundle size, and not all modules need to be used immediately upon app initialization. Regarding this, Vue leverages webpack’s dynamic import mechanism to dynamically load components.
In Vuex, we can use store.registerModule to add modules to the store only when needed. With this API, we can pair it with webpack’s dynamic import mechanism to reduce the bundle size and keep operations as simple as possible. When initializing the app, we only need to include the essential modules.
Today, I’ll introduce how to achieve dynamic module loading using webpack’s dynamic import mechanism.
Webpack Dynamic Import
Before diving in, let’s talk about webpack’s dynamic import mechanism. Generally, methods for achieving code splitting in webpack include:
- Setting multiple entry points
- Splitting chunks using the SplitChunks plugin
- Importing code via the dynamic import mechanism
If we want to dynamically import the corresponding module inside a component, the most convenient method is using dynamic imports. It turns import() into a function that returns a Promise. When webpack builds, it automatically splits these files into separate chunks, for example:
import(/* webpackChunkName: "CreateMenu" */ './pages/NewMenu.js'),
When webpack builds, it will split CreateMenu.js into its own 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
Webpack provides magic comments to define the chunk name, making it clearer during debugging which chunk is currently being imported.
To use this mechanism, you need to configure the Babel plugin Syntax Dynamic Import Babel Plugin.
Now that we know how webpack dynamic import works, let’s integrate it with Vuex.
Timing of the Import
When dynamically importing modules, several questions must be considered:
- When should the module be imported?
- How should loading failures and errors be handled?
When Should the Module Be Imported?
Load it when the component might need the store’s state (obviously). So we can write it like this:
// component.vue
export default {
mounted() {
import("./modules/menus").then(menus =>
this.$store.registerModule("menus", menus.default)
)
},
render() {
// your template
},
}
It looks straightforward, but you will quickly encounter a few issues:
- If loading hasn’t finished when rendering occurs, accessing
menusdata in the template will blow up due toundefined. - If another component has already loaded it, you’ll get an error:
duplicate getter key: menus.
To fix these issues, let’s modify it slightly:
// 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
},
}
This looks much better, but repeating the same logic across every component that needs store data can be tedious. Let’s extract it into a generic HOC (Higher-Order 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
This way, you can safely import the corresponding module whenever needed without having to handle the tedious loading logic inside each component. Of course, you can also modify the parameters so this function can accept multiple modules. While that introduces more considerations (such as module name mapping, handling multiple promises, etc.), the core concept remains the same.
Loading Only When Needed
The previous example solves our earlier problems. However, upon closer inspection, passing import() directly as an argument means the network request will always be fired regardless! What if we check whether the corresponding data already exists in the store before deciding whether to load it?
So next, we’ll modify the function slightly so that the loader can be passed as a function.
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'),
})
Just pass the last argument as a function. Of course, you can also add more handling to the loader (error handling, error logging, GA, etc.) to make the component more robust. If you want to take it a step further, the third argument could also accept options like a timeout or a LoadingComponent, making this higher-order function even more practical (though in most cases, we simply want the module to load as quickly as possible).
Error Handling
While we’d love for promises to always resolve smoothly and live in peace, in reality, far too many factors can affect module loading. Most commonly, these are unstable network connections, drops in connectivity, and so on. Therefore, we need an error-handling mechanism.
In mounted, we can use catch to handle errors and define a new data property to record the error details.
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'),
})
Of course, you can also leverage Vue’s errorCaptured hook or implement an ErrorBoundary component to log this information.
When to Unregister?
When should we unregister? In the first half, we handled the loading part. However, unregistering cannot simply be done when the component unmounts, because we don’t know whether other components are accessing that store data. Therefore, unless you are absolutely certain that only a single component will ever use it, avoid calling unregisterModule recklessly.
Other Considerations
It isn’t quite as convenient in React, though react-loadable is available. However, Redux lacks an API like registerReducer, so you have to implement it yourself. If you’re using libraries like redux-observable or redux-saga (to dynamically load epics or sagas), you can achieve something similar using an approach like this.
Conclusion
Compared to React and Redux, the Vue ecosystem offers better support (and easier implementation) for asynchronous loading. Of course, introducing such a mechanism inevitably increases debugging complexity. While it effectively reduces bundle size, it still requires various supporting mechanisms to ensure the entire app runs reliably.
This article attempted to address the potential issues and solutions encountered during dynamic loading. I hope it helps developers who are struggling with bundle size.
Related Posts
- Things to Keep in Mind When Using Images in Frontend Development Expanding on Jake Archibald's article, this post organizes how modern responsive images should be written: why width/height are still necessary, when to use CSS aspect-ratio, how to choose between AVIF and WebP, and using picture/source/srcset for art direction on mobile devices.
- CSS field-sizing — Auto-resize Form Elements with a Single Line of CSS Previously, auto-resizing a textarea required listening to scrollHeight in JavaScript. With CSS field-sizing: content, a single line replaces it all, supporting textarea, input, and select. This article covers the pain points of older approaches and how to use field-sizing.
- Make Your Link Underlines Look Better: text-underline-offset By default, underlines sit very close to the text. Some designers dislike this look, and personally, I don't think it looks great either.
- Why the Web Shouldn't Strive for Pixel Perfection You should only focus on pixel perfection when it truly matters; otherwise, it often results in a lose-lose situation.