Как стать автором
Обновить

Комментарии 17

Мы же можем просто объявить let timeoutId; и использовать замыкание [...] Ответ кроется в самом React. При каждом новом цикле рендера React пересоздает все функции в теле компонента. Если вы попробуете применить последний пример, то он будет работать так же, как и самый первый пример

Это не React пересоздаёт, это JavaScript так работает.

Спасибо за комментарий. Опыт показывает, что некоторые начинающие работать с React не всегда очевидно понимают этот момент и какое-то время уходит, чтобы разобраться именно в этом аспекте. Это работает не для всех функций в теле компонента, что может сбивать с толку на первых порах. Допустим, возвращаемая хуком useState функция-сеттер гарантированно остается стабильной между циклами ререндера. React осознанно реализует это поведение с пересозданием функций и правильное использование того же хука useCallback так же требует некоторого понимания деталей.

А еще useCallback и useMemo не гарантируют 100% мемоизацию.


Опыт показывает, что некоторые начинающие работать с React не всегда очевидно понимают этот момент

Я как раз столкнулся с задачей объяснить новичкам React. Хуки с одной стороны как простые магические коробки, с другой — сложный для понимания механизм. А потом еще и складывается ощущение, что хуки можно применять и вне реакта. А там вообще ничего реактивного нет. Как быть? Как объяснить? Спасибо)

Хуки — это некоторый сдвиг парадигмы мышления. Особенно, если уже был некоторый опыт работы с компонентами на классах или React начинали изучать с них, а уже потом принялись за хуки. Или же в целом изучающий более привычен к понимаю классическоого и повсеместно преподаваемого ООП на классах.


А ведь конечном итоге мы имеем всегда одни и те же основные цели — получить удобный способ переиспользования кода, удобную и прозрачную работу с состояниям компонента и понятные механизмы управления эффектами.


Как известно, React и его создатели очень много вдохновения черпают в OCaml и любят функциональный подход. В обсуждениях вопроса о том, на сколько функциональны хуки сломано не мало копий. Кто-то называет это чистой процедурщиной. Кто-то с этим не согласен. Уходить в эти дебри сразу с самого начала объяснения хуков — точно не стоит.
Как говорил Кхал Дрого из известного сериала: "Это вопрос для мудрых мужчин с тощими руками." :)


Мне кажется, для начального понимания хуков нужно еще раз освежить в голове изучающих такую концепцию, как композиция. С этой точки зрения хуки — очень понятная идея. Мы берем функцию и в нее страиваем другие функции. Или выносим часть переиспользуемого функционала в отдельные хуки. По сути те же функции, но с особенностями. А их в свою очередь переиспользуем в других функциях. Хуки, в свою очередь, отличаются от обычных функций тем, что могут использовать внутренние механизмы React.


Это отражено в примере из статьи. Есть соблазн создать обычную функцию, использовать замыкания и сделать тот самый debounce. Ведь сама по себе задача уже давно известная и хорошо изученная, даже тривиальная. Но, как мы видим, напрямую в лоб оно так не заработает. Для реализации debounce мы прибегаем к внутренним механизмам React: узнаем о состоянии монтирования компонента, просим React сохранить какой-то объект в течении всего цикла жизни компонента.


И из вышесказанного можно уже подвести к тому, что хуки вне React работать не будут. Их отличие от обычных функций, которые мы можем встроить в другие функции — именно в том, что они используют возможности самого React внутри себя. А без React нет и его возможностей. :)

Хук useEffect в момент размонтирования компонента вызывает функцию, которую мы возвращаем из тела этого хука. В нашем случае будет вызвана clearTimer() и последний запланированный отложенный вызов будет отменен.

А еще он будет выполнен при переключении cleanUp, что в случае переключения с true на false сбросит последнюю операцию. Маловероятно, конечно, что кто-то будет его переопределять в рантайме, но, потенциально, это может создать кучу непониманий.


Из более значимого замечания: ваш хук каждый раз возвращает новую функцию, что просто очень плохо.


