Много раз, когда шла речь о переводе React-проектов на TypeScript, я часто слышал, что самую сильную боль вызывает создание HoC’ов (Higher-Order Components — компоненты-обертки). Сегодня я покажу приём, как делать это безболезненно и довольно легко. Данный приём будет полезен не только для проектов TS, но также и для проектов ES6+.

В качестве примера, возьмем HoC, который оборачивает стандартный HTMLInput, и в первый аргумент onChange вместо объекта Event передает реальное значение текстового поля. Рассмотрим 2 варианта реализации данного адаптера: в качестве функции, принимающей компонент, и в качестве обертки.

Многие новички решают эту задачу в лоб — с помощью React.cloneElement создают клон элемента, переданного в качестве ребенка, с новыми Props. Но это приводит к сложностям в поддержке этого кода. Давайте посмотрим на этот пример, чтобы больше так никогда не делать. Начнем с ES6-кода:

// Здесь мы задаем свой обработчик событий
const onChangeHandler = event => onChange && onChange(event.target.value);

export const OnChange = ({ onChange, children }) => {
   // Проверка на то, что нам передали
   // только один компонент в виде children
   const Child = React.Children.only(children);

   // Клонируем элемент и передаем в него новые props
   return React.cloneElement(Child, {onChange: onChangeHandler});
}

Если пренебречь проверкой на единственность ребенка и передачу свойства onChange, то этот пример можно записать еще короче:

// Здесь мы задаем свой обработчик событий
const onChangeHandler = event => onChange(event.target.value);

export const OnChange = ({ onChange, children }) =>
   React.cloneElement(children, {...children.props, onChange: onChangeHandler});

Обратите внимание, что callback для передачи во внутренний компонент мы задаем вне функции-обертки, это позволит не пересоздавать функцию при каждом render-цикле компонента. Но мы говорим про TypeScript, поэтому добавим немного типов и получим следующий компонент:

import * as React from 'react';

export interface Props {
   onChange: (value: string) => void;
   children: JSX.Element;
}

export const OnChange = ({ onChange, children }: Props) => {
   const onChangeHandler = (event: React.ChangeEvent<HTMLInputElement>) => (
       onChange(event.target.value)
   )

   const Child = React.Children.only(children);

   return React.cloneElement(Child, {...children.props, onChange: onChangeHandler});
}

Мы добавили описание Props у компонента и типизировали onChange: в нем мы указали, что ожидаем на вход аргумент event, который по сигнатуре совпадает с объектом события, передаваемым из HTMLInput. При этом во внешних свойствах мы указали, что в onChange первым аргументом вместо объекта события передается строка. На этом плохой пример закончен, пора двигаться дальше.

HoC


Теперь разберем хороший пример написания HoC’а: функция, которая возвращает новый компонент, оборачивая исходный. Таким образом работает функция connect из пакета react-redux. Что для этого нужно? Если говорить простым языком, то нужна функция, возвращающая анонимный класс, являющийся HoC’ом для компонента. Ключевая проблема в TypeScript — это необходимость использования generic’ов для строгой типизации HoC’ов. Но об этом чуть позже, начнем также с примера на ES6+.

export const withOnChange = Child => {
   return class OnChange extends React.Component {
       onChangeHandler = event => this.props.onChange(event.target.value);

       render() {
           return <Child {...this.props} onChange={this.onChangeHandler} />;
       }
   }
}

Первым аргументом нам передается объявление класса-компонента, которое используется для создания инстанса компонента. В методе render в инстанс обернутого компонента мы передаем измененный callback onChange и все остальные свойства без изменений. Как и в первом примере, мы вынесли инициализацию функции onChangeHandler за пределы метода render и передали ссылку на инстанс функции во внутренний компонент. В любом более или менее сложном проекте на React использование HoC’ов обеспечивает лучшую переносимость кода, поскольку, общие обработчики выносятся в отдельные файлы и подключаются по мере необходимости.

Стоит отметить, что анонимный класс в этом примере можно заменить на stateless-функцию:

const onChangeHandler = onChange => event => onChange(event.target.value);

export const withOnChange =
   Child => ({ onChange, ...props }) =>
       <Child {...props} onChange={onChangeHandler(onChange)} />

Здесь мы создали функцию с аргументом компонент-класса, которая возвращает stateless-функцию, принимающую props этого компонента. В обработчик onChange передали функцию, создающую новый onChangeHandler при передаче обработчика событий из внутреннего компонента.

Теперь вернёмся к TypeScript. Выполнив подобные действия, мы не сможем воспользоваться всеми преимуществами строгой типизации, поскольку по умолчанию переданный компонент и возвращаемое значение примут тип any. При включенном strict-режиме TS выведет ошибку о неявном типе any у аргумента функции. Что ж, приступим к типизации. Первым делом объявим свойства onChange в принимаемом и отдаваемом компонентах:

// Свойства компонента после композиции
export interface OnChangeHoFProps {
   onChange?: (value: string) => void;
}

// Свойства компонента, принимаемого в композицию
export interface OnChangeNative {
   onChange?: React.ChangeEventHandler<HTMLInputElement>;
}

Теперь мы явно указали, какие Props должны быть у оборачиваемого компонента, а какие Props получаются в результате композиции. Теперь объявим сам компонент:

export function withOnChangeString<T extends OnChangeNative>(Child: React.ComponentType<T>) {
 . . .
}

Здесь мы указали, что в качестве аргумента принимается компонент, у которого в свойствах задано свойство onChange определенной сигнатуры, т.е. имеющий нативный onChange. Чтобы HoC работал, из него необходимо вернуть React-компонент, который уже имеет те же внешние свойства, что и у самого компонента, но с измененным onChange. Это делается выражением OnChangeHoCProps & T:

export function withOnChangeString<T extends OnChangeNative>(Child: React.ComponentType<T>) {
   return class extends React.Component<OnChangeHoCProps & T, {}> {
      . . .
   }
}

Теперь у нас есть типизированный HoC, который принимает callback onChange, ожидающий получить string в виде параметра, возвращает обернутый компонент и задает onChange во внутренний компонент, отдающий Event в качестве аргумента.

При отладке кода в React DevTools мы можем не увидеть названия компонентов. За отображение названий компонентов отвечает статическое свойство displayName:

static displayName = `withOnChangeString(${Child.displayName || Child.name})`;

Мы пытаемся достать аналогичное свойство из внутреннего компонента и оборачиваем его названием нашего HoC’а в виде строки. Если такого свойства нет, то можно воспользоваться спецификацией ES2015, в которую добавили свойство name у всех функций, указывающее на название самой функции. Однако TypeScript при компиляции в ES5 выведет ошибку о том, что функция не имеет такого свойства. Для решения этой проблемы необходимо добавить следующую строчку в tsconfig.json:

"lib": ["dom", "es2015.core", "es5"],
 

Этой строкой мы сказали компилятору, что можем использовать в коде базовый набор спецификации ES2015, ES5 и API для работы с DOM. Полный код нашего HoC’а:

export function withOnChangeString<T extends OnChangeNative>(Child: React.ComponentType<T>) {
   return class extends React.Component<OnChangeHoFProps & T, {}> {
       static displayName = `withOnChangeString(${Child.displayName || Child.name})`;

       onChangeHandler = (event: React.ChangeEvent<HTMLInputElement>) =>
           this.props.onChange(event.target.value);

       render() {
           return <Child {...this.props} onChange={this.onChangeHandler} />;
       }
   }
}
 

Теперь наш HoC готов к бою, используем следующий тест, чтобы проверить его работу:

// Берем все Props из стандартного HTMLInputElement
type InputProps = React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>;

// Объявляем простейший компонент, возвращающий HTMLInputElement
const SimpleInput: React.StatelessComponent<InputProps> = ({...props}: InputProps) => <input className="input" {...props} />;

// Оборачиваем его нашим HoC'ом
const SimplerInput = withOnChangeString<InputProps>(SimpleInput);

describe('HoC', () => {
   it('simulates input events', () => {
       const onChange = jasmine.createSpy('onChange');
       const wrapper = mount(<SimplerInput onChange={onChange} />);
       wrapper.find(SimplerInput).simulate('change', { target: {value: 'hi'} });
       expect(onChange).toHaveBeenCalledWith('hi');
   });
});
 

В заключение


Сегодня мы рассмотрели основные приемы написания HoC’ов на React. Однако в реальной жизни бывает так, что используется не один, не два, а целая цепочка HoC’ов. Чтобы не превращать код в лапшу, существует функция compose, но о ней мы поговорим в следующий раз.

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

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


  1. biziwalker
    23.04.2018 19:03

    с использованием Flow описывать HOC'и можно легко с помощью такого сниппета:

    type ComponentWithDefaultProps<DefaultProps, Props> = React.ComponentType<Props> & {
      defaultProps: DefaultProps,
    };
    
    export type HOC<ProvidedProps, RequiredProps> = (<Props, DefaultProps>(
      component: ComponentWithDefaultProps<DefaultProps, ProvidedProps & Props>,
    ) => React.ComponentType<RequiredProps &
        // Props, with diffed-out default props. Make sure to merge with ProvidedProps to work with
        // $Diff constraints in nested HoCs.
        $Diff<ProvidedProps & Props, DefaultProps & ProvidedProps> &
        // Force props to be in the shape of all potential props (effectively allows properly-typed
        // overrides of DefaultProps)
        $Shape<RequiredProps & DefaultProps & Props>,>) &
      (<Props>(
        component: React.StatelessFunctionalComponent<ProvidedProps & Props>,
      ) => React.ComponentType<RequiredProps & Props>) &
      (<Props>(
        component: React.ComponentType<ProvidedProps & Props>,
      ) => React.ComponentType<RequiredProps & Props>);
    


    к сожалению авторство данного сниппета потерял


    1. dagen
      24.04.2018 01:31

      Зачем так много букв? Посмотрите HOC<> в flow-тайпингах recompose. Если не хочется тащить все тайпинги, то эти три declare прекрасно будут работать отдельно (как мы и сделали).


      1. biziwalker
        24.04.2018 10:02

        Ответ лежит в учете defaultProps в этих буквах


  1. gnaeus
    25.04.2018 10:46

    А что насчет hoist-non-react-statics, как советуют в официальной документации к React?


    1. Luanre Автор
      25.04.2018 12:52
      +1

      Действительно, в рамках этой статьи не было рассмотрено копирование статических свойств в HoC, можем рассказать об этом в одной из следующих публикаций.