Как стать автором
Обновить
3134.54
RUVDS.com
VDS/VPS-хостинг. Скидка 15% по коду HABR15

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

Время на прочтение9 мин
Количество просмотров14K


Привет, меня зовут Артём Арутюнян и я автор менеджера состояния 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('')
// вычисляемый атом
// `spy` динамически читает атом и подписывается на него
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-контейнер на стероидах, заточенный под стейт-менеджемент. В подавляющем большинстве колбеков реатома он передается первым аргументом, этот минимальный контракт позволяет реализовать очень продвинутые паттерны разработки, когда это потребуется. Помимо подписки для вычисляемого значения (spy), которая работает только внутри атома, он позволяет читать актуальный стейт атома ctx.get(anAtom), подписываться на атом для сайд-эффектов ctx.subscribe(anAtom, newState => sideEffect(newState)) и планировать сайд-эффекты во время транзакции, но об этом попозже. Главное, что нужно запомнить — ctx прокидывается первым аргументом в большинстве колбэков реатома и каждый раз приходит новый (под капотом содержит весь стек предыдущих контекстов вплоть до глобального) — это помогает в дебаге и собственной реализации грядущего proposal AsyncContext в стандарт JavaScript.

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

Базовый атом может быть вызван как функция с новым значением или редьюсером этого значения: countAtom(ctx, ctx.get(countAtom) + 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.. Кстати, в экосистеме есть eslint-плагин, который умеет сам проставлять имена автофиксом!

Про 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}
    </>
  )
}

Зачем менять нативный метода реакта на какую-то библиотеку? Реатом работает намного эффективнее и по умолчанию оптимизирует максимум, профит от этого становится заметен уже на нескольких компонентах. Более того, useAtom и useAction знают имя компонента, в котором он был вызван, и логирование и отслеживание что и почему поменялось, в какой последовательности обновлялось — самая большая боль в дебаге реакта, становится очень простым и приятным занятием.

▍ Заключение


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

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

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

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

Играй в нашу новую игру прямо в Telegram!
Теги:
Хабы:
Всего голосов 39: ↑38 и ↓1+59
Комментарии61

Публикации

Информация

Сайт
ruvds.com
Дата регистрации
Дата основания
Численность
11–30 человек
Местоположение
Россия
Представитель
ruvds