Pull to refresh

Ant Design Component Customization and Bundle Optimization

Mail.ru Group corporate blog JavaScript *TypeScript *

I'm Ivan Kopenkov, a senior front-end developer at Mail.ru Cloud Solutions. In this article, I will tell you about the approaches we have used for the UI library components customization. You will also learn how to significantly decrease bundle size, cutting off all the unnecessary modules Ant Design takes there.

In our case, we are making wrappers for original Ant Design components inside the project, changing their appearance, and developing their logic. At the same time, we import both customized and original components right from the ant-design module. That saves tree shaking functionality and makes complex library components use our wrappers instead of original nested elements.

If you are already or about to use Ant Design, this article will provide you with a better and more effective way to do so. Even if you have chosen another UI library, you might be able to implement these ideas.

Problems with using UI libraries

UI libraries provide developers with a variety of ready-to-use components that are commonly required in any project. Usually, such components are covered with tests, and they support the most common use cases.

If you're going to use one of these libraries, you should be ready to face the next two problems:

  1. Surely, every project requires UI components to be modified. The components must match the project design. Moreover, it's often needed to develop or change some components' logic for particular use cases.

  2. The majority of UI libraries include more components, icons, and utilities than will be used in one project, at least in its early stages. But all these files might be put into the bundle, which can dramatically increase the initial loading time for your app.

The first issue is solved by the customization of library components, and the second is tackled by bundle optimization. Some libraries, including Ant Design, are already adapted for tree shaking, which lets the bundler automatically exclude unused modules from the bundle.

However, even if you use Ant Design, built-in tree shaking support will be not enough to achieve effective bundle size. All the icons of this library will be included in the bundle, as well as the entire Moment.js library with every localization file since it is a dependency for some Ant components. Moreover, if some of the Ant components are re-exported in one file, each of them will be added to the bundle. Even if only one of them is used.

Methods of customization

Let’s begin by defining available solutions for customization of UI library components.

1. Redefinition of global classes (CSS only)

This is the simplest method. You just need to add styles for global CSS classes, which are used by UI library components.

The cons:

  • The behavior and logic of components can’t be changed or added.

  • CSS-in-JS may be used in this way, but only for global class definition, without the superpowers of this solution.

  • Global class usage causes unwanted style mixing: the same classes might be used in other parts of a project, and the selected UI-library may be used by third-party modules on the same site.

Indeed, the only advantage of this method is its simplicity.

2. Local wrappers for components

This method is more advanced, and it involves creating a separate file in your project for every component that you need to customize. Inside such a file, you make a new component, which renders inside itself the optional one from the UI-library.

The pros:

  • It lets you customize the styles of the components and also modify component logic.

  • You can use all the powers of CSS-in-JS at the same time.

The cons:

  • If an original component is used widely across the project, you will need to change all its imports to your new wrapper’s source. It can be quite time-consuming depending on the component usage broadness.

  • Suppose you use IDE autocomplete to automatically import selected components, using this approach. In that case, you will need to pay attention to the component you select from the list because you will have at least two of them: the customized one and the original one. It's easy to forget about this and pick the original component or even accidentally leave imports of some original ones after creating a new wrapper.

  • And the most important thing: many of the components are complex, and they use inside themselves other components of the same library. Since the original components have absolutely no idea about our wrappers, they will continue to use the original ones inside themselves, ignoring the logic or appearance changes made in wrappers. For example, such an Ant Design component as AutoComplete renders inside itself the components Input and Select. At the same time, inside List are used Grid, Pagination, and Spin. The same thing with Password, Search, and Textarea, which are the dependencies for Input, and so on.

3. Forking the UI library repository

Making a private copy of the original UI library repository seems to be the most powerful and the most complicated approach at once.

The pros:

  • It gives you maximum freedom in appearance customization and logic modification.

  • There is the opportunity to reuse the same forked UI library in other projects. 

The cons:

  • You could meet some complications when you try to pull the original repository updates to the forked one. 

  • It can be quite inconvenient for developers to continuously modify components in a separate repository to meet the main project’s requirements. 

How we have been customizing Ant components

After a long discussion, our team decided to use the Ant Design UI library for new projects. My responsibility was to create a boilerplate for a new project, which will be used later to launch other projects. It is crucial for us to change styles and also to modify and add logic for components.

We didn't want to fork the Ant Design repository because we had a bad experience separating the components to a detached repo. Developing MCS, we've been using the Semantic UI library, storing its components in a separate repository. No convenient way of working with that was found. For the first time, we used to share this repository with another project (b2c-cloud), developing different themes for each other. But that was inconvenient, and changes for one project could accidentally affect another, so at some point, we forked from this repository again. Eventually, we moved the wrappers from the detached repository to the project, and we're pretty happy with that.

