Много раз, когда шла речь о переводе 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)
gnaeus
25.04.2018 10:46А что насчет hoist-non-react-statics, как советуют в официальной документации к React?
Luanre Автор
25.04.2018 12:52+1Действительно, в рамках этой статьи не было рассмотрено копирование статических свойств в HoC, можем рассказать об этом в одной из следующих публикаций.
biziwalker
с использованием Flow описывать HOC'и можно легко с помощью такого сниппета:
к сожалению авторство данного сниппета потерял
dagen
Зачем так много букв? Посмотрите HOC<> в flow-тайпингах recompose. Если не хочется тащить все тайпинги, то эти три declare прекрасно будут работать отдельно (как мы и сделали).
biziwalker
Ответ лежит в учете defaultProps в этих буквах