Analysis of several ways to implement on-demand introduction of component library

Posted by nevynev on Sat, 04 Dec 2021 01:55:36 +0100

On demand loading is a basic capability provided by all component libraries. This paper will analyze the implementation of ElementUI, Vant and varlet and carry out corresponding practice to help you thoroughly understand its implementation principle.

Build a simple component library first

The author copied two components from ElementUI: Alert and Tag, and named our component library XUI. The current directory structure is as follows:

Components are placed in the packages directory. Each component is a separate folder. The most basic structure is a js file and a Vue file. Components support Vue.component registration and plug-in Vue.use registration. js files are used to support plug-in use. For example, the js file of Alert is as follows:

import Alert from './src/main';

Alert.install = function(Vue) {
  Vue.component(Alert.name, Alert);
};

export default Alert;

This is to add an install method to the component, so that Vue.use(Alert) can be used to register.

The theme files of components are uniformly placed in the / theme chat directory, which is also a style file for each component. index.css contains the styles of all components. The source code of ElementUI is the scss file. For simplicity, this paper directly copies the compiled CSS file in its npm package.

There is also an index.js file in the outermost layer, which is obviously used to export all components as an entry file:

import Alert from './packages/alert/index.js';
import Tag from './packages/tag/index.js';

const components = [
    Alert,
    Tag
]

const install = function (Vue) {
    components.forEach(component => {
        Vue.component(component.name, component);
    });
};

if (typeof window !== 'undefined' && window.Vue) {
    install(window.Vue);
}

export default {
    install,
    Alert,
    Tag
}

First, introduce all components of the component library in turn, then provide an install method, traverse all components, and register with the Vue.component method in turn. Next, judge whether there is a global Vue object. If yes, it means that it is used in the CDN mode, then register automatically, and finally export the install method and all components.

Vue's plug-in is an object with an install method, so we can directly introduce all components:

import XUI from 'xui'
import 'xui/theme-chalk/index.css'
Vue.use(XUI)

You can also register a component separately:

import XUI from 'xui'
import 'xui/theme-chalk/alert.css'
Vue.use(XUI.Alert)

Why not import it directly through the import {alert} form 'Xui'. Obviously, an error will be reported.

Because our component library is not published to NPM, we link our component library to the global through npm link.

Next, the author uses Vue CLI to build a test project and runs npm link xui to link to the component library. Then use the previous method to register the component library or a component. Here we only use the Alert component.

Through the test, it can be found that whether all components are registered or only Alert components are registered, the contents of Tag components exist in the finally packaged js:

Next, open the body of this article to see how to remove Tag.

The simplest on-demand introduction

Because each component can be used as a plug-in, we can only introduce one component, such as:

import Alert from 'xui/packages/alert'
import 'xui/theme-chalk/alert.css'

Vue.use(Alert)

In this way, we only introduce alert related files, and of course, only the contents of alert components will be included in the end. Such problems are troublesome and expensive to use. The best way is as follows:

import { Alert } from 'xui'

Through babel plug-in

Using babel plug-in is the way most component libraries implement on-demand introduction. The ElementUI uses babel plugin component:

You can see that you can directly use the import {Alert} form 'Xui' method to introduce Alert components without manually introducing styles. Then how to implement this? Let's roll out a minimalist version.

The principle is very simple. What we want is the following way:

import { Alert } from 'xui'

However, the actual use needs to be as follows:

import Alert from 'xui/packages/alert'

Obviously, we just need to help users convert the first method to the second, and it is insensitive to users to convert through babel plug-in.

First, add a babel-plugin-component.js file at the same level of babel.config.js as our plug-in file, and then modify the babel.config.js file:

module.exports = {
  // ...
  plugins: ['./babel-plugin-component.js']
}

Use the relative path to reference our plug-in, and then you can code happily.

Let's take a look at the AST tree corresponding to import {alert} from 'Xui'