I've chosen the second approach to create wrappers directly in the project. At the same time, I wanted customized components to be imported right from the antd module. This allows us to avoid changing imports of already used components when we make wrappers for them.  This also saves tree shaking and makes complex components automatically use custom wrappers instead of original components inside themselves.

After that, I will tell you how meeting these requirements was achieved step by step, and you will understand how to implement the same approach in other projects.

Step 1. Files with wrappers

In the folder where project components are stored, I made a new catalog for future wrappers, called antd. Here, we gradually added new files for wrappers, depending on our demands in modification. Every file is a composition, a wrapper component rendering an original one imported from a UI library. Let’s look at the simplified example of such a file:

import AntButton, {
    ButtonProps as AntButtonProps,
} from 'antd/lib/button/index';
import Tooltip from 'antd/lib/tooltip';
import classNames from 'classnames';
import React from 'react';
import styled from 'styled-components';

const ButtonStyled = styled(AntButton)`
    background-color: red;
`;

export type ButtonProps = AntButtonProps & {
    tooltipTitle?: React.ReactNode;
};

const Button = ({ tooltipTitle, ...props }: ButtonProps) => {
    const button = (
        <ButtonStyled {...props} className={classNames(props.className)} />
    );
    if (tooltipTitle) {
        return <Tooltip title={tooltipTitle}>{button}</Tooltip>;
    }
    return button;
};

export default Button;

To demonstrate a method of style customization, I just changed the component background color using Styled Components. To show the method of logic customization, I added the tooltipTitle parameter to additionally render a tooltip when it is passed.

Step 2. Change component imports with aliases to wrappers

Now let's consider how to make a builder (here: Webpack) change the original path of modules imported from the root of antd to the path of our wrappers.

We should create an index.ts file in the root folder with wrappers src/components/antd and copy into this file the content of the file located at node_modules/antd/lib/index.d.ts. Then, using the massive replace tool of some IDE, we change every import path from ./componentName to antd/lib/componentName.

By this point, there should be the next content:

export { default as Affix } from 'antd/lib/affix';
export { default as Anchor } from 'antd/lib/anchor';
...
export { default as Upload } from 'antd/lib/upload';
export { default as version } from 'antd/lib/version';
export { default as Button } from 'antd/lib/version';

Then, we change the import paths of the components for which we made the wrappers. In this case, we should import Button from src/components/antd/Button:

export { default as Affix } from 'antd/lib/affix';
export { default as Anchor } from 'antd/lib/anchor';
...
export { default as Upload } from 'antd/lib/upload';
export { default as version } from 'antd/lib/version';
export { default as Button } from 'src/components/antd/Button';

Now we only need to configure Webpack to use these paths as the aliases to the Ant components. I've made a simple tool that makes the set of aliases. 

The code for this tool (AntAliases.ts) you can see under the spoiler.

Spoiler
 import { execSync } from 'child_process';
        import fs from 'fs';
        import _ from 'lodash';
        import path from 'path';

        const SPECIAL_ALIASES = {};

        const COMPONENT_LIST_FILE_LOCATION = path.resolve(
            __dirname,
            '../src/components/antd/index.ts',
        );

        const ANT_LIB_LOCATION = path.resolve(__dirname, '../node_modules/antd/es');

        function getComponentLocations() {
            let content = getComponentListFileContent();

            const namePathMap: Record<string, string> = {};

            const singleExportRegexp = /([\s\S]*)^export {[ \n][ ]*default as ([a-zA-Z]+)[ ,]\n?} from '(.+)';\n?$([\s\S]*)/m;
            while (singleExportRegexp.test(content)) {
                const name = content.replace(singleExportRegexp, '$2');
                const path = content.replace(singleExportRegexp, '$3');
                content = content.replace(singleExportRegexp, '$1$4');
                const importedFromAnt = path.indexOf('antd') === 0;
                if (!importedFromAnt) {
                    namePathMap[name] = path;
                }
            }

            return namePathMap;
        }

        function getComponentListFileContent() {
            return fs.readFileSync(COMPONENT_LIST_FILE_LOCATION, {
                encoding: 'utf8',
            });
        }

        function makeAliasEntries() {
            const componentLocations = getComponentLocations();

            const absoluteAliases = Object.entries(componentLocations).map(
                ([name, path]) => {
                    const alias = `antd/es/${_.kebabCase(name)}$`;
                    return [alias, path];
                },
            );

            const relativeAliases = getAntRelativeImports()
                .replace(/^.*\.\.\/([\w-]+).*$/gm, '$1')
                .split('\n')
                .filter(Boolean)
                .map((fileName) => {
                    const key = _.capitalize(_.camelCase(fileName));
                    const path =
                        SPECIAL_ALIASES[fileName as keyof typeof SPECIAL_ALIASES] ||
                        componentLocations[key];
                    return [`../${fileName}$`, path];
                })
                .filter(([, alias]) => alias);

            return Object.fromEntries([...absoluteAliases, ...relativeAliases]);
        }

        function getAntRelativeImports() {
            return execSync(
                `find ${ANT_LIB_LOCATION} -type f -name "*.js" -not \\( -path "*_util*" -or -path "*tests*" -or -path "*locale*" -or -path "*style*" \\) -exec grep -hr "^import .* from '\\.\\./[a-z\\-]*';$"  {} \\; | grep -v 'config-provider\\|time-picker\\|locale-provider'`,
            ).toString();
        }
        
        export const AntAliasesEs = makeAliasEntries();

