Кастомизация компонентов Ant Design и оптимизация бандла

z4jrbdrz8osbdx8_d153woajwnk.jpeg

The Archivist by juliedillon

Я Иван Копенков, ведущий фронтенд-разработчик в Mail.ru Cloud Solutions, в статье расскажу, какие есть подходы к кастомизации компонентов UI-библиотеки Ant Design, как это сделали мы, а так же покажу, как удалось полностью избавиться от неиспользуемых модулей и уменьшить размер бандла.

Если вы уже используете или собираетесь использовать библиотеку компонентов Ant Design, то из данной статьи сможете узнать, как можно делать это удобнее и эффективнее. Если вы используете другую библиотеку компонентов, то сможете использовать описанный подход в работе с вашей UI-библиотекой.

Какие проблемы возникают при использовании UI-библиотек


UI-библиотеки предоставляют разработчикам возможность использовать готовые типовые компоненты, зачастую уже покрытые тестами и поддерживающие основные сценарии использования.

Однако в таком случае возникают две проблемы:

  1. Обычно все сталкиваются с необходимостью стилизовать эти компоненты под проект и модифицировать их логику для непредусмотренных юзкейсов.
  2. В большинстве случаев библиотеки содержат большое количество компонентов, иконок и утилит, не все из которых потребуются в конкретном проекте, но могут попадать в бандл и существенно увеличивать время загрузки приложения.


Первая проблема решается кастомизацией компонентов, а вторая — оптимизацией бандла. Некоторые библиотеки, например Ant Design, адаптированы под tree shaking, что позволяет сборщику вырезать из бандла неиспользуемые части.

Однако этого может оказаться недостаточно, так как в случае с Аnt Design в бандл все равно попадают все иконки и файлы локализации Moment.js. Кроме того, если где-то в проекте компоненты будут реэкспортится из одного файла, то все такие модули окажутся в сборке независимо от того, используются они или нет.

Способы кастомизации


Для начала разберемся, какие можно придумать решения, если для проекта требуется кастомизировать компоненты сторонней библиотеки.

Переопределение глобальных классов (только CSS)


Это самый легкий способ — просто модифицировать стили через CSS, изменяя свойства глобальных классов выбранной библиотеки компонентов.

Из очевидных недостатков:

  • Не можем изменять поведение компонентов.
  • Не можем в полную силу использовать CSS-in-JS (только для объявления глобальных классов).
  • Минусы, вытекающие из использования глобальных классов, приводят к нежелательному смешению стилей: классы с такими же именами могут использоваться в других местах; сама UI-библиотека может использоваться сторонними модулями на вашем сайте.


В качестве единственного плюса этого подхода в голову приходит только его простота.

Локальные обертки для компонентов


Это более продвинутый способ — создать файл с оберткой над оригинальным компонентом и импортировать его во всех местах.

Плюсы:

  • Это позволит помимо стилей довольно гибко изменять поведение.
  • Также становится доступным полноценно использовать CSS-in-JS, например Styled Components.


Минусы:

  • Если оригинальный компонент уже использовался раньше, то придется заменять импорты во всех таких местах. Это отнимает некоторое время.
  • Кроме того, при использовании подсказок IDE для автоимпорта, нужно отличать стандартный компонент от кастомизированного. В каких-то местах можно забыть и оставить некастомизированные компоненты, либо случайно импортировать оригинальный.
  • И самое главное — многие компоненты UI-библиотек используют внутри себя другие компоненты. Соответственно, о наших обертках библиотека ничего не знает, внутри комплексных компонентов будут продолжать использоваться оригинальные компоненты, поведение и дизайн которых должен значительно отличаться в обертках. Например, в Ant Design компонент AutoComplete использует Input и Select. Внутри List импортируются Grid, Pagination и Spin. Password, Search и Textarea зависят от Input и так далее.


Форк репозитория библиотеки компонентов


На мой взгляд, это одновременно как самый мощный подход, так и самый сложный.

Достоинства:

  • Максимальная свобода в модификации.
  • Возможность переиспользовать форкнутую библиотеку в других проектах.


Недостатки:

  • Могут быть сложности с подтягиванием обновлений.
  • Кроме того, работать с отдельным репозиторием может быть неудобно.


Как поступили мы


После длительного обсуждения наша команда выбрала использовать в новых проектах библиотеку Ant Design. На мою долю выпало полное создание каркаса будущих приложений, включая подключение библиотеки.

Нам, безусловно, требуется изменять не только стили, но и осуществлять многочисленные модификации и расширение логики работы компонентов UI-библиотеки. Форкать репозиторий Ant Design желания не было.

На проекте MCS мы долгое время разрабатывали обертку над другой библиотекой компонентов Semantic UI, подключая ее из отдельного репозитория, но так и не нашли удобного способа для работы с ним. Переиспользовать этот репозиторий на другом проекте (b2b-облако) тоже оказалось неудобно, и мы с ним разошлись. В конечном итоге перенесли обертку из отдельного репозитория в сам проект.

