Всем привет!


Меня зовут Сергей, я работаю в команде разработки приложений контроля качества Tinkoff.
Поделюсь опытом нашей команды в использовании библиотеки Mobx и расскажу о деталях работы с ней в связке с React. В этой статье не будет описания базовых концепций. Я расскажу о вещах, которые мы отметили для себя за время разработки и считаем полезными для всех, кто решил использовать Mobx в своем проекте.


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


Сказ об удобстве


В статьях и комментариях о Mobx часто встречается слово «магия». Это связано с непониманием того, как Mobx обновляет UI-компоненты.


На проектах с Redux, как правило, используют PureCompoment, хуки и shouldComponentUpdate для предотвращения лишних перерисовок. Mobx же автоматически переопределяет shouldComponentUpdate и использует мемоизацию для компонент. Скрытые под капотом оптимизации смущают разработчиков, а смущение вызывает недоверие. Чтобы понять, как работает MobX с React компонентами, я расскажу о проблеме, с которой мы столкнулись при использовании собственного HOC (Higher-Order Component), отвечающего за необходимость отрисовки компонента.


Допустим, HOC называется withVisible и добавляет передаваемому компоненту дополнительный булевый параметр visible. Отрисовка компонента вызывается, если передать этот параметр со значением true, в противном случае возвращаем null:


function withVisible(wrappedComponent) {
   function Visible({ visible, ...otherProps }) {
       if (!visible) return null;
       const WrappedComponent = wrappedComponent;
       return <WrappedComponent {...otherProps} />;
   }

   return Visible;
}

  • Такая обертка делает компоненты чище, в render-функции больше не нужно описывать кейс, когда отрисовка не требуется.
  • Мы получаем единый инструмент для разруливания видимости переиспользуемых «глупых» компонент, таких как кнопки и инпуты.
  • Мы не любим тернарные выражения в JSX-коде. Благодаря withVisible HOC от них можно избавиться.

Однако использование этого HOC несет за собой побочный эффект в виде излишних перерисовок. Проблема возникает из-за того, что вызывается ререндер компонента, отображение которого не зависит напрямую от значений observable-полей. Давайте взглянем на пример использования этого HOC:


const Loader = withVisible(LoaderComponent);
const Audio = withVisible(AudioComponent);

function Player({ model }) {
   return (
       <div>
           <Loader visible={model.isLoading} />
           <Audio visible={!model.hasAudio && !model.hasErrors} />
       </div>
   );
}

export inject("model")(observer(Player))

Получается довольно простой и читаемый код. Но тут есть сразу несколько проблем. Уже видите их?


Начнем по порядку.


Передаваемая модель содержит три наблюдаемых значения: hasAudio, isLoading и hasErrors. Чтобы компонент обновлялся после изменения этих полей, используется функция observer из библиотеки mobx-react. Она подписывает компонент на обновления всех observable. Отметим, что эти поля напрямую не влияют на отрисовку компонента Player, но используются для отображения дочерних компонентов. Из-за HOC мы получили дополнительную перерисовку компонента на уровень выше того, состояние которого обновляется.


В Mobx используются inject’ы для связи компонента со стором, так почему бы не инжектить необходимые поля внутри компонента, который непосредственно использует наблюдаемые значения.


В примере с кодом присутствует и другая проблема, которая не связана с HOC. Давайте взглянем на вот эту строчку:


<Audio visible={!model.hasAudio && !model.hasErrors} />

Это участок кода приводит к излишним вызовам render функции компонента Player. Как мы уже знаем, Mobx подписывает компонент на изменения hasAudio, isLoading и hasErrors, но на отображение Audio влияет результат выражения !hasAudio && !hasErrors. Этот компонент будет отображаться только в одном случае, если оба параметра будут равны false. При трех других возможных комбинациях значений этих полей HOC вернет null. Заметим, что родительский компонент Player будет обновляться при каждом отдельном изменении обоих из полей, но это не будет влиять на результат отрисовки.


Первая проблема показала, что каждое observable поле лучше инжектить по месту непосредственного использования. Для решения второй проблемы, на помощь приходят computed значения.


Чтобы не попадать на такие ошибки стоит придерживаться базового принципа, который советует создатель библиотеки Michel Weststrate.


Базовый принцип


Базовый принцип гласит: делайте компоненты как можно меньше и используйте computed-значения.


Давайте разбираться. С разбиением на маленькие компоненты вроде бы все понятно: чем меньше компоненты, тем точечней будут перерисовки. Но зачем computed-значения? А они как раз помогают избежать ситуации, которая описана в проблеме выше:


@computed
get showAudio() {
       return !this.hasAudio && !this.hasErrors;
}

Computed помогают избегать лишних перерисовок. По сути это новое observable-поле, которое не хранит значение внутри себя, а вычисляет его на основе других observable-полей. Для компонентов оно будет выглядеть как еще одно observable-поле, но обновляться будет только при изменении результата выполнения выражения внутри себя. Тут может возникнуть справедливый вопрос: а как это работает?


Computed автоматически вычисляется, если изменяется какое-либо observable-значение, влияющее на него. При изменении hasAudio или hasErrors showAudio получит уведомление и выполнит пересчет собственного значения. Возвращаемое значение кэшируется и не будет изменено, пока не поступит новое уведомление об изменении. То есть вычисления происходят не при каждом обращении к computed, а когда изменяются зависимые observable-поля.


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


С базовым принципом более-менее понятно. Дальше я поделюсь методиками, которые редко упоминают в других статьях или используют в проектах (по крайней мере те, что я видел), но стоят того, чтобы о них сказать.


А всегда ли нужен хук useCallback?


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


  1. UI-представление;
  2. Модель с состоянием;
  3. Cервис.



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


Рассмотрим пример. Компонент Button принимает в параметр onClick коллбек doSomething из модели Model.


// Пример модели
class Model {
   @action
   doSomething() {
       // some code
   }
}

// Пример компонента
function AnyComponent({ model }) {
   return (
       <div>
           <Button onClick={model.doSomething} /> // не хорошо
           ...
       </div>
   );
}

Передать коллбек напрямую нельзя, теряется контекст. Чтобы решить эту проблему, разработчики пользуются тремя стандартными способами.


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


 <Button onClick={() => model.doSomething()} />

Этот способ не оптимален. Использование стрелочной функции в render-методе приводит к созданию новой функции при каждом обновлении компонента. Button будет перерисовываться при каждом изменении родительского компонента из-за изменения ссылки в параметре onClick. Это нарушает оптимизации приложения и влияет на его производительность.


Второй способ — сделать компонент классовым и вынести коллбек-функцию в отдельный метод класса при помощи стрелочной функции.


class AnyComponent extends Component {
   onClick = () => {
    const { model } = this.props;
       model.doSomething();
   };

  render() {
       return (
           <Button onClick={this.onClick} />
       );
   }
}

Это стандартное решение. При передаче метода дочернему компоненту сохраняется контекст, но при этом функция не изменяет ссылку при обновлении компонента.


C появлением хуков в React 16.8 появился еще один способ решения проблемы. Переделывать функциональный компонент на классовый из-за одного маленького коллбека перестало быть необходимым. Можно воспользоваться хуком useCallback:


function AnyComponent({ model }) {
   const onClick = useCallback(() => {
       model.doSomething();
   }, []);

   return (
       <Button onClick={onClick} />
   );
}

Этот хук возвращает мемоизированную версию коллбека и обновится, только если изменятся зависимости, переданные вторым параметром в виде массива. Контекст сохраняется так же, как и в предыдущем способе, но компонент остается функциональным.


Однако в mobx реализован еще один способ для передачи коллбека в дочерний компонент, но он подходит только для действий, которые изменяют observable-поля. Чтобы не использовать перечисленные выше решения, которые добавляют ощутимое количество кода, декоратор @action содержит дополнение в виде @action.bound. Оно, как и хук useCallback, возвращает мемоизированную версию коллбека и сохраняет контекст функции.


При использовании @action.bound код будет выглядеть примерно так:


// Пример модели
class Model {
   @action.bound
   doSomething() {
       // some code
   }
}

// Пример компонента
function AnyComponent({ model }) {
   return (
       <div>
           <Button onClick={model.doSomething} />
           ...
       </div>
   );
}

Заметим, что использование декоратора @action.bound делает код более компактным, но не эквивалентно применению хука useCallback. Не используйте его, если внутри коллбека нет изменения наблюдаемых полей.


Строгость наше всё


Как мы знаем, в MobX есть наблюдаемые (observable) переменные и action-функции, которые предназначены для изменения наблюдаемых переменных. Изменять observable-поля только из action-функций — хорошая практика, но не обязательная. Чтобы включить строгий режим для всего приложения, в mobx есть настройка enforceActions. Ее достаточно указать в корне проекта.


