Kalan's Blog

Software Engineer / Taiwanese / Life in Fukuoka

Current Theme light

In recent projects, I have been using Vue for development. When it comes to managing complex data flow or state, I usually use Vuex as a store for Single Truth of Source. In Vue, when creating a store, all modules are written first and then placed in the root of Vue.

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

This approach works fine for small to medium-sized projects. However, as the project structure becomes larger, it can easily make the data structure of the store more complex and larger. Additionally, having a large number of actions and mutations in modules can increase the bundle size unnecessarily. Not all modules are used immediately after app initialization. To address this, Vue provides dynamic import mechanism through webpack for async components.

In Vuex, we can use store.registerModule to add modules to the store only when needed. With this API, we can reduce the bundle size by leveraging webpack's dynamic import mechanism and simplify all operations. During app initialization, we only need to include the necessary modules.

Today, I will introduce how to dynamically load modules using webpack's dynamic import mechanism.

Webpack Dynamic Import

Before we begin, let's talk about webpack's dynamic import mechanism. Generally, there are three methods to achieve code splitting in webpack:

  • Setting up multiple entry points
  • Using the SplitChunks plugin to split chunks
  • Using the dynamic import mechanism to import code

When we want to dynamically import the corresponding module in a component, the most convenient method is to use the dynamic import mechanism. It allows import() to become a function that returns a Promise. During webpack build, these files will be automatically split into separate chunks. For example:

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

During webpack build, CreateMenu.js will be split into a separate 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

The official documentation provides a way to specify the chunk name using comments, which helps in debugging to identify the imported chunk.

To use this mechanism, you need to configure the Babel plugin Syntax Dynamic Import Babel Plugin.

Now that we know how to use webpack's dynamic import, let's integrate it with Vuex.

Timing of Import

To dynamically import modules, we need to consider a few questions:

  • When should we import the module?
  • How should we handle errors if the import fails?

When to import the module?

We should import the module when the component may require 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
  },
}

This looks simple, but we quickly encounter a few issues:

  • If the import is not completed before rendering, accessing the menus data in the template will cause an explosion due to undefined.
  • If the module has already been imported in another component, there will be an error: duplicate getter key: menus.

To address these issues, let's make a slight modification:

// 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. However, it becomes cumbersome to repeat this logic in every component that requires store data. So let's extract it into a reusable Higher-Order Component (HOC).

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

Now we can safely import the corresponding module when needed, without having to handle the annoying loading logic in each component. Of course, you can modify the parameters to allow the function to accept multiple modules. However, this introduces more considerations (such as module name mapping, handling multiple promises, etc.), but the concept remains the same.

Lazy Loading

In the previous example, we solved the issues mentioned earlier. However, if we directly write import() in the parameter, it seems like a request will always be sent, regardless of the circumstances! What if we can check if the corresponding data exists in the store before deciding whether to load it or not?

So let's modify the function further to allow passing the store as a parameter.

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'),
})

Now we can pass a function as the last parameter. This function can handle additional processing such as error handling, error logging, GA, etc., to make the component more robust. If we want to be more detailed, we can also pass additional parameters like timeouts and a LoadingComponent mechanism to make this higher-order function even more useful (although in most cases, we want the modules to load as quickly as possible).

Error Handling

Although we hope that promises will load smoothly and everything will be fine, there are many factors that can affect the loading of modules. Most commonly, it could be due to unstable network connections or being offline. Therefore, we need an error handling mechanism.

In the mounted lifecycle hook, we can use catch to handle errors and set a new data property to record the error information.

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, we can also make use of Vue's errorCaptured or implement an ErrorBoundary component to record this information.

When to Unregister

When should we unregister? In the earlier part, we handled the loading, but unmounting cannot be directly implemented in the component because we don't know if there are other components accessing the store data. So unless you are certain that only a single component will use it, do not unregister the module indiscriminately.

Others

In React, it's not as convenient, but you can use react-loadable. However, Redux does not have an API similar to registerModule, so you would need to implement it yourself. If you are using libraries like redux-observable or redux-saga (for dynamically loading epics or sagas), you can also implement similar mechanisms.

Conclusion

Compared to React and Redux, Vue's ecosystem has better support for asynchronous loading (easier implementation). However, introducing this mechanism inevitably increases the complexity of debugging, although it effectively reduces the bundle size. It is important to combine various mechanisms to make the entire app more robust.

This article attempts to address the issues and solutions that may arise when dynamically loading modules, and I hope it can help developers who are struggling with bundle size.

Prev

Difference between make and new

Next

[golang notes] How to set environment variables for your project

If you found this article helpful, please consider buy me a drink ☕️ It'll make my ordinary day shine✨

Buy me a coffee

作者

Kalan 頭像照片,在淡水拍攝,淺藍背景

愷開 | Kalan

Hi, I'm Kai. I'm Taiwanese and moved to Japan in 2019 for work. Currently settled in Fukuoka. In addition to being familiar with frontend development, I also have experience in IoT, app development, backend, and electronics. Recently, I started playing electric guitar! Feel free to contact me via email for consultations or collaborations or music! I hope to connect with more people through this blog.