javascript

React + mobx путь с нуля. Mobx + react, взгляд со стороны

  • вторник, 21 марта 2017 г. в 03:14:38
https://habrahabr.ru/post/324388/
  • ReactJS
  • JavaScript




В «настоящих» проектах мы получаем данные от сервера или пользовательского ввода, форматируем, валидируем, нормализуем и производим другие операции над ними. Всё это принято считать бизнес логикой и должно быть помещено в 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.

components/menu/index.js

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 для «гамбургера» в
menu/index.js
<div
      onClick={() => { menuStore.toggleLeftPanel() }}
      className={styles['toggle-btn']}>☰</div>

и метод toggleLeftPanel() в
stores/menu-store.js
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: По дефолту мы экспортируем хранилище как инстанс синглтона, также экспортируется и класс напрямую, так как он тоже может понадобиться, например, для тестов.

Для наглядности добавим стили:

components/menu/styles.css
.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 для управления состоянием панели. Давайте немного нарастим мяса и попробуем управлять панелью из другого компонента. Нам потребуются дополнительные методы для открытия и закрытия панели:

stores/menu-store.js
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

Создадим еще один компонент
components/left-panel-controller.js
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 страницу. Должно получиться следующее:

структура


В браузере это будет выглядеть так:

mobx в работе


Теперь мы можем управлять состоянием панели не только из самой панели, но и из другого компонента.
Note: если несколько раз произвести одно и тоже действие, например, нажать кнопку «close left panel», то в деббагере можно видеть, что экшен срабатывает, но никакой реакции не происходит. Это значит, что mobx не перересовывает компонент, так как состояние не изменилось и нам не нужно писать «лишний» код, как для pure react компонент.

Осталось немного причесать наш подход, работать со сторами приятно, но разбрасывать импорты хранилищ по всему проекту некрасиво. В mobx-react для таких целей появился Provider (см. Provider and inject) — компонент, который позволяет передавать сторы (и не только) потомкам, используя react context. Для этого обернем корневой компонент app.js в Provider:

app.js
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 компонент на два, чтобы получился «глупый» и «умный» компонент.

components/menu/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;


components/menu/index.js
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:

components/left-menu-controller/left-menu-controller.js
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;


components/left-menu-controller/index.js
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" для получения пользователей:

server.js
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);
});


Нарочно добавим задержку, чтобы проверить, что приложение работает корректно даже с большим интервалом ответа сервера.

Далее нам понадобится

user-store:
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:

user-list/index.js
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.

user-list/user-list.js
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.

user-list/user-list-item.js
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 приложений и предоставляет богатый функционал для реализации.