import { configure } from "mobx"

// don't allow state modifications outside actions
configure({ enforceActions: "always" })

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


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


Первый вариант для устранения этой проблемы — использовать runInAction-функцию. Она является надстройкой для action и позволяет не выносить всю логику обратного вызова в отдельный action-метод. Достаточно выделить только ту часть, которая влияет на состояние в коллбеке асинхронной функции:


import { observable, configure, runInAction } from 'mobx';

class User {
   @observable surname = '';

   @observable lastName = '';

   @action
   fetchUser() {
       fetchRequest().then(
           data => {
               runInAction(() => {
                   this.surname = data.surname;
                   this.lastName = data.lastName;
               });
           }
           // ...
       );
   }
}

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


Как вариант, подойдет декоратор @action.bound, но придется выносить функцию обратного вызова в отдельный метод. Возможно, это один из самых простых и элегантных способов решения проблемы. С одной стороны, мы избавимся от ада обратных вызовов и получим более читаемый код за счет разбиения логики на модули, а с другой — придется соблюдать строгий подход, при котором на каждый коллбек асинхронной функции необходим отдельный метод, обернутый в action. А ведь хочется иметь возможность передавать анонимную функцию и не думать о декораторах на уровне обратного вызова.


В библиотеке Mobx есть функция flow для работы с асинхронными action-функциями. Про нее можно прочитать тут, но я думаю, что она не подходит для многих проектов. Если не вдаваться в подробности, то ее основной минус в том, что работает только с генераторами. Согласитесь, далеко не во всех проектах используются генераторы и хочется писать всем привычные async-функции и промисы. К сожалению, в Mobx других альтернатив нет, но у нас на проекте было придумано свое решение.


Для нашего приложения мы используем babel, и именно он позволил решить проблему с асинхронными функциями. Для него есть очень полезный плагин @babel/plugin-transform-async-to-generator, который при компиляции исходного JS-кода меняет все асинхронные функции на генераторы. А ведь с генераторами mobx умеет работать, осталось только это связать:


"plugins": [
                ["@babel/plugin-transform-async-to-generator", {
                    "module": "mobx",
                    "method": "flow"
                  }],
            ]

В примере показана упрощенная настройка плагина для babel, которая заменяет каждую асинхронную функцию в генератор и оборачивает результат в mobx.flow. Однако такая замена может быть избыточной. Далеко не каждая асинхронная функция будет содержать логику, при которой происходит изменение observable-полей. Чтобы подсказывать babel-плагину о необходимости трансформации, в нашем проекте дополнительно сделан кастомный декоратор. Babel-плагин видит его и понимает, что обернутую асинхронную функцию необходимо трансформировать в генератор и дополнительно обернуть результат в mobx.flow-функцию.


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


Обзор будущего Mobx


В начале апреля появился пропозал к MobX 6. Эта часть статьи будет посвящена обзору предлагаемых изменений. Основное — декораторы. Создатель библиотеки рассматривает вариант отказа от них, пока декораторы официально не войдут в стандарт джаваскрипта. Такой шаг обусловлен пятью преимуществами:


  • Совместимость с современным стандартом джаваскрипта.
  • Работа библиотеки из коробки.
  • Уменьшение количества вариантов использования Mobx.
  • Уменьшение размера бандла.
  • Прямая совместимость с будущим стандартом декораторов.

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


Отметим, что у нас в проекте декораторы используются не только в связке с MobX, но и для Dependency Injection. Хоть декораторы и не входят в стандарт джаваскрипта, все-таки они используются во многих библиотеках для простоты и наглядности кода. Ярким примером является Angular и NestJS. Большинство комментаторов в обсуждении к пропозалу пока скептически относятся к удалению декораторов и высказываются за возможность оставить декораторы или вынести их в отдельную библиотеку.


Еще одно интересное изменение, о котором стоит рассказать в контексте этой статьи: теперь строгий режим будет включен по умолчанию. Как и говорилось в предыдущем разделе, изменение observable-значений только из action-функций — хорошая практика. Если вы столкнетесь с проблемой асинхронных функций, то, надеюсь, статья будет полезна в решении этой проблемы.


Итог


Mobx — очень мощный инструмент, который подходит для больших проектов и легок для старта. Но нужно следить за тем, как вы с ним работаете. Используйте computed-значения как можно чаще, делайте компоненты как можно меньше. Старайтесь инжектить данные по месту их использования.