javascript

Компонентная разработка (reusable)

  • среда, 3 июня 2026 г. в 00:00:16
https://habr.com/ru/companies/megafon/articles/1041500/

Это не разбор, не рекомендация, а скорее ценный опыт фронтовой команды с полей российского IT. Все что описано ниже — общий результат нашей работы.

Перед любой командой со стороны менеджмента и бизнеса стоит одна и та же задача «давайте быстрее — надо было еще вчера». Нас эта судьба также не миновала. Первое, с чего начинается оптимизация, своя дизайн‑система. У нас уже была своя дизайн‑система, она выручала нас во многих кейсах, но она была старая, подходы к разработке давно изменились, и UI Kit уже не отвечал требованиям. Поэтому было принято решение параллельно разрабатывать новую, с учетом всех последних требований и подходов.Это не разбор, не рекомендация, а скорее ценный опыт фронтовой команды с полей российского IT. Все что описано ниже — общий результат нашей работы.

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

Разработка имеет такую очередь: архитектурно наш UI Kit лежит в закрытом artifactory npm пакетом. Также есть шаблон со всеми необходимыми зависимостями и UI Kit«ом. Когда приходит время нового проекта, делается fork заготовки. Это дает быстрый старт.»

Начинаем

Давайте посмотрим, как бы начиналась разработка приложения. Возьмем для примера компонент Select. Не будем писать сами, возьмем Antd Select и через обертку emotion стилизуем его под корпоративные стили.

Структура получится такая:

Select.styles.tsx

import { Select } from 'antd';
import styled from '@emotion/styled';

const StyledSelect = styled(Select)`
  width: 240px;

  .ant-select-selector {
    border-radius: 12px !important;
    border: 1px solid #444 !important;
    background-color: #1e1e1e !important;
    color: #fff !important;
    padding: 4px 8px !important;
  }

  .ant-select-selection-item {
    color: #fff !important;
  }

  &:hover .ant-select-selector {
    border-color: #888 !important;
  }

  &.ant-select-focused .ant-select-selector {
    border-color: #1677ff !important;
    box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.2) !important;
  }
`;

Select.tsx

import { useState } from 'react';
import { StyledSelect } from './Select.styles.tsx'

const { Option } = Select;

Export const ExampleSelect = () => {
    const [value, setValue] = useState<string | undefined>(undefined);

    const handleChange = (val: string) => {
        setValue(val);
    };

    return (
        <StyledSelect
            placeholder="Выбери опцию"
            value={value}
            onChange={handleChange}
            allowClear
        >
            <Option value="apple">Apple</Option>
            <Option value="banana">Banana</Option>
            <Option value="orange">Orange</Option>
        </StyledSelect>
    );
};

Но никому не нужен просто Select компонент с локальным state. Обычно пишут приложение со state manager, redux или mobx. Например, в нужном разделе хранилища расположим логику для нашего Select«а. Пускай путь для логики Select»а будет такой root → cart → order → city. Для примера это будет город доставки.

Хранилище для Select содержит в себе:

// переменные

  • options для списка возможных вариантов;

  • selectedOptions выбранные опции;

  • isLoading для отображения loading«а при загрузке options;»

  • url для получения данных с backend;

// методы

  • getOptions если они берутся с backend;

  • getSelectedOptions получить выбранные элементы;

  • setIsLoading смена значения isLoading;

  • resetSelected метод сброса выбранных элементов;

  • resetOptions метод сброса опций;

Дальше подключается созданное хранилище к Select и им начинают пользоваться.

Что я часто встречал на разных проектах, так это созданный Select, стилизованный под корпоративные стили, который переиспользовался в разных местах. При этом, где‑то он должен иметь немного иные стили, и там его делали еще раз. Логика хранилища писалась всегда с нуля, поэтому где‑то опции брались из конфига, где‑то с backend«а, где‑то это был multi select, где‑то single. В целом вариаций использования было очень много, поэтому каждый писал свою логику под свой индивидуальный случай.»

Начинаем задумываться о переиспользовании

Раз уж есть переиспользуемый React компонент, так пускай и будет переиспользуемое хранилище. Для примера возьмем mobx, так получится более наглядно. Основные переменные и методы мы выделили выше, так что просто их опишем в mobx store.

export interface HasIdI {
  id: string | number;
}

interface SelectStoreProps<T> {
  url?: string | (() => string);
  onSelect?: (selectedOptions: T[]) => void;
  options?: T[];
}

export class SelectStore<T extends HasIdI> {
  url: string | (() => string);
  onSelect?: (selectedOptions: T[]) => void;

  options: T[] = [];
  selected: T[] = [];
  isLoading = false;

  constructor({ url = '', onSelect = () => {}, options }: SelectStoreProps<T> = {}) {
    this.url = url;
    this.onSelect = onSelect;

    if (options) {
      this.options = options;
    }

    this.reset();

    makeAutoObservable(this, { url: false, onSelect: false }, { autoBind: true });
  }

  get isDisabled(): boolean {
    return this.options.length === 0;
  }

  async getList() {
    if (!this.url) {
      return;
    }

    try {
      this.setLoading(true);

      let query = '';
      const url: string = typeof this.url === 'function' ? this.url() : this.url;
      const list = await API.request<T[]>(url);

      this.setOptions(list);
    } catch (e) {
      console.error(“Возникла ошибка при загрузке опфильтра”);
    } finally {
      this.setLoading(false);
    }
  }

  setOptions(list: T[]) {
    this.options = list;
  }

  setLoading(status: boolean) {
    this.isLoading = status;
  }

  setSelected(values: T[]) {
    this.selected = values;

    if (this.onSelect) {
      this.onSelect(values);
    }
  }

  selectAll() {
    if (!this.options.length) return;

    this.setSelected(this.options);
  }

  reset() {
    this.isLoading = false;
    this.selected = [];

    if (this.url) {
      this.options = [];
    }
  }

  clearSelected() {
    this.selected = [];
  }
}

Здесь стоит обратить внимание на интерфейс HasIdI, ибо каждый item из опции должен обладать уникальным id признаком.

В хранилище по пути root → cart → order → city создаем Select Store для хранения данных города из заказа.

import { makeAutoObservable } from 'mobx';
import { SelectStore } from 'src/reusable-stores/select.store.ts';

export class OrderStore {
  city = new SelectStore({ url: 'backend/url/cities', onSelect: () => {} });

  …

Теперь есть два пазла, которые можно соединить вместе: хранилище + компонент. Далее нужно подключить Select к mobx, из store взять SelectStore и использовать необходимые методы и переменные для работы компонента.

Select.tsx

import { StyledSelect } from './Select.styles.tsx';
import { observer } from 'mobx-react-lite';
import { useStores } from 'src/stores/root.store.ts';
import { useEffect } from 'react';

export const ExampleSelect = observer(() => {
    const {
      cart: {
        order: { city: citySelectStore },
      },
    } = useStores();

    useEffect(() => {
      // получаем лист с опциями с backend
       citySelectStore.getList()

       return () => {
          // очищаем компонент
          citySelectStore.reset();
       };
    }, []);


    return (
        <StyledSelect
            placeholder="Выбери опцию"
            value={citySelectStore.selected}
            onChange={citySelectStore.setOptions}
            allowClear
        >
            {
	       citySelectStore.options.map((cityOption) => {
                return <Option value={cityOption.value}>{cityOption.name}</Option>
              })
            }
        </StyledSelect>
    );
});

Казалось бы, все оптимизированно: если нужен Select, создаешь new SelectStore в заданном месте, потом компонент. Все быстро. Но можно еще немного оптимизировать, добавив прослойку, которая будет парсить SelectStore и мапить опции. Это повторяющиеся одинаковые действия, так что проблем не будет.

Так как это адаптер для Select компонента, пусть он так и называется.

AdaterSelect.tsx

import { StyledSelect } from './Select.styles.tsx';
import { observer } from 'mobx-react-lite';
import { HasIdI } from 'src/interfaces/has-id.ts';
import type { SelectStore } from 'src/stores-reusable/select.store.ts';

type SelectAdapterProps<T extends HasIdI> = {
  store?: SelectStore<T>;
  props: any;
};

export const AdapterSelect = observer(<T extends HasIdI>({ store, ...props }: SelectAdapterProps <T>) => {
  if (!store) {
    return null;
  }

  useEffect(() => {
    // получаем лист с опциями с backend
    store.getList()

    return () => {
      // очищаем компонент
       store.reset();
    };
  }, []);


    return (
        <StyledSelect
            placeholder="Выбери опцию"
            value={store.selected}
            onChange={store.setOptions}
            allowClear
        >
            {
	       store.options.map((cityOption) => {
                return <Option value={cityOption.value}>{cityOption.name}</Option>
              })
            }
        </StyledSelect>
    );
});

Теперь если нам нужен Select, нужно сделать минимум действий:

1) В хранилище создаем переиспользуемую SelectStore

city = new SelectStore({ url: 'backend/url/cities', onSelect: () => {} });

2) В компоненте, содержащем Select, импортируем AdapterSelect

import { AdapterSelect } from 'src/store-adapter-components/AdapterSelect';

3) Из хранилища забираем re‑usable SelectStore и бросаем ее в AdapterSelect

import { useStores } from 'src/stores/root.store';
import { observer } from 'mobx-react-lite';
import { AdapterSelect } from 'src/store-adapter-components/AdapterSelect';

export const CardComponent = observer(() => {
    const {
        cart: {
            order: { city: citySelectStore },
        },
    } = useStores();
    
    return (
        <div>
            ...
            <AdapterSelect store={citySelectStore} />
            ...
        </div>
    );
});

Адаптер сам сделаем парсинг re‑usable store, получит данные и выведет компонент

А как вы ускоряете разработку?