Choices and consequences .. BY  Ash-3xpired. Источник https://www.deviantart.com/ash-3xpired/art/Choices-and-consequences-198140687
Choices and consequences .. BY Ash-3xpired. Источник https://www.deviantart.com/ash-3xpired/art/Choices-and-consequences-198140687

Меня зовут Назим Гафаров, я разработчик интерфейсов в Mail.ru Cloud Solutions. На дворе 2020 год, а мы продолжаем обсуждать «нововведения» ES6-синтаксиса и преимущества MobX над Redux. Существует много причин использовать Redux в своем проекте, но так как я не знаю ни одной, расскажу о том, почему мы выбрали MobX.

Как мы пришли к использованию MobX

Mail.ru Cloud Solutions — это платформа облачных сервисов. С точки зрения разработчика, у нас типичная React-админка, которая позволяет полностью управлять облачной средой: создавать виртуальные машины, базы данных и кластеры Kubernetes. Можно скачивать отчеты по балансу, смотреть графики по нагрузке и тому подобное. 

Текущий стек — TypeScript, React, Redux, Formik — нас полностью устраивал, за исключением Redux. В какой-то момент к нам пришли с задачей разработать новый проект — админку для платформы интернета вещей. Так как это был новый проект — на отдельном домене и со своим дизайном, мы решили посмотреть в сторону MobX.

Почему не Redux

Многословность

Наверное, всем надоели завывания про многословность Redux, но это реальная проблема. Посмотрите код обычного счетчика. Итак, чтобы изменить состояние нам нужны экшены:

export function increment() {
  return {
    type: 'INCREMENT'
  }
}

export function decrement() {
  return {
    type: 'DECREMENT'
  }
}

Программисты, которым этого мало, создают actionTypes:

export const INCREMENT = 'INCREMENT'
export const DECREMENT = 'DECREMENT'

Дальше пишем редьюсеры:

export default (state = { count: 0 }, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return {
        count: state.count + 1
      }
    case 'DECREMENT':
      return {
        count: state.count - 1
      }
    default:
      return state
  }
}

Мапим State и Dispatch — непонятно зачем, но почему бы и нет:

const mapStateToProps = (state) => {
  return {
    count: state.count
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    onIncrement: () => {
      dispatch(increment())
    },
    onDecrement: () => {
      dispatch(decrement())
    }
  }
}

Дальше нам осталось всего лишь законнектить компонент:

import { connect } from 'react-redux'

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(Counter)

А, нет, еще не все. Еще нужно проинициализировать Store:

import { createStore } from 'redux'
import reducers from './reducer'

const store = createStore(reducers)

export default store

И прокинуть наш Store дальше в приложение через Provider:

import store from './store'
import Page from './Page'

const App = () => (
  <Provider store={store}>
    <Page />
  </Provider>
)

Теперь тот же самый пример, но на MobX. Верстка:

import { observer } from "mobx-react"
import CounterStore from "./Counter"

const App = observer(props => {
  const { count, increase, decrease } = CounterStore

  return (
    <div>
      <h1>{count}</h1>
      <button onClick={increase}>increment</button>
      <button onClick={decrease}>decrement</button>
    </div>
  )
})

Store:

import { observable, action } from "mobx"

class Counter {
  @observable count = 0

  @action decrease = () => {
    this.count = this.count - 1
  }

  @action increase = () => {
    this.count = this.count + 1
  }
}

const CounterStore = new Counter()
export default CounterStore

Я могу понять многословность в обмен на какую-то пользу. Например, статические гарантии в обмен на многословность типизации. Но многословность ради многословности... Зачем? Для чего?

Хотя проблема частично решается хуками, никому не хочется писать лишний бесполезный код, и поэтому появилось 100500 библиотек для борьбы с этим бойлерплейтом. Например:

  • underscopeio/reduxable

  • jkeam/reduxsauce

  • jamesplease/zero-boilerplate-redux

  • redux-zero/redux-zero

  • MynockSpit/no-boilerplate-redux

У нас тоже была подобная обертка, она называется Redux-helper. Я думаю, что каждый уважающий себя фронтендер должен написать обертку над Redux для борьбы с бойлерплейтом. Проблема в том, что если вы пытаетесь так улучшить Redux, то у вас случайно получается MobX.

Проблема выбора

Пример со счетчиком был полностью синхронным. Давайте напишем асинхронный код на MobX:

class PostsStore {
  @observable isLoading = false
  @observable posts = []

  @action getPosts = async () => {
    this.isLoading = true
    this.posts = await api.getPosts()
    this.isLoading = false
  }
}

Верстка:

import PostStore from 'PostStore'

const PostsPage = observer(() => {
  const { posts, isLoading } = PostStore

  if (isLoading) {
    return <div>Загружаем список постов</div>
  }

  return (
    <ul>
      { posts.map(post => <li>{post.title}</li>) }
    </ul>
  )
})

Воспроизводить тот же код на Redux я не буду пытаться даже за 300 баксов, так как это займет слишком много времени.

Отсутствие асинхронности в Redux — на самом деле странное решение. В моем детстве тебя могли избить и за меньшую оплошность.

Для решения внезапно возникшей проблемы асинхронности (никто не ожидал ее в вебе), сообщество создало redux-thunk, redux-saga, redux-observable и redux-loop. 

Таким образом, любую проблему Redux можно решить с помощью другой проблемы:

  • Нужна асинхронность — добавь redux-thunk.

  • Хочешь меньше бойлерплейта — возьми redux-zero.

  • Reselect для мемоизации селекторов.

  • Normalizr для хранения данных в нормализованном виде.

  • Еще нужен Immutable.js, чтобы постоянно не писать спреды.

  • Ну и хотелось бы писать в мутабельном стиле, поэтому добавим immer (от автора MobX!)

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

В итоге каждый проект на Redux — множество чужих спорных решений с модными на тот момент библиотеками. Получается «Франкенштейн», поддерживать который приходится вам, а не тому, кто его создал.

Скорость разработки

Азат Разетдинов в своем докладе рассказывал, как увеличилась производительность его команды после внедрения MobX. А вот еще один отзыв, его автор утверждает, что скорость разработки увеличилась в три раза.

Это не единичные мнения. Тысячи других разработчиков на Stateofjs.com пишут, что раздутость (Bloated) и корявость стиля (Clumsy) — одни из самых нелюбимых аспектов Redux. Раздутый код дольше писать и сложнее поддерживать.

Раздутость кода — это одна из причин, почему инопланетяне еще не вышли с нами на связь.

Производительность

Скорость работы MobX не зависит от количества компонентов, потому что мы заранее знаем список компонентов, которые надо обновить, — вместо O(n) сложности Redux.

Тормознутость Redux вшита в его парадигму. В JavaScript очень дорогая иммутабельность, и вы создаете огромную нагрузку на сборщик мусора при копировании объектов на каждое изменение. Даже просто пройти по всем редьюсерам с помощью операции сравнения строк — это очень дорого, намного дороже, чем работа с объектами по ссылке.

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

Почему не useContext

Потому что useContext не дает производительности из коробки, вам дополнительно нужно будет обмазаться useMemo и useCallback.

Посмотрите наглядный пример от пользователя @MaZaAa — alert() выскочит только один раз, при первом рендере.

Так как MobX переопределяет shouldComponentUpdate, вам не нужно за этим следить вручную — перерендерится только то, что надо, а не всё дерево.

Ненастоящие минусы MobX

1. Декораторы еще не в стандарте, но, во-первых, можно писать без декораторов, во-вторых, можно писать на TypeScript.

2. Пятый MobX не поддерживает IE11, потому что использует ES6 Proxy, который сложно полифилить. Для нас это не было проблемой, так как мы не поддерживаем браузер семилетней давности. Но если вы его по каким-то причинам поддерживаете (хотя уже сам Microsoft перестает это делать), можно использовать MobX 4 версии (UPD: или MobX 6).

3. Redux в свое время многих подкупил своими дев-тулзами. В MobX с этим тоже нет проблем, можно использовать mobx-devtools или mobx-remotedev.

4. Для Server-side рендеринга обычно на сервере формируют стейт, сериализуют его в JSON и кладут в window.__PRELOADED_STATE__. К сожалению, MobX никак вас не ограничивает в том, что вы можете положить в стейт. Там могут быть циклические данные и другие структуры, которые не могут быть однозначно представлены в JSON.

Но в целом, если вы не храните подобные структуры в стейте, то SSR с MobX — давно решенная проблема, например, в том же Nextjs с помощью useStaticRendering.

