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

Также важно будет упомянуть, что для полного понимания описанного в статье, нужно быть знакомым с паттернами Observable/Observer, MVVM и DI.

О самой технологии MobX уже знает немало человек. Судя по данным npmjs.com, этот пакет в среднем скачивается около 850 тысяч раз в неделю. Его главным соперником можно считать библиотеку Redux, и судя по тем же данным, её в среднем скачивают в 8 раз чаще. Такую популярность Redux на фоне гораздо более удобного MobX мне сложно принять, так как я являюсь ярым сторонником этой библиотеки. Поэтому в этой статье я бы хотел описать, почему MobX настолько хорош и как можно сделать его ещё удобнее.

Вообще уже существует немало статей, описывающих плюсы MobX в сравнении с Redux, но для тех, кто с ними не знаком, сейчас я максимально кратко их опишу:

  • В MobX не нужно писать шаблонный код;

  • В MobX сторов может быть множество, а значит их можно логически разделять;

  • MobX гораздо проще для восприятия и изучения;

  • Типизация в MobX гораздо проще описывается и используется.

Но! Есть довольно большое «Но!». Мне не по душе подход самого MobX, который описывается для взаимодействия этой библиотеки с React. В примерах, которые описывают разработчики MobX предлагается создавать некоторый объект – стор, – в котором должна храниться обновляемая информация. Проблема в том, что это слишком размытое представление, не описывающее возможности более сложного взаимодействия, например, при вложенности компонент или при использовании одних сторов другими.

Уровень первый: Better MVVM

Начнем с терминологии. Гораздо логичнее использовать MobX, применяя паттерн MVVM. Формально, использование паттерна MVVM с MobX ничего нового не добавляет, только даёт названия сущностям системы. Моделью (Model) в таком подходе является любой JavaScript объект, а его описанием – его типизация. Представление (View) – это React-компонент. А модель представления (ViewModel) – некоторый класс, хранящий в себе observable поля, по факту являющийся стором.

На этом моменте заканчиваются все вводные. Далее я буду рассказывать о доработках, способных улучшить взаимодействие.

Взаимодействие View и ViewModel можно немного прокачать:

  • Сам объект ViewModel’и можно создавать в момент первой отрисовки View. Так, в конструкторе ViewModel можно писать код, который сработает перед монтированием View. К тому же в памяти компьютера не придется хранить неиспользуемую информацию.

  • Пропы, передаваемые во View можно передавать ViewModel’и. При этом удобно сделать поле, в котором хранятся пропы у ViewModel, observable, что позволит автоматически отслеживать их изменение.

  • При вложенности одной View в другой, можно передавать дочерней ViewModel ссылку на родительскую ViewModel.

  • При необходимости у ViewModel можно описать метод, запускаемый при монтировании и размонтировании View.

Для наглядности, давайте я опишу это на картинке

View1 инициализирует ViewModel1, передавая ей свои пропы. View2 является дочерним элементом View1 (необязательно напрямую) в виртуальном DOM’е. View2 инициализирует ViewModel2, передает ей ссылку на ViewModel1 и свои пропы.

View не обязательно должен быть observer-компонентом. Его главная задача проинициализировать объект его ViewModel’и, передать ей свои пропы и, если требуется, вызывать колбэки своего жизненного цикла.

ChildView

Ещё одна доработка по MVVM – введение понятия ChildView – некоторого компонента внутри View, который также имеет ссылку на ViewModel. ChildView не инициализирует новую ViewModel и не передаёт ей свои пропы. ChildView также не обязательно должен быть observer-компонентом.

В указанном примере у View есть 3 ChildView и каждая имеет ссылку на ViewModel. Из представленных ChildView observer-компонентом является только нижняя левая. Остальные используют статические поля и/или методы ViewModel.

Описание сущностей архитектуры в вакууме не имеет особого смысла. Поэтому я вам крайне рекомендую посмотреть на то, как эти сущности выглядят в коде: CodeSandbox / GitHub.

Уровень второй: Services

