React. Странные хуки: каррирование функционального компонента

от автора

Добрый день! Я начинающий фулстек-разработчик, и это моя первая статья.

Сегодня я хочу рассказать, как сделать функциональные компоненты в реакте чуть более функциональными, а именно как сделать каррирование функционального компонента.

Предупреждение: в статье использованы как функциональные, так и классовые компоненты

Каррирование

Немного определений.

Вот здесь определение каррирования с википедии.

Мы будем называть каррированием преобразование функции 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.

Определим семантику хука:

  1. Хук должен принимать компонент и возвращать каррированный компонент

  2. Хук должен помимо компонента принимать часть пропсов этого компонента и прокидывать их в исходный компонент

  3. При изменении любых пропсов каррированный компонент перерендеривается, как и любой обычный компонент

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

Запомните свойства 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, но это сделано для наглядности

На этом всё. Если статья найдёт положительный отклик, то я продолжу рассказывать о придуманных мной странных хуках.


ссылка на оригинал статьи https://habr.com/ru/articles/593479/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *