Открыть список
Как стать автором
Обновить
140,92
Рейтинг
Сбер
Больше чем банк

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

Блог компании СберРазработка веб-сайтовПрограммированиеReactJSTypeScript


Много раз, когда шла речь о переводе 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. Подписывайтесь на наш блог и следите за обновлениями!
Теги:разработка веб-сайтовЕФСReactNativeTypeScriptобёрткакомпонентыreact
Хабы: Блог компании Сбер Разработка веб-сайтов Программирование ReactJS TypeScript
Всего голосов 10: ↑9 и ↓1 +8
Просмотры20.5K

Комментарии 6

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

Похожие публикации

Frontend разработчик (Антифрод)
от 150 000 до 280 000 ₽СберМосква
Frontend (Fullstack) разработчик
от 150 000 до 300 000 ₽СберМоскваМожно удаленно
DB Архитектор
СберМоскваМожно удаленно
QA Engineer
СберВоронеж
Senior Java разработчик
СберМоскваМожно удаленно

Лучшие публикации за сутки

Информация

Дата основания
Местоположение
Россия
Сайт
www.sber.ru
Численность
свыше 10 000 человек
Дата регистрации

Блог на Хабре