Comments 22
В который раз убеждаюсь, что Контексты с Хуками в сыром виде совершенно не пригодны для работы. Для этого нужно написать свой мини-фреймворк (как у вас), задать границы применения, добавить щепотку мемоизации… И только тогда можно пользоваться этим без боли.
Вопрос, только, а зачем городить свой велосипед? (Вопрос не к автору статьи, а, скорее, риторический).
Можно взять Recoil, Mobx, ну или Redux-tookit на худой конец.
Virtual-scroll компоненты (если делать их по уму, а не как в $mol) — это всегда большое количество подводных камней, костылей и прочего. Особенно когда содержимое это дерево произвольного размера элементов. Особенно когда часть из них могут иметь height на десятки тысяч пикселей. Особенно когда нужно чтобы это всё было редактируемым. Ах да, ещё же поиск :)
Пожалуй стоит упомянуть ещё про пару моментов, которые можно было бы назвать западнёй:
1) Не пишите в коде вещи вроде
<context.Provider value={{ dispatch, state }}/>
Причина: { dispatch, state }
— это всегда новый объект. А там внутри что-то вроде:
if (oldValue !== prevValue) notifyConsumers();
Это в свою очередь reset-ит context value при каждом рендере компонента, который использует Provider. А это в свою очередь принудительно rerender-ит всех тех кто использует useState
.
Отсюда вывод — нужно группировать несколько значений в одно каждый render? useMemo
к вашим услугам
В вашем коде я такого не заметил, но зато вижу такое регулярно в статьях про контекст. Думаю не лишним будет ещё раз про это упомянуть.
3) При таком вот подходе нельзя подписаться только под изменение какого-то конкретного поля в state, т.к. любое изменение state вызывает rerender всех потребителей контекста. Всегда без исключения. И кастомный хук не поможет, т.к. React пока не умеет в хуки оторванные от render-а.
Это причина того, почему тот же самый redux не использует context api для хранения state. Вместо этого там своя модель подписки. Рекомендую посмотреть как устроен useSelector
. В свою очередь это создаёт проблемы при использовании redux с какими-нибудь другими штуками вроде react-router
-а, который как раз использует контекст для таких вещей. Они обновляются столь не синхронизировано, что бывают очень бесячие баги из области race condition.
По сути context api пока выглядит очень куцым, недоделанным. Оно слишком топорное и с его помощью невозможно реализовать хоть сколько нибудь сложные вещи. Но кажется там у них идёт работа над его улучшением.
даже наивная реализация React Redux на “чистых” hooks+context не уступает сколько-нибудь значимо в production mode пакету react-redux
Это не так. Я бы даже сказал что это чепуха. Вы занимаетесь самообманом. На многих реальных приложениях разница будет просто аховой. Объяснить или вы сами всё хорошо понимаете? (но тогда зачем написали это ^)
у описанной в статье версии не значительно меньшая производительность, за счет вызова проверок в useMemo у всех компонент использующих useContext
useMemo
это капля в море и можно вообще исключить из уравнения. Даже пару vDom-тегов лишний раз создать и вы уже перебороли useMemo. Там ведь внутрь несколько сравнений указателей. ЗНАЧИТЕЛЬНО меньшая производительность будет именно из-за лишних рендеров. Т.е.:
- вызываются все хуки
- вызываются все эффекты
- генерируется куча новых vDom элементов
- производится множество реконсиляций
- если где-то не было мемоизации аля React.memo — оно ещё и в глубину убегает
Вот именно это ^ создаёт тормоза в случае "топорного" решения. Которое СИЛЬНО медленнее. Умоляю, выкиньте тот абзац из статьи. Ваш результат на табличке скорее всего базируется на том, что по условиям задачи происходит total-rerender всякий раз. И любая мемоизация просто оказывается лишней. Я прав? (лень лезть в код задачи)
Посмотрите — js занимает незначительное время в обновлении дерева — основное время занимает layout и paint.
Что делает само сравнение бессмысленным. Вы зачем его провели то? ) Можно вообще выкинуть всё и оставить одни props.
мы сегодня уже используем данное решение в связке с use-context-selection в продукте
Ну я залез туда… И что я там увидел? Да ровным счётом то же самое, что и в redux-е. Свой observable в обход контекста. Со всеми вытекающими проблемами. Т.е. чтобы переписать redux, надо… написать redux? :) В чём выгода?
будущие версии React обещают оптимизацию useContext (useContextSelector и lazy Context propagation), что уравняет ее в производительности с react-redux
Вот их и ждём. Всё так.
А что за недокументированные возможности? unstable_batch?
P.S. ваши mapStateToProps
и mapDispatchToProps
можно сильно оптимизировать сделав props
опциональной зависимостью (ибо.length === 2
).
У меня не так много времени чтобы читать все ваши ссылки. Вашу статью я прочёл. Некоторые утверждения из неё не выдерживают критики. Кратко я их описал.
3) прочтите www.npmjs.com/package/use-context-selection
Я залез в исходный код и даже привёл вам ссылку на то как оно работает изнутри. Если вам лень её открыть, то вот суть этого пакета:
function createContextDispatcher<T>(
listeners: Set<ContextListener<T>>,
equalityFn: EqualityFn<T> = isEqualShallow
): ContextComparator<T> {
return (oldValue: T, newValue: T): 0 => {
for (const listener of listeners) {
const newResult = listener.selection(newValue);
if (!equalityFn(newResult, listener.selection(oldValue))) {
listener.forceUpdate(newResult);
}
}
return 0;
};
}
Поздравляю мы нашли самописный observable. Остальное всё пляшет от этой точки. И да, у меня дежавю, тоже самое и внутри redux. Не поленитесь и полистайте их код. Правда его писали куда более талантливые программисты, это сразу бросается в глаза. Но суть примерно та же самая.
я это решение вдоль и поперек профилировал и каждый оператор тестировал — у вас одни эмоции и ни капли кода и подтверждения
? я выше привёл ряд аргументов. Вам есть что сказать то? Или какие-то из моих аргументов вам непонятны?
Касательно профилирования — вы профилировали render-движок браузера. Зачем?!
const Context = React.createContext<T>(initValue, createContextDispatcher<T>(listeners, equalityFn));
О. Нашёл наконец, про что вы говорили. Спасибо. Я правда ожидал большего, думал там что-то вроде частичной подписки на содержимое контекста.
стейт хранится и изменяется в контексте React — корректно работает в concurrent mode
Это хорошо.
возможность более гибко применять (можно проще создавать страничные хранилища и хранилища больших форм)
Ничего не понял :) Вы про множественные stores?
и чем это плохо??
По большому счёту ничем (кроме "any"). Но проблемы у этого решения, те же что и у redux. Оно работает в обход react-а. Свой собственный observer и setState-ы в цикле. Значит будут те же самые race-condition-ы с другими компонентами, которые используют уже свои контексты. Например у меня много таких было с react-router
-м.
Но пока у нас нет нативной поддержки со стороны React выбора у нас всё равно нет. Только такие вот костыли.
а что же по вашему я должен профилировать?? сферического коня в вакууме который ни на что не влияет?
Код который вы сравниваете. Причём в тех условиях которые предполагаются. Какой самый худший сценарий для "прямолинейного решения"? Когда подписок много и 99% из них зря. Условно вы поменяли кол-во like-ов к комментарию. В идеально только 1 условный <CommentLike/>
должен дойти до return <div/>
. Если дойдут все (скажем 5_000) комментариев, то производительность в сравнении с оптимизированной версией будет вообще ни разу не сопоставима.
А что вы протестировали? Рендер движок? Ну ок, один и тот же рендер движок имеет одну и ту же производительность. Эка новость.
срабатывает обновление лишь того компонента у которого селектор для контекста дал отличный результат от предыдущего!
Контекст содержит весь store.state. Целиком. Т.е. "отличный результат от предыдущего" это любое изменение стора. Но я полагаю, что вы имеете ввиду именно селекторы из mapStateToProps или mapDispatchToProps. В таком случае это не так.
На всякий случай хочу уточнить, мы с вами обсуждаем одно и то же? Я обсуждаю прямолинейную реализацию, тот код в статье. Т.е. БЕЗ use-context-selection
. Обычный { createContext, useContext }
из react
.
Так вот, то обновление получают те компоненты которые используют useConnect
. И они получают их просто исходя из того, что:
<StateContext.Provider value={state}>
и
const state = useContext(StateContext);
Все useMemo
уже не играют никакой большой роли, т.к. компонент уже получил render.
Вот ваш код:
const Container = (ownProps) => {
const props = useConnect(
mapStateToProps, mapDispatchToProps, ownProps
);
return {
const { propA, propB } = props;
return <Component propA={propA} propB={propB} />;
};
});
Сколько в useConnect
вы useMemo
не используйте, если useConnect
уже запущен (а он запущен ввиду useContext
), то return <Component propA={propA} propB={propB} />
уже не избежать. Ведь это hook. Он сам часть компонента. Рендер hook-а производится в рамках рендера его родительского компонента.
Или же вы предполагали этот пример показать как HoC аналогичный connect, а не как самостоятельный конечный компонент с бизнес-логикой? Если это так, то я вас просто не понял (но зачем тогда useConnect
?).
если вам удается при профилировании профилировать только render — поделитесь как!
Я скорее покажу как убрать из этой связки браузер. Просто возвращайте из всех компонент-листьев null. И браузер уже будет не причём. Сможете профилировать ровно то, что пишете :)
Тогда я рекомендую вам внести поправки в вашу статью, т.к. мне было предельно непонятно, что вы ведёте речь про use-context-selection
. Со стороны это выглядит примерно так:
- redux версия такая-сякая
- вот моё решение, оно проще и чуть-чуть медленнее
- а если добавить
use-context-selection
, то будет также быстро
А на самом деле оно так:
- моё решение простое как валенок, но
- оно чертовски медленное
- однако если взять патченный context из
use-context-selection
то будет также быстро
К примеру даже заголовок у вас звучит так:
React Redux на базе Reac.Hooks + React.Context
Т.е. ни слова про use-context-selection
, но зато чуть ниже:
у описанной в статье версии не значительно меньшая производительность
Но незначительно меньшая производительность как раз у use-context-selection
версии (или местами даже лучше). А у версии в лоб она зверски медленная. И в течении всей вашей статьи у вас речь про версию в лоб (во всяком случае так статью видит читатель).
Статья в целом полезная и правильная, но если оставить всё как есть, можно многим заморочить голову. И народ действительно может пойти писать свои велосипеды но без кастомного observer-а. И так огребут, что мало не покажется.
Я вот кстати не понимаю, почему, что у вас, что у redux, что… везде вот так:
useIsomorphicLayoutEffect(() => {
currentState.current = state;
});
Я вот никаких useLayoutEffect
-ов не ставлю и патчу ref-ы прямо во время рендера. И я реально не вижу в чём проблема? Задача такого вот рефа всегда иметь самую актуальную версию store-а. И даже если React отбросит этот отдельно взятый render ввиду каких-то своих оптимизаций, всё равно это изменение точно не вызовет никаких багов. Зато можно точно ручаться что значение в этой переменной актуально практически всегда. Я что-то упускаю?
да, мы используем для отдельных разделов приложения code-splitting со своими сторами — очень много данных в каждом разделе, которые никак не пересекаются с другими разделами.
Flux :)
Ну собственно он ничего и не ответил :)
То что это side effect и без того очевидно. А вот как это может "break" совершенно не ясно. React может отбросить отдельно взятый render — и тогда, формально, запись в ref вовремя render-а может оказаться бесполезной\лишней и т.п..
Но, имхо, в большинстве подобных случаев (к примеру, как у вас в коде), это не вызовет никаких негативных последствий ни при каких обстоятельствах, просто потому что, тут ref используется как class properties из class components для того чтобы ref можно было рассматривать как гарантированный источник истины. И в этом случае useLayoutEffect
как ежу футболка.
Ну во всяком случае я так это вижу. Мы в коде используем даже такое:
export const useRefStorage = <T,>(value: T): T => {
const ref = useRef<T>(({} as unknown) as T);
Object.assign(ref.current, value);
return ref.current;
};
const refValue = useRefStorage(someObjectState);
И в итоге не нужны никакие .current
и пр. штуки, и обращаться к нему можно и в render-е и в любых callback-ах (некоторые из которых могут вызываться как во время рендера, так и после, просто ввиду бизнес-логики)
Я выяснил в чём дело. Немного болезненный опыт. Поэтому решил отписать сюда, может кому ещё интересно.
В общем возможны 2 ситуации когда мутирование ref-а может привести к проблемам.
Ситуация 1. Адекватная
React может запустить render вашего компонента. А потом отбросить его результат. Потому что нашлись более приоритетные задачи. Проблема получается такая:
- HTML и древо ниже работает как если бы render-а не было вовсе и оперирует старыми данными
- Всё что ссылается на ref имеет доступ к новым данным
- Итого неконсистентность. Что-то может сломаться
В моём коде полно таких мест. Но нигде ничего не ломается, т.к. я by design хочу иметь доступ к последним данным, вне зависимости от — рендер был отброшен или нет. Но возможны всякие хитрые случаи и это правда unsafe код.
Ситуация 2. Сюрприз от React Dev Tools
Оное расширение может сделать extra-render вашего компонента. И подсунет туда ненастоящие хуки, а fake-и. Вот они. И тут сразу целый ворох проблем:
- эти хуки, хотя и возвращают корректные данные
- на самом деле ничего кроме этого не делают
- скажем useCallback возвращает тот метод что ему передали без каких-либо проверок на deps
- а useState возвращает setter который ничего не делает
- та же песня в useReducer
- и если вы это поместить в ref
- то до следующего нормального рендера (который может не произойти) у вас в ref лежит мусор от React Dev Tools.
Вот тут уже, как говорится, — против лома нет приёма. Это самая настоящая западня. Такого я не ожидал :D
Кмк вы зря придераетесь...
Решение из статьи может и не гениальное, но для проектов которые требуют крайне простой общий стор формата десятка простых объектов ( скажем формата { color: string; time: string } — это всё довольно удобно и работает приемлемо.
Для серьёзного проекта уже понятно лучше специализированный стейт-менеджер брать.
С вашего посыла я таки прочёл эту эпичную писанину про историю развития redux. Честно говоря единственное что я для себя новое оттуда подчерпнул, так это то, что, оказывается в 6-й версии они убрали store из context-а и воткнули туда state, а потом в 7-й всё вернули как было (lol). Мои знания по большей части касались 4-й и 7-й версии. 4-ая была безнадёжно кривой, а в 7-й… ну это текущая версия. Её я дебажил не так давно.
про PS: не понял каким образом опциональность mapStateToProps и mapDispatchToProps поможет оптимизации? оптимизации чего?
Это одна из простых оптимизаций redux-а. Суть в том что mapStateToProps имеет много сигнатур из которых наибольший интерес представляют вот эти две:
rootState => stateProps
(rootState, ownProps) => stateProps
Вот у вас в коде только 2-ая. И поэтому вы добавляете props
в dependencies
для useMemo
. А это очень сильно бьёт по производительности в ряде случаев когда эти самые state не зависят от props. И там элементарная вилка вида:
if (mstp.length === 2) return mstp(state, ownProps);
else return mstp(state);
и тоже самое с зависимостями, разумеется, если мы используем useMemo
.
Как эффективно применять React Context