· 7 min read

Dynamically Loading Modules with Vuex and Webpack Dynamic Import

This article was auto-translated from Chinese. Some nuances may be lost in translation.

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 menus data in the template will blow up due to undefined.
  • 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

Explore Other Topics