company_banner

React HoC в TypeScript. Типизация без боли



    Много раз, когда шла речь о переводе 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. Подписывайтесь на наш блог и следите за обновлениями!

    Сбербанк

    178,00

    Компания

    Поделиться публикацией
    Комментарии 6
      0
      с использованием 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>);
      


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

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

          +1

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

          0
          Особая боль возникает когда от использования «старых» вариантов TS, когда заместо простого `connect` пишут `connect<StateProps, ComponentMappedProps, PublicComponentProps>`, при том что часть PublicComponentProps могут быть перекрыты ComponentMappedProps.

          «Такой» TypeScript сеет панику и ошибки в в рядах падаванов.

          Так и тут —
          withOnChangeString<InputProps>(SimpleInput)
          — «без проблем» может узнать тип события из переданного компонента.
          Да, всегда можно сказать — это чтобы перепроверить входящие значения, но имхо, лучше доверится типам.

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

          Самое читаемое