В «настоящих» проектах мы получаем данные от сервера или пользовательского ввода, форматируем, валидируем, нормализуем и производим другие операции над ними. Всё это принято считать бизнес логикой и должно быть помещено в Model. Так как react — это только треть MVC пирога, для создания пользовательских интерфейсов, то нам потребуется еще что-то для бизнес логики. Некоторые используют паттерны redux или flux, некоторые — backbone.js или даже angular, мы же будем использовать mobx.js в качестве Model.
В предыдущей статье мы уже подготовили фундамент, будем строить на нём. Так как mobx — это standalone библиотека, то для связки с react-ом нам понадобится mobx-react:
npm i --save mobx mobx-react
Кроме того, для работы с декораторами и трансформации свойств классов нам потребуются babel плагины babel-plugin-transform-class-properties и babel-plugin-transform-decorators-legacy:
npm i --save-dev babel-plugin-transform-decorators-legacy babel-plugin-transform-class-properties
Не забудем добавить их в .babelrc
"plugins": [
"react-hot-loader/babel",
"transform-decorators-legacy",
"transform-class-properties"
]
У нас есть компонента Menu, давайте продолжим работу с ней. У панели будет два состояния «открыта/закрыта», а управлять состоянием будем с помощью mobx.
1. Первым делом нам нужно определить состояние и сделать его наблюдаемым посредством добавления декоратора @observable. Состояние может быть представлено любой структурой данных: объектами, массивами, классами и прочими. Создадим хранилище для меню (menu-store.js) в директории stores.
import { observable} from 'mobx';
class MenuStore {
@observable show;
constructor() {
this.show = false;
}
}
export default new MenuStore();
Стор представляет собой ES6 class с единственным свойством show. Мы повесили на него декоратор @observable, тем самым сказали mobx-у наблюдать за ним. Show — это состояние нашей панели, которое мы будем менять.
2. Создать представление, реагирующее на изменение состояния. Хорошо, что у нас уже оно есть, это component/menu/index.js. Теперь, когда состояние будет изменяться, наше меню будет автоматически перересовываться, при этом mobx найдет кротчайший путь для обновления представления. Что бы это произошло, нужно обернуть функцию, описывающую react компонент, в observer.
import React from 'react';
import cn from 'classnames';
import { observer } from 'mobx-react';
/* stores */
import menuStore from '../../stores/menu-store';
/* styles */
import styles from './style.css';
const Menu = observer(() => (
<nav className={cn(styles.menu, { [styles.active]: menuStore.show })}>
<div className={styles['toggle-btn']}>☰</div>
</nav>
));
export default Menu;
В любом react приложении нам понадобится утилита classnames для работы с className. Раньше она входила в пакет react-а, но теперь ставится отдельно:
npm i --save classnames
c её помощью можно склеивать имена классов, используя различные условия, незаменимая вещь.
Видно, что мы добавляем класс «active», если значение состояние меню show === true. Если в конструкторе хранилища поменять состояние на this.show = true, то у панели появится «active» класс.
3. Осталось изменить состояние. Добавим событие click для «гамбургера» в
<div
onClick={() => { menuStore.toggleLeftPanel() }}
className={styles['toggle-btn']}>☰</div>
и метод toggleLeftPanel() в
import { observable } from 'mobx';
class MenuStore {
@observable show;
constructor() {
this.show = false;
}
toggleLeftPanel() {
this.show = !this.show;
}
}
const menuStore = new MenuStore();
export default menuStore;
export { MenuStore };
Note: По дефолту мы экспортируем хранилище как инстанс синглтона, также экспортируется и класс напрямую, так как он тоже может понадобиться, например, для тестов.
Для наглядности добавим стили:
.menu {
position: fixed;
top: 0;
left: -180px;
bottom: 0;
width: 220px;
background-color: tomato;
&.active {
left: 0;
}
& .toggle-btn {
position: absolute;
top: 5px;
right: 10px;
font-size: 26px;
font-weight: 500;
color: white;
cursor: pointer;
}
}
И проверим, что по клику на иконку, наша панель открывается и закрывается. Мы написали минимальный mobx store для управления состоянием панели. Давайте немного нарастим мяса и попробуем управлять панелью из другого компонента. Нам потребуются дополнительные методы для открытия и закрытия панели:
import { observable, computed, action } from 'mobx';
class MenuStore {
@observable show;
constructor() {
this.show = false;
}
@computed get isOpenLeftPanel() {
return this.show;
}
@action('toggle left panel')
toggleLeftPanel() {
this.show = !this.show;
}
@action('show left panel')
openLeftPanel() {
this.show = true;
}
@action('hide left panel')
closeLeftPanel() {
this.show = false;
}
}
const menuStore = new MenuStore();
export default menuStore;
export { MenuStore };
Можно заметить, что мы добавили computed и action декораторы, они обязательны только в strict mode (по умолчанию отключено). Computed значения будут автоматически пересчитаны при изменении соответствующих данных. Рекомендуется использовать action, это поможет лучше структурировать приложение и оптимизировать производительность. Как видно, первым аргументом мы задаём расширенное название производимого действия. Теперь при деббаге мы сможем наблюдать, какой метод был вызван и как менялось состояние.
Note: При разработке удобно использовать расширения хрома для mobx и react, а так же react-mobx devtools
Создадим еще один компонент
import React from 'react';
/* stores */
import menuStore from '../../stores/menu-store';
/* styles */
import styles from './styles.css';
const Component = () => (
<div className={styles.container}>
<button onClick={()=>{ menuStore.openLeftPanel(); }}>Open left panel</button>
<button onClick={()=>{ menuStore.closeLeftPanel(); }}>Close left panel</button>
</div>
);
export default Component;
Внутри пара кнопок, которые будут открывать и закрывать панель. Этот компонент добавим на Home страницу. Должно получиться следующее:
В браузере это будет выглядеть так:
Теперь мы можем управлять состоянием панели не только из самой панели, но и из другого компонента.
Note: если несколько раз произвести одно и тоже действие, например, нажать кнопку «close left panel», то в деббагере можно видеть, что экшен срабатывает, но никакой реакции не происходит. Это значит, что mobx не перересовывает компонент, так как состояние не изменилось и нам не нужно писать «лишний» код, как для pure react компонент.
Осталось немного причесать наш подход, работать со сторами приятно, но разбрасывать импорты хранилищ по всему проекту некрасиво. В mobx-react для таких целей появился Provider (см. Provider and inject) — компонент, который позволяет передавать сторы (и не только) потомкам, используя react context. Для этого обернем корневой компонент app.js в Provider:
import React from 'react';
import { Provider } from 'mobx-react';
import { useStrict } from 'mobx';
/* components */
import Menu from '../components/menu';
/* stores */
import leftMenuStore from '../stores/menu-store';
/* styles */
import './global.css';
import style from './app.css';
useStrict(true);
const stores = { leftMenuStore };
const App = props => (
<Provider { ...stores }>
<div className={style['app-container']}>
<Menu />
<div className={style['page-container']}>
{props.children}
</div>
</div>
</Provider>
);
export default App;
Тут же импортируем все сторы (у нас один) и передаём их провайдеру через props. Так как провайдер работает с контекстом, то сторы будут доступны в любом дочернем компоненте. Также разобьем menu.js компонент на два, чтобы получился «глупый» и «умный» компонент.
import React from 'react';
import cn from 'classnames';
import styles from './style.css';
const Menu = props => (
<nav className={cn(styles.menu, { [styles.active]: props.isOpenLeftPanel })}>
<div onClick={props.toggleMenu}
className={styles['toggle-btn']}>☰</div>
</nav>
);
export default Menu;
import React from 'react';
import { observer, inject } from 'mobx-react';
import Menu from './menu'
const Component = inject('leftMenuStore')(observer(({ leftMenuStore }) => (
<Menu
toggleMenu={() => leftMenuStore.toggleLeftPanel()}
isOpenLeftPanel={leftMenuStore.isOpenLeftPanel} />
)));
Component.displayName = "MenuContainer";
export default Component;
«Глупый» нам не интересен, так как это обычный stateless компонент, который получает через props данные о том открыта или закрыта панель и колбэк для переключения.
Гораздо интереснее посмотреть на его враппер: мы видим тут HOC, где мы инжектим необходимые сторы, в нашем случае «leftMenuStore», в качестве компонента мы передаем наш «глупый компонент», обернутый в observer. Так как мы приинжектили leftMenuStore, то хранилище теперь доступно через props.
практически тоже самое мы проделываем с left-panel-controller:
import React from 'react';
/* styles */
import style from './styles.css';
const LeftPanelController = props => (
<div className={style.container}>
<button onClick={() => props.openPanel()}>Open left panel</button>
<button onClick={() => props.closePanel()}>Close left panel</button>
</div>
);
export default LeftPanelController;
import React from 'react';
import { inject } from 'mobx-react';
import LeftPanelController from './left-panel-controller';
const Component = inject('leftMenuStore')(({ leftMenuStore }) => {
return (
<LeftPanelController
openPanel={() => leftMenuStore.openLeftPanel()}
closePanel={() => leftMenuStore.closeLeftPanel()} />
);
});
LeftPanelController.displayName = 'LeftPanelControllerContainer';
export default Component;
С той лишь разницей, что тут мы не используем observer, так как для этого компонента перерисовавать ничего не требуется, от хранилища нам нужны лишь методы openLeftPanel() и closeLeftPanel().
Note: я использую displayName для задания имени компоненту, это удобно для деббага:
Это все просто, теперь давайте получим данные с сервера, пусть это будет список пользователей с чекбоксами.
Идем на сервер и добавляем роут "/users" для получения пользователей:
const USERS = [
{ id: 1, name: "Alexey", age: 30 },
{ id: 2, name: "Ignat", age: 15 },
{ id: 3, name: "Sergey", age: 26 },
];
...
app.get("/users", function(req, res) {
setTimeout(() => {
res.send(USERS);
}, 1000);
});
Нарочно добавим задержку, чтобы проверить, что приложение работает корректно даже с большим интервалом ответа сервера.
Далее нам понадобится
import { observable, computed, action, asMap, autorun } from 'mobx';
class User {
@observable user = observable.map();
constructor(userData = {}, checked = false) {
this.user.merge(userData);
this.user.set("checked", checked);
}
@computed get userInfo() {
return `${this.user.get("name")} - ${this.user.get("age")}`;
}
@action toggle() {
this.user.set("checked", !this.user.get("checked"));
}
}
class UserStore {
@observable users;
constructor() {
this.users = [];
this.fetch();
}
@computed get selectedCount() {
return this.users.filter(userStore => {
return userStore.user.get("checked");
}).length;
}
getUsers() {
return this.users;
}
@action fetch() {
fetch('/users', { method: 'GET' })
.then(res => res.json())
.then(json => this.putUsers(json));
}
@action putUsers(users) {
let userArray = [];
users.forEach(user => {
userArray.push(new User(user));
});
this.users = userArray;
}
}
const userStore = new UserStore();
autorun(() => {
console.log(userStore.getUsers().toJS());
});
export default userStore;
export { UserStore };
Тут описан класс User со свойством user. В mobx есть observable.map тип данных, он как раз подойдет нам для описания user-а. Грубо говоря, мы получаем наблюдаемый объект, причем, наблюдать можно за изменением конкретного поля. Также становятся доступны getter, setter и прочие вспомогательные методы. Например, в конструкторе с помощью «merge», мы легко можем скопировать поля из userData в user. Это очень удобно, если объект содержит много полей. Также напишем один action для переключения состояния пользователя и вычисляемое значения для получения информации о пользователе.
Ниже описан сам стор, в котором наблюдаемый являемся массив пользователей. В конструкторе мы дергаем метод для получения пользователей с сервера и через action putUsers заполняем пустой массив пользователями. Напоследок, добавим метод, который возвращает вычисляемое количество чекнутых пользователей.
Note: autorun выполняет функцию автоматически, если наблюдаемое значение было изменено. Для примера, тут выводится все пользователи в консоль. Если попробовать достать пользователей методом «getUsers()», то можно заметить, что тип возвращаемых данных не Array, а ObservableArray. Для конвертации observable объектов в javascript структуру, используем toJS().
В app.js не забудем дописать новый user-store, чтобы потомки могли им пользоваться.
Добавим react компоненты в директорию components:
import React from 'react';
import { observer, inject } from 'mobx-react';
import UserList from './user-list';
const Component = inject('userStore')(observer(({ userStore }) => {
return (
<UserList
users={userStore.getUsers()}
selectedUsersCount={userStore.selectedCount} />
);
}));
Component.displayName = 'UserList';
export default Component;
Тут уже привычная нам обертка, передаем массив юзеров и количество чекнутых пользователей через props.
import React from 'react';
/* components */
import UserListItem from './user-list-item';
/* styles */
import style from './styles.css';
const UserList = props => {
return (
<div className={style.container}>
<ul>
{props.users.map(userStore => {
return (
<UserListItem
key={userStore.user.get('id')}
isChecked={userStore.user.get('checked')}
text={userStore.userInfo}
onToggle={() => userStore.toggle()} />);
})}
</ul>
<span>{`Users:${props.users.length}`}</span>
<span>{`Selected users: ${props.selectedUsersCount}`}</span>
</div>
);
};
export default UserList;
Показываем список пользователей и информацию по их количеству. Передаём «toggle()» метод стора через props.
import React from 'react';
const UserListItem = props => (
<li><input type="checkbox" checked={props.isChecked} onClick={() => props.onToggle()} />{props.text}
</li>
);
export default UserListItem;
Рендерим одного пользователя.
Добавляем стили и цепляем готовый компонент на Home страницу. Все готово(github), можно поиграть с чекбоксами и убедиться, что все методы работают.
В итоге мы увидели как работает mobx в связке с react-ом, учитывая все возможности mobx, можно предположить, что такое решение имеет право на жизнь. Mobx прекрасно справляется с обязанностью менеджера состояний для react приложений и предоставляет богатый функционал для реализации.
Автор: movax10h