Добрый день! Я начинающий фулстек-разработчик, и это моя первая статья.
Сегодня я хочу рассказать, как сделать функциональные компоненты в реакте чуть более функциональными, а именно как сделать каррирование функционального компонента.
Предупреждение: в статье использованы как функциональные, так и классовые компоненты
Каррирование
Немного определений.
Вот здесь определение каррирования с википедии.
Мы будем называть каррированием преобразование функции f от N переменных в функцию g от M <= N переменных, которая возвращает функцию w от N - M переменных.
Вот пример:
function sum(first, second, third) {
return first + second + third;
}
// пусть существует некоторая магическая функция curry,
// которая принимает на вход функцию и возвращает её каррированную версию
const curriedSum = curry(sum);
// теперь можем делать так:
const sum1 = curriedSum(10)(5)(20);
const sum2 = curriedSum(10, 5)(20);
const sum3 = curriedSum(10)(5, 20);
const sum4 = curriedSum(10, 5, 20);
// и мы ожидаем, что sum1 === sum2 === sum3 === sum4 === 25
А что с реактом?
Пусть имеется следующий функциональный компонент:
function ExampleComponent({title, text}) {
return (
<div>
<p>{title}</p>
<p>{text}</p>
</div>
)
}
И мы хотим к нему применить каррирование. Назовём хук useCurry.
Определим семантику хука:
Хук должен принимать компонент и возвращать каррированный компонент
Хук должен помимо компонента принимать часть пропсов этого компонента и прокидывать их в исходный компонент
При изменении любых пропсов каррированный компонент перерендеривается, как и любой обычный компонент
Каррированный компонент не монтируется каждый раз заново при рендере родительского компонента или изменении пропсов
Запомните свойства 3 и 4, дальше я буду часто к ним обращаться.
Вот пример использования такого хука:
function ParentComponent() {
const CurriedEC = useCurry(ExampleComponent,
{text: 'Это каррированный компонент'}});
// то же самое, что и <ExampleComponent text title />
return <CurriedEC title="Заголовок каррированного компонента" />
}
Реализация useCurry
Наивная реализация:
Достаточно легко придумать реализацию, удовлетворяющую первым трём пунктам
function useCurry(ComponentToCurry, props) {
const CurriedComponent = function(restProps) {
return <ComponentToCurry {...props} {...restProps} />
};
return CurriedComponent;
}
Действительно, useCurry будет вызываться и возвращать новый компонент всякий раз при рендере родительского компонента, поэтому новый компонент будет заново монтироваться и, как следствие, рендериться. Это про свойство 3, а первые два и так очевидны.
Для реализации последнего свойства нам надо 'запомнить' ссылку на CurriedComponent, чтобы она не пересоздавалась каждый раз внутри useCurry, поскольку реакт заново монтирует компонент при изменении ссылки на него. Если я не ошибаюсь, именно так работает условный рендеринг, и именно поэтому в списках с одинаковыми компонентами нужны ключи.
Но как запомнить функцию? useCallback должен с этим справиться, да?
useCallback:
function useCurry(ComponentToCurry, props) {
const CurriedComponent = useCallback((restProps) => {
return <ComponentToCurry {... props} {... restProps} />
}, [props, ComponentToCurry]);
return CurriedComponent;
}
У useCallback есть массив зависимостей, куда очевидным образом поместились props.
Это значит, что при изменении props будет заново пересоздаваться и монтироваться CurriedComponent, а мы хотим его просто перерендерить!
Лучше чем было раньше, но двигаемся дальше.
На помощь приходит useRef. Действительно, если мы не хотим ничего помещать в зависимости useCallback, то только рефы смогут нам в этом помочь. Надо просто положить props в useRef, и менять значение рефа при изменении props, да?
useRef:
function useCurry(ComponentToCurry, props) {
const propsRef = useRef(props);
useEffect(() => {
propsRef.current = props;
}, [props]);
const CurriedComponent = useCallback(
(restProps) => {
return <ComponentToCurry {...propsRef.current}
{...restProps} />;
}, [ComponentToCurry]
);
return CurriedComponent;
}
Вот теперь зависимостей кроме ComponentToCurry нет, и мы можем гарантировать, что CurriedComponent монтируется ровно один раз (на совести пользователя хука остаётся ComponentToCurry, ссылка на который, конечно, тоже не должна меняться).
Но это ещё не конец, ведь в погоне за четвёртым свойством мы потеряли третье! useEffect, конечно, обновит реф, но не заставит перерендериться CurriedComponent, ведь propsRef.current никак не взаимодействует с каррированным компонентом и не входит в число его пропсов
Значит надо силой заставить CurriedComponent рендериться при изменении propsRef.current. А что за сила, спросите вы. Насколько мне известно, только одна конструкция в реакте на такое способна, и, увы, придётся писать классовый компонент.
Я говорю о forceUpdate, эту тёмную магию ещё не перенесли в функциональные компоненты.
forceUpdate:
function useCurry(ComponentToCurry, props) {
const propsRef = useRef(props);
const curriedComponentRef = useRef(undefined);
useEffect(() => {
propsRef.current = props;
curriedComponentRef.current?.forceUpdate();
}, [props]);
const CurriedComponent = useMemo(() =>
class CurriedComponent extends React.Component {
constructor(restProps) {
super(restProps);
curriedComponentRef.current = this;
}
render() {
return <ComponentToCurry {...this.props}
{...curriedPropsRef.current} />;
}
},
[ComponentToCurry]
);
return CurriedComponent;
}
При изменении propsRef будет вызываться forceUpdate, и CurriedComponent перерендерится.
useCallback исчез, вместо него useMemo
Вот теперь готово!
Практическое применение
Рассмотрим модальные окна.
Часто можно увидеть следующий код: const [(d/D)ialog, setOpen, (open)] = useDialog(...)
,
где (d/D)ialog либо элемент, либо компонент.
В первом случае нам надо внутрь useDialog прокидывать все пропсы диалога (например, текст) помимо open, а внутри useDialog прокидывать их в компонент Dialog, что вряд ли кому-то покажется элегантным решением.
Выглядеть это будет так:
function useDialog(dialogProps) {
const [open, setOpen] = useState(false);
const dialog = useMemo(() => <Dialog open={open} {...dialogProps} />,
[dialogProps, open]);
return [dialog, setOpen];
}
function DialogWrapper() {
const [dialog, setOpen] = useDialog({text: 'Это диалог!'});
useEffect(() => {
setTimeout(() => setOpen(true), 3000);
}, []);
return dialog;
}
Во втором случае нам надо прокидывать open внутрь Dialog, хотя это мог бы сделать useDialog:
function useDialog() {
const [open, setOpen] = useState(false);
return [Dialog, setOpen, open];
}
function DialogWrapper() {
const [Dialog, setOpen, open] = useDialog();
useEffect(() => {
setTimeout(() => setOpen(true), 3000);
}, []);
return <Dialog text='Это диалог' open={open} />;
}
С useCurry это выглядело бы так:
function useDialog() {
const [open, setOpen] = useState(false);
const CurriedDialog = useCurry(Dialog, {open});
return [CurriedDialog, setOpen];
}
function DialogWrapper() {
const [Dialog, setOpen] = useDialog();
useEffect(() => {
setTimeout(() => setOpen(true), 3000);
}, []);
return <Dialog text='Это диалог'/>;
}
На мой взгляд, последний вариант превосходит первые два по простоте и читаемости.
Более того, в отличие от первого варианта мы получили возможность открывать несколько диалогов одновременно, достаточно будет написать<Dialog text='Первый диалог' />
<Dialog text='Второй диалог' />
Примечание: выше предполагается, что useDialog устроен значительно сложнее, а setOpen не просто меняет open, но также производит сложные действия. В примере же можно вообще обойтись без useDialog, но это сделано для наглядности
На этом всё. Если статья найдёт положительный отклик, то я продолжу рассказывать о придуманных мной странных хуках.
Комментарии (26)
kreddos
05.12.2021 13:40+2render() { return <ComponentToCurry {...this.props} {...curriedPropsRef.current} />; }
Тут я бы поменял местами this.props и curriedProps, на мой вгляд у пропсов компонента должен быть приоритет выше.
А так, поддерживаю коммент выше, не очень понимаю какую поблему можно этим решить
kahi4
05.12.2021 13:41useMemo не подойдёт в данном случае, потому что в будущем он может сбросить состояние в любой момент и пересоздать объект, см документацию
Но, как заметили выше, не очень понятно зачем это нужно. Какая-то хитрая альтернатива HOC? Как по мне, так это только затрудняет чтение кода, потому что нужно будет просматривать больше кода чтобы понять откуда появился пропс, когда можно было просто сделать что-то типа:
вместо const ComponentWithRouterProps = useCurry(Component, routerProps); делать const routerProps = useRouter(); return <Component {...routerProps} />
Или, раз уж компоненту нужен роутер, просто делать useRouter внутри, при необходимости делать разделение через контексты. Это если про переиспользование кода.
Если ответ: для упрощения тестирования, то ИМХО HOC контейнеры гораздо проще в написании, проще в тестировании, и проще в переиспользовании.
EdgarAbgaryan
05.12.2021 15:13+4спасибо, пиши ещё
только классовый компонент не нужен был. для форс-апдейта можно и useState юзать
const [, forceUpdate] = useState();
forceUpdate({});
а конкретней вот так: https://codesandbox.io/s/mutable-wave-sfxhz?file=/src/App.js
практической пользы правда не смог придумать для хука. всегда заместо него можно просто заспредить пропсы <ComponentToCurry {...props1} {...props2} />
bevertax Автор
05.12.2021 16:14Спасибо за useState! Сколько ни пытался избавиться от классового компонента, так и не смог придумать альтернативы
Alexandroppolus
05.12.2021 16:28const CurriedEC = useCurry(ExampleComponent, {text: '...'}});
При таком использовании props в useCurry всегда новый, даже если "начинка" не поменялась. Бесполезно ставить его в зависимости хуков с зависимостями. Надо снаружи обертывать в useMemo, либо внутри useCurry поюзать какой-нибудь useShallowEqual, чтобы это дело исправить.
Присоединяюсь к непонимающим великий смысл этого хука.
Да и с типами TS тут придется повоевать. Скажем, для ExampleComponent: FC<{title: string, text: string}> из примера результат useCurry(ExampleComponent, {text: '...'}}) должен получить тип FC<{title: string, text?: string}> , чтобы text стал необязательным, но всё-таки возможным. Что-то навскидку непонятно, как это сделать на генериках (хотя я не шибко ведаю в типизации).
bevertax Автор
05.12.2021 18:05Да, с типами немного сложно.
Вот небольшой пример: https://codesandbox.io/s/eloquent-marco-hqfg1
indestructable
06.12.2021 00:30Curried<TCurriedProps, TOrigProps> = TCurriedProps & Optional<Omit<TOrigProps, keyof TCurriedProps>> как-то так
faiwer
06.12.2021 01:34+1Какой-то лютый оверинжиниринг получился, не понятно зачем это вообще всё нужно. Особенно первые версии, которые убивали уже отрендеренные компоненты на каждом рендере родителя.
useEffect(() => { propsRef.current = props; curriedComponentRef.current?.forceUpdate(); }, [props]);
Вот это
, [props]
бесполезно. У вас всегдаprops
новые ({open}
). Так что вы при любом рендере родительского компонента вызываете и render дочернего. Просто очень странным способом :)faiwer
06.12.2021 01:38function useDialog(dialogProps) { const [open, setOpen] = useState(false); const dialog = useMemo(() => <Dialog open={open} {...dialogProps} />, [dialogProps, open]); return [dialog, setOpen]; }
Тут та же история. Тут вы убиваете весь диалог на каждом рендере (потому что
dialogProps
всегда новые).P.S. не понял почему вы с диалогом работаете настолько странно. Это прямо ну очень нестандартно и неудобно.
bevertax Автор
06.12.2021 02:11Выше уже написали про useMemo, который, конечно, неплохо было бы использовать. Я не хотел написать идеальный код, скорее показать общую идею, которую при желании можно улучшать.
Кажется, вы не правы насчёт того, что я "убиваю" диалог. Он будет каждый раз рендериться, но не монтироваться. Но ведь у реакта по умолчанию именно такое поведение и есть: родитель рендерится -> рендерится потомок.
А как обычно работают с диалогом? Просто я встречал именно те две версии, о которых написал. Обычно есть компонент, который принимает в себя разные пропсы, включая open, и есть отдельно хук, который этим компонентом управляет и наружу выбрасывает setOpen
Alexandroppolus
06.12.2021 10:21Кажется, вы не правы насчёт того, что я "убиваю" диалог. Он будет каждый раз рендериться, но не монтироваться.
При изменении стейта open имеем каждый раз новый компонент CurriedDialog, то есть перемонтирование.
А как обычно работают с диалогом?
Обычно просто рендерится исходный диалог, с передачей всех пропсов. А так, возможны варианты. Мне показался удобным для диалогов (именно диалогов, где надо что-то спросить у юзера) такой способ:
const request = useDialog(SomeDialogComponent); ... const userResponse = await request(params);
То есть не рисую модалку самостоятельно, а передаю в специальный хук, который возвращает функцию вызова, с промисом для ответа пользователя. В некоторых случаях, например, когда попапчик надо вызвать при некотором условии, сильно помогает.
faiwer
06.12.2021 11:20+1про useMemo, который, конечно, неплохо было бы использовать
Ага. Очень удобно:
useCarry(Component, useMemo(someProps, [someProps.p1, someProps.P2, ...]);
Он будет каждый раз рендериться, но не монтироваться
А вы проверьте. Это уже вопрос не render-а, а реконсиляции. Там React находит вместо старого компонента новый. Ввиду чего убивает его и создаёт новый. Отдельно отмечу, что он помимо этого убивает и связанный с ним DOM.
А как обычно работают с диалогом?
Кто как. Мне больше всего нравится версия: HoC сверху, hook снизу. Это в типовом случае когда в приложении нельзя открыть два таких диалога одновременно. Хук снизу при этом использует родительский контекст и возвращает что-то типа:
{ open, close, isOpened }
, гдеopen
это метод возвращающийPromise
которыйresolve
-ится тогда когда диалог закрыт. Такой подход удобен когда диалог нужен сразу во многих местах.Но на самом деле вариантов уйма. Такого странного как в статье я не видел никогда. Вот ещё простая версия:
const Something = () => { const visibility = useVisibility(false /* hide */); // ^ { open, close, isOpen }; return <Dialog {...visibility} text={...}/>; }
Непонятно зачем вам вообще понадобилось каррирование, да ещё и на уровне компонент (где есть unmount на любой чих).
bevertax Автор
06.12.2021 22:05+1Это уже вопрос не render-а, а реконсиляции. Там React находит вместо старого компонента новый. Ввиду чего убивает его и создаёт новый. Отдельно отмечу, что он помимо этого убивает и связанный с ним DOM.
Всё-таки вы не правы, это вопрос рендера. Компонент всегда имеет одинаковый тип Dialog, поэтому размонтирование не происходит. <Dialog /> каждый раз новый, Dialog один и тот же, так и должно быть
faiwer
06.12.2021 23:05Пригляделся внимательнее, да. Я напутал. У вас ведь там ещё такой был:
const CurriedComponent = useCallback((restProps) => { return <ComponentToCurry {... props} {... restProps} /> }, [props, ComponentToCurry]);
Sorry :) Два примера смешались. Поторопился. В варианте с
createElement
этой проблемы не будет, всё так.
faiwer
06.12.2021 23:08+1Кстати говоря, сильно большого смысла вот тут мемоизировать что-то нет:
const dialog = useMemo( () => <Dialog open={open} {...dialogProps} />, [dialogProps, open] );
всё равно на выходе элемент. Помимо того что memo всегда будет бесполезным (из-за
dialogProps
), можно просто вернуть этот элемент как есть и доверить его React-у
Vlad_Murashchenko
06.12.2021 13:02Прикольно, возможно в редких случаях даже оправдано. Главное не злоупотреблять этим там, где это явно не нужно.
Также это скорее частичное применение, чем каррирование. Я бы переименовал хук в usePartial.
mobilz
06.12.2021 22:15Интересно, не бросай это дело (в плане статей на хабре), НО ) зачем это надо, когда есть mobx?
pistonsky
07.12.2021 01:34Вот не понимаю людей, которые с модалками работают через хуки или через redux store их показывают. У меня на всё приложение одна модалка, которая написана таким образом, чтобы её можно было удобно вызывать вот так:
import Modal from 'ui/modal';
Modal.show(content, options);
Modal.hide(afterHideCallback, options);
Мега удобно. Рекомендую. Таких модалок замонтировано две, чтобы можно было (если вдруг очень надо) показать одну поверх другой.content
- этоReact.ReactNode
, а в опциях можно передать, к примеру,title
, какой-нибудьsubtitle
, или что-нибудь ещё, если не хочется писать свойcontent
. Также там иtop
можно пробросить, чтобы модалка показалась поверх другой. Я ещё придумалminShowTime
иautoHide
- чтобы модалка сама собой закрылась (либо по вызовуModal.hide
), но минимум через определённое время (бывает полезно для состояний загрузок, которые можно делать как модалки на весь экран, и даже блокировать тем самым жест "назад" на айфоне если не хочется чтобы юзер "сбежал" пока какой-то важный процесс происходит). А, ну и если надо что-то внутри модалки обновить из того места, где она была вызвана - то просто вызываешь ещё разModal.show
и она рендерит заново контент (но без анимации появления).
Alexandroppolus
07.12.2021 17:59+2Поигрался с кодом. Не совсем нравится то, что обновление компонента идет из useEffect через forceUpdate (или его хуковый аналог useState) - что-то в этом есть подозрительное. Компонент как будто обновляется отдельно.
Запилил такую версию:
кодъ
type CurriedProps< AllProps, CurriedPropsNames extends Partial<keyof AllProps> > = Omit<AllProps, CurriedPropsNames> & { [propName in CurriedPropsNames]?: AllProps[propName] }; export function useCurriedComponent< AllProps, CurriedPropsNames extends Partial<keyof AllProps> >( ComponentFunction: React.FC<AllProps>, curriedProps: { [propName in CurriedPropsNames]: AllProps[propName] } ): React.FC<CurriedProps<AllProps, CurriedPropsNames>> { const render = ( Component: React.FC<AllProps>, props: CurriedProps<AllProps, CurriedPropsNames> ): React.ReactElement => { const allProps = {...curriedProps, ...props} as AllProps; return <Component {...allProps} />; }; const renderRef = useRef(render); renderRef.current = render; return useCallback( (props) => renderRef.current(ComponentFunction, props), [ComponentFunction, renderRef] ); }
Здесь каррирование (или частичное применение) компонента сводится к каррированию функции. Да и рендер нормально работает в рамках обновления всего дерева. Плюс, не нужно useMemo для второго параметра в useCurriedComponent, мы тут не зависим от ссылки на объект curriedProps. ComponentFunction добавлен в депсы useCallback намеренно, чтобы возвращался новый компонент, если исходный поменяется.
Прикольная тема. Реквестую ещё странных хуков.
bevertax Автор
08.12.2021 03:40Чертовски хорошо у вас получилось. Да, в моей версии из-за useEffect компонент будет дважды рендериться. Я по какой-то причине даже не подумал о том, что рендер можно отдельно от компонента определить, не хватило гибкости мышления
lexey111
За статью спасибо, но... чтобы - что? Какая задача решается?
bevertax Автор
Изначально я решал следующую проблему: у меня был готовый компонент A с большим количеством пропсов, при этом частью этих пропсов props_1 я хотел управлять внутри отдельного хука hook, а часть пропсов props_2 прокидывать в него из другого компонента B. Я также хотел, чтобы компонент B ничего не знал о пропсах props_1.
Вот и выходит, что мой hook должен был принять компонент A, вставить в него свои пропсы props_1, и вернуть наружу в компонент B более простой каррированный компонент.
Полагаю, что это может быть полезно при использовании готовых наборов компонентов вроде бутстрепа, чтобы частично их кастомизировать
lexey111
HOC? https://reactjs.org/docs/higher-order-components.html
bevertax Автор
HOC не позволит менять каррированные пропсы. То есть один раз их вставил в компонент и дальше пользуешься тем что получилось. Но в моём случае hook должен был многократно менять каррированные пропсы
DmitryKazakov8
Почему не позволит? В HOC можно сделать такие же propTypes, как в обернутом компоненте, просто если они не переданы использовать дефолтные значения
return <OriginalComponent prop1={props.prop1 || 'default value'} />