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

Знание и состояние

Сердце любого современного сайта или браузерного приложения (что SPA, что PWA, что любые другие три буквы) — это его State, или состояние.

Мы можем сколько угодно спорить о том, что лучше — React, Vue, Svelte, Angular, можем продолжать пользоваться jQuery, но в действительности это не так важно. Это та часть нашего приложения, которое мы видим — его “мышцы“ и “кожа”. Но то, как вы думаете — какими терминами оперируете, какие механики используете для даже визуализации в голове того, как в вашем приложении “текут” данные — все это идет из его скелета. Из state manager-а.

Помните, пару лет назад у нас была усталость от JavaScript-а [1]? Сейчас я вижу у огромного количества людей усталость от state manger-ов. Redux? Да [2], да [3] and да [4]. RxJS? Тоже [5]. MobX? Если он такой простой — блин, почему у него есть в документации страница западни.html [6]?

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

Выбирая state manger — мы выбираем образ мышления [7]. Вариантов сейчас много, но самые популярные подходы бьются на 3 группы:

  • Flux/Redux-подобные: глобальное хранилище с action-ами и reducer-ами. Их довольно много, но я бы отметил сам Redux [8], Effector [9], Storeon [10], Unstated [11], и Reatom [12]. Это не “лучшие из лучших”, скорее “самые разнообразные”. Все решения из списка несут в себе что-то уникальное и необычное, и из них можно выхватить разные интересные идеи.

    Этот подход в первую очередь императивный (Тюринг-полный) и глобальный.

  • Observables и пайпы. Самые популярые на сегодняшний день решения в этой группе — это RxJS [13] и MobX [14]. Из менее известных — Kefir [15], Bacon [16], CycleJS [17]. Svelte [18], кстати, тоже попадает в этот список. Они все очень разные с точки зрения developer experience, но с фундаментальной точки зрения отличаются только в одном: MobX, Svelte и другие могут быть описаны с точки зрения топологии обычным графом связей “что триггерит что”, а вот RxJS — нет, его граф связей многомерен и в нем могут возникать “странные петли” [19], передавая обзерваблы в обзерваблах. Это делает его с одной стороны более мощным, с другой — более сложным. Похожая история была с тайпскриптом. Узнали, что его система типов Тюринг-полна. Единственное, что из этого следовало — так это то, что он может зависнуть на шаге проверки, и они добавили ограничение по времени работы.

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

    Дело в том, что на каком-то уровне, гхм, осознания многие разработчики, склонные к парадигмам функционального и реактивного функционального программирования начинают описывать преобразования данных, и при помощи кода “рисовать” пайплайны. Они стараются избегать делать произвольные функции, используя вместо этого библиотеки вроде Lodash [20], Ramda [21], или io-ts [22]. В какой-то момент такой код начинает быть похожим на LISP поверх JS, но в целом это довольно удобно — большая часть логики уезжает в не-тюринг-полный “язык”, который легче поддерживать, чем обычный код.

  • GraphQL и ему решения. Apollo [23] и Relay [24] — это два самых известных примера, но в целом их очень мого. Отдельно стоит упомянуть Falcor [25] — альтернативу GraphQL от Netflix, GunDB [26] и PouchDB [27]. Более того, многие из этих решений интегрируются с Redux, MobX, RxJS и всеми остальными решениями. Но наличие интеграций не умаляет “отдельности” GraphQL. Важно то, что это декларативный глобальный подход к хранению состояния. И он как раз на 100% декларативный (если не считать редких расширений)

У нас есть два основных аспекта, определяющих наше мышление [7]. Состояние для нас — это либо код, либо структура данных; и хранится оно либо на уровне отдельных сущностей, либо где-то преимущественно глобально. И это разбиение вызывает множество вопросов.

Императивный подход Декларативный подход
Глобальное состояние Flux GraphQL
Локальное состояние Observables ?????

Стоит сделать оговорку: термины “локальный” и “глобальный” могут быть не на 100% корректны: можно запихнуть redux-хранилище в контекст или компонент, или вообще его загружать по запросу [28], а RxJS — положить в глобальную переменную. Но в JS в целом можно сделать почти что угодно, поэтому логично будет разделить по тому, как рекомендуют и в большинстве своем пользуются этими решениями.

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

Потерянное звено

Это странно. У нас нет локально-декларативного менеджера состояния. Возможно, есть пара эзотерических решений, которые позволяют так делать, но в "state of js” [29] не упоминается ни одного решения.

И вот в чем дело.

Мы пытаемся смешать две разных вещи вместе.

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

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

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

Модели

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

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

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

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

  • Слой знания: автоматическое кэширование ответов и их инвалидация. Нюанс в том, что обычно он спрятан от глаз рядового разработчика, и не каждый вообще задумывается, что это отдельное полноценное хранилище данных.
  • Слой состояния: порой это конечный автомат [30] или statechart [31]. Порой — отдельные классы с состоянием. Иногда — это те же самые observables (RxJava, RxRuby, RxSwift, RxBrainfuck — идея понятна) с логикой как раз внутри топологии. Иногда — какое-то самописное решение, возможно, даже смешанное с духе спагетти-кода с остальными частями.

Решение

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

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

И они не принадлежат нам. Если мы начнем его менять, они станут, гхм, corrupted. Они ограничены: мы не должны их менять, и они обязаны быть представимыми в качестве DTO [32]. Знание не может включать в себя обычные функции. Если нужно в них держать логику — можно пользоваться маленьким аналогом LISP-а. Не волнуйтесь: согласно десятому правилу Гринспена, он у вас будет в любом случае.

Состояние же — про наше локальное приложение. Да, если у вас микросервисы на клиенте — вы можете рассматривать данные из отдельных микросервисов как знание, но это исключительный случай, который стоит проработать отдельно, но в целом — если вам нужна какая-то переменная для текущих задач, это именно состояние. Мы можем хранить в нем функции, ссылки на глобальные объекты, что угодно. Важное правило: если нужно что-то сделать с имеющимся у нас знанием — мы должны ссылаться на него, а не изменять. Скопировать, держать в состоянии патчи для него, или воспользоваться CRDT [33]-типами. Это чудовищно облегчает нашу ментальную модель — даже если приходится писать немного больше кода вначале.

И еще раз:

Ваше клиентское приложение работает с двумя мирами.

Первый из них для нас — лишь проекция, платоновская пещера, где реальный мир — это сервера, базы данных, права доступа и запросы. Мы же видим лишь отдельные части этого мира, которые нам разрешено увидеть. Мы не можем и не должны скачивать всю базу данных на клиент, поэтому у нас есть запросы к ней, ответы на которые не должны быть изменены. Мы можем пользоваться как императивным подходом await getBlogPost(id), так и декларативным: @gql("blogPost(id){...}") class extends Component, но если вы пользуетесь декларативным — ваш код становится проще и более легко поддержваемым, поэтому стоит придерживаться именно его.

С данными из мира знаний мы обязаны обращаться осторожно. Они должны быть иммутабельны. Берите ImmutableJS [34], Object.freeze [35], readonly [36] Тайпскрипта или Record & Tuple [37] stage 1 proposal. Или просто введите правила. Такое знание можно хранить даже в сервис-воркере или shared worker.

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

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

Вы не должны делать выбор в пользу конкретных решений, но для своего нового проекта я взял бы GraphQL + Apollo для хранения знаний, а для управления состоянием — Xstate + RxJS, благо, они дружат друг с другом [39].

Перестаньте беспокоиться о том, как запихнуть все в одно решение. Вам это не нужно.

Автор: Сева Родионов

Источник [40]


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

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

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

[1] усталость от JavaScript-а: https://medium.com/@ericclemmons/javascript-fatigue-48d4011b6fc4

[2] Да: https://www.reddit.com/r/reactjs/comments/ce95u0/am_i_the_only_one_who_feels_redux_is_very/

[3] да: https://dev.to/bettercodingacademy/redux-is-seriously-overrated-change-my-mind-45pm

[4] да: https://www.quora.com/Why-is-Redux-confusing

[5] Тоже: https://www.quora.com/What-makes-RxJS-so-difficult

[6] западни.html: https://mobx.js.org/best/pitfalls.html

[7] мышления: http://www.braintools.ru

[8] Redux: https://redux.js.org/

[9] Effector: https://github.com/zerobias/effector

[10] Storeon: https://github.com/storeon/storeon

[11] Unstated: https://github.com/jamiebuilds/unstated

[12] Reatom: https://github.com/artalar/reatom

[13] RxJS: https://rxjs.dev/

[14] MobX: https://mobx.js.org/

[15] Kefir: https://kefirjs.github.io/kefir/

[16] Bacon: https://baconjs.github.io/

[17] CycleJS: https://cycle.js.org/

[18] Svelte: https://svelte.dev/

[19] “странные петли”: https://en.wikipedia.org/wiki/Strange_loop

[20] Lodash: https://lodash.com

[21] Ramda: https://ramdajs.com/

[22] io-ts: https://github.com/gcanti/io-ts

[23] Apollo: https://www.apollographql.com/

[24] Relay: https://relay.dev/

[25] Falcor: https://netflix.github.io/falcor/

[26] GunDB: https://gun.eco/

[27] PouchDB: https://pouchdb.com/

[28] загружать по запросу: https://redux-dynamic-modules.js.org/#/

[29] "state of js”: https://2019.stateofjs.com/data-layer/other-tools/

[30] конечный автомат: https://en.wikipedia.org/wiki/Finite-state_machine

[31] statechart: https://en.wikipedia.org/wiki/State_diagram

[32] DTO: https://en.wikipedia.org/wiki/Data_transfer_object

[33] CRDT: https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type

[34] ImmutableJS: https://immutable-js.github.io/immutable-js/

[35] Object.freeze: https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze

[36] readonly: https://www.typescriptlang.org/docs/handbook/classes.html#readonly-modifier

[37] Record & Tuple: https://github.com/tc39/proposal-record-tuple

[38] XState: https://xstate.js.org/

[39] дружат друг с другом: https://xstate.js.org/docs/recipes/rxjs.html

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