Правда ли, что хуки React сделали компоненты более высокого порядка устаревшими? И единственный вариант их использования — быть пережитком прошлого в некоторых экзистенциальных устаревших уголках наших приложений? И вообще, что такое компонент более высокого порядка? Зачем они нам понадобились в первую очередь?
Отвечая на эти вопросы и доказывая, что компоненты более высокого порядка по-прежнему полезны даже в современных приложениях для определенных типов задач.
Но давайте начнем с самого начала.
Согласно документации React , это продвинутая техника повторного использования логики компонентов, которая используется для сквозных задач.
Это просто функция, которая принимает компонент в качестве одного из своих аргументов, возится с ним, а затем возвращает его измененную версию. Самый простой вариант, который ничего не делает, таков:
1 2 3 4 5 6 7 8 9 10 |
// accept a Component as an argument const withSomeLogic = (Component) => { // do something // return a component that renders the component from the argument return (props) => <Component {...props} />; }; |
Ключевым моментом здесь является возвращаемая часть функции — это просто компонент, как и любой другой компонент. И аналогично шаблону render props , нам нужно передать props возвращаемому компоненту, иначе они будут проглочены.
И затем, когда пришло время его использовать, это будет выглядеть так:
1 2 3 4 5 |
const Button = ({ onClick }) => <button onClick={func}>Button</button>; const ButtonWithSomeLogic = withSomeLogic(Button); |
Вы передаете свой Button
компонент функции, и она возвращает новый Button
, который включает в себя любую логику, определенную в компоненте более высокого порядка. И тогда эту кнопку можно использовать как любую другую кнопку:
1 2 3 4 5 6 7 8 9 10 11 |
const SomePage = () => { return ( <> <Button /> <ButtonWithSomeLogic /> </> ); }; |
Если мы хотим создать ментальную карту того, что происходит, она может выглядеть примерно так:
Поэкспериментируйте с этими примерами в codeandbox.
До появления хуков компоненты более высокого порядка широко использовались для доступа к контексту и любым подпискам на внешние данные. Функции Redux connect или react-router withRouter
являются компонентами более высокого порядка: они принимают компонент, внедряют в него какие-то пропсы и возвращают обратно.
1 2 3 4 5 6 7 8 9 10 |
// location is injected by the withRouter higher-order component // would you guessed that by the look at this component alone? const SomeComponent = ({ location }) => { return <>{location}</>; }; const ComponentWithRouter = withRouter(SomeComponent); |
Как видите, компоненты более высокого порядка довольно сложны для написания и понимания. Поэтому, когда появились хуки , неудивительно, что все переключились на них.
Теперь вместо того, чтобы создавать сложные мысленные карты того, какой реквизит куда идет, и пытаться выяснить, как location
он оказался в реквизите, мы можем просто написать:
1 2 3 4 5 6 7 8 9 |
const SomeComponent = () => { // we see immediately where location is coming from const { location } = useRouter(); return <>{location}</>; }; |
Все происходящее в компоненте читается сверху вниз и источник всех данных очевиден, что значительно упрощает отладку и разработку.
И хотя хуки, вероятно, заменили 90% проблем с общей логикой и 100% вариантов использования для доступа к контексту, все еще есть по крайней мере три типа функциональности, где могут быть полезны компоненты более высокого порядка.
Давайте посмотрим на них.
Представьте, что вам нужно отправить какой-то расширенный журнал для некоторых обратных вызовов. Например, когда вы нажимаете кнопку, вы хотите отправить некоторые события регистрации с некоторыми данными. Как бы вы сделали это с хуками? У вас, вероятно, будет Button
компонент с onClick
обратным вызовом:
1 2 3 4 5 6 7 8 9 10 11 |
type ButtonProps = { onClick: () => void; children: ReactNode; } const Button = ({ onClick }: { onClick }: ButtonProps) => { return <button onClick={onClick}>{children}</button> } |
А затем на стороне потребителя вы подключитесь к этому обратному вызову и отправите туда событие регистрации:
1 2 3 4 5 6 7 8 9 10 11 12 |
const SomePage = () => { const log = useLoggingSystem(); const onClick = () => { log('Button was clicked'); }; return <Button onClick={() => onClick}>Click here</Button>; }; |
И это нормально, если вы хотите запустить одно или два события. Но что, если вы хотите, чтобы ваши события регистрации последовательно запускались во всем приложении при каждом нажатии кнопки? Вероятно, мы можем запечь его в сам Button
компонент.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
const Button = ({ onClick }: { onClick }: ButtonProps) => { const log = useLoggingSystem(); const onButtonClick = () => { log('Button was clicked') onClick(); } return <button onClick={() => onClick()}>{children}</button> } |
Но тогда что? Для правильных журналов вам также нужно будет отправить какие-то данные. Мы, конечно, можем расширить Button
компонент некоторыми loggingData
свойствами и передать его:
1 2 3 4 5 6 7 8 9 10 |
const Button = ({ onClick, loggingData }: { onClick, loggingData }: ButtonProps) => { const onButtonClick = () => { log('Button was clicked', loggingData) onClick(); } return <button onClick={() => onButtonClick()}>{children}</button> } |
Но что, если вы хотите запустить те же самые события, когда щелчок произошел на других компонентах? Button
обычно это не единственное, на что люди могут нажимать в наших приложениях. Что делать, если я хочу добавить такое же ведение журнала к ListItem
компоненту? Копипастить туда точно такую же логику?
1 2 3 4 5 6 7 8 9 10 |
const ListItem = ({ onClick, loggingData }: { onClick, loggingData }: ListItemProps) => { const onListItemClick = () => { log('List item was clicked', loggingData) onClick(); } return <Item onClick={() => onListItemClick()}>{children}</Item> } |
Слишком много копи-пасты и склонность к ошибкам, и кто-то забывает что-то изменить.
То, что , по сути, это инкапсулировать логику «что-то вызвало onClick
обратный вызов — отправить несколько событий регистрации» где-то, а затем просто повторно использовать его в любом компоненте, который хотим, без какого-либо изменения кода этих компонентов.
И это первый вариант использования, когда хуки бесполезны, но могут пригодиться компоненты более высокого порядка.
Вместо того, чтобы везде копировать логику «случился щелчок → данные журнала», можно просто создать withLoggingOnClick
функцию, которая:
Это будет выглядеть примерно так:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
type Base = { onClick: () => void }; // just a function that accepts Component as an argument export const withLoggingOnClick = <TProps extends Base>(Component: ComponentType<TProps>) => { return (props: TProps) => { const onClick = () => { console.log('Log on click something'); // don't forget to call onClick that is coming from props! // we're overriding it below props.onClick(); }; // return original component with all the props // and overriding onClick with our own callback return <Component {...props} onClick={onClick} />; }; }; |
И теперь можно просто добавить его к любому компоненту, который захотим. Можно Button
запечь журнал с записью:
1 2 3 4 |
export const ButtonWithLoggingOnClick = withLoggingOnClick(SimpleButton); |
Или используйте его в элементе списка:
1 2 3 4 |
export const ListItemWithLoggingOnClick = withLoggingOnClick(ListItem); |
Или любой другой компонент с onClick
обратным вызовом, который хотите отслеживать. Без единой строчки кода, измененного ни в одном, Button
ни в других ListItem
компонентах!
Теперь осталось добавить некоторые данные извне в функцию регистрации. А учитывая, что компонент более высокого порядка — это не что иное, как просто функция, мы можем сделать это легко. Просто нужно добавить некоторые другие аргументы в функцию, вот и все:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
type Base = { onClick: () => void }; export const withLoggingOnClickWithParams = <TProps extends Base>( Component: ComponentType<TProps>, // adding some params as a second argument to the function params: { text: string }, ) => { return (props: TProps) => { const onClick = () => { // accessing params that we passed as an argument here // everything else stays the same console.log('Log on click: ', params.text); props.onClick(); }; return <Component {...props} onClick={onClick} />; }; }; |
И теперь, когда мы оборачиваем нашу кнопку компонентом более высокого порядка, мы можем передать текст, который мы хотим регистрировать:
1 2 3 4 |
const ButtonWithLoggingOnClickWithParams = withLoggingOnClickWithParams(SimpleButton, { text: 'button component' }); |
Со стороны потребителя мы бы просто использовали эту кнопку как обычный компонент кнопки, не беспокоясь о тексте журнала:
1 2 3 4 5 6 |
const Page = () => { return <ButtonWithLoggingOnClickWithParams onClick={onClickCallback}>Click me</ButtonWithLoggingOnClickWithParams>; }; |
Но что, если мы действительно хотим побеспокоиться об этом тексте? Что, если мы хотим отправить разные тексты в разных контекстах использования кнопки? Мы не хотели бы создавать миллион кнопок в оболочке для каждого варианта использования.
Также очень легко решить: вместо того, чтобы передавать этот текст в качестве аргумента функции, мы можем внедрить его в качестве реквизита в результирующую кнопку. Код будет выглядеть так:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
type Base = { onClick: () => void }; export const withLoggingOnClickWithProps = <TProps extends Base>(Component: ComponentType<TProps>) => { // our returned component will now have additional logText prop return (props: TProps & { logText: string }) => { const onClick = () => { // accessing it here, as any other props console.log('Log on click: ', props.logText); props.onClick(); }; return <Component {...props} onClick={onClick} />; }; }; |
А затем используйте его следующим образом:
1 2 3 4 5 6 7 8 9 10 |
const Page = () => { return ( <ButtonWithLoggingOnClickWithProps onClick={onClickCallback} logText="this is Page button"> Click me </ButtonWithLoggingOnClickWithProps> ); }; |
Смотрите коды и коробку со всеми примерами .
Здесь мы не ограничиваемся кликами и обратными вызовами. Помните, что это всего лишь компоненты, мы можем делать все, что хотим и в чем нуждаемся 🙂 Мы можем использовать все, что может предложить React. Например, мы можем отправлять эти события регистрации, когда компонент смонтирован:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
export const withLoggingOnMount = <TProps extends unknown>(Component: ComponentType<TProps>) => { return (props: TProps) => { // no more overriding onClick, just adding normal useEffect useEffect(() => { console.log('log on mount'); }, []); // just passing props intact return <Component {...props} />; }; }; |
И точно такая же история, как с onClick
добавлением данных через аргументы или пропсы. Не будем копипастить сюда, смотрите в кодах и боксе .
Мы даже можем сойти с ума и объединить все эти компоненты более высокого порядка:
1 2 3 4 5 6 7 8 9 10 11 |
export const SuperButton = withLoggingOnClick( withLoggingOnClickWithParams( withLoggingOnClickWithProps( withLoggingOnMount(withLoggingOnMountWithParams(withLoggingOnMountWithProps(SimpleButton), { text: 'button component' })), ), { text: 'button component' }, ), ); |
Конечно, мы не должны этого делать 😅 Если что-то возможно, это не всегда означает, что это хорошая идея. Представьте, что вы пытаетесь отследить, какие реквизиты откуда берутся, когда приходит время отладки. Если нам действительно нужно объединить несколько компонентов более высокого порядка в один, мы можем быть по крайней мере немного более конкретными:
1 2 3 4 5 6 |
const ButtonWithLoggingOnClick = withLoggingOnClick(SimpleButton); const ButtonWithLoggingOnClickAndMount = withLoggingOnMount(ButtonWithLoggingOnClick); // etc |
Еще одно очень полезное применение компонентов высшего порядка — перехват различных событий DOM. Представьте, например, что вы реализуете на своей странице какие-то сочетания клавиш. Когда нажимаются определенные клавиши, вы хотите делать разные вещи, например открывать диалоги, создавать задачи и т. д. Вы, вероятно, добавили бы прослушиватель событий в окно для чего-то вроде этого:
1 2 3 4 5 6 7 8 9 10 11 12 |
useEffect(() => { const keyPressListener = (event) => { // do stuff }; window.addEventListener('keypress', keyPressListener); return () => window.removeEventListener('keypress', keyPressListener); }, []); |
Кроме того, у вас есть различные части вашего приложения, такие как модальные диалоги, выпадающие меню, ящики и т. д., где вы хотите заблокировать этот глобальный прослушиватель, пока диалог открыт. Если это был только один диалог, вы можете вручную добавить onKeyPress
в сам диалог и сделать event.stopPropagation()
для этого:
1 2 3 4 5 6 7 8 |
export const Modal = ({ onClose }: ModalProps) => { const onKeyPress = (event) => event.stopPropagation(); return <div onKeyPress={onKeyPress}>...// dialog code</div>; }; |
Но та же история, что и с onClick
логированием — а если у вас несколько компонентов, где вы хотите увидеть эту логику?
Что мы можем здесь сделать, так это снова реализовать компонент более высокого порядка. На этот раз он примет компонент, поместит его в div с прикрепленным обратным вызовом onKeyPress и вернет компонент без изменений.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
export const withSupressKeyPress = <TProps extends unknown>(Component: ComponentType<TProps>) => { return (props: TProps) => { const onKeyPress = (event) => { event.stopPropagation(); }; return ( <div onKeyPress={onKeyPress}> <Component {...props} /> </div> ); }; }; |
Вот оно! Теперь мы можем просто использовать его везде:
1 2 3 4 5 6 |
const ModalWithSupressedKeyPress = withSupressKeyPress(Modal); const DropdownWithSupressedKeyPress = withSupressKeyPress(Dropdown); // etc |
Здесь важно отметить одну важную вещь: управление фокусом. Чтобы приведенный выше код действительно работал, вам нужно убедиться, что ваши компоненты диалогового типа перемещают фокус на открытую часть, когда они открыты. Но это уже совсем другой разговор об управлении фокусом, может быть, в следующий раз.
Для примера мы можем просто вручную включить автофокус в самом модальном окне:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
const Modal = () => { const ref = useRef<HTMLDivElement>(); useEffect(() => { // when modal is mounted, focus the element to which the ref is attached if (ref.current) ref.current.focus(); }, []); // adding tabIndex and ref to the div, so now it's focusable return <div tabIndex={1} ref={ref}> <!-- modal code --> </div> } |
Поиграйте с ним в кодах и ящике .
Последний и очень интересный вариант использования компонентов более высокого порядка: функциональность, подобная селекторам, для контекста React. Как мы знаем, когда значение контекста изменяется, это вызывает повторную визуализацию всех потребителей контекста, независимо от того, была ли изменена их конкретная часть состояния или нет. (И если вы не знали об этом, вот статья для вас: Как писать производительные приложения React с помощью Context ).
Давайте сначала реализуем некоторый контекст и форму, прежде чем переходить к компонентам более высокого порядка.
У нас будет контекст с id
и name
и API, чтобы изменить их:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
type Context = { id: string; name: string; setId: (val: string) => void; setName: (val: string) => void; }; const defaultValue = { id: 'FormId', name: '', setId: () => undefined, setName: () => undefined, }; const FormContext = createContext<Context>(defaultValue); export const useFormContext = () => useContext(FormContext); export const FormProvider = ({ children }: { children: ReactNode }) => { const [state, setState] = useState(defaultValue); const value = useMemo(() => { return { id: state.id, name: state.name, setId: (id: string) => setState({ ...state, id }), setName: (name: string) => setState({ ...state, name }), }; }, [state]); return <FormContext.Provider value={value}>{children}</FormContext.Provider>; }; |
А затем некоторая форма с компонентами Name
иCountries
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
const Form = () => { return ( <form css={pageCss}> <Name /> <Countries /> </form> ); }; export const Page = () => { return ( <FormProvider> <Form /> </FormProvider> ); }; |
Где в Name
компоненте у нас будет вход, который изменяет значение Context
, и Countries
просто используем id
форму для получения списка стран (не будем реализовывать фактическую выборку, не важно для примера:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
const Countries = () => { // using only id from context here const { id } = useFormContext(); console.log("Countries re-render"); return ( <div> <h3>List on countries for form: {id}</h3> <ul> <li>Australia</li> <li>USA</li> <!-- etc --> </ul> </div> ); }; |
1 2 3 4 5 6 7 8 9 |
const Name = () => { // using name and changing it here const { name, setName } = useFormContext(); return <input onChange={(event) => setName(event.target.value)} value={name} />; }; |
Теперь каждый раз, когда мы что-то вводим в поле ввода имени, мы будем обновлять значение контекста, что вызовет повторный рендеринг всех компонентов, использующих контекст, включая страны. И это нельзя решить, извлекая это значение в хук и запоминая его: хуки всегда перерисовываются ( почему пользовательские реагирующие хуки могут снизить производительность вашего приложения ).
Конечно, есть и другие способы справиться с этим, если такое поведение вызывает проблемы с производительностью, такие как запоминание частей дерева рендеринга или разделение контекста на разных поставщиков (см. статьи, описывающие эти методы: как писать производительные приложения React с контекстом и писать производительный код React: правила, шаблоны, что можно и чего нельзя делать ).
Но большим недостатком всех вышеперечисленных методов является то, что они не являются общими и должны применяться в каждом конкретном случае. Было бы неплохо, если бы у нас была какая-то функциональность, подобная выбору, которую мы могли бы использовать для id
безопасного извлечения этого значения в любом компоненте без значительного рефакторинга useMemo
во всем приложении?
Интересно, что мы можем реализовать что-то подобное с компонентами более высокого порядка. И причина этого в том, что у компонентов есть одна вещь, которую нам не дают хуки: они могут запоминать вещи и останавливать цепочку повторных рендеров, спускающихся к дочерним элементам. По сути, это даст нам то, что мы хотим:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
export const withFormIdSelector = <TProps extends unknown>( Component: ComponentType<TProps & { formId: string }> ) => { const MemoisedComponent = React.memo(Component) as ComponentType< TProps & { formId: string } >; return (props: TProps) => { const { id } = useFormContext(); return <MemoisedComponent {...props} formId={id} />; }; }; |
и тогда мы можем просто создать CountriesWithFormIdSelector
компонент:
1 2 3 4 5 6 7 8 9 10 11 12 |
// formId prop here is injected by the higher-order component below const CountriesWithFormId = ({ formId }: { formId: string }) => { console.log("Countries with selector re-render"); return ( <-- code is the same as before --> ); }; const CountriesWithFormIdSelector = withFormIdSelector(CountriesWithFormId); |
И используем его в нашей форме:
1 2 3 4 5 6 7 8 9 10 11 |
const Form = () => { return ( <form css={pageCss}> <Name /> <CountriesWithFormIdSelector /> </form> ); }; |
Проверьте это в codeandbox . Обратите особое внимание на
вывод консоли при вводе ввода — компонент CountryWithFormIdSelector не перерисовывается!
withFormIdSelector
это весело и может работать для небольших контекстно-зависимых приложений. Но было бы неплохо иметь его как что-то общее? Так что нам не нужно реализовывать собственный селектор для каждого свойства состояния.
Нет проблем, если речь идет о творческом хакерстве! Проверьте, сам селектор:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
export const withContextSelector = <TProps extends unknown, TValue extends unknown>( Component: ComponentType<TProps & Record<string, TValue>>, selectors: Record<string, (data: Context) => TValue>, ): ComponentType<Record<string, TValue>> => { // memoising component generally for every prop const MemoisedComponent = React.memo(Component) as ComponentType<Record<string, TValue>>; return (props: TProps & Record<string, TValue>) => { // extracting everything from context const data = useFormContext(); // mapping keys that are coming from "selectors" argument // to data from context const contextProps = Object.keys(selectors).reduce((acc, key) => { acc[key] = selectors[key](data); return acc; }, {}); // spreading all props to the memoised component return <MemoisedComponent {...props} {...contextProps} />; }; }; |
а затем использовать его с компонентами:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
// props are injected by the higher order component below const CountriesWithFormId = ({ formId, countryName }: { formId: string; countryName: string }) => { console.log('Countries with selector re-render'); return ( <div> <h3>List of countries for form: {formId}</h3> Selected country: {countryName} <ul> <li>Australia</li> <li>USA</li> </ul> </div> ); }; // mapping props to selector functions const CountriesWithFormIdSelector = withContextSelector(CountriesWithFormId, { formId: (data) => data.id, countryName: (data) => data.country, }); |
Мы в основном реализовали mini-Redux на контексте, даже с должным mapStateToProps
функционалом 🙂 Проверьте это в codeandbox .
Вот и все на сегодня! Надеюсь, что компоненты более высокого порядка теперь не какие-то ужасные устаревшие гоблины, а то, что вы можете найти хорошее применение даже в современных приложениях. Давайте повторим варианты использования для них:
Не спешите выбрасывать из своих закладок старые проверенные способы!
Источник статьи: http://dev.to/