DEV Community

Cover image for Vite: How to resolve bundle fragmentation?
Uncle Pushui
Uncle Pushui

Posted on

Vite: How to resolve bundle fragmentation?

Preface

When we use Vite for bundle code files, we often encounter this problem: With the development of the business, there are more and more pages, more and more third-party dependencies, and the chunks are getting bigger and bigger. If the pages are imported dynamically, then all the files shared by several pages will be independently bundled to the same chunk, which will create a large number of tiny js chunk files, such as: 1K, 2K, 3K, which significantly increases the resource request of the browser.

Although you can customize the chunks-merging strategy through the rollupOptions.output.manualChunks, the dependencies between the files are intricate. If the chunks-merging configuration is unreasonable, it will cause the initial chunks to be too large, or the circular reference will occur. Therefore, the mental burden is heavy. Is there an automated chunks-merging mechanism to completely solve the problem of bundle fragmentation?

Two potential issues of chunks-merging

As mentioned earlier, there are two potential issues to use the rollupOptions.output.manualChunks custom chunks-merging strategy.

1. Cause the initial chunks to large

Image description

As shown in the figure, file A originally only depends on file C, but according to the chunks-merging configuration in the figure, Chunk1 and Chunk2 must be downloaded before using file A. In a complex project, due to the complexity of the dependencies between the files, this dependency will spread quickly with the merger of a large number of small files, resulting in the large size of the initial bundles.

2. Cause circular reference error

Image description

As shown in the figure, due to the mutual dependence between the files, Chunk1 and Chunk2 will cause circular dependence error. Then in complex projects, the scenes of mutual dependence between code files are more common.

Solution: modular system

Because the chunks-merging configuration will lead to the above two potential issues, it is often difficult to follow, and it is difficult to have a simple and easy-to-use configuration rules that can be followed. Because the chunks-merging configuration is closely related to the current state of the project. Once the code of the project has changed, the chunks-merging configuration also needs to be changed accordingly.

To solve this problem, I introduced a modular system in the project. That is, the code of the project is split according to business characteristics to form a combination of several modules. Each module can include pages, components, configurations, languages, tools and other resources. Then a module is a natural bundle boundary, and automatically bundled into an independent asynchronous chunk when building, bidding farewell to the hassle of Vite configuration and effectively avoiding the fragmentation of bundles. Especially in large business systems, this advantage is particularly evident. Of course, the use of a modular system is also conducive to code decoupling and facilitating division of labor collaboration.

Since a module is a bundle boundary, we can control the content and quantity of the chunks by controlling the content and quantity of the modules. And we divide the modules based on business characteristics to make the chunks-merging configuration have obvious business significance. Compared with the customization of the rollupOptions.output.manualChunks, it is obviously low mental burden.

Directory Structure

As the project continues to evolve iteratively, the business modules created will also expand. In addition, for some business scenarios, multiple modules are often required to be implemented together. To solve the above problems, We can introduces the concept of suite. In short, a suite is a combination of a group of business modules. In this way, a project is composed of several suites and several modules. Below is the directory structure of a project:

project
├── src
│  ├── module
│  ├── module-vendor
│  ├── suite
│  │  ├── a-demo
│  │  └── a-home
│  │    ├── modules
│  │    │  ├── home-base
│  │    │  ├── home-icon
│  │    │  ├── home-index
│  │    │  └── home-layout
│  └── suite-vendor
Enter fullscreen mode Exit fullscreen mode
Name Description
src/module Standalone module (not part of a suite)
src/module-vendor Standalone module (from third-party)
src/suite Suite
src/suite-vendor Suite (from third-party)
Name Description
a-demo demo suite: Put the test code into a suite, so that it is convenient to disable at any time
a-home business suite: including 4 business modules

Chunks-merging effect

Let's take a look at the actual bundle effect:

Taking the module home-base as an example, the left shows the code of the module, and the right shows the chunk size is 12k, and 3K with gzip. To achieve this chunks-merging effect, no configuration is required.

Image description

