Разработчики React тяготеют к функциональному подходу, но с появлением MobX, появилась возможность работать с состоянием в более-менее привычном ООП-стиле. Mobx старается не навязывать какую либо архитектуру, позволяя работать с реактивным состоянием, как с обычными объектами. При этом он делает автоматическое связывание вычислений, когда достаточно написать C = A + B, чтобы при обновлении A, обновился и C. В HelloWorld это выглядит просто, но если мы добавим fetch, отображение статусов загрузки и обработку ошибок, мы увидим, что получается много копипаста, а в код начинают просачиваться хелперы вроде when, fromPromise или lazyObservable. И уже не получается писать код так, как будто нет асинхронности. Я хочу разобрать некоторые подобные примеры в MobX и попытаться улучшить его базовую концепцию, развив идею псевдосинхронности. Рассмотрим простейший список дел на MobX и React. fiddle В простом случае компонент через componentWillMount должен сам начать загрузку данных. Каждый раз, создавая новый компонент, используюший todoList, программисту надо держать в голове, что todoList.todos надо загрузить. Если этого не сделать, то кто даст гарантию, что кто-то там наверху уже загрузил эти данные? Можно, конечно, лучше разделить состояние и UI без componentWillMount для целей загрузки. О чем и говорит автор MobX Michel Weststrate в статье How to decouple state and UI. При открытии страницы все данные, необходимые для ее рендеринга, запрашиваются с сервера. А ответственность по инициализации этой загрузки автор предлагает перенести на роутер. Такой подход порождает проблему — роутер должен знать, что конкретно из данных требуется компонентам, которые будут на открываемой странице. Вызов метода store.showOverview в этом месте кода нарушает инкапсуляцию. Что будет, если в ходе рефакторинга на страницу добавили новый компонент, которому надо что-то получить с сервера, а в роутер не добавили загрузку? Ошибиться здесь легко, так как детали работы со стором размазаны по разным местам приложения. Вызов fetchTodos() не обязательно должен быть в componentWillMount. Он может быть замаскирован за HOC, за роутером, за вызовом onClick в какой-нибудь кнопке, даже напрямую вызываться в index.js, как в примере с redux-saga: Где store.runSaga(rootSaga) сразу запускает загрузку всех необходимых для работы приложения данных. Суть одна — в коде будет место, где программист должен инициировать загрузку. И это место будет вне модели или того что вместо неё (например саги), хотя по смыслу сам факт вызова инициализации — лишь внутренняя деталь работы с сетью. Если убрать асинхронность, то она становится ненужной. Причем загрузка в подобных решениях происходит не по факту обращения компонента к этим данным, а заранее. В MobX ошибки и статус загрузки сами собой никак не попадут на интерфейс. Чтобы их отобразить, нам для каждой загружаемой сущности надо создать свойство error в сторе. В каждом компоненте с todoList.todos необходимо cделать обработку этого свойства, которая в большинстве случаев будет одинаковой — показать надпись или stack trace в dev-режиме. Если программист забудет их обработать — пользователь не увидит ничего, даже надписи «Что-то пошло не так». fiddle Шаблонного кода в предыдущем примере много, как в сторе, так и в компоненте. Для уменьшения копипаста можно использовать хелпер fromPromise из mobx-utils, который вместе со значением отдает статус загрузки этого значения. Вот пример демонстрации его работы: fiddle У нас уже есть свойство todoContainer, которое содержит значение и статус. Обработать в компоненте его уже проще. В примере выше вызов fetchTodos делается в конструкторе стора TodoList. В отличие от примера с роутингом, это позволяет лучше инкапсулировать детали реализации, не выставляя fetchTodos наружу. Метод fetchTodos остается приватной деталью реализации TodoList. Минусы такого подхода: Чтобы загрузка из последнего примера происходила лениво, по факту рендеринга компонента (а не в new TodoList) можно обернуть fromPromise в хелпер lazyObservable из mobx-utils. Загрузка начнется, после того, как в компоненте выполнится todoContainer.current(). fiddle Хелпер lazyObservable решает проблему ленивости, но не спасает от шаблонного кода в компоненте. Да и конструкция lazyObservable(sink => sink(fromPromise(fetchSomeTodos()))) уже не так просто выглядит как fetchSomeTodos().then(todos => this.todos = todos) в первой версии списка. Помните идею «пишем так, как будто нет асинхронности». Что если пойти дальше MobX? Может кто-то уже это сделал? Пока, на мой взгляд, дальше всех продвинулся mol_atom. Эта библиотека является частью фреймворка mol от vintage. Здесь, на хабре, автор написал много статей о нем и о принципах его работы (например, Объектное Реактивное Программирование или ОРП). Mol интереснен своими оригинальными идеями, которых нет нигде больше. Проблема в том, что у него полностью своя экосистема. Нельзя взять mol_atom и начать использовать в проекте с реактом, вебпаком и т. д. Поэтому пришлось написать свою реализацию, lom_atom. По сути это адаптация mol_atom, заточенная для использования с реактом. Рассмотрим аналогичный пример с todo-листом на lom. Для начала посмотрим на стор с компонентом. fiddle Происходит тут следующее. ErrorableView может быть такого содержания: Неважно какой компонент и какие данные в нем используются, поведение по умолчанию для всех одинаково: при любом исключении показывается либо крутилка (в случе mem.Wait), либо текст ошибки. Такое поведение сильно экономит код и нервы, но иногда его надо переопределить. Для этого можно задать кастомный ErrorableView: fiddle Можно просто перехватить исключение внутри TodoListView, обернув в try/catch todoList.todos. Исключение, бросаемое в компоненте, роняет только его, рисуя ErrorableView. fiddle В этом примере мы увидим Fatal error только на месте второго todo. Такой подход на исключениях дает следующие преимущества: По сравнению с MobX бойлерплейта стало гораздо меньше. Каждая строчка — это строчка бизнес-логики. А что будет, если в одном компоненте отобразить несколько загружаемых сущностей, то есть кроме todos, например, есть еще users. fiddle Если при первом рендере TodoListView todos и users не будут загружены, вместо них в компонент придут прокси-объекты. То есть когда мы пишем const {todos, users} = todoList, выполняются get todos() и get users(), инициируется их параллельная загрузка, бросается mem.Wait, mem оборачивает исключение в прокси. В компоненте, при обращении к свойствам todos.map или к users.map, выбросится исключение mem.Wait и отрендерится ErrorableView. После загрузки компонент еще раз отрендерится, но уже с реальными данными в todos и users. Это то, что в mol называется синхронный код, но неблокирующие запросы. У такого подхода правда есть и минус — необходимо сперва вытащить из todoList todos и users и только потом с ними работать, иначе будет последовательная загрузка и оптимизации не получится. Примеры выше довольно простые. Декоратор mem это такой умный кэш, то есть если todos один раз загрузились, то во второй раз mem отдаст их из кэша. Раз есть кэш, значит должна быть возможность писать в кэш, минуя обработчик set todos. Значит есть проблема инвалидации кэша. Нужен способ автоматически сбрасывать значение, если зависимость изменилась, также нужно уметь вручную сбрасывать значение, если надо по нажатию кнопки перевытянуть данные и т. д. Очистка при изменении зависимости и обновление компонента решаются аналогично MobX. А проблема ручного управления кэшем решена через декоратор force. Его работу демонстрирует следующий пример: fiddle При нажатии кнопки Reset запрашивается todoList.forced.todos, который безусловно выполняет get todos и заново заполняет кэш. При присвоении значения к todoList.forced.todos значение же запишется в кэш, минуя обработчик set todos. Помните выше был код с this.$.todos = todos? Запись в кэш — это приватная деталь get todos. Когда fetch в нем получит данные, то запишет их в кэш напрямую, минуя вызов set todos. Извне запись в todoList.$.todos не допускается. А вот сброс кэша (чтение todoList.$.todos) вполне может быть инициирован извне, что бы повторить запрос. То, как это сейчас выглядит с force, не самое интуитивно понятное решение, но оно не привносит хелперов в код, оно практически не искажает интерфейс свойств класса (не надо все делать методами), то есть остается ненавязчивым. И очень просто решает целый класс задач, которые неизбежно возникают в MobX-подобных подходах. Тут главное понять некоторые правила: В lom_atom нет observable-оберток свойств-объектов и массивов, как в MobX. Но есть простой key-value словарь. Например, если к каждому todo понадобилось отдельно подгружать описание по todoId, вместо свойства можно использовать метод, где первый аргумент — ключ на который кэшируется описание, второй — само описание. fiddle Если выполнить todoList.description(todo.id), то метод сработает как геттер, аналогично get todos.
Так как метод один, а функции 2 — get/set, то внутри есть ветвление: То есть если todoDescription !== undefined, значит метод вызван как сеттер: todoList.description(todo.id, todo). Ключ может быть любым сериализуемым типом, объекты и массивы будут сериализованы в ключи с некоторой потерей производительности. Зачем я в начале завел разговор о MobX? Дело в том, что обычно в бизнес-требованиях ничего нет про асинхронность — это приватные детали реализации работы с данными, от неё пытаются всячески абстрагироваться — через потоки, промисы, async/await, волокна и т. д. Причем в вебе выигрывают абстракции проще и менее навязчивее. Например, async/await менее навязчив, по сравнению с промисами, так как это конструкция языка, работает привычный try/catch, не надо передавать функции в then/catch. Иными словами, код на async/await выглядит больше похожим на код без асинхронности. Как антипод этого подхода, можно упомянуть RxJS. Здесь уже надо окунаться в функциональное программирование, привносить в язык тяжеловесную библиотеку и изучать её API. Вы выстраиваете поток простых вычислений, вставляя их в огромное количество точек расширения библиотеки, или заменяете все операции на функции. Если бы еще RxJS был в стандарте языка, однако наряду с ним есть most, pull-stream, beacon, ramda и многие другие в схожем стиле. И каждый привносит свою спецификацию для реализации ФП, сменить которую уже не получится без переписывания бизнес-логики. Mobx же не привносит новых спецификаций для описания observable-структур. Остаются нативные классы, а декораторы работают прозрачно и не искажают интерфейс. API его гораздо проще за счет автоматического связывания данных, нет многочисленных видимых оберток над данными. Актуализация данных, обработка статусов и ошибок в компонентах — это тоже просачивающаяся асинхронность: инфраструктура, которая в большинстве случаев имеет косвенное отношение к предметной области. Приложения без fetch на MobX выглядят просто, однако стоит добавить этот необходимый слой, как уши асинхронности начинают торчать из каждого сервиса или более-менее сложного компонента. Либо у нас шаблонный код, либо хелперы, захламляющие бизнес логику и ухудшающие чистоту идеи «пишем так, как будто нет асинхронности». Структура данных усложняется, вместе с самими данными в компоненты просачиваются детали реализации канала связи: ошибки и статусы загрузки данных. Как альтернатива MobX, lom_atom пытается решить эти проблемы в основе, без привнесения хелперов. Для адаптации к компонентамам реакта используется reactive-di (по смыслу аналогичен mobx-react). О нем я рассказывал в своей первой статье, как о попытке развить идею контекстов реакта, получив более гибкие в настройке компоненты, переиспользуемую альтернативу HOC и лучшую интеграцию компонент с flow-типами и, в перспективе, дешевый SOLID. Надеюсь, я смог показать на примере атомов, как небольшая доработка базовой концепции может существенно упростить код в типовых задачах для веба и избавить компоненты от знания деталей получения данных. И это небольшая часть того, что может ОРП. На мой взгляд, это целая область программирования со своими паттернами, достоинствами и недостатками. А такие вещи как mol_atom, MobX, delegated-properties в Kotlin это первые попытки нащупать контуры этой области. Если кому-то что-либо известно о подобных подходах в других языках и экосистемах — пишите в комментах, это может быть интересно. Программист веб и не только