The whole is an ImportDeclaration. The source of import can be determined through cause.value. The imported variables can be found in the specifiers array. Each variable is an ImportSpecifier. You can see that there are two objects in it: ImportSpecifier.imported and ImportSpecifier.local. What is the difference between the two is whether alias import is used, such as:

import { Alert } from 'xui'

In this case, imported and local are the same, but if an alias is used:

import { Alert as a } from 'xui'

So here's the thing:

For simplicity, we don't consider aliases and only use imported.

The next task is to transform. Take a look at the AST structure of import Alert from 'xui/packages/alert':

The target AST structure is also clear. The next thing is simple. Traverse the specifiers array to create a new importDeclaration node, and then replace the original node:

// babel-plugin-component.js
module.exports = ({
    types
}) => {
    return {
        visitor: {
            ImportDeclaration(path) {
                const {
                    node
                } = path
                const {
                    value
                } = node.source
                if (value === 'xui') {
                    // Find the list of imported component names
                    let specifiersList = []
                    node.specifiers.forEach(spec => {
                        if (types.isImportSpecifier(spec)) {
                            specifiersList.push(spec.imported.name)
                        }
                    })
                    // Create an import statement for each component
                    const importDeclarationList = specifiersList.map((name) => {
                        // Folder names start with lowercase letters
                        let lowerCaseName = name.toLowerCase()
                        // Construct importDeclaration node
                        return types.importDeclaration([
                            types.importDefaultSpecifier(types.identifier(name))
                        ], types.stringLiteral('xui/packages/' + lowerCaseName))
                    })
                    // Replacing a single node with multiple nodes
                    path.replaceWithMultiple(importDeclarationList)
                }
            }
        },
    }
}

Next, the packaging test results are as follows:

You can see that the contents of the Tag component are gone.

Of course, the above implementation is only the simplest demo. In fact, you also need to consider various problems such as the introduction of styles, aliases, de duplication, the introduction of a component in a component, but it is not actually used. You can read it directly if you are interested babel-plugin-component Source code.

Vant and antd also use this method, but the plug-ins used are different. Both of them use babel-plugin-import , the Babel plugin component is actually fork from Babel plugin import.

Tree Shaking mode

The Vant component library not only supports loading on demand using the Babel plug-in above, but also supports Tree Shaking mode. The implementation is also very simple. The code finally released by Vant provides three standard source codes, namely commonjs, umd and esmodule, as shown in the following figure:

Commonjs specification is the most common way to use it. umd is generally used for cdn mode to be directly introduced on the page, and esmodule is used to implement Tree Shaking. Why esmodule can implement Tree Shaking but commonjs specification can't? The reason is that esmodule is statically compiled, that is, what a module exports and introduces can be determined at the compilation stage, The code execution phase will not change, so the packaging tool can analyze which methods are used, which are not, and which can be deleted safely when packaging.

Next, modify our component library so that it also supports Tree Shaking. Because our component itself is an esmodule module, it does not need to be modified, but we need to modify the exported file index.js, because the following export method is not supported at present:

import { Alert } from 'xui'

Add the following code:

// index.js
// ...

export {
    Alert,
    Tag
}

// ...

Next, we need to modify package.json. We all know that the main field in package.json is used to indicate the package's entry file. In fact, we can't just point this field to the entry file of esmodule, because it usually points to the commonjs module entry, and a package may support nodejs and web environments, The nodejs environment may not support the esmodule module. Since you cannot modify the old fields, you can only import new fields, that is, pkg.module. Therefore, modify the package.json file as follows:

// package.json
{
    // ...
    "mains": "index.js",
    "module": "index.js",// Add this field
    // ...
}

Because our component library only has esmodule module, in fact, these two fields point to the same. In actual development, we need to compile different types of modules like Vant, and modules published to npm generally need to be compiled into es5 syntax. Because these are not the focus of this article, this step is omitted.

The pkg.module field is added. If the packaging tool can recognize this field, it will give priority to the code of esmodule specification, but it does not end here. At this time, it is found that the content of Tag component is still after packaging. Why? You may wish to take a look at the following import scenarios:

import 'core-js'
import 'style.css'

These two files are only introduced, but they are not obviously used. Can they be deleted? Obviously, they can't be deleted. This is called "side effects", so we need to tell the packaging tool which files have no side effects, can be deleted and which ones have. Keep them for me. Vue CLI uses webpack, Correspondingly, we need to add a sideEffects field in the package.json file:

// package.json
{
    // ...
    "sideEffects": ["**/*.css"],
    // ...
}

Only style files in our component library have side effects.

Next, package the test and find that the content of the Tag component that has not been introduced has been removed:

More about Tree Shaking can be read Tree Shaking.

Using the plugin Vue components plug-in

In the on-demand introduction section of the official document of varlet component library, it is mentioned that unplugin-vue-components plug-in unit:

The advantage of this method is that it does not need to import components by itself. It is directly used in the template. The plug-in scans, imports and registers. This plug-in supports many popular component libraries in the market. For the built-in supported component libraries, you can directly refer to the above figure to import the corresponding parsing function for configuration, but our broken component library does not support it, So you need to write this parser yourself.

First of all, the plug-in just helps us introduce components and register. In fact, the function of on-demand loading still depends on the first two methods.

Tree Shaking

We first modify the module and sideEffects configuration of package.json based on the previous section, then delete the code imported and registered by the component from main.js, and then modify the vue.config.js file. Because the official documentation of this plug-in is relatively concise, I can't see a reason. Therefore, I modified it with reference to the built-in vant parser:

The meaning of the three returned fields should be clear. importName indicates the name of the imported component, such as Alert, and path indicates where to import it. For our component library, it is xui, and sideEffects is a file with side effects. Basically, it is to configure the corresponding style file path, so we modify it as follows:

// vue.config.js
const Components = require('unplugin-vue-components/webpack')

module.exports = {
    configureWebpack: {
        plugins: [
            Components({
                resolvers: [{
                    type: "component",
                    resolve: (name) => {
                        if (name.startsWith("X")) {
                            const partialName = name.slice(1);
                            return {
                                importName: partialName,
                                path: "xui",
                                sideEffects: 'xui/theme-chalk/' + partialName.toLowerCase() + '.css'
                            };
                        }
                    }
                }]
            })
        ]
    }
}

The author is afraid that the prefix and ElementUI coincide, so the prefix of component name is changed from El to X. for example, ElAlert is changed to XAlert. Of course, the template also needs to be changed to x-alert. Next, test:

You can see that the operation is normal, and the unused Tag components are successfully removed after packaging.

Separate introduction

Finally, let's take a look at the separate import method. First remove the pkg.module and pkg.sideEffects fields, and then modify the index.js file of each component to support the following import methods:

import { Alert } from 'xui/packages/alert'

The Alert component is modified as follows:

// index.js
import Alert from './src/main';

Alert.install = function(Vue) {
  Vue.component(Alert.name, Alert);
};

// Add the following two lines
export {
  Alert
}

export default Alert;

Next, modify our parser:

const Components = require('unplugin-vue-components/webpack')

module.exports = {
    configureWebpack: {
        mode: 'production',
        plugins: [
            Components({
                resolvers: [{
                    type: "component",
                    resolve: (name) => {
                        if (name.startsWith("X")) {
                            const partialName = name.slice(1);
                            return {
                                importName: partialName,
                                // Modify the path field to point to the index.js of each component
                                path: "xui/packages/" + partialName.toLowerCase(),
                                sideEffects: 'xui/theme-chalk/' + partialName.toLowerCase() + '.css'
                            };
                        }
                    }
                }]
            })
        ]
    }
}

In fact, the path field is modified to point to the index.js file of each component. After running the test and packaging the test, the results also meet the requirements.

Section

This paper briefly analyzes several ways to implement on-demand introduction of component library. Friends with component library development needs can choose by themselves. Please move to the following example code: https://github.com/wanglin2/ComponentLibraryImport.

Topics: Front-end