For another example, we can also concentrate the layout components into the module home-layout for management. The module is bundled into an independent chunk, which size is 29K and 6K with gzip.

Image description

Source code inside

1. Dynamic import modules

Since the module directory structure of the project is regular, we can extract all the module list before the project startup, and then generate a js file to centrally implement the dynamic import of modules:

const modules = {};
...
modules['home-base'] = { resource: () => import('home-base')};
modules['home-layout'] = { resource: () => import('home-layout')};
...
export const modulesMeta = { modules };
Enter fullscreen mode Exit fullscreen mode

Since all modules are dynamically imported through the import method, it will automatically split into independent chunks when performing Vite building.

2. Chunks-merging Configuration

We also need to customize the chunks-merging configuration through rollupOptions.output.manualChunks to ensure that the code inside the module is uniformly bundled together to avoid fragmented files.

const __ModuleLibs = [
  /src\/module\/([^\/]*?)\//,
  /src\/module-vendor\/([^\/]*?)\//,
  /src\/suite\/.*\/modules\/([^\/]*?)\//,
  /src\/suite-vendor\/.*\/modules\/([^\/]*?)\//,
];

const build = {
  rollupOptions: {
    output: {
      manualChunks: (id) => {
        return customManualChunk(id);
      },
    },
  },
};

function customManualChunk(id: string) {
  for (const moduleLib of __ModuleLibs) {
    const matched = id.match(moduleLib);
    if (matched) return matched[1];
  }
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Each file path is matched with a regular expression. If the matching is successful, use the corresponding module name as chunk name.

Solutions to two potential issues

If the modules are interdependent, there may be two potential issues mentioned above, as shown in the figure:

Image description

In order to prevent the occurrence of the two potential issues, we can implement a dynamic loading and resource positioning mechanism. In short, when we access the resources of Module 2 in Module 1, we must first dynamically load module 2, then find the resources of module 2 and return them to the caller.

For example, there is a Vue component Card in module 2, and a page component FirstPage in module 1. We need to use the Card component in the page component FirstPage. Then, we need to do it like this:

// dynamically load module
export async function loadModule(moduleName: string) {
  const moduleRepo = modulesMeta.modules[moduleName];
  return await moduleRepo.resource();
}

// create dynamic component
export function createDynamicComponent(moduleName: string, name: string) {
  return defineAsyncComponent(() => {
    return new Promise((resolve) => {
      // dynamically load module
      loadModule(moduleName).then((moduleResource) => {
        // return the component of module
        resolve(moduleResource.components[name]);
      });
    });
  });
}
Enter fullscreen mode Exit fullscreen mode
const ZCard = createDynamicComponent('Module2', 'Card');

export class RenderFirstPage {
  render() {
    return (
      <div>
        <ZCard />
      </div>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Advanced import mechanism

Although using createDynamicComponent can achieve the desired purpose, the code is not concise enough and cannot fully utilize the automatic import mechanism provided by Typescript. We still hope to use the component in the usual way:

import { ZCard } from 'Module2';

export class RenderFirstPage {
  render() {
    return (
      <div>
        <ZCard />
      </div>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Such code is in the form of static import, which will cause Module 1 and Module 2 to be strongly dependent on each other. So, is there a way to have the best of both worlds? Yes. We can develop a Babel plug-in to parse the AST syntax tree and automatically change the ZCard import to a dynamic import form. In this way, our code is not only concise and intuitive, but also can implement dynamic import, avoiding the occurrence of two potential issues when bundling. In order to avoid distracting the topic, how to develop the Babel plug-in will not be expanded here. If you are interested, you can directly refer to the source code: babel-plugin-zova-component

Conclusion

This article analyzes the causes of Vite bundle fragmentation and proposes a modular system to simplify the chunks-merging configuration. At the same time, it adopts a dynamic loading mechanism to perfectly avoid the occurrence of two potential issues during bundling.

Of course, there are still many details to consider in order to realize a complete modular system. If you want to experience the out-of-the-box effect, you can visit my open source framework: https://github.com/cabloy/zova. You can contact me on twitter: https://twitter.com/zhennann2024

Top comments (0)