5. Мне непонятен этот аргумент, но часто можно слышать, что в MobX слишком много магии. Если не разобраться, как устроена какая-то технология, то, конечно, она покажется магией. MobX — это просто FRP, только вам не нужно вручную подписываться на наблюдаемые объекты. MobX делает это за вас и прозрачно. Он наблюдает, к каким данным вы обращаетесь, подписывается на них и так строит объектный граф.

Создать свой аналог MobX можно за полчаса.

Настоящие минусы MobX и как с ними бороться

Единственный реальный минус MobX — он дает вам слишком много свободы в том, как структурировать код, хранить и обрабатывать данные. Для больших команд и крупных проектов это может быть проблемой, поэтому расскажу, как мы боролись с излишней свободой.

Не мутировать модель в представлении

Вернемся к нашему примеру со счетчиком:

<h1>{Store.count}</h1>

<button onClick={Store.increase}>
  increment
</button>

Мы могли бы написать его вот так, то есть мы можем мутировать состояние прямо во вьюхе:

<button onClick={() => Store.count++}>
  increment
</button>

Мы решили ни в коем случае так не писать, так как еще наши деды учили разделению ответственности и MVC.

Чтобы запретить мутировать состояние вне хранилища, можно использовать флаг enforceActions в настройках MobX. Но он будет предупреждать вас только в рантайме и создавать проблемы с Promise.

Второй вариант — помечать поля объекта как private. Но в этом случае на каждое приватное поле вам придется создать геттер.

Возможно, вам будет достаточно просто внутри команды договориться мутировать поля исключительно через экшены и следить за этим в код-ревью.

Не наследовать стор от стора

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

В классической MVC-архитектуре контроллер получает команды от пользователя, работает с моделью и оповещает view об изменениях. Если нужно разделить какую-то логику между контроллерами, вы создаете сервисный слой.

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

Сервисы предоставляют данные, инкапсулируют бизнес-логику и дают возможность компонентам общаться друг с другом. Так работает Ember и Angular, но к ним мы еще вернемся.

Не внедрять модель через провайдера

Документация MobX учит подключать сторы таким образом:

@inject("CounterStore")
@observer
class App extends Component {
    render() {
        return (
          <h1>{this.props.CounterStore.count}</h1>
        )
    }
}

Мы же решили просто подключать через импорты:

import CounterStore from "./Counter"

const App = observer(() => {
  return <h1>{CounterStore.count}</h1>
})

Импортируем Store напрямую и получаем все бонусы от IDE, такие как автокомплит и статический тайпчекинг.

На Хабре есть огромный тред по этому поводу, там десятки сторонников и противников подобного подхода, не буду повторяться.

Почему не Vue/Angular/Ember

У команды был большой опыт с React, поэтому мы решили не менять фреймворк. Но в целом — связка React+MobX с наблюдаемыми и вычисляемыми полями может напомнить то, как работает Vue.js.

С ограничениями, которые мы наложили на MobX, организация данных стала похожа на Angular. Вот пример из официальной документации:

export class CartService {
  items = [];

  addToCart(product) {
    this.items.push(product);
  }

  getItems() {
    return this.items;
  }

  clearCart() {
    this.items = [];
    return this.items;
  }
}

В Ember всё то же самое, один в один:

import { A } from '@ember/array';
import Service from '@ember/service';

export default class ShoppingCartService extends Service {
  items = A([]);

  add(item) {
    this.items.pushObject(item);
  }

  remove(item) {
    this.items.removeObject(item);
  }

  empty() {
    this.items.clear();
  }
}

Получается, что весь цивилизованный энтерпрайз-мир так разрабатывает приложения. Все данные одной доменной области в одном месте. В этом же месте методы для изменения этих данных — всё максимально прозрачно.

Redux кичится тем, что имеет один источник правды, хотя по факту — это просто глобальная переменная. В сложных проектах вам, скорее, нужно много объектов, каждый из которых отвечает за свою доменную область, чем куча хлама в глобальной переменной.

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

Выводы

Мы выбрали MobX, потому что это простая и эффективная библиотека, которая позволяет аккуратно упорядочить сложные модели предметной области в классы.

В свое время Redux победил, потому что был разумной альтернативой императивному jQuery. Но необязательно страдать всю жизнь, пора двигаться дальше.


P.S. Это текстовая версия доклада с React Moscow и Panda Meetup #39.

P.P.S. Друзья из разработки бэкенда просили передать, что они ищут разработчиков на Python/Go в команду IaaS, команду PaaS и для разработки IAM. Из интересного — разработка на open source, highload, kubernetes, распределенные системы.