В связи с этим я решил выбрать второй способ и создавать обертки сразу внутри проекта. При этом хотелось сохранить импорты вида import { Button } from 'antd', поддержку tree shaking, а также сделать так, чтобы внутри сложных компонентов автоматически использовались обернутые взамен дефолтных.

Далее расскажу по шагам, как это было сделано и как это можно повторить в других проектах.

Шаг 1. Файлы с обертками


В папке с компонентами проекта я создал папку antd. В ней мы постепенно, по мере появления потребностей в модификациях, создаем файлы для наших оберток. Данные файлы представляют собой композицию, где компонент-обертка рендерит оригинальный компонент UI-библиотеки. Рассмотрим пример обертки в упрощенном виде.

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 = (
        
    );
    if (tooltipTitle) {
        return {button};
    }
    return button;
};

export default Button;


В качестве примера модификации стилей изменяем цвет фона через Styled Components. А чтобы показать возможности кастомизации поведения, добавляем параметр, в зависимости от наличия которого при наведении указателя на кнопку будет появляться тултип.

Шаг 2. Замена импортов дефолтных компонентов алиасами на обертки


Теперь о том, как заставить сборщик (Webpack) при импортировании именованных модулей из корня antd заменять их на пути к нашим оберткам.

В корне папки с обертками src/components/antd создаем файл index.ts, в который копируем содержимое файла node_modules/antd/lib/index.d.ts. Методом массового редактирования изменяем все пути импорта с ./componentName на antd/lib/componentName.

На этом этапе получаем примерно такое содержимое:

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';


Теперь заменяем пути импорта тех компонентов, для которых мы сделали обертки. В нашем случае — импортируем Button из 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';


Остается сделать так, чтобы Webpack при импорте компонентов из Ant Design фактически брал наши обертки. Для этого на основе файла antd/index.ts создаем набор алиасов и добавляем его в конфигурацию Webpack. В нашем случае мы автоматически создаем набор ссылок на ES-модули для Webpack. Для этого создали в папке с конфигами Webpack файл AntAliases.ts, который автоматически собирает набор алиасов на основе имеющихся оберток и относительных импортов библиотеки Ant Design на свои же модули.

Содержимое AntAliases.ts
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 = {};

    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();


Секция resolve нашего конфига для Webpack выглядит так:

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


Шаг 3. Поддержка TypeScript (опционально)


В целом первых двух шагов достаточно, чтобы все работало. Но если вы используете TypeScript и изменяете интерфейс обернутых компонентов (как в нашем случае — добавлением к пропсам обертки для Button поля tooltipTitle), то потребуется добавить алиасы в конфиг TypeScript. Здесь уже гораздо проще — достаточно всего одной строки в разделе paths файла tsconfig.json:

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


Шаг 4. Использование переменных (опционально)


Поскольку мы используем Styled Components, нам удобно хранить переменные для стилей в отдельном ts-файле. Стили Ant Design написаны на less, поэтому есть возможность собирать их, передавая переменные через less-loader. В связи с этим у нас используются одни и те же переменные и для сборки стилей UI-библиотеки, и для стилизованных компонентов внутри проекта.

Так как наш стайлгайд предполагает написание кода в camelCase, мы объявляем css-переменные в том же стиле. В less-файлах Ant Design переменные используются в kebab-case, поэтому наши переменные экспортируем в обоих стилях, автоматически переводя camelCase в kebab-сase с помощью Lodash.

Файл с переменными в сокращенном виде выглядит так
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) {
    const entriesKebab = Object.entries(variables).map(([key, value]) => {
        return [_.kebabCase(key), value];
    });
    return Object.fromEntries(entriesKebab);
}

export const CssVariablesKebabCase = getKebabVariables(CssVariables);


Полный список переменных Ant Design можно посмотреть в этом файле.

Сборка less-файлов и инжект переменных делается добавлением соответствующего лоадера в конфиг Webpack:

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


Пример использования компонентов


После этих манипуляций все должно работать. Так выглядит код, в котором мы используем модифицированные компоненты:

import { Button } from 'antd';

export const SomeComponent = () => {
    return 
}


Проблема с комплексными компонентами


Этот пункт можно пропустить, но остается проблема с двумя комплексными компонентами: Grid и Radio. Фактически компонента Grid не существует, в файле node_modules/antd/es/grid/index.js лежат реэкспорты компонентов Col и Row.

Во всех других сложных компонентах уже автоматически используются существующие обертки, благодаря добавленным на втором шаге алиасам. Но если нужно, чтобы Grid отрисовывал кастомные Col и Row, то нужно сделать следующее.

В качестве примера я создал обертку над компонентом Col и сделал фон этого компонента красным по умолчанию. Для теста отрисовываю оригинальный компонент List и хочу добиться того, чтобы в качестве столбцов он выводил кастомный Col.

 {item}}
/>


Чтобы List использовал не дефолтный Col, а именно нашу обертку, в папке components/antd создаем файл Grid.tsx со следующим содержанием:

export { default as Col } from 'src/components/antd/Col; // наша обертка
export { Row } from 'antd/es/grid/index'; // реэкспортим дефолтный Row, так как мы его не кастомизировали


