Простая фабрика - довольно простой и распространенный паттерн проектирования, подробнее о котором, и не только, уже рассказано в статье VK team.

Здесь же, вы узнаете о фиче typescript, называемой "Discriminated Unions". Мы сделаем типизированный компонент фабрику.

Абстрактная задача

Создать компонент(далее "компонент-фабрика"), который, в зависимости от принимаемых им параметров, будет возвращать подходящий к ним компонент(далее "подкомпонент"). При этом учтем то, что:

  • Компонент не должен принимать таких параметров, при которых он ломается

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

  • Он должен выполнять только те вычисления, которые соответвствуют возращаемому подкомпоненту

  • При использовании компонента должно быть понятно какие параметры он принимает для каждого подкомпонента и что возращает в ответ

Реальная задача

Создать компонент, который, по переданным ему параметрам, рисует подходящую фигуру.

Моделирование

Смоделируем фигуры и параметры, необходимые для их отрисовки:

  • Прямоугольник - высота и ширина

  • Круг - радиус

  • Треугольник - три стороны

У каждой фигуры уникальные параметры, но в добавок они могут иметь общий параметр — имя класса (css).

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

Как это выглядит на Javascript

Создадим абстрактные подкомпоненты (фигуры), т. к. нас в текущем контексте не интересует их реализация.

const Rectangle = ({className, width, height}) => ...;

const Circle = ({className, radius}) => ...;

const Triangle = ({className, sideA, sideB, sideC}) => ...;

Переходим к компоненту фабрике.

const Figure = ({
  className,
  type,
  width,
  height,
  radius,
  sideA,
  sideB,
  sideC,
}) => {
  switch (type) {
    case "rectangle":
      return height && width ? (
        <Rectangle className={className} height={height} width={width} />
      ) : null;

    case "circle":
      return radius ? <Circle className={className} radius={radius} /> : null;

    case "triangle":
      return sideA && sideB && sideC ? (
        <Triangle
          className={className}
          sideA={sideA}
          sideB={sideB}
          sideC={sideC}
        />
      ) : null;

    default:
      return null;
  }
};
Кратко описание того, что в нем происходит.

По переданному параметру type, определяем какая это фигура. Проверяем приходят ли все необходимые для этой фигуры параметры, если да, рисуем фигуру.

Проблемы, которые возникают при использовании javascript

  1. Разработчик, используя наш компонент-фабрику, не знает какие параметры ему передать, чтобы получить нужную ему фигуру. Ему придется лазить в нашем коде, чтобы понять что к чему.

  2. Если параметры фигуры приходят из предка пятого поколения или же, к примеру, из redux store, велик шанс, что параметры станут когда-то некорректны и придется потратить уйму времени, чтобы понять, почему не отрисовалась фигура.

  3. Если передать все параметры одновременно, то фабрика отрисует фигуру, переданную в type, используя подходящие ему параметры, а остальные параметры будут проигнорированы. Это прибавит неясности в логике нашего компонента. Пример:

    Использование компонента-фабрики со всеми пропсами одновременно
    Использование компонента-фабрики со всеми пропсами одновременно
  4. Если передать некорректные параметры, узнать об этом можно только просмотрев отрисовванный результат в браузере, так как IDE нам никак не поможет.

Как же это выглядит на typescript

Для начала определим параметры, которые будут иметь все фигуры. Это имя класса, как мы уже писали в главе "Моделирование".

interface IBaseFigure {
  className?: string;
}

Создадим те же абстрактные подкомпоненты, что и в варианте с javascript, но теперь с типизацией.

interface ICircle extends IBaseFigure {
  radius: number;
}

const Circle: React.VFC<ICircle> =  ({className, radius}) => ...;
interface IRectangle extends IBaseFigure {
  width: number;
  height: number;
}

const Rectangle: React.VFC<IRectangle> = ({className, width, height}) => ...;
interface ITriangle extends IBaseFigure {
  sideA: number;
  sideB: number;
  sideC: number;
}

const Triangle: React.VFC<ITriangle> = ({className, sideA, sideB, sideC}) => ...;
);
Обьяснение того, что написано выше

Мы создали интерфейс BaseFigure, а потом расширяем его интерфейсами наших фигур с помощью "extend". Если мы определим новый параметр в BaseFigure, то он появится во всех интерфейсах расширяюших его.

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

Сами подкомпоненты мы протипизировали с помощью встроенного в React типа VFC, который означает "Функциональный компонент без children".

В целом такого "определения" будет достаточно в рамках этой статьи.

Протипизируем также нашу фабрику.

interface IFigureRectangle extends IRectangle {
  type: "rectangle";
}

interface IFigureCircle extends ICircle {
  type: "circle";
}

interface IFigureTriangle extends ITriangle {
  type: "triangle";
}

type IFigure = IFigureRectangle | IFigureCircle | IFigureTriangle;