У описанного подхода все ещё могут быть свои минусы. Например, зачем хранить некоторую общую информацию для всего приложения в какой-то ViewModel’и?  В таком случае логично сделать некоторый отдельный класс. Предлагаю называть такой класс сервисом.

В сервисе должна храниться общая информация, например, информация о пользователе – статус его авторизации и список разрешенных действий; или методы: создание всплывающего уведомления, переключение между страницами, и т.п. Сервис также может являться стором, то есть хранить observable поля.

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

В JavaScript уже есть несколько готовых библиотек для реализации этого паттерна. В моей, которую я приложу в конце статьи, я использовал TSyringe.

Ограничений, накладываемых на сервисы, не очень много. Если в общем случае у ViewModel может быть ссылка на 1 другую ViewModel – родительскую, – то сервисов она может использовать сколько необходимо. Причем одни сервисы могут свободно использовать другие сервисы.

В реализациях DI часто есть деление на Singleton и Transient классы. И в общем случае я рекомендую делать ViewModel Transient классом, а сервис Singleton.

Если вам интересно посмотреть на работу View, ChildView и ViewModel совместно с сервисами и паттерном DI, можете взглянуть на этот пример: CodeSandbox / GitHub.

Уровень третий: Better Model

Как я писал выше любой JavaScript объект уже является моделью. В TypeScript по-хорошему у каждой такой модели должен быть определен тип. И этого уже достаточно.

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

В таких моделях помимо информации о полях – их тип и, возможно, значения по умолчанию – можно хранить метаинформацию о полях. В дополнительных декораторах можно указывать, как нужно и нужно ли в принципе валидировать определенные поля; а также указывать изменение каких полей нужно отслеживать.

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

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

  • Модель способна отслеживать, какие поля в настоящее время не являются валидными. Причем в процессе валидации могут участвовать не только значения полей, но и весь объект в целом.

  • Прокаченная модель способна наследоваться от другой. При этом в дочерней модели можно перезаписывать метаинформацию родительской модели и/или создавать новые поля.

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

Пример на работу с Model можно посмотреть по этим ссылкам: CodeSandbox / GitHub.

Резюмируя

В конце хотелось бы перечислить преимущества описанной архитектуры:

  • Используя данный подход к разработке можно структурировать подход к обработке состояния приложения. При этом сам подход использует исключительно существующие паттерны.

  • У этого подхода нет необходимости в написании шаблонного кода и нет никаких проблем с типизацией.

  • Данные можно логично разделить по нескольким классам-сторам, причем так, что общие (находящиеся в сервисах) можно будет использовать во всем проекте, а частные (находящиеся во ViewModel) можно будет использовать только в необходимых частях.

  • View могут состоять только из JSX кода, не используя ни одного хука.

  • Работа с формами сокращается до качественного описания моделей.

В дополнение могу ещё сказать, что разработчикам, знакомым с Vue, будет гораздо проще перейти на React с такой архитектурой, нежели с Redux, ведь MobX в формате MVVM имеет очень много схожих концепций.

Ссылки

Послесловие

В статье я занимался только описанием сущностей архитектуры, и того, как они должны между собой взаимодействовать. И хоть я приложил весьма, как мне кажется, полезные ссылки с кодом, своих комментариев я по нему не оставил. Поэтому чуть позже я напишу ещё 1-2 статьи, в которых я опишу полезные use-case’ы описанной архитектуры. А если в комментариях я увижу заинтересованность, я займусь этим как можно скорее.

Комментарии (15)


  1. dark_ruby
    12.02.2022 16:16

    Мне тоже был не по душе слишком свободный подход написанию сторов в МобХ, потом я написал для себя базовый класс Store<T>, где Т хранимый обьект (может быть массивом),

    У базового класса есть опциональное defaultValue и так же он берет насебя логику установки флагов loading, error и т.д.

    Конкретная имплементация стора может взаимодействовать с fetch() или localStorage, или любым другим сторонним сервисом.

    Таким образом шаблонный код наисан однажды, и больше не трогается, и можно концентрироваться на бизнес логике.


    1. Yoskutik Автор
      12.02.2022 18:59

      Если я правильно понял, в вашей реализации в Store может храниться одно некоторое значение. Наверняка, это решение неплохо себя показало, если вы им пользуетесь, но для меня это звучит не очень удобно.


  1. Alexandroppolus
    12.02.2022 19:47
    +2

    Глянул кодъ. Ряд вопросов вызвал useViewModel. Например, для ViewModel есть parent, который подставляется через ViewModelContext, и совершенно нет никакого тайпчекинга: какая вьюмоделька ближайшая по дереву, та и подставится. Сравните это с обычным реактовским контекстом, у которого тайпчекинг работает по всей цепочке от провайдера до useContext. Плюс у парента функция onViewMount может вызваться позднее, потому что так работает useEffect - сначала он отрабатывает у чилдов, потом у родителя. Думаю, parent не нужен. Тем более что всё дополнительное можно передать через DI.

    useMemo для хранения экземпляра ViewModel нежелателен, ибо не гарантирован, см. строку болдом. useRef в помощь.

    Ну и радикальное обновление viewModel.viewProps - по факту меняем наблюдаемое в реакции, что не очень правильно. Надо бы в useEffect запихнуть, или даже в useLayoutEffect.

    Его главным соперником можно считать библиотеку Redux, и судя по тем же данным, её в среднем скачивают в 8 раз чаще. Такую популярность Redux на фоне гораздо более удобного MobX мне сложно принять

    "вариант по умолчанию". Он может быть в сто раз хуже, но всё равно более распространен. Пример - Internet Explorer в нулевые. Увы, такое бывает даже на выборах...


    1. Yoskutik Автор
      12.02.2022 21:16

      Да, вы абсолютно правы. Подставится ближайшая по дереву ViewModel. Проставление типизации лежит на плечах разработчика. Но в этом и смысл. Одну View можно использовать в разных View и в таких случаях будет подставляться разный parent.

      С useMemo тоже верно подметили, надо будет поправить.

      По третьему пункту не могу понять ваше негодование. Вы бы не могли описать, почему так делать не стоит?


      1. site_trouble
        13.02.2022 12:14

        Использовать useRef тоже так себе идея, потому что на каждый рендер будет создавать новый инстанс вьюмодели. Лучше использовать useState с коллбэком в параметрах.


        1. Yoskutik Автор
          13.02.2022 12:17

          Тут вы ошибаетесь. useRef не будет создавать новый инстанс. Сам по себе этот хук принимает некоторое значение по умолчанию, и затем дает возможность его изменять через поле current. Но если использовать этот хук в формате useRef(initValue).current, значение будет постоянным и не будет меняться от рендера к рендеру.


          1. site_trouble
            13.02.2022 12:46
            +2

            1. Yoskutik Автор
              13.02.2022 13:16

              А, вот вы о чем говорили. Да, изначально неправильно вас понял. Действительно на каждый рендер будет создаваться новое значение.


        1. Alexandroppolus
          13.02.2022 23:40
          +1

          Использовать useRef тоже так себе идея, потому что на каждый рендер будет создавать новый инстанс вьюмодели

          Ну так кто вам мешает "выдумать порох непромокаемый"?

          function useInstance<T>(create: () => T): T {
            const ref = useRef<T>();
            if (ref.current === undefined) {
              ref.current = create();
            }
            return ref.current;
          }
          
          ...
          const viewModel = useInstance(() => container.resolve(VM));

          useState можно, но он более тяжеловесный, с дополнительной логикой, и вообще это будет для него "нетипичное использование"


      1. Alexandroppolus
        13.02.2022 23:32

        По третьему пункту не могу понять ваше негодование. Вы бы не могли описать, почему так делать не стоит?

        Рендер - это, по замыслу, отображение текущего стейта в UI (основная концепция Реакта - "UI как значение"). Менять стейт на рендере - как менять коней на переправе потенциально бажная затея, которая может привести к бесконечному циклу. Вообще говоря, если у вас в компоненте есть как пропсы, так и вьюМодель с наблюдаемыми значениями, то в рендере самого копонента всегда можно получить компутеды как функцию от пропсов. А для дочерних компонентов, которые захотят воспользоваться вьюМоделью родителя, можно обновить эту родительскую вьюМодель и в useEffect - они перерендерятся следующим этапом. Ну и в любом случае тот компонент, где будет обновление viewModel.viewProps, ни в коем случае не должен это самое viewModel.viewProps читать - разве что через компутеды.

        Я бы вообще сделал вот так:

        useEffect(() => {
          viewModel.updateProps({...props});
        }, [props, viewModel])

        В реализации updateProps уже конкретные действия модели.

        Одну View можно использовать в разных View и в таких случаях будет подставляться разный parent.

        Это понятно. Проблема в том, что, например, в классе у нас parent: ViewModelA, но по факту в него может невозбранно заехать ViewModelB, и TypeScript промолчит - в useViewModel проверка выключена (и выключена не потому что влом разбираться, как там затипизировать, а потому что затипизировать такое нельзя в принципе). Та же самая проблема, кстати, есть и для childView.

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

        Было бы очень круто подставлять паренты как настоящие DI-зависимости в конструкторе, вместе с другими зависимостями. Для этого надо не импортировать container из "tsyringe", а как-то, пока не совсем понятно как, допилить его, чтобы он мог быть в "размазанном по vdom-дереву виде". Т.е. контейнеры лежали в контексте, при попытке резолва очередной зависимости ближайший контейнер сначала пробовал найти у себя, а при неудаче - у родительского контейнера. А вьюМоделька при создании добавлялась бы в контейнер, лежащий в провайдере своего view. Как-то так, в общем.

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


        1. Yoskutik Автор
          14.02.2022 10:21

          сейчас схема с парентом выглядит как "DI для очень бедных"

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

          и нет возможности работать с несколькими парентами

          Мне очень сложно представить код, в котором бы использовалось несколько parent'ов. Но даже если они требуется, можно всегда использовать вложенные parent (например, parent.parent, parent.parent.parent) Кажется, что это неудобно. Но я не думаю, что это плохо. Мне кажется, что в общем случае не стоит делать настолько глубокие зависимости дочерних ViewModel от родительских.

          Было бы очень круто подставлять паренты как настоящие DI-зависимости...

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

          В добавок, в вашем предложении будут проблемы с использованием View в разных View. Сейчас можно указать тип для родительской ViewModel IViewModelAOrB, а в вашей реализации нужно было бы продумать какое-нибудь усложенение, для того, чтобы искалась ViewModelB, если не нашлась ViewModelA, и при этом одна не является дочерней ViewModel другой


        1. faiwer
          14.02.2022 22:04

          Менять стейт на рендере — потенциально бажная затея, которая может привести к бесконечному циклу

          Всё так. А ещё бывают fake-ые рендеры (от тех же react dev tools). И рендеры в пустоту (Suspense).


  1. iurii_gudkov
    15.02.2022 10:26

    Спасибо за статью. Тоже думал в подобном направлении, но подобный подход не прижился. Это очень похоже на redux с hoc-компонентом connect и аргументами в виде mapStateToProps и mapDispatchToProps.


    1. Yoskutik Автор
      15.02.2022 11:25

      Думаю, единственная общая черта между connect и view - это то, что они обе являются HOC'ами. В остальном же подход абсолютно разный


  1. JustDont
    15.02.2022 11:10

    Хорошая статья. Уже давно практикую подобный подход (где удаётся, глубина распространения mobx в энтерпрайзе, увы, до сих пор очень такая себе), только без автоматики связывания моделей, все зависимости явно задаются через конструкторы (т.е. дочерняя модель явно должна получить инстанс родительской через конструктор "руками"). Это чуток более многословно, но и более очевидно, а заодно и нет проблем с потенциально небезопасной типизацией, как в статье с parent.
    Ну и у вьюшек нет никаких пропсов, отличных от моделей (а зачем им?).


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