Сегодня я хочу рассказать вам о том, как на нашем проекте состоялся переход на Mobx, какие преимущества это даёт. Также будет показан типовой проект и даны пояснения по основным вопросам. Но сначала вводные.
![image](https://habrastorage.org/r/w1560/webt/lv/he/d2/lvhed2wb-qbln7ffhw3_e9ljj0e.png)
Почему вообще надо на что-то переходить? На самом деле, ответ на этот вопрос — уже половина дела. Многие сейчас любят применять новые технологии только потому, что они новые. Хорошая строчка в резюме, возможность саморазвития, быть в тренде. Здорово, когда можно просто идти вперёд.
И всё же, каждый инструмент должен решать свои задачи, и от них мы так или иначе отталкиваемся, когда пишем коммерческий код.
У нас в проекте существует некоторое количество виджетов, куда пользователь вводит свои данные, взаимодействует с формами. Как правило, в каждом виджете несколько экранов. Когда-то давно это всё работало на старом добром шаблонизаторе MarkoJS + обязательный jQuery на клиенте. Взаимодействие с формами писалось в императивном стиле, if… else, коллбэки и вот это всё самое то, что уже, кажется, осталось в прошлом.
Потом настало время React. Бизнес-логика на клиенте всё утолщалась, вариантов взаимодействий становилось много, императивный код превращался в сложно устроенную кашу. Декларативный react-код оказался гораздо удобнее. Можно было, наконец, сконцентрироваться на логике, а не представлении, переиспользовать компоненты и легко распределять задачи по разработке новых фич между разными сотрудниками.
Но и приложение на чистом React со временем упирается в ощутимые границы. Конечно, нам надоедает писать this.setState на каждый чих и думать про его асинхронность, но особенные трудности доставляет пробрасывание данных и коллбэков через толщу компонентов. Короче, настаёт момент окончательно разделить данные и представление. Не вопрос, можно исхитриться сделать это на чистом React, но в индустрии в последнее время популярны фреймворки, реализующие Flux-архитектуру фронт-енд приложения.
У нас, если судить по количеству статей и упоминаниям в вакансиях, наиболее известен Redux. Собственно, я уже занёс руку, чтобы инсталлировать его в наш проект и начать разработку, как в самый последний момент (и это буквально!) чёрт дёрнул полистать Хабр, а тут как-раз шло обсуждение темы «Redux или Mobx?» Вот эта статья: habr.com/ru/post/459706. Прочитав её, а также все комментарии под ней, я понял, что всё-таки буду использовать Mobx.
Итак, ещё раз. Ответ на самый главный вопрос – зачем всё это? – выглядит так: настало время разделить представление и данные, управление данными хотелось бы построить в декларативном стиле (как и отрисовку), никакого перекрёстного опыления коллбэками и пробрасываемыми атрибутами.
Теперь мы готовы приступить.
Нам требуется на фронте построить некий конструктор экранов и форм, которые потом можно было бы быстро тасовать, соединять друг с другом вслед за меняющимися требованиями бизнеса. Это неизбежно подвигает нас к следующему: создать коллекцию абсолютно изолированных компонентов, а также некий основной компонент, соответствующий каждому из наших виджетов (по сути это отдельные SPA, создаваемые каждый раз под новый бизнес-кейс в общем приложении).
В примерах будет показана урезанная версия одного из таких мини-приложений. Чтобы не громоздить лишний код, пусть это будет форма из трёх полей ввода и кнопки.
Mobx по сути не является фреймворком, это всего лишь библиотека. В руководстве прямо сказано, что она не организует ваши данные напрямую. Вы сами должны придумать такую организацию. Кстати, мы используем Mobx 4, потому что версия 5 использует тип данных Sybmol, который, к сожалению, поддерживается не всеми браузерами.
Итак, все данные выделяются в отдельные сущности. Наше приложение стремится к набору из двух папок:
– components, куда мы положим все view
– stores, где будут содержаться данные, а также логика работы с ними.
Например, типовой компонент для ввода данных у нас состоит из двух файлов: Input.js и InputStore.js. Первый файл – это глупый компонент React, отвечающий строго за отображение, второй – данные этого компонента, правила работы с пользователем (onClick, onChange, etc...)
Прежде чем мы перейдём непосредственно к примерам, нужно решить ещё один важный вопрос.
Хорошо, у нас полностью автономные компоненты View-Store, но как у нас всё это срастается в целое приложение? Для отображения у нас будет корневой компонент App.js, а для управления потоками данных основное хранилище mainStore.js. Принцип простой: mainStore знает всё обо всех хранилищах всех нужных компонентов (ниже будет показано, как это достигается). Другие хранилища ничего не знают об окружающем мире вообще (ну, будет одно исключение — словари). Таким образом, мы гарантированно знаем, куда идут наши данные и где их перехватывать.
![image](https://habrastorage.org/r/w780q1/webt/9c/bf/wi/9cbfwie08uztcx5vvocxj6szwqq.jpeg)
mainStore декларативно, через изменение частей своего state может управлять остальными компонентами. На следующем рисунке Actions и State относится к хранилищам компонентов, а Computed values относится к mainStore:
![image](https://habrastorage.org/r/w1560/webt/y1/d9/uv/y1d9uvdt1z24mzeezyjzer0jjj0.png)
Начнём писать код. Основной файл приложения index.js:
Здесь видна основная концепция Mobx. Данные (stores) доступны в любом месте приложения через механизм Provider. Мы оборачиваем наше приложение, перечисляя необходимые в работе хранилища. Для использования Provider подключаем модуль mobx-react. Для того, чтобы основное управляющее хранилище mainStore со старта имело доступ ко всем остальным данным, делаем инициализацию дочерних хранилищ внутри mainStore:
Теперь App.js, скелет нашего приложения
Тут ещё две основные концепции Mobx – inject и observer.
inject внедряет только необходимый store в приложении. Разные части нашего приложения используют разные хранилища, которые мы перечисляем в inject через запятую. Естественно, подключаемые хранилища должны быть изначально перечислены в Provider. Хранилища доступны в компоненте через this.props.yourStoreName.
observer – декоратор указывает, что наш компонент будет подписан на данные, которые изменяются с помощью Mobx. Данные изменились – в компоненте возникла реакция (ниже будет показано, как). Таким образом, никаких специальных подписок и коллбэков – Mobx доставляет изменения сам!
К управлению всем приложением в mainStore мы ещё вернёмся, а пока сделаем компоненты. У нас их три вида – Fio, Email, Button. Пусть первый и третий будут универсальными, а Email – кастомный. С него и начнём.
За отображение отвечает обычный глупый React-компонент:
Мы подключаем внешний компонент валидации, и важно сделать это после того, как элемент уже включён в вёрстку. Поэтому метод из store вызывается в componentDidMount.
Теперь само хранилище:
Здесь стоит обратить внимание на две новых сущности.
observable – объект, любое изменение полей которого отслеживает Mobx (и передаёт сигналы в observer, который подписан на наше конкретное хранилище).
action – этим декоратором должен обёртываться любой хэндлер, который меняет state приложения и/или вызывает сайд-эффекты. Здесь мы меняем значение value в @observable params.
Всё, наш простой компонент готов! Он умеет отслеживать пользовательские данные и записывать их. Позже мы увидим, как центральное хранилище mainStore подписывается на изменение этих данных.
Теперь типовой компонент Fio. Его отличие от предыдущего в том, что мы собираемся использовать компоненты этого типа неограниченное количество раз в одном приложении. Это накладывает некоторые дополнительные требования на store компонента. В довесок, сделаем ещё подсказки по вводимым символам с помощью прекрасного сервиса DaData. Отображение:
Здесь есть кое-что новое: мы обращаемся к state компонента не напрямую, а через get:
Дело в том, что количество экземпляров компонента не ограничено, а хранилище одно на все компоненты этого типа. Поэтому при регистрации мы заносим параметры каждого экземпляра в Map:
State нашего универсального компонента инициализируется так:
С обычным объектом JS было бы удобнее работать, однако он не будет «прослушиваться» при изменении значений его полей, т.к. поля добавляются динамически при добавлении новых компонентов на странице. Получение подсказок DaData мы выносим отдельно.
Похожим образом выглядит компонент кнопок, только отсутствуют подсказки:
Компонент кнопки Button обёрнут HOC-компонентом ButtonArea. Обратите внимание, что старший компонент включает свой набор stores, а младший свой. В цепочках вложенных компонентов нет необходимости пробрасывать какие-либо параметры и коллбэки. Всё, что нужно для работы конкретного компонента, добавляется непосредственно в нём же.
Итак, у нас готовы все компоненты. Дело осталось за управляющим mainStore. Сначала весь код хранилища:
Ещё несколько ключевых сущностей.
computed – декоратор для функций, отслеживающих изменения в наших observable. Важным преимуществом Mobx является то, что отслеживаются только те данные, которые вычисляются в computed и потом возвращаются в качестве результата. Реакция и, как следствие, перерисовка virual DOM происходит только тогда, когда это необходимо.
reaction – инструмент для организации сайд-эффектов на основе изменившегося состояния. Он принимает две функции: первая computed, возвращающая вычисленное состояние, вторая с эффектами, которые должны последовать вслед за изменениями state. В нашем примере reaction применяется два раза. В первом мы смотрим состояние полей и делаем вывод о том, корректна ли вся форма, а также записываем значение каждого поля. Во втором мы по клику на кнопку (точнее, при наличии признака «кнопка нажата») отправляем данные на сервер. Объект с данными выводится в консоль браузера. Поскольку mainStore знает все хранилища, мы сразу после обработки клика кнопки можем позволить себе в императивном стиле отключить признак:
Можно обсудить, насколько допустимым является наличие такой «императивщины», но в любом случае управление происходит только в одну сторону — от mainStore к ButtonStore.
autorun используется там, где мы хотим запустить какие-то действия директивно, не в качестве реакции на изменение store. В нашем примере запускается одна вспомогательная функция, а так же предзаполнение полей формы данными из словаря.
Таким образом, последовательность выполнения действий у нас следующая. Компоненты отслеживают пользовательские события и изменяют своё состояние. mainStore через computed вычисляет результат, основанный только на тех state, которые изменились. Разные computed смотрят за изменением разных состояний в разных хранилищах. Далее через reaction мы на основе результатов computed выполняем действия с observables, а также выполняем сайд-эффекты (например, делаем AJAX-запрос). На obsevables подписаны дочерние компоненты, которые при необходимости перерисовываются. Однонаправленный поток данных с полным контролем над тем, где и что меняется.
Попробовать пример и код можно самому. Ссылка на репозиторий: github.com/botyaslonim/mobx-habr.
Дальше как обычно: npm i, npm run local. В папке public файл index.html. Подсказки DaData работают на моём бесплатном аккаунте, поэтому, вероятно, могут в некоторые моменты падать из-за хабра-эффекта.
Буду рад любым конструктивным замечаниям и предложениям по работе приложений на Mobx!
В заключение скажу, что библиотека очень упростила работу с данными. Для маленьких и средних приложений она точно будет очень удобным инструментом, чтобы забыть о свойствах компонентов и коллбэках и сконцентрироваться непосредственно на бизнес-логике.
![image](https://habrastorage.org/webt/lv/he/d2/lvhed2wb-qbln7ffhw3_e9ljj0e.png)
Почему вообще надо на что-то переходить? На самом деле, ответ на этот вопрос — уже половина дела. Многие сейчас любят применять новые технологии только потому, что они новые. Хорошая строчка в резюме, возможность саморазвития, быть в тренде. Здорово, когда можно просто идти вперёд.
И всё же, каждый инструмент должен решать свои задачи, и от них мы так или иначе отталкиваемся, когда пишем коммерческий код.
У нас в проекте существует некоторое количество виджетов, куда пользователь вводит свои данные, взаимодействует с формами. Как правило, в каждом виджете несколько экранов. Когда-то давно это всё работало на старом добром шаблонизаторе MarkoJS + обязательный jQuery на клиенте. Взаимодействие с формами писалось в императивном стиле, if… else, коллбэки и вот это всё самое то, что уже, кажется, осталось в прошлом.
Потом настало время React. Бизнес-логика на клиенте всё утолщалась, вариантов взаимодействий становилось много, императивный код превращался в сложно устроенную кашу. Декларативный react-код оказался гораздо удобнее. Можно было, наконец, сконцентрироваться на логике, а не представлении, переиспользовать компоненты и легко распределять задачи по разработке новых фич между разными сотрудниками.
Но и приложение на чистом React со временем упирается в ощутимые границы. Конечно, нам надоедает писать this.setState на каждый чих и думать про его асинхронность, но особенные трудности доставляет пробрасывание данных и коллбэков через толщу компонентов. Короче, настаёт момент окончательно разделить данные и представление. Не вопрос, можно исхитриться сделать это на чистом React, но в индустрии в последнее время популярны фреймворки, реализующие Flux-архитектуру фронт-енд приложения.
У нас, если судить по количеству статей и упоминаниям в вакансиях, наиболее известен Redux. Собственно, я уже занёс руку, чтобы инсталлировать его в наш проект и начать разработку, как в самый последний момент (и это буквально!) чёрт дёрнул полистать Хабр, а тут как-раз шло обсуждение темы «Redux или Mobx?» Вот эта статья: habr.com/ru/post/459706. Прочитав её, а также все комментарии под ней, я понял, что всё-таки буду использовать Mobx.
Итак, ещё раз. Ответ на самый главный вопрос – зачем всё это? – выглядит так: настало время разделить представление и данные, управление данными хотелось бы построить в декларативном стиле (как и отрисовку), никакого перекрёстного опыления коллбэками и пробрасываемыми атрибутами.
Теперь мы готовы приступить.
1. О приложении
Нам требуется на фронте построить некий конструктор экранов и форм, которые потом можно было бы быстро тасовать, соединять друг с другом вслед за меняющимися требованиями бизнеса. Это неизбежно подвигает нас к следующему: создать коллекцию абсолютно изолированных компонентов, а также некий основной компонент, соответствующий каждому из наших виджетов (по сути это отдельные SPA, создаваемые каждый раз под новый бизнес-кейс в общем приложении).
В примерах будет показана урезанная версия одного из таких мини-приложений. Чтобы не громоздить лишний код, пусть это будет форма из трёх полей ввода и кнопки.
2. Данные
Mobx по сути не является фреймворком, это всего лишь библиотека. В руководстве прямо сказано, что она не организует ваши данные напрямую. Вы сами должны придумать такую организацию. Кстати, мы используем Mobx 4, потому что версия 5 использует тип данных Sybmol, который, к сожалению, поддерживается не всеми браузерами.
Итак, все данные выделяются в отдельные сущности. Наше приложение стремится к набору из двух папок:
– components, куда мы положим все view
– stores, где будут содержаться данные, а также логика работы с ними.
Например, типовой компонент для ввода данных у нас состоит из двух файлов: Input.js и InputStore.js. Первый файл – это глупый компонент React, отвечающий строго за отображение, второй – данные этого компонента, правила работы с пользователем (onClick, onChange, etc...)
Прежде чем мы перейдём непосредственно к примерам, нужно решить ещё один важный вопрос.
3. Управление
Хорошо, у нас полностью автономные компоненты View-Store, но как у нас всё это срастается в целое приложение? Для отображения у нас будет корневой компонент App.js, а для управления потоками данных основное хранилище mainStore.js. Принцип простой: mainStore знает всё обо всех хранилищах всех нужных компонентов (ниже будет показано, как это достигается). Другие хранилища ничего не знают об окружающем мире вообще (ну, будет одно исключение — словари). Таким образом, мы гарантированно знаем, куда идут наши данные и где их перехватывать.
![image](https://habrastorage.org/webt/9c/bf/wi/9cbfwie08uztcx5vvocxj6szwqq.jpeg)
mainStore декларативно, через изменение частей своего state может управлять остальными компонентами. На следующем рисунке Actions и State относится к хранилищам компонентов, а Computed values относится к mainStore:
![image](https://habrastorage.org/webt/y1/d9/uv/y1d9uvdt1z24mzeezyjzer0jjj0.png)
Начнём писать код. Основной файл приложения index.js:
import React from "react";
import ReactDOM from "react-dom";
import {Provider} from "mobx-react";
import App from "./components/App";
import mainStore from "./stores/mainStore";
import optionsStore from "./stores/optionsStore";
// для IE11
require("es6-object-assign").polyfill();
require( "./static/less/main.less");
const stores = {
mainStore,
optionsStore,
ButtonStore : mainStore.ButtonStore,
FioStore : mainStore.FioStore,
EmailStore : mainStore.EmailStore
};
ReactDOM.render((
<Provider {...stores}>
<App />
</Provider>
), document.getElementById('reactContainer'));
Здесь видна основная концепция Mobx. Данные (stores) доступны в любом месте приложения через механизм Provider. Мы оборачиваем наше приложение, перечисляя необходимые в работе хранилища. Для использования Provider подключаем модуль mobx-react. Для того, чтобы основное управляющее хранилище mainStore со старта имело доступ ко всем остальным данным, делаем инициализацию дочерних хранилищ внутри mainStore:
// mainStore.js
import optionsStore from "./optionsStore";
import ButtonStore from "./ButtonStore";
import FioStore from "./FioStore";
import EmailStore from "./EmailStore";
....
class mainStore {
constructor() {
/**
* Инициализация дочерних хранилищ
*/
this.ButtonStore = new ButtonStore();
this.FioStore = new FioStore();
this.EmailStore = new EmailStore();
...
Теперь App.js, скелет нашего приложения
import React from "react";
import {observer, inject} from "mobx-react";
import ButtonArea from "./ButtonArea";
import Email from "./Email";
import Fio from "./Fio";
import l10n from "../../../l10n/localization.js";
@inject("mainStore")
@observer
export default class App extends React.Component {
constructor(props) {
super(props);
};
render() {
const mainStore = this.props.mainStore;
return (
<div className="container">
<Fio
label={l10n.ru.profile.name}
name={"name"}
value={mainStore.userData.name}
daData={true}
/>
<Fio
label={l10n.ru.profile.surname}
name={"surname"}
value={mainStore.userData.surname}
daData={true}
/>
<Email
label={l10n.ru.profile.email}
name={"email"}
value={mainStore.userData.email}
/>
<ButtonArea />
</div>
);
}
}
Тут ещё две основные концепции Mobx – inject и observer.
inject внедряет только необходимый store в приложении. Разные части нашего приложения используют разные хранилища, которые мы перечисляем в inject через запятую. Естественно, подключаемые хранилища должны быть изначально перечислены в Provider. Хранилища доступны в компоненте через this.props.yourStoreName.
observer – декоратор указывает, что наш компонент будет подписан на данные, которые изменяются с помощью Mobx. Данные изменились – в компоненте возникла реакция (ниже будет показано, как). Таким образом, никаких специальных подписок и коллбэков – Mobx доставляет изменения сам!
К управлению всем приложением в mainStore мы ещё вернёмся, а пока сделаем компоненты. У нас их три вида – Fio, Email, Button. Пусть первый и третий будут универсальными, а Email – кастомный. С него и начнём.
За отображение отвечает обычный глупый React-компонент:
Email.js
import React from "react";
import {inject, observer} from 'mobx-react';
@inject("EmailStore")
@observer
export default class Email extends React.Component {
constructor(props) {
super(props);
};
componentDidMount = () => {
this.props.EmailStore.validate(this.props.name);
};
componentWillUnmount = () => {
this.props.EmailStore.unmount(this.props.name);
};
render() {
const name = this.props.name;
const EmailStore = this.props.EmailStore;
const params = EmailStore.params;
let status = "form-group email ";
if (params.isCorrect && params.onceValidated) status += "valid";
if (params.isWrong && params.onceValidated) status += "error";
return (
<div className={status}>
<label htmlFor={name}>{this.props.label}</label>
<input
type="email"
disabled={this.props.disabled}
name={name}
id={name}
value={params.value}
onChange={(e) => EmailStore.bindData(e, name)}
/>
</div>
);
}
}
Мы подключаем внешний компонент валидации, и важно сделать это после того, как элемент уже включён в вёрстку. Поэтому метод из store вызывается в componentDidMount.
Теперь само хранилище:
EmailStore.js
import {action, observable} from 'mobx';
import reactTriggerChange from "react-trigger-change";
import Validators from "../../../helpers/Validators";
import {
getTarget
} from "../../../helpers/elementaries";
export default class EmailStore {
@observable params = {
value : "",
disabled : null,
isCorrect : null,
isWrong : null,
onceValidated : null,
prevalidated : null
}
/**
* Ввод пользовательских данных
*/
@action bindData = (e, name) => {
this.params.value = getTarget(e).value;
};
/**
* Валидация поля
*/
@action validate = (name) => {
const callbacks = {
success : (formatedValue) => {
this.params.value = formatedValue;
this.params.isCorrect = true;
this.params.isWrong = false;
this.params.onceValidated = true;
},
fail : (formatedValue) => {
this.params.value = formatedValue;
this.params.isCorrect = false;
this.params.isWrong = true;
}
};
const options = {
type : "email"
};
const element = document.getElementById(name);
new Validators(element, options, callbacks).init();
// превалидация данных поля
reactTriggerChange(element);
this.params.prevalidated = true;
};
}
Здесь стоит обратить внимание на две новых сущности.
observable – объект, любое изменение полей которого отслеживает Mobx (и передаёт сигналы в observer, который подписан на наше конкретное хранилище).
action – этим декоратором должен обёртываться любой хэндлер, который меняет state приложения и/или вызывает сайд-эффекты. Здесь мы меняем значение value в @observable params.
Всё, наш простой компонент готов! Он умеет отслеживать пользовательские данные и записывать их. Позже мы увидим, как центральное хранилище mainStore подписывается на изменение этих данных.
Теперь типовой компонент Fio. Его отличие от предыдущего в том, что мы собираемся использовать компоненты этого типа неограниченное количество раз в одном приложении. Это накладывает некоторые дополнительные требования на store компонента. В довесок, сделаем ещё подсказки по вводимым символам с помощью прекрасного сервиса DaData. Отображение:
Fio.js
import React from "react";
import {inject, observer} from 'mobx-react';
import {get} from 'mobx';
@inject("FioStore")
@observer
export default class Fio extends React.Component {
constructor(props) {
super(props);
};
componentDidMount = () => {
/**
* Обязательная регистрация компонента с параметрами вызова
*/
this.props.FioStore.registration(this.props);
};
componentWillUnmount = () => {
this.props.FioStore.unmount(this.props.name);
};
render() {
/**
* Подсказки берутся соответственно типу запрашиваемых данных
* Для DaData:
* data.surname - Фамилия
* data.name - Имя
* https://dadata.ru/api/suggest/name
*/
const FioStore = this.props.FioStore;
const name = this.props.name;
const item = get(FioStore.items, name);
if (item && item.isCorrect && item.onceValidated && !item.prevalidated) status = "valid";
if (item && item.isWrong && item.onceValidated) status = "error";
// до регистрации в store
let value = this.props.value;
if (item) value = item.value;
return (
<div className="form-group fio">
<label htlmfor={name}>{this.props.label}</label>
<input
type="text"
disabled={this.props.disabled}
name={name}
id={name}
value={value}
onChange={(e) => FioStore.bindData(e, name)}
/>
{(item && item.suggestions && item.suggestions.length > 0) && <div className="hint-container" id={"hint-container-" + item.id}>{item.suggestions.map((suggestion, i) => {
return (
<div className={"suggestion-item fs-" + i} key={i} value={suggestion.data[name]} onClick={(e) => FioStore.setSuggestion(e, name)}>
<span className="suggestion-text">{suggestion.data[name]}</span>
</div>)
})}</div>}
</div>
);
}
}
Здесь есть кое-что новое: мы обращаемся к state компонента не напрямую, а через get:
get(FioStore.items, name)
Дело в том, что количество экземпляров компонента не ограничено, а хранилище одно на все компоненты этого типа. Поэтому при регистрации мы заносим параметры каждого экземпляра в Map:
FioStore.js
import {action, autorun, observable, get, set} from 'mobx';
import reactTriggerChange from "react-trigger-change";
import Validators from "../../../helpers/Validators";
import {
getDaData,
blockValidate
} from "../../../helpers/functions";
import {
getAttrValue,
scrollToElement,
getTarget
} from "../../../helpers/elementaries";
export default class FioStore {
constructor() {
autorun(() => {
/**
* Клик по любому полю для закрытия окна подсказок. Клик по самому полю подсказок обрабатывается отдельно в setSuggestion()
*/
const self = this;
$("body").click((e) => {
if (e.target.className !== "suggestion-item" && e.target.className !== "suggestion-text") {
const items = self.items.entries();
for (var [key, value] of items) {
value.suggestions = [];
}
}
});
})
}
/**
* В объект items записываются данные каждого из экзепляров компонента Fio при их создании
*/
@observable items = new Map([]);
/**
* Регистрация экземпляра компонента
*/
@action registration = (params) => {
const nameExists = get(this.items, params.name);
if (!blockValidate({params, nameExists, type: "Fio"})) return false;
// расширяем items новым объектом
const value = {
value : params.value,
disabled : params.disabled,
isCorrect : null,
isWrong : null,
suggestions : [],
daData : params.daData,
startValidation : true,
// элемент был однажды положительно валидирован
onceValidated : false,
// элемент был провалидирован при открытии
prevalidated : false
};
set(this.items, params.name, value);
this.validate(params.name);
};
/**
* Открепление экземпляра компонента
*/
@action unmount = (name) => {
this.items.delete(name);
};
/**
* Ввод пользовательских данных
* Может сопровождаться подсказками
*/
@action bindData = (e, name) => {
const value = getTarget(e).value;
const item = get(this.items, name);
/**
* Запрашиваем подсказку в сервисе DaData
*/
if (item.daData && !item.startValidation) {
getDaData({value, type: "fio", name})
.then((result) => {
item.suggestions = result.suggestions;
})
.catch((error) => {console.log(error)})
} else {
item.startValidation = false;
item.value = value;
}
};
/**
* Выбор подсказки
*/
@action setSuggestion = (e, name) => {
if (e) e.preventDefault();
get(this.items, name).value = getAttrValue(e);
// закрываем окно с подсказками
get(this.items, name).suggestions = [];
get(this.items, name).isCorrect = true;
get(this.items, name).isWrong = false;
};
/**
* Валидация поля
*/
@action validate = (name) => {
const callbacks = {
success : (formatedValue) => {
get(this.items, name).value = formatedValue;
get(this.items, name).isCorrect = true;
get(this.items, name).isWrong = false;
get(this.items, name).onceValidated = true;
},
fail : (formatedValue) => {
get(this.items, name).value = formatedValue;
get(this.items, name).isCorrect = false;
get(this.items, name).isWrong = true;
}
};
const options = {
type : "fio"
};
const element = document.getElementById(name);
new Validators(element, options, callbacks).init();
// превалидация данных поля
reactTriggerChange(element);
get(this.items, name).prevalidated = true;
};
}
State нашего универсального компонента инициализируется так:
@observable items = new Map([]);
С обычным объектом JS было бы удобнее работать, однако он не будет «прослушиваться» при изменении значений его полей, т.к. поля добавляются динамически при добавлении новых компонентов на странице. Получение подсказок DaData мы выносим отдельно.
Похожим образом выглядит компонент кнопок, только отсутствуют подсказки:
Button.js
import React from "react";
import {inject, observer} from 'mobx-react';
@inject("ButtonStore")
@observer
export default class CustomButton extends React.Component {
constructor(props) {
super(props);
};
componentDidMount = () => {
/**
* Обязательная регистрация компонента с параметрами вызова
*/
this.props.ButtonStore.registration(this.props);
};
componentWillUnmount = () => {
this.props.ButtonStore.unmount(this.props.name);
};
render() {
const name = this.props.name;
return (
<div className="form-group button">
<button
disabled={this.props.disabled}
onClick={(e) => this.props.ButtonStore.bindClick(e, name)}
name={name}
id={name}
>{this.props.text}</button>
</div>
);
}
}
ButtonStore.js
import {action, observable, get, set} from 'mobx';
import {blockValidate} from "../../../helpers/functions";
export default class ButtonStore {
constructor() {}
/**
* В объект items записываются данные каждого из экзепляров компонента Button при их создании
*/
@observable items = new Map([])
/**
* Регистрация экземпляра компонента
*/
@action registration = (params) => {
const nameExists = get(this.items, params.name);
if (!blockValidate({params, nameExists, type: "Button"})) return false;
// расширяем items новым объектом
const value = {
disabled : params.disabled,
isClicked : false
};
set(this.items, params.name, value);
};
/**
* Открепление экземпляра компонента
*/
@action unmount = (name) => {
this.items.delete(name);
};
/**
* Нажатие на кнопку
*/
@action bindClick = (e, name) => {
e.preventDefault();
get(this.items, name).isClicked = true;
};
}
Компонент кнопки Button обёрнут HOC-компонентом ButtonArea. Обратите внимание, что старший компонент включает свой набор stores, а младший свой. В цепочках вложенных компонентов нет необходимости пробрасывать какие-либо параметры и коллбэки. Всё, что нужно для работы конкретного компонента, добавляется непосредственно в нём же.
ButtonArea.js
import React from "react";
import {inject, observer} from 'mobx-react';
import l10n from "../../../l10n/localization.js";
import Button from "./Button";
@inject("mainStore", "optionsStore")
@observer
export default class ButtonArea extends React.Component {
constructor(props) {
super(props);
};
render() {
return (
<div className="button-container">
<p>{this.props.optionsStore.dict.buttonsHeading}</p>
<Button
name={"send_data"}
disabled={this.props.mainStore.buttons.sendData.disabled ? true : false}
text={l10n.ru.common.continue}
/>
</div>
);
}
}
Итак, у нас готовы все компоненты. Дело осталось за управляющим mainStore. Сначала весь код хранилища:
mainStore.js
import {observable, computed, autorun, reaction, get, action} from 'mobx';
import optionsStore from "./optionsStore";
import ButtonStore from "./ButtonStore";
import FioStore from "./FioStore";
import EmailStore from "./EmailStore";
import {
fetchOrdinary,
sendStats
} from "../../../helpers/functions";
import l10n from "../../../l10n/localization.js";
class mainStore {
constructor() {
/**
* Инициализация дочерних хранилищ
*/
this.ButtonStore = new ButtonStore();
this.FioStore = new FioStore();
this.EmailStore = new EmailStore();
autorun(() => {
this.fillBlocks();
this.fillData();
});
/**
* Показ кнопки отправки первоначальных данных
*/
reaction(
() => this.dataInput,
(result) => {
let isIncorrect = false;
for (let i in result) {
for (let j in result[i]) {
const res = result[i][j];
if (!res.isCorrect) isIncorrect = true;
this.userData[j] = res.value;
}
};
if (!isIncorrect) {
this.buttons.sendData.disabled = false
} else {
this.buttons.sendData.disabled = true
};
}
);
/**
* Кнопка отправки первоначальных данных
*/
reaction(
() => this.sendDataButton,
(result) => {
if (result) {
if (result.isClicked) {
get(this.ButtonStore.items, "send_data").isClicked = false;
const authRequestSuccess = () => {
console.log("request is success!")
};
const authRequestFail = () => {
console.log("request is fail!")
};
const request = {
method : "send_userdata",
params : {
name : this.userData.name,
surname : this.userData.surname,
email : this.userData.email
}
};
console.log("Request body is:");
console.log(request);
fetchOrdinary(
optionsStore.OPTIONS.sendIdentUrl,
JSON.stringify(request),
{
success: authRequestSuccess,
fail: authRequestFail
}
);
}
}
}
);
}
@observable userData = {
name : "",
surname : "",
email : ""
};
@observable buttons = {
sendData : {
disabled : true
}
};
/**
* Схема компонентов на страницах
* @key - имя страницы
* @value - массив с последовательным перечислением компонентов (name, type), которые в этом порядке включаются в вёрстку на странице
*/
componentsMap = {
userData : [
["name", "fio"],
["surname", "fio"],
["email", "email"],
["send_data", "button"]
]
};
/**
* Коллекции компонентов для работы listener'ов различных stores
*/
@observable listenerBlocks = {};
/**
* Заполнение коллекций компонентов
*/
@action fillBlocks = () => {
for (let i in this.componentsMap) {
const pageBlock = this.componentsMap[i];
// преобразуем в объект типов компонентов (key) с массивами их имён (value)
const blocks = {};
pageBlock.forEach((item, i) => {
const _name = item[0];
const _type = item[1];
if (!blocks[_type]) {
blocks[_type] = [_name]
} else {
blocks[_type].push(_name)
}
})
this.listenerBlocks[i] = blocks;
}
};
/**
* Предзаполнение полей ввода
*/
@action fillData = () => {
if (optionsStore.preset) {
// проверки, чтобы избежать undefined, что поломает неконтролируемый компонент
if (optionsStore.preset.name) this.userData.name = optionsStore.preset.name;
if (optionsStore.preset.surname) this.userData.surname = optionsStore.preset.surname;
}
};
/**
* Listener для компонентов страницы
*/
@computed get dataInput() {
const mappedResult = {
fio : {},
email : {
email : {}
}
};
if (this.FioStore && this.FioStore.items) {
this.listenerBlocks.userData.fio.forEach((item) => {
const i = get(this.FioStore.items, item);
if (i) {
mappedResult.fio[item] = {
isCorrect : i.isCorrect,
value : i.value
}
}
})
}
if (this.EmailStore && this.EmailStore.params) {
mappedResult.email.email = {
isCorrect : this.EmailStore.params.isCorrect,
prevalidated : this.EmailStore.params.prevalidated,
value : this.EmailStore.params.value
}
}
return mappedResult
}
/**
* Listener нажатия кнопки отправления данных
*/
@computed get sendDataButton() {
let result = {};
const i = get(this.ButtonStore.items, "send_data");
if (i) {
result = {
isClicked : i.isClicked
}
}
return result
}
}
export default new mainStore();
Ещё несколько ключевых сущностей.
computed – декоратор для функций, отслеживающих изменения в наших observable. Важным преимуществом Mobx является то, что отслеживаются только те данные, которые вычисляются в computed и потом возвращаются в качестве результата. Реакция и, как следствие, перерисовка virual DOM происходит только тогда, когда это необходимо.
reaction – инструмент для организации сайд-эффектов на основе изменившегося состояния. Он принимает две функции: первая computed, возвращающая вычисленное состояние, вторая с эффектами, которые должны последовать вслед за изменениями state. В нашем примере reaction применяется два раза. В первом мы смотрим состояние полей и делаем вывод о том, корректна ли вся форма, а также записываем значение каждого поля. Во втором мы по клику на кнопку (точнее, при наличии признака «кнопка нажата») отправляем данные на сервер. Объект с данными выводится в консоль браузера. Поскольку mainStore знает все хранилища, мы сразу после обработки клика кнопки можем позволить себе в императивном стиле отключить признак:
get(this.ButtonStore.items, "send_data").isClicked = false;
Можно обсудить, насколько допустимым является наличие такой «императивщины», но в любом случае управление происходит только в одну сторону — от mainStore к ButtonStore.
autorun используется там, где мы хотим запустить какие-то действия директивно, не в качестве реакции на изменение store. В нашем примере запускается одна вспомогательная функция, а так же предзаполнение полей формы данными из словаря.
Таким образом, последовательность выполнения действий у нас следующая. Компоненты отслеживают пользовательские события и изменяют своё состояние. mainStore через computed вычисляет результат, основанный только на тех state, которые изменились. Разные computed смотрят за изменением разных состояний в разных хранилищах. Далее через reaction мы на основе результатов computed выполняем действия с observables, а также выполняем сайд-эффекты (например, делаем AJAX-запрос). На obsevables подписаны дочерние компоненты, которые при необходимости перерисовываются. Однонаправленный поток данных с полным контролем над тем, где и что меняется.
Попробовать пример и код можно самому. Ссылка на репозиторий: github.com/botyaslonim/mobx-habr.
Дальше как обычно: npm i, npm run local. В папке public файл index.html. Подсказки DaData работают на моём бесплатном аккаунте, поэтому, вероятно, могут в некоторые моменты падать из-за хабра-эффекта.
Буду рад любым конструктивным замечаниям и предложениям по работе приложений на Mobx!
В заключение скажу, что библиотека очень упростила работу с данными. Для маленьких и средних приложений она точно будет очень удобным инструментом, чтобы забыть о свойствах компонентов и коллбэках и сконцентрироваться непосредственно на бизнес-логике.