И в файле AntAliases.ts указываем путь к обертке для Col в константе SPECIAL_ALIASES:

...
const SPECIAL_ALIASES = {
    grid: 'src/components/antd/Grid',
    radio: 'src/components/antd/Radio',
};
...


На этом манипуляции закончены. Теперь List будет выводить нашу обертку в качестве столбцов. Когда мы захотим кастомизировать Row, нужно будет создать файл с оберткой и указать пути к нему в Grid.tsx и переменной SPECIAL_ALIASES. Это не очень удобно, но этот шаг при необходимости потребуется только для компонентов Grid и Radio, в чем на реальных проектах у нас пока нужды не возникало.

Оптимизация сборки


Tree shaking


Как уже указывалось, сейчас Ant Design поддерживает tree shaking из коробки. В более ранних версиях для этого приходилось использовать babel-plugin-import. Полагаю, что для UI-библиотек без поддержки tree shaking можно попробовать активировать его хотя бы частично с помощью этого же плагина.

Импорт стилей


Несмотря на нативную поддержку tree shaking, от babel-plugin-import мы не отказались. Он все еще нужен нам, чтобы при импорте js-файлов компонента в проект автоматически подтягивались файлы со стилями. Так в бандле остаются только нужные стили и становится невозможной ситуация, когда разработчик забывает вручную импортировать стили отдельного компонента.

Плагин подключается в файле babel.config.js:

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


Moment.js


На данном этапе состав бандла получается таким:

lxprcx5jggd_g46h6xdxyozlfow.jpeg

Ant Design использует библиотеку Moment.js, при этом в сборку попадают файлы локализации всех поддерживаемых языков, которые довольно ощутимо увеличивают размер бандла. Если вам не нужны такие компоненты как DatePicker, использующие эту Moment.js, то можно ее вырезать, например, создав для нее алиас на пустой файл.

Поскольку мы продолжаем использовать Moment.js во всех проектах (несмотря на то, что создатели недавно задепрекейтили ее), то полностью исключать ее не стали, но вырезали файлы локализации для всех языков, кроме поддерживаемых.

В этом нам помог ContextReplacementPlugin, встроенный в Webpack.

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


Можем убедиться, что лишние файлы локализации Moment.js исчезли из сборки:

qsn8djr1g_1evbsymd6kyndiom4.jpeg

Думаю все и так знают, но вдруг найдется кто-то, кому это сможет облегчить жизнь: если вы используете lodash или ramda, не хотите, чтобы в бандл попадали неиспользуемые файлы, при этом вам лень импортировать каждую функцию из отдельного файла, то можно просто добавить в конфиг Babel плагины babel-plugin-lodash и babel-plugin-ramda.


Иконки


На скриншотах Webpack Bundle Analyzer из предыдущего раздела можно было заметить, что больше всего места в сборке занимает встроенный в Ant Design набор иконок. Это происходит из-за того, что все иконки экспортируются из одного файла.

В нашем проекте используются собственные иконки, поэтому данный файл в сборке не нужен. Чтобы убрать его, можно, опять же, сделать алиас на пустой файл. Но для примера я решил показать, как можно оставить только нужные иконки, если вы их используете.

Для этого создаем файл src/antd/components/Icons.tsx. В нем я решил оставить только иконку спинера, чтобы отрисовать кнопку со статусом "loading":

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


В конфиге Webpack добавляем соответствующий алиас:

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


И выводим саму кнопку:



В итоге получаем бандл лишь с одной иконкой вместо полного комплекта:

ajtpqlklrgksq4jq6vg9hlhlqyu.jpeg

При желании с использованием этого же файла можно изменять стандартные иконки на кастомные.

Итоги


В готовой сборке Webpack отсечет все остальные компоненты библиотеки Ant Design. При этом мы продолжаем импортировать кнопку из корня библиотеки.

Вместе с тем при разработке TypeScript отобразит кастомный тип, например, как в случае с Button из примера, для которой был добавлен дополнительный параметр tooltipTitle.

Когда мы решим кастомизировать какой-то другой компонент, даже уже использовавшийся ранее в проекте, достаточно будет создать файл с оберткой и единственный раз поменять путь импорта к этому компоненту в файле src/components/antd/index.ts.

Мы пользуемся этим способом уже более года на двух проектах и пока не обнаружили каких-то подводных камней.

Готовый бойлерплейт проекта с рабочим прототипом можно посмотреть у меня в репозитории. Также в рамках нашего подхода к использованию Ant Design мы тестируем компоненты с помощью Jest и React Testing Library. Как это делать, могу рассказать в отдельной статье.

А вот еще интересные статьи от моих коллег:

  1. Почему мы выбрали MobX, а не Redux, и как его использовать эффективнее.
  2. Property-based тестирование для JavaScript и UI: необычный подход к автоматизированным тестам.


P.S. Наша команда фронтенда ищет разработчика на React, MobX, TypeScript в MCS и другие проекты, а коллеги из бэка ищут разработчика на Go в команду Identity Access Management. Из интересного — разработка на open source, highload, Kubernetes, распределенные системы. А полный список наших вакансий — здесь.

© Habrahabr.ru