Всем привет!
Меня зовут Сергей, я работаю в команде разработки приложений контроля качества 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, можно выделить три основных слоя:
- UI-представление;
- Модель с состоянием;
- 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-значения как можно чаще, делайте компоненты как можно меньше. Старайтесь инжектить данные по месту их использования.
MaZaAa
Батчит всё по умолчанию и полностью отпадает нужна в action и runInAction.
codesandbox.io/s/zen-surf-g9r9t?file=/src/App.tsx
Посмотрите код, посмотрите консоль, по нажимаете на кнопочку и снова посмотрите в консоль. После этого раскомментируйте конфигурацию реакций.
membrum Автор
Привет, правильно ли я понимаю, что reactionScheduler просто откладывает вызов реакций после изменения Observable поля? Попробуйте добавить в Mobx конфигурацию строгий режим (настройка enforceActions: «always») и у вас все упадет. Мы же решали проблему асинхронных функций при строгом режиме: изменять Observable поля только из action функций. ReactionScheduler, как мне кажется, не подходит.
MaZaAa
Строгий режим нужен лишь для того, чтобы как раз всегда был батчинг изменений, когда ты изменяешь реактивные переменные, внутри action и runInAction происходит батчинг и enforceActions: «always» проверяет когда ты изменяешь реактивную переменную, включен ли сейчас режим батчинга или нет и если нет, то он падает. А настройка reactionScheduler избавляет от этого, и делает батчинг по умолчанию и настройку enforceActions: «always» не актуальной, ее нужно убрать. Следовательно код становится красивее, его объем меньше и читается он лучше. И при этом ты ни сколько не теряешь в оптимизациях и лишних рендерах.
DarkTemplar
А не получится так, что этот шедулер сработает между изменениями observable? И выкатит часть до, а потом обновит оставшуюся?
MaZaAa
Нет, т.к. синхронные изменения состояния происходят синхронно, и уже в свободное время вызывается эта реакция. То есть вы хоть в цикле миллиард раз изменяйте состояние, реакция вызовется только после того, как этот синхронный код отработает. Проверяется тоже легко.
atomic1989
Не совсем верно. Внутри action любые уведомления об изменении observable откладываются до завершения синхронного кода. Если скажем не использовать action и строгий режим, то уведомления об изменении будут происходить сразу после изменения значения observable свойства.
MaZaAa
Вы посмотрите тред комментариев и вот это — codesandbox.io/s/zen-surf-g9r9t?file=/src/App.tsx
Посмотрите код, посмотрите консоль, по нажимаете на кнопочку и снова посмотрите в консоль. После этого раскомментируйте конфигурацию реакций и перезагрузите страницу и снова посмотрите в консоль.
DarkTemplar
И строгий режим не только для батчинга нужен, но и для того чтобы изменять observable поле только из action метода, а не из любого куска приложения, например изменить observable напрямую из компонента нельзя будет, только через action функцию. Помогает не замусорить код и при дебаге видны названия action.
MaZaAa
Если вы не используете Tyescript и пишете код так, что нужно использовать MobX Dev Tools чтобы понять что вообще происходит и почему ХХХ не работает, тогда у меня для вас плохие новости, а в иных случая это не нужно и только лишь засоряет код. Не забывайте, помимо action надо ещё после асинхронных вызовов изменять состояние при строгом режиме через runInAction что так же не удобно и засоряет код.
А для кого придумали code review?
Хотите навешивать всюду action и runInAction и принципиально не пользоваться удобством, да ради бога, главное не на моих проектах)
Ах да, и ещё, игнорировать в IDEшках «Find All References» / «Find Usages» чтобы видеть где читается и изменяется то или иное свойство, где вызывается та или ина функция и т.п. предпочитая этому захламление кода ради того, чтобы потом в один прекрасный момент через dev tools это выяснять… Ну я даже не знаю =) Я серьезно, VSCode — «Find All References», WebStorm — «Find Usages» попробуйте, уверен вам понравится и вы взгляните на «проблемы» отладки совсем под другим углом.
MaZaAa
Вот же смотрите, это проверяется на раз два, ещё модицифировал пример конкретно имитирующий асинхронный вызов и после этого модификацию состояния — codesandbox.io/s/staging-bush-52r7q?file=/src/App.tsx
Посмотрите код, посмотрите консоль, по нажимаете на кнопочку и снова посмотрите в консоль. После этого раскомментируйте конфигурацию реакций и перезагрузите страницу.
ilyapirogov
В вашем примере
setTimeout()
будет вызван 10 раз, на каждый инкремент!И после таких вот перлов люди удивляются, что сайты-визитки тормозят даже на современных смартфонах и отъедают по гигабайту памяти.
MaZaAa
Вы не понимаете как это работает, при чет тут тормоза и гигабайты памяти? Эта настройка говорит что нужно выполнить реакцию не сразу же после изменения реактивной переменной, а через таймаунт, эти реакции не накапливаются, а выполнятся лишь по разу. Для этого в примере есть console.log в которых всё явно видно. Вместо того что писать всякую ерись, просто посмотрели бы и убедились сразу же.