Рассмотрим ваш же пример:


 const handleChange = async (event, newValue) => { /* ... */  }; // функция пересоздается каждый раз как компонент перерисован

 <Slider /*... */ 
        onChange={handleChange} // поэтому в слайдер каждый раз передается новая ссылка на функцию (новая функция в целом)
 />

Это означает что React.memo работать не будет и компонент будет перерисован каждый раз. Иногда со всякими кнопками и чекбоксами в этом проблем нет, однако бывают компоненты со сложной логикой и, обычно, они являются частью формы. Соответственно, если какое-то поле в форме изменилось, перерисована будет вся форма, за счет чего она может сильно тормозить и быть неотзывчивой.


Для исправления, очевидно, нужно обернуть handleChange в React.useCallback:


const handleChange = React.useCallback(event => { /* ... */ }, [ debouncedValueCheck ]); // упс, debouncedValueCheck пересоздается каждый раз когда компонент вызван, т.е. handleChange так же пересоздается.

Вообще необходимость расставлять React.useCallback для хуков — большая пролема. Хуже того, что часто нужно решать "стоит ли оно того", ведь эта операция не бесплатная сама по себе.

По поводу cleanUp — это полностью осознанное решение. Согласен, что смена флага в рантайме очень маловероятна. Но если все же флаг изменится, мне кажется будет честным сбросить таймер.


По поводу useCallback полностью согласен. Как и в том, что операция действительно не бесплатная сама по себе. Не добавлять useCallback в этом случае полностью осознанное решение. Этот момент я не стал упоминать в этой статье еще и по той причине, что объем показался итак достаточно значительным и не хотелось перегружать материал еще и хуком useCallback. Хотелось поговорить об этом отдельно чуть позже и более подробно. Спасибо за комментарий.

Да, я понимаю, что это обучающая статья, но мне кажется, что useCallback является слегка более базовой концепцией, и введение в хуки можно было сделать на чем-то более простом. Вот пример как это делают в библиотеке. Я понимаю, что треть логики связана с "leading", и можно сократить, но все равно впечатляет.

Хотелось подобрать какой-то действительно интересный и хороший пример, который может быть реально полезен в повседневной работе. Но в процессе написания объем материала стал нарастать очень быстро и пришлось разделить его на какие-то вменяемые для восприятия части. Концепции мемоизации в React сами по себе очень интересны и заслуживают детального рассмотрения. Не хотелось бегло цеплять их в одном-двух абзацах.


Планирую поговорить об этом в дальнейшем. Ну а пока, надеюсь, что эта ветка комментариев тоже будет полезна читателям)

Да, понимаю вас. Только хотел в комментарии выше написать "я бы рассказал на примере как оно работает внутри", но комментарий быстро разросся настолько, что его полезность стала совсем сомнительной. Но я все еще верю в то, что задание из серии "написать свой движок для хуков" может хорошо объяснить некоторые тонкости и странности хуков, например, почему порядок их вызова очень важен.


Что-то самодельное из серии:


function executeInContext(fn, tag) { }
function effect(effectFn) {}

// usage:
const callsCount = {};
function getCallsCount(tag) { return ++callsCount[tag]; }; 
function foo() {
   console.log(effect(getCallsCount)); // эффект вызывает функцию и передает её контекст
} 

executeInContext(() => {
    foo(); // 1
   foo(); // 2
   foo(); //3
}, 'context 1');

executeInContext(() => {
    foo(); // 1
   foo(); // 2
}, 'context 2')

И как на этом построить тот же useState, и как сделать чтобы можно было вызвать два разных useState в пределах одной функции, и что эти "контексты" в реакте называются Fiber, и у них есть ссылка на метод "перерисовать" (пересчитать в данном случае) и тогда должно быть понятно как оно работает.

Самостоятельная реализация таких вещей очень хорошо прокачивает понимание и навык. Полностью согласен.


При написании туториала приходится балансировать по очень тонкой грани. От "ну это же супер банально" и "примеры о сферических конях в вакууме, которые вообще непонятно, как применить в реальной жизни" до "А сейчас мы начнем изучение JavaScript на примере этого ассемблер кода. Все началось в далеком..." :) Немножко утрирую, но приходится искать компромис и какие-то вещи опускать, что бы сфокусировать внимание на других деталях. Опираясь на поступательный подход с постепенным усложнением тем и большим количеством деталей.

У debounce есть полезные варианты использования, но в этом конкретном случае со слайдером разве не проще воспользоваться обработчиком `onChangeCommitted` вместо нетривиальной настройки debounce?

Компонент слайдера из material-ui взят исключительно для наглядности примера. Это может быть и созданный кем-то другим слайдер, без подобного свойства, как onChangeCommitted. И у onChangeCommitted все таки немного другая логика работы. Он реагирует на событие mouseup и после него вызывает callback-функцию. И тут уже нужно смотреть, устраивает нас именно такое поведение или мы хотим реагировать в том числе и на остановку движения ползунка на длительный промежуток. В данном случае учебный пример подразумевает, что мы хотим реагировать на остановки ползунков.

Признаюсь, статью не читал, т.к. из названия был уверен, что в ней будет описан способ получить нечто совершенно бесполезное. Финальная версия "хука" подтверждает догадки:


  1. Вы используете ref вместо let из-за того, что в каждом рендере замыкание разное. Т.е. по сути, решаете проблему, которая существует только из-за того, useDebounce() — хук, а не обычный метод-декоратор. На самом деле как и любой декоратор, его следует вызывать не во время рендера, а на этапе подготовки.
    const too = () => { ... }
    const debouncedFoo = debounce(foo, timeout)
    const Component = () => {
    // use debounced version here
    }
  2. Если декорирование требуется на уровне экземпляра компонента, с этим отлично справится useMemo() или useRef():
    const debouncedFoo = useMemo(() => debounce(foo, timeout), [])
  3. Первый пункт так же решает проблемы ссылочной стабильности матода и создания множества замыканий при каждом рендере без дополнительных плясок с useCallback().
  4. Как Вы сами написали, если декларируемая функция асинхронная, мы все ещё можем попасть в ситуацию с обновлением состояния размонтированного компонента. Причина в том, что хук берет на себя и эту ответственность. Такая проблема обычно решается отменой самого выполнения после размогтирования:


    useEffect(() => {
    const { token, cancel } = axios.CancelToken.source()
    
    debouncedAsyncRequest(token).then(...)
    
    return cancel
    }, [])

    На мой взгляд статья хорошо иллюстрирует ситуацию, когда очень хотелось сделать хук, но в данном случае не стоило.


Очень жалко, что Вы не прочитали статью и комментарии к ней. Многое уже было бы более ясно и какие-то описанные пункты отпали бы сами собой. :)


Теперь давайте поговорим подробно по каждому из пунктов Вашего комментария:


Пункт 1. Как Вы сами заметили во втором своем пункте, если нам нужен доступ к чему-то из текущего экземпляра компонента, мы этот вариант просто не можем сразу использовать, как есть. Нужно делать дополнительные действия и тут можно сразу перейти к пункту 2.


Пункт 2. Сразу отмечу, в чем одно из предназначений хука — нам не нужно разделять случаи использования debounce() и думать о том, используем ли мы что-то из экземпляра компонента, будем ли это потом делать в дальнейшем и как. У нас есть унифицированный подход, который мы можем использовать и в дальнейшем менять с минимальными трудозатратами.


В Вашем примере useMemo используется не для того, для чего он предназначен. useMemo нужен мемоизации тяжелых вычислений и оптимизации производительности. В данном случае семантически правильно использовать useCallback и к тому же нам не придётся делать дополнительную обертку над debounce().


То есть, можно написать так:


const debouncedFoo = useCallback(debounce(foo, timeout), [])

Теперь сравним, что же мы получаем. С хуком в статье мы имеем решение в одну строчку кода, которая декорирует нашу функцию и сразу дает на выходе тот результат, который нам нужен.


В предложенном варианте нам нужно каждый раз оборачивать в useCallback наш debounce(). Собственно, оно и работает именно благодаря использованию useCallback.


Хук в статье не предоставляет ссылочную стабильность не просто так. Операции мемоизации не бесплатны. Каждая оптимизация и мемоизация должны быть сделаны в тех случаях, когда нам это действительно нужно и мы понимаем, что получим взамен и как это улучшит нашу производительность. Если бы мемоизировать все и везде было всегда выгоднее и более оптимально для производительности приложения, React мог бы по умолчанию сделать это для всех функций в теле компонентов, которые мы создаем. Однако, это далеко не так. В большинстве случаев поведение с пересозданием функций не должно нас беспокоить. Это работает в React достаточно хорошо, чтобы в подавляющем большинстве случаев не думать на этот счет.


Если же нам нужна ссылочная стабильность в каком-то конкретном случае, мы используем useCallback() и это нормально. Именно по этой причине сам хук не определяет за нас, нужно ли нам это или нет. По опыту использования могу сказать, что очень часто функции обернутые в debounce() этой ссылочной стабильности не требовали.


Для примера давайте взглянем даже на код в статье. Функции обернутые в debounce() используются внутри функции-хэндлера handleChange(). То есть в первую очередь нам нужно сделать handleChange()ссылочно-стабильной. Именно ее мы передаем в пропс onChange. Для этого мы обернем ее в useCallback, обернем все наши отложенные функции в useCallback.


И тогда мы получим… а что же мы получим? А по сути мы не получим ничего. Единственное, для чего нам нужно была бы в этом случае ссылочная стабильность — это предотвратить ненужные ререндеры компонента, куда мы передали в пропсах нашу handleChange(). Но вот засада, тут все равно будет происходит ререндер при каждом движении ползунка, потому что нам нужно пересовать компонент слайдера из-за измененного значения положения ползунка. Сам родительский компонент RangeSlider не предполагает, что он будет отрендерен таким образом, что это не потребует ререндера нашего слайдера.


В итоге – дополнительные операции мемоизации в данной ситуации просто не нужны. От них будут только дополнительные расходы, но никакой пользы мы не получим.


Пункт 3. Думаю здесь ответ дан в предыдущем пункте.


Пункт 4. Хук говорит – если компонент размонтирован, я не стану вызывать отложенную функцию. И это может быть очень удобно, если наши отложенные функции синхронные. Нам не нужно каждый раз следить за этим самим – это может сделать для нас наш хук. Это еще одно предназначение хука — стандартное поведение, которое мы можем переиспользовать. Если бы хук мог так же контролировать асинхронные вызовы, было бы еще удобнее. Но в этом случае нам придется озаботится этим в самом асинхронном вызове. Тут уже ничего не поделать.


О работе с асинхронными вызовами и о мемоизации в целом, я хотел более подробно поговорить в дальнейшем. О чем прямо написал и в статье, и в комментариях выше. Все сразу в один материал я запихивать не стал, статья и так достаточно объемна.


Предложенное Вами решение имеет место быть. Но это не эквивалентные решения с разным подходом. И это нужно понимать.

И еще хотелось бы добавить по поводу мемоизации. В случае с ней, я стараюсь придерживаться известного принципа: «преждевременная оптимизация — корень всех зол». Если при работе приложения и его компонентов не возникает реальных проблем с производительностью, то я никогда заранее не использую техники мемоизации. Это также позволяет не писать лишний код и не добавлять сложности в текущую логику работы компонентов. Их использованию всегда предшествует какой-то реальный практический кейс, который явно влияет на производительность. Кейс, который можно измерить и сравнить с работой после мемоизации. И если разница действительно ощутима и имеет практическую пользу – тогда можно посмотреть в сторону мемоизации. Это очень часто можно увидеть еще на раннем этапе разработки. В частности, со случаями, когда хорошо видно, что ссылочная стабильность поможет нам избежать лишних затратных вычислений и отрисовок. Иногда эти техники помогают улучшить уже работающие части.


Сам React предполагает, что не нужно везде и всегда делать мемоизации. Это подход самого фреймворка. Об этом есть хорошие материалы, например интересная статья: When to useMemo and useCallback. React старается сделать все достаточно быстрым и хорошо работающим в большинстве случаев использования. И до определенного момента нам не нужно думать об этом. Если же мы упираемся в базовые возможности, у нас есть некоторые инструменты по повышению производительности в узких местах. А там уже нужно смотреть и проверять, что и как можно оптимизировать. Если же где-то мы можем использовать useRef при разработке компонента, то для начала стоит попробовать его. В частности, хук в статье следует именно этим принципам.

Пункт 1. Как Вы сами заметили во втором своем пункте, если нам нужен доступ к чему-то из текущего экземпляра компонента...

Про доступ я ничего не писал. Речь шла о декорировании функции, которое можно выполнить на уровне модуля (если все экземпляры компонента могут использовать один экземпляр декорированной функции) или экземпляра компонента (когда каждому компоненту нужен свой экземпляр функции).


Сразу отмечу, в чем одно из предназначений хука — нам не нужно разделять случаи использования debounce() и думать о том, используем ли мы что-то из экземпляра компонента, будем ли это потом делать в дальнейшем и как. У нас есть унифицированный подход, который мы можем использовать и в дальнейшем менять с минимальными трудозатратами.

Как раз наоборот, хук априори менее универсален, т.к. может быть использован только внутри компонента. Вам не удастся использовать его внутри другой функции. Вы также не сможете использовать один экземпляр декорированной функции в разных экземплярах компонента(ов) — хотя это может и не так часто нужно.


В Вашем примере useMemo используется не для того, для чего он предназначен...

Вы правы, можно и с useCallback(). Лично я не вижу никаких проблем в том чтобы использовать useMemo() в том числе и для устойчивых ссылок. По сути, useCallback(fn) = useMemo(() => fn). Вот только из-за его названия лично я предпочитаю его использовать только для коллбеков. И конкретно в нашем случае с useCallback() каждый раз будет создаваться декорированная функция, а с useMemo() только более простая функция декорирования. Но это всё мелочи и но не настаиваю.


Теперь сравним, что же мы получаем. С хуком в статье мы имеем решение в одну строчку кода...

Прошу прощения, не заметил как мы вернулись назад во времени в тот момент, когда экономия на словах была важна. Раз уж это критично, никто не мешает сделать


const useDebounce = (fn, timeout) => useCallback(debounce(fn, timeout), [...])

… которая декорирует нашу функцию и сразу дает на выходе тот результат, который нам нужен.

Но который имеет ряд описанных в моем и других комментариях проблем.


Хук в статье не предоставляет ссылочную стабильность не просто так. Операции мемоизации не бесплатны…

Если вы попробуете использовать ссылочной неустойчивую функцию в useEffetc() — вас ждёт неприятный сюрприз.
Если уж говорить о цене — я не уверен, что useRef()+useEffect() под капотом useDebounce() бесплатнее одного useMemo()/useCallback() ;)


Каждая оптимизация и мемоизация должны быть сделаны в тех случаях, когда нам это действительно нужно и мы понимаем, что получим взамен и как это улучшит нашу программу...

Пару предложений назад Вы писали, что мемоизация дорогая. Так мы должны преждевременно оптимизировать или нет?
В том и плюс декоратора перед представленным хуком — его можно легко мемоизировать или нет, создать единственный экземпляр при первом рендере или пересоздавать каждый раз и т.д. То есть использовать гибко. Можно даже сделать:


const debounced = useRef(debounce(fn, timeout))

Если же нам нужна ссылочная стабильность в каком-то конкретном случае, мы используем useCallback() и это нормально.

Если я правильно понял, что Вы имеете в виду, то это похоже на потенциально проблемную конструкцию. Что-то мне подсказывает, что на подобное линтер должен бы ругаться из-за проблем с зависимостями:


const debounced = useDebounced(fn, timeout)
const stableDebounced = useCallback(debounced, [])

Для примера давайте взглянем даже на код в статье… То есть нам нужно сделать handleChange() ссылочно-стабильной...

Это при условии, что компонент мемоизирован/чистый, иначе смысла в этом нет. Но как я уже писал выше — используя useCallback() не забудьте про зависимости.


Единственное, для чего нам нужно была бы в этом случае ссылочная стабильность — это предотвратить ненужные ререндеры компонента… тут все равно будет происходит ререндер при каждом движении ползунка, потому что нам нужно пересовать компонент слайдера из-за измененного значения положения ползунка...

В вашем конкретном примере действительно так и есть. Но бывают ведь и другие приложения, в которых родительский компонент передает в дочерний только коллбек, кроме которого нет другой причины для повторного рендера.


Пункт 3. Думаю здесь ответ дан в предыдущем пункте.

Будем считать, что так (почему плохо создавать новые функции при каждом рендере можно почитать в комментарии к правилу react/jsx-no-bind)


Пункт 4. Хук говорит – если компонент размонтирован, я не стану вызывать отложенную функцию...

Назначение функции нескольких ответственностьей также делает ее менее гибкой. Еще что-то мне подсказывает, что синхронный вызов в размонтированном компоненте указывает на проблему в его дизайне.

Со своей стороны я все же считаю, что функция может быть хуком. Во-первых, в React в первую очередь она мне нужна именно для использования в компонентах. Честно, не очень могу с ходу припомнить, когда последний раз использовал debounce вне компонента. А вот внутри них постоянно. Во-вторых, возможность переложить на хук контроль за состоянием монтирования компонента для отложенной функции — удобный способ переиспользовать стандартное поведение. Именно это и делает функцию хуком в данном случае. Использование возможностей React внутри себя. В данном случае — умение работать с состоянием рендера.


Когда я говорил о мемоизации, я имел ввиду мемоизацию хука из примера. Ваш пример работает именно благодаря ней. Хук из статьи работает и без нее. Как если бы мы могли сделать вызов функции debounce() в теле компонента без каких либо дополнительных телодвижений. Мемоизацию можно сделать дополнительно сверху. Но только в случаях, когда нам будет нужна ссылочная стабильность.


С точки зрения производительности не думаю, что хук из статьи или Ваше решение хоть как-то значимо отличаются, чтобы нужно было об этом переживать.


По поводу хуков debounce. Команда React уже делает свою реализацию в виде хука useDeferredValue. Пока для работы со значениями и в экспериментальном режиме. Но именно в виде хука.


Как я и сказал, Ваше решение имеет место быть. Но это другое решение, с другим подходом. Мне удобно, когда хук сам проследит за отменой вызова и мне не придётся каждый раз писать бойлерплейт код для контроля за состоянием монтирования. И мне удобно, что я могу сразу вызвать функцию в любом месте компонента и предварительно не оборачивать ее в вызов useCallback. Для меня так код становиться немного чище и проще. Это опциональное действие, которое остается на мое усмотрение.


По поводу размонтирования, синхронных вызовов и проблем в дизайне. Только переданный в debounce() коллбэк синхронный. А вот его вызов уже становится асинхронным по своей сути, через переданную задержку отложенного вызова. И за эту задержку пользователь может успеть всякое, особенно если мы ставим более или менее значительную задержку по каким-то причинам. Хорошим тоном будет всегда следить за таким отложенным вызовом и отменять его при размонтировании.


По поводу проблем с оборачиванием хука в useCallback. Их нет.
Если нужно, можно спокойно написать:


  // Отложенное логирование
  const debouncedValueLogging = useCallback(
    useDebouncedFunction(newValue => valueLogging(newValue), 300),
    []
  );

И это будет прекрасно работать. Визуально эта конструкция полностью эквивалентна использованию debounce() с let внутри. Ну, только название у хука чуточку длиннее. Но это решаемо. :) Про массив зависимостей и предупреждения линтера согласен. Об этом я писал в статье. За этим желательно следить всегда и не пытаться обмануть линтер.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий