Русский
Русский
English
Статистика
Реклама

Redux

Интервью с Дэном Абрамовым React 17, Suspense, Redux, холивары

11.03.2021 14:15:49 | Автор: admin


Главный миф о Дэне Абрамове что он создал React. Но хотя это и не так, сейчас он имеет самое прямое отношение к фреймворку, так что поговорить с ним про React очень интересно. Обычно Дэна не увидеть на российских конференциях, но нам помог онлайн-формат, и на HolyJS его подробно расспросили Наталия Теплухина (член core team Vue.js) и Наталия Короткова (занимается веб-проектами с 2010 года).


Тут получился не поверхностный разговор о погоде, а обсуждение технической конкретики для тех, кто вдается в детали. Но под конец были и более общие вопросы: когда появится новая документация React, переписанная с нуля? Что не так с Redux? Как Дэн воспринимает холивары о фреймворках? Что он может рассказать о релокации?


Так что, если не осилите все технические детали в первой половине интервью, переходите ко второй, там попроще. Под катом и видеозапись разговора, и текстовая расшифровка.



Оглавление



О Suspense


Первый вопрос немного философский. Начну его с вводной. Чем стабильность отличается от стагнации? В 17-м апдейте React у нас не было никаких классных фич и заметных обновлений, хотя ожидались какие-то глобальные апдейты. Почему React вообще сменил версию с 16 на 17, ради чего это было?


Изначально не планировали такой 17-й версии, которая получилась. Действительно, планировали апдейты, связанные с конкурентным режимом, Suspense. Но так сложилось, что было несколько проблем, которые мы хотели пофиксить довольно давно, с 2014 или 2015 года. Они связаны с ивентами. И мы снова столкнулись с ними внутри.


Была конкретная проблема. Вы, наверное, помните редактор Atom: там с ней столкнулись до того, как редактор закончился. Они какое-то время использовали React, на нем можно было писать плагины, и столкнулись с тем, что если вложить в одно React-дерево другое они плохо работают вместе.


Если у тебя File Explorer на React 13, а Project View на 14-м, конкретная вещь, которая ломается
event.stopPropagation(). Эти реакты не знают друг о друге, не могут координировать события.


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


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


Мы решили пофиксить события, работал в основном Доминик (Dominic Gannaway прим. ред.). Большое изменение события перенеслись. Раньше они подписывались на документе, а теперь делегируются к корню дерева React. Даже если вы используете две версии React, что в целом не очень хорошо, в большом приложении есть разумный компромисс: какие-то старые трехуровневые диалоги можно держать на старой версии, а основное приложение перевести на новую. Благодаря этому изменению они могут сосуществовать, код можно переводить с одной версии на другую, и это не должно сломать приложение в целом. Может, придется немного подтянуть его.


Мы не можем взять какой-то апдейт и сделать тяп-ляп нужна стратегия, которая работает для огромных кодовых баз с сотнями тысяч компонентов. Поэтому мы говорим, что нет новых фич, но начиная с React 17 как раз будет большая фича в больших кодовых базах появится возможность вкладывать кусок приложения одной версии в кусок приложения другой версии.


Таким образом в 18-й версии это позволит нам убрать какие-то вещи, которые мы не могли убрать раньше, так как от них, к сожалению, до сих пор зависит много кода.


Поэтому мы характеризуем 17 версию словами stepping stone (нечто вроде ступень на пути, позволяющая перейти к большему прим. ред.).


Почему вы так долго пилите Suspense, что мешает его релизнуть?


Давайте я сначала расскажу, как мы в принципе работаем, а потом отвечу на вопрос.


Мы не просто команда разработки, мы команда Research & Development. В продуктовой команде бывает, что кто-то просто просит какую-то штуку, и её делают. У нас немного другой подход, потому что продукт другой.


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


Если начать думать, как встроить само понятие асинхронности в UI-программирование, то это довольно обширная область. И одна из основных идей в Suspense мы хотим утащить понятие того, что какой-то компонент не готов зарендериться, что он ждет каких-то данных или чего-то ещё, в декларативную модель, как React делает с другими вещами. Вместо того, чтобы приходилось делать что-то вручную, это становится элементом дерева.


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



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


И в целом у нас это уже хорошо работает c Relay. То есть у нас есть опенсорсная библиотека для GraphQL, которая использует Suspense. Но мы знаем, что в опенсорсе никто не использует Relay, мы хотим общее решение.


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


Мы по-прежнему развиваем эту идею. Есть конкретные вещи, которые, я надеюсь, мы сможем показать в ближайшие месяцы. Они дополняют картинку и делают её целостной. (прим. ред.: в конце декабря были представлены React Server Components)


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


О Concurrent mode


Как вы конкретно исследуете эту проблематику? Насколько я знаю, вы тестируете Concurrent mode и Suspense data fetching на Next.js-проекте. Можешь рассказать об итогах тестирования? Помогает ли это вам в развитии React?


Да, если кто-то не знает, у нас есть активная коллаборация с Next.js, это React-фреймворк. Это коллаборация между нами, Next.js и кусочком команды Google Chrome. У нас нет какого-то направления вроде помогать только Next.js, просто в разное время работаем с разными командами над разными проектами.


Понятно, что людям нравится React, но делать всё на клиенте неэффективно. И если хочешь сделать прочный фундамент, на котором можно строить не беспокоясь, что ты делаешь что-то неэффективное, нужен какой-то фреймворк. Либо самописный, либо из коробки.


И в этом смысле Next.js неидеальная, но хорошая база, на которой можно строить. И для нас, если мы хотим ввести какие-то best practices это хорошее место, и другие все равно скопируют. И у Google такой же интерес, они до этого пытались делать по-своему, что-то у них получалось.


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


Мы внутри Facebook используем Concurrent mode больше года, новый сайт работает полностью на нём. Понятно, что новый сайт неидеальный (одна кодовая база и сотни людей), там тоже свои сложности: иногда люди заходят, кликают, видят баг и говорят о, Concurrent mode. Но нет, это не совсем так работает.


Во главе Concurrent mode, потому что очень сложно сделать сайт такого объёма на классическом React. Это не то же самое, что перевести существующее приложение мы и с Next.js работаем, у них есть какие-то клиенты. И через них можно попробовать перевести один проект на Next.js и посмотреть, что будет на Concurrent mode.


У них есть своя data fetching-история с getInitialProps, которая в чем-то нас вдохновила. Это один из ингредиентов, который мы получили от Relay, нашего собственного ресёрча, а теперь мы хотим посмотреть можем ли мы дополнить то, что Next.js сделал с getInitialProps, внести свои идеи туда и посмотреть более общее решение, которое имеет смысл попробовать.


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


По экспериментам насколько знаю, Concurrent mode уже пробовали в каких-то проектах. На Next.js Conf недавно был доклад от команды Chrome, в котором они показали слайд с изменениями в хорошую сторону.



Вот этот момент в докладе.


Эксперименты идут нормально, я думаю, эта работа только набирает обороты, результаты будут в течение следующего года.


Будет ли Concurrent mode работать с React Native, и правда ли Facebook использует React Native, который отличается от того, что лежит в общем доступе на GitHub?


Мы всегда используем не совсем то, что в опенсорсе, потому что у нас иначе собираются ошибки, есть существующая инфраструктура, с которой нужно интегрироваться. Поэтому мы не используем npm package, а билдим вручную и меняем некоторые файлы.


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


Сам билд можно сделать, если заклонить репозиторий React на GitHub. Можно сделать yarn build --type=FB и получить те же бандлы, что мы используем. Только там будут внутренние референсы.


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


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


Мы столкнулись с тем, что на React Native легко делать экраны приложения, но трудно создать что-то встраиваемое. Например, ленту новостей Facebook никто не будет никогда переписывать на React Native, там уже семь лет коду. И, например, хочется встроить какой-нибудь feed item на React Native, но поскольку он всегда был асинхронным, его нельзя отрендерить, измерить, чтобы он синхронно встал в ленту новостей, пока ты скроллишь.


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


Мы не сделали шажок для веба и потом портируем на React Native. Всё задумывалось как единое целое, просто для веба это быстрее сделать.


О state managers


Почему unstable_observedBits анстейбл, почему аналогичную функциональность не реализуют в паблик? Это сильно упростило бы state management для маленьких и средних приложений.


Почему анстейбл? Потому что это очень странный API, которым сложно пользоваться. Я думаю, мы это точно уберем.


Почему люди думают, что им это нужно? Тут смотря как структурировать приложение. Если весь тип state вынести наверх, и потом где-то глубоко использовать, то может быть неэффективно, что у тебя слишком много перерендеривается и так далее. Часто это решается тем, что state нужно выносить туда, где он используется, а не пихать наверх.


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


Если мы говорим про какие-то долгосрочные решения, то одна вещь, которую мы всегда хотели сделать (особенно с хуками, они были спроектированы специально под это) это компилятор, который автоматически делает мемоизацию.


И в таком случае я думаю, будет спорный вопрос, что нужен такой API для state management. Если проблему закрыть именно с тем, что слишком агрессивно всё ререндерится автоматической мемоизацией, то результат будет похож на тот, что у Svelte или Vue.


Только они это делают мутацией и реактивностью, а мы это делаем мемоизацией. Но для этого нужен компилятор, и это тоже большой проект. Может быть, мы начнем его в 2021-м, может, нет трудно сказать.


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


Почему React всегда позиционировался как библиотека для View-слоя с возможностью хранить и обрабатывать данные, но никогда не предоставлял API для интеграции с внешними источниками данных, например, state managers?


И похожий вопрос: так как GraphQL тоже технология Facebook, и, к примеру, в Redux можно увидеть, что какая-то его часть встроена в React посредством hooks, планируется ли внедрять похожую историю с GraphQL, к примеру, как Relay для стандартизации? Будут ли предусмотрены какие-то API, чтобы сделать state manager в React более стандартизированным?


У меня первая реакция на такие вопросы я не знаю, что такое state management, не понимаю, что люди имеют в виду, когда говорят об этом. Может, у меня профдеформация, потому что в React-команде мы просто думаем об этом иначе.


В React уже есть понятие State, это UI-состояние, его менеджишь тем, что просто используешь State, если нужно передать глубоко, используешь контекст и все дела.


Есть вещи, которые туда класть неудобно. В основном то, что не является UI-состоянием. Я не имею в виду бизнес-логику, это ещё одно слово, значения которого я не знаю, а я имею в виду кэши. Например, если ты фетчишь данные, у тебя есть, скажем, страница Twitter Feed, и эти твиты не State.


Понятно, что обычно мы используем State, если пользуемся чистым React, или используем Redux, кладём всё туда, но так или иначе оно в конечном итоге становится реактовым State где-то внутри или копируется туда.


Но это не состояние, основанное на пользовательском вводе, а, скорее, копия чего-то, что есть на сервере. И эту копию всегда можно вытащить с сервера. Её не всегда можно редактировать локально, потому что если что-то постишь, нужно пойти на сервер тогда уже придет какой-то результат.


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


Получается, что у людей возникают проблемы вроде zombie children или нестабильного батчинга, я так понимаю, в интеграции со State-менеджерами. Также неясно, как интегрироваться с конкурентным режимом. То есть для большинства state management это не совсем тривиально.


Тут вопрос подразумевает, что вместо того чтобы пользоваться React для UI-состояния, мы пихаем его куда-то ещё и потом синхронизируем. Разложу вопрос по частям.


Первая часть была про то, что в React есть API unstable batched updates и если внутри вызова сделаешь много substates, React всё равно пройдет по дереву один раз, и не будет каких-то странных несоответствий, то, что назвали Zombie children. Это проблема, когда компонент где-то внизу и у него состояние не соответствует чему-то, что он получил по props, потому что другой компонент над ним ещё не обновился.


Эта проблема уходит, если ты делаешь батчинг. Конкурентный режим включает батчинг для всего. Unstable batched updates можно спокойно использовать в любой библиотеке, мы его используем. Это плохой пример, его не нужно было называть unstable, но сейчас уже глупо его переименовывать, потому что в конкурентном режиме это просто default.


В конкурентном режиме он вообще ничего не делает, потому что там всё всегда батчится и даже агрессивнее, чем обычно. Поэтому этой проблемы там нет.


Проблема с синхронизацией, если ты настаиваешь на том, чтобы держать UI State не в React, а где-то ещё, и при этом это не кэш (потому что с кэшами у нас будет своя история).


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


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


Поэтому одно из них если ты выносишь именно UI State, не кэш, из React в какой-то store, то у нас будет несколько стадий, как с этим интегрироваться. Первый вопрос мутабельный ли store или нет, потому что если иммутабельный, то всё прекрасно интегрируется.


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


Они необязательно будут работать с мутабельными сторами. Но у нас есть план добавить другой API если всё равно нужна мутация UI-стора вне React вместо того, чтобы просто использовать React state, мы добавим другое API, которое позволит это делать, если ты реализуешь несколько дополнительных хуков в своей библиотеке.


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


О нормализации данных на клиенте


Продолжая тему внешних источников данных, что ты думаешь о нормализации данных на клиенте, насколько это нужно? Популярные библиотеки для фетчинга, ReactQuerry, UseSWR, не нормализуют данные, в отличие от того же Apollo-клиента.


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


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


Но нормализованный кэш как в Apollo или Relay, c GraphQL, если его вручную не менеджерить, это ещё как-то терпимо. Но это накладывает ограничения на структуру данных, и вносит сложные вопросы в духе как работает инвалидация вещей, связанных в этом кэше?, сколько они живут?.


В Redux issue tracker меня года четыре назад спросили: а как часто вещи будут пропадать, если я их держу в Redux. Ну, как перезагрузишь вкладку. Тогда как-то более наивно про всё думал.


В этом смысле нормализованный кэш это то, что люди ассоциируют со state management, особенно если ты его делаешь вручную. Тогда понятно, почему используешь Redux или что-то подобное, какую-то сложную библиотеку, которой доверяешь это делать, как в Apollo.


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


Но если это данные сервера, то окей. Например, мы в команде React пришли к мнению, что оптимистичные апдейты, например, если делаешь пост и он тут же показывается это на самом деле плохо, потому что это дурачит юзера: он может подумать, что всё запостилось и уйти со страницы.


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


Мы рисерчим, и нам ближе всего денормализация, мы движемся в сторону упрощения клиента и больше работы переносим обратно на сервер. В большинстве случаев нормализованный кэш не нужен. Но это зависит от приложения: в Facebook он всё равно будет нужен, но для многих случаев, например, интернет-магазин, это необязательные вещи.


О Redux


В твоих постах периодически видно пассивно-агрессивное отношение к Redux. Почему он тебе так не нравится, учитывая, насколько удобные обёртки написали мейнтейнеры, Redux toolkit и насколько улучшилась документация?


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


Он как тест Роршаха, каждый видит там то, что видит.



Поскольку Redux ничего не предписывает, это просто присваивание переменной. И что? Он не отвечает на вопросы, не решает никаких сложных проблем. Он вносит проблемы что я там держу, как оно там хранится, как это описывать и так далее, но не дает решения, а просто даёт ощущение, что есть какая-то коробка, куда можно что-то класть. А что зависит от каждого проекта.


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


А если есть дерево, которое общается с Redux, и тут нужно сделать два независимых дерева удачи.


Люди используют его, чтобы реализовать кэш, потому что в React нет first class-концепции кэша, но каждый реализует это по-своему. Решать проблемы сложно, поэтому они обычно нерешенные. Получается мешанина из boilerplate-кода.


Я не против boilerplate-кода, который имеет какой-то смысл количество строк меня не пугает. Но это концептуальный boilerplate, когда разведены вещи, которые на самом деле тесно связаны. Короче, не нравится мне это всё!


Я думаю, что в целом это как со всеми такими вещами я не говорю, что его не надо использовать и всё такое. Мы все экспериментируем. Я вот поэкспериментировал, сделал какой-то прототип для доклада, и потом получилось, что на этом все приложения пишут.


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


Что-то как-то решается, но я думаю, что мы постепенно будем закрывать потребности какими-то действительно продуманными решениями. Дело не в том, что тебе не надо использовать Redux. Дело в том, что хочется верить, что для каждой потребности у нас будет что-то лучшее, что тебе не надо будет сваливать всё в один объект куда-то наверх.


Redux это как место, куда ты кладёшь то, что ты не знаешь, куда положить. Хочется, чтобы для всего нашлось место, и тогда туда будет нечего класть.


Я на самом деле очень жду каких-то новых решений, в последнее время всё же использовала Redux, потому что это проверенный метод. Из способов, чтобы удобнее справляться с данными, ничего больше Redux не понравилось. Плюс для него есть удобные инструменты, можно всё в браузере посмотреть, но буду только за, если появится что-то более интересное с вашей стороны для решения вопроса.


В Facebook разрабатывается state manager Recoil. Это просто ещё один независимый проект внутри компании или решение, на котором вы тестируете и которое когда-нибудь хотите внедрить взамен Redux?


Нет, это совершенно независимый проект. Он разрабатывался для конкретного продукта, потом некоторые другие продукты начали его использовать. Мы общаемся с автором, и есть возможность, что какие-то идеи из Recoil найдут место в React. Не то, что Recoil станет официальным Опять-таки, я не знаю, что такое state management.


Есть технические вопросы, которые Recoil решает лучше, чем то, что у нас есть. Вопрос в том, какая часть фундаментальная, что должно быть в самом React. Мы про это общаемся с автором. Возможно, он поработает над каким-то proof of concept, чтобы посмотреть, на что это похоже, если бы это было в React. Но никаких гарантий. Мы активно следим за всеми state managers, что появлялись за последние пять лет.


О прошлом React


У нас было про перспективу, про state managers, давай в ретроспективе. Была ли какая-то работа над React, куда компания вкладывала кучу сил, над чем-то работала, но оно не взлетело? Какая-то тупиковая ветка?


Мне трудно оценить количественно, но тоже вопрос, что такое тупик. Тупиков было довольно много, и они информируют Приведу дурацкую аналогию: это как Бильбо не прибил Голлума, а потом тот сожрал кольцо, и кольцо в итоге всё же упало, и он свою роль выполнил.


У нас обычно выполняют такую роль проекты, которые не срослись. Например, был прототип React, который работает как в Worker, на другом потоке, и в 2014 году все думали про многопоточность. По нашему опыту, это не особо интересно.


Потому что UI должен отвечать сразу, для многих событий должен быть preventDefault(), который должен быть синхронно. Похожая проблема в React Native: он всегда асинхронный, многие вещи трудно сделать.


И если есть какой-то объем работ, который нужно выполнить, то, если ты поставишь его на другой поток, он быстрее не закончится. Но теперь у тебя есть какая-то задержка, всё происходит, а экран не обновляется. Это не лучше.


Это была тупиковая ветка, но она отчасти информировала редизайн React Native. И некоторые вещи, связанные с дата-фетчингом, которые делаем сейчас, отчасти истоки берут из некоторых идей того прототипа.


Ещё пара людей из React-команды существенное время вложили в Prepack экспериментальный компилятор JavaScript в JavaScript, который исполняет JavaScript во время билда. Что достаточно странная идея (как и многие идеи Себастиана). Проект не выгорел слишком сложно, как вскипятить океан.


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


Отчасти это было одним из векторов вдохновления для хуков: если делать какой-то очень умный компилятор, это гораздо более разумная для него платформа. И с тем же Suspense, что мы сейчас делаем это тоже отчасти вдохновлено тем, что мы пытались делать с Prepack, но тогда поняли, что у него плохие характеристики и трейд-офф неправильный.


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


О хуках


Как раз-таки ты упомянул про хуки. Я встречала множество статей и обсуждений, что людям не нравятся хуки, что люди фрустрированы, что у них love-hate relationship. Понятно, что всегда найдутся недовольные какими-то решениями, но все же спустя время довольны ли вы своим выбором, было ли это удачное решение?


Хороший вопрос. Это интересно: даже когда я сам пользуюсь, у меня тоже бывает такое, что вот это клёво, а это бесит. И я ни за что не вернулся бы к классам.


Нам тоже было интересно, что люди думают и как у них ощущения. Проводили опрос, собрали где-то 15 тысяч ответов. Там было два вопроса. Один вопрос предпочитаете ли вы хуки, классы, когда как или не знаю, и 70% выбрали, что предпочитают хуки, 10% когда как, 7% классы и остальные не определились.


Другой вопрос был нужно ли использовать в продакшене хуки или классы, и у хуков около 70% было, у классов 50%. И это всего спустя два года после выхода. Уже другая парадигма


Понятно, что когда мы выпускали хуки, говорили, что всё нормально, ничего не меняется. Но по сути это уже другой API, который более правдиво выражает, что такое React, и у него есть лёгкие стороны, есть сложные. Это факт, мы не пытаемся это скрыть. И сейчас переписываем документацию, думаю, будет очень прямолинейны.


Всякие вещи типа useEffect это hard mode у React. И у этого есть причина не потому что мы не можем придумать лучший API или из-за идеологии, а потому что сделать хороший мостик между декларативным и императивным миром сложно.


Приходится думать про комбинации вещей, которые могут произойти, но сходу не придут в голову о race condition, о том, что какие-то вещи могут открыться или закрыться, данные могут поменяться на середине того, как что-то делаешь. И с классами было проще писать код, который как бы работает, но ломается в edge case.


А хуки заставляют тебя обрабатывать такие случаи. И там парадигма, что мы сразу ломаем код. Это бесит, особенно если используешь низкоуровневое типа useEffect ощущение, словно решаешь уравнение. Нужно понять, как так скомпоновать, чтобы всё работало. Но когда получается, понимаешь, что реально работает и для редких случаев, и для типичных.


Ты просто пакуешь в свой хук и всё. Все, кто его использует дальше, могут об этом не думать, потому что ты запаковал сложную императивную хрень в декларативную обёртку. Ей можно просто говорить, что ты хочешь, а она сама это выполнит. И в этом трейд-офф.


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


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


И хуки гарантия того, что всё будет работать. Потому что они так математически построены. В целом они чем-то бесят, когда перед тобой ставят задачу. Но то, что их можно использовать много раз для нас важнее.


О холиварах


Уведу от хуков немного в холивар, потому что то, что ты говоришь о хуках и сложном варианте React очень близко и Vue.js тоже, потому что у Vue есть новый composition API hard mode Vue. Его очень часто сравнивают с хуками. Даже сейчас в чатике выступления вижу зрительский вопрос о том, что есть вещи, которые Vue утаскивает у React, есть то, что React утаскивает у Vue. Есть куча вопросов, какой фреймворк лучше использовать, кто у кого что утащил, чего нет, а что у кого есть. Тебя не утомила тема о войнах фреймворков и конкуренции?


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


Например, Svelte считает, что генерировать больше кода это окей, зато там очень таргетированные апдейты. Vue считает, что это окей оборачивать данные в какие-то обёртки, и это того стоит, хотя мы тратим больше времени на тот же mount, потому что там будут какие-то апдейты.


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


Это разные трейд-оффы.


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


Это в целом. Всегда есть парадигма у каждого у Svelte, у Vue, они на самом деле похожи, у Angular и React какая-то парадигма. И всегда будет один фреймворк каждой парадигмы, потому что у каждой парадигмы есть свои сильные и слабые стороны, на которых можно что-то интересное строить.


Поэтому конкуренция будет, есть разные идеи, и у каждой есть свои сильные стороны. Холиварные статьи меня никак не колышут, мне всё равно если можем сделать React лучше, меня это устраивает.


Это скорее было чтобы поделиться болью, потому что это точно такие же вопросы, которые любят задавать и представителям Vue, и представителям Angular. Теперь и у тебя спросили.


Насколько я знаю, Vue уже переписали на TypeScript. Когда, может быть, React перепишут с Flow на TypeScript?


Зачем? Я не то, чтобы какой-то адский фанат Flow, мне и то и другое по большей части по барабану, но это звучит как проект, в котором я не вижу смысла. На definitions, которые публичные, это никак не влияет. Наши внутренние типы всё равно будут другие, потому что то, как ты используешь React, отличается от того, как он реализован. Поэтому это звучит как большой проект, который не даёт для нас никакого выхлопа, и главное для наших юзеров.


Для нас это может дать лучшую IDE-интеграцию, но я не пользуюсь IDE, так что мне всё равно. Контрибьюторов у нас не так много, контрибьютить в core довольно сложно, потому что нужно много контекста для понимания, почему определенные вещи сделаны таким путём. Поэтому смысла, наверное, нет. Если и будем переписывать, то как-то более фундаментально, на тот же Rust или что-то такое. Конкретных планов пока нет.


О личном


Может быть, поговорим о чем-то более личном? Я знаю, что Наташа недавно переехала в Амстердам, я недавно уехала из Лондона. Хотелось бы спросить у Дэна может, ты что-то посоветуешь людям, которые хотят переехать и работать за границей? Может, есть какие-то моменты, на которые, как ты считаешь, нужно обратить внимание?


У меня всё случилось очень быстро, я сам не особо понял, что произошло. Мне было гораздо проще, потому что это большая компания, с агентством по релокации. Есть люди, которые тебя пилят: пришли тот документ, другой. В этом плане проще, чем если бы я сам всё делал.


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


А по коммуникации с командой, с людьми другой культуры возникали сложности?


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


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



У тебя в сентябре был твит, что тебе сейчас не нравится работать над каким-то проектами ничего не увлекает особо. Это изменилось, тебе нравится работать над чем-то сейчас, ждешь чего-то увлеченно?


Отчасти да, есть какие-то вещи, над которыми мы работаем. Они набирают обороты, мне интересно за ними следить и немного тоже помогать. Плюс мы с Рейчел (Rachel Nabors прим. ред.) переписываем с нуля документацию React.


Но у меня всё равно чувство вверх-вниз, вверх-вниз, когда в один день кажется классным, что написали, а в другой кажется стрёмным. Чистый лист, вот это всё. Думаю, что сейчас время сложное: я привык, что можно как-то развеяться, куда-то пойти, съездить. А сейчас всё одинаковое, и это немного задолбало.


А в Лондоне сейчас локдаун продолжается?


Да, сейчас собираются снимать, но вместо него вводят другой, с какими-то уровнями. Как-то всё ужасно некомпетентно, мне кажется.


Ты упомянул документацию. Как человек, который работает над документацией в Vue, не могу не спросить ты сказал, что вы всё переписываете с Рейчел, вы только вдвоем работаете над докой? Как вообще организована работа над докой? Кто что пишет?


Мы начинаем только вдвоём, потому что я просто не готов к какому-то фидбэку, чтобы тысячи людей что-то мне писали. Я верю, что мы вдвоем уж совсем какую-то ерунду не напишем, наверно. И первую фазу, главные темы будем писать закрыто для сохранения нашего рассудка. Потом будет какой-то бранч, и может быть, будем брать какие-то контрибьюшны.


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


Я не знаю, насколько эффективно построен процесс. Мы написали аутлайн, какие темы от каких зависят, что нужно поставить вперёд, у нас есть список тем, где-то 4050. Пока мы делаем так: я пишу болванку, которая минимально описывает тему и даёт какие-то челленджи, чтобы проверить, понята тема или нет.


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


Это очень похоже на то, как мы писали доку Vue 3. Мы тоже держали её в закрытом репозитории, так что я полностью разделяю эту идею. Хотя в какой-то момент люди начали комплейнить, но это была новая версия. А тут версия та же.


Да, только когда мы её закончим, там уже может быть новая версия (смеётся).


Это долгосрочный проект, но есть ли очень приблизительная оценка, когда планируете закончить?


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


Этот разговор состоялся на ноябрьской HolyJS а мы сейчас вовсю готовим следующую HolyJS, которая пройдёт 20-23 апреля. Как в случае с этим интервью некоторые вопросы были от зрителей, так и на новой конференции важной частью будут чаты и видеосессии, где зрители могут пообщаться со спикерами и друг с другом. Часть программы уже известна увидеть описания докладов и узнать всё остальное можно на сайте.
Подробнее..

React Server Components что это?

28.12.2020 00:15:11 | Автор: admin

Буквально неделю назад, команда реакт опубликовала в своем блоге о новом RFC. Давайте разберемся, что это за зверь и зачем он нужен.


Что это

Как понятно из названия React Server Components - это компоненты которые исполняются на сервере. Теперь у нас есть несколько видов компонентов:

  • Клиентские компоненты

  • Серверные компоненты

  • Гибридные компоненты

Клиентские компоненты

Это те компоненты которые мы пишем сейчас, используя состояние и эффекты, они позволяют взаимодействовать с пользователем и исполняются в браузере. До этого момента у нас существовали только клиентские компоненты. Теперь клиентские компоненты имеют постфикс в названии .client.js

Серверные компоненты

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

Гибридные компоненты

Эти компоненты могут исполняться как на сервере так и на клиенте. У них самые сильные ограничения. Они не могут использовать клиентские хуки и серверную инфраструктуру. Фактически могут только содержать JSX разметку.

Различие с SSR

Дочитав до этого момента многие из вас спросят себя, а в чем различие с SSR и инструментами вроде Next.js. Ведь реакт и раньше умел исполняться на сервере, верно? Не совсем так. Различие заключается в том, что SSR возвращает нам HTML разметку, которую после этого необходимо гидрировать. Чаще всего SSR используется для первой загрузки приложения, чтобы пользователь не видел белый экран. При этом Server Components возвращает JSON структуру части virtual dom.

Картинка слева, это пример того, что передается по сети при использовании server components.

На данный момент ничего не сказано о совмещении SSR и server components, но я думаю в будущем эти два подхода можно будет совмещать.

Пример использования

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

При готовом окружении, чтобы начать использовать серверные компоненты, необходимо создать файл с постфиксом .server.js

// Note.server.js - Server Componentimport db from 'db.server'; // (A1) We import from NoteEditor.client.js - a Client Component.import NoteEditor from 'NoteEditor.client';function Note(props) {  const {id, isEditing} = props;  // (B) Can directly access server data sources during render, e.g. databases  const note = db.posts.get(id);  return (    <div>      <h1>{note.title}</h1>      <section>{note.body}</section>      {/* (A2) Dynamically render the editor only if necessary */}      {isEditing         ? <NoteEditor note={note} />        : null      }    </div>  );}

Вот мы и создали первый серверный компонент.

Зачем нужно

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

Zero-Bundle-Size Components

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

// NoteWithMarkdown.js// NOTE: *before* Server Componentsimport marked from 'marked'; // 35.9K (11.2K gzipped)import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)function NoteWithMarkdown({text}) {  const html = sanitizeHtml(marked(text));  return (/* render */);}

При это к размеру bundle добавиться 74кб дополнительно, но если этот компонент мы превратим с server components, то получим следующее:

// NoteWithMarkdown.server.js - Server Component === zero bundle sizeimport marked from 'marked'; // zero bundle sizeimport sanitizeHtml from 'sanitize-html'; // zero bundle sizefunction NoteWithMarkdown({text}) {  const html = sanitizeHtml(marked(text));  return (/* render */);}

В данном примере изменилось только название файла, но при этом мы экономим 74кб исполняя данную часть кода на сервере.

Полный доступ к Backend

Так как компоненты исполняются на сервере, можно получать полный доступ к окружению сервера:

// Note.server.js - Server Componentimport fs from 'react-fs';function Note({id}) {  const note = JSON.parse(fs.readFile(`${id}.json`));  return <NoteWithMarkdown note={note} />;}
// Note.server.js - Server Componentimport db from 'db.server';function Note({id}) {  const note = db.notes.get(id);  return <NoteWithMarkdown note={note} />;}

Только для доступа к окружения сервера, требуются обертки для реакта, на данный момент команда реакта разработала 3 обертки:

  • react-fs - Обертка для работы с файлами

  • react-fetch - Обертка для работы с сетью

  • react-pg - Обертка для работы с PostgresSql

Думаю в ближайшем будущем, у нас появится API с помощью которого можно будет создавать собственный обертки для работы с server component.

Автоматический Code Splitting

Если вы работаете с реакт, то вы уже знакомы с понятием Code Splitting. Это процесс когда компоненты загружаются по необходимости, для того, чтобы уменьшить размер нашего bundle мы используем динамические модули и React.lazy:

// PhotoRenderer.js// NOTE: *before* Server Componentsimport React from 'react';// one of these will start loading *when rendered on the client*:const OldPhotoRenderer = React.lazy(() => import('./OldPhotoRenderer.js'));const NewPhotoRenderer = React.lazy(() => import('./NewPhotoRenderer.js'));function Photo(props) {  // Switch on feature flags, logged in/out, type of content, etc:  if (FeatureFlags.useNewPhotoRenderer) {    return <NewPhotoRenderer {...props} />;   } else {    return <OldPhotoRenderer {...props} />;  }}

При использовании Server Components, мы получаем данную возможность по умолчанию:

// PhotoRenderer.server.js - Server Componentimport React from 'react';// one of these will start loading *once rendered and streamed to the client*:import OldPhotoRenderer from './OldPhotoRenderer.client.js';import NewPhotoRenderer from './NewPhotoRenderer.client.js';function Photo(props) {  // Switch on feature flags, logged in/out, type of content, etc:  if (FeatureFlags.useNewPhotoRenderer) {    return <NewPhotoRenderer {...props} />;  } else {    return <OldPhotoRenderer {...props} />;  }}

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

Отсутствие client-server водопадов

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

Такой подход ухудшает пользовательский опыт при использовании приложения. Существуют разные решения, чаще всего они касаются API. Например graphql/JSON API и другие.

Чаще всего мы пишем компоненты следующим образом:

// Note.js// NOTE: *before* Server Componentsfunction Note(props) {  const [note, setNote] = useState(null);  useEffect(() => {    // NOTE: loads *after* rendering, triggering waterfalls in children    fetchNote(props.id).then(noteData => {      setNote(noteData);    });  }, [props.id]);  if (note == null) {    return "Loading";  } else {    return (/* render note here... */);  }}

Тут мы загрузили сам компонент Note, после этого выполнили рендер и только после этого запросили данные с сервера. Теперь с server components мы можем это делать не последовательно, а одновременно:

// Note.server.js - Server Componentfunction Note(props) {  // NOTE: loads *during* render, w low-latency data access on the server  const note = db.notes.get(props.id);  if (note == null) {    // handle missing note  }  return (/* render note here... */);}

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

Итог

На мой взгляд server components имеет место быть, сочетая Suspence, Concurent Mode и Server Components можно гибко для разработчиков и удобно для пользователя реализовывать UI.

Не забывайте это RFC и подход, реализации и API может поменяться до официального релиза.

Что думаете по поводу Server Components?

Дополнительный материал

Если хотите более детально погрузиться в тему Server Components.

Выступление Дэна Абрамова

Документация RFC

Пример приложения

Подробнее..
Категории: Html , Javascript , React , Reactjs , Frontend , Redux

Перевод Как управлять состоянием React приложения без сторонних библиотек

21.06.2020 16:16:20 | Автор: admin

image


Реакт это все что вам нужно для управления состоянием вашего приложения.


Управление состоянием это одна из сложнейших задач при разработки приложения. Вот почему каждый день появляются все новые и новые библиотеки для управления состоянием, их становиться все больше и больше, причем многие из них разрабатываются поверх уже существующих решений. В npm вы можете найти сотни "упрощенных Redux" библиотек. Однако, несмотря на то что управлять состоянием сложно, одной из причин того почему так получилось стало именно то что мы слишком переусложняем решение проблемы.


Существует метод управления состоянием который лично я пытаюсь применять еще с тех пор как я начал использовать Реакт. И теперь, после релиза хуков (hooks) и улучшения контекстов (context), этот метод управления состояниями стало очень просто использовать.


О компонентах Реакта часто говорят как о детальках Лего конструктора, из которых мы собираем наши приложения. Но эту аналогию можно применить не только к компонентам, но и к состоянию приложения. "Секрет" моего подхода к управлению состоянием в том что состояние приложения должно соответствовать структуре самого приложения.


Причиной популярности Редакса (redux), помимо прочего, стало то что react-redux решал проблему проп дриллинга (prop drilling). Редакс позволил обмениваться данными между различными частями дерева компонентов, просто передавая компонент в магическую функцию connect. Другие возможности Редакса редюсеры, экшены и прочее, конечно хороши, но я уверен что повсеместное использование Редакса связано именно с тем, что он позволил избавиться от проп дриллинга.


Проп дриллинг это такой анти-паттерн при котором передача пропсов происходит через промежуточные компоненты которые не используют получаемые пропсы, а только передают их в следующие компоненты.


Но применение Редакса может привести к различным проблемам. Я часто вижу как разработчики переносят все состояния приложения в Редакс. Не только глобальное состояние, но и локальные. Это приводит к тому что когда вы создаете любое взаимодействие с состоянием, оно запускает взаимодействие с редюсерами, генераторами/типами экшенов и вызовами dispatch (dispatch calls). Из-за этого, просто чтобы понять как и какие стейты оказывают влияние на приложение, вам нужно открывать кучу файлов и отслеживать весь написанный там код.


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


Держать все состояние вашего приложения в одном объекте не лучшая идея и может привести к другим проблемам (в том числе если вы не используете для этого Редакс). Когда Реакт <Context.Provider> получает новое значение, все компоненты, которые используют это значение, обновляются и запускают рендер, даже если это функциональный компонент который отвечает только за какую-то часть данных. Это может привести к проблемам с производительностью. Что я хочу сказать у вас не будет подобных проблем если ваше состояние разделено и находится в дереве компонентов Реакта таким образом чтобы быть как можно ближе к тем местам к которым это состояние и относиться.




Тут вот какое дело если вы создаете приложение при помощи React, у вас уже установлен пакет для управления состоянием. Чтобы использовать его, вам не нужно применять npm install или yarn add. Этот пакет не добавляет лишних байтов в ваше приложение, он уже интегрирован со всеми библиотеками для Реакта, и он уже хорошо документирован командой Реакта. Это сам Реакт.


Реакт это библиотека для управления состоянием

Когда вы создаете приложение при помощи Реакта, вы собираете множество компонентов, чтобы создать дерево компонентов. Вы начинаете с вашего <App /> и заканчиваете низкоуровневыми <input />, <div /> и <button />. Вы не управляете всеми низкоуровневыми составными компонентами, которые ваше приложение рендерит, из какого-то централизованного места. Вместо этого вы позволяете каждому отдельному компоненту управлять им. Как оказалось, это действительно простой и эффективный способ создания UI.


Такой же подход можно применить и к состоянию:


function Counter() {  const [count, setCount] = React.useState(0)  const increment = () => setCount(c => c + 1)  return <button onClick={increment}>{count}</button>}function App() {  return <Counter />}

Edit React Codesandbox


Имейте в виду что все, о чем здесь идет речь, работает и с классами. Хуки просто упрощают работу (особенно работу с контекстом, вскоре мы рассмотрим и такой вариант).


class Counter extends React.Component {  state = {count: 0}  increment = () => this.setState(({count}) => ({count: count + 1}))  render() {    return <button onClick={this.increment}>{this.state.count}</button>  }}

"Окей, это конечно легко управлять одним элементом состояния в одном компоненте, но что если мне нужно разделить это состояние между компонентами? Например, что, если я хочу сделать это:


function CountDisplay() {  // откуда нам брать значение для `count`?  return <div>The current counter count is {count}</div>}function App() {  return (    <div>      <CountDisplay />      <Counter />    </div>  )}

"Управление состоянием для подсчета значения происходит внутри <Counter />, выходи, теперь мне нужна библиотека управления состоянием, чтобы получить доступ к значению count для <CountDisplay /> и для его обновлений в <Counter />!"


Ответ этот вопрос настолько же стар, настолько стар и сам Реакт (или старше?), и был в документации столько, сколько я себя помню: Подъём состояния


Подъём состояния (Lifting State Up) это надежный и рекомендуемый способ управления состоянием в Реакте. Вот каким образом можно применять его:


function Counter({count, onIncrementClick}) {  return <button onClick={onIncrementClick}>{count}</button>}function CountDisplay({count}) {  return <div>The current counter count is {count}</div>}function App() {  const [count, setCount] = React.useState(0)  const increment = () => setCount(c => c + 1)  return (    <div>      <CountDisplay count={count} />      <Counter count={count} onIncrementClick={increment} />    </div>  )}

Edit React Codesandbox


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


"Да, конечно, но что на счет проблемы проп дриллинга (prop drilling)?"


На самом деле это проблема у которой уже довольно давно есть решение контексты (context). На протяжении долгого времени люди применяли react-redux из-за предупреждений в документации Реакта об использовании контекстов. Но сейчас контексты это официально поддерживаемая часть React API, и мы можем использовать их напрямую:


// src/count/count-context.jsimport React from 'react'const CountContext = React.createContext()function useCount() {  const context = React.useContext(CountContext)  if (!context) {    throw new Error(`useCount must be used within a CountProvider`)  }  return context}function CountProvider(props) {  const [count, setCount] = React.useState(0)  const value = React.useMemo(() => [count, setCount], [count])  return <CountContext.Provider value={value} {...props} />}export {CountProvider, useCount}// src/count/page.jsimport React from 'react'import {CountProvider, useCount} from './count-context'function Counter() {  const [count, setCount] = useCount()  const increment = () => setCount(c => c + 1)  return <button onClick={increment}>{count}</button>}function CountDisplay() {  const [count] = useCount()  return <div>The current counter count is {count}</div>}function CountPage() {  return (    <div>      <CountProvider>        <CountDisplay />        <Counter />      </CountProvider>    </div>  )}

Edit React Codesandbox


ПРИМЕЧАНИЕ. Этот код является просто примером, и я НЕ рекомендую использовать контекст для решения конкретно этой проблемы. В данном случае более простым решением стало бы просто передача состояний через пропсы (подробнее здесь: Prop Drilling). Не нужно применять контексты там где можно обойтись более простыми методами.

Одна из крутейших особенностей этого решения заключается в том что мы можем абстрагировать всю логику которую часто применяем для обновления состояния в наш useContext хук:


function useCount() {  const context = React.useContext(CountContext)  if (!context) {    throw new Error(`useCount must be used within a CountProvider`)  }  const [count, setCount] = context  const increment = () => setCount(c => c + 1)  return {    count,    setCount,    increment,  }}

Edit React Codesandbox


При желании можно поменять useState на useReducer:


function countReducer(state, action) {  switch (action.type) {    case 'INCREMENT': {      return {count: state.count + 1}    }    default: {      throw new Error(`Unsupported action type: ${action.type}`)    }  }}function CountProvider(props) {  const [state, dispatch] = React.useReducer(countReducer, {count: 0})  const value = React.useMemo(() => [state, dispatch], [state])  return <CountContext.Provider value={value} {...props} />}function useCount() {  const context = React.useContext(CountContext)  if (!context) {    throw new Error(`useCount must be used within a CountProvider`)  }  const [state, dispatch] = context  const increment = () => dispatch({type: 'INCREMENT'})  return {    state,    dispatch,    increment,  }}

Edit React Codesandbox


Это дает нам гибкость и уменьшает сложность кода. Вот о чем следует помнить когда вы так делаете:


  1. Не нужно держать все в одном объекте состояния. Разделяйте логику состояния, используйте разные контексты для разных ситуаций, например, пользовательские настройки не обязательно должны находиться в контексте в котором уже находятся уведомления.


  2. Не нужно делать все контексты глобальными! Держите состояние как можно ближе к месту к которому оно относиться.



Подробнее о втором пункте. Структура вашего приложения может выглядеть примерно так:


function App() {  return (    <ThemeProvider>      <AuthenticationProvider>        <Router>          <Home path="/" />          <About path="/about" />          <UserPage path="/:userId" />          <UserSettings path="/settings" />          <Notifications path="/notifications" />        </Router>      </AuthenticationProvider>    </ThemeProvider>  )}function Notifications() {  return (    <NotificationsProvider>      <NotificationsTab />      <NotificationsTypeList />      <NotificationsList />    </NotificationsProvider>  )}function UserPage({username}) {  return (    <UserProvider username={username}>      <UserInfo />      <UserNav />      <UserActivity />    </UserProvider>  )}function UserSettings() {  // это специальный кастомный хук для AuthenticationProvider  const {user} = useAuthenticatedUser()}

Обратите внимание что у каждой страницы может быть свой собственный провайдер (provider) контекста, который передает данные необходимые компоненту находящемуся под ним. При таком подходе разделение кода (Code-Splitting) работает само по себе. То, как вы передаете данные в каждый провайдер, зависит от того как эти провайдеры используют хуки, и от того каким образом вы извлекаете данные в своем приложении. В любом случае чтобы понять как работает ваш контекст, в первую очередь вам нужно посмотреть в код компонента-провайдера.


Если хотите узнать больше о том что такое "совместное размещение", читайте статью State Colocation will make your React app faster (на русском как сделать React приложение быстрее при помощи совместного размещения состояний
) и Colocation. А если интересно почитать больше о работе с контекстами, читайте статью How to use React Context effectively


Кэш Сервера против Состояние Интерфейса


Существуют различные виды состояний приложения, но каждый тип состояния можно отнести к одной из этих категорий:


  1. Кэш Сервера (Server Cache) состояние которое размещено на сервере для быстрого доступа к нему на клиенте (к примеру данные пользователя).


  2. Состояние Интерфейса (UI State) состояние в котором есть смысл только в интерфейсе пользователя, оно нужно для управления интерактивными частями приложения (к примеру, открытие модального окна modal isOpen)



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


Вы определенно можете управлять им при помощи ваших собственных useState или useReducer, с правильными useContext там и тут. Но, имейте в виду, кэширование это очень сложная проблема (некоторые говорят что это одна из сложнейших проблем в информатике), так что, касательно этого вопроса, будет разумно "встать на плечи гигантов".


Я сам использую, и всячески рекомендую библиотеку react-query для подобных состояний. Знаю, знаю, я сам сказал что вам не нужны библиотеки для управления состоянием, но, я не считаю что react-query это библиотека для управления состоянием. Я считаю что это библиотека для управления кэшем. И она офигенно хороша. Попробуйте ее. Этот парень Tanner Linsley весьма умен.


Заключение


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

Подробнее..

Redux store vs React state

02.07.2020 22:10:39 | Автор: admin
Как спроектировать хранение данных в React-приложении? Где хранить данные приложения: в глобальном хранилище (Redux store) или в локальном хранилище (component state)?
Такие вопросы возникают у разработчиков, начинающих использовать библиотеку Redux, и даже у тех, кто ей активно пользуется.
Мы в BENOVATE за 5 лет разработки на React опробовали на практике различные подходы к построению архитектуры таких приложений. В статье рассмотрим возможные критерии для выбора места хранения данных в приложении.

А может быть совсем без Redux? Да, если вы можете обойтись без него. На эту тему можете ознакомиться со статьей от одного из создателей библиотеки Дэна Абрамова. Если разработчик понимает, что без Redux не обойтись, то, можно выделить несколько критериев для выбора хранилища данных:

  1. Продолжительность жизни данных
  2. Частота использования
  3. Возможность отслеживания изменений в state


Продолжительность жизни данных


Можно выделить 2 категории:
  • Часто изменяющиеся данные.
  • Редко изменяющиеся данные. Такие данные редко изменяются во время непосредственной работы пользователя с приложением или между сеансами работы с приложением.


Часто изменяющиеся данные


К этой категории относятся, например, параметры фильтрации, сортировки и постраничной навигации компонента, реализующего работу со списком объектов, или флаг, отвечающий за отображение отдельных UI-элементов в приложении, например, выпадающий список или модальное окно (при условии, что оно не привязано к пользовательским настройкам). Сюда же можно отнести и данные заполняемой формы, пока они не отправлены на сервер.
Такие данные лучше хранить в state компонента, т.к. они захламляют глобальное хранилище и усложняют работу с ними: надо писать actions, reducers, инициализировать state и вовремя его очищать.

Bad example
import React from 'react';import { connect } from 'react-redux';import { toggleModal } from './actions/simpleAction'import logo from './logo.svg';import './App.css';import Modal from './elements/modal';const  App = ({                  openModal,                  toggleModal,              }) => {    return (        <div className="App">            <header className="App-header">                <img src={logo} className="App-logo" alt="logo" />            </header>            <main className="Main">                <button onClick={() => toggleModal(true)}>{'Open  Modal'}</button>            </main>            <Modal isOpen={openModal} onClose={() => toggleModal(false)} />        </div>    );}const mapStateToProps = (state) => {    return {        openModal: state.simple.openModal,    }}const mapDispatchToProps = { toggleModal }export default connect(    mapStateToProps,    mapDispatchToProps)(App)// src/constants/simpleConstants.jsexport const simpleConstants = {    TOGGLE_MODAL: 'SIMPLE_TOGGLE_MODAL',};// src/actions/simpleAction.jsimport { simpleConstants} from "../constants/simpleConstants";export const toggleModal = (open) => (    {        type: simpleConstants.TOGGLE_MODAL,        payload: open,    });// src/reducers/simple/simpleReducer.jsimport { simpleConstants } from "../../constants/simpleConstants";const initialState = {    openModal: false,};export function simpleReducer(state = initialState, action) {    switch (action.type) {        case simpleConstants.TOGGLE_MODAL:            return {                ...state,                openModal: action.payload,            };        default:            return state;    }}


Good example
import React, {useState} from 'react';import logo from './logo.svg';import './App.css';import Modal from './elements/modal';const  App = () => {  const [openModal, setOpenModal] = useState(false);  return (    <div className="App">      <header className="App-header">        <img src={logo} className="App-logo" alt="logo" />      </header>      <main className="Main">          <button onClick={() => setOpenModal(true)}>{'Open  Modal'}</button>      </main>      <Modal isOpen={openModal} onClose={() => setOpenModal(false)} />    </div>  );}export default App;



Редко изменяющиеся данные


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

Bad example
// App.jsimport React from 'react';import './App.css';import Header from './elements/header';import ProfileEditForm from './elements/profileeditform';const  App = () => {  return (    <div className="App">      <Header />      <main className="Main">          <ProfileEditForm />      </main>    </div>  );}export default App;// src/elements/header.jsimport React from "react";import logo from "../logo.svg";import Menu from "./menu";export default () => (    <header className="App-header">        <img src={logo} className="App-logo" alt="logo" />        <Menu />    </header>)// src/elements/menu.jsimport React, {useEffect, useState} from "react";import { getUserInfo } from '../api';const Menu = () => {    const [userInfo, setUserInfo] = useState({});    useEffect(() => {        getUserInfo().then(data => {            setUserInfo(data);        });    }, []);    return (        <>            <span>{userInfo.userName}</span>            <nav>                <ul>                    <li>Item 1</li>                    <li>Item 2</li>                    <li>Item 3</li>                    <li>Item 4</li>                </ul>            </nav>        </>    )}export default Menu;// src/elements/profileeditform.jsimport React, {useEffect, useState} from "react";import {getUserInfo} from "../api";const ProfileEditForm = () => {    const [state, setState] = useState({        isLoading: true,        userName: null,    })    const setName = (e) => {        const userName = e.target.value;        setState(state => ({            ...state,            userName,        }));    }    useEffect(() => {        getUserInfo().then(data => {            setState(state => ({                ...state,                isLoading: false,                userName: data.userName,            }));        });    }, []);    if (state.isLoading) {        return null;    }    return (        <form>            <input type="text" value={state.userName} onChange={setName} />            <button>{'Save'}</button>        </form>    )}export default ProfileEditForm;


Good example
// App.jsimport React, {useEffect} from 'react';import {connect} from "react-redux";import './App.css';import Header from './elements/header';import ProfileEditForm from './elements/profileeditform';import {loadUserInfo} from "./actions/userAction";const  App = ({ loadUserInfo }) => {  useEffect(() => {      loadUserInfo()  }, [])  return (    <div className="App">      <Header />      <main className="Main">          <ProfileEditForm />      </main>    </div>  );}export default connect(    null,    { loadUserInfo },)(App);// src/elements/header.jsimport React from "react";import logo from "../logo.svg";import Menu from "./menu";export default () => (    <header className="App-header">        <img src={logo} className="App-logo" alt="logo" />        <Menu />    </header>)// src/elements/menu.jsimport React from "react";import { connect } from "react-redux";const Menu = ({userName}) => (    <>        <span>{userName}</span>        <nav>            <ul>                <li>Item 1</li>                <li>Item 2</li>                <li>Item 3</li>                <li>Item 4</li>            </ul>        </nav>    </>)const mapStateToProps = (state) => {    return {        userName: state.userInfo.userName,    }}export default connect(    mapStateToProps,)(Menu);// src/elements/profileeditform.jsimport React from "react";import { changeUserName } from '../actions/userAction'import {connect} from "react-redux";const ProfileEditForm = ({userName, changeUserName}) => {    const handleChange = (e) => {        changeUserName(e.target.value);    };    return (        <form>            <input type="text" value={userName} onChange={handleChange} />            <button>{'Save'}</button>        </form>    )}const mapStateToProps = (state) => {    return {        userName: state.userInfo.userName,    }}const mapDispatchToProps = { changeUserName }export default connect(    mapStateToProps,    mapDispatchToProps,)(ProfileEditForm);// src/constants/userConstants.jsexport const userConstants = {    SET_USER_INFO: 'USER_SET_USER_INFO',    SET_USER_NAME: 'USER_SET_USER_NAME',    UNDO: 'USER_UNDO',    REDO: 'USER_REDO',};// src/actions/userAction.jsimport { userConstants } from "../constants/userConstants";import { getUserInfo } from "../api/index";export const changeUserName = (userName) => (    {        type: userConstants.SET_USER_NAME,        payload: userName,    });export const setUserInfo = (data) => (    {        type: userConstants.SET_USER_INFO,        payload: data,    })export const loadUserInfo = () => async (dispatch) => {    const result = await getUserInfo();    dispatch(setUserInfo(result));}// src/reducers/user/userReducer.jsimport { userConstants } from "../../constants/userConstants";const initialState = {    userName: null,};export function userReducer(state = initialState, action) {    switch (action.type) {        case userConstants.SET_USER_INFO:            return {                ...state,                ...action.payload,            };        case userConstants.SET_USER_NAME:            return {                ...state,                userName: action.payload,            };        default:            return state;    }}



Частота использования


Второй критерий сколько компонентов в React-приложении должно иметь доступ к одному и тому же state. Чем больше компонентов используют одни и те же данные в state, тем больше пользы от использования Redux store.
Если вы понимаете, что для определенного компонента или небольшой части вашего приложения state изолирован, то лучше использовать React state отдельного компонента или HOC-компонент.

Глубина передачи state


В приложениях без Redux данные React state должны храниться в самом верхнем (в дереве) компоненте, дочерним компонентам которого потребуется доступ к этим данным, в предположении, что мы избегаем хранения одинаковых данных в разных местах.
Иногда данные из state родительского компонента требуются большому количеству дочерних компонентов на разных уровнях вложенности, что приводит к сильному зацеплению компонентов и появлению в них бесполезного кода, который накладно редактировать каждый раз, когда вы обнаружите, что дочернему компоненту требуется доступ к новым данным state. В таких случаях разумнее сохранить state в Redux и извлекать нужные данные из хранилища в соответствующих компонентах.

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

Bad example
//App.jsimport React from 'react';import './App.css';import Header from './elements/header';import MainContent from './elements/maincontent';const  App = ({userName}) => {  return (    <div className="App">      <Header userName={userName} />      <main className="Main">          <MainContent />      </main>    </div>  );}export default App;// ./elements/header.jsimport React from "react";import logo from "../logo.svg";import Menu from "./menu";export default ({ userName }) => (    <header className="App-header">        <img src={logo} className="App-logo" alt="logo" />        <Menu userName={userName} />    </header>)// ./elements/menu.jsimport React from "react";export default ({userName}) => (    <>        <span>{userName}</span>        <nav>            <ul>                <li>Item 1</li>                <li>Item 2</li>                <li>Item 3</li>                <li>Item 4</li>            </ul>        </nav>    </>)


Good example
// App.jsimport React from 'react';import './App.css';import Header from './elements/header';import MainContent from './elements/maincontent';const  App = () => {  return (    <div className="App">      <Header />      <main className="Main">          <MainContent />      </main>    </div>  );}export default App;//./elements/header.jsimport React from "react";import logo from "../logo.svg";import Menu from "./menu";export default () => (    <header className="App-header">        <img src={logo} className="App-logo" alt="logo" />        <Menu />    </header>)//./elements/menu.jsimport React from "react";import { connect } from "react-redux";const Menu = ({userName}) => (    <>        <span>{userName}</span>        <nav>            <ul>                <li>Item 1</li>                <li>Item 2</li>                <li>Item 3</li>                <li>Item 4</li>            </ul>        </nav>    </>)const mapStateToProps = (state) => {    return {        userName: state.userInfo.userName,    }}export default connect(    mapStateToProps,)(Menu)



Несвязанные компоненты, оперирующие одинаковыми данными в state


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

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

Можно сделать проще: сохраняем данные профиля пользователя в Redux store, и позволяем компоненту контейнера header и компоненту редактирования профиля получать и изменять данные в Redux store.

image
Bad example
// App.jsimport React, {useState} from 'react';import './App.css';import Header from './elements/header';import ProfileEditForm from './elements/profileeditform';const  App = ({user}) => {  const [userName, setUserName] = useState(user.user_name);  return (    <div className="App">      <Header userName={userName} />      <main className="Main">          <ProfileEditForm onChangeName={setUserName} userName={userName} />      </main>    </div>  );}export default App;// ./elements/header.jsimport React from "react";import logo from "../logo.svg";import Menu from "./menu";export default ({ userName }) => (    <header className="App-header">        <img src={logo} className="App-logo" alt="logo" />        <Menu userName={userName} />    </header>)// ./elements/menu.jsimport React from "react";const Menu = ({userName}) => (    <>        <span>{userName}</span>        <nav>            <ul>                <li>Item 1</li>                <li>Item 2</li>                <li>Item 3</li>                <li>Item 4</li>            </ul>        </nav>    </>)export default Menu;// ./elements/profileeditform.jsimport React from "react";export default ({userName, onChangeName}) => {    const handleChange = (e) => {        onChangeName(e.target.value);    };    return (        <form>            <input type="text" value={userName} onChange={handleChange} />            <button>{'Save'}</button>        </form>    )}


Good example
// App.jsimport React from 'react';import './App.css';import Header from './elements/header';import ProfileEditForm from './elements/profileeditform';const  App = () => {  return (    <div className="App">      <Header />      <main className="Main">          <ProfileEditForm />      </main>    </div>  );}export default App;//./elements/header.jsimport React from "react";import logo from "../logo.svg";import Menu from "./menu";export default () => (    <header className="App-header">        <img src={logo} className="App-logo" alt="logo" />        <Menu />    </header>)//./elements/menu.jsimport React from "react";import { connect } from "react-redux";const Menu = ({userName}) => (    <>        <span>{userName}</span>        <nav>            <ul>                <li>Item 1</li>                <li>Item 2</li>                <li>Item 3</li>                <li>Item 4</li>            </ul>        </nav>    </>)const mapStateToProps = (state) => {    return {        userName: state.userInfo.userName,    }}export default connect(    mapStateToProps,)(Menu)//./elements/profileeditformimport React from "react";import { changeUserName } from '../actions/userAction'import {connect} from "react-redux";const ProfileEditForm = ({userName, changeUserName}) => {    const handleChange = (e) => {        changeUserName(e.target.value);    };    return (        <form>            <input type="text" value={userName} onChange={handleChange} />            <button>{'Save'}</button>        </form>    )}const mapStateToProps = (state) => {    return {        userName: state.userInfo.userName,    }}const mapDispatchToProps = { changeUserName }export default connect(    mapStateToProps,    mapDispatchToProps,)(ProfileEditForm)



Возможность отслеживания изменений в state


Другой случай: вам требуется реализовать возможность отменять/повторять пользовательские операции в приложении или вы просто хотите логировать изменения state.
Такая необходимость возникла у нас при разработке конструктора учебных пособий, с помощью которого пользователь может добавлять и настраивать блоки с текстом, изображением и видео на страницу пособия, а также может выполнять операции Undo/Redo.
В подобных случаях Redux отличное решение, т.к. каждый созданный action является атомарным изменением state. Redux упрощает все эти задачи, сосредотачивая их в одном месте Redux store.

Undo/redo example
// App.jsimport React from 'react';import './App.css';import Header from './elements/header';import ProfileEditForm from './elements/profileeditform';const  App = () => {  return (    <div className="App">      <Header />      <main className="Main">          <ProfileEditForm />      </main>    </div>  );}export default App;// './elements/profileeditform.js'import React from "react";import { changeUserName, undo, redo } from '../actions/userAction'import {connect} from "react-redux";const ProfileEditForm = ({ userName, changeUserName, undo, redo, hasPast, hasFuture }) => {    const handleChange = (e) => {        changeUserName(e.target.value);    };    return (        <>            <form>                <input type="text" value={userName} onChange={handleChange} />                <button>{'Save'}</button>            </form>            <div>                <button onClick={undo} disabled={!hasPast}>{'Undo'}</button>                <button onClick={redo} disabled={!hasFuture}>{'Redo'}</button>            </div>        </>    )}const mapStateToProps = (state) => {    return {        hasPast: !!state.userInfo.past.length,        hasFuture: !!state.userInfo.future.length,        userName: state.userInfo.present.userName,    }}const mapDispatchToProps = { changeUserName, undo, redo }export default connect(    mapStateToProps,    mapDispatchToProps,)(ProfileEditForm)// src/constants/userConstants.jsexport const userConstants = {    SET_USER_NAME: 'USER_SET_USER_NAME',    UNDO: 'USER_UNDO',    REDO: 'USER_REDO',};// src/actions/userAction.jsimport { userConstants } from "../constants/userConstants";export const changeUserName = (userName) => (    {        type: userConstants.SET_USER_NAME,        payload: userName,    });export const undo = () => (    {        type: userConstants.UNDO,    });export const redo = () => (    {        type: userConstants.REDO,    });// src/reducers/user/undoableUserReducer.jsimport {userConstants} from "../../constants/userConstants";export function undoable(reducer) {    const initialState = {        past: [],        present: reducer(undefined, {}),        future: [],    };    return function userReducer(state = initialState, action) {        const {past, present, future} = state;        switch (action.type) {            case userConstants.UNDO:                const previous = past[past.length - 1]                const newPast = past.slice(0, past.length - 1)                return {                    past: newPast,                    present: previous,                    future: [present, ...future]                }            case userConstants.REDO:                const next = future[0]                const newFuture = future.slice(1)                return {                    past: [...past, present],                    present: next,                    future: newFuture                }            default:                const newPresent = reducer(present, action)                if (present === newPresent) {                    return state                }                return {                    past: [...past, present],                    present: newPresent,                    future: []                }        }    }}// src/reducers/user/userReducer.jsimport { undoable } from "./undoableUserReducer";import { userConstants } from "../../constants/userConstants";const initialState = {    userName: 'username',};function reducer(state = initialState, action) {    switch (action.type) {        case userConstants.SET_USER_NAME:            return {                ...state,                userName: action.payload,            };        default:            return state;    }}export const userReducer = undoable(reducer);



Резюмируя


Рассмотреть вариант хранения данных в Redux store стоит в следующих случаях:
  1. Если эти данные редко изменяются;
  2. Если одни и те же данные используются в нескольких (больше 2-3) связанных компонентах или в несвязанных компонентах;
  3. Если требуется отслеживать изменения данных.

Во всех остальных случаях лучше использовать React state.

P.S. Большое спасибо mamdaxx111 за помощь в подготовке статьи!
Подробнее..
Категории: Javascript , Node.js , Reactjs , Frontend , Redux , React.js

Из песочницы Urban Bot или как писать чат-ботов для Telegram, Slack, Facebook на React.js

27.07.2020 22:10:16 | Автор: admin

image


В этой статье я хочу познакомить с новой библиотекой Urban Bot, которая адаптирует React для написания чат-ботов. Ниже я расскажу, зачем эта библиотека появилась на свет, какие дает преимущества и как написать вашего первого чат-бота.


Чат-бот это чаще всего отдельный чат в мессенджере, в котором вы общаетесь не с человеком, а с программой. Он может присылать сообщения в виде текста, изображений, кнопок и многих других UI элементов и реагировать на сообщения от пользователей. Современные чат-боты это полноценные UI приложения внутри мессенджеров.

В отличии от большинства чат-бот библиотек, которые чаще всего просто оборачивают http запросы в функции с готовыми аргументами и предоставляют подписки вида bot.on('message', callback), иногда позволяя передавать контекст между вызовами, Urban Bot предлагает совершенно иной подход к разработке чат-ботов через декларативное программирование и компонентный подход. Живой пример, написанный на Urban Bot, вы можете попробовать в Telegram, cсылка на чат-бот, и посмотреть код на GitHub.


Как мы заметили выше, чат-боты это полноценные UI приложения. А какой язык в 2020 и какая библиотека наиболее подходит для разработки UI приложений? Правильно, JavaScript и React. Такая интеграция позволяет легко и непринужденно строить чат-боты любой сложности без единого знания об API мессенджеров. Далее я расскажу, как создавать простые компоненты и на их основе строить сложных чат-ботов, работать с навигацией, создавать диалоги любой вложенности, писать одно приложение и запускать в любых мессенджерах, и многое другое.


Отправка сообщений


Так выглядит самый простой пример на Urban Bot. Для отправки текстового сообщения нам нужно создать функцию и вернуть из него готовый компонент Text с текстом внутри, который мы хотим отправить. Когда компонент отрендериться, все пользователи чат-бота получат сообщение "Hello, world!".


import React from 'react';import { Text } from '@urban-bot/core';function App() {    return (        <Text>           Hello, world!        </Text>    );}

Изображение можно отправить так:


import React from 'react';import { Image } from '@urban-bot/core';function App() {    const imageByURL =  'https://some-link.com/image.jpg';    return <Image file={imageByURL} />;}

Urban Bot имеет готовый набор компонентов, для каждого вида сообщений, для файлов File, для кнопок ButtonGroup и много других, подробнее можно взглянуть здесь. В каждый из них можно передать определенный набор пропсов, например, имитировать будто бот печатает сообщение 1 секунду <Text simulateTyping={1000}>.


Получение сообщений


Мы рассмотрели как посылать сообщения, давайте разберемся как подписываться на сообщения от пользователей. За подписки в Urban Bot отвечают React Hooks.


Чтобы подписаться на текстовые сообщения, мы можем использовать хук useText.


import React from 'react';import { Text, useText } from '@urban-bot/core';function App() {    useText(({ text }) => {        console.log(`Пользователь отправил сообщение ${text}`);    });    return (        <Text>            Hello, world!        </Text>    );}

Urban Bot предоставляет готовы набор хуков для разных типов сообщений. Например, useImage, если пользователь отправил изображение, useFile и т.д. Полный список здесь. В каждый хук также приходит мета информация, кто отправил сообщение и т.д.


Эхо бот


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


В этом компоненте мы впервые добавим работу с переменными через React хук useState. Этот хук возвращает переменную и функцию, чтобы ее изменять. React.useState нужен, чтобы изменение переменной приводило к ререндеру и, соответсвенно, отправке нового сообщения. Мы определим начальное значение переменной text как "Привет" и передадим в компонент Text. Также мы подпишемся на сообщения от пользователей с помощью хука useText, и будем изменять text через функцию setText. После вызова setText React перерендерит компонент Echo с новым значением, и пользователь получит новое сообщение с тем что он сам отправил боту.


import React from 'react';import { Text, useText } from '@urban-bot/core';function Echo() {    const [text, setText] = React.useState('Привет!');    useText(({ text }) => {        setText(text);    });    return (        <Text>            {text}        </Text>    );}

Кнопки


Давайте также напишем пример с кнопками, сделаем простейший счетчик. Для этого нам понадобятся компоненты ButtonGroup и Button. Каждой кнопке мы определим свой обработчик, который будет менять count на +1 или -1 и будем передавать результат в ButtonGroup в проп title. Мы установим проп isNewMessageEveryRender как false, чтобы при последующих ререндерах отправлялось не новое сообщение с новыми кнопками, а просто изменялось начальное сообщение.


import React from 'react';import { ButtonGroup, Button } from '@urban-bot/core';function Counter() {    const [count, setCount] = React.useState(0);    const increment = () => setCount(count + 1);    const decrement = () => setCount(count - 1);    return (        <ButtonGroup title={count} isNewMessageEveryRender={false}>            <Button onClick={increment}>+1</Button>            <Button onClick={decrement}>-1</Button>        </ButtonGroup>    );}


Мы написали два простых компонента эхо и счетчик. Давайте представим, что мы хотим чтобы они были у нас в одном чат-боте, но работали независимо. Для этого мы можем подключить компонент Router и разделить компоненты по разным путям. Пути это обычные сообщения от пользователей.


Теперь, когда пользователь напишет "/echo" отрендериться компонент Echo, когда "/counter" управление перейдет в Counter. Роуты также могут принимать path как regexp.


import React from 'react';import { Router, Route } from '@urban-bot/core';import { Echo } from './Echo';import { Counter } from './Counter';function App() {    return (        <Router>            <Route path="/echo">                <Echo />            </Route>            <Route path="/counter">                <Counter />            </Route>        </Router>    );}

Визуальный пример, чтобы увидеть как будет работать код.


image


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


Форматирование текста


Urban Bot позволяет стилизовать сообщения через привычные HTML теги. Писать жирным <b>, курсивом <i>, зачеркнутым <s>, переносить строки <br /> и так далее, полный список здесь.


Пример
const someCode = `function sum2() {    return 2 + 2;}if (sum2() !== 4) {    console.log('WTF');}`;<Text>    Usual text    <br />    <b>Bold text</b>    <br />    <i>Italic text</i>    <br />    <u>Underscore text</u>    <br />    <s>Strikethrough text</s>    <br />    <q>quote</q>    <br />    <b>        Bold and <s>Strikethrough text</s>    </b>    <br />    <code >Code 2 + 2</code >    <br />    <pre>{someCode}</pre>    <br />    <a href="http://personeltest.ru/aways/github.com/urban-bot/urban-bot">External link</a></Text>


Диалоги


Чат-боты могут вести диалоги с пользователем, что-то спрашивать, реагировать на сообщения в зависимости от ответа пользователя. В Urban Bot встроен компонент Dialog, который позволяет легко создавать диалоги любой сложности.


Пример плоского диалога. Бот задаст первый вопрос, после ответа задаст второй, потом третий, потом запустит callback onFinish с готовыми ответами. На каждом шаге можно обрабатывать ответ пользователя через callback onNext.


import React from 'react';import { Dialog, DialogStep, Text } from '@urban-bot/core';function FlatDialogExample() {    return (        <Dialog onFinish={(answers) => console.log(answers)}>            <DialogStep                content={<Text>Привет, как тебя зовут?</Text>}                 id="name"                onNext={(name) => console.log(name)}            >                <DialogStep                    content={<Text>Cколько тебе лет?</Text>}                    id="age"                >                    <DialogStep                         content={<Text>Из какого ты города?</Text>}                        id="city"                    />                </DialogStep>            </DialogStep>        </Dialog>    );}

Можно получать на следующем шаге прошлый ответ через паттерн render-props .


function FlatDialogExample() {    return (        <Dialog>            <DialogStep content={<Text>Привет, как тебя зовут?</Text>}>                {(name) => (                    <DialogStep                         content={<Text>{`${name}, cколько тебе лет?`}</Text>}                    />                )}            </DialogStep>        </Dialog>    );}

Можно добавить валидацию на каждый шаг.


function FlatDialogExample() {    return (        <Dialog onFinish={(answers) => console.log(answers)}>            <DialogStep                content={<Text>Привет, как тебя зовут?</Text>}                id="name"                validation={{                     isValid: (answer) => answer !== 'Самуэль',                     errorText: 'Самуэль заблокирован.'                 }}            >                // ...            </DialogStep>        </Dialog>    );}

Также возможно построение более сложных диалогов, когда бот отвечает определенным образом в зависимости от ответа пользователя. Для предопределенных ответов можно использовать кнопки.


import React from 'react';import { Dialog, DialogStep, Text, ButtonGroup, Button } from '@urban-bot/core';function TreeDialogExample() {    return (        <Dialog>            <DialogStep                content={                    <ButtonGroup title="Привет, что вы хотите купить?">                        <Button id="hat">Футболка</Button>                        <Button id="glasses">Очки</Button>                    </ButtonGroup>                }            >                <DialogStep                    match="hat"                    content={                        <ButtonGroup title="Футболка какого размера?">                            <Button id="m">S</Button>                            <Button id="s">M</Button>                            <Button id="l">L</Button>                        </ButtonGroup>                    }                />                <DialogStep                    match="glasses"                    content={                        <ButtonGroup title="Очки какого цвета?">                            <Button id="black">Черный</Button>                            <Button id="white">Белый</Button>                        </ButtonGroup>                    }                />            </DialogStep>        </Dialog>    );}

Состояние


Что вы можете использовать для управления состоянием? Все то же что и в любом React приложении. Можете использовать React useState и передавать состояние ниже по дереву компонентов через пропсы или React Context. Можно использовать библиотеки для управления состоянием: Redux (пример), MobX (пример), Apollo и любые другие, которые обычно используют вместе с React, вы можете даже переиспользовать готовые части из готовых React Web или React Native приложений, так как Urban Bot использует тот же чистый React, который работает в миллионах приложений.


Сессия


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


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


function Counter() {    const [count, setCount] = React.useState(0);    const increment = () => setCount(count + 1);    const decrement = () => setCount(count - 1);    return (        <ButtonGroup title={count} isNewMessageEveryRender={false}>            <Button onClick={increment}>+1</Button>            <Button onClick={decrement}>-1</Button>        </ButtonGroup>    );}

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


function Counter() {    const [count, setCount] = useGlobalCount();   // ...}

Также, в любом месте приложения мы можем получить данные о конкретном чате, какой пользователь написал, с каким никнеймом и т.д. Если чат-бот персональный, то chat и from будут совпадать.


import React from 'react';import { Text, useText, useBotContext } from '@urban-bot/core';function UserId() {    const { chat } = useBotContext();    useText(({ from }) => console.log(`Пришло сообщение от ${from.username}`));   return <Text>Чат id {chat.id}</Text>;}

Типизация


Urban Bot написан на TypeScript, соответсвенно проект полностью типизирован, и если вы пишете на TypeScript, вам будет очень удобно.


Запуск в мессенджерах


Большой плюс Urban Bot, что он не привязан ни к одному мессенджеру. Есть основной пакет @urban-bot/core, который позволяет создавать абстрактных чат-ботов, а уже их подключать к определенным мессенджерам. В данный момент есть поддержка Telegram, Slack, Facebook. В дальнейшем, мы планируем добавлять любые мессенджеры, где есть чат-боты и открытое API. Если вам интересно, и вы хотите писать Urban Bot приложения для других мессенджеров, скажем Viber, Discord или у вас есть свой мессенджер то пишите к нам в группу https://t.me/urbanbotjs, одной просьбы будет достаточно, чтобы появилось большая мотивация реализовать ваш функционал.


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


Скажем, у нас есть готовое приложение App и мы хотим его запустить его в Telegram. Для этого нам понадобится класс UrbanBotTelegram из пакет @urban-bot/telegram. Функция render из @urban-bot/core подобная ReactDOM.render и компонент Root. Мы создаем экземпляр UrbanBotTelegram и передаем туда бот токен из Telegram, также можно передать isPolling, чтобы не настраивать вебхук, и бот работал локально. Готовый экземпляр мы передаем в компонент Root, и оборачиваем наше готовое приложение и, соответсвенно, передаем все в функцию render, которая запустит все процессы.


Более подробная инструкция с видео как разрабатывать локально и деплоить Telegram бота можно прочитать в инструкции.


import React from 'react';import { render, Root } from '@urban-bot/core';import { UrbanBotTelegram } from '@urban-bot/telegram';import { App } from './App';const urbanBotTelegram = new UrbanBotTelegram({    token: 'telegramToken',    isPolling: true,});render(    <Root bot={urbanBotTelegram}>        <App />    </Root>);

Если нам нужно запустить два и более мессенджера, просто используем пакеты других мессенджеров, передаем туда нужные данные и делаем по аналогии с Telegram.


// ...import { UrbanBotSlack } from '@urban-bot/slack';// ...render(    <Root bot={urbanBotTelegram}>        <App />    </Root>);const urbanBotSlack = new UrbanBotSlack({    signingSecret: 'slackSigningSecret',    token: 'slackToken',});render(    <Root bot={urbanBotSlack}>        <App />    </Root>);

Прямой вызов API


С помощью Urban Bot вы можете создавать чат-ботов просто описывая их через компоненты. А что если вам будет нужно вручную вызвать API? Каждый экземпляр UrbanBot* содержит в себе API клиент для активного мессенджера. Рассмотрим пример для Telegram.


Мы можем получить bot с помощью хука useBotContext. bot содержит client и type c типом мессенджера. client будет представлять собой экземпляр библиотеки node-telegram-bot-api . В любом месте приложения можно получить client и вызвать любой метод на ваше усмотрение, скажем блокировать пользователя, если он написал нецензурное сообщение.


import React from 'react';import { useText, useBotContext } from '@urban-bot/core';function SomeComponent() {    const { bot } = useBotContext();    useText(({ text, chat, from }) => {        if (text.includes('бл***')) {            bot.client.kickChatMember(chat.id, from.id);        }    });    // ...}

В каждом мессенджере уникальный API. Если вы разрабатываете несколько мессенджеров одновременно, можно отделять функционал сравнивая bot.type.


import { useBotContext } from '@urban-bot/core';import { UrbanBotTelegram } from '@urban-bot/telegram';import { UrbanBotSlack } from '@urban-bot/slack';function SomeComponent() {    const { bot } = useBotContext();    if (bot.type === UrbanBotTelegram.type) {        // telegram api        bot.client.kickChatMember(/* ... */);    }    if (bot.type === UrbanBotSlack.type) {        // slack api        bot.client.conversations.kick(/* ... */);    }    // ...}

Как попробовать?


У Urban Bot есть стартер, который позволит вам начать разрабатывать чат-ботов за минуту, сделан по аналогии с create-rect-app. Все что вам нужно, чтобы попробовать Urban Bot это выполнить команду в терминале для


TypeScript


npx create-urban-bot my-app

JavaScript


npx create-urban-bot my-app --template js

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


Итого


Несмотря на то что Urban Bot запустился только недавно, на мой взгляд это библиотека с огромным потенциалом. Только представьте, если у вас есть базовые знания React, вы можете написать чат-бот любой сложности на все возможные платформы, создавать и использовать библиотеки с готовым набором компонентов на манер ui-kit, переиспользовать код между вашими другими UI приложеними на React, будь то web или mobile.


Если вы уже разрабатывали чат-ботов, попробуйте Urban Bot, вы почувствуете как библиотека делает за вас кучу работы. Если никогда не разрабатывали, но имеете представление о React, то напишете вашего первого чат-бота за 5 минут. Если вам понравилась идея и хотите, чтобы проект развивался дальше, вступайте в нашу группу в Telegram, ставьте звезду на гитхабе, будем рады любому фидбеку.


Полезные ссылки


Сайт
Github
Группа в Telegram
Наглядный чат-бот в Telegram, с открытым кодом.
Как создать Todo List чат-бот в Telegram с помощью React.js

Подробнее..

Перевод Почему это антипаттерн?

21.09.2020 20:06:45 | Автор: admin
Всем привет. В сентябре в OTUS стартует сразу несколько курсов по JS-разработке, а именно: JavaScript Developer. Professional, JavaScript Developer. Basic и React.js Developer. В преддверии старта этих курсов мы подготовили для вас еще один интересный перевод, а также предлагаем записаться на бесплатные демо-уроки по следующим темам:



А теперь перейдём к статье.





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

Как один компонент получает доступ к информации (особенно к переменной состояния), которая находится в другом компоненте?
Как один компонент вызывает функцию, которая находится в другом компоненте?

JavaScript-разработчики в целом (и React-разработчики в частности) в последнее время все больше тяготеют к написанию так называемых чистых функций. Функций, которые не связаны с изменениями состояния. Функций, которым не нужны внешние соединения с базами данных. Функций, которые не зависят от того, что происходит за их пределами.
Безусловно, чистые функции это благородная цель. Но если вы разрабатываете более-менее сложное приложение, то сделать каждую функцию чистой не получится. Обязательно наступит момент, когда вам придется создать хотя бы несколько компонентов, которые так или иначе связаны с другими компонентами. Пытаться избежать этого просто смешно. Такие узы между компонентами называются зависимостями.

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

Стандартный подход: используем пропсы для передачи значений


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

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

App обращается к ContentArea
ContentArea обращается к MainContentArea
MainContentArea обращается к MyDashboard
MyDashboard обращается к MyOpenTickets
MyOpenTickets обращается к TicketTable
TicketTable обращается к последовательности TicketRow
Каждый TicketRow обращается к TicketDetail

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

Может ли компонент TicketDetail в примере выше считывать значения состояния, которые хранятся в ContentArea? Или. Может ли компонент TicketDetail вызывать функции, которые находятся в ContentArea?
Ответ на оба вопроса да. Теоретически все потомки могут знать обо всех переменных, которые хранятся в родительских компонентах. Они также могут вызывать функции предков но с большой оговоркой. Это возможно, только если такие значения (значения состояния или функции) в явном виде переданы потомкам через пропсы. В противном случае значения состояния или функции компонента не будут доступны его дочернему компоненту.

В небольших приложениях и утилитах это особой роли не играет. Например, если компоненту TicketDetail нужно обратиться к переменным состояния, которые хранятся в TicketRow, достаточно сделать так, чтобы компонент TicketRow передавал эти значения своему потомку TicketDetail через один или несколько пропсов. Точно так же дело обстоит в случае, когда компоненту TicketDetail нужно вызвать функцию, которая находится в TicketRow. Компонент TicketRow передаст эту функцию своему потомку TicketDetail через проп. Головная боль начинается, когда какому-нибудь компоненту, расположенному далекоооо вниз по дереву, нужно получить доступ к состоянию или функции компонента, расположенного вверху иерархии.
Для решения этой проблемы в React переменные и функции традиционно передаются на все уровни вниз. Но это загромождает код, отнимает ресурсы и требует серьезного планирования. Нам пришлось бы передавать значения на много уровней примерно так:
ContentArea MainContentArea MyDashboard MyOpenTickets TicketTable TicketRow TicketDetail
То есть для того чтобы передать переменную состояния из ContentArea в TicketDetail, нам нужно проделать огромную работу. Опытные разработчики понимают, что возникает безобразно длинная цепочка передачи значений и функций в виде пропсов через промежуточные уровни компонентов. Решение настолько громоздкое, что из-за него я даже пару раз бросал изучение React.

Чудовище по имени Redux


Я не единственный, кто считает, что передавать через пропсы все значения состояния и все функции, общие для компонентов, очень непрактично. Вряд ли вы найдете хоть сколько-нибудь сложное React-приложение, к которому не прилагался бы инструмент управления состоянием. Таких инструментов не так уж мало. Лично я обожаю MobX. Но, к сожалению, отраслевым стандартом считается Redux.

Redux это детище создателей ядра React. То есть они сначала создали прекрасную библиотеку React. Но сразу же поняли, что с ее средствами управлять состоянием практически невозможно. Если бы они не нашли способа решить присущие этой (во всем остальном замечательной) библиотеке проблемы, многие из нас никогда бы не услышали о React.

Поэтому они придумали Redux.
Если React это Мона Лиза, то Redux это пририсованные ей усы. Если вы используете Redux, вам придется написать тонну шаблонного кода почти в каждом файле проекта. Устранение проблем и чтение кода становятся адом. Бизнес-логика выносится куда-то на задворки. В коде разброд и шатание.

Но если перед разработчиками стоит выбор: React + Redux или React без каких-либо сторонних инструментов управления состоянием, они почти всегда выбирают React + Redux. Поскольку библиотеку Redux разработали авторы ядра React, она по умолчанию считается одобренным решением. А большинство разработчиков предпочитают использовать решения, которые были вот так молчаливо одобрены.

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

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

Новый взгляд на общие значения в React


Я слишком упрям, чтобы так просто сдаться. Взглянув на Redux, я понял, что нужно искать другие решения. Я могу использовать Redux. И я работал в командах, которые пользовались этой библиотекой. В общем, я понимаю, что она делает. Но это не значит, что Redux мне нравится.
Как я уже говорил, если без отдельного инструмента для управления состоянием не обойтись, то MobX примерно в миллион раз лучше, чем Redux! Но меня мучает более серьезный вопрос. Он касается коллективного разума React-разработчиков:

Почему первым делом мы всегда хватаемся за инструмент управления состоянием?

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

Представим, что в гипотетическом приложении, о котором я писал выше, мы создаем такой файл:

// components.jslet components = {};export default components;


И все.Только две короткие строчки кода. Мы создаем пустой объект старый добрый JS-объект. Экспортируем его по умолчанию с помощью export default.

Теперь давайте посмотрим, как может выглядеть код внутри компонента <ContentArea>:

// content.area.jsimport components from './components';import MainContentArea from './main.content.area';import React from 'react';export default class ContentArea extends React.Component {   constructor(props) {      super(props);      components.ContentArea = this;   }   consoleLog(value) {      console.log(value);   }   render() {      return <MainContentArea/>;   }}


По большей части он выглядит, как вполне нормальный классовый React-компонент. У нас есть простая функция render(), которая обращается к следующему компоненту вниз по дереву. У нас есть небольшая функция console.log(), которая выводит в консоль результат выполнения кода, и конструктор. Но в конструкторе есть некоторые нюансы.

В самом начале мы импортировали простой объект components. Затем в конструкторе мы добавили новое свойство к объекту components с именем текущего React-компонента (this).В этом свойстве мы ссылаемся на компонент this. Теперь при каждом обращении к объекту components у нас будет прямой доступ к компоненту <ContentArea>.

Давайте посмотрим, что происходит на нижнем уровне иерархии. Компонент <TicketDetail> может быть таким:

// ticket.detail.jsimport components from './components';import React from 'react';export default class TicketDetail extends React.Component {   render() {      components.ContentArea.consoleLog('it works');      return <div>Here are the ticket details.</div>;   }}


А происходит вот что. При каждом рендере компонента TicketDetail будет вызываться функция consoleLog(), которая хранится в компоненте ContentArea.

Обратите внимание, что функция consoleLog() не передается по всей иерархии через пропсы. Фактически функция consoleLog() не передается никуда вообще никуда, ни в один компонент.
И тем не менее TicketDetail может вызвать функцию consoleLog(), которая хранится в ContentArea, потому что мы выполнили два действия:

  1. Компонент ContentArea при загрузке добавил в общий объект components ссылку на себя.
  2. Компонент TicketDetail при загрузке импортировал общий объект components, то есть у него был прямой доступ к компоненту ContentArea, несмотря на то что свойства ContentArea не передавались компоненту TicketDetail через пропсы.


Этот подход работает не только с функциями/колбэками. Его можно использовать для прямого запроса значений переменных состояния. Представим, что <ContentArea> выглядит так:

// content.area.jsimport components from './components';import MainContentArea from './main.content.area';import React from 'react';export default class ContentArea extends React.Component {   constructor(props) {      super(props);      this.state = { reduxSucks:true };      components.ContentArea = this;   }   render() {      return <MainContentArea/>;   }}


Тогда мы можем написать <TicketDetail> вот так:

// ticket.detail.jsimport components from './components';import React from 'react';export default class TicketDetail extends React.Component {   render() {      if (components.ContentArea.state.reduxSucks === true) {         console.log('Yep, Redux is da sux');      }      return <div>Here are the ticket details.</div>;   }}


Теперь при каждом рендере компонента <TicketDetail> он будет искать значение переменной state.reduxSucks в <ContentArea>. Если переменная вернет значение true, функция console.log() выведет в консоль сообщение. Это произойдет, даже если значение переменной ContentArea.state.reduxSucks никогда не передавалось вниз по дереву ни одному из компонентов через пропсы. Таким образом, благодаря одному простому базовому JS-объекту, который обитает за пределами стандартного жизненного цикла React, мы можем сделать так, чтобы любой потомок мог считывать переменные состояния непосредственно из любого родительского компонента, загруженного в объект components. Мы даже можем вызывать функции родительского компонента в его потомке.

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

Для начала в компоненте <ContentArea> создадим простую функцию, которая меняет значение переменной reduxSucks.

// content.area.jsimport components from './components';import MainContentArea from './main.content.area';import React from 'react';export default class ContentArea extends React.Component {   constructor(props) {      super(props);      this.state = { reduxSucks:true };      components.ContentArea = this;   }   toggleReduxSucks() {      this.setState((previousState, props) => {         return { reduxSucks: !previousState.reduxSucks };      });   }   render() {      return <MainContentArea/>;   }}


Затем в компоненте <TicketDetail> мы вызовем этот метод через объект components:

// ticket.detail.jsimport components from './components';import React from 'react';export default class TicketDetail extends React.Component {   render() {      if (components.ContentArea.state.reduxSucks === true) {         console.log('Yep, Redux is da sux');      }      return (         <>            <div>Here are the ticket details.</div>            <button onClick={() => components.ContentArea.toggleReduxSucks()}>Toggle reduxSucks</button>         </>      );   }}


Теперь после каждого рендера компонента <TicketDetail> пользователь сможет нажимать кнопку, которая будет изменять (переключать) значение переменной ContentArea.state.reduxSucks в режиме реального времени, даже если функция ContentArea.toggleReduxSucks() никогда не передавалась вниз по дереву через пропсы.

С таким походом родительский компонент может вызвать функцию непосредственно из своего потомка. Вот как это можно сделать.
Обновленный компонент <ContentArea> будет выглядеть так:

// content.area.jsimport components from './components';import MainContentArea from './main.content.area';import React from 'react';export default class ContentArea extends React.Component {   constructor(props) {      super(props);      this.state = { reduxSucks:true };      components.ContentArea = this;   }   toggleReduxSucks() {      this.setState((previousState, props) => {         return { reduxSucks: !previousState.reduxSucks };      });      components.TicketTable.incrementReduxSucksHasBeenToggledXTimes();   }   render() {      return <MainContentArea/>;   }}


А теперь добавим логику в компонент <TicketTable>. Вот так:

// ticket.table.jsimport components from './components';import React from 'react';import TicketRow from './ticket.row';export default class TicketTable extends React.Component {   constructor(props) {      super(props);      this.state = { reduxSucksHasBeenToggledXTimes: 0 };      components.TicketTable = this;   }   incrementReduxSucksHasBeenToggledXTimes() {      this.setState((previousState, props) => {         return { reduxSucksHasBeenToggledXTimes: previousState.reduxSucksHasBeenToggledXTimes + 1};      });         }   render() {      const {reduxSucksHasBeenToggledXTimes} = this.state;      return (         <>            <div>The `reduxSucks` value has been toggled {reduxSucksHasBeenToggledXTimes} times</div>            <TicketRow data={dataForTicket1}/>            <TicketRow data={dataForTicket2}/>            <TicketRow data={dataForTicket3}/>         </>      );   }}


В результате компонент <TicketDetail> не изменился. Он все еще выглядит так:

// ticket.detail.jsimport components from './components';import React from 'react';export default class TicketDetail extends React.Component {   render() {      if (components.ContentArea.state.reduxSucks === true) {         console.log('Yep, Redux is da sux');      }      return (         <>            <div>Here are the ticket details.</div>            <button onClick={() => components.ContentArea.toggleReduxSucks()}>Toggle reduxSucks</button>         </>      );   }}


Вы заметили странность, связанную с этими тремя классами? В иерархии нашего приложения ContentArea это родительский компонент для TicketTable, который является родительским компонентом для TicketDetail. Это означает, что когда мы монтируем компонент ContentArea, он еще не знает о существовании TicketTable.А функция toggleReduxSucks(), записанная в ContentArea, неявно вызывает функцию потомка: incrementReduxSucksHasBeenToggledXTimes().Получается, код работать не будет, так?

А вот и нет.

Смотрите. Мы создали в приложении несколько уровней, и есть только один путь вызова функции toggleReduxSucks(). Вот так.
  1. Монтируем и рендерим ContentArea.
  2. В ходе этого процесса в объект components загружается ссылка на ContentArea.
  3. В результате монтируется и рендерится TicketTable.
  4. В ходе этого процесса в объект components загружается ссылка на TicketTable.
  5. В результате монтируется и рендерится TicketDetail.
  6. У пользователя появляется кнопка Изменить значение reduxSucks (Toggle reduxSucks).
  7. Пользователь нажимает кнопку Изменить значение reduxSucks.
  8. Нажатие кнопки вызывает функцию toggleReduxSucks(), которая записана в компоненте ContentArea.
  9. Это в свою очередь вызывает функцию incrementReduxSucksHasBeenToggledXTimes() из компонента TicketTable .
  10. Все работает, потому что к тому моменту, когда пользователь сможет нажать кнопку Изменить значение reduxSucks, ссылка на компонент TicketTable будет загружена в объект components. А функция toggleReduxSucks() при вызове из ContentArea сможет найти ссылку на функцию incrementReduxSucksHasBeenToggledXTimes(), записанную в TicketTable, в объекте components.

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

Инструменты управления состоянием на свалку


Как я уже объяснил, я глубоко уверен в том, что Redux не идет ни в какое сравнение с MobX. И когда мне выпадает честь работать над проектом с нуля (к сожалению, нечасто), я всегда агитирую за MobX. Не за Redux. Но когда я разрабатываю собственные приложения, я вообще редко использую сторонние инструменты управления состоянием практически никогда. Вместо этого я просто-напросто кеширую объекты/компоненты, когда это возможно. А если это подход не работает, я частенько возвращаюсь к решению, которое используется в React по умолчанию, то есть просто передаю функции/переменные состояния через пропсы.

Известные проблемы этого подхода


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

  • Лучше всего он работает с одиночками.
    Например, в нашей иерархии в компоненте находятся компоненты со связью ноль-ко-многим. Если вы захотите кешировать ссылку на каждый потенциальный компонент внутри компонентов (и их дочерних компонентов ) в кеш components, вам придется сохранить их в массив, и тут могут возникнуть сложности. Я всегда избегал этого.
    При кешировании объекта components предполагается, что мы не можем использовать переменные/функции из других компонентов, если они не были загружены в объект components. Это очевидно.
    Если архитектура вашего приложения делает такой подход нецелесообразным, то не надо его использовать. Он идеально подходит для одностраничных приложений, когда мы уверены в том, что родительский компонент всегда монтируется раньше потомка. Если вы решили сослаться на переменные/функции потомка непосредственно из родительского компонента, создавайте такую структуру, которая будет выполнять эту последовательность только после загрузки потомка в кеш components.
    Можно считывать переменные состояния из других компонентов, ссылки на которые хранятся в кеше components, но если вы захотите обновить такие переменные (через setState()), вам придется вызвать функцию setState(), которая записана в соответствующем компоненте.


    Ограничение ответственности


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

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

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

    Я обнаружил, что JS-разработчики и, в частности, React-разработчики бывают слишком категоричны. Иногда они действительно объясняют, почему подход А неправильный, а подход Б правильный. Но в большинстве случаев они просто смотрят на фрагмент кода и объявляют, что он плохой, даже если сами не могут объяснить, почему.

    Так почему же этот подход так раздражает React-разработчиков?


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

    • С таким подходом о чистых функциях можно забыть, он захламляет приложение жесткими зависимостями.
      Окей...Понял. Но те самые люди, которые с ходу отвергли этот подход, с удовольствием будут использовать Redux (или MobX, или любое другое средство управления состоянием) почти со всеми классами/функциями в своих React-приложениях. Я не отрицаю, что иногда без инструментов управления состоянием действительно сложно обойтись. Но любой такой инструмент по своему характеру это гигантский генератор зависимостей. Каждый раз, когда вы используете инструменты управления состоянием с функциями/классами, вы захламляете приложение зависимостями. Обратите внимание: я не говорил, что нужно отправлять каждую функцию или класс в кеш объекта components. Вы самостоятельно решаете, какие именно функции/классы будут кешироваться в components, а какие функции/классы будут обращаться к тому, что вы поместили в кеш components. Если вы пишете чистую вспомогательную функцию/класс, то наверняка моя идея с кешем components вам не подходит, потому что для кеширования в components компоненты должны знать о других компонентах приложения.Если вы пишете компонент, который будет использоваться в разных фрагментах кода вашего приложения или даже в разных приложениях, не применяйте этот подход. Но опять же, если вы создаете такой глобальный компонент, в нем не нужно использовать ни Redux, ни MobX, ни какое-либо еще средство управления состоянием.
    • Просто в React так не делается. Или Это не соответствует отраслевым стандартам.
      Ага Это мне говорили не раз. И знаете что? Когда я это слышу, я даже немножко перестаю уважать своего собеседника. Если единственная причина это какое-то размытое так не делается или отраслевой стандарт, который сегодня один, а завтра другой, то разработчик просто чертов лентяй. Когда появилась React, у нас не было вообще никаких инструментов управления состоянием. Но люди начали изучать эту библиотеку и решили, что они нужны. И их создали.Если вы действительно хотите соответствовать отраслевым стандартам, просто передавайте все переменные состояния и все обратные вызовы функций через пропсы.Но если вам кажется, что базовая реализация React не удовлетворяет ваши потребности на 100 %, откройте глаза (и разум) и взгляните повнимательней на нестандартные решения, которые не были одобрены лично господином Дэном Абрамовым.


    Итак, что скажете В?


    Я написал этот пост, потому что уже годами использую этот подход (в личных проектах). И он работает превосходно. Но каждый раз, когда я вылезаю из своего личного пузыря и пытаюсь вести интеллектуальную беседу об этом подходе с другими, сторонними React-разработчиками, я сталкиваюсь только с категоричными заявлениями и бестолковыми суждениями об отраслевых стандартах.

    Этот подход действительно плох? Ну правда. Я хочу знать. Если это действительно антипаттерн, я буду безмерно благодарен тем, кто обоснует его неправильность. Ответ я к такому не привык меня не устроит. Нет, я не зациклился на этом методе. Я не утверждаю, что это панацея для React-разработчиков. И я признаю, что он подходит не для всех ситуаций. Но может хоть кто-нибудь объяснить мне, что в нем не так?

    Мне очень хочется узнать ваше мнение по этому поводу даже если вы разнесете меня в пух и прах.

    Бесплатные уроки:


Подробнее..

Перевод Как эффективно применять React Context

18.10.2020 18:16:23 | Автор: admin

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

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

Давайте, для начала, создадим файл src/count-context.js и пропишем там наш контекст:

// src/count-context.jsimport React from 'react'const CountStateContext = React.createContext()const CountDispatchContext = React.createContext()

Во-первых, в CountStateContext нет начального значения. Его можно было бы прописать, например, так: React.createContext ({count: 0}), но в этом нет смысла. Дефолтное значение defaultValue будет полезно только в такой ситуации:

function CountDisplay() {  const {count} = React.useContext(CountStateContext) // <-  return <div>{count}</div>}ReactDOM.render(<CountDisplay />, document.getElementById(''))

Из-за того что у CountStateContext нет значения по умолчанию, мы получим ошибку в useContext Это из-за того что наше дефолтное значение не было определено, оно undefined а мы не можем передавать undefined в useContext.

Никому не нравятся runtime-ошибки, так что, скорее всего, вашей первой реакцией будет добавление какого-то дефолтного значения. Но зачем вообще использовать контекст если вы не используете его для какого-то реального значения? Если он будет использовать значение по умолчанию, то в нем едва ли есть какой либо смысл. В подавляющем числе случаев когда вы создаете и используете контекст в своем приложении вы хотите чтобы потребители (Context Consumer), которые используют useContext, отображались внутри провайдера (Context Provider), который может передать какое-то полезное значение.

Замечу, что да, возможны ситуации когда значения по умолчанию полезны, но в большинстве случаев они не нужны.

Документация Реакта говорит о том что передача дефолтных значений "может быть полезной для тестирования компонентов в изоляции без необходимости оборачивать их". Да, так делать можно, но, на мой взгляд, лучше оборачивать компоненты нужным контекстом. Помните - вы не можете быть уверены в тесте на сто процентов если делаете в нем что-то, чего не делаете в самом приложении. Существуют ситуации когда нужно так делать The Merits of Mocking, но это не тот случай.

Примечание. Если вы используете Flow или TypeScript, и применяете React.useContext, отсутствие дефолтного значения может сильно раздражать. Ниже я покажу как избежать этой проблемы.

Так зачем нужен этот CountDispatchContext? Уже какое-то время я экспериментирую с контекстами, я так же общаюсь со знакомыми из Facebook которые экспериментируют с ними намного дольше чем я, и могу сказать что самая простая вещь которую вы можете сделать для того чтобы избежать проблем с контекстами (особенно если вы вызываете dispatch в эффектах) это разделить состояние (state) и dispatch в контексте. Это звучит странно, но сейчас я все объясню!

Кастомный компонент провайдер

Чтобы модуль контекста работал, нам нужно использоватьProviderи компонент, который передает значение. Пример:

function App() {  return (    <CountProvider>      <CountDisplay />      <Counter />    </CountProvider>  )}ReactDOM.render(<App />, document.getElementById(''))

Итак, давайте напишем код этого компонента:

// src/count-context.jsimport React from 'react'const CountStateContext = React.createContext()const CountDispatchContext = React.createContext()function countReducer(state, action) {  switch (action.type) {    case 'increment': {      return {count: state.count + 1}    }    case 'decrement': {      return {count: state.count - 1}    }    default: {      throw new Error(`Unhandled action type: ${action.type}`)    }  }}function CountProvider({children}) {  const [state, dispatch] = React.useReducer(countReducer, {count: 0})  return (    <CountStateContext.Provider value={state}>      <CountDispatchContext.Provider value={dispatch}>        {children}      </CountDispatchContext.Provider>    </CountStateContext.Provider>  )}export {CountProvider}

Примечание. Это довольно надуманный пример. Я намеренно переусложнил решение чтобы показать более приближенный к реальности код.Это не значит что всегда нужно так делать.Применяйте хукuseStateесли это больше подходит для вашей ситуации. На практике, какие-то ваши провайдеры будут простыми и короткими, как этот, а другие будут КУДА более сложными, и будут применять множество различных хуков.

Кастомный хук потребитель (Consumer Hook)

Обычно разработчики используют контексты таким образом:

import React from 'react'import {SomethingContext} from 'some-context-package'function YourComponent() {  const something = React.useContext(SomethingContext)}

Но на мой взгляд существует куда более удобный способ:

import React from 'react'import {useSomething} from 'some-context-package'function YourComponent() {  const something = useSomething()}

Чем таком подход лучше? Ну, это открывает нам целый ряд новых возможностей:

// src/count-context.jsimport React from 'react'const CountStateContext = React.createContext()const CountDispatchContext = React.createContext()function countReducer(state, action) {  switch (action.type) {    case 'increment': {      return {count: state.count + 1}    }    case 'decrement': {      return {count: state.count - 1}    }    default: {      throw new Error(`Unhandled action type: ${action.type}`)    }  }}function CountProvider({children}) {  const [state, dispatch] = React.useReducer(countReducer, {count: 0})  return (    <CountStateContext.Provider value={state}>      <CountDispatchContext.Provider value={dispatch}>        {children}      </CountDispatchContext.Provider>    </CountStateContext.Provider>  )}// наши кастомные хуки:function useCountState() {  const context = React.useContext(CountStateContext)  if (context === undefined) {    throw new Error('useCountState must be used within a CountProvider')  }  return context}function useCountDispatch() {  const context = React.useContext(CountDispatchContext)  if (context === undefined) {    throw new Error('useCountDispatch must be used within a CountProvider')  }  return context}export {CountProvider, useCountState, useCountDispatch}

Во-первых, кастомные хукиuseCountStateиuseCountDispatchиспользуютReact.useContextдля того чтобы получить значение контекста из ближайшегоCountProvider. В ситуации когда значения нет, мы показываем сообщение об ошибке, которое указывает на то что хук должен быть использован внутриCountProvider. Так как это ошибка, то, конечно, полезно эту ошибку отобразить.

Кастомный компонент потребитель

Если у вас есть возможность использовать хуки, то вы можете пропустить этот раздел. Однако, если вы используете React < 16.8.0, или, если вам нужно использовать Контекст в классовом компоненте, вот каким образом вы можете поступить, используя подход render-prop:

function CountConsumer({children}) {  return (    <CountStateContext.Consumer>      {context => {        if (context === undefined) {          throw new Error('CountConsumer must be used within a CountProvider')        }        return children(context)      }}    </CountStateContext.Consumer>  )}

Именно так я и делал до того как появились хуки. Это не плохой подход. Однако, если вы можете использовать хуки, используйте хуки.

TypeScript / Flow

Выше я обещал рассказать о том как избежать ошибок связанных с тем что мы не стали указывать дефолтное значение (defaultValue) при использовании TypeScript или Flow. Вот оно:

// src/count-context.tsximport * as React from 'react'type Action = {type: 'increment'} | {type: 'decrement'}type Dispatch = (action: Action) => voidtype State = {count: number}type CountProviderProps = {children: React.ReactNode}const CountStateContext = React.createContext<State | undefined>(undefined)const CountDispatchContext = React.createContext<Dispatch | undefined>(  undefined,)function countReducer(state: State, action: Action) {  switch (action.type) {    case 'increment': {      return {count: state.count + 1}    }    case 'decrement': {      return {count: state.count - 1}    }    default: {      throw new Error(`Unhandled action type: ${action.type}`)    }  }}function CountProvider({children}: CountProviderProps) {  const [state, dispatch] = React.useReducer(countReducer, {count: 0})  return (    <CountStateContext.Provider value={state}>      <CountDispatchContext.Provider value={dispatch}>        {children}      </CountDispatchContext.Provider>    </CountStateContext.Provider>  )}function useCountState() {  const context = React.useContext(CountStateContext)  if (context === undefined) {    throw new Error('useCountState must be used within a CountProvider')  }  return context}function useCountDispatch() {  const context = React.useContext(CountDispatchContext)  if (context === undefined) {    throw new Error('useCountDispatch must be used within a CountProvider')  }  return context}export {CountProvider, useCountState, useCountDispatch}

При таком подходе можно использоватьuseCountStateилиuseCountDispatchбез проверки наundefined, так мы уже провели эту проверку!

Вот версия на codesandbox

Что на счетtypeтипов вdispatch?

В этот момент пользователи Редакса могли бы закричать: "Эй, а где генераторы экшенов?!" (Action Creator). Если вам хочется, то вы можете применять генераторы экшенов, в этом нет ничего плохого. Но на мой взгляд это лишняя абстракция, в которой нет необходимости. К тому же, если вы используете TypeScript или Flow, и вы тщательно прописали типы для ваших экшенов, то вам не нужно прописывать генераторы экшенов. Вы уже и так получаете автодополнение и отображение ошибок типов в редакторе кода:

Мне действительно нравиться передаватьdispatchтаким образом, и, как дополнительный плюс,dispatchстабилен все время пока живет компонент который создал его, так что его можно смело добавлять в лист зависимостейuseEffect(не имеет значения добавлен он туда или нет).

Если вы не типизируете свой JavaScript код (вообще, неплохо было бы типизировать), то ошибка которую мы отображаем при пропущенном типе экшена - отказоустойчива.

Что на счет асинхронности?

Это хороший вопрос. Что делать если нужно сделать асинхронный запрос, и вам нужно отправлять (dispatch) данные в ходе этого запроса? Да, это можно сделать в самом вызываемом компоненте, но получается придется прописывать это все для каждого компонента.

Я предлагаю сделать вспомогательную функцию внутри вашего контекстного модуля, эта функция будет приниматьdispatchвместе с любыми другими данными которые вам нужны. Вот пример такой функции (из моего курсаAdvanced React Patterns Workshop):

// user-context.jsasync function updateUser(dispatch, user, updates) {  dispatch({type: 'start update', updates})  try {    const updatedUser = await userClient.updateUser(user, updates)    dispatch({type: 'finish update', updatedUser})  } catch (error) {    dispatch({type: 'fail update', error})  }}export {UserProvider, useUserDispatch, useUserState, updateUser}

После чего вы можете использовать ее так:

// user-profile.jsimport {useUserState, useUserDispatch, updateUser} from './user-context'function UserSettings() {  const {user, status, error} = useUserState()  const userDispatch = useUserDispatch()  function handleSubmit(event) {    event.preventDefault()    updateUser(userDispatch, user, formState)  }  // more code...}

Совмещение состояния (state) и отправки (dispatch)

Некоторые считают такой код излишним:

const state = useCountState()const dispatch = useCountDispatch()

Они спрашивают, "можно ли просто делать так?":

const [state, dispatch] = useCount()

Да, можно:

function useCount() {  return [useCountState(), useCountDispatch()]}

Итоги

Финальная версия кода:

// src/count-context.jsimport React from 'react'const CountStateContext = React.createContext()const CountDispatchContext = React.createContext()function countReducer(state, action) {  switch (action.type) {    case 'increment': {      return {count: state.count + 1}    }    case 'decrement': {      return {count: state.count - 1}    }    default: {      throw new Error(`Unhandled action type: ${action.type}`)    }  }}function CountProvider({children}) {  const [state, dispatch] = React.useReducer(countReducer, {count: 0})  return (    <CountStateContext.Provider value={state}>      <CountDispatchContext.Provider value={dispatch}>        {children}      </CountDispatchContext.Provider>    </CountStateContext.Provider>  )}function useCountState() {  const context = React.useContext(CountStateContext)  if (context === undefined) {    throw new Error('useCountState must be used within a CountProvider')  }  return context}function useCountDispatch() {  const context = React.useContext(CountDispatchContext)  if (context === undefined) {    throw new Error('useCountDispatch must be used within a CountProvider')  }  return context}export {CountProvider, useCountState, useCountDispatch}

Вот код на codesandbox

Заметьте что я НЕ экспортируюCountContext. Это сделано намеренно. Благодаря этому существует только один способ предоставлять значение контекста и только один способ потреблять его. Это позволяет убедиться в том что разработчики используют значение так как это было задумано, это также позволяет добавлять полезные утилиты для потребителей.

Надеюсь это было полезно для вас! Помните:

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

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

  3. Вы можете (и скорее всего должны) иметь несколько логически не связанных контекстов в вашем приложении.

Подробнее..

Соединяем Redux и GraphQL на простом примере

19.10.2020 12:14:37 | Автор: admin

Попробуем соединить Redux и GraphQL без использования Apollo Client или Relay.

Что такое Redux

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

Псевдокод:

function dispatch(action) {    state = reducer(state, action);}

Для соединения реакта и редакса используется библиотека react-redux. Она позволяет получать данные из state внутри компонентов и инициировать их обновление через dispatch.

Что такое GraphQL

GraphQL для клиента это просто POST-запрос с особым форматом body:

REST

GraphQL

GET /books/id

POST /graphql
query{ book(id) {} }

POST /books
{}

POST /graphql
mutation{ addBook() {...} }

UPDATE /books/id
{}

POST /graphql
mutation{ updateBook(id) {...} }

DELETE /books/id

POST /graphql
mutation{ deleteBook(id) {} }

GraphQL без Apollo Client (и Relay)

Apollo-client это самая популярная библиотека для работы с GraphQL. Она предоставляет интерфейс для работы с запросами внутри компонентов, а также решает задачу хранения кэша. Вместо Redux предполагается использовать внутренние механизмы. Однако эти механизмы далеко не такие простые и удобные.

Вместо Apollo Client для примера воспользуемся такой функцией:

const graphqlAPI = (query, variables) => fetch("/graphql", {    method: "POST",    body: JSON.stringify({ query, variables })});

redux-toolkit

Я буду использовать официальный набор утилит от redux redux-toolkit. Они решают самую частую претензию к редаксу бесконечное количество шаблонного кода. В этот набор входит:

  • createEntityAdapter для упрощения работы с редьюсерами и селекторами.

  • createAsyncThunk для упрощения работы с сайд-эффектами и API.

  • createSlice для упрощения работы с actions и разрешения мутабельности.

Пример

Представим простое приложение, которое запрашивает список книг с сервера и выводит их.

// booksSlice.jsimport {  createEntityAdapter,  createAsyncThunk,  createSlice} from "@reduxjs/toolkit";// Наш редьюсер будет хранить список книг.// createEntityAdapter генерирует готовый набор функций// для добавления, обновления и удаления книг внутри редьюсера// (addOne, addMany, updateOne, removeOne, setAll, ...), const books = createEntityAdapter();// createAsyncThunk позволяет работать с асинхронным кодом.// Например, с запросами к API.// getBooks работает как набор actions, которые можно диспатчить в приложении.export const getBooks = createAsyncThunk("get books", async () =>   await graphqlAPI(`    query {      books  {        id        title      }    }  `));export const addBook = createAsyncThunk("add book", ({ title }) =>  graphqlAPI(`    mutation ($title: string!){      add_book(objects: { title: $title }) {        id        title      }    }  `, { title }));// createSlice  обертка над createAction и createReducer.// Она позволяет работать со стейтом мутабильно.export const booksSlice = createSlice({  name: "books",  initialState: books.getInitialState(),  extraReducers: {    // Работаем с результатом запроса getBooks    [getBooks.fulfilled]: (state, action) => {      // setAll автоматически создан в createEntityAdapter      books.setAll(state, action.payload);    },    // Работаем с результатом запроса addBook    [addBook.fulfilled]: (state, action) => {      // addOne автоматически создан в createEntityAdapter      books.addOne(state, action.payload);    },  },});// createEntityAdapter генерирует набор готовых селекторов// (selectIds, selectById, selectAll, ...)export const booksSelectors = books.getSelectors((s) => s.books);export default booksSlice.reducer;
// index.jsimport { useDispatch, useSelector } from "react-redux";import { getBooks, addBook, booksSelectors } from "./booksSlice";export function Books() {  const dispatch = useDispatch();  // booksSelectors.selectAll втоматически создан через createEntityAdapter  const books = useSelector(booksSelectors.selectAll);   useEffect(() => {    dispatch(getBooks());  }, [dispatch]);  return (    <div>      <button onClick={() => dispatch(addBook({ title: "New Book" }))}>        Add book      </button>      <ul>        {books.map((b) => <li key={b.id}>{b.title}</li>)}      </ul>    </div>  );}

Что дальше

Поэкспериментировать с Redux можно локально.
Для настройки окружения достаточно одной команды:
npx create-react-app my-app --template redux

Для Typescript:
npx create-react-app my-app --template redux-typescript

Вместо fetch можно воспользоваться библиотекой graphql-request.

Создать бэкенд на GraphQL можно без написания кода.
Для этого подходят такие проекты как hasura или prisma.

Подробнее..

Serverless шагает по планете. Сравним SberCloud и AWS

14.12.2020 10:07:17 | Автор: admin

image


Мне с друзьями довелось поучаствовать в хакатоне
SberCloud.Advanced Hacking и пощупать облачные сервисы Сбера. На моем текущем месте работы я каждый день работаю с Serverless стеком от AWS (CloudFront + Lambda Edge + S3 + Lambda + Step Functions + Aurora PostgreSQL Serverless + DynamoDB + Cognito). Так что мне есть с чем сравнить. Если заинтересовались, то добро пожаловать под кат.


Недавно Сбер выкатил в публичный доступ свою облачную платформу SberCloud. К сожалению, сейчас, чтобы ей воспользоваться, нужно быть юридическим лицом. Представители Сбера говорят, что, возможно, доступ для физических лиц появится в следующем году. Но тем не менее, в рамках хакатона, я смог посмотреть, что там внутри, как обычный смертный. Что я там увидел? Огромное количество сервисов прям, как у AWS, на любой вкус https://sbercloud.ru/ru/products.


Меня прежде всего интересовал Serverless стек. Вообще, мало кто может похвастаться настоящими Serverless сервисами, у которых стоимость использования зависит только от реальной нагрузки. Сервером с платой за месяц уже ни кого не удивишь.


Я хотел собрать приложение аналогичное такому в AWS.
AWS Serverless Application


Вот что получилось на стеке SberCloud.
SberCloud Serverless Application


Я быстренько создал лямбду FunctionGraph.
FunctionGraph. 1
FunctionGraph. 2


Прикрутил к ней API Gateway. Настроил HTTP/HTTPS trigger.
API Gateway


Кстати, читать логи лямбды оказалось намного удобнее чем в AWS. Всё в одном месте. Ненужно прыгать по вкладкам и искать нужный Log Stream.
FunctionGraph Logs


К сожалению, настоящей Serverless базы данных у SberCloud не нашлось. Пришлось взять сервис с фиксированной платой.
RDS Postgres


Настроил Environment variables от PostgreSQL пользователя у лямбды FunctionGraph.
Environment variables


Написал небольшой Serverless адаптер для работы с PostgreSQL базой из лямбды. Нужно аккуратно закрывать за собой соединения, а то они кончатся.


import { Client } from 'pg'export const escapeId = (str: string): string => `"${String(str).replace(/(["])/gi, '$1$1')}"`export const escapeStr = (str: string): string => `'${String(str).replace(/(['])/gi, '$1$1')}'`export const executeStatement = async <T extends Record<string, any>>(  sql: string): Promise<Array<T>> => {  const client = new Client()  await client.connect()  try {    const res = await client.query(sql)    return res.rows  } finally {    await client.end()  }}

Настроил Object Storage Service.
Object Storage Service. 1
Object Storage Service. 2


Некое SDK для работы с SberCloud имеется, но чего-то полезного мне там найти не удалось.
SberCloud SDK


Для загрузки картинок из браузера в Object Storage Service через подписанный URL код пришлось писать самому. Не самое приятное занятие угадывать сигнатуры и считать хеши руками.


async (req: Request, res: Response) => {  validateUserHandler(req, res)  const { authToken } = context  const { userId } = req.params  const uploadId = uuidV4()  const body = Buffer.from(    JSON.stringify({      auth: {        identity: {          methods: ['token'],          token: {            id: authToken,            'duration-seconds': '900',          },        },      },    }),    'utf8'  )  const options = {    hostname: 'iam.ru-moscow-1.hc.sbercloud.ru',    port: 443,    path: '/v3.0/OS-CREDENTIAL/securitytokens',    method: 'POST',    headers: {      'Content-Type': 'application/json; charset=utf8',      'Content-Length': body.length,    },  }  const credentialBuffer: Buffer = await new Promise((resolve, reject) => {    const req = https.request(options, (res) => {      res.on('data', (data) => {        resolve(data)      })      res.on('error', (error) => {        reject(error)      })    })    req.write(body)    req.end()  })  const {    credential,  }: {    credential: { securitytoken: string; secret: string }  } = JSON.parse(credentialBuffer.toString('utf8'))  const { securitytoken, secret } = credential  const stringToSign = Buffer.from(    JSON.stringify({      expiration: '2020-12-31T12:00:00.000Z',      conditions: [        { 'x-obs-acl': 'public-read' },        { 'x-obs-security-token': securitytoken },        { bucket: 'anti-cube-images' },        ['starts-with', '$key', `${userId}/`],      ],    }),    'utf8'  ).toString('base64')  const hmac = crypto.createHmac('sha1', secret)  hmac.update(stringToSign)  const signature = hmac.digest('base64')  res.json({    uploadId,    signature,  })}

В целом SberCloud мне понравился. Очень достойно. Не ожидал увидеть международный уровень облачных сервисов на российском рынке. Желаю Сберу обзавестись настоящей Serverless базой данных с платой только за использование. Лучше смотреть в этом плане в сторону CosmosDB от Azure или опенсорсного TiDB на своих серверах. У AWS сделано не особо грамотно, не копируйте у них.


Дополнительно про SberCloud можно посмотреть на YouTube:



Документация: https://docs.sbercloud.ru/


На хакатоне мы с ребятами делали изоморфное приложение на React+Redux. Презентация нашего проекта: https://docs.google.com/presentation/d/1LNjg2xfFHljTuH6ke_lkF6o1m9HTfemWcxW0w1OOssc/edit?usp=sharing


Подробнее..

Перевод Использование Redux в MV3 расширениях Chrome

23.12.2020 16:22:50 | Автор: admin

Примечание к переводу: Оригинальная статья была написана до того как стало известно о MV3. Тем не менее она полностью актуальна и для MV3 расширений (по крайней мере на данный момент). Поэтому я решил немного изменить ее название, добавив упоминание "MV3", что нисколько не противоречит содержанию. Если кто не в курсе: MV3 новый формат/стандарт расширений Chrome, должен быть введен в январе 2021 года.


В этой статье, предназначенной для опытных веб-разработчиков, рассматривается (и решается) проблема использования Redux в т.н. событийно-ориентированных (event-driven) расширениях Chrome.


Специфика событийно-ориентированных расширений


Событийно-ориентированная модель расширения впервые появилась в Chrome 22 в 2012 г. В этой модели фоновый скрипт расширения (если есть) загружается/выполняется только когда это нужно (в основном в ответ на события) и выгружается из памяти когда он ничего не делает.


Документация Chrome разработчика настоятельно советует использовать событийно-ориентированную модель для всех новых расширений, а для уже существующих расширений, использующих постоянную (persistent) модель делать миграцию. Есть правда одно исключение (в MV3 уже не актуально, в т.ч. поэтому переход на новую модель в MV3 обязателен). Но похоже что многие расширения до сих пор используют постоянную модель, даже если могут быть событийно-ориентированными. Конечно, многие из них были впервые выпущены до того, как стало известно о событийно-ориентированной модели. И теперь у их авторов просто нет стимула переходить на новую модель. С одной стороны это (пока) необязательно, а с другой означает необходимость множества изменений, не только в фоновом скрипте, но и в других компонентах расширения. К тому же многие при разработке используют кроссбраузерный подход, собирая готовые расширения для разных браузеров из одного и того же исходного кода. Событийно-ориентированная модель на данный момент поддерживается только в Chrome и она существенно отличается от постоянной модели, поддерживаемой остальными браузерами. Естественно, это усложняет кроссбраузерную разработку.


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


Проблема с Redux


Управление состоянием одна из вещей, имеющих особое значение в современной веб-разработке. Каждое веб-приложение (в т.ч. браузерное расширение), как только оно становится достаточно сложным, нуждается в едином и последовательном способе управления своим состоянием. И это то в чем силен Redux.


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


Прежде всего, в любом браузерном расширении может всплыть проблема с правилом "единого источника" это один из фундаментальных принципов Redux, по которому все состояние приложения должно храниться в одном месте. Расширения браузера часто состоят из нескольких отдельных компонентов (таких как фоновые и внедряемые скрипты), что затрудняет соблюдение данного принципа. К счастью эта проблема решаема и есть библиотеки, такие как Webext Redux, которые решают ее с помощью передачи сообщений. К несчастью такое решение неприменимо к событийно-ориентированным расширениям из-за уже упоминавшихся различий между событийно-ориентированной и постоянной моделями. Теперь самое время рассмотреть подробно эти различия.


В постоянной модели расширения его состояние обычно хранится в локальной переменной внутри постоянного (persistent) фонового скрипта, живущего в течение всей сессии браузера, до самого его закрытия. Так что такое состояние всегда доступно остальным компонентам расширения (например, внедряемым скриптам) через фоновый скрипт, который таким образом выполняет роль сервера. Это стандартный постоянный подход к управлению состоянием, который используется в библиотеках, таких как Webext Redux.



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


Решение


Решение заключается в том, чтобы использовать chrome.storage как непосредственное место/способ хранения/изменения состояния. Этот подход (между прочим он явно предлагается в руководстве по миграции) предполагает, что состояние хранится непосредственно в chrome.storage, чье API вызывается всякий раз когда нужно изменить состояние, либо отследить такие изменения.



Как часть стандартного API расширений chrome.storage обладает рядом преимуществ, самое важное из которых то что любой компонент расширения может иметь прямой доступ к нему. Еще одно преимущество автоматическое сохранение состояния между сессиями браузера, что является встроенной фичей, работающей "из коробки".


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


Остается только одна проблема API chrome.storage отличается от Redux, что делает невозможным его использование в качестве замены Redux. Конечно, можно использовать chrome.storage как есть, либо написать к нему кастомную обертку. Однако, Redux уже успел стать чем-то вроде стандарта в управлении состоянием. Так что было бы неплохо как-нибудь адаптировать chrome.storage к принципам Redux, или другими словами, сделать Redux из chrome.storage).


Наша цель в этой статье получить Redux-совместимый интерфейс к chrome.storage, который будет переводить функционал chrome.storage в термины Redux. В терминах API нам нужно реализовать в рамках chrome.storage интерфейс функционала Redux, имеющего непосредственное отношение к хранилищу (store) Redux. Он включает в себя функцию createStore и возвращаемый ею объект Store (хранилище Redux). Ниже их интерфейсы:


Спецификация интерфейса

Реализация


Итак, прежде всего нам нужно написать класс, реализующий интерфейс Store. Назовем его ReduxedStorage.


Реализовать методы getState и subscribe достаточно просто, т.к. у них есть близкие аналоги в chrome.storage: метод get и событие onChanged. Конечно, они не могут напрямую заменить указанные методы Store, но могут помочь в организации хранения локальной копии состояния в нашем классе. Мы можем инициализировать локальное состояние в нашем классе, вызвав метод get из chrome.storage во время создания экземпляра ReduxedStorage и затем, всякий раз когда появляется событие onChanged, изменять соответственно локальное состояние. Таким образом гарантируется актуальность локального состояния. Тогда getState в рамках нашего класса будет тривиальным геттером. Реализация метода subscribe немного сложнее: он должен добавлять аргумент-функцию к некоторому массиву слушателей, которые будут вызываться всякий раз когда появляется событие onChanged.


В отличие от getState и subscribe, в chrome.storage нет ничего похожего на метод Store.dispatch. Там есть метод set, но его прямое использование противоречит еще одному фундаментальному принципу Redux, по которому состояние Redux присваивается только один раз, во время создания хранилища, после чего оно может быть изменено только через вызов метода dispatch. Так что нам нужно как-то воспроизвести функционал dispatch в нашем классе ReduxedStorage. Есть два способа сделать это. Радикальный предполагает полное воспроизведение соответствующего функционала Redux в рамках нашего класса, короче говоря, тупо скопировать код Redux. Но есть также и компромисный вариант, который и будет рассмотрен ниже.


Идея состоит в том, чтобы создавать новый экземпяр хранилища всякий раз, когда отправляется какое-то действие. Да, это звучит немного странно, но это единственная альтернатива полному копированию выше. Говоря более конкретно, всякий раз когда в нашем классе вызывается метод dispatch, нам нужно создать новый экземпяр хранилища, вызвав "оригинальную" функцию createStore, инициализовать его состояние локальным состоянием из нашего класса и наконец вызвать "оригинальный" метод Store.dispatch, передав ему аргументы из нашего dispatch. Помимо этого, к созданному хранилищу нужно добавить одноразовый слушатель изменения состояния, чтобы когда данное действие дойдет до хранилища, обновить chrome.storage новым состоянием, получающимся в результате данного действия. Далее это обновление должно быть отслежено и обработано слушателем события chrome.storage.onChanged, описанным выше.


Несколько замечаний насчет инициализации состояния: Поскольку метод chrome.storage:get выполняется асинхронно, мы не можем вызывать его из конструктора нашего класса. Поэтому нам придется перенести код вызова chrome.storage:get в отдельный метод, который должен вызываться сразу после конструктора (создания экземпляра класса). Этот метод, назовем его init, будет возвращать промис, который должен быть разрешен, когда метод chrome.storage:get завершит выполнение. В методе init нам также нужно создать еще одно локальное хранилище Redux, чтобы получить дефолтное состояние, которое будет использоваться, если состояние в chrome.storage в данный момент пусто.


Ниже пример как может выглядеть наш класс ReduxedStorage в первом приближении:


Реализация в первом приближении

Замечание: Мы обращаемся к части данных в chrome.storage под определенным ключом (this.key), чтобы иметь возможность сразу получить новое (измененное) состояние в слушателе chrome.storage.onChanged, не вызывая дополнительно метод chrome.storage:get. Кроме того, это может быть полезно при хранении в состоянии непосредственно массивов, т.к. chrome.storage позволяет хранить на корневом уровне только объект.


К сожалению, в реализации выше есть скрытый недостаток, который возникает из-за того, что мы обновляем свойство this.state не напрямую, а через метод chrome.storage:set, выполняющийся асинхронно. Само по себе это не проблема. Но при создании локального хранилища Redux внутри метода dispatch используется значение свойства this.state, что может представлять проблему, т.к. this.state не всегда может содержать актуальное состояние. Так может быть, если несколько действий отправляются синхронно сразу друг за другом. В этом случае 2-й и все последующие вызовы dispatch имеют дело с устаревшими данными в свойстве this.state, которое еще не успевает обновиться из-за асинхронного выполнения метода chrome.storage:set. Таким образом, синхронное отправление нескольких действий друг за другом может приводить к нежелательным результатам.


Чтобы решить указанную проблему, можно изменить код dispatch так, чтобы использовать для таких синхронных действий одно и то же хранилище Redux. Такое буферизированное хранилище должно быть сброшено по истечении небольшого периода времени (допустим 100 мсек), чтобы для следующих действий использовалось уже новое хранилище. Для этого решения нам потребуется добавить в наш класс дополнительные свойства для буферизированного хранилища и соответствующего состояния. Ниже пример как может выглядеть такая буферизированная версия метода dispatch:


Буферизированная версия dispatch

Как это часто бывает, решая одну проблему, можно получить другую. В нашем случае использование буферизированного многоразового варианта хранилища вместо локального одноразового может сломать асинхронную логику в Redux. Асинхронная логика, по умолчанию отсутствующая в Redux, может быть введена с помощью middleware, такого как Redux Thunk. С Redux Thunk можно например отложить отправку действия, используя создатель действия, который возвращает функцию вместо действия. Ниже пример такого создателя действия:


Пример отложенного создателя действия

delayAddTodo откладывает отправку действия 'ADD_TODO' на 1 сек.


Если мы попытаемся использовать такой создатель действия с буферизированным вариантом dispatch выше, мы получим ошибку во время вызова this.buffStore.getState внутри колбека this.buffStore.subscribe. Причина в том что колбек this.buffStore.subscribe вызывается как минимум через 1 сек после вызова нашего метода dispatch, когда this.buffStore уже сброшен в null (через 100 мсек после вызова dispatch). При этом предыдущий вариант dispatch без проблем работает с такими асинхронными создателями действий, т.к. использует локальное хранилище, которое всегда доступно соответствующему колбеку subscribe.


Таким образом, нам нужно совместить оба варианта, т.е. использовать, как буферизированный, так и локальный вариант хранилища Redux. Первый будет использоваться для синхронных действий, а последний для асинхронных, занимающих какое-то время, таких как delayAddTodo. Однако, это не значит, что нам нужны два отдельных экземпляра хранилища Redux в одном вызове dispatch. Можно создать экземпляр хранилища один раз, сначала сохранив его в свойстве this.buffStore, а затем скопировать ссылку на него в локальной переменной, назовем ее lastStore. Тогда, когда свойство this.buffStore будет сброшено, lastStore все еще будет указывать на тот же самый экземпляр хранилища и будет доступен соответствующему колбеку subscribe. Следовательно, внутри колбека subscribe можно использовать переменную lastStore как запасную ссылку на хранилище на тот случай, если свойство this.buffStore недоступно, что означает асинхронное действие "в действии"). Когда изменение состояния будет обработано внутренним колбеком subscribe, было бы полезно отписать данный колбек/слушатель от хранилища и сбросить переменную lastStore, чтобы высвободить соответствующие ресурсы.


Кроме того, было бы неплохо провести рефакторинг в коде класса, в т.ч.:


  • сделать свойства this.areaName, this.key изменяемыми/настраиваемыми через параметры конструктора.
  • переместить код, непосредственно вызывающий API chrome.storage, в отдельный класс, назовем его WrappedStorage.

Итак, ниже окончательная реализация нашего интерфейса:


Окончательная реализация

Его использование аналогично использованию оригинального Redux, за исключением того, что в нашем интерфейсе создатель хранилища (store creator) обернут в функцию и выполняется асинхронно, возвращая промис вместо созданного хранилища.


Стандартное использование интерфейса выглядит так:


Стандартное использование

Кроме того, с синтаксисом async/await, доступным начиная ES 2017, этот интерфейс может использоваться так:


Продвинутое использование

Исходный код доступен на Github.


Также этот интерфейс доступен как пакет в NPM:


npm install reduxed-chrome-storage
Подробнее..

Перевод Почему Context это не инструмент управления состоянием

26.01.2021 16:05:52 | Автор: admin


TL;DR


Context и Redux это одно и тоже?

Нет. Это разные инструменты, делающие разные вещи и используемые в разных целях.

Является ли контекст инструментом управления состоянием?

Нет. Контекст это форма внедрения зависимостей (dependency injection). Это транспортный механизм, который ничем не управляет. Любое управление состоянием осуществляется вручную, как правило, с помощью хуков useState()/useReducer().

Являются ли Context и useReducer() заменой Redux?

Нет. Они в чем-то похожи и частично пересекаются, но сильно отличаются в плане возможностей.

Когда следует использовать контекст?

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

Когда следует использовать Context и useReducer()?

Когда вам требуется управление состоянием умеренно сложного компонента в определенной части приложения.

Когда следует использовать Redux?

Redux наиболее полезен в следующих случаях:

  • Большое количество компонентов с состояниями, в которых используются одни и те же данные
  • Состояние приложение часто обновляется
  • Сложная логика обновления состояния
  • Приложение имеет среднюю или большую кодовую базу и над ним работает много людей
  • Вы хотите знать когда, почему и как обновляется состояние приложения и иметь возможность визуализировать эти изменения
  • Вам требуются более мощные возможности по управлению побочными эффектами, стабильностью (постоянством) и сериализацией данных


Понимание Context и Redux


Для правильного использования инструмента критически важно понимать:

  • Для чего он предназначен
  • Какие задачи он решает
  • Когда и зачем он был создан

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

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

Что такое контекст?


Начнем с определения контекста из официальной документации:

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

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

Обратите внимание, в данном определении ни слова не говорится об управлении, только о передаче и распределении.

Текущее API контекста (React.createContext()) впервые было представлено в React 16.3 в качестве замены устаревшего API, доступного в ранних версиях React, но имеющего несколько недостатков дизайна. Одной из главных проблемой являлось то, что обновления значений, переданных через контекст, могли быть заблокированы, если компонент пропускал рендеринг через shouldComponentUpdate(). Поскольку многие компоненты прибегали к shouldComponentUpdate() в целях оптимизации, передача данных через контекст становилась бесполезной. createContext() был спроектирован для решения этой проблемы, поэтому любое обновление значения отразится на дочерних компонентах, даже если промежуточный компонент пропускает рендеринг.

Использование контекста

Использование контекста в приложении предполагает следующее:

  • Вызываем const MyContext = React.createContext() для создания экземпляра объекта контекста
  • В родительском компоненте рендерим &ltMyContext.Provider value={someValue}>. Это помещает некоторые данные в контекст. Эти данные могут быть чем угодно: строкой, числом, объектом, массивом, экземпляром класса, обработчиком событий и т.д.
  • Получаем значение контекста в любом компоненте внутри провайдера, вызывая const theContextValue = useContext(MyContext)

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

Обычно, значением контекста является состояние компонента:

import { createContext } from 'react'export const MyContext = createContext()export function ParentComponent({ children }) {  const [counter, setCounter] = useState(0)  return (    <MyContext.Provider value={[counter, setCounter]}>      {children}    </MyContext.Provider>  )}

После этого дочерний компонент может вызвать хук useContext() и прочитать значение контекста:

import { useContext } from 'react'import { MyContext } from './MyContext'export function NestedChildComponent() {  const [counter, setCounter] = useContext(MyContext)  // ...}

Цель и случаи использования контекста

Мы видим, что контекст, в действительности, ничем не управляет. Вместо этого, он представляет собой своего рода тоннель (pipe). Вы помещаете данные в начало (наверх) тоннеля с помощью <MyContext.Provider>, затем эти данные опускаются вниз до тех пор, пока компонет не запросит их с помощью useContext(MyContext).

Таким образом, основная цель контекста состоит в предотвращении бурения пропов (prop-drilling). Вместо передачи данных в виде пропов на каждом уровне дерева компонентов, любой компонент, вложенный в <MyContext.Provider>, может получить к ним доступ посредством useContext(MyContext). Это избавляет от необходимости писать код, реализующий логику передачи пропов.

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

Что такое Redux?


Вот о чем гласит определение из Основ Redux:

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

Redux позволяет управлять глобальным состоянием состоянием, которое трубется нескольким частям приложения.

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

Обратите внимание, что данное описание указывает на:

  • Управление состоянием
  • Цель Redux определение того, почему и как произошло изменение состояния

Изначально Redux являлся реализацией архитектуры Flux, паттерна проектирования, разработанного Facebook в 2014 году, через год после появления React. После появления Flux, сообщество разработало множество библиотек, по-разному реализующих данную концепцию. Redux появился в 2015 году и быстро стал победителем в этом соревновании благодаря продуманному дизайну, решению наиболее распространенных проблем и отличной совместимости с React.

Архитектурно, Redux подчеркнуто использует принципы функционального программирования, что позволяет писать код в форме предсказуемых функций-редукторов (reducers), и обособлять идею какое событие произошло от логики, определяющей как обновляется состояние при возгникновении данного события. В Redux также используется промежуточное программное обеспечение (middleware) как способ расширения возможностей хранилища, включая обработку побочных эффектов (side effects).

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

Redux и React

Сам по себе Redux не зависит от UI вы можете использовать его с любым слоем представления (view layer) (React, Vue, Angular, ванильный JS и т.д.) либо без UI вообще.

Однако, чаще всего, Redux используется совместно с React. Библиотека React Redux это официальный связывающий слой UI, позволяющий React-компонентам взаимодействовать с хранилищем Redux, получая значения из состояния Redux и инициализируя выполнение операций. React-Redux использует контекст в своих внутренних механизмах. Тем не менее, следует отметить, что React-Redux передает через контекст экземпляр хранилища Redux, а не текущее значение состояния! Это пример использования контекста для внедрения зависимостей. Мы знаем, что наши подключенные к Redux компоненты нуждаются во взаимодействии с хранилищем Redux, но мы не знаем или нам неважно, что это за хранилище, когда мы определяем компонент. Настоящее хранилище Redux внедряется в дерево во время выполнения с помощью компонента <Provider>, предоставляемого React-Redux.

Следовательно, React-Redux также может быть использован для предотвращения бурения (по причине внутреннего использования контекста). Вместо явной передачи нового значения через <MyContext.Provider>, мы можем поместить эти данные в хранилище Redux и затем получить их в нужном компоненте.

Цель и случаи использования (React-)Redux

Основное назначение Redux согласно официальной документации:

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

Существует еще несколько причин использования Redux. Одной из таких причин является предотвращение бурения.

Другие случаи использования:

  • Полное разделение логики управления состоянием и слоя UI
  • Распределение логики управления состоянием между разными слоями UI (например, в процессе перевода приложения с AngularJS на React)
  • Использование возможностей Redux middleware для добавления дополнительной логики при инициализации операций
  • Возможность сохранения частей состояния Redux
  • Возможность получения отчетов об ошибках, которые могут быть воспроизведены другими разработчиками
  • Возможность быстрой отладки логики и UI во время разработки

Дэн Абрамов перечислил эти случаи в статье 2016 года Почему вам может быть не нужен Redux.

Почему контекст не является инструментом управления состоянием?


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

David Khourshid, автор библиотеки XState и специалист по управлению состоянием, в одном из своих твитов отметил, что:

Управление состоянием это изменение состояния в течение времени.

Таким образом, мы можем сказать, что управление состоянием означает следующее:

  • Сохранение начального значения
  • Получение текущего значения
  • Обновление значения

Также, как правило, существует способ получения уведомлений об изменении текущего значения состояния.

React-хуки useState() и useReducer() являются отличными примерами управления состоянием. С помощью этих хуков мы можем:

  • Сохранять начальное значение путем вызова хука
  • Получать текущее значение также посредством вызова хука
  • Обновлять значение, вызывая функцию setState() или dispatch(), соответственно
  • Узнавать об обновлении состояния благодаря повторному рендерингу компонента

Redux и MobX также позволяют управлять состоянием:

  • Redux сохраняет начальное значение путем вызова корневого редуктора (root reducer), позволяет читать текущее значение с помощью store.getState(), обновлять значение с помощью store.dispatch(action) и получать уведомления об обновлении состояния через store.subscribe(listener)
  • MobX сохраняет начальное значение путем присвоения значения полю класса хранилища, позволяет читать текущее значение и обновлять его через поля хранилища и получать учведомления об обновлении состояния с помощью методов autorun() и computed()

К инструментам управления состоянием можно причислить даже инструменты для работы с кэшем сервера, такие как React-Query, SWR, Apollo и Urql они сохраняют начальное значение на основе полученных (fetched) данных, возвращают текущее значение с помощью хуков, позволяют обновлять значения посредством серверных мутаций и уведомляют об изменениях с помощью повторного рендеринга компонента.

React Context не соответствует названным критериям. Поэтому он не является инструментом управления состоянием

Как было отмечено ранее, контекст сам по себе ничего не хранит. За передачу значения, которое, обычно, зависит от состояния компонента, в контекст отвечает родительский компонент, который рендерит <MyContext.Provider>. Настоящее управление состоянием происходит при использовании хуков useState()/useReducer().

David Khourshid также отмечает:

Контекст это то, как существующее состояние распределяется между компонентами. Контекст ничего не делает с состоянием.

И в более позднем твите:

Полагаю, контекст это как скрытые пропы, абстрагирующие состояние.

Все, что делает контекст, это позволяет избежать бурения.

Сравнение Context и Redux


Сравним возможности контекста и React+Redux:

  • Context
    • Ничего не хранит и ничем не управляет
    • Работает только в компонентах React
    • Передает ниже простое (единственное) значение, которое может быть чем угодно (примитивом, объектом, классом и т.д.)
    • Позволяет читать это простое значение
    • Может использоваться для предотвращения бурения
    • Показывает текущее значение для компонентов Provider и Consumer в инструментах разработчика, но не показывает историю изменений этого значения
    • Обновляет потребляющие компоненты при изменении значения, но не позволяет пропустить обновление
    • Не предоставляет механизма для обработки побочных эффектов отвечает только за рендеринг

  • React+Redux
    • Хранит и управляет простым значением (обычно, этим значением является объект)
    • Работает с любым UI, а также за пределами React-компонентов
    • Позволяет читать это простое значение
    • Может использоваться для предотвращения бурения
    • Может обновлять значение путем инициализации операций и запуска редукторов
    • Инструменты разработчика показывают историю инициализации операций и изменения состояния
    • Предоставляет возможность использования middleware для обработки побочных эффектов
    • Позволяет компонентам подписываться на обновления хранилища, извлекать определенные части состояния хранилища и контролировать повторный рендеринг компонентов


Очевидно, что это совершенно разные инструменты с разными возможностями. Единственной точкой пересечения между ними является предотвращение бурения.

Context и useReducer()


Одной из проблем в дискуссии Context против Redux является то, что люди, зачастую, на самом деле имеют ввиду следующее: Я использую useReducer() для управления состоянием и контекст для передачи значения. Но, вместо этого, они просто говорят: Я использую контекст. В этом, на мой взгляд, кроется основная причина неразберихи, способствующая поддержанию мифа о том, что контекст управляет состоянием.

Рассмотрим комбинацию Context + useReducer(). Да, такая комбинация выглядит очень похоже на Redux + React-Redux. Обе эти комбинации имеют:

  • Сохраненное значение
  • Функцию-редуктор
  • Возможность инициализации операций
  • Возможность передачи значения и его чтения во вложенных компонентах

Тем не менее, между ними по-прежнему существуют некоторые важные отличия, проявляющиеся в их возможностях и поведении. Я отметил эти отличия в статьях Поведение React, Redux и Context и "(Почти) полное руководство по рендерингу в React". Суммируя, можно отметить следующее:

  • Context + useReducer() основан на передаче текущего значения через контекст. React-Redux передает через контекст текущий экземпляр хранилища Redux
  • Это означает, что когда useReducer() производит новое значение, все компоненты, подписанные на контекст, принудительно перерисовываются, даже если они используют только часть данных. Это может привести к проблемам с производительностью в зависимости от размера значения состояния, количества подписанных компонентов и частоты повторного рендеринга. При использовании React-Redux компоненты могут подписываться на определенную часть значения хранилища и перерисовываться только при изменении этой части

Существуют и другие важные отличия:

  • Контекст + useReducer() являются встроенными возможностями React и не могут использоваться за его пределами. Хранилище Redux не зависит от UI, поэтому может использоваться отдельно от React
  • React DevTools показывают текущее значение контекста, но не историю его изменений. Redux DevTools показывают все инициализированные операции, их содержимое (тип и полезную нагрузку, type and payload), состояние после каждой операции и разницу между состояниями
  • useReducer() не имеет middleware. Некоторые побочные эффекты можно обработать с помощью хука useEffect() в сочетании с useReducer(), я даже встречал отдельные попытки оборачивания useReducer() в нечто похожее на middleware, однако всему этому далеко до функционала и возможностей Redux middleware

Вот, что сказал Sebastian Markbage (архитектор команды ядра React) об использовании контекста:

Мое личное мнение состоит в то, что новый контекст готов к использованию для маловероятных обновлений с низкой частотой (таких как локализация или тема). Он также может использоваться во всех тех случаях, в которых использовался старый контекст, т.е. для статических значений с последующим распространением обновления по подпискам. Он не готов к использованию в качестве замены Flux-подобных распространителей состояния.

В сети существует много статей, рекомендующих настройку нескольких отдельных контекстов для разных частей состояния, что позволяет избежать ненужных повторных рендерингов и решить проблемы, связанные с областями видимости. Некоторые из постов также предлагают добавлять собственные компоненты по выбору контекста, что требует использования сочетания React.memo(), useMemo() и аккуратного разделения кода на два контекста для каждой части приложения (одна для данных, другая для функций обновления). Безусловно, код можно писать и так, но в этом случае вы заново изобретаете React-Redux.

Таким образом, несмотря на то, что Context + useReducer() это легкая альтернатива Redux + React-Redux в первом приближении эти комбинации не идентичны, контекст + useReducer() не может полностью заменить Redux!

Выбор правильного инструмента


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

Обзор случаев использования


  • Context
    • Передача данных вложенным компонентам без бурения

  • useReducer()
    • Управление состоянием сложного компонента с помощью функции-редуктора

  • Context + useReducer()
    • Управление состоянием сложного компонента с помощью функции-редуктора и передача состояния вложенным компонентам без бурения

  • Redux
    • Управление очень сложным состоянием с помощью функций-редукторов
    • Прослеживаемость того, когда, почему и как менялось состояние в течение времени
    • Желание полной изоляции логики управления состоянием от слоя UI
    • Распределение логики управления состоянием между разными слоями UI
    • Использование возможностей middleware для реализации дополнительной логики при инициализации операций
    • Возможность сохранения определенных частей состояния
    • Возможность получения воспроизводимых отчетов об ошибках
    • Возможность быстрой отладки логики и UI в процессе разработки

  • Redux + React-Redux
    • Все случаи использования Redux + возможность взаимодействия React-компонентов с хранилищем Redux


Еще раз: названные инструменты решают разные задачи!

Рекомендации


Как же решить, что следует использовать?

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

  • Если вам требуется просто избежать бурения, используйте контекст
  • Если у вас имеется сложное состояние, но вы не хотите использовать сторонние библиотеки, используйте контекст + useReducer()
  • Если вам требуется хорошая трассировка изменений состояния во времени, управлемый повторный рендеринг определенных компонентов, более мощные возможности обработки побочных эффектов и т.п., используйте Redux + React-Redux

Я считаю, что если в вашем приложении имеется 2-3 контекста для управления состоянием, то вам следует переключиться на Redux.

Часто можно услышать, что использование Redux предполагает написание большого количества шаблонного кода, однако, современный Redux значительно облегчает изучение данного инструмента и его использование. Официальный пакет Redux Toolkit решает проблему шаблонизации, а хуки React-Redux упрощают использование Redux в компонентах React.

Разумеется, добавление RTK и React-Redux в качестве зависимостей увеличивает бандл приложения по сравнению с контекстом + useReducer(), которые являются встроенными. Но преимущества такого подхода перекрывают недостатки лучшая трассировка состояния, простая и более предсказуемая логика, улучшенная оптимизация рендринга компонентов.

Также важно отметить, что одно не исключает другого вы можете использовать Redux, Context и useReducer() вместе. Мы рекомендуем хранить глобальное состояние в Redux, а локальное в компонентах и внимательно подходить к определению того, какая часть приложения должна храниться в Redux, а какая в компонентах. Так что вы можете использовать Redux для хранения глобального состояния, Context + useReducer() для хранения локального состояния, и Context для статических значений, одновременно и в одном приложении.

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

Наконец, контекст и Redux не единственные в своем роде. Существует множество других инструментов, решающих иные аспекты управления состоянием. MobX популярное решение, использующее ООП и наблюдаемые объекты (observables) для автоматического обновления зависимостей. Среди других подходов к обновлению состояния можно назвать Jotai, Recoil и Zustand. Библиотеки для работы с данными, вроде React Query, SWR, Apollo и Urql, предоставляют абстракции, упрощающие применение распространенных паттернов для работы с состоянием, кэшируемым сервером (скоро похожая библиотека (RTK Query) появится и для Redux Toolkit).

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

Svelte Redux Redux-saga

07.02.2021 02:13:24 | Автор: admin

Попытка жалкого подобия на хуки useSelector, useDispatch, как в react-redux.

Большинство из нас сталкивались с redux, а те, кто использовал его в ReactJS могли пощупать хуки useSelector, useDispatch, в ином случае через mstp, mdtp + HOC connect. А что со svelte? Можно навернуть, или найти что-то похожее на connect, по типу svelte-redux-connect, описывать огромные конструкции, которые будем отдавать в тот самый connect:

const mapStateToProps = state => ({  users: state.users,  filters: state.filters});const mapDispatchToProps = dispatch => ({  addUser: (name) => dispatch({    type: 'ADD_USER',    payload: { name }  }),  setFilter: (filter) => dispatch({    type: 'SET_FILTER',    payload: { filter }  }) });

Прямо какие-то страшные флэшбэки до середины 2018, до введения хуков :). Хочу хуки в svelte. Что мы можем из него взять? Хм... store у svelte глобальный, не нужны никакие провайдеры с контекстом (шучу, нужны для разделения контекстов, но пока выкинем). Значит так: мы создаем redux-store, потом попробуем написать наши жалкие хуки для удобства использования.

Итак, наши константы:

//constants.jsexport const GET_USER = '@@user/get'export const FETCHING_USER = '@@user/fetch'export const SET_USER = '@@user/set'

Редюсер:

//user.jsimport {FETCHING_USER, SET_USER} from "./constants";const initialState = {  user: null,  isFetching: false}export default function user(state = initialState, action = {}){  switch (action.type){    case FETCHING_USER:    case SET_USER:      return {        ...state,        ...action.payload      }    default:      return state  }}

Экшены:

//actions.jsimport {FETCHING_USER, GET_USER, SET_USER} from "./constants";export const getUser = () => ({  type: GET_USER})export const setUser = (user) => ({  type: SET_USER,  payload: {    user  }})export const setIsFetchingUser = (isFetching) => ({  type: FETCHING_USER,  payload: {    isFetching  }})

Селекторы. К ним вернемся отдельно:

//selectors.jsimport {createSelector} from "reselect";import path from 'ramda/src/path'export const selectUser = createSelector(  path(['user', 'user']),  user => user)export const selectIsFetchingUser = createSelector(  path(['user', 'isFetching']),  isFetching => isFetching)

И главный combineReducers:

//rootReducer.jsimport {combineReducers} from "redux";import user from "./user/user";export const reducers = combineReducers({  user})

Теперь надо прикрутить redux-saga, а в качестве api у нас будет https://randomuser.me/api/. Во время тестирования всего процесса, эта апи очень быстро работала, а я очень сильно хотел посмотреть на лоадер подольше (у каждого свой мазохизм), поэтому я завернул таймаут в промис на 3 сек.

//saga.jsimport {takeLatest, put, call, cancelled} from 'redux-saga/effects'import {GET_USER} from "./constants";import {setIsFetchingUser, setUser} from "./actions";import axios from "axios";const timeout = () => new Promise(resolve => {  setTimeout(()=>{    resolve()  }, 3000)})function* getUser(){  const cancelToken = axios.CancelToken.source()  try{    yield put(setIsFetchingUser(true))    const response = yield call(axios.get, 'https://randomuser.me/api/', {cancelToken: cancelToken.token})    yield call(timeout)    yield put(setUser(response.data.results[0]))    yield put(setIsFetchingUser(false))  }catch (error){    console.error(error)  }finally {    if(yield cancelled()){      cancelToken.cancel('cancel fetching user')    }    yield put(setIsFetchingUser(false))  }}export default function* userSaga(){  yield takeLatest(GET_USER, getUser)}
//rootSaga.jsimport {all} from 'redux-saga/effects'import userSaga from "./user/saga";export default function* rootSaga(){  yield all([userSaga()])}

И наконец инициализация store:

//store.jsimport {applyMiddleware, createStore} from "redux";import {reducers} from "./rootReducer";import {composeWithDevTools} from 'redux-devtools-extension';import {writable} from "svelte/store";import createSagaMiddleware from 'redux-saga';import rootSaga from "./rootSaga";const sagaMiddleware = createSagaMiddleware()const middleware = applyMiddleware(sagaMiddleware)const store = createStore(reducers, composeWithDevTools(middleware))sagaMiddleware.run(rootSaga)// берем изначальное состояние из storeconst initialState = store.getState()// написали writable store для useSelectorexport const useSelector = writable((selector)=>selector(initialState))// написали writable store для useDispatch, хотя можно было и без этого// но для симметрии использования оставил такexport const useDispatch = writable(() => store.dispatch)// подписываемся на обновление storestore.subscribe(()=>{  const state = store.getState()  // при обновлении store обновляем useSelector, тут нет никакой мемоизации,   // проверки стейтов, обработки ошибок и прочего очень важного для оптимизации  useSelector.set(selector => selector(state))})

Всё. Самое интересное начинается с 18 строки. После того, как приходит понятие того, что мы написали, возникает вопрос - если я буду использовать useSelector в 3 разных компонентах с разными данными из store - у меня будут обновляться все компоненты сразу? Нет, обновятся и перерисуются данные, которые мы используем. Даже если логически предположить, что при каждом чихе в store у нас меняется ссылка на функцию, то и обновление компонента по идее должно быть, но его нет. Я честно не до конца разобрался как это работает, но я доберусь до сути, не ругайтесь :)

Хуки готовы, как использовать?

Начнем c useDispatch. Его вообще можно было не заворачивать в svelte-store и сделать просто
export const useDispatch = () => store.dispatch, только по итогу с useSelector мы используем store bindings, а с useDispatch нет - сорян, всё же во мне есть частичка маленького перфекционизма. Используем хук useDispatch в App.svelte:

<!--App.svelte--><script>  import {getUser} from "./store/user/actions";  import {useDispatch} from "./store/store";  import Loader from "./Loader.svelte";  import User from "./User.svelte";  // создаем диспатчер  const dispatch = $useDispatch()  const handleClick = () => {    // тригерим экшен    dispatch(getUser())  }</script><style>    .wrapper {        display: inline-block;        padding: 20px;    }    .button {        padding: 10px;        margin: 20px 0;        border: none;        background: #1d7373;        color: #fff;        border-radius: 8px;        outline: none;        cursor: pointer;    }    .heading {        line-height: 20px;        font-size: 20px;    }</style><div class="wrapper">    <h1 class="heading">Random user</h1>    <button class="button" on:click={handleClick}>Fetch user</button>    <Loader/>    <User/></div>
Кнопока которая тригерит экшенКнопока которая тригерит экшен

Вот такая вот загогулина у меня свёрстана. При нажатии на кнопку Fetch user, тригерим экшен GET_USER. Смотрим в Redux-dev-tools - экшен вызвался, всё хорошо. Смотрим network - запрос к апи выполнен, тоже всё хорошо:

Теперь нужно показать процесс загрузки и полученного нами пользователя. Используем useSelector:

<!--Loader.svelte--><script>    import {useSelector} from "./store/store";    import {selectIsFetchingUser} from "./store/user/selector";// Только в такой конструкции мы можем получить из store данные,     // выглядит не так страшно и не лагает, я проверял :3    $: isFetchingUser = $useSelector(selectIsFetchingUser)</script><style>    @keyframes loading {        0% {            background: #000;            color: #fff;        }        100% {            background: #fff;            color: #000;        }    }    .loader {        background: #fff;        box-shadow: 0px 0px 7px rgba(0,0,0,0.3);        padding: 10px;        border-radius: 8px;        transition: color 0.3s ease-in-out, background 0.3s ease-in-out;        animation: loading 3s ease-in-out forwards;    }</style>{#if isFetchingUser}    <div class="loader">Loading...</div>{/if}

Лоадер рисуется. Данные из store прилетают, теперь надо показать юзера:

<!--User.svelte--><script>    import {useSelector} from "./store/store";    import {selectIsFetchingUser,selectUser} from "./store/user/selector";    $: user = $useSelector(selectUser)    $: isFetchingUser = $useSelector(selectIsFetchingUser)</script><style>    .user {        background: #fff;        box-shadow: 0px 0px 7px rgba(0,0,0,0.3);        display: grid;        padding: 20px;        justify-content: center;        align-items: center;        border-radius: 8px;    }    .user-image {        width: 100px;        height: 100px;        background-position: center;        background-size: contain;        border-radius: 50%;        margin-bottom: 20px;        justify-self: center;    }</style>{#if user && !isFetchingUser}    <div class="user">        <div class="user-image" style={`background-image: url(${user.picture.large});`}></div>        <div>{user.name.title}. {user.name.first} {user.name.last}</div>    </div>{/if}

Пользователя так же получили.

Итог

Запилили какие-никакие подобия на хуки, вроде удобно, но не известно как это отразится в будущем, если сделать из этого mini-app на пару страниц. Саги так же пашут. Через redux devtools можно дебажить redux и прыгать от экшена к экшену, всё хорошо работает.

Подробнее..
Категории: Javascript , Redux , Hooks , Svelte , Redux-saga , Sveltejs

React основные подходы к управлению состоянием

12.03.2021 08:08:07 | Автор: admin


Доброго времени суток, друзья!

Предлагаю вашему вниманию простое приложение список задач. Что в нем особенного, спросите вы. Дело в том, что я попытался реализовать одну и ту же тудушку с использованием четырех разных подходов к управлению состоянием в React-приложениях: useState, useContext + useReducer, Redux Toolkit и Recoil.

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

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

Условно, состояние можно разделить на локальное и глобальное. Под локальным состоянием, обычно, понимается состояние отдельно взятого компонента, например, состояние формы, как правило, является локальным состоянием соответствующего компонента. В свою очередь, глобальное состояние правильнее именовать распределенным или совместно используемым, подразумевая под этим то, что такое состояние используется более чем одним компонентом. Условность рассматриваемой градации выражается в том, что локальное состояние вполне может использоваться несколькими компонентами (например, состояние, определенное с помощью useState(), может в виде пропов передаваться дочерним компонентам), а глобальное состояние не обязательно используется всеми компонентами приложения (например, в Redux, где имеется одно хранилище для состояния всего приложения, обычно, создается отдельный срез (slice) состояния для каждой части UI, точнее, для логики управления этой частью).

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

Я не буду вдаваться в подробности работы каждого инструмента, а ограничусь общим описанием и ссылками на соответствующие материалы. Для прототипирования UI будет использоваться react-bootstrap.

Код на GitHub
Песочница на CodeSandbox

Создаем проект с помощью Create React App:

yarn create react-app state-management# илиnpm init react-app state-management# илиnpx create-react-app state-management

Устанавливаем зависимости:

yarn add bootstrap react-bootstrap nanoid# илиnpm i bootstrap react-bootstrap nanoid

  • bootstrap, react-bootstrap стили
  • nanoid утилита для генерации уникального ID

В src создаем директорию use-state для первого варианта тудушки.

useState()


Шпаргалка по хукам

Хук useState() предназначен для управления локальным состоянием компонента. Он возвращает массив с двумя элементами: текущим значением состояния и сеттером функцией для обновления этого значения. Сигнатура данного хука:

const [state, setState] = useState(initialValue)

  • state текущее значение состояния
  • setState сеттер
  • initialValue начальное или дефолтное значение

Одним из преимуществ деструктуризации массива, в отличие от деструктуризации объекта, является возможность использования произвольных названий переменных. По соглашению, название сеттера должно начинаться с set + название первого элемента с большой буквы ([count, setCount], [text, setText] и т.п.).

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

Структура проекта:

|--use-state  |--components    |--index.js    |--TodoForm.js    |--TodoList.js    |--TodoListItem.js  |--App.js

Думаю, тут все понятно.

В App.js мы с помощью useState() определяем начальное состояние приложения, импортируем и рендерим компоненты приложения, передавая им состояние и сеттер в виде пропов:

// хукimport { useState } from 'react'// компонентыimport { TodoForm, TodoList } from './components'// стилиimport { Container } from 'react-bootstrap'// начальное состояние// изучите его как следует, чтобы понимать логику обновленияconst initialState = {  todos: {    ids: ['1', '2', '3', '4'],    entities: {      1: {        id: '1',        text: 'Eat',        completed: true      },      2: {        id: '2',        text: 'Code',        completed: true      },      3: {        id: '3',        text: 'Sleep',        completed: false      },      4: {        id: '4',        text: 'Repeat',        completed: false      }    }  }}export default function App() {  const [state, setState] = useState(initialState)  const { length } = state.todos.ids  return (    <Container style={{ maxWidth: '480px' }} className='text-center'>      <h1 className='mt-2'>useState</h1>      <TodoForm setState={setState} />      {length ? <TodoList state={state} setState={setState} /> : null}    </Container>  )}

В TodoForm.js мы реализуем добавление новой задачи в список:

// хукimport { useState } from 'react'// утилита для генерации IDimport { nanoid } from 'nanoid'// стилиimport { Container, Form, Button } from 'react-bootstrap'// функция принимает сеттерexport const TodoForm = ({ setState }) => {  const [text, setText] = useState('')  const updateText = ({ target: { value } }) => {    setText(value)  }  const addTodo = (e) => {    e.preventDefault()    const trimmed = text.trim()    if (trimmed) {      const id = nanoid(5)      const newTodo = { id, text, completed: false }      // обратите внимание, как нам приходится обновлять состояние      setState((state) => ({        ...state,        todos: {          ...state.todos,          ids: state.todos.ids.concat(id),          entities: {            ...state.todos.entities,            [id]: newTodo          }        }      }))      setText('')    }  }  return (    <Container className='mt-4'>      <h4>Form</h4>      <Form className='d-flex' onSubmit={addTodo}>        <Form.Control          type='text'          placeholder='Enter text...'          value={text}          onChange={updateText}        />        <Button variant='primary' type='submit'>          Add        </Button>      </Form>    </Container>  )}

В TodoList.js мы просто рендерим список элементов:

// компонентimport { TodoListItem } from './TodoListItem'// стилиimport { Container, ListGroup } from 'react-bootstrap'// функция принимает состояние и сеттер только для того,// чтобы передать их потомкам// обратите внимание, как мы передаем отдельную задачуexport const TodoList = ({ state, setState }) => (  <Container className='mt-2'>    <h4>List</h4>    <ListGroup>      {state.todos.ids.map((id) => (        <TodoListItem          key={id}          todo={state.todos.entities[id]}          setState={setState}        />      ))}    </ListGroup>  </Container>)

Наконец, в TodoListItem.js происходит самое интересное здесь мы реализуем оставшиеся операции: переключение, обновление и удаление задачи:

// стилиimport { ListGroup, Form, Button } from 'react-bootstrap'// функция принимает задачу и сеттерexport const TodoListItem = ({ todo, setState }) => {  const { id, text, completed } = todo  // переключение задачи  const toggleTodo = () => {    setState((state) => {      // небольшая оптимизация      const { todos } = state      return {        ...state,        todos: {          ...todos,          entities: {            ...todos.entities,            [id]: {              ...todos.entities[id],              completed: !todos.entities[id].completed            }          }        }      }    })  }  // обновление задачи  const updateTodo = ({ target: { value } }) => {    const trimmed = value.trim()    if (trimmed) {      setState((state) => {        const { todos } = state        return {          ...state,          todos: {            ...todos,            entities: {              ...todos.entities,              [id]: {                ...todos.entities[id],                text: trimmed              }            }          }        }      })    }  }  // удаление задачи  const deleteTodo = () => {    setState((state) => {      const { todos } = state      const newIds = todos.ids.filter((_id) => _id !== id)      const newTodos = newIds.reduce((obj, id) => {        if (todos.entities[id]) return { ...obj, [id]: todos.entities[id] }        else return obj      }, {})      return {        ...state,        todos: {          ...todos,          ids: newIds,          entities: newTodos        }      }    })  }  // небольшой финт для упрощения обновления задачи  const inputStyles = {    outline: 'none',    border: 'none',    background: 'none',    textAlign: 'center',    textDecoration: completed ? 'line-through' : '',    opacity: completed ? '0.8' : '1'  }  return (    <ListGroup.Item className='d-flex align-items-baseline'>      <Form.Check        type='checkbox'        checked={completed}        onChange={toggleTodo}      />      <Form.Control        style={inputStyles}        defaultValue={text}        onChange={updateTodo}        disabled={completed}      />      <Button variant='danger' onClick={deleteTodo}>        Delete      </Button>    </ListGroup.Item>  )}

В components/index.js мы выполняем повторный экспорт компонентов:

export { TodoForm } from './TodoForm'export { TodoList } from './TodoList'

Файл scr/index.js выглядит следующим образом:

import React from 'react'import { render } from 'react-dom'// стилиimport 'bootstrap/dist/css/bootstrap.min.css'// компонентimport App from './use-state/App'const root$ = document.getElementById('root')render(<App />, root$)

Основные проблемы данного подхода к управлению состоянием:

  • Необходимость передачи состояния и/или сеттера на каждом уровне вложенности, обусловленная локальным характером состояния
  • Логика обновления состояния приложения разбросана по компонентам и смешана с логикой самих компонентов
  • Сложность обновления состояния, вытекающая из его иммутабельности
  • Однонаправленный поток данных, невозможность свободного обмена данными между компонентами, находящимися на одном уровне вложенности, но в разных поддеревьях виртуального DOM

Первые две проблемы можно решить с помощью комбинации useContext()/ useReducer().

useContext() + useReducer()


Шпаргалка по хукам

Контекст (context) позволяет передавать значения дочерним компонентам напрямую, минуя их предков. Хук useContext() позволяет извлекать значения из контекста в любом компоненте, обернутом в провайдер (provider).

Создание контекста:

const TodoContext = createContext()

Предоставление контекста с состоянием дочерним компонентам:

<TodoContext.Provider value={state}>  <App /></TodoContext.Provider>

Извлечение значения состония из контекста в компоненте:

const state = useContext(TodoContext)

Хук useReducer() принимает редуктор (reducer) и начальное состояние. Он возвращает значение текущего состояния и функцию для отправки (dispatch) операций (actions), на основе которых осуществляется обновление состояния. Сигнатура данного хука:

const [state, dispatch] = useReducer(todoReducer, initialState)

Алгоритм обновления состояния выглядит так: компонент отправляет операцию в редуктор, а редуктор на основе типа операции (action.type) и опциональной полезной нагрузки операции (action.payload) определенным образом изменяет состояния.

Результатом комбинации useContext() и useReducer() является возможность передачи состояния и диспетчера, возвращаемых useReducer(), любому компоненту, являющемуся потомком провайдера контекста.

Создаем директорию use-reducer для второго варианта тудушки. Структура проекта:

|--use-reducer  |--modules    |--components      |--index.js      |--TodoForm.js      |--TodoList.js      |--TodoListItem.js    |--todoReducer      |--actions.js      |--actionTypes.js      |--todoReducer.js    |--todoContext.js  |--App.js

Начнем с редуктора. В actionTypes.js мы просто определяем типы (названия, константы) операций:

const ADD_TODO = 'ADD_TODO'const TOGGLE_TODO = 'TOGGLE_TODO'const UPDATE_TODO = 'UPDATE_TODO'const DELETE_TODO = 'DELETE_TODO'export { ADD_TODO, TOGGLE_TODO, UPDATE_TODO, DELETE_TODO }

Типы операций определяются в отдельном файле, поскольку используются как при создании объектов операции, так и при выборе редуктора случая (case reducer) в инструкции switch. Существует другой подход, когда типы, создатели операции и редуктор размещаются в одном файле. Такой подход назвается утиной структурой файла.

В actions.js определяются так называемые создатели операций (action creators), возвращающие объекты определенной формы (для редуктора):

import { ADD_TODO, TOGGLE_TODO, UPDATE_TODO, DELETE_TODO } from './actionTypes'const createAction = (type, payload) => ({ type, payload })const addTodo = (newTodo) => createAction(ADD_TODO, newTodo)const toggleTodo = (todoId) => createAction(TOGGLE_TODO, todoId)const updateTodo = (payload) => createAction(UPDATE_TODO, payload)const deleteTodo = (todoId) => createAction(DELETE_TODO, todoId)export { addTodo, toggleTodo, updateTodo, deleteTodo }

В todoReducer.js определяется сам редуктор. Еще раз: редуктор принимает состояние приложения и операцию, отправленную из компонента, и на основе типа операции (и полезной нагрузки) выполняет определенные действия, приводящие к обновлению состояния. Обновление состояния выполняется точно также, как в предыдущем варианте тудушки, только вместо setState() редуктор возвращает новое состояние.

// утилита для генерации IDimport { nanoid } from 'nanoid'// типы операцийimport * as actions from './actionTypes'export const todoReducer = (state, action) => {  const { todos } = state  switch (action.type) {    case actions.ADD_TODO: {      const { payload: newTodo } = action      const id = nanoid(5)      return {        ...state,        todos: {          ...todos,          ids: todos.ids.concat(id),          entities: {            ...todos.entities,            [id]: { id, ...newTodo }          }        }      }    }    case actions.TOGGLE_TODO: {      const { payload: id } = action      return {        ...state,        todos: {          ...todos,          entities: {            ...todos.entities,            [id]: {              ...todos.entities[id],              completed: !todos.entities[id].completed            }          }        }      }    }    case actions.UPDATE_TODO: {      const { payload: id, text } = action      return {        ...state,        todos: {          ...todos,          entities: {            ...todos.entities,            [id]: {              ...todos.entities[id],              text            }          }        }      }    }    case actions.DELETE_TODO: {      const { payload: id } = action      const newIds = todos.ids.filter((_id) => _id !== id)      const newTodos = newIds.reduce((obj, id) => {        if (todos.entities[id]) return { ...obj, [id]: todos.entities[id] }        else return obj      }, {})      return {        ...state,        todos: {          ...todos,          ids: newIds,          entities: newTodos        }      }    }    // по умолчанию (при отсутствии совпадения со всеми case) редуктор возвращает состояние в неизменном виде    default:      return state  }}

В todoContext.js определяется начальное состояние приложения, создается и экспортируется провайдер контекста со значением состояния и диспетчером из useReducer():

// reactimport { createContext, useReducer, useContext } from 'react'// редукторimport { todoReducer } from './todoReducer/todoReducer'// создаем контекстconst TodoContext = createContext()// начальное состояниеconst initialState = {  todos: {    ids: ['1', '2', '3', '4'],    entities: {      1: {        id: '1',        text: 'Eat',        completed: true      },      2: {        id: '2',        text: 'Code',        completed: true      },      3: {        id: '3',        text: 'Sleep',        completed: false      },      4: {        id: '4',        text: 'Repeat',        completed: false      }    }  }}// провайдерexport const TodoProvider = ({ children }) => {  const [state, dispatch] = useReducer(todoReducer, initialState)  return (    <TodoContext.Provider value={{ state, dispatch }}>      {children}    </TodoContext.Provider>  )}// утилита для извлечения значений из контекстаexport const useTodoContext = () => useContext(TodoContext)

В этом случае src/index.js выглядит так:

// React, ReactDOM и стилиimport { TodoProvider } from './use-reducer/modules/TodoContext'import App from './use-reducer/App'const root$ = document.getElementById('root')render(  <TodoProvider>    <App />  </TodoProvider>,  root$)

Теперь у нас нет необходимости передавать состояние и функцию для его обновления на каждом уровне вложенности компонентов. Компонент извлекает состояние и диспетчера с помощью useTodoContext(), например:

import { useTodoContext } from '../TodoContext'// в компонентеconst { state, dispatch } = useTodoContext()

Операции отправляются в редуктор с помощью dispatch(), которому передается создатель операции, которому может передаваться полезная нагрузка:

import * as actions from '../todoReducer/actions'// в компонентеdispatch(actions.addTodo(newTodo))

Код компонентов
App.js:

// componentsimport { TodoForm, TodoList } from './modules/components'// stylesimport { Container } from 'react-bootstrap'// contextimport { useTodoContext } from './modules/TodoContext'export default function App() {  const { state } = useTodoContext()  const { length } = state.todos.ids  return (    <Container style={{ maxWidth: '480px' }} className='text-center'>      <h1 className='mt-2'>useReducer</h1>      <TodoForm />      {length ? <TodoList /> : null}    </Container>  )}

TodoForm.js:

// reactimport { useState } from 'react'// stylesimport { Container, Form, Button } from 'react-bootstrap'// contextimport { useTodoContext } from '../TodoContext'// actionsimport * as actions from '../todoReducer/actions'export const TodoForm = () => {  const { dispatch } = useTodoContext()  const [text, setText] = useState('')  const updateText = ({ target: { value } }) => {    setText(value)  }  const handleAddTodo = (e) => {    e.preventDefault()    const trimmed = text.trim()    if (trimmed) {      const newTodo = { text, completed: false }      dispatch(actions.addTodo(newTodo))      setText('')    }  }  return (    <Container className='mt-4'>      <h4>Form</h4>      <Form className='d-flex' onSubmit={handleAddTodo}>        <Form.Control          type='text'          placeholder='Enter text...'          value={text}          onChange={updateText}        />        <Button variant='primary' type='submit'>          Add        </Button>      </Form>    </Container>  )}

TodoList.js:

// componentsimport { TodoListItem } from './TodoListItem'// stylesimport { Container, ListGroup } from 'react-bootstrap'// contextimport { useTodoContext } from '../TodoContext'export const TodoList = () => {  const {    state: { todos }  } = useTodoContext()  return (    <Container className='mt-2'>      <h4>List</h4>      <ListGroup>        {todos.ids.map((id) => (          <TodoListItem key={id} todo={todos.entities[id]} />        ))}      </ListGroup>    </Container>  )}

TodoListItem.js:

// stylesimport { ListGroup, Form, Button } from 'react-bootstrap'// contextimport { useTodoContext } from '../TodoContext'// actionsimport * as actions from '../todoReducer/actions'export const TodoListItem = ({ todo }) => {  const { dispatch } = useTodoContext()  const { id, text, completed } = todo  const handleUpdateTodo = ({ target: { value } }) => {    const trimmed = value.trim()    if (trimmed) {      dispatch(actions.updateTodo({ id, trimmed }))    }  }  const inputStyles = {    outline: 'none',    border: 'none',    background: 'none',    textAlign: 'center',    textDecoration: completed ? 'line-through' : '',    opacity: completed ? '0.8' : '1'  }  return (    <ListGroup.Item className='d-flex align-items-baseline'>      <Form.Check        type='checkbox'        checked={completed}        onChange={() => dispatch(actions.toggleTodo(id))}      />      <Form.Control        style={inputStyles}        defaultValue={text}        onChange={handleUpdateTodo}        disabled={completed}      />      <Button variant='danger' onClick={() => dispatch(actions.deleteTodo(id))}>        Delete      </Button>    </ListGroup.Item>  )}


Таким образом, мы решили две первые проблемы, связанные с использованием useState() в качестве инструмента для управления состоянием. На самом деле, прибегнув к помощи одной интересной библиотеки, мы можем решить и третью проблему сложность обновления состояния. immer позволяет безопасно мутировать иммутабельные значения (да, я знаю, как это звучит), для этого достаточно обернуть редуктор в функцию produce(). Создадим файл todoReducer/todoProducer.js:

// утилита, предоставляемая immerimport produce from 'immer'import { nanoid } from 'nanoid'// типы операцийimport * as actions from './actionTypes'// сравните с "классической" реализацией редуктора// для обновления состояния используется draft - черновик исходного состоянияexport const todoProducer = produce((draft, action) => {  const {    todos: { ids, entities }  } = draft  switch (action.type) {    case actions.ADD_TODO: {      const { payload: newTodo } = action      const id = nanoid(5)      ids.push(id)      entities[id] = { id, ...newTodo }      break    }    case actions.TOGGLE_TODO: {      const { payload: id } = action      entities[id].completed = !entities[id].completed      break    }    case actions.UPDATE_TODO: {      const { payload: id, text } = action      entities[id].text = text      break    }    case actions.DELETE_TODO: {      const { payload: id } = action      ids.splice(ids.indexOf(id), 1)      delete entities[id]      break    }    default:      return draft  }})

Главное ограничение, накладываемое immer, состоит в том, что мы должны либо мутировать состояние напрямую, либо возвращать состояние, обновленное иммутабельно. Нельзя делать и то, и другое одновременно.

Вносим изменения в todoContext.js:

// import { todoReducer } from './todoReducer/todoReducer'import { todoProducer } from './todoReducer/todoProducer'// в провайдере// const [state, dispatch] = useReducer(todoReducer, initialState)const [state, dispatch] = useReducer(todoProducer, initialState)

Все работает, как и прежде, но код редуктора стало легче читать и анализировать.

Двигаемся дальше.

Redux Toolkit


Руководство по Redux Toolkit

Redux Toolkit это набор инструментов, облегчающий работу с Redux. Сам по себе Redux очень похож на то, что мы реализовали с помощью useContext() + useReducer():

  • Состояние всего приложения находится в одном хранилище (store)
  • Дочерние компоненты оборачиваются в Provider из react-redux, которому в виде пропа store передается хранилище
  • Редукторы (reducers) каждой части состояния объединяются с помощью combineReducers() в один корневой редуктор (root reducer), который передается при создании хранилища в createStore()
  • Компоненты подключаются к хранилищу с помощью connect() (+ mapStateToProps(), mapDispatchToProps()) и т.д.

Для реализации основных операций мы воспользуемся следующими утилитами из Redux Toolkit:

  • configureStore() для создания и настройки хранилища
  • createSlice() для создания частей состояния
  • createEntityAdapter() для создания адаптера сущностей

Чуть позже мы расширим функционал списка задач с помощью следующих утилит:

  • createSelector() для создания селекторов
  • createAsyncThunk() для создания преобразователей (thunk)

Также в компонентах мы будем использовать следующие хуки из react-redux: useDispatch() для получения доступа к диспетчеру и useSelector() для получения доступа к селекторам.

Создаем директорию redux-toolkit для третьего варианта тудушки. Устанавливаем Redux Toolkit:

yarn add @reduxjs/toolkit# илиnpm i @reduxjs/toolkit

Структура проекта:

|--redux-toolkit  |--modules    |--components      |--index.js      |--TodoForm.js      |--TodoList.js      |--TodoListItem.js  |--slices    |--todosSlice.js  |--App.js  |--store.js

Начнем с хранилища. store.js:

// утилита для создания хранилищаimport { configureStore } from '@reduxjs/toolkit'// редукторimport todosReducer from './modules/slices/todosSlice'// начальное состояниеconst preloadedState = {  todos: {    ids: ['1', '2', '3', '4'],    entities: {      1: {        id: '1',        text: 'Eat',        completed: true      },      2: {        id: '2',        text: 'Code',        completed: true      },      3: {        id: '3',        text: 'Sleep',        completed: false      },      4: {        id: '4',        text: 'Repeat',        completed: false      }    }  }}// хранилищеconst store = configureStore({  reducer: {    todos: todosReducer  },  preloadedState})export default store

В этом случае src/index.js выглядит так:

// React, ReactDOM & стили// провайдерimport { Provider } from 'react-redux'// основной компонентimport App from './redux-toolkit/App'// хранилищеimport store from './redux-toolkit/store'const root$ = document.getElementById('root')render(  <Provider store={store}>    <App />  </Provider>,  root$)

Переходим к редуктору. slices/todosSlice.js:

// утилиты для создания части состояния и адаптера сущностейimport {  createSlice,  createEntityAdapter} from '@reduxjs/toolkit'// создаем адаптерconst todosAdapter = createEntityAdapter()// инициализируем начальное состояние// получаем { ids: [], entities: {} }const initialState = todosAdapter.getInitialState()// создаем часть состоянияconst todosSlice = createSlice({  // уникальный ключ, используемый в качестве префикса при генерации создателей операции  name: 'todos',  // начальное состояние  initialState,  // редукторы  reducers: {    // данный создатель операции отправляет в редуктор операцию { type: 'todos/addTodo', payload: newTodo }    addTodo: todosAdapter.addOne,    // Redux Toolkit использует immer для обновления состояния    toggleTodo(state, action) {      const { payload: id } = action      const todo = state.entities[id]      todo.completed = !todo.completed    },    updateTodo(state, action) {      const { id, text } = action.payload      const todo = state.entities[id]      todo.text = text    },    deleteTodo: todosAdapter.removeOne  }})// экспортируем селектор для получения всех entities в виде массиваexport const { selectAll: selectAllTodos } = todosAdapter.getSelectors(  (state) => state.todos)// экспортируем создателей операцииexport const {  addTodo,  toggleTodo,  updateTodo,  deleteTodo} = todosSlice.actions// эскпортируем редукторexport default todosSlice.reducer

В компоненте для доступа к диспетчеру используется useDispatch(), а для отправки конкретной операции создатель операции, импортируемый из todosSlice.js:

import { useDispatch } from 'react-redux'import { addTodo } from '../slices/todosSlice'// в компонентеconst dispatch = useDispatch()dispatch(addTodo(newTodo))

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

Начнем с сервера.

В качестве fake API мы будем использовать JSON Server. Вот шпаргалка по работе с ним. Устанавливаем json-server и concurrently утилиту для выполнения двух и более команд:

yarn add json-server concurrently# илиnpm i json-server concurrently

Вносим изменения в раздел scripts package.json:

"server": "concurrently \"json-server -w db.json -p 5000 -d 1000\" \"yarn start\""

  • -w означает наблюдение за изменениями файла db.json
  • -p означает порт, по умолчанию запросы из приложения отправляются на порт 3000
  • -d задержка ответа от сервера

Создаем файл db.json в корневой директории проекта (state-management):

{  "todos": [    {      "id": "1",      "text": "Eat",      "completed": true,      "visible": true    },    {      "id": "2",      "text": "Code",      "completed": true,      "visible": true    },    {      "id": "3",      "text": "Sleep",      "completed": false,      "visible": true    },    {      "id": "4",      "text": "Repeat",      "completed": false,      "visible": true    }  ]}

По умолчанию все запросы из приложения отправляются на порт 3000 (порт, на котором запущен сервер для разработки). Для того, чтобы запросы отправлялись на порт 5000 (порт, на котором будет работать json-server), необходимо их проксировать. Добавляем в package.json следующую строку:

"proxy": "http://localhost:5000"

Запускаем сервер с помощью команды yarn server.

Создаем еще одну часть состояния. slices/filterSlice.js:

import { createSlice } from '@reduxjs/toolkit'// фильтрыexport const Filters = {  All: 'all',  Active: 'active',  Completed: 'completed'}// начальное состояние - отображать все задачиconst initialState = {  status: Filters.All}// состояние фильтраconst filterSlice = createSlice({  name: 'filter',  initialState,  reducers: {    setFilter(state, action) {      state.status = action.payload    }  }})export const { setFilter } = filterSlice.actionsexport default filterSlice.reducer

Вносим изменения в store.js:

// нам больше не требуется preloadedStateimport { configureStore } from '@reduxjs/toolkit'import todosReducer from './modules/slices/todosSlice'import filterReducer from './modules/slices/filterSlice'const store = configureStore({  reducer: {    todos: todosReducer,    filter: filterReducer  }})export default store

Вносим изменения в todosSlice.js:

import {  createSlice,  createEntityAdapter,  // утилита для создания селекторов  createSelector,  // утилита для создания преобразователей  createAsyncThunk} from '@reduxjs/toolkit'// утилита для выполнения HTTP-запросовimport axios from 'axios'// фильтрыimport { Filters } from './filterSlice'const todosAdapter = createEntityAdapter()const initialState = todosAdapter.getInitialState({  // добавляем в начальное состояние статус загрузки  status: 'idle'})// адрес сервераconst SERVER_URL = 'http://localhost:5000/todos'// преобразовательexport const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {  try {    const response = await axios(SERVER_URL)    return response.data  } catch (err) {    console.error(err.toJSON())  }})const todosSlice = createSlice({  name: 'todos',  initialState,  reducers: {    addTodo: todosAdapter.addOne,    toggleTodo(state, action) {      const { payload: id } = action      const todo = state.entities[id]      todo.completed = !todo.completed    },    updateTodo(state, action) {      const { id, text } = action.payload      const todo = state.entities[id]      todo.text = text    },    deleteTodo: todosAdapter.removeOne,    // создатель операции для выполнения всех задач    completeAllTodos(state) {      Object.values(state.entities).forEach((todo) => {        todo.completed = true      })    },    // создатель операции для очистки выполненных задач    clearCompletedTodos(state) {      const completedIds = Object.values(state.entities)        .filter((todo) => todo.completed)        .map((todo) => todo.id)      todosAdapter.removeMany(state, completedIds)    }  },  // дополнительные редукторы  extraReducers: (builder) => {    builder      // после начала выполнения запроса на получения задач      // меняем значение статуса на loading      // это позволит отображать индикатор загрузки в App.js      .addCase(fetchTodos.pending, (state) => {        state.status = 'loading'      })      // после получения задач от сервера      // записываем их в состояние      // и меняем статус загрузки      .addCase(fetchTodos.fulfilled, (state, action) => {        todosAdapter.setAll(state, action.payload)        state.status = 'idle'      })  }})export const { selectAll: selectAllTodos } = todosAdapter.getSelectors(  (state) => state.todos)// создаем и экспортируем кастомный селектор для получения отфильтрованных задачexport const selectFilteredTodos = createSelector(  selectAllTodos,  (state) => state.filter,  (todos, filter) => {    const { status } = filter    if (status === Filters.All) return todos    return status === Filters.Active      ? todos.filter((todo) => !todo.completed)      : todos.filter((todo) => todo.completed)  })export const {  addTodo,  toggleTodo,  updateTodo,  deleteTodo,  completeAllTodos,  clearCompletedTodos} = todosSlice.actionsexport default todosSlice.reducer

Вносим изменения в src/index.js:

// после импорта компонента "App"import { fetchTodos } from './redux-toolkit/modules/slices/todosSlice'store.dispatch(fetchTodos())

App.js выглядит так:

// хук для доступа к селекторамimport { useSelector } from 'react-redux'// индикатор загрузки - спиннерimport Loader from 'react-loader-spinner'// компонентыimport {  TodoForm,  TodoList,  TodoFilters,  TodoControls,  TodoStats} from './modules/components'// стилиimport { Container } from 'react-bootstrap'// селектор для получения всех entitites в виде массиваimport { selectAllTodos } from './modules/slices/todosSlice'export default function App() {  // получаем длину массива сущностей  const { length } = useSelector(selectAllTodos)  // получаем значение статуса  const loadingStatus = useSelector((state) => state.todos.status)  // стили для индикатора загрузки  const loaderStyles = {    position: 'absolute',    top: '50%',    left: '50%',    transform: 'translate(-50%, -50%)'  }  if (loadingStatus === 'loading')    return (      <Loader        type='Oval'        color='#00bfff'        height={80}        width={80}        style={loaderStyles}      />    )  return (    <Container style={{ maxWidth: '480px' }} className='text-center'>      <h1 className='mt-2'>Redux Toolkit</h1>      <TodoForm />      {length ? (        <>          <TodoStats />          <TodoFilters />          <TodoList />          <TodoControls />        </>      ) : null}    </Container>  )}

Код остальных компонентов
TodoControls.js:

// reduximport { useDispatch } from 'react-redux'// stylesimport { Container, ButtonGroup, Button } from 'react-bootstrap'// action creatorsimport { completeAllTodos, clearCompletedTodos } from '../slices/todosSlice'export const TodoControls = () => {  const dispatch = useDispatch()  return (    <Container className='mt-2'>      <h4>Controls</h4>      <ButtonGroup>        <Button          variant='outline-secondary'          onClick={() => dispatch(completeAllTodos())}        >          Complete all        </Button>        <Button          variant='outline-secondary'          onClick={() => dispatch(clearCompletedTodos())}        >          Clear completed        </Button>      </ButtonGroup>    </Container>  )}

TodoFilters.js:

// reduximport { useDispatch, useSelector } from 'react-redux'// stylesimport { Container, Form } from 'react-bootstrap'// filters & action creatorimport { Filters, setFilter } from '../slices/filterSlice'export const TodoFilters = () => {  const dispatch = useDispatch()  const { status } = useSelector((state) => state.filter)  const changeFilter = (filter) => {    dispatch(setFilter(filter))  }  return (    <Container className='mt-2'>      <h4>Filters</h4>      {Object.keys(Filters).map((key) => {        const value = Filters[key]        const checked = value === status        return (          <Form.Check            key={value}            inline            label={value.toUpperCase()}            type='radio'            name='filter'            onChange={() => changeFilter(value)}            checked={checked}          />        )      })}    </Container>  )}

TodoForm.js:

// reactimport { useState } from 'react'// reduximport { useDispatch } from 'react-redux'// libsimport { nanoid } from 'nanoid'// stylesimport { Container, Form, Button } from 'react-bootstrap'// action creatorimport { addTodo } from '../slices/todosSlice'export const TodoForm = () => {  const dispatch = useDispatch()  const [text, setText] = useState('')  const updateText = ({ target: { value } }) => {    setText(value)  }  const handleAddTodo = (e) => {    e.preventDefault()    const trimmed = text.trim()    if (trimmed) {      const newTodo = { id: nanoid(5), text, completed: false }      dispatch(addTodo(newTodo))      setText('')    }  }  return (    <Container className='mt-4'>      <h4>Form</h4>      <Form className='d-flex' onSubmit={handleAddTodo}>        <Form.Control          type='text'          placeholder='Enter text...'          value={text}          onChange={updateText}        />        <Button variant='primary' type='submit'>          Add        </Button>      </Form>    </Container>  )}

TodoList.js:

// reduximport { useSelector } from 'react-redux'// componentimport { TodoListItem } from './TodoListItem'// stylesimport { Container, ListGroup } from 'react-bootstrap'// selectorimport { selectFilteredTodos } from '../slices/todosSlice'export const TodoList = () => {  const filteredTodos = useSelector(selectFilteredTodos)  return (    <Container className='mt-2'>      <h4>List</h4>      <ListGroup>        {filteredTodos.map((todo) => (          <TodoListItem key={todo.id} todo={todo} />        ))}      </ListGroup>    </Container>  )}

TodoListItem.js:

// reduximport { useDispatch } from 'react-redux'// stylesimport { ListGroup, Form, Button } from 'react-bootstrap'// action creatorsimport { toggleTodo, updateTodo, deleteTodo } from '../slices/todosSlice'export const TodoListItem = ({ todo }) => {  const dispatch = useDispatch()  const { id, text, completed } = todo  const handleUpdateTodo = ({ target: { value } }) => {    const trimmed = value.trim()    if (trimmed) {      dispatch(updateTodo({ id, trimmed }))    }  }  const inputStyles = {    outline: 'none',    border: 'none',    background: 'none',    textAlign: 'center',    textDecoration: completed ? 'line-through' : '',    opacity: completed ? '0.8' : '1'  }  return (    <ListGroup.Item className='d-flex align-items-baseline'>      <Form.Check        type='checkbox'        checked={completed}        onChange={() => dispatch(toggleTodo(id))}      />      <Form.Control        style={inputStyles}        defaultValue={text}        onChange={handleUpdateTodo}        disabled={completed}      />      <Button variant='danger' onClick={() => dispatch(deleteTodo(id))}>        Delete      </Button>    </ListGroup.Item>  )}

TodoStats.js:

// reactimport { useState, useEffect } from 'react'// reduximport { useSelector } from 'react-redux'// stylesimport { Container, ListGroup } from 'react-bootstrap'// selectorimport { selectAllTodos } from '../slices/todosSlice'export const TodoStats = () => {  const allTodos = useSelector(selectAllTodos)  const [stats, setStats] = useState({    total: 0,    active: 0,    completed: 0,    percent: 0  })  useEffect(() => {    if (allTodos.length) {      const total = allTodos.length      const completed = allTodos.filter((todo) => todo.completed).length      const active = total - completed      const percent = total === 0 ? 0 : Math.round((active / total) * 100) + '%'      setStats({        total,        active,        completed,        percent      })    }  }, [allTodos])  return (    <Container className='mt-2'>      <h4>Stats</h4>      <ListGroup horizontal>        {Object.entries(stats).map(([[first, ...rest], count], index) => (          <ListGroup.Item key={index}>            {first.toUpperCase() + rest.join('')}: {count}          </ListGroup.Item>        ))}      </ListGroup>    </Container>  )}


Как мы видим, с появлением Redux Toolkit использовать Redux для управления состоянием приложения стало проще, чем комбинацию useContext() + useReducer() (невероятно, но факт), не считая того, что Redux предоставляет больше возможностей для такого управления. Однако, Redux все-таки рассчитан на большие приложения со сложным состоянием. Существует ли какая-то альтернатива для управления состоянием небольших и средних приложений, кроме useContext()/useReducer(). Ответ: да, существует. Это Recoil.

Recoil


Руководство по Recoil

Recoil это новый инструмент для управления состоянием в React-приложениях. Что значит новый? Это значит, что некоторые его API все еще находятся на стадии разработки и могут измениться в будущем. Однако, те возможности, которые мы будем использовать для создания тудушки, являются стабильными.

В основе Recoil лежат атомы и селекторы. Атом это часть состояния, а селектор часть производного состояния. Атомы создаются с помощью функции atom(), а селекторы с помощью функции selector(). Для извлечение значений из атомов и селекторов используются хуки useRecoilState() (для чтения и записи), useRecoilValue() (только для чтения), useSetRecoilState() (только для записи) и др. Компоненты, использующие состояние Recoil, должны быть обернуты в RecoilRoot. По ощущениям, Recoil представляет собой промежуточное звено между useState() и Redux.

Создаем директорию recoil для последнего варианта тудушки и устанавливаем Recoil:

yarn add recoil# илиnpm i recoil

Структура проекта:

|--recoil  |--modules    |--atoms      |--filterAtom.js      |--todosAtom.js    |--components      |--index.js      |--TodoControls.js      |--TodoFilters.js      |--TodoForm.js      |--TodoList.js      |--TodoListItem.js      |--TodoStats.js  |--App.js

Вот как выглядит атом списка задач:

// todosAtom.js// утилиты для создания атомов и селекторовimport { atom, selector } from 'recoil'// утилита для выполнения HTTP-запросовimport axios from 'axios'// адрес сервераconst SERVER_URL = 'http://localhost:5000/todos'// атом с состоянием для списка задачexport const todosState = atom({  key: 'todosState',  default: selector({    key: 'todosState/default',    get: async () => {      try {        const response = await axios(SERVER_URL)        return response.data      } catch (err) {        console.log(err.toJSON())      }    }  })})

Одной из интересных особенностей Recoil является то, что мы можем смешивать синхронную и асинхронную логику при создании атомов и селекторов. Он спроектирован таким образом, что у нас имеется возможность использовать React Suspense для отображения резервного контента до получения данных. Также у нас имеется возможность использовать предохранитель (ErrorBoundary) для перехвата ошибок, возникающих при создании атомов и селекторов, в том числе асинхронным способом.

В этом случае src/index.js выглядит так:

import React, { Component, Suspense } from 'react'import { render } from 'react-dom'// recoilimport { RecoilRoot } from 'recoil'// индикатор загрузкиimport Loader from 'react-loader-spinner'import App from './recoil/App'// предохранитель с официального сайта Reactclass ErrorBoundary extends Component {  constructor(props) {    super(props)    this.state = { error: null, errorInfo: null }  }  componentDidCatch(error, errorInfo) {    this.setState({      error: error,      errorInfo: errorInfo    })  }  render() {    if (this.state.errorInfo) {      return (        <div>          <h2>Something went wrong.</h2>          <details style={{ whiteSpace: 'pre-wrap' }}>            {this.state.error && this.state.error.toString()}            <br />            {this.state.errorInfo.componentStack}          </details>        </div>      )    }    return this.props.children  }}const loaderStyles = {  position: 'absolute',  top: '50%',  left: '50%',  transform: 'translate(-50%, -50%)'}const root$ = document.getElementById('root')// мы оборачиваем основной компонент приложения сначала в Suspense, затем в ErrorBoundaryrender(  <RecoilRoot>    <Suspense      fallback={        <Loader          type='Oval'          color='#00bfff'          height={80}          width={80}          style={loaderStyles}        />      }    >      <ErrorBoundary>        <App />      </ErrorBoundary>    </Suspense>  </RecoilRoot>,  root$)

Атом фильтра выглядит следующим образом:

// filterAtom.js// recoilimport { atom, selector } from 'recoil'// атомimport { todosState } from './todosAtom'export const Filters = {  All: 'all',  Active: 'active',  Completed: 'completed'}export const todoListFilterState = atom({  key: 'todoListFilterState',  default: Filters.All})// данный селектор использует два атома: атом фильтра и атом списка задачexport const filteredTodosState = selector({  key: 'filteredTodosState',  get: ({ get }) => {    const filter = get(todoListFilterState)    const todos = get(todosState)    if (filter === Filters.All) return todos    return filter === Filters.Completed      ? todos.filter((todo) => todo.completed)      : todos.filter((todo) => !todo.completed)  }})

Компоненты извлекают значения из атомов и селекторов с помощью указанных выше хуков. Например, код компонента TodoListItem выглядит так:

// хукimport { useRecoilState } from 'recoil'// стилиimport { ListGroup, Form, Button } from 'react-bootstrap'// атомimport { todosState } from '../atoms/todosAtom'export const TodoListItem = ({ todo }) => {  // данный хук - это как useState() для состояния Recoil  const [todos, setTodos] = useRecoilState(todosState)  const { id, text, completed } = todo  const toggleTodo = () => {    const newTodos = todos.map((todo) =>      todo.id === id ? { ...todo, completed: !todo.completed } : todo    )    setTodos(newTodos)  }  const updateTodo = ({ target: { value } }) => {    const trimmed = value.trim()    if (!trimmed) return    const newTodos = todos.map((todo) =>      todo.id === id ? { ...todo, text: value } : todo    )    setTodos(newTodos)  }  const deleteTodo = () => {    const newTodos = todos.filter((todo) => todo.id !== id)    setTodos(newTodos)  }  const inputStyles = {    outline: 'none',    border: 'none',    background: 'none',    textAlign: 'center',    textDecoration: completed ? 'line-through' : '',    opacity: completed ? '0.8' : '1'  }  return (    <ListGroup.Item className='d-flex align-items-baseline'>      <Form.Check type='checkbox' checked={completed} onChange={toggleTodo} />      <Form.Control        style={inputStyles}        defaultValue={text}        onChange={updateTodo}        disabled={completed}      />      <Button variant='danger' onClick={deleteTodo}>        Delete      </Button>    </ListGroup.Item>  )}

Код остальных компонентов
TodoControls.js:

// recoilimport { useRecoilState } from 'recoil'// stylesimport { Container, ButtonGroup, Button } from 'react-bootstrap'// atomimport { todosState } from '../atoms/todosAtom'export const TodoControls = () => {  const [todos, setTodos] = useRecoilState(todosState)  const completeAllTodos = () => {    const newTodos = todos.map((todo) => (todo.completed = true))    setTodos(newTodos)  }  const clearCompletedTodos = () => {    const newTodos = todos.filter((todo) => !todo.completed)    setTodos(newTodos)  }  return (    <Container className='mt-2'>      <h4>Controls</h4>      <ButtonGroup>        <Button variant='outline-secondary' onClick={completeAllTodos}>          Complete all        </Button>        <Button variant='outline-secondary' onClick={clearCompletedTodos}>          Clear completed        </Button>      </ButtonGroup>    </Container>  )}

TodoFilters.js:

// recoilimport { useRecoilState } from 'recoil'// stylesimport { Container, Form } from 'react-bootstrap'// filters & atomimport { Filters, todoListFilterState } from '../atoms/filterAtom'export const TodoFilters = () => {  const [filter, setFilter] = useRecoilState(todoListFilterState)  return (    <Container className='mt-2'>      <h4>Filters</h4>      {Object.keys(Filters).map((key) => {        const value = Filters[key]        const checked = value === filter        return (          <Form.Check            key={value}            inline            label={value.toUpperCase()}            type='radio'            name='filter'            onChange={() => setFilter(value)}            checked={checked}          />        )      })}    </Container>  )}

TodoForm.js:

// reactimport { useState } from 'react'// recoilimport { useSetRecoilState } from 'recoil'// libsimport { nanoid } from 'nanoid'// stylesimport { Container, Form, Button } from 'react-bootstrap'// atomimport { todosState } from '../atoms/todosAtom'export const TodoForm = () => {  const [text, setText] = useState('')  const setTodos = useSetRecoilState(todosState)  const updateText = ({ target: { value } }) => {    setText(value)  }  const addTodo = (e) => {    e.preventDefault()    const trimmed = text.trim()    if (trimmed) {      const newTodo = { id: nanoid(5), text, completed: false }      setTodos((oldTodos) => oldTodos.concat(newTodo))      setText('')    }  }  return (    <Container className='mt-4'>      <h4>Form</h4>      <Form className='d-flex' onSubmit={addTodo}>        <Form.Control          type='text'          placeholder='Enter text...'          value={text}          onChange={updateText}        />        <Button variant='primary' type='submit'>          Add        </Button>      </Form>    </Container>  )}

TodoList.js:

// recoilimport { useRecoilValue } from 'recoil'// componentsimport { TodoListItem } from './TodoListItem'// stylesimport { Container, ListGroup } from 'react-bootstrap'// atomimport { filteredTodosState } from '../atoms/filterAtom'export const TodoList = () => {  const filteredTodos = useRecoilValue(filteredTodosState)  return (    <Container className='mt-2'>      <h4>List</h4>      <ListGroup>        {filteredTodos.map((todo) => (          <TodoListItem key={todo.id} todo={todo} />        ))}      </ListGroup>    </Container>  )}

TodoStats.js:

// reactimport { useState, useEffect } from 'react'// recoilimport { useRecoilValue } from 'recoil'// stylesimport { Container, ListGroup } from 'react-bootstrap'// atomimport { todosState } from '../atoms/todosAtom'export const TodoStats = () => {  const todos = useRecoilValue(todosState)  const [stats, setStats] = useState({    total: 0,    active: 0,    completed: 0,    percent: 0  })  useEffect(() => {    if (todos.length) {      const total = todos.length      const completed = todos.filter((todo) => todo.completed).length      const active = total - completed      const percent = total === 0 ? 0 : Math.round((active / total) * 100) + '%'      setStats({        total,        active,        completed,        percent      })    }  }, [todos])  return (    <Container className='mt-2'>      <h4>Stats</h4>      <ListGroup horizontal>        {Object.entries(stats).map(([[first, ...rest], count], index) => (          <ListGroup.Item key={index}>            {first.toUpperCase() + rest.join('')}: {count}          </ListGroup.Item>        ))}      </ListGroup>    </Container>  )}


Заключение


Итак, мы с вами реализовали список задач с использованием четырех разных подходов к управлению состоянием. Какие выводы можно из всего этого сделать?

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

  • Для управления локальным состоянием (состоянием одного-двух компонентов; при условии, что эти два компонента тесно связаны между собой) используйте useState()
  • Для управления распределенным состоянием (состоянием двух и более автономных компонентов) или состоянием небольших и средних приложений используйте Recoil или сочетание useContext()/useReducer()
  • Обратите внимание, что если вам нужно просто передавать значения в глубоко вложенные компоненты, то вам вполне хватит useContext() (useContext() сам по себе не является инструментом для управления состоянием)
  • Наконец, для управления глобальным состоянием (состоянием всех или большинства компонентов) или состоянием сложного приложения используйте Redux Toolkit

Что касается MobX, то я слышал о нем много хорошего, но изучить как следует пока не успел.

Благодарю за внимание и хорошего дня.
Подробнее..

Реализация архитектуры Redux на MobX. Часть 1 Проблемные места Redux

13.03.2021 14:14:35 | Автор: admin

Эта часть статьи будет об осмыслении составляющих Redux. Так ли они необходимы, что является их аналогом. Также будет предложена более удобная альтернатива хука useReducer.

На самом деле, я не буду воспроизводить Redux. Вместо этого, в следующей части будет описано, как спроектировать более удобную архитектуру с использованием MobX. Следующая часть в основном для тех, у кого проекты на MobX получались запутанными, с неконтролируемыми изменениями.

В этой части статьи я хочу показать, что:

  • редьюсеры - это аналоги обычных чистых функции для получения нового состояния.

  • селекторы - это аналоги обычных функций с мемоизацией, которые возвращают данные.

  • dispatch + action + action creators - это аналог обычных вызовов функций, и разбиение на dispatch, action, action creators является зачастую ненужным и используются не к месту.

В статье не будет рассматриваться Redux Toolkit и прочие библиотеки для уменьшения бойлерплейта. Только то, в каком виде Redux использовался изначально. Отмечу, что похожая структура кода сторов, к которой пришли разработчики библиотеки Redux, существовала до появления Redux Toolkit в более user-friendly виде в других менеджерах состояний, вроде MobX, Vuex (я буду иногда его упоминать, т.к. он похож на MobX, и я немного знаком с ним).

Содержание первой части

Одно хранилище (стор) vs множество хранилищ

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

"Единственный источник правды" в определенном понимании действительно хорошая идея, упрощающая структуру приложения. Проще понять систему, когда ее структура везде одинаковая и все не локальные данные (данные с сервера, данные с localStorage/sessionStorage, общие данные не связанных напрямую компонентов) компонентов проходят через стор, хоть это и требует написание дополнительного кода. Но в отличие от Redux подхода, я бы считал единственным источником правды не один единственный стор на все приложение, а сам слой сторов.

Reducer vs чистая функция для мутации состояния. Нарушение SOLID

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

Из минусов - изначальная реализация сделана на ugly switch и имеет сложность O(n), а также количество ответственностей равное количеству actions в редьюсере. На практике сложность O(n) вряд ли заметно повлияет на производительность, если у вас не графическое приложения с перерисовкой по 60 раз в секунду. Другое дело лишний и усложненный код и ухудшение масштабируемости. Даже в редьюсерах можно заменить switсh на словарь с парами ключ-значение [actionNameKey][function] и код уже станет лучше.

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

Редьюсеры нарушают 3 принципа SOLID и один из принципов GRASP

Насколько я вижу, редьюсеры нарушают некоторые принципы проектирования. Некоторые из принципов проектирования довольно похожи, а соблюдение/нарушение одного ведет к соблюдению/нарушению второго. Нарушать их можно и зачастую даже нужно. Чрезмерное следование принципам проектирования ведет к ненужному усложнению кода. Об этом можно почитать по ссылкам "Когда не нужно использовать SOLID" и "О принципах проектирования". Особо интересный комментарий: "вопрос не в том, когда не нужно использовать SOLID, а в том, насколько использовать каждый из его принципов в своем, конкретном случае". И это вполне применимо и к другим принципам. Если какой-то принцип противоречит другому, то стоит выбирать в какой степени какой из принципов уместней будет нарушить/применить. В случае редьюсеров, хоть они и нарушают несколько принципов, к серьезным проблемам это не приводит, если нарушать принципы в меру.

Далее я буду употреблять термин "программные сущности". Имеются ввиду классы, модули, функции ит.п.

Нарушение single-responsibility principle (SRP)

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

Ошибочно может показаться, что у редьюсера только одна ответственность - вычислить новое состояние. На самом деле их много. Одна функция редьюсер отвечает за обработку разных actions. То есть количество причин для изменения редьюсера равно количеству actions. Когда редьюсер разрастается, он начинает отвечать за слишком многое. Конечно, тут приходит на помощь combineReducers.

Нарушение принципа открытости/закрытости

Принцип открытости/закрытости означает, что программные сущности должны быть:

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

  • закрыты для изменения: в результате расширения поведения сущности, не должны вноситься изменения в код, который эту сущность использует.

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

Если в программу добавится новый action для работы с одним уже существующим участком стора, то потребуется менять и код внутри редьюсера. А это уже нарушение принципа. Если использовать словари (а объекты в JS и есть словари), то проблема исчезает. В отличие от функции, словарь открыт для расширения и позволяет добавлять новые пары action-функция.

С другой стороны, редьюсеры слабо связаны с остальной частью программы и нарушение именно этого принципа в редьюсерах вряд ли приведет к существенным проблемам в обычных проектах. Если же делать, например, фреймворк поверх Redux с какими-то определенными actions и редьюсерами, то у программистов, использующих этот фреймворк, уже может возникнуть проблема с расширением его редьюсеров. Также стоит отметить, что привязка редьюсеров к конкретным типам actions затрудняет повторное использование редьюсеров.

Нарушение принципа подстановки Барбары Лисков

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

https://medium.com/webbdev/solid-4ffc018077da - в этой статье описан случай, при котором нарушается этот принцип: "Если оказывается, что в коде проверяется тип класса, значит принцип подстановки нарушается."

К типам событий, базовым типам и константным значением это тоже относится. Насколько я понимаю, любая проверка на конкретное значение входящего параметра функции нарушает принцип LSP, если вариантов значений в будущем может стать больше. Изначально данный принцип ограничивался наследованием, а сейчас он расширен. Об этом упоминается в книге "Чистая архитектура ..." Роберта Мартина в главе, посвященной этому принципу.

Вернемся к редьюсерам. Думаю, понятно, что редьюсеры, сделанные с использованием switch, нарушают данный принцип, так как каждый тип action они обрабатывают по-разному.

Последствия нарушения этого принципа аналогичны последствиям нарушения предыдущего принципа.

Нарушение принципа высокой связности (High Cohesion) из GRASP

Связность (не путать со связанностью/зацеплением) - то, насколько данные внутри модуля связанны друг с другом. "То, насколько хорошо все методы класса или все фрагменты метода соответствуют главной цели." Хорошо, когда связность высокая и плохо, когда низкая.

В редьюсере, использующем switch, несколько действий объединены в одну функцию. Они связаны состоянием, которое изменяют. Иногда есть связность по передаваемым в редьюсер данным. Но отсутствует связность по action.type. К тому же, сами действия в разных case не зависимы друг от друга и выполняют разные задачи. Для объекта/класса естественно хранить в себе несколько функций их зачастую можно переопределить/заменить. Но когда функция содержит в себе несколько незаменяемых функций для выполнения разных задач - это уже низкая связность, что плохо.

Данный принцип тесно пересекается с SRP принципом из SOLID. Последствием нарушения данного принципа в редьюсерах является снижение читабельности кода и усложнение их повторного использования. Редьюсер, обрабатывающий несколько actions, мало где можно использовать повторно. Функцию же, которая обрабатывает только один action, гораздо чаще можно использовать повторно.

Заключение по редьюсерам

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

Мутации с помощью чистых функции. Правильно, что у функции для изменения стора должно быть только одно назначение изменить его состояние. Не правильно, что поведение функции зависит от входящего параметра - action, типов которого может быть довольно много. Является функция чистой или нет - это не главное. Главное - делать функции в сторах как можно проще, читабельней, не реализовывать в них сторонний функционал. Вместо редьюсеров с таким кодом:

case 'todos/todoAdded': {  return {    ...state,    todos: [      ...state.todos,      action.paylod.newTodo    ]  }}

можно было бы писать примерно такие функции:

function todoAdded(state, newTodo) {  return {    ...state,    todos: [      ...state.todos,      newTodo    ]  }}

Функция-редьюсер заменена обычной чистой функцией, возвращающей новое состояние. Вместо поля type в action, как в Redux, здесь используется имя функции. И у функции только одна ответственность - изменить определенный участок состояния. Даже если потребуется вызывать функции изменения стора с помощью событий, все равно есть возможность вызвать функцию, передав имя события/функции через строковую переменную: todoStore['todoAdded'].

Селектор vs функция с мемоизацией, возвращающая данные

Селекторы в Redux используются для мемоизации данных и для более краткой записи при чтение какого-то участка стора. Не то, чтобы была какая-то существенная проблема в них. Просто введено дополнительное понятие.

Аналогом селекторов в MobX являются вычисляемые значения (computed values). Так же, если надо просто сократить запись, можно использовать обычные JS геттеры. Можно сделать геттеры вычисляемыми значениями. Стоит упомянуть, что и в Vuex есть аналог селекторов - геттеры.

Согласно Redux, селекторы можно использовать как в компонентах, так и в middleware. Я использую аналогичную логику. Только геттеры и вычисляемые значения в случае MobX являются частью сторов.

Бизнес-логика в сторах MobX vs бизнес-логика в middleware's

В Redux сайд-эффекты вынесены из стора и это, на мой взгляд, более правильно в больших проектах.

В Vuex и MobX очень распространен подход, когда в action пишется вызов API и прочая бизнес-логика. В Vuex сторах сторонняя бизнес-логика вообще является частью сторов. Я считаю, что это превращает стор в аналог контроллера с пассивной моделью. То есть в контроллере пишется бизнес-логика, а модель ответственна за получения данных (из базы или с сервера). В MVC это считается плохим подходом. О пассивнойи активной модели MVC можно прочитать в wikipedia - MVC, наиболее частые ошибки. С другой стороны, сторы Vuex и MobX - это вариация MVVM, а не MVC.

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

С какого-то момента это с большой вероятностью затруднит рефакторинг и повторное использование кода. Если у функции 2 ответственности (например, api запрос и изменение состояния), то ее сложнее использовать повторно. Функцию можно разбить в пределах стора тогда у самой функции проблема исчезнет, но онане исчезнет у класса стора. Если же у функции или класса будет только одна ответственность, то будет меньше причин для их изменений и их чаще и проще можно будет использовать повторно.

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

Action creators, actions, dispatch VS прямые вызовы функций

Действиев Redux - это просто объект с именем события. Фактическиможно рассматривать действие как событие.

События нужны, чтобы оповестить определенные программные сущности, что в системе произошло какое-то действие. Диспетчеризация событий в Redux является вариацией паттерна pub/sub (издатель-подписчик).

Pub/sub нужен для передачи событий от издателя к подписчикам через посредника (канал событий) так, чтобы издатель и подписчик не знали друг о друге.

Я полагаю, что события можно использовать для случаев, если:

  • нужно избавиться от большой связанности. Например, когда один (либо несколько) объект должен вызывать схожий метод у множества других объектов.

  • программная сущность должна уметь вызывать методы других объектов или оповещать, что выполнилось такое-то действие, но сама она закрыта для изменений. То есть она может вызывать только методы по заранее заданным именам, либо отправлять заранее определенные уведомления. Пример такой ситуации - вызовы методов жизненного цикла в React компонентах. Свой метод жизненного цикла в компонент не добавить, т.к. он не будет вызываться внутренним механизмом react-а.

Где в Redux используются action-ы? В трех местах:

  1. в компоненте, чтобы вызвать middleware;

  2. в middleware, чтобы обновить стор;

  3. в редьюсере, чтобы знать, как именно нужно обновить стор.

Что здесь не так? Все эти функции в типичном фронтенд приложении открыты для изменений. Их создает команда проекта, а не какая-то другая команда, у которой нет доступа к ним. К тому же actions не скрываются наследованием или еще каким-нибудь механизмом. Так что в типичных случаях для всех 3-х мест использования actions причиной может являться лишь избавление от чрезмерной связанности объектов. Рассмотрим 3 места использования actions для первого случая (избавление от от большой связанности).

1. Подписка middleware-ом на событие компонента (отправителя событие). Посмотрим, зачем здесь нужна подписка?

Action, используемый для вызова middleware, не используется в сторе. То есть со стором он не связан.

Смотрим далее. Часто ли один компонент вызывает одним dispatch много middleware-ов? По-моему, практически никогда. Да и в таком случае сложнее отследить логику работы, т.к. не явно будут вызваны несколько функций. Более понятно будет, если объединить вызов нескольких middleware-ов в новом middleware и вызывать только его.

По-моему, подписку здесь можно безболезненно заменить обычным вызовом функции.

2. Middleware обновляет стор. Аналогично. Часто ли нужно обработать один action разными редьюсерами? Довольно редко такое встречается на практике и лучше заменить неявный вызов явным.Думаю, что подписку и здесь можно заменить на обычный вызов функции.

3. actions в редьюсере. Редьюсер принимает много actions и по имени action определяет, какой код надо выполнить. Это случай я уже рассматривал в главе о редьюсерах.

Дополнение - а нужен ли useReducer?

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

У useReducer аналогичная задача, но для состояния компонента. Я уже писал выше про недостатки редьюсеров и как упросить код. В компонентах обычно меньше actions, т.к. они локальные, поэтому и проблем меньше. Основные оставшиеся недостатки у useReducer - лишний код и снижение читабельности.

Функционал, аналогичный useReducer, можно сделать вручную через useState, но это долго и не удобно. Но можно не делать это каждый раз, а вынести отдельно, что я и сделал. Я написал хук useStateWithUpdaters, чтобы писать более читабельный и удобный код. Ниже пример его использования:

const updaters = {  subtract: (prevState, value) => (    { ...prevState, count: prevState.count - value }  ),  add: (prevState, value) => (    { ...prevState, count: prevState.count + value }  ),};const MyComponent = () => {  const [{ count }, {add, subtract}] =         useStateWithUpdaters({ count: 0 }, updaters);  return (    <div>      Count: {count}      <button onClick={() => subtract(1)}>-</button>      <button onClick={() => add(1)}>+</button>    </div>  );};

Его реализацию вы можете найти в issue.
Есть TypeScript версия.

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

Подробнее..

Фреймворк-независимое браузерное SPA

14.03.2021 16:17:43 | Автор: admin

1. Но... зачем?

  1. Существует огромное количество фреймворков для разработкиSPA(Single Page Application).

  2. Существует огромное количество документации, иллюстрирующей как создавать приложение набазе конкретного фреймворка.

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

Учитывая насколько hype-driven является разработка софта внаше время, можно быть уверенным втом что через несколько лет будут существовать новые модные фреймворки для фронтенд разработки. Вмомент когда фреймворк набазе которого построено приложение выходит измоды вывынуждены либо поддерживать устаревшую (legacy) кодовую базу либо стартовать процесс перевода приложения нановый фреймворк.

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

Данная статья является примером построения SPA сиспользованием высокоуровневых принципов дизайна архитектуры. При этом конкретные библиотеки ифреймворки выбираются для удовлетворения ответственностей, определённых желаемой архитектурой.

2. Архитектурные цели иограничения

Цели:

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

  2. Стимулируется разделение ответственностей (separation ofconcerns) иследовательно модульность кода так что:

    • Модули легко поддаются тестированию

    • Интеграции свнешними сервисами (boundaries) атакже грязные хаки иворкэраунды вынесены вотдельные модули инепротянуты через несколько различных файлов. Таким образом смена реализации интеграции ссервисом или отказ отхака становится реалистичной задачей анедолгосрочным рефакторингом

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

  4. Позволяет реализовать слабую связность между модулями. Как следствие изменение реализации модуля (или его замена) недолжна приводить кизменению кода, использующего данный модуль.

  5. Механики взаимодействия модулей неприводят кнедопустимым проблемам спроизводительностью.

  6. Зависимости отбиблиотек неторчат сквозь всю кодовую базу. Они удерживаются внутри ограниченного числа модулей, ответственных заинтеграцию сконкретной библиотекой.

Ограничения:

Приложение должно работать вбраузере. Следовательно оно должно быть написано сиспользованием (или скомпилированов) HTML+CSS для определения статического интерфейса иJavaScript для добавления динамического поведения.

3. Ограничим тему данной статьи

Существует большое количество архитектурных подходов кструктурированию кода. Наиболее распространенные наданный момент: слоеная (layered), луковичная (onion) ишестигранная (hexagonal). Беглое сравнение было дано вмоей предыдущейстатье.

Данная статья ограничивается слоем представления втерминологии слоеной/луковичной архитектур поскольку большинство SPA занимается исключительно отображением данных. Таким образом слои домена (domain) иприложения (application) могут быть проигнорированы. Как следствие, наиболее естественный способ понять назначение такого приложения получить обзорное представление ослое представления.

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

Интересно отметить что вслучае отсутствия вышеупомянутых слоев приложение напоминает классическую шестигранную структуру (также называемуюPorts and Adapters) вкоторой представлениеявляетсяприложением. Взгляните наинтеграцию сlocalStorage вTodoMVCпримере созданном вкачестве иллюстрации кданной статье (папкаboundaries/local-storage).

4. Структура файлов. Как заставить SPAкричать?

Будем исходить из терминологии дяди Боба.

Рассмотрим типичный онлайн магазин. Приблизительно так онмогбы быть нарисован насалфетке владельцем бизнеса:

Рисунок1: типичный онлайн магазин, нарисованный насалфетке

Каким может быть наиболее кричащий способ структурировать кодовую базу? Нарисунке 2все страницы отражены как папки.

Рисунок2: структура папок верхнего уровня, отражающая страницы определённые нарисунке 1

Заметим что мыдобавили папку shared как место где будут определены общие UIблоки, такие как шаблон, панель навигации, корзина.

Наши страницы построены излогических (ивидимых) частей. Пока что назовем их блоками иположим впапку сименем parts. Посмотрим что получилось (рисунок 3).

Рисунок3: размещение вложенных блоков внутри подпапки parts

Как видно, вложенность выглядит отвратительно уже для второго уровня для страницы goods catalogue. Путь goods-catalogue/parts/goods-list/parts/good-details.jsуже награнице адекватной длины пути кфайлу. При том что вреальных приложениях два уровня вложенности далеко непредел.

Давайте избавимся отпапок parts вфайловой структуре. Посмотрим нарисунок 4.

Рисунок4: вложенные блоки вынесены изпапок parts

Теперь внутри пути goods-catalogue/goods-listнаходится три файла.goods-list.js(родительский) расположен между файлами, определяющими вложенные внего блоки. Вреальных проектах, учитывая кол-во разнородных файлов (js, html, css) это приводит кневозможности разделить файлы, определяющие текущий блок ифайлы, отвечающими завложенные внего блоки.

Решение:

  1. Если конкретный блок определяется несколькими файлам создаем для него папку.

    • goods-listявляется блоком исостоит изболее чем одного файла, потому для него создана папка.

    • filtersявляется блоком состоящим изодного файла, потому для него несоздана отдельная папка.

  2. Если конкретный блок (неважно изодного файла или изнескольких) являетсявложенным блоком добавим кназванию файла префикс _. Таким образом все вложенные блоки будут подняты кверху папки вфайловом обозревателе.

    • _goods-list folderявляется вложенным блоком относительноgoods-catalogueсоответственно кназванию папки добавлен префикс.

    • goods-list.jsявляется частью определения блока_goods-listсоответственно префикс недобавлен.

    • _good-details.jsявляется вложенным блоком относительно_goods-listсоответственно префикс добавлен.

Рисунок5: использование префикса _ для разделения вложенных блоков отихродителей

Готово! Теперь открывая папку сблоком мыможем сразуже увидеть иоткрыть основной файл, определяющий данный блок. После чего при необходимости перейти квложенному блоку. Обратите внимание что папкаpagesбыла переименована вcomponentsнарисунке 5. Так сделано поскольку страницы иблоки логически являются разными вещами новтерминологии HTML итоидругое можетбы представлено какcomponent. Сэтого момента папкаcomponentsявляется основной папкой нашего приложения, домом для слоя представления.

5. Язык разработки. JavaScript?

Единственный язык который может быть выполнен вбраузере это JavaScript. Существует множество статей посвященных его несуразности. Выможетепосмеяться онем (тайм код1-20), ноэто только веселая часть...

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

Потому даже при использовании чистого JavaScript разработчик вынужден использовать транспилятор (babel) стем чтобы получить JavaScript, совместимый сбраузерами изсовременного имодного JavaScript. Поскольку использование транспилятора исоответствующее замедление сборки приложения неизбежно нет причин игнорировать другие, более предсказуемые иболее функциональные языки программирования.

Глубокий анализ возможных опций лежит вне границ данной статьи, номой персональный выборTypeScriptпосколькуон:

  • Обеспечивает проверку типов наэтапе компиляции

  • Будучи над-множеством JavaScript, может выполнять JavaScript код без дополнительного интеграционного кода

  • Определения типов (typings) могут быть добавлены поверх существующего JavaScript кода без его изменения. Благодаря простоте этой возможности, большинство существующих npm пакетов уже покрыты тайпингами. Таким образом выможете использовать эти пакеты так, как будтобы они являются TypeScript пакетами. Соответственно ихиспользование также является типо-безопасным.

Хинт: рекомендую посмотреть всторонуasm.js,blazorиelmесли вызаинтересованы вдругих опциях

6. Требования кдизайну приложения

Давайте вспомним ограничения, накладываемые браузерами: HTML, CSS, JavaScript. Также вспомним структуру файлов, определенную вразделе4: дерево директорий, отражающее дерево визуальных элементов.

Таким образомпервой целью [6.1]будет возможность определения компонентов средствами HTML иCSS иихпоследующее переиспользование другими компонентами.

Существенный недостаток чистого HTML состоит втом что оннетипизирован. Существует достаточное количество движков шаблонизации, таких какunderscore.js,handlebars.js. Однако все они принимают навход строки, что ограничивает нас впроверке корректности используемых вшаблоне данных наэтапе компиляции приложения.

Таким образомвторой целью [6.2]является возможность определить TypeScript интерфейсы отражающие все свойства, используемые вшаблоне (компоненте).После чего наэтапе компиляции выбросить исключение вслучае если вразметке компонента происходит обращение кнеопределенному свойству.

Каждый UIэлемент может выглядеть поразному взависимости отпереданных ему данных. HTML элементы принимают данные ввидеHTMLатрибутов. Этого достаточно для статической разметки. Для динамически изменяемой разметки нам необходимы некоторые хранилища для данных. Вэтих хранилищах данные будут изменяться взависимости отдействий пользователя настранице. Втоже время, мынедолжны потерять возможность передавать данные вкомпоненты ввиде атрибутов.

Таким образомтретьей целью [6.3]является возможность компонентов принимать данные изатрибутов иизхранилищ одновременно. Компоненты должны быть перерисованы при изменении любой части принимаемых данных.

Четвертой целью [6.4]станет определение требований ктаким хранилищам:

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

  • Должна существовать возможность создавать хранилища вместе сконкретным компонентом. Это необходимо для случая когда различные экземпляры компонента автономны друг отдруга идолжны иметь различные наборы данных.

  • Хранилища должны иметь возможность использовать сервисы ифункции слоев Domain иApplication. Воизбежание сильной связности между хранилищем играницами приложения, сервисы должны быть использованы спомощью механизмаDependency Injection. Хранилища должны ссылаться только наинтерфейсы.

Ипоследнее мынехотим чтобы данные внутри хранилищ были публичными воизбежание нежелательного изменения данных впроцессе рендеринга. Хранилища должны быть ответственны засвою целостность. Компонентыже, всвою очередь, должны быть ничем большим чем строго-типизированные-и-оптимизированные-html-шаблоны. Для достижения подобного разделения хранилища должны инкапсулировать данные внутри себя ипредоставлять методы для работы сэтими данными. Другими словами, хранилища должны быть классическими экземплярами классов.

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

  • Лучшей читаемости кода, т.к. разработчик может предположить назначение компонента изнабора данных принимаемых этим компонентом.

  • Лучшей производительности т.к. можно избежать перерисовки компонента вслучае изменения неиспользуемых данных.

Таким образом,пятая цель [6.5] позволить хранилищам данных быть определенными как классические TypeScript классы. Обозначить механику определения среза данных, используемого конкретным компонентом.

Держа эти цели вголове, давайте перечислим необходимые логические блоки кода:

  • Компоненты (Components) строго типизированные HTML шаблоны + CSS стили

  • Модели вида (ViewModels) классы, инкапсулирующие состояние данных, используемое компонентом (ивсей иерархией компонентов под ним).

  • Фасады моделей вида (ViewModel facades) ограничивают видимость свойств модели вида теми, которые используются вконкретном компоненте.

Рисунок6: желаемая структура кода вслое представления

  • Не-пунктирные стрелки отражают рендеринг компонентов родительскими компонентами. Направление стрелки отражает направление передачи атрибутов.

  • Пунктирные линии отражают зависимости одних логических кусков кода отдругих (ссылки).

  • Блоки сзеленой рамкой границы модуля. Каждый модуль/подмодуль отражен выделенной под него папкой. Общие модули лежат впапке shared.

  • Голубые блоки модели вида. Модели вида определены поштуке намодуль/подмодуль.

Что упущено? Заметьте как модели вида нарисунке 6не имеют никаких параметров. Это всегда справедливо для модулей верхнего уровня (страниц) иглобальных моделей вида. Ноподмодули зачастую зависят отпараметров, определённых впроцессе работы сприложением.

Обозначимшестую цель [6.6] позволить атрибутам подмодуля быть использованными моделью вида этого подмодуля.

Рисунок7: атрибуты передаются нетолько вкорневой компонент модуля ноивего модель вида

7. Техническая реализация

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

7.1. Компоненты

Для отрисовки строго-типизированной разметки можно использовать синтаксис tsx (типизированныйjsx). Рендеринг tsx поддерживается различными библиотеками, такими какReact,PreactandInferno. TsxНЕявляется чистым HTML, тем неменее онможет быть автоматически сконвертирован в/из HTML. Потому зависимость отtsx мне кажется допустимой т.к.вслучае миграции начистый HTML, значительная часть работы может быть выполнена автоматически.

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

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

Другими словами,компонентылишены состояния. Представим ихчерез выражение UI=F(S) где

  • UI видимая разметка

  • F определение компонента

  • S текущее значение данных внутри модели вида (здесь идалее вьюмодели)

Пример компонента может выглядет так:

interfaceITodoItemAttributes{name:string;status:TodoStatus;toggleStatus:()=>void;removeTodo:()=>void;}constTodoItemDisconnected=(props:ITodoItemAttributes)=>{constclassName=props.status===TodoStatus.Completed?'completed':'';return(<liclassName={className}><divclassName="view"><inputclassName="toggle"type="checkbox"onChange={props.toggleStatus}checked={props.status===TodoStatus.Completed}/><label>{props.name}</label><buttonclassName="destroy"onClick={props.removeTodo}/></div></li>)}

Этот компонент отвечает заотрисовку одного todo элемента внутриTodoMVCприложения.

Единственная зависимость вэтом коде это зависимость отсинтаксиса JSX. Соответственно этот компонент может быть отрисован различными библиотеками. Стаким подходом замена библиотеки для отрисовки все еще неявляется бесплатной, ноявляется реалистичной.

Итого мыдостигли целей[6.1]и[6.2].

Хинт: яиспользую react дляTodoMVC приложенияприведенного вкачестве примера.

7.2. Модели Вида (вьюмодели)

Как было сказано ранее, мыхотим чтобы вьюмодели были написаны ввиде TypeScript классов стем что-бы:

  • Обеспечивать инкапсуляцию данных.

  • Предоставлять возможность взаимодействия сослоями domain/application посредством механизма dependency injection.

Однако, классы непредоставляют встроенных механик перерисовки компонентов использующих данные, инкапсулированные экземпляром класса.

Применим принципы реактивного интерфейса (reactive UI). Подробное описание этих принципов приведено вэтом документе. Данный подход был впервые представлен вWPF (C#) иназванModel-View-ViewModel. ВJavaScript сообществе, объекты предоставляющие доступ кобозреваемым (observable) данным чаще называются хранилищами (stores) следуя терминологииflux. Отмечу чтохранилищеэто очень абстрактный термин, онможет определять:

  • Глобальное хранилище данных для всего приложения.

  • Доменный объект, инкапсулирующий логику логику бизнеса инепривязанный кконкретному компоненту ноинеявляющийся глобальным.

  • Локальное хранилище данных для конкретного компонента или иерархии компонентов.

Таким образом любая вьюмодель является хранилищем, нонекаждое хранилище является вьюмоделью.

Определим ограничения креализации вьюмоделей:

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

  • Вьюмодель недолжна ссылаться накомпоненты инедолжна знать отом что существуют конкретные компоненты, ссылающиеся наэту вьюмодель.

Яиспользуюmobxдекораторы для того, чтобы сделать поля класса обозреваемыми. Пример вьюмодели:

classTodosVM{@mobx.observableprivatetodoList:ITodoItem[];//use"poormanDI",butintherealapplicationstodoDaowillbeinitializedbythecalltoIoCcontainerconstructor(props:{status:TodoStatus},privatereadonlytodoDao:ITodoDAO=newTodoDAO()){this.todoList=[];}publicinitialize(){this.todoList=this.todoDao.getList();}@mobx.actionpublicremoveTodo=(id:number)=>{consttargetItemIndex=this.todoList.findIndex(x=>x.id===id);this.todoList.splice(targetItemIndex,1);this.todoDao.delete(id);}publicgetTodoItems=(filter?:TodoStatus)=>{returnthis.todoList.filter(x=>!filter||x.status===filter)asReadonlyArray<Readonly<ITodoItem>>;}///...othermethodssuchascreationandstatustogglingoftodoitems...}

Обратите внимание что мыссылаемся наmobx напрямую, однако декораторы неприсутствуют втеле методов.

Япродемонстрирую как абстрагировать реактивность иубрать зависимость отmobx вследующей статье. Пока что посчитаем достаточным обращаться кдекораторам через пространство имен mobx. При таком ограничении заменить декораторы надекораторы издругой библиотеки можно будет спомощью автоматизированного скрипта.

Также обратите внимание что конструктор вьюмодели принимает первый аргумент типа{status:TodoStatus}. Это позволяет удовлетворитьцели [6.6]. Тип должен совпадать стипом определяющим атрибутыкорневого компонентамодуля. Ниже обобщенный интерфейс вьюмодели:

interfaceIVMConstructor<TProps,TVMextendsIViewModel<TProps>>{new(props:TProps,...dependencies:any[]):TVM;}interfaceIViewModel<IProps=Record<string,unknown>>{initialize?:()=>Promise<void>|void;cleanup?:()=>void;onPropsChanged?:(props:IProps)=>void;}

Все методы вьюмодели необязательны. Они могут быть определены для:

  • Выполнения кода при создании вьюмодели

  • Выполнения кода при удалении вьюмодели

  • Выполнения кода при изменении атрибутов (под-)модуля.

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

Как показано нарисунке7, точкой входа для модуля является его корневой компонент. Таким образом вьюмодель должна быть создана когда корневой компонент модуля добавлен вструктуру DOM(mounted) иудалена когда онудаляется состраницы(unmounted). Решить эту задачу можно спомощью техники компонентов высшего порядка (higher order components).

Определим тип функции:

typeTWithViewModel=<TAttributes,TViewModelProps,TViewModel>(moduleRootComponent:Component<TAttributes&TViewModelProps>,vmConstructor:IVMConstructor<TAttributes,TViewModel>,)=>Component<TAttributes>

Эта функция возвращает компонент высшего порядка над moduleRootComponent, который:

  • Должен обеспечить создание вьюмодели перед созданием имонтированием (mount) компонента.

  • Должен обеспечить зачистку(удаление) вьюмодели при демонтировании (unmount).

Просмотритереализациюиспользованную для приведенного вкачестве примераTodoMVCприложения. Эта реализация немного более сложна т.к. учитываем возможность использования автономного IoCконтейнера, что выходит зарамки данной статьи.

Пример использования данной функции:

constTodoMVCDisconnected=(props:{status:TodoStatus})=>{return<sectionclassName="todoapp"><Header/><TodoListstatus={props.status}/><FooterselectedStatus={props.status}/></section>};constTodoMVC=withVM(TodoMVCDisconnected,TodosVM);

Вразметку корневой страницы приложения (либо роутера, зависит оттого что как построено ваше приложение), результирующий компонент будет вставлен как<TodoMVCstatus={statusReceivedFromRouteParameters}/>. После чего, экземплярTodosVMстановится доступным для всех под-компонентов внутри компонентаTodoMVC.

Важно что реализация создания вьюмодели, атакже доступности вьюмодели для дочерних компонентов, скрыта внутри withVM.

  • TodoMVCDisconnected компонент независит отбиблиотеки рендера

  • TodoMVC компонент может быть прорендерен вкомпоненте, независящем отбиблиотеки рендера

  • TodosVM ссылается только надекораторы. Потому, как описано выше, еёотвязка отmobx реальна.

Хинт: вреализации изпримера, функцияwithVMзависит отreact context API. Выможете попробовать реализовать аналогичное поведение вобход контекст апи. Важно, что реализация должна быть синхронизирована среализацией доступа квьюмодели изфасадов вьюмоделей смотрите описание функцииconnectFnвследующем разделе.

7.3. Фасады вьюмоделей

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

Попробуем вместо классических фасадов использовать функции, принимающие вьюмодель (или несколько вьюмоделей) ивозвращающие набор функций/данных, необходимых конкретному компоненту. Назовем ихфункциями среза (slicing function). Что если такая функция будет получать атрибуты компонента, который она обслуживает, вкачестве последнего аргумента?

Рисунок8: передача атрибутов компонента фасаду вьюмодели (функции среза/slicing function)

Посмотрим насинтаксис (вслучае одной вьюмодели):

typeTViewModelFacade=<TViewModel,TOwnProps,TVMProps>(vm:TViewModel,ownProps?:TOwnProps)=>TVMProps

Выглядит очень похоже нафункцию connectизбиблиотеки Redux. Стой лишь разницей что вместо аргументовmapStateToProps,mapDispatchToActionsиmergePropsмы имеем один аргумент функцию среза, которая должна вернуть данные иметоды одним объектом. Ниже пример функции среза для компонентаTodoItemDisconnectedивьюмоделиTodosVM.

constsliceTodosVMProps=(vm:TodosVM,ownProps:{id:string,name:string,status:TodoStatus;})=>{return{toggleStatus:()=>vm.toggleStatus(ownProps.id),removeTodo:()=>vm.removeTodo(ownProps.id),}}

Заметка: Яназвал аргумент функции, содержащий атрибуты компонента OwnProps что-бы приблизить его ктерминологии применяемой вreact/redux.

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

typeconnectFn=<TViewModel,TVMProps,TOwnProps={}>(ComponentToConnect:Component<TVMProps&TOwnProps>,mapVMToProps:TViewModelFacade<TViewModel,TOwnProps,TVMProps>,)=>Component<TOwnProps>constTodoItem=connectFn(TodoItemDisconnected,sliceTodosVMProps);

Отрисовка такового компонента всписке todo элементов:<TodoItemid={itemId}name={itemName}status={itemStatus}/>

Заметьте чтоconnectFnскрывает детали реализации реактивности:

  • Она берёт компонентTodoItemDisconnectedифункцию срезаsliceTodosVMProps обе незнающие ничего ореактивности иобиблиотеке для рендеринга JSX.

  • Она возвращает компонент, который будет перерисован реактивно как только данные, инкапсулированные вьюмоделью, изменяться.

Смотрите нареализациюфункции connectFnдля TodoMVCприложения, сделанного вкачестве примера.

8. Заключение

Итого весь код, относящийся кконкретным бизнес задачам приложения, независим отфреймворков. TypeScript объекты, функции, TSX это все кчему мыпривязаны.

Надеюсь что прочтение этой статьи продемонстрировало пользу проработки архитектуры SPA приложения вперёд старта разработки. Буду счастлив если майндсет хотябы одного разработчика настарте разработки SPA изменится сберем свежий фреймворк ивсе должно быть хорошо наподумаем что конкретно нужно сделать ивыберем подходящие инструменты.

Всеже, можетли слой представления быть полностью независим отфреймворков вреальном приложении?

Для того что-бы убрать ссылки наmobx, react иmobx-react изслоя представления, нужно сделать немного больше:

  • Абстрагироваться отmobx декораторов

  • Абстрагировать все фреймворко-зависимые библиотеки, используемые слоем представления. КпримеруTodoMVCзависит отбиблиотек react-router иreact-router-dom.

  • Абстрагироваться отсинтетических событий, специфичных для конкретной библиотеки, отрисовывающей JSX.

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

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

P.S. Сравнение рассмотренной структуры иеереализации спопулярными фреймворками для разработки SPA:

  • Всравнении сосвязкойReact/Redux: вьюмодели заменяютreducers,action creatorsиmiddlewares. Вьюмодели содержат состояние (являются stateful). Нет time-travel. Множество хранилищ. Отсутствие просадки производительности вызванной наличием большого числа использований функции connect скакой тологикой внутри. Redux-dirven приложения становятся все медленнее имедленнее стечением времени иззадобавления новых connected компонентов вприложение. При этом несуществует какого токонкретного ботлнека, устранением которого можно былобы исправить ситуацию.

  • Всравнении сvue: строго типизированные представления благодаря TSX. Вьюмодели являются обычными классами инетребуют использования функций сторонних библиотек, равно как необязаны удовлетворять интерфейсу, определенному сторонними фреймворками. Vue.js заставляет определять состояниевнутри определенной структурыимеющей свойства data,methods, ит.д. Отсутствие vue-специфических директив исинтаксиса привязки кмодели.

  • Всравнении сangular: строго типизированные представления благодаря TSX. Отсутствие angular-специфических директив исинтаксиса привязки кмодели. Инкапсуляция данных внутри вьюмоделей вотсутствие двусторонней привязки данных (two-way data binding).Хинт: для определенных сценариев, таких как формы, двусторонняя привязка данных удобна иполезна.

  • Всравнении счистым react где управление состоянием выполняется спомощью хуков (hooks, такие какuseState/useContext):Лучшее разделение ответственностей. Вьюмодели могут восприниматься втерминологии реакта как контейнер компоненты, которые лишены возможность рендерить что-либо иявляются ответственными исключительно заработу сданными. Нет необходимости:

    • следить запоследовательностью вызова хуков.

    • отслеживать зависимости хуков useEffect внутри deps массива.

    • проверять смонтированли все еще компонент после каждого асинхронного действия.

    • следить что замыкания изпредыдущих рендеров неиспользуются внутри обработчика хука эффекта.

    Как любая технология, хуки (ивчастности useEffect) требует разработчика следовать некоторым рекомендациям. Эти рекомендации неявляются частью интерфейсов, ноприняты как подход, модель мышления (mental model) или стандартные практики (best practices). Прекраснаястатья про использование хуковотчлена команды разработки react. Прочитайте ееиответьте себе надва вопроса:

    • Что выполучаете используя хуки?

    • Как много правил, неконтролируемых компилятором/линтером исложно отслеживаемых наручном код ревью, нужно соблюдать чтобы использование хуков оставалось предсказуемым?Если второй список получился больше первого это хороший сигнал что относится кхукам стоит сбольшой осторожностью.Хинт: примерфрустрацииотхуков

  • Всравнении сreact-mobx интеграцией. Структура кода неопределяется пакетом react-mobx инепредлагается документацией кнему. Разработчик должен придумать подход кструктурированию кода сам. Рассмотренную встатье структуру можно считать таким подходом.

  • Всравнении сmobx-state-tree: Вьюмодели являются обычными классами инетребуют использования функций сторонних библиотек, равно как необязаны удовлетворять интерфейсу, определенному сторонними фреймворками.Определение типавнутри mobx-state-tree опирается наспецифические функции этого пакета. Использование mobx-state-tree всвязке сTypeScript провоцирует дублирование информации поля типа объявляются как отдельный TypeScript интерфейс нопри этом обязаны быть перечислены вобъекте, используемом для определения типа.

Оригинал статьи наанглийском языке вблоге автора (меня же)

Подробнее..

Продвинутые дженерики в TypeScript. Доклад Яндекса

03.05.2021 12:05:25 | Автор: admin
Дженерики, или параметризованные типы, позволяют писать более гибкие функции и интерфейсы. Чтобы зайти дальше, чем параметризация одним типом, нужно понять лишь несколько общих принципов составления дженериков и TypeScript раскроется перед вами, как шкатулка с секретом. AlexandrNikolaichev объяснил, как не бояться вкладывать дженерики друг в друга и использовать автоматический вывод типов в ваших проектах.

Всем привет, меня зовут Александр Николаичев. Я работаю в Yandex.Cloud фронтенд-разработчиком, занимаюсь внутренней инфраструктурой Яндекса. Сегодня расскажу об очень полезной вещи, без которой сложно представить современное приложение, особенно большого масштаба. Это TypeScript, типизация, более узкая тема дженерики, и то, почему они нужны.

Сначала ответим на вопрос, почему TypeScript и при чем тут инфраструктура. У нас главное свойство инфраструктуры ее надежность. Как это можно обеспечить? В первую очередь можно тестировать.


У нас есть юнит- и интеграционные тесты. Тестирование нужная стандартная практика.

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

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

Синтаксис


Чтобы провести базовый ликбез, сначала рассмотрим основы синтаксиса.

Дженерик в TypeScript это тип, который зависит от другого типа.

У нас есть простой тип, Page. Мы его параметризуем неким параметром <T>, записывается через угловые скобки. И мы видим, что есть какие-то строки, числа, а вот <T> у нас вариативный.

Кроме интерфейсов и типов мы можем тот же синтаксис применять и для функций. То есть тот же параметр <T> пробрасывается в аргумент функции, и в ответе мы переиспользуем тот же самый интерфейс, туда его тоже пробросим.

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

Для классов существует похожий синтаксис. Прокидываем параметр в приватные поля, и у нас есть некий геттер. Но там мы тип не записываем. Почему? Потому что TypeScript умеет выводить тип. Это очень полезная его фишка, и мы ее применим.

Посмотрим, что происходит при использовании этого класса. Мы создаем инстанс, и вместо нашего параметра <T> передаем один из элементов перечисления. Создаем перечисление русский, английский язык. TypeScript понимает, что мы передали элемент из перечисления, и выводит тип lang.

Но посмотрим, как работает вывод типа. Если мы вместо элементов перечисления передадим константу из этого перечисления, то TypeScript понимает, что это не всё перечисление, не все его элементы. И будет уже конкретное значение типа, то есть lang en, английский язык.

Если мы передаем что-то другое, допустим, строку, то, казалось бы, она имеет то же самое значение, что и у перечисления. Но это уже строка, другой тип в TypeScript, и мы его получим. И если мы передаем строку как константу, то вместо строки и будет константа, строковый литерал, это не все строки. В нашем случае будет конкретная строка en.

Теперь посмотрим, как можно это расширить.

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

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

Посмотрим, как это происходит в функциях. Мы создаем функцию random. Она рандомно дает либо первый аргумент, либо второй.

Первый аргумент типа A, второй типа B. Соответственно, возвращается их объединение: либо тот, либо этот. В первую очередь мы можем явно типизировать функцию. Мы указываем, что A это строка, B число. TypeScript посмотрит, что мы явно указали, и выведет тип.

Но мы также можем воспользоваться и выводом типа. Главное знать, что выводится не просто тип, а минимально возможный тип для аргумента.

Предположим, мы передаем аргумент, строковый литерал, и он должен соответствовать типу A, а второй аргумент, единичка, типу B. Минимально возможные для строкового литерала и единички литерал A и та же самая единичка. Нам TypeScript это и выведет. Получается такое сужение типов.

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

Отношение типов


Типы можно условно рассматривать как некие множества. Посмотрим на диаграммку, где показан кусок всего множества типов.

Мы видим, что типы в нем связаны некими отношениями. Но какими? Это отношения частичного порядка значит, для типа всегда указан его супертип, то есть тип выше его, который охватывает все возможные значения.

Если идти в обратную сторону, то у каждого типа может быть подтип, меньше его.

Какие супертипы у строки? Любые объединения, которые включают строку. Строка с числом, строка с массивом чисел, с чем угодно. Подтипы это все строковые литералы: a, b, c, или ac, или ab.

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

И в этом порядке есть тип, как бы самый верхний, unknown. И самый нижний, аналог пустого множества, never. Never подтип любого типа. А unknown супертип любого типа.

И, конечно, есть исключение any. Это специальный тип, он вообще игнорирует этот порядок, и используется, если мы мигрируем с JavaScript, чтобы не заботиться о типах. С нуля не рекомендуется использовать any. Это стоит делать, если нам на самом деле не важно положение типа в этом порядке.

Посмотрим, что нам даст знание этого порядка.

Мы можем ограничивать параметры их супертипами. Ключевое слово extends. Мы определим тип, дженерик, у которого будет всего один параметр. Но мы скажем, что он может быть только подтипом строки либо самой строкой. Числа мы передавать не сможем, это вызовет ошибку типа. Если мы явно типизируем функцию, то в параметрах можем указать только подтипы строки или строку apple и orange. Обе строки это объединение строковых литералов. Проверка прошла.


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

Посмотрим, как расширить эти ограничения.

Мы ограничились просто строкой. Но строка слишком простой тип. Хотелось бы работать с ключами объектов. Чтобы с ними работать, мы сначала поймем, как устроены сами ключи объектов и их типы.

У нас есть некий объектик. У него какие-то поля: строки, числа, булевы значения и ключи по именам. Чтобы получить ключи, используем ключевое слово keyof. Получаем объединение всех имен ключей.

Если мы хотим получить значения, то можем сделать это через синтаксис квадратных скобок. Это похоже на JS-синтаксис. Только он возвращает типы. Если мы передадим все подмножество ключей, то получим объединение вообще всех значений этого объекта.

Если мы хотим получить часть, то мы можем так и указать не все ключи, а какое-то подмножество. Мы ожидаемо получим лишь те поля, которым соответствуют указанным ключам. Если совсем всё редуцировать до одинарного случая, это одно поле, и один ключ дает одно значение. Так можно получать соответствующее поле.

Посмотрим, как использовать ключи объекта.

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

Посмотрим, как это работает с keyof. Мы определили тип CustomPick. На самом деле это почти полная копия библиотечного типа Pick из TypeScript. Что он делает?

У него есть два параметра. Второй это не просто какой-то параметр. Он должен быть ключами первого. Мы видим, что у нас он расширяет keyof от <T>. Значит, это должно быть какое-то подмножество ключей.

Далее мы для каждого ключа K из этого подмножества бегаем по объекту, кладем то же самое значение и специально убираем синтаксисом опциональность, минус знак вопроса. То есть все поля будут обязательными.

Смотрим на применение. У нас есть объект, в нем имена полей. Мы можем брать только их подмножество a, b или c, либо все сразу. Мы взяли a или c. Выводятся только соответствующие значения, но мы видим, что поле a стало обязательным, потому что мы, условно говоря, убрали знак вопроса. Мы определили такой тип, использовали его. Никто нам не мешает взять этот дженерик и засунуть его в еще один дженерик.

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

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

Указанные аргументы не обязательно должны идти по порядку. Вроде как параметр P расширяет ключи T в дженерике CustomPick. Но никто нам не мешал указать его первым параметром, а T вторым. TypeScript не идет последовательно по параметрам. Он смотрит все параметры, что мы указали. Потом решает некую систему уравнений, и если он находит решение типов, которые удовлетворяют этой системе, то проверка типов прошла.

В связи с этим можно вывести такой забавный дженерик, у которого параметры расширяют ключи друг друга: a это ключи b, b ключи a. Казалось бы, как такое может быть, ключи ключей? Но мы знаем, что строки TypeScript это на самом деле строки JavaScript, а у JavaScript-строк есть свои методы. Соответственно, подойдет любое имя метода строки. Потому что имя у метода строки это тоже строка. И у нее оттуда есть свое имя.

Соответственно, мы можем получить такое ограничение, и система уравнений разрешится, если мы укажем нужные типы.

Посмотрим, как это можно использовать в реальности. Используем для API. Есть сайт, на котором деплоятся приложения Яндекса. Мы хотим вывести проект и сервис, который ему соответствует.

В примере я взял проект для запуска виртуальных машин qyp для разработчиков. Мы знаем, что у нас в бэкенде есть структура этого объекта, берем его из базы. Но помимо проекта есть и другие объекты: черновики, ресурсы. И у всех есть свои структуры.

Более того, мы хотим запрашивать не весь объект, а пару полей имя и имя сервиса. Такая возможность есть, бэкенд позволяет передавать пути и получить неполную структуру. Здесь описан DeepPartial. Мы его чуть попозже научимся конструировать. Но это значит, что передается не весь объект, а какая-то его часть.

Мы хотим написать некоторую функцию, которая бы запрашивала эти объекты. Напишем на JS. Но если присмотреться, видны опечатки. В типе какой-то Projeact, в путях тоже опечатка в сервисе. Нехорошо, ошибка будет в рантайме.

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

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

Посмотрим, как получить функцию.


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

Допустим, для проекта мы где-то описываем его тип. В нашем проекте мы генерируем тайпинги из protobuf-файлов, которые доступны в общем репозитории. Далее мы смотрим, что у нас есть все используемые типы: Project, Draft, Resource.

Посмотрим на реализацию. Разберем по порядку.

Есть функция. Сначала смотрим, чем она параметризуется. Как раз этими уже ранее описанными именами. Посмотрим, что она возвращает. Она возвращает значения. Почему это так? Мы использовали синтаксис квадратных скобок. Но так как мы передаем в тип одну строку, объединение строковых литералов при использовании это всегда одна строка. Невозможно составить строку, которая одновременно была бы и проектом, и ресурсом. Она всегда одна, и значение тоже одно.

Обернем все в DeepPartial. Необязательный тип, необязательная структура. Самое интересное это параметры. Задаем их с помощью еще одного дженерика.

Тип, которым параметризуется дженерик параметров, также совпадает с ограничением на функцию. Он может принимать только тип имени Project, Resource, Draft. ID это, конечно, строка, она нам не интересна. Вот тип, который мы указали, один из трех. Интересно, как устроена функция для путей. Это еще один дженерик почему бы нам его не переиспользовать. На самом деле все, что он делает, просто создает функцию, которая возвращает массив из any, потому что в нашем объекте могут быть поля любых типов, мы не знаем каких. В такой реализации мы получаем контроль над типами.

Если кому-то это показалось простым, перейдем к управляющим конструкциям.

Управляющие конструкции


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

Что такое условные типы? Они очень напоминают тернарки в JavaScript, только для типов. У нас есть условие, что тип a это подтип b. Если это так, то возврати c. Если это не так возврати d. То есть это обычный if, только для типов.

Смотрим, как это работает. Мы определим тип CustomExclude, который по сути копирует библиотечный Exclude. Он просто выкидывает нужные нам элементы из объединения типов. Если a это подтип b, то возврати пустоту, иначе возврати a. Это странно, если посмотреть, почему это работает с объединениями.

Нам пригодится специальный закон, который говорит: если есть объединение и мы проверяем с помощью extends условия, то мы проверяем каждый элемент отдельно и потом снова их объединяем. Это такой транзитивный закон, только для условных типов.

Когда мы применяем CustomExclude, то смотрим поочередно на каждый элемент наблюдения. a расширяет a, a это подтип, но верни пустоту; b это подтип a? Нет верни b. c это тоже не подтип a, верни c. Потом мы объединяем то, что осталось, все плюсики, получаем b и c. Мы выкинули a и добились того, что хотели.

Ту же технику можно использовать, чтобы получить все ключи кортежа. Мы знаем, что кортеж это тот же массив. То есть у него есть JS-методы, но нам это не нужно, а нужны только индексы. Соответственно, мы просто из всех ключей кортежа выкидываем имена всех методов и получаем только индексы.

Как нам определить наш ранее упомянутый тип DeepPartial? Тут впервые используется рекурсия. Мы пробегаемся по всем ключам объекта и смотрим. Значение это объект? Если да, применяем рекурсивно. Если нет и это строка или число оставляем и все поля делаем опциональными. Это все-таки Partial-тип.

Этот рекурсивный вызов и условные типы на самом деле делают TypeScript полным по Тьюрингу. Но не спешите этому радоваться. Он надает вам по рукам, если вы попытаетесь провернуть нечто подобное, абстракцию с большой рекурсивностью.

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

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

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

Мы хотим сделать функцию, которая их патчит, патчит значения. Добавить ядер или убавить. В том же JavaScript, как вы могли догадаться, возникают опечатки. Здесь же мы складываем число со строкой не очень хорошо.

В TypeScript почти ничего не изменилось, но на самом деле этот контроль еще на уровне IDE вам подскажет, что вы не можете передать ничего кроме этой строки или конкретного числа.

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

Посмотрим, как это реализовывается в TypeScript.

Мы определили функцию. У нее как раз три аргумента, сам объект, который мы патчим, и имя поля. Но это не просто имя поля. Оно может быть только именем числовых полей. Мы сейчас узнаем, как это делается. И сам патчер, который представляет собой чистую функцию.

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

Вновь разберем по порядку, как это происходит.

Мы пробегаемся по всем ключам переданного объекта, потом делаем вот такую процедуру. Смотрим, что поле объекта это подтип нужного, то есть числовое поле. Если да, то важно, что мы записываем не значение поля, а имя поля, а иначе вообще, пустоту, never.

Но потом получился такой странный объект. Все числовые поля стали иметь свои имена в качестве значений, а все не числовые поля пустоту. Дальше мы берем все значения этого странного объекта.

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

В примере показано: есть простой объект, поле единичка. Это число? Да. Поле число, это число? Да. Последняя строка не число. Получаем только нужные, числовые поля.

С этим разобрались. Самый сложный я оставил напоследок. Это вывод типа Infer. Захват типа в условной конструкции.


Он неотделим от предыдущей темы, потому что работает только с условной конструкцией.

Как это выглядит? Допустим, мы хотим знать элементы массива. Пришел некий тип массива, нам бы хотелось узнать конкретный элемент. Мы смотрим: нам пришел какой-то массив. Это подтип массива из переменной x. Если да верни этот x, элемент массива. Если нет верни пустоту.

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

Если мы передаем массив строк, то нам ожидаемо возвратится строка. И важно понимать, что у нас определяется не просто тип. Из массива строк визуально понятно: там строки. А вот с кортежем все не так просто. Нам важно знать, что определяется минимально возможный супертип. Понятно, что все массивы как бы являются подтипами массива с any или с unknown. Нам это знание ничего не дает. Нам важно знать минимально возможное.

Предположим, мы передаем кортеж. На самом деле кортежи это тоже массивы, но как нам сказать, что за элементы у этого массива? Если есть кортеж из строки числа, то на самом деле это массив. Но элемент должен иметь один тип. А если там есть и строка, и число значит, будет объединение.

TypeScript это и выведет, и мы получим для такого примера именно объединение строки и числа.

Можно использовать не только захват в одном месте, но и сколько угодно переменных. Допустим, мы определим тип, который просто меняет элементы кортежа местами: первый со вторым. Мы захватываем первый элемент, второй и меняем их местами.

Но на самом деле не рекомендуется слишком с этим заигрываться. Обычно для 90% задач хватает захвата всего лишь одного типа.


Посмотрим пример. Задача: нужно показать в зависимости от состояния запроса либо хороший вариант, либо плохой. Тут представлены скриншоты из нашего сервиса для деплоймента приложений. Некая сущность, ReplicaSet. Если запрос с бэкенда вернул ошибку, надо ее отрисовать. При этом есть API для бэкенда. Посмотрим, при чем тут Infer.

Мы знаем, что используем, во-первых, redux, а, во-вторых, redux thunk. И нам надо преобразовать библиотечный thunk, чтобы получить такую возможность. У нас есть плохой путь и хороший.

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

Про JavaScript я не привожу сравнение, потому что оно не имеет смысла. В JavaScript в принципе нельзя никак контролировать типы и полагаться только на память. Здесь нет плохого варианта, потому что его просто нет.

Мы знаем, что хотим получить этот тип. Но у нас же не просто так появляется action. Нам нужно вызвать dispatch с этим action. И нам нужен вот такой вид, где по ключу запроса нужно отображать ошибку. То есть нужно поверх redux thunk примешивать такую дополнительную функциональность с помощью метода withRequestKey.

У нас, конечно, есть этот метод, но у нас есть и исходный метод API getReplicaSet. Он где-то записан и нам надо оверрайдить redux thunk с помощью некоего адаптера. Посмотрим, как это сделать.

Надо получить функцию вот такого вида. Это thunk с такой дополнительной функциональностью. Звучит страшно, но не пугайтесь, сейчас разберем по полочкам, чтобы было понятно.

Есть адаптер, который расширяет исходный библиотечный тип. Мы к этому библиотечному типу просто примешиваем дополнительный метод withRequestKey и кастомный вызов. Посмотрим, в чем основная фишка дженерика, какие параметры используются.

Первое это просто наш API, объектик с методами. Мы можем делать getReplicaSet, получать проекты, ресурсы, неважно. Мы в текущем методе используем конкретный метод, а второй параметр это просто имя метода. Далее мы используем параметры функции, которую запрашиваем, используем библиотечный тип Parameters, это TypeScript-тип. И аналогично для ответа с бэкенда мы используем библиотечный тип ReturnType. Это для того, что вернула функция.

Дальше мы просто прокидываем свой кастомный вывод в AsyncThunk-тип, который нам предоставила библиотека. Но что это за вывод? Это еще один дженерик. На самом деле он выглядит просто. Мы сохраняем не только ответ с сервера, но и наши параметры, то, что мы передали. Просто чтобы в Reducer за ними следить. Дальше мы смотрим withRequestKey. Наш метод просто добавляет ключ. Что он возвращает? Тот же адаптер, потому что мы можем его переиспользовать. Мы вообще не обязаны писать withRequestKey. Это просто дополнительная функциональность. Она оборачивает и рекурсивно нам возвращает тот же самый адаптер, и мы прокидываем туда то же самое.

Наконец, посмотрим, как выводить в Reducer то, что нам этот thunk вернул.


У нас есть этот адаптер. Главное помнить, что там четыре параметра: API, метод API, параметры (вход) и выход. Нам надо получить выход. Но мы помним, что выход у нас кастомный: и ответ сервера, и параметр запроса.

Как это сделать с помощью Infer? Мы смотрим, что на вход подается этот адаптер, но он вообще любой: any, any, any, any. Мы должны вернуть этот тип, выглядит он вот так, ответ сервера и параметры запроса. И мы смотрим, на каком месте должен быть вход. На третьем. На это место мы и помещаем наш захват типа. Получаем вход. Аналогично, на четвертом месте стоит выход.

TypeScript основывается на структурной типизации. Он эту структуру разбирает и понимает, что вход находится здесь, на третьем месте, а выход на четвертом. И мы возвращаем нужные типы.

Так мы добились вывода типов, у нас есть доступ к ним уже в самом Reducer. В JavaScript сделать такое в принципе невозможно.
Подробнее..

За что я не люблю Redux

19.06.2021 18:15:23 | Автор: admin

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

Flux - это вовсе не что-то новое либо революционное

Не то, чтобы я не люблю его за это. Скорее, в этом даже нет ничего плохого - если решение хорошее и проверенное временем, разве это плохо? Скорее мне просто удивительно слышать, как подход, реализованный в Flux в целом (и в Redux в частности) некоторые пытаются выдавать за что-то инновационное и революционное. Да и само решение, на мой взгляд, как минимум не лишено недостатков. Но об этом далее, а пока вспомню молодость.

В начале нулевых я разрабатывал ПО и библиотеки компонент на Delphi под Windows (сначала Win9x, потом XP). В операционных системах Windows с самых первых, если не ошибаюсь, версий, для визуальных элементов интерфейса (кнопки, поля ввода) существует понятие окна - да, окно это не только то, что с рамкой, почти любой визуальный элемент управления имел свое собственное окно. Окно в данном случае - это некая структура в памяти, которая имеет ассоциированный с ним идентификатор (window handle) и оконную функцию (см. далее). Если мы хотим выполнить какое-либо действие над элементом, например - изменить текст кнопки, мы должны упаковать это действие в специальную структуру-сообщение (Window message) и отправить ее соответствующему окну. Структура состоит из закодированного типа сообщения (например WM_SETTEXT - для установки текста) и собственно payload. Будучи отправленным, сообщение не попадает в обработчик напрямую - вместо этого оно отправится в очередь, из которой его извлекает некий диспетчер и вызывает оконную функцию того окна, в которое мы сообщение отправили, передав его в виде параметра. Оконная функция в простейшем случае - это большой switch, где в зависимости от типа сообщения мы передаем управление более конкретному обработчику. Ничего не напоминает?

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

Нарушение принципа "Low coupling, high cohesion"

Если вы ищите простую и понятную формулировку, что такое качественный дизайн, то эти четыре слова из подзаголовка коротко и емко его описывают - внутри модуля или компонента его элементы должны быть тесно связанны друг с другом, в то время как связи между отдельными модулями/компонентами должны быть слабыми. Это базовая ценность. Все остальные принципы и подходы в проектировании - следствия из этого принципа. "Low coupling, high cohesion" отвечает на вопрос "чего мы хотим добиться", в то время как, скажем, SOLID-принципы или любой из Design Pattern указывает нам "как мы можем этого добиться".

И вот тут Redux подводит - то, что должно быть цельным внутри компонента, оказывается размазанным по множеству файлов и сущностей - получаем Low cohesion вместо High. Связи, которые должны оставаться внутри, выходят наружу. Если нарушение принципа Low Coupling обычно представляют себе в виде переплетений из лапши, то здесь у меня в голове всплывает другое кулинарное блюдо. Позаимствовав терминологию у Java-разработчиков, если отдельный компонент - это фасолинка (Bean) - цельная, замкнутая вещь в себе, то тут мы получаем что-то вроде рагу, где фасоль полопалась и его содержимое вытекло, образовав густую однородную кашу, обволакивающую всю систему целиком, и не позволяющую на нее смотреть как на композицию отдельных законченных и слабо-зависимых сущностей.

Множество Boilerplate кода

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

Неуместное использование

А еще мне не нравится, что Redux или схожие с ним инструменты пытаются использовать там, где они не нужны - скажем, в Angular (angular-redux, NgRx). Redux предназначен для решения проблемы передачи данных в компоненты путем использования глобального State, и в React.js действительно существует такая проблема, там его использование кажется уместным. Но в Angular такой проблемы нет, Injectable-сервисы прекрасно справляются с этой задачей. Зачем решать несуществующую проблему, порождая при этом новые (о которых было написано выше)?

Ну и если говорить о неуместном использовании, также неуместным кажется использования Redux для управления локальным стейтом, что встречается довольно часто.

В заключение надо бы отметить и что-нибудь хорошее. Допустим, это не так плохо, когда у вас большая команда разработчиков разного уровня, и все пишут понятный всем код примерно в одном стиле ничего не выдумывая. Если иначе такая разношерстная команда не сможет работать эффективно, значит овчинка выделки определенно стоит. Но на самом деле хотелось бы иметь простое и эффективное решение, лишенное вышеназванных недостатков. Context API? Может быть.

Подробнее..
Категории: Javascript , React , Reactjs , Web , Redux , Flux

Из песочницы Serverless и полтора программиста

05.08.2020 18:06:10 | Автор: admin

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


Архитектура


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


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


Третье, пожалуй самое безрассудное и на грани слабоумия и отваги, рискнуть пойти на новорожденное решение от MongoDB, которое в тот момент называлось Stitch, а сейчас называется Realm (но это не совсем тот самый Realm, а ядреная смесь из Stitch и Realm, которая получилась после приобретения последнего MongoDB, Inc в конце 2019 года)


Backend


В результате серверная сторона вышла вот такой:



Node и Redis в ней появились только для реализации Server Side Rendering и кэширования (ну и для того, чтобы не кормить Atlas лишними платными запросами), для тех кто не решает задачи CEO-оптимизации их можно легко выкинуть.


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


Frontend


Клиентская часть классическая: React + Redux + Redux-Saga + TypeScript



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


Аутентификация и авторизация


Ну а теперь самое интересно, для чего вообще нужно было все это мракобесие с Mongo.Realm. Вместе с лямбдами мы получаем полноценную интеграцию из коробки с ворохом способов аутентификации (Google, Apple, Facebook, Email/Password и прочими) и механизмом авторизации операций до уровня полей в коллекциях:



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


Прочие радости


Также облачная монга нам из коробки дает sync наборов данных с клиентом, push-уведомления, хранилище секретов, триггеры (на события базы, аутентификация, работу по расписанию), вебхуки, хорошие логи и еще много чего. То есть снаружи оно обслуживается и воспринимается как полноценный сервис, при этом не приходится решать вопросы масштабирования и переноса между датацентрами, бекапов, мониторинга и кучи других задач.


Ну, а для самых безбашенных, есть возможность работы через GraphQL.


Как все это работает



В случае если попали в кэш, при 100 RPS на один экземпляр (в конфигурации по одному ядру и одному гигабайту на один экземпляр Node.js под управлением PM2), время ответа укладывается в 200 мс, в противном случае вместе со всеми запросами к Mongo серверный рендер отрабатывает до 500 мс.


В работе с Mongo.Realm есть нюансы, которые никак не отражены в документации, но проявляются во всех недорогих инстансах с разделяемой памятью (M1, M2, M5): если запросы выполняются от имени клиента, то, видимо в качестве защиты от перегрузок, периодически время ответа на какой-нибудь aggregation-pipeline может резко вырасти до 5-10 секунд на запрос. При этом, если вызывается серверная функция (с тем же самым aggregation-pipeline), которая выполняется от имени системного пользователя, то таких трюков не наблюдается.
Возможно дело именно в типе кластера, и со временем это исправят или все решится переходом на М10 и выше, но сейчас для некоторых сложных запросов пришлось пойти на рискованный шаг и для чтения данных анонимными пользователями сделать несколько функций исполняемых от имени системного пользователя, в этом случае правила авторизации для доступа к данным игнорируются, и за безопасностью надо следить уже самим в коде.



В случае аутентифицированного доступа Server Side Rendering не нужен, все работает прямо на клиенте.


Выводы


В заголовок вынесен ресурс потраченный на разработку, а именно полтора программиста (1 фронтендер и бэкендера). Ровно столько и 5 месяцев работы, понадобилось для вывода в прод довольно развесистого портала с собственной системой управления контентом, интеграцией с несколькими поставщиками данных включая нечеткие сопоставления, оптимизированного под самые суровые требования по SEO и c поддержкой мобильный браузеров как first class citizen.


Весь мой предыдущий, более чем 16-летний опыт управления разработкой говорил, что ресурсоемкость этого проекта будет раза в 4 выше.


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

Подробнее..

Кому с Redux жить хорошо

11.02.2021 14:07:02 | Автор: admin
Приветствую всех любителей хорошей инженерки! Меня зовут Евгений Иваха, я фронтенд-разработчик в команде, занимающейся дев-программой в ManyChat. В рамках дев-программы мы разрабатываем инструменты, позволяющие расширять функциональность ManyChat за счет интеграции со сторонними системами.

Существует мнение, что разработка через тестирование, или по канонам Test Driven Development (TDD) для фронтенда не применима. В данной статье я постараюсь развенчать этот миф и покажу, что это не только возможно, но и очень удобно и приятно.

Сам по себе React достаточно понятен любому разработчику, чего не скажешь про Redux. На первый взгляд может показаться, что это какой-то монструозный и непонятный инструмент. Прочитав данную статью, вы узнаете как разрабатывать приложения через тестирование на React, используя Redux, поймёте преимущества его использования, научитесь не открывать браузер при разработке фронтенд-приложений и экономить время на дебаге. Возможно, найдёте что-то новое для себя про написание фронтовых тестов.



Про TDD вообще и частный случай тестирования React-компонентов можно прочитать в статье моего коллеги Андрея, в данном материале я не буду заострять внимания на нюансах, но целиком и полностью пройду весь путь создания приложения через написание тестов.

Любой фронтенд-компонент продукта сам по себе делится на несколько составляющих. Для простоты можно считать, что в браузерном приложении есть свой фронтенд и свой бэкенд. Первый отвечает за непосредственный пользовательский интерфейс, а второй за бизнес-логику. React нам помогает с интерфейсами, а Redux представляет собой очень удобный инструмент для обработки бизнес-логики.

Поскольку, мы в ManyChat в начале 2020 года полностью перешли на TypeScript, будем писать код сразу с использованием строгой типизации.

Redux


Что такое Redux? Redux это паттерн и библиотека для управления и обновления состоянием приложения с использованием специальных событий, называемых Action. Он предоставляет централизованное хранилище состояния, которое используется во всём приложении с правилами, гарантирующими предсказуемое изменение этого состояния. Если посмотреть на диаграмму потока данных в Redux для приложений на React, мы увидим примерно следующее:



При необходимости изменения состояния, например, при клике на элемент в DOM, вызывается Action creator, который создаёт определенный Action. Этот Action c помощью метода Dispatch отправляется в Store, где он передаётся на обработку в Reducers. Редьюсеры, в свою очередь, на основании текущего состояния и информации, которая находится в экшене, возвращают новое состояние приложения, которое принимает React с помощью Selectors для нового рендера DOM. Более подробно о каждом компоненте Redux будет рассказано ниже по ходу разработки приложения.

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

Задача


Создадим форму принятия какого-то пользовательского соглашения. Форма должна содержать чекбокс, который следует отметить пользователем, в случае, если он принимает соглашение, а так же кнопку Submit, которая становится активной только при активации чекбокса. Выглядеть это должно примерно вот так:



Воспользуемся шаблоном create-react-app:

npx create-react-app my-app --template typescriptcd my-appnpm start

Запустили, убедились, что приложение работает.



Можем остановить сборщик, поскольку он понадобится нам ещё очень не скоро. Можем даже закрыть браузер.

Теперь приступим к самому вкусному подключению Redux в наш проект. Т.к. приложение у нас совершенно пустое, мы не знаем структуру стора, оставим его напоследок.

Установим нужные пакеты:
npm i redux react-redux redux-mock-store @types/redux @types/react-redux @types/redux-mock-store  

Actions


Что такое Action? Это обычный Javascript объект, у которого есть обязательное свойство type, в котором содержится, как правило, осознанное имя экшена. Создатели Redux рекомендуют формировать строку для свойства type по шаблону домен/событие. Также в нём может присутствовать дополнительная информация, которая, обычно, складывается в свойство payload. Экшены создаются с помощью Action Creators функций, которые возвращают экшены.

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

Напишем первый тест. Для тестирования используем уже ставший стандартным фреймворк Jest. Для запуска тестов в следящем режиме, достаточно в корне проекта выполнить команду npm test.
// actions/actions.test.tsimport { checkboxClick } from '.'describe('checkboxClick', () => {  it('returns checkboxClick action with action name in payload', () => {    const checkboxName = 'anyCheckbox'    const result = checkboxClick(checkboxName)    expect(result).toEqual({ type: 'checkbox/click', payload: checkboxName })  })})

Здесь мы проверяем, Action Creator вернёт экшн с нужным типом и правильными данными, а именно с названием чекбокса. И больше нам здесь нечего проверять.

Само собой, тест у нас красный (сломанный), т.к. код ещё не написан:



Пора написать код:
// actions/package.json{  "main": "./actions"}// actions/actions.tsexport const checkboxClick = (name: string) => ({ type: 'checkbox/click', payload: name })

Проверяем:



Тест пройден, можем приступить к рефакторингу. Здесь мы видим явное дублирование константы с типом экшена, вынесем её в отдельный модуль.
// actionTypes.tsexport const CHECKBOX_CLICK = 'checkbox/click'

Поправим тест:
// actions/actions.test.tsimport { CHECKBOX_CLICK } from 'actionTypes'import { checkboxClick } from '.'describe('checkboxClick', () => {  it('returns checkboxClick action with action name in payload', () => {    const checkboxName = 'anyCheckbox'    const result = checkboxClick(checkboxName)    expect(result).toEqual({ type: CHECKBOX_CLICK, payload: checkboxName })  })})

Тест не проходит, потому что мы не использовали относительный путь к actionTypes. Чтобы это исправить, добавим в tsconfig.json в секцию compilerOptions следующий параметр "baseUrl": "src". После этого понадобится перезапустить тесты вручную.

Убедимся, что тест позеленел, теперь поправим сам код:
// actions/actions.tsimport { CHECKBOX_CLICK } from 'actionTypes'export const checkboxClick = (name: string) => ({ type: CHECKBOX_CLICK, payload: name })

Ещё раз убеждаемся, что тест проходит, и можем двигаться дальше.

Reducers


Напишем обработчик события изменения состояния чекбокса, т.е. Reducer. Редьюсеры это специальные чистые функции, которые принимают на вход текущий state и action, решают как нужно изменить состояние, если это требуется, и возвращают новый state. Редьюсеры должны содержать всю бизнес-логику приложения, насколько это возможно.

Хранить состояние чекбоксов (отмечены они или нет) мы будем простым объектом, где ключом будет выступать название чекбокса, а в булевом значении непосредственно его состояние.
{  checkboxName: true}

Приступим. Первый тест будет проверять, что мы получаем исходное состояние, т.е. пустой объект.
// reducers/reducers.test.tsimport { checkboxReducer } from '.'describe('checkboxReducer', () => {  it('creates default state', () => {    const state = checkboxReducer(undefined, { type: 'anyAction' })    expect(state).toEqual({})  })})

Т.к. у нас даже нет файла с редьюсером, тест сломан. Напишем код.
// reducers/package.json{  "main": "./reducers"}// reducers/reducers.tsconst initialState: Record<string, boolean> = {}export const checkboxReducer = (state = initialState, action: { type: string }) => {  return state}

Первый тест редьюсера починили, можем написать новый, который уже проверит, что получим в результате обработки экшена с информацией о нажатом чекбоксе.
// reducers/reducers.test.tsimport { CHECKBOX_CLICK } from 'actionTypes'import { checkboxReducer } from '.'describe('checkboxReducer', () => {  it('creates default state', () => {    const state = checkboxReducer(undefined, { type: 'anyAction' })    expect(state).toEqual({})  })  it('sets checked flag', () => {    const state = checkboxReducer(undefined, { type: CHECKBOX_CLICK, payload: 'anyName' })    expect(state.anyName).toBe(true)  })})

Минимальный код для прохождения данного теста будет выглядеть следующим образом:
// reducers/reducers.tsimport { CHECKBOX_CLICK } from 'actionTypes'const initialState: Record<string, boolean> = {}export const checkboxReducer = (  state = initialState,  action: { type: string; payload?: string },) => {  if (action.type === CHECKBOX_CLICK && action.payload) {    return { ...state, [action.payload]: true }  }  return state}

Мы убедились, что при обработке экшена, в котором содержится имя чекбокса, в state будет состояние о том, что он отмечен. Теперь напишем тест, который проверит обратное поведение, т.е. если чекбокс был отмечен, то отметка должна быть снята, свойство должно получить значение false.
// reducers/reducers.test.ts  it('sets checked flag to false when it was checked', () => {    const state = checkboxReducer({ anyName: true }, { type: CHECKBOX_CLICK, payload: 'anyName' })    expect(state.anyName).toBe(false)  })

Убеждаемся, что тест красный, т.к. у нас всегда устанавливается значение в true, ведь до сего момента у нас не было других требований к коду. Исправим это.
// reducers/reducers.tsimport { CHECKBOX_CLICK } from 'actionTypes'const initialState: Record<string, boolean> = {}export const checkboxReducer = (  state = initialState,  action: { type: string; payload?: string },) => {  if (action.type === CHECKBOX_CLICK && action.payload) {    return { ...state, [action.payload]: !state[action.payload] }  }  return state}

Отлично! Мы описали всю необходимую бизнес-логику в тестах, написали код, который этим тестам удовлетворяет.

Selectors


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

Напишем первый тест для селектора.
// selectors/selectors.test.tsimport { getCheckboxState } from './selectors'describe('getCheckboxState', () => {  const state = {    checkboxes: { anyName: true },  }  it('returns current checkbox state', () => {    const result = getCheckboxState('anyName')(state)    expect(result).toBe(true)  })})

Теперь заставим его позеленеть.

Так как селектор должен знать, откуда извлекать информацию, определим структуру хранения.
// types.tsexport type State = {  checkboxes: Record<string, boolean>}

Теперь напишем код селектора. Здесь используется функция высшего порядка из-за особенностей хука useSelector пакета react-redux, который принимает на вход функцию, принимающую один аргумент текущее состояние стора, а нам требуется сообщить ещё дополнительные параметры название чекбокса.
// selectors/package.json{  "main": "./selectors"}// selectors/selectors.tsimport { State } from 'types'export const getCheckboxState = (name: string) => (state: State) => state.checkboxes[name]

Кажется, мы всё сделали правильно, тест теперь зелёный. Но что произойдёт, если у нас ещё не было информации о состоянии чекбокса? Напишем ещё один тест.
// selectors/selectors.test.ts  it('returns false when checkbox state is undefined', () => {    const result = getCheckboxState('anotherName')(state)    expect(result).toBe(false)  })

Получим вот такую картину:



И это правильно, мы получили на выходе undefined, т.е. state ничего не знает об этом чекбоксе. Исправим код.
// selectors/selectors.tsimport { State } from 'types'export const getCheckboxState = (name: string) => (state: State) => state.checkboxes[name] ?? false

Вот теперь селектор работает, как и требуется.

Store


Давайте теперь создадим сам Store, т.е. специальный объект Redux, в котором хранится состояние приложения.
// store.tsimport { AnyAction, createStore, combineReducers } from 'redux'import { State } from 'types'import { checkboxReducer } from 'reducers'export const createAppStore = (initialState?: State) =>  createStore<State, AnyAction, unknown, unknown>(    combineReducers({      checkboxes: checkboxReducer,    }),    initialState,  )export default createAppStore()

Этот код мы отдельно тестировать не будем, т.к. мы используем стороннюю библиотеку, которая уже протестирована. Но далее мы напишем интеграционный тест, который проверит всю связку React + Redux.

React Components


Т.к. всю необходимую бизнес-логику мы уже описали, можем приступить к слою отображения.

Для более удобной работы мы написали небольшую утилиту для тестов. В ней несколько больше функциональности, чем требуется для нашего первого теста, но далее мы всё это применим. Используем удобную библиотеку react-test-renderer, которая позволяет не производить рендер в настоящий DOM, а получать его JS представление. Установим пакет:
npm i react-test-renderer @types/react-test-renderer

Приступим к написанию тестов на компоненты. Начнём непосредственно с чекбокса.

Checkbox


// components/Checkbox/Checkbox.test.tsximport { create } from 'react-test-renderer'import Checkbox from '.'describe('Checkbox', () => {  it('renders checkbox input', () => {    const checkboxName = 'anyName'    const renderer = create(<Checkbox />)    const element = renderer.root.findByType('input')    expect(element.props.type).toBe('checkbox')  })})

Первый тест компонента проверяет, что внутри Checkbox рендерится стандартный input с типом checkbox.

Сделаем тест зелёным.
// components/Checkbox/package.json{  "main": "Checkbox"}// components/Checkbox/Checkbox.tsximport React from 'react'const Checkbox: React.FC = () => {  return (    <div>      <input type="checkbox" />    </div>  )}export default Checkbox

Отлично, теперь добавим свойство label, содержащее текст для html элемента label, который должен отображаться рядом с чекбоксом.
// components/Checkbox/Checkbox.test.tsxit('renders label', () => {    const labelText = 'anyLabel'    const renderer = create(<Checkbox label={labelText} />)    const element = renderer.root.findByType('label')    expect(element.props.children).toBe(labelText)  })

Заставим тест пройти.
// components/Checkbox/Checkbox.tsxconst Checkbox: React.FC<{ label: string }> = ({ label }) => {  return (    <div>      <input type="checkbox" />      <label>{label}</label>    </div>  )}

Осталась небольшая деталь чекбокс как-то должен себя идентифицировать, кроме того, для корректной работы клика по label, нужно прописать id чекбокса в свойство htmlFor. Напишем тест, проверяющий установку свойства id:
// components/Checkbox/Checkbox.test.tsx  it('sets name prop as input id', () => {    const checkboxName = 'anyCheckbox'    const renderer = create(<Checkbox name={checkboxName} label={'anyLabel'} />)    const element = renderer.root.findByType('input')    expect(element.props.id).toBe(checkboxName)  })

Убедившись, что он красный, исправим код:
// components/Checkbox/Checkbox.tsxconst Checkbox: React.FC<{ name: string; label: string }> = ({ name, label }) => {  return (    <div>      <input id={name} type="checkbox" />      <label>{label}</label>    </div>  )}

Тест зеленый, можем написать ещё один, который проверит установку свойства name в свойство htmlFor элемента label.
// components/Checkbox/Checkbox.test.tsx  it('sets name prop as label htmlFor', () => {    const checkboxName = 'anyCheckbox'    const renderer = create(<Checkbox name={checkboxName} label={'anyLabel'} />)    const element = renderer.root.findByType('label')    expect(element.props.htmlFor).toBe(checkboxName)  })

Тест красный, нужно снова поправить код.
// components/Checkbox/Checkbox.tsxconst Checkbox: React.FC<{ name: string; label: string }> = ({ name, label }) => {  return (    <div>      <input id={name} type="checkbox" />      <label htmlFor={name}>{label}</label>    </div>  )}

Пора бы подключить Store к компоненту. Напишем тест, который покажет, что состояние чекбокса (свойство checked) соответствует тому, что хранится в Store.
// components/Checkbox/Checkbox.test.tsximport { Provider } from 'react-redux'import { create } from 'react-test-renderer'import { createAppStore } from 'store'import Checkbox from '.'// omit old code  it('sets checked flag from store when it`s checked', () => {    const store = createAppStore({ checkboxes: { anyName: true } })    const renderer = create(      <Provider store={store}>        <Checkbox name="anyName" label="anyLabel" />      </Provider>,    )    const element = renderer.root.findByType('input')    expect(element.props.checked).toBe(true)  })

Тест пока красный, т.к. компонент ничего не знает о сторе. Заставим тест позеленеть.
// components/Checkbox/Checkbox.tsximport React from 'react'import { useSelector } from 'react-redux'import { getCheckboxState } from 'selectors'const Checkbox: React.FC<{ name: string; label: string }> = ({ name, label }) => {  const checked = useSelector(getCheckboxState(name))  return (    <div>      <input id={name} type="checkbox" checked={checked} />      <label htmlFor={name}>{label}</label>    </div>  )}export default Checkbox

Тест пройден. Наконец-то, мы задействовали Redux! Мы использовали ранее написанный селектор getCheckboxState, который вызвали с помощью хука useSelector, получили значение и передали его в свойство checked элемента input. Но сейчас произошла другая проблема сломались остальные тесты на компонент.



Дело в том, что ранее в тестах мы не передавали стор в компонент. Выделим часть с провайдером стора в функцию и перепишем наши тесты.
// components/Checkbox/Checkbox.test.tsximport { ReactElement } from 'react'import { Provider } from 'react-redux'import { create } from 'react-test-renderer'import { createAppStore } from 'store'import { State } from 'types'import Checkbox from '.'export const renderWithRedux = (node: ReactElement, initialState: State = { checkboxes: {} }) => {  const store = createAppStore(initialState)  return create(<Provider store={store}>{node}</Provider>)}describe('Checkbox', () => {  it('renders checkbox input', () => {    const checkboxName = 'anyName'    const renderer = renderWithRedux(<Checkbox />)    const element = renderer.root.findByType('input')    expect(element.props.type).toBe('checkbox')  })  it('renders label', () => {    const labelText = 'anyLabel'    const renderer = renderWithRedux(<Checkbox label={labelText} />)    const element = renderer.root.findByType('label')    expect(element.props.children).toBe(labelText)  })  it('sets name prop as input id', () => {    const checkboxName = 'anyCheckbox'    const renderer = renderWithRedux(<Checkbox name={checkboxName} label={'anyLabel'} />)    const element = renderer.root.findByType('input')    expect(element.props.id).toBe(checkboxName)  })  it('sets name prop as label htmlFor', () => {    const checkboxName = 'anyCheckbox'    const renderer = renderWithRedux(<Checkbox name={checkboxName} label={'anyLabel'} />)    const element = renderer.root.findByType('label')    expect(element.props.htmlFor).toBe(checkboxName)  })  it('sets checked flag from store when it`s checked', () => {    const initialState = { checkboxes: { anyName: true } }    const renderer = renderWithRedux(<Checkbox name="anyName" label="anyLabel" />, initialState)    const element = renderer.root.findByType('input')    expect(element.props.checked).toBe(true)  })})

Функция renderWithRedux выглядит достаточно полезной, вынесем её в отдельный модуль и импортируем в тестах.
// utils.tsximport { ReactElement } from 'react'import { Provider } from 'react-redux'import { create } from 'react-test-renderer'import { Store } from './types'import { createAppStore } from './store'export const renderWithRedux = (node: ReactElement, initialState: Store = { checkboxes: {} }) => {  const store = createAppStore(initialState)  return create(<Provider store={store}>{node}</Provider>)}

В итоге, шапка тестового файла будет выглядеть вот так:
// components/Checkbox/Checkbox.test.tsximport { renderWithRedux } from 'utils'import Checkbox from '.'describe('Checkbox', () => {

Для полной уверенности напишем ещё один тест, который проверит, что checked бывает и false.
// components/Checkbox/Checkbox.test.tsx  it('sets checked flag from store when it`s unchecked', () => {    const initialState = { checkboxes: { anyName: false } }    const renderer = renderWithRedux(<Checkbox name="anyName" label="anyLabel" />, initialState)    const element = renderer.root.findByType('input')    expect(element.props.checked).toBe(false)  })

Тест пройден, но у нас теперь появилось два теста с похожими описаниями и почти идентичным кодом, давайте немного модифицируем наши тесты, создав табличный тест. Последние два теста превратятся в один:
// components/Checkbox/Checkbox.test.tsx  test.each`    storedState | state    ${true}     | ${'checked'}    ${false}    | ${'unchecked'}  `('sets checked flag from store when it`s $state', ({ storedState }) => {    const initialState = { checkboxes: { anyName: storedState } }    const renderer = renderWithRedux(<Checkbox name="anyName" label="anyLabel" />, initialState)    const element = renderer.root.findByType('input')    expect(element.props.checked).toBe(storedState)  })

Так уже лучше. А теперь самое вкусное напишем интеграционный тест, который проверит, что при нажатии на чекбокс, он изменит своё состояние, т.е. свойство checked.
// components/Checkbox/Checkbox.test.tsximport { act } from 'react-test-renderer'// omit old code    it('changes it`s checked state when it`s clicked', () => {    const initialState = { checkboxes: { anyName: false } }    const renderer = renderWithRedux(<Checkbox name="anyName" label="anyLabel" />, initialState)    const element = renderer.root.findByType('input')    act(() => {      element.props.onChange()    })    expect(element.props.checked).toBe(true)  })

Здесь мы воспользовались функцией act, пакета react-test-renderer, выполняя которую, мы убеждаемся в том, что все сайд-эффекты уже произошли и мы можем продолжить проверки. И далее проверяем, что когда будет вызвано событие onChange на нашем чекбоксе, он изменит свойство checked на true. Пока этого не происходит, требуется написать код. Окончательный вариант компонента примет вот такой вид.
// components/Checkbox/Checkbox.tsximport React from 'react'import { useDispatch, useSelector } from 'react-redux'import { getCheckboxState } from 'selectors'import { checkboxClick } from 'actions'const Checkbox: React.FC<{ name: string; label: string }> = ({ name, label }) => {  const dispatch = useDispatch()  const checked = useSelector(getCheckboxState(name))  const handleClick = React.useCallback(() => {    dispatch(checkboxClick(name))  }, [dispatch, name])  return (    <div>      <input id={name} type="checkbox" checked={checked} onChange={handleClick} />      <label htmlFor={name}>{label}</label>    </div>  )}export default Checkbox

В коде мы навесили обработчик на событие change, который отправляет action в store, создаваемый функцией checkboxClick. Как видим, тест позеленел. Не открывая браузера и даже не запуская сборку приложения, мы имеем протестированный компонент с отдельным слоем бизнес-логики, заключенной в Redux.

AgreementSubmitButton


Нам требуется ещё один компонент непосредственно кнопка Submit, создадим его. Конечно, вначале тест:
// components/AgreementSubmitButton/AgreementSubmitButton.test.tsximport { renderWithRedux } from 'utils'import AgreementSubmitButton from '.'describe('AgreementSubmitButton', () => {  it('renders button with label Submit', () => {    const renderer = renderWithRedux(<AgreementSubmitButton />)    const element = renderer.root.findByType('input')    expect(element.props.type).toBe('button')    expect(element.props.value).toBe('Submit')  })})

Теперь заставим тест позеленеть:
// components/AgreementSubmitButton/package.json{  "main": "./AgreementSubmitButton"}// components/AgreementSubmitButton/AgreementSubmitButton.tsximport React from 'react'const AgreementSubmitButton: React.FC = () => {  return <input type="button" value="Submit" />}export default AgreementSubmitButton

Тест зелёный, начало положено. Напишем новый тест, проверяющий зависимость свойства disabled новой кнопки от состояния чекбокса. Т.к. может быть два состояния, вновь используем табличный тест:
// components/AgreementSubmitButton/AgreementSubmitButton.test.tsx  test.each`    checkboxState | disabled | agreementState    ${false}      | ${true}  | ${'not agreed'}    ${true}       | ${false} | ${'agreed'}  `(    'render button with disabled=$disabled when agreement is $agreementState',    ({ checkboxState, disabled }) => {      const initialState = { checkboxes: { agree: checkboxState } }      const renderer = renderWithRedux(<AgreementSubmitButton />, initialState)      const element = renderer.root.findByType('input')      expect(element.props.disabled).toBe(disabled)    },  )

Имеем двойной красный тест, напишем код для прохождения этого теста. Компонент станет выглядеть вот так:
// components/AgreementSubmitButton/AgreementSubmitButton.tsximport React from 'react'import { useSelector } from 'react-redux'import { getCheckboxState } from 'selectors/selectors'const AgreementSubmitButton: React.FC = () => {  const checkboxName = 'agree'  const agreed = useSelector(getCheckboxState(checkboxName))  return <input type="button" value="Submit" disabled={!agreed} />}export default AgreementSubmitButton

Ура, все тесты зелёные!
Следует обратить внимание, что в табличном тесте мы намеренно использовали два различных параметра checkboxState и disabled, хотя может показаться, что достаточно только первого, а в тесте написать вот так expect(element.props.disabled).toBe(!disabled). Но это плохой паттерн закладывать какую-то логику внутри тестов. Вместо этого мы явно описываем входные и выходные параметры. Так же, мы здесь немного ускорились, т.к., фактически написали два теста за раз. Такое допустимо, когда чувствуешь в себе силы и понимаешь, что реализация достаточно очевидная. Когда уровень владения TDD ещё не совершенный, лучше создавать по одному тесту за раз. В нашем случае это писать по одной строчке в таблице.

LicenseAgreement


Оформим нашу работу в то, ради чего мы всё это затевали в форму принятия лицензионного соглашения. Какие имеются требования к форме:
  1. Содержится заголовок и непосредственно текст лицензионного соглашения. Эта часть компонента не требует тестирования.
  2. На форме имеется компонент Checkbox с определенными label и name. Это можно и нужно тестировать.
  3. На форме имеется кнопка AgreementSubmitButton. Это тоже прекрасно поддаётся тестированию.

Приступим, первый тест на то, что на форме есть Checkbox:
// components/LicenseAgreement/LicenseAgreement.test.tsximport { renderWithRedux } from 'utils'import Checkbox from 'components/Checkbox'import LicenseAgreement from '.'jest.mock('components/Checkbox', () => () => null)describe('LicenseAgreement', () => {  it('renders Checkbox with name and label', () => {    const renderer = renderWithRedux(<LicenseAgreement />)    const element = renderer.root.findByType(Checkbox)    expect(element.props.name).toBe('agree')    expect(element.props.label).toBe('Agree')  })})

На что тут стоит обратить внимание мы использовали тестовый дублёр для компонента Checkbox в строчке jest.mock('components/Checkbox', () => () => null). Это делает наш тест изолированным, таким образом он не зависит от реализации Checkbox, возможные ошибки в этом компоненте не повлияют на результат выполнения данного теста. Дополнительно это экономит вычислительные ресурсы и время выполнения тестов. Тест красный, требуется написать правильный код:
// components/LicenseAgreement/package.json{  "main": "./LicenseAgreement"}// src/components/LicenseAgreement/LicenseAgreement.tsximport React from 'react'import Checkbox from 'components/Checkbox'const LicenseAgreement: React.FC = () => {  return (    <div>      <Checkbox name="agree" label="Agree" />    </div>  )}export default LicenseAgreement

Получили зеленый тест, можем написать второй для этого компонента. Файл с тестами изменится:
// components/LicenseAgreement/LicenseAgreement.test.tsximport { renderWithRedux } from 'utils'import Checkbox from 'components/Checkbox'import AgreementSubmitButton from 'components/AgreementSubmitButton'import LicenseAgreement from '.'jest.mock('components/Checkbox', () => () => null)jest.mock('components/AgreementSubmitButton', () => () => null)describe('LicenseAgreement', () => {  it('renders Checkbox with name and label', () => {    const renderer = renderWithRedux(<LicenseAgreement />)    const element = renderer.root.findByType(Checkbox)    expect(element.props.name).toBe('agree')    expect(element.props.label).toBe('Agree')  })  it('renders SubmitAgreementButton', () => {    const renderer = renderWithRedux(<LicenseAgreement />)    expect(() => renderer.root.findByType(AgreementSubmitButton)).not.toThrow()  })})

Чтобы он позеленел, добавим AgreementSubmitButton в компонент:
// src/components/LicenseAgreement/LicenseAgreement.tsximport React from 'react'import Checkbox from 'components/Checkbox'import AgreementSubmitButton from 'components/AgreementSubmitButton'const LicenseAgreement: React.FC = () => {  return (    <div>      <Checkbox name="agree" label="Agree" />      <AgreementSubmitButton />    </div>  )}export default LicenseAgreement

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

Ключ на старт!


Вставим над компонентами сам текст соглашения, далее можем добавлять компонент в приложение. В сгенерированном приложении имеется корневой компонент App, модифицируем его тесты на проверку рендера LicenseAgreement:
// App.test.tsximport { renderWithRedux } from 'utils'import LicenseAgreement from 'components/LicenseAgreement'import App from 'App'jest.mock('components/LicenseAgreement', () => () => null)test('renders LicenseAgreement', () => {  const renderer = renderWithRedux(<App />)  expect(() => renderer.root.findByType(LicenseAgreement)).not.toThrow()})

Заставим тест позеленеть:
// App.tsximport React from 'react'import LicenseAgreement from 'components/LicenseAgreement'const App: React.FC = () => {  return <LicenseAgreement />}export default App

Мы получили зелёный тест, можно, наконец, запустить сборку приложения с помощью npm start. Сборка пройдёт успешно, но в браузере мы увидим следующую картину:



Это говорит о том, что мы не подключили Redux store в само приложение. Сделаем это в файле index.tsx:
// index.tsximport React from 'react'import ReactDOM from 'react-dom'import { Provider } from 'react-redux'import 'index.css'import store from 'store'import App from 'App'ReactDOM.render(  <React.StrictMode>    <Provider store={store}>      <App />    </Provider>  </React.StrictMode>,  document.getElementById('root'),)

Теперь приложение запускается, всё работает, как ожидается, кроме внешнего вида:



Исправим это, поправив вёрстку, и получим конечный результат:



Заключение


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

Во второй части данной статьи предполагался рассказ о библиотеке Redux Tookilt, которая значительно упрощает использование Redux в разработке фронтенд-приложений, но я решил в следующей статье показать, как можно написать настоящее полезное приложение, хоть и очень простое, на React, Redux и Redux Toolkit.

Исходные коды полученного приложения доступны на GitHub.

Дополнительные источники информации:
Подробнее..

Android Redux amplt3

25.03.2021 18:04:40 | Автор: admin

Привет! Меня зовут Виталий Сулимов, я Android-разработчик в компании Wheely, и сегодня я бы хотел поговорить с вами об архитектуре мобильных приложений. А именно о том, как мы в компании применили Redux-архитектуру к двум нашим приложениям и что из этого вышло.

Дисклеймер #1

Я разрабатываю коммерческие Android-приложения с 2016-го года, начинал с классического в то время MVC, потом был MVP, библиотека Moxy от ребят из Arello Mobile, Clean Architecture и вот теперь Redux. Мое мнение идеальной и единственно правильной архитектуры не существует. Любая из них будет набором компромиссов, начиная от особенностей интеграции с самой платформой и заканчивая простотой, расширяемостью и возможностью написания тестов. То, что отлично подходит под наши приложения, может оказаться абсолютно непригодным для вашего проекта, и наоборот. Цель данной статьи показать еще один способ написания Android-приложений.

К чему мы стремимся?

Перед тем как начать разбирать Redux, давайте тезисно обозначим, что мы хотим получить в итоге:

1. Пассивные View

Задача View отображать интерфейс пользователю, в ней не должно быть бизнес-логики, она не должна принимать решения, всё, что она должна делать сообщать о происходящих с ней событиях и взаимодействиях (создание, уничтожение, изменился размер, нажали кнопку, потянули Pull To Refresh, и т.д.)

2. Отдельно живущая модель, бизнес-логика / механизмы принятия решений

Здесь всё тоже должно быть очень просто, но в то же время четко разграничено.

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

3. Возможность покрывать логику тестами

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

4. Консистентность подхода в масштабе всего приложения

Мало кто сегодня разрабатывает приложения в одиночку, как правило, это команды из нескольких человек, которые отвечают за какое-то отдельное направление. Но иногда каждому из нас приходится залезать в чужой код, и было бы круто, если бы общие подходы были максимально схожими: неважно, статичный ли это экран с двумя кнопками или чат с сокетом в режиме реального времени.

Redux, я выбираю тебя!

Перед тем как говорить о Redux в контексте Android-приложения, давайте для начала разберемся, чем он является сам по себе, отдельно от Android.

В контексте данной статьи Redux рассматривается как библиотека для управления состоянием приложения. Основная идея заключается в том, что глобальное иммутабельное состояние содержится в глобальном компоненте Store, который среди прочего позволяет подписываться на это состояние, получать уведомления каждый раз, когда что-то меняется, а также отправлять ему события, когда что-то происходит. Это базовое описание, давайте теперь копнем чуть глубже и посмотрим, из каких компонентов состоит Redux.

State

Я не просто так вынес состояние на первое место, потому что считаю это основной и ключевой фишкой Redux. Состояние является иммутабельным (важно) объектом и единственным источником истины для всего приложения.

Store

Store содержит глобальное глобальное состояние (State) вашего приложения, а также все подключенные к нему Middleware и Reducer.

Типичное API позволяет вам получать текущее состояние, отправлять события (Action), подписываться и отписываться от изменений состояния.

Action

События, единственные данные, которые вы можете отправлять вашему Store. События как правило сообщают о взаимодействии с приложением или являются своего рода намерением явно изменить состояние (рассмотрим на примере позже). Опционально могут содержать внутри себя дополнительные данные.

Reducer

Чистая функция, которая меняет текущее состояние в ответ на пришедшее событие (Action). Для тех, кто не знает, чистая функция всегда возвращает одинаковое значение при одинаковых входных данных (детерминированность) и не имеет побочных эффектов (никаким образом не изменяет локальные переменные, не осуществляет ввод, вывод, и т.д.).

Поскольку состояние у нас является иммутабельным, Reducer использует Copy-on-write подход, копируя состояние целиком с изменением только необходимой части.

Middleware

Middleware является своего рода промежуточным звеном, позволяя перехватывать события (Action) и заменять их в случае необходимости, до того, как они попадут в наш Reducer.

Middleware является тем самым механизмом принятия решений и местом, где содержится бизнес-логика нашего приложения.

А как натянуть сову на глобус подружить Redux с Android?

Всё очень просто. Достаточно представить Android как источник событий (Action), не важно, что это, создание Activity, View, нажатие кнопки или BroadcastReceiver - просто отправьте Action об этом и обработайте его, как обычно (рассмотрим детальнее дальше).

Talk is cheap. Show me the code.

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

Что нам потребуется?

Чистый проект в Android Studio и реализация Redux для языка Kotlin.

Код библиотеки доступен в репозитории на GitLab.

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

Дисклеймер #2

В процессе разбора вам может показаться, что в этой библиотеке очень нужен Rx, или Coroutines, или что-то ещё, о чем я даже не догадываюсь, но здесь этого нет. Библиотека создавалась по принципу KISS, она полностью открыта и вы можете без проблем изменить ее для соответствия вашим требованиям, главное не менять фундаментальную идею.

Описываем состояние

Помните, что я говорил о роли состояния в Redux? Простой иммутабельный объект, который содержит всю необходимую нам информацию и позволяет легко производить над ним операции по принципу Copy-on-write. Лучшее, что вы можете выбрать для этого подхода в Kotlin - data class.

ApplicationState.kt

data class ApplicationState( val counter: Int = 0)

Выглядит так же просто, как и звучит, едем дальше.

События

Все события в нашей реализации должны так или иначе реализовывать интерфейс Action из библиотеки Redux. Интерфейс Action является маркерным и не содержит никаких методов для реализации. Я стараюсь логически декомпозировать события для простоты работы с ними и обработки их в Middleware и Reducer, а также использовать sealed classы, последние ограничивают всех возможных наследников до узкого круга того, что нас непосредственно интересует. В итоге наши события будут выглядеть вот так.

CounterAction.kt

sealed class CounterAction : Action {   object Increment : CounterAction()     object Reset : CounterAction()}

Reducer

В нашем случае это объект, который реализует интерфейс Reducer<S>, где S - глобальное состояние нашего приложения, т.е. В нашем случае ApplicationState. Интерфейс описывает одну-единственную функцию - reduce. Не забываем про то, что функция должна быть чистой.

CounterReducer.kt

object CounterReducer : Reducer<ApplicationState> {   override fun reduce(action: Action, state: ApplicationState): ApplicationState = when (action) { is CounterAction.Increment -> state.copy(counter = state.counter.inc())                 is CounterAction.Reset -> state.copy(counter = 0)                 else -> state }}

Store

А теперь соберем все эти компоненты в единый механизм, и поможет нам в этом Store.

Библиотека уже содержит в себе абстрактный Store с реализацией всех необходимых методов. Всё что нам нужно сделать - создать наследника класса AbstractStore<S> и явно указать тип нашего глобального состояния. Это же состояние будет передаваться в наши Middleware и Reducer.

ApplicationStore.kt

class ApplicationStore( initialState: ApplicationState, middlewares: List<Middleware<ApplicationState>>, reducers: List<Reducer<ApplicationState>>) : AbstractStore<ApplicationState>(initialState, middlewares, reducers)

Теперь нам необходимо создать экземпляр класса ApplicationStore, передать ему изначальное состояние и список всех подключенных Middleware и Reducer. Поскольку Store, равно как и ApplicationState должны иметь время жизни равное времени жизни нашего приложения - сделаем AppComponent и положим наш Store туда.

AppComponent.kt

object AppComponent {val store = ApplicationStore(initialState = ApplicationState(),middlewares = emptyList(),reducers = listOf(CounterReducer))}

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

ReduxFunctions.kt

fun dispatch(action: Action) = AppComponent.store.dispatch(action)    fun subscribe(subscription: Subscription<ApplicationState>) = AppComponent.store.subscribe(subscription)    fun unsubscribe(subscription: Subscription<ApplicationState>) = AppComponent.store.unsubscribe(subscription)

Подведем промежуточный итог

Сейчас у нас есть иммутабельное состояние, которое отражает значение нашего счетчика, есть события, которые позволяют взаимодействовать с этим значением, и reducer, который обрабатывает эти события, меняя состояние соответствующим образом. Несмотря на общую простоту конструкции, можно заметить весьма жесткие ограничения. Мы можем быть уверены в том, что состояние может измениться только в результате работы reducera, а тот в свою очередь сработает, только если в store будет отправлено соответствующее событие. Дело осталось за малым, UI!

Прикручиваем отображение

В данном случае в ход идет непосредственно часть Android-фреймворка. Тут может быть несколько подходов (Single Activity / Multiple Activities / Fragments?), я покажу один из них, который нравится мне больше всего - это связка одной Activity и чистых View. Activity является своего рода контроллером и может заменять текущую View, а те в свою очередь подписываются на состояние и непосредственно отображают интерфейс пользователю.

CounterView.kt

class CounterView( context: Context) : FrameLayout(context) {   private val counterSubscription = SubStateSubscription<ApplicationState, Int>( transform = { it.counter }, onStateChange = { state: Int, _: Boolean -> handleCounterStateChange(state) } )     private lateinit var counterTextView: TextView private lateinit var floatingActionButton: FloatingActionButton   init { inflate(context, R.layout.view_counter, this) findViewsById() setOnClickListeners() }     private fun findViewsById() { counterTextView = findViewById(R.id.counterTextView) floatingActionButton = findViewById(R.id.floatingActionButton) }     private fun setOnClickListeners() { floatingActionButton.setOnClickListener { dispatch(CounterAction.Increment) } }     override fun onAttachedToWindow() { super.onAttachedToWindow() subscribeToStateChanges() }     private fun subscribeToStateChanges() { subscribe(counterSubscription) }     override fun onDetachedFromWindow() { unsubscribeFromStateChanges() super.onDetachedFromWindow() }     private fun unsubscribeFromStateChanges() { unsubscribe(counterSubscription) }     private fun handleCounterStateChange(state: Int) { counterTextView.text = state.toString() }}

Давайте внимательно рассмотрим этот класс и разберем, что происходит. Для начала создадим SubStateSubscription, это вспомогательный класс, который позволяет подписываться не на состояние целиком, а на какую-то из его частей, опять же, если вы захотите Rx, то эту заботу на себя возьмет map(), в нашем случае - руками.

Дальше ничего необычного, объявляем lateinit var для всех виджетов внутри данной View.

Конструктор. Надуваем XML-разметку, находим в ней наши виджеты и вешаем обработчик нажатия на Floating Action Button. Внутри обработчика делаем dispatch события CounterAction.Increment, которое мы создали ранее.

OnViewAttached / Detached from window.

Здесь подписываемся и отписываемся соответственно на наше значение и пишем обработчик, который выполнится, когда произойдет изменение этой части состояния. В нашем случае всё очень тривиально, просто устанавливаем пришедшее значение в TextView.

CounterView.kt

...counterTextView.text = state.toString()...

И мы на финишной прямой! Почти.

Если запустить приложение сейчас, то оно будет работать, View будет отображать значение счетчика, которое соответствует переменной counter внутри ApplicationState, поворот экрана никак не ломает наше приложение, это решается by design, ведь состояние нашего счётчика живёт на уровне Application и жизненный цикл View на него никак не влияет, но Помните, я говорил вам, что счетчик надо сбрасывать, когда мы выходим из приложения (нажимаем клавишу Назад). Как это сделать? Давайте разбираться.

Еще раз вспоминаем о том, как подружить Redux и Android

Выше я писал о том, что нам нужно представить Android как источник событий в архитектуре Redux. Давайте сделаем это. В библиотеке также есть уже готовый класс AppCompatActivity, который является самой обычной AppCompatActivity с одним маленьким бонусом: эта Activity отправляет события ActivityLifecycleAction (тоже являются частью библиотеки) на каждое событие жизненного цикла. Всё что вам нужно сделать для интеграции - это создать наследника данной AppCompatActivity и предоставить ему Store, в который она и будет отправлять события. Итоговый код выглядит так.

MainActivity.kt

class MainActivity : AppCompatActivity<ApplicationState>() {   private lateinit var contentViewGroup: ViewGroup   override fun getStore(): Store<ApplicationState> = AppComponent.store   override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) findViewsById() addCounterView() }     private fun findViewsById() { contentViewGroup = findViewById(R.id.contentViewGroup) }     private fun addCounterView() { contentViewGroup.addView(CounterView(context = this)) }}

Наша первая Middleware и принятие решения

У нас есть всё необходимое для написания финального элемента нашего приложения - MIddleware. Для начала давайте сформулируем, что мы хотим: Ловить событие уничтожения Activity (onDestroy) и если флаг isFInishing == true - обнулять наш счетчик.

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

Опять же, выглядит так же просто, как и звучит. Создаем объект, который реализует интерфейс Middleware<S>, где S - тип нашего глобального состояния и реализуем метод handleAction().

ActivityLifecycleMiddleware.kt

object ActivityLifecycleMiddleware : Middleware<ApplicationState> {   override fun handleAction( action: Action, state: ApplicationState, next: Next<ApplicationState> ): Action { val newAction = when (action) { is ActivityLifecycleAction.OnDestroy -> handleActivityOnDestroy(action)                 else -> action } return next(newAction, state) }     private fun handleActivityOnDestroy(action: ActivityLifecycleAction.OnDestroy): Action = if (action.isFinishing) CounterAction.Reset else action}

Давайте посмотрим, что здесь происходит. До того как событие ActivityLifecycleAction.OnDestroy попадет в Reducer, оно пройдет через все наши Middleware, и именно здесь мы можем заменить это изначальное событие на то, что нас интересует. Это и происходит, если флаг isFinishing == true, то в Reducer попадёт событие CounterAction.Reset, которое обнулит наш счетчик, если же флаг false - событие уйдёт как есть, но поскольку никто его не обрабатывает, оно никак не поменяет состояние нашего приложения, и подписчики на состояние ничего об этом не узнают. Не забудьте добавить middleware в наш AppComponent-класс.

AppComponent.kt

store = ApplicationStore( initialState = ApplicationState(), middlewares = listOf(ActivityLifecycleMiddleware), reducers = listOf(CounterReducer))

Вот и всё!

Выводы

Лично мне очень нравится Redux. Простая на вид идея, но в тоже время при этой простоте получается создавать сложные вещи, главное научиться его правильно готовить. И он действительно предсказуемый, как и говорится в оригинальном описании библиотеки на JavaScript. Он также позволяет вам четко разграничить места, где у вас есть логика, и места, где этой логики нет. Создание унифицированного кода становится очень простым. Что-то происходит - Action. Нужно принять решение - Middleware, нужно отреагировать на событие - Reducer. А View является простым представлением, которое умеет рисовать себя и сообщать о взаимодействиях с ней.

Бонус

Все исходники данной статьи лежат в открытом доступе, там вы можете найти полный код библиотеки, проект Counter, в котором Middleware и Reducer покрыты тестами, но это еще не все. Я также сделал куда более сложное приложение на Redux-архитектуре это приложение Погода, которое поддерживает систему разрешений Android (доступ к локации), определяет местоположение пользователя и выполняет асинхронную загрузку данных с API OpenWeatherMap. Всё это покрыто тестами и также лежит в открытом доступе.

https://gitlab.com/v.sulimov/android-redux-kotlin

https://gitlab.com/v.sulimov/android-redux-demo

https://gitlab.com/v.sulimov/android-openweather-kotlin

Прощаемся

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

Спасибо за уделенное время, надеюсь, вам было интересно.

С уважением, Виталий. Команда разработки Wheely.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru