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