> Worth noting, the solution to the problem with complex components is using original nested elements instead of the custom ones. There is a piece of code in the file AntAliases.ts that finds relative imports of nested components inside the complex ones located in the Ant Design library folder files. It then creates aliases for these imports, making complex components use our custom wrappers for the nested components.

The resolve section of our Webpack config looks like this:

...
    resolve: {
        alias: {
            ...AntAliasesEs,
        },
    },
...

Step 3. TypeScript support (optional)

The first two steps are enough to work on their own. However, if you use TypeScript and change interfaces of original components in your wrappers (as I did in the example, having added the additional property tooltipTitle), then you will need to add aliases to the TypeScript config. In this case, it's much simpler than it was with Webpack; you simply add the path of the file with imports of the wrappers from the previous step to tsconfig.json:

...
    "paths": {
        "antd": ["src/components/antd"],
    },
...

Step 4. Variables (optional)

As we use Styled Components for our projects, it's pretty convenient for us to declare style variables in a single ts file and import some of them where we need them. Ant Design styles were written using Less.js, which allows us to build styles in our project, injecting our variables using less-loader. Thus, it's a great opportunity to use the same variables inside our components and wrappers, as well as to build styles of the original components with them. 

Because our style guide implies naming variables and functions in camelCase, initially we defined variables in this case. Ant Designless-files use kebab-case for variable naming, thus we automatically transform and export these variables in kebab-case as well.

Our file with style variable declarations in short form looks like this:

        import _ from 'lodash';

        export const CssColors = {
            primaryColor: '#2469F5',
            linkColor: '#2469F5',
            linkHoverColor: '#2469F5',

            // non antd
            defaultBg: '#f0f0f0',
        };

        export const CssSizes = {
            fontSizeSmall: '12px',
            fontSizeBase: '15px',

            // non antd
            basicHeight: '40px',
        };

        export const CssOtherVars = {
            linkDecoration: 'none',
            linkHoverDecoration: 'underline',

            // non antd
            disabledOpacity: '0.75',
        };

        export const CssVariables = {
            ...CssColors,
            ...CssSizes,
            ...CssOtherVars,
        };

        function getKebabVariables(variables: Record<string, string>) {
            const entriesKebab = Object.entries(variables).map(([key, value]) => {
                return [_.kebabCase(key), value];
            });
            return Object.fromEntries(entriesKebab);
        }

        export const CssVariablesKebabCase = getKebabVariables(CssVariables);

You can see the complete list of Ant Design variables in this file.

We do injection of variables and building of less-files by adding less-loader into the Webpack configuration:

...
    {
        test: /\.less$/,
        include: /node_modules/,
        use: [
            ...
            {
                loader: 'less-loader',
                options: {
                    sourceMap: true,
                    javascriptEnabled: true,
                    plugins: [
                        new CleanCSSPlugin({ advanced: true }),
                    ],
                    modifyVars: CssVariablesKebabCase,
                },
            },
        ],
    },
...

The component example

Once you have completed the first two steps, everything should work fine. Let's have a look at the code in which we use the modified component:

import { Button } from 'antd';

export const SomeComponent = () => {
    return <Button tooltipTitle="some tooltip text">some button text</Button>
}

The problem with Grid and Radio

You can omit this part if you don't plan to make Grid and Radio render wrapped components inside themselves instead of original ones. This problem is caused by the fact that Grid is virtually not a separate component. In fact, its source located at node_modules/antd/es/grid/index.js contains only re-exports of the components Col and Row. 

All the other complex components already use our wrappers, thanks to aliases we made. But when we use Grid it will still import original Col and Row because of its file content. To fix this we should consider the next steps.

To illustrate this case, I made a wrapper for the Col component and made its background red by default. I rendered the original List component for the test and want it to render the modified Col for its columns.

<List
    data-testid="list"
    grid={{ column: 2 }}
    dataSource={['Item 1', 'Item 2']}
    renderItem={(item) => <List.Item>{item}</List.Item>}
/>

To make List use exactly our wrapper instead of the default Col, we created a new file to replace original re-exports located in node_modules/antd/es/grid/index.js with paths to our wrappers. We applied this new file to antd/Grid.ts, and here is its content:

export { default as Col } from 'src/components/antd/Col; // path to our wrapper for Col
export { Row } from 'antd/es/grid/index'; // still reexport default Row as we didn't make a wrapper for it yet

Now we only need to set the path to this file in the constant SPECIAL_ALIASES defined in AntAliases.ts:

...
const SPECIAL_ALIASES = {
    grid: 'src/components/antd/Grid',
    radio: 'src/components/antd/Radio', // just to illustrate how to do the same for Radio
};
...

Finally, the customization part is over. Now List will render our Col wrapper as its columns. To customize Row as well just make a wrapper and change the Row path at src/components/antd/Grid.tsx. It's not very convenient to do, but you only need it for two components: Grid and Radio. Although, during the last year, we haven't received demand for that in our projects.

Bundle optimization

Tree shaking

As I mentioned, the latest version of Ant Design is adapted for tree shaking right out of the box. Its previous versions weren’t, so we used to use babel-plugin-import to drop the unused code. I assume that the other libraries without built-in tree shaking support can achieve this, at least partially, using this plugin. 

Styles import

Despite native tree shaking support, we didn't drop babel-plugin-import and continue to use it to automatically get styles of a component when we import its js-code. Using it, no excess styles are added to the bundle, and developers don't need to think about style dependencies. Now, it's impossible to forget to import the styles of some components.

The plugin is enabled in the babel.config.js file in the root of the project:

...
    [
        'import',
        {
            libraryName: 'antd',
            libraryDirectory: 'es',
            style: true,
        },
        'antd',
    ],
...

Moment.js

At this time, the bundle consists of the following modules:

Ant Design uses Moment.js, which pulls all the localization files it has to the bundle. You can see in the image how dramatically it increases the size of the bundle. If you don't need such components depending on Moment.js, such as DatePicker, you can simply cut this library, for example, by adding an alias for Moment.js to some empty file.

As we're still using Moment.js for our projects (ignoring the fact that its creators have recently deprecated it ?), we didn't need to fully eliminate it. We just excluded useless localization files from adding to the bundle, leaving only supported languages (en and ru).

It became possible thanks to ContextReplacementPlugin, delivered with Webpack:

...
    plugins: [
        new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /ru|en-gb/),
    ],
...

Now we can make sure that redundant files are eliminated, as in the next screenshot:

> If you use Lodash and/or Ramda and want to exclude their unused files from the bundle, but at the same time you don't want to import every function from their separate files, you can just add to your Babel config babel-plugin-lodash and babel-plugin-ramda.

Icons

Webpack Bundle Analyzer screenshots above show that the heaviest part of the bundle is the Ant Design built-in icon set. This happens because Ant Design exports icons from a single file.

We use unique custom icons in our projects, so we don't need this file at all. You can cut it off, as well as Moment.js, just by making an alias to some empty file. However, I want to illustrate the ability to save only the required default icons if you want to use them.

For that reason, I added the file src/antd/components/Icons.tsx. I left there only the Spinner icon to render a button in state "loading":

export { default as Loading } from '@ant-design/icons/lib/outline/LoadingOutline';

I also added an alias to this file into the Webpack config.

...
'@ant-design/icons/lib/dist$': path.resolve('src/components/antd/Icons.tsx'),
...

And now we just need to render the button itself:

<Button loading>Loading</Button>

As a result, we get the bundle with only the one icon we used instead of getting the full pack of icons as before:

Optionally, you can easily replace default icons with standard ones using the same file we've just created.

Conclusion

Finally, every unused component of Ant Design has been cut off by Webpack. At the same time, we continue to import any component, whether it is a wrapper or an original one, from the root of the library.

Moreover, during development, TypeScript will show proper types for customized components as it was with Button from the example above, for which we added the additional property tooltipTitle.

If we decide to customize another component in the project, even a widely used one, we will just need to add a file with the wrapper and change the path of that component in the file with re-exports located at src/components/antd/index.ts.

We've been using this approach for more than a year in two different projects, and we still haven't found any flaws.

You can see the ready-to-use boilerplate with a prototype of this approach and the examples described in this article in my repository. Along with this solution, we test our components using Jest and React Testing Library. This will be addressed in a different post, as it includes a few tricky elements.

Tags: mail.ru cloud solutionstypescriptjavascriptreactwebpackant design
Hubs: Mail.ru Group corporate blog JavaScript TypeScript
Total votes 12: ↑12 and ↓0 +12
Comments 0
Comments Leave a comment

Popular right now

Top of the last 24 hours

Information

Founded
Location
Россия
Website
team.mail.ru
Employees
5,001–10,000 employees
Registered
Representative
Павел Круглов

Habr blog