const Figure: React.VFC<IFigure> = (props) => {
  switch (props.type) {
    case "rectangle": {
      return <Rectangle {...props} />;
    }
    case "circle": {
      return <Circle {...props} />;
    }
    case "triangle": {
      return <Triangle {...props} />;
    }

    default:
      return null;
  }
};

Сначала расширяем интерфейсы фигур, добавляя к ним свойство type с определенным значением. Если type задать такое значение, то остальные параметры компонента определятся по этому интерфейсу.

На пальцах

Мы объединяем(строка 13) такие интерфейсы и получаем единый интерфейс, где в случае, если параметр type равно:

rectangle, остальныe параметры будут из IRectangle

circle, остальные параметры будут из ICircle

triangle, остальные параметры будут из ITriangle

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

Мы можем внутри "case"-ов узнавать, какие параметры будут получены.

Просмотр принимаемых прямоугольником параметров
Просмотр принимаемых прямоугольником параметров
Просмотр принимаемых треугольником параметров
Просмотр принимаемых треугольником параметров

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

Подсказки, когда не указан type
Подсказки, когда не указан type

Но так, как type обязательный параметр, то его обязательно нужно будет прописать. И как только мы это сделаем, подсказка будет показывать только подходящие параметры и typescript начнет ругаться при использовании неподходящих.

Подсказки, когда указан type
Подсказки, когда указан type
Некорректные и корректные параметры для прямоугольника
Некорректные и корректные параметры для прямоугольника

Возможно на скриншотах не совсем наглядно, но что есть, то есть. Оставляю ссылку на codesanbox, где вы можете потыкать и в живую посмотреть результат.

Вывод

Пользуясь typescript, мы получаем такие плюсы, как:

  • Избавление от лишних проверок на наличие параметров

  • Подсказки во время разработки самого компонента-фабрики

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

  • Предупреждение от IDE, если передавать в него некорректные параметры

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

Комментарии (4)


  1. Tenzo_as
    12.02.2022 22:39

    Очень ценная статья, спасибо что поделились! Но у меня остались кое какие вопросы. Для чего в компоненте Figure создавать 3 новых интерфейса для фигур когда можно было бы props под названием type прописать в интерфейсах самих фигур? Таким образом код работал бы точно так же и был бы короче.


    1. GreatFix Автор
      12.02.2022 22:43

      Спасибо за комментарий. Параметр type в данном случае является параметром нашего компонента-фабрики и к подкомпонентам не имеет отношения.


  1. andykras
    12.02.2022 22:43

    Спасибо за интересную статью.

    Сам я пишу на JavaScript + Vue и никогда не писал ни на TS ни на React. Вы заставили меня с ними поковыряться, потому что привели реальный интересный пример.

    Честно говоря я бы предпочел сделать через merge типов (&), для меня это выглядит более лаконичным. Уверен что все подсказки и бенефиты из ваших примеров работают и здесь.

    type CircleProps = { radius: number }
    const Circle = ({ radius }: CircleProps) => ...
    
    type RectProps = { width: number; height: number }
    const Rect = ({ width, height }: RectProps) => ...
    
    type FigureCircle = { type: 'circle' } & CircleProps
    type FigureRect = { type: 'rect' } & RectProps
    
    const Figure = (props: FigureRect | FigureCircle) => {
      switch (props.type) {
        case 'circle':
          return <Circle {...props} />
    
        case 'rect':
          return <Rect {...props} />
    
        default:
          return null
      }
    }

    Но вообще мне кажется вы недооцениваете JavaScript. Все тоже самое, со всеми подсказки и плюшками возможно сделать и на JS.

    function getFigure() {
      return {
        circle({ radius = 0 }) {
          return <div>I'm a circle {radius}</div>
        },
    
        rect({ width = 0, height = 0 }) {
          return <div>I'm a rect {width}, {height}</div>
        }
      }
    }
    
    export default function App() {
      const type = 'rect'
    
      return (
        <>
          {getFigure()[type]({ width: 123, height: 321 })}
          {getFigure().circle({ radius: 42 })}
        </>
      )
    }

    И сюрприз-сюрприз, получать хинты можно прямо в IDE, включая тип number для аргументов. Ниже результат.

    А при правильно настроенном ESLint и юнит тестах, разницы с TS вы не заметите, кроме более простого и понятного кода.


    1. GreatFix Автор
      12.02.2022 22:56

      Это очень хорошо, что вы решили попробовать это на практике!

      Насчет использования &, то здесь скорее нет технической разницы и каждый может выбрать то, что ему по душе. Я, например, предпочитаю использовать type для сущностей (User, Card, Address), функций ((name: string)=>void) или для объединения нескольких interface. А interface использую для всего остального.

      Что касается реализации на js, то в статье описаны проблемы, которые возникают на нем. В вашем примере вам даже пришлось отказаться от jsx.

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