- PVSM.RU - https://www.pvsm.ru -

Знакомство c Reatom

Знакомство c Reatom - 1

Привет, меня зовут Артём Арутюнян и я автор менеджера состояния Reatom. Этим постом открывается серия обучающих материалов на русском языке, документация на английском доступна на официальном сайте [1].

А оно вам надо? Думаю, да, потому что Reatom — это универсальное решение, которое позволяет легко пошарить глобальное состояние за микроскопическую (2.5KB [2]) цену, эффективно строить самодостаточные и переиспользуемые логические модули гигантских приложений или просто сделать ваш сетевой кеш реактивным с помощью дополнительного пакета @reatom/async [3].

В этой статье мы кратко пройдёмся по мотивации и истории, а потом разберём основные фичи и примеры их использования вместе с биндингами к React.js. Похожий разбор есть в виде скринкаста [4].

▍ Мотивация

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

Мне нравилось, как реактивность решает проблемы связанности кода, а иммутабелность упрощает дебаг — это и стало главными столпами разрабатываемой библиотеки.

Сложно сделать хорошо всё и сразу, поэтому эволюция Reatom заняла годы.

▍ История

Первый релиз был осенью 2019-го, хотя ему предшествовали почти два года исследований. Началось всё в феврале 2018-го, тогда меня передёрнуло от function-tree [5], и я решил сделать с подобным апи убийцу редакса (тогда это было популярным занятием). Далее история долгая: погружение в дзен вывода типов TypeScript, десятки прототипов, постоянные попытки выжать лучшее из современных технологий. Исследование алгоритмов обхода графов для решения проблемы глитчей [6]. Рост комьюнити и попытки писать понятную документацию. Обслуживание инфраструктуры монорепы. Погружение в теорию баз данных, которые я администрировал ещё в 2014-м, но не задавался вопросами подкапотной архитектуры. Переосмысление архитектуры веб-приложений и состояния как явления. Один из артефактов всего этого — недавняя статья «Что такое состояние» [7], в которой изложены ключевые принципы архитектуры менеджера состояния.

Но главное — первая LTS и вторая версия реатома пытались быть совместимы с редаксом, и сколько я ни старался, нормально это сделать не выходило, он просто фундаментально сломан:

  • O(n) сложность, где n — количество подписчиков;
  • единая очередь для подписчиков и вычисляемых значений, из-за чего в селекторах нет атомарности [8];
  • невозможность батчинга (диспатча нескольких экшенов).

Бойлерплейт для меня всегда был меньшей проблемой, но вы просто посмотрите [9] на эту разницу между тулкитом(!) и реатомом. По ссылке используется пакет @reatom/framework, который включает в себя базовый набор самых часто используемых пакетов и просто реэкспортит из них всё для удобства установки и импорта. В последние пару лет требования к развитой экосистеме всё важнее. В 2020-м было нормально иметь маленькую библиотеку и дать на откуп пользователей писать и публиковать в NPM свои хелперы. Но сейчас индустрия уже повзрослела и предъявляет взвешанные требования к экосистеме, её слаженности и поддержке. Все пакеты Reatom хранятся в монорепе, что позволяет тестировать любое изменение со всеми зависимостями и синхронизировать релизный цикл, сделав его предсказуемым.

Это что касается технического аспекта поддержки, в общем же политика выглядит так: нечётные релизы считаются LTS и поддерживаются несколько лет. Первая версия поддерживалась три года, сейчас можно подменить импорты и использовать код на ней дальше с новыми фичами и дальнейшей поддержкой. Текущая третья версия (@reatom/core@3.x.x) будет актуальна ещё несколько лет. Раз в год возможны небольшие ломающие изменения от рефакторинга типов.

▍ Базовые сущности

Cперва взглянем на код базового примера из этой песочницы [10]:

import { action, atom } from '@reatom/core'
import { useAction, useAtom } from '@reatom/npm-react'

const inputAtom = atom('')
const greetingAtom = atom((ctx) => `Hello, ${ctx.spy(inputAtom)}!`)
const onChange = action((ctx, event) =>
  inputAtom(ctx, event.currentTarget.value),
)

export const Greeting = () => {
  const [input] = useAtom(inputAtom)
  const [greeting] = useAtom(greetingAtom)
  const handleChange = useAction(onChange)

  return (
    <>
      <input value={input} onChange={handleChange} />
      {greeting}
    </>
  )
}

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

Конечно, больше всего вопросов вызывает ctx. Это некий глобальный DI-контейнер на стероидах, заточенный под стейт-менеджемент. Он позволяет читать актуальный стейт атома ctx.get(anAtom), подписываться на него ctx.subscribe(anAtom, newState => sideEffect(newState)) и планировать сайд-эффекты во время транзакции, но об этом попозже. Главное, что нужно запомнить — ctx прокидывается первым аргументом в большинстве колбэков реатома и каждый раз приходит новый (под капотом содержит весь стек предыдущих контекстов вплоть до глобального).

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

Базовый атом может быть вызван как функция с новым значением или редьюсером этого значения: countAtom(ctx, 1) и countAtom(ctx, state => state + 1). Такой вызов функции возвращает новое значение атома. В типах такой атом называется AtomMut (mutable atom).

Благодаря такому апи код быстро и удобно парсить глазами: ctx.get и ctx.spy получают значение атома, а doSome(ctx) и someAtom(ctx, value) мутируют.

greetingAtom — вычисляемый атом, который вызывает переданную функцию при первой подписке и с помощью метода spy в контексте подписывается на переданный атом и получает его значение. Это map и combine в одном флаконе, только гибче и удобнее. В типах такой атом называется просто Atom. У вычисляемых значений реатома есть две киллер-фичи, каждая из которых отдельно встречается в ФП (редакс) или ООП (мобыкс) мире, но я ещё не встречал их вместе.

Первое — если вычисление в редьюсере (да, вторым аргументом приходит предыдущий стейт) упадёт с ошибкой, все предыдущие изменения в текущей транзакции откатятся и будет соблюдена атомарность (рассказывал об этом здесь [7]). Это важный аспект, позволяющий не допустить неконсистентные данные, и он важен для крупных приложений. Базовое поведение React практически такое же, даже жёстче, всё приложение размонтируется (в случае отсутствия componentDidCatch).

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

const listAtom = atom([])
const listAggregatedAtom = atom(ctx => aggregate(ctx.spy(listAtom)))
const listViewAtom = atom((ctx) => {
  if (ctx.spy(isAdminAtom)) {
    return ctx.spy(listAggregatedAtom)
  } else {
    return ctx.spy(listAtom)
  }
})

Подобный код на реселекте или ФРП-библиотеке, скорее всего, получал бы вместе isAdmin, list и listAggregated и способствовал избыточным вычислениям (для isAdmin === false). Конечно, в теории можно описать селекторы, которые будут делать примерно то же самое, но на практике так не заморачиваются и получают очередную каплю в замедление приложения. В реатоме такие условные подписки — базовый принцип.

Удобно использовать в вычисляемом атоме и простой ctx.get для чтения какого-то значения — это не создаёт подписку, но гарантированно отдаёт самый актуальный стейт переданного атома.

На самом деле атомы не хранят значения, а являются лишь объектом с метаданными и ключом WeakMap-контекста, где и хранятся все стейты и связи между атомами. Это позволяет прозрачно и безопасно инстанцировать цепочки вычислений и упрощает SSR и тестирование [11].

onChange — экшен, хелпер для батчинга изменений. Если у вас есть несколько атомов для последовательного обновления, каждое изменение будет тригерить их зависимые вычисления и подписчиков. Что бы забатчить вычисления, можно использовать колбэк в ctx.get(() => {...}) или просто создать выделенный экшен и произвести все апдейты в нём. Экшены удобны тем что им можно, как и атомам, давать имена (второй аргумент), что в дальнейшем упрощает дебаг @reatom/lgger. [12]

Про TypeScript, реатом разрабатывается с большим фокусом на автоматическом выводе типов и всегда старается понять переданные данные. Если вам необходимо затипизировать параметры экшена, просто укажите их тип у них же: action((ctx, event React.ChangeEvent) => ...). Больше рекомендаций по описанию типов ищите в документации [13].

Под капотом экшен — это атом со временным стейтом, хранящий params вызова и возвращённый payload. С ним можно делать всё то же, что и с атомом: подписываться через ctx.subscribe для сайд-эффектов и ctx.spy в вычисляемом атоме. Например, можно в вычисляемом атоме получить данные другого атома только при срабатывании какого-то экшена — это редкий, но очень удобный способ оптимизации. Больше примеров и возможных паттернов разберём в следующих статьях.

Стоит упомянуть о ctx.schedule, который позволяет планировать сайд-эффекты, как useEffect в реакте. Его можно вызывать где угодно, но чаще всего это пригождается в экшенах.


const onSubmit = action((ctx) => {
  const input = ctx.get(inputAtom)
  inputAtom(ctx, '')
  ctx.schedule(() => api.submit({ input }))
})

Переданный в ctx.schedule колбэк будет вызван после всех чистых вычислений, но до вызова подписчиков — это удобно, т. к. иногда эффекты просто сохраняют что-то в localStorage или делают другие не чистые, но синхронные операции и вызывают ещё апдейты. Подробности есть в документации [14], в общем же реатом старается всегда максимально отложить вызов подписчиков, чтобы избежать лишних ререндеров и предоставить самый последний и актуальный стейт. У редакса с этим ситуация радикально хуже.

▍ @reatom/npm-react

Все пакеты-адаптеры имеют префикс платформы (npm, web, node), подробнее об этом можно почитать в документации [15].

Думаю, использование useAtom и useAction понятно и практически не нуждается в комментариях :) Хотя несколько вещей всё же нужно учесть.

В документации [16] к npm-react описаны обязательные инструкции по подключению реатома в провайдер реакта и настройке батчинга [17] для старой (<18) версии реакта.


import { createCtx } from '@reatom/core'
import { reatomContext } from '@reatom/npm-react'

const ctx = createCtx()

export const App = () => (
  <reatomContext.Provider value={ctx}>
    <Main />
  </reatomContext.Provider>
)

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

export const Greeting = () => {
  const [input, setInput] = useAtom(inputAtom)
  const [greeting] = useAtom(greetingAtom)

  return (
    <>
      <input value={input} onChange={e => setInput(e.currentTarget.value)} />
      {greeting}
    </>
  )
}

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

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


export const Greeting = () => {
  const [input, setInput, inputAtom] = useAtom('')
  const [greeting] = useAtom((ctx) => `Hello, ${ctx.spy(inputAtom)}!`, [inputAtom])

  return (
    <>
      <input value={input} onChange={e => setInput(e.currentTarget.value)} />
      {greeting}
    </>
  )
}

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

▍ Заключение

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

Можно лишь отметить, что любителям ФРП стоит обратить внимание на пакет @reatom/lens [18], а сторонникам более классической архитектуры — взглянуть на пакет @reatom/hooks [19], который позволяет писать более изолированный код, приближенный к акторам.

Ах да, и про реактивный кеш. Пакет @reatom/async [3] в связке с базовыми фичами реатома даёт большую часть фич react-query, а какие-то даже превосходит, всего за 3.4KB (gzip) [20].

Смотрите больше примеров на соответствующей странице [21] документации, добавляйтесь в Телеграм-канал [22] и чат [23]. И, конечно, оставляйте ваши комментарии и вопросы ниже.

Автор: Артём Арутюнян

Источник [24]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/javascript/381770

Ссылки в тексте:

[1] официальном сайте: https://www.reatom.dev

[2] 2.5KB: https://bundlejs.com/?q=%40reatom%2Fcore

[3] @reatom/async: https://www.reatom.dev/packages/async

[4] скринкаста: https://youtu.be/VqCV_J15_5o

[5] function-tree: https://www.npmjs.com/package/function-tree

[6] проблемы глитчей: https://en.wikipedia.org/wiki/Reactive_programming#Glitches

[7] «Что такое состояние»: https://habr.com/ru/company/ruvds/blog/706086/

[8] нет атомарности: https://github.com/artalar/state-management-specification/blob/master/src/index.test.js#L165

[9] посмотрите: https://github.com/artalar/RTK-entities-basic-example/pull/1/files#diff-43162f68100a9b5eb2e58684c7b9a5dc7b004ba28fd8a4eb6461402ec3a3a6c6

[10] песочницы: https://replit.com/@artalar/reatom-react-ts#src/App.tsx

[11] тестирование: https://www.reatom.dev/packages/testing

[12] @reatom/lgger.: https://www.reatom.dev/packages/logger

[13] документации: https://www.reatom.dev/guides/typescript

[14] документации: https://www.reatom.dev/core#ctxschedule

[15] документации: https://www.reatom.dev/guides/naming

[16] документации: https://www.reatom.dev/packages/npm-react

[17] настройке батчинга: https://www.reatom.dev/packages/npm-react#setup-batching-for-old-react

[18] @reatom/lens: http://reatom.dev/packages/lens

[19] @reatom/hooks: https://www.reatom.dev/packages/hooks

[20] 3.4KB (gzip): https://bundlejs.com/?q=%40reatom%2Fcore%2C%40reatom%2Fasync

[21] странице: https://www.reatom.dev/examples

[22] Телеграм-канал: https://t.me/reatom_ru_news

[23] чат: https://t.me/reatom_ru

[24] Источник: https://habr.com/ru/post/708826/?utm_source=habrahabr&utm_medium=rss&utm_campaign=708826