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

в 9:00, , рубрики: javascript, React, react.js, reactive programming, ReactJS, Reatom, redux, ruvds_статьи, state, state management, state manager, Блог компании RUVDS.com, состояние

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

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

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

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

▍ Мотивация

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

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

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

▍ История

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

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

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

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

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

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

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

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. У вычисляемых значений реатома есть две киллер-фичи, каждая из которых отдельно встречается в ФП (редакс) или ООП (мобыкс) мире, но я ещё не встречал их вместе.

Первое — если вычисление в редьюсере (да, вторым аргументом приходит предыдущий стейт) упадёт с ошибкой, все предыдущие изменения в текущей транзакции откатятся и будет соблюдена атомарность (рассказывал об этом здесь). Это важный аспект, позволяющий не допустить неконсистентные данные, и он важен для крупных приложений. Базовое поведение 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 и тестирование.

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

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

Под капотом экшен — это атом со временным стейтом, хранящий 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 или делают другие не чистые, но синхронные операции и вызывают ещё апдейты. Подробности есть в документации, в общем же реатом старается всегда максимально отложить вызов подписчиков, чтобы избежать лишних ререндеров и предоставить самый последний и актуальный стейт. У редакса с этим ситуация радикально хуже.

▍ @reatom/npm-react

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

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

В документации к npm-react описаны обязательные инструкции по подключению реатома в провайдер реакта и настройке батчинга для старой (<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, а сторонникам более классической архитектуры — взглянуть на пакет @reatom/hooks, который позволяет писать более изолированный код, приближенный к акторам.

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

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

Играй в нашу новую игру прямо в Telegram!

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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js