Хабрахабр

Вам действительно нужен Redux?

Не так давно React позиционировал себя как "V in MVC". После этого коммита маркетинговый текст изменился, но суть осталась той же: React отвечает за отображение, разработчик — за все остальное, то есть, говоря в терминах MVC, за Model и Controller.

Одним из решений для управления Model (состоянием) вашего приложения стал Redux. Его появление мотивировано возросшей сложностью frontend-приложений, с которой не способен справиться MVC.

Главный Технический Императив Разработки ПО — управление сложностью

— Совершенный код

Redux предлагает управлять сложностью с помощью предсказуемых изменений состояния. Предсказуемость достигается за счет трех фундаментальных принципов:

  • состояние всего приложения хранится в одном месте
  • единственный способ изменить состояние — отправка Action'ов
  • все изменения происходят с помощью чистых функций

Смог ли Redux побороть возросшую сложность и было ли с чем бороться?

MVC не масштабируется

Redux вдохновлен Flux'ом — решением от Facebook. Причиной создания Flux, как заявляют разработчики Facebook (видео), была проблема масштабируемости архитектурного шаблона MVC.

По описанию Facebook, связи объектов в больших проектах, использующих MVC, в конечном итоге становятся непредсказуемыми:

  1. modelOne изменяет viewOne
  2. viewOne во время своего изменения изменяет modelTwo
  3. modelTwo во время своего изменения изменяет modelThree
  4. modelThree во время своего изменения изменяет viewTwo и viewFour

О проблеме непредсказуемости изменений в MVC также написано в мотивации Redux'a. Картинка ниже иллюстрирует как видят эту проблему разработчики Facebook'а.

Flux, в отличии от описанного MVC, предлагает понятную и стройную модель:

  1. View порождает Action
  2. Action попадает в Dispatcher
  3. Dispatcher обновляет Store
  4. Обновленный Store оповещает View об изменении
  5. View перерисовывается

Кроме того, используя Flux, несколько Views могут подписаться на интересующие их Stores и обновляться только тогда, когда в этих Stores что-нибудь изменится. Такой подход уменьшает количество зависимостей и упрощает разработку.

Реализация MVC от Facebook полностью отличается от оригинального MVC, который был широко распространен в Smalltalk-мире. Это отличие и является основной причиной заявления "MVC не масштабируется".

Назад в восьмидесятые

MVC — это основной подход к разработке пользовательских интерфейсов в Smalltalk-80. Как Flux и Redux, MVC создавался для уменьшения сложности ПО и ускорения разработки. Я приведу краткое описание основных принципов MVC-подхода, более детальный обзор можно почитать здесь и здесь.

Ответственности MVC-сущностей:

  • Model — это центральная сущность, которая моделирует реальный мир и бизнес-логику, предоставляет информацию о своем состоянии, а также изменяет свое состояние по запросу из Controller'a
  • View получает информацию о состоянии Model и отображает ее пользователю
  • Controller отслеживает движение мыши, нажатие на кнопки мыши и клавиатуры и обрабатывает их, изменяя View или Model

А теперь то, что упустил Facebook, реализуя MVC — связи между этими сущностями:

  • View может быть связана только с одним Сontroller'ом
  • Сontroller может быть связан только с одним View
  • Model ничего не знает о View и Controller и не может их изменять
  • View и Controller подписываются на Model
  • Одна пара View и Controller'а может быть подписана только на одну Model
  • Model может иметь много подписчиков и оповещает всех их после изменения своего состояния

Посмотрите на изображение ниже. Стрелки, направленные от Model к Controller'у и View — это не попытки изменить их состояние, а оповещения об изменениях в Model.

Оригинальный MVC совершенно не похож на реализацию Facebook'a, в которой View может изменять множество Model, Model может изменять множество View, а Controller не образует тесную связь один-к-одному с View. Более того, Flux — это MVC, в котором роль Model играют Dispatcher и Store, а вместо вызова методов происходит отправка Action'ов.

React через призму MVC

Давайте посмотрим на код простого React-компонента:

class ExampleButton extends React.Component { render() { return ( <button onClick={() => console.log("clicked!")}> Click Me! </button> ); }
}

А теперь еще раз обратимся к описанию Controller'a в оригинальном MVC:

Controller отслеживает движение мыши, нажатие на кнопки мыши и клавиатуры и обрабатывает их, изменяя View или Model

Сontroller может быть связан только с одним View

Заметили, как Controller проник во View на третьей строке компонента? Вот он:

onClick={() => console.log("clicked!")}

Это идеальный Controller, который полностью удовлетворяет своему описанию. JavaScript сделал нашу жизнь легче, убрав необходимость самостоятельно отслеживать положение мыши и координаты в которых произошло нажатие. Наши React-компоненты превратились не просто во View, а в тесно связанные пары View-Controller.

Работая с React, нам остается только реализовать Model. React-компоненты смогут подписываться на Model и получать уведомления при обновлении ее состояния.

Готовим MVC

Для удобства работы с React-компонентами, создадим свой класс BaseView, который будет подписываться на переданную в props Model:

// src/Base/BaseView.tsx
import * as React from "react";
import BaseModel from "./BaseModel"; export default class <Model extends BaseModel, Props> extends React.Component<Props & {model: Model}, {}> { protected model: Model; constructor(props: any) { super(props); this.model = props.model } componentWillMount() { this.model.subscribe(this); } componentWillUnmount() { this.model.unsubscribe(this); }
}

В этой реализации атрибут state всегда является пустым объектом, потому что мне он показался бесполезным. View может хранить свое состояние непосредственно в атрибутах экземпляра класса и при необходимости вызывать this.forceUpdate(), чтобы перерисовать себя. Возможно, такое решение является не самым лучшим, но его легко изменить, и оно не влияет на суть статьи.

Теперь реализуем класс BaseModel, который предоставляет возможность подписаться на себя, отписаться от себя, а также оповестить всех подписчиков об изменении состояния:

// src/Base/BaseModel.ts
export default class { protected views: React.Component[] = []; subscribe(view: React.Component) { this.views.push(view); view.forceUpdate(); } unsubscribe(view: React.Component) { this.views = this.views.filter((item: React.Component) => item !== view); } protected updateViews() { this.views.forEach((view: React.Component) => view.forceUpdate()) }
}

Я реализую всем известный TodoMVC с урезанным функционалом, весь код можно посмотреть на Github.

TodoMVC является списком, который содержит в себе задачи. Список может находится в одном из трех состояний: "показать все задачи", "показать только активные задачи", "показать завершенные задачи". Также в список можно добавлять и удалять задачи. Создадим соответствующую модель:

// src/TodoList/TodoListModel.ts
import BaseModel from "../Base/BaseModel";
import TodoItemModel from "../TodoItem/TodoItemModel"; export default class extends BaseModel { private allItems: TodoItemModel[] = []; private mode: string = "all"; constructor(items: string[]) { super(); items.forEach((text: string) => this.addTodo(text)); } addTodo(text: string) { this.allItems.push(new TodoItemModel(this.allItems.length, text, this)); this.updateViews(); } removeTodo(todo: TodoItemModel) { this.allItems = this.allItems.filter((item: TodoItemModel) => item !== todo); this.updateViews(); } todoUpdated() { this.updateViews(); } showAll() { this.mode = "all"; this.updateViews(); } showOnlyActive() { this.mode = "active"; this.updateViews(); } showOnlyCompleted() { this.mode = "completed"; this.updateViews(); } get shownItems() { if (this.mode === "active") { return this.onlyActiveItems; } if (this.mode === "completed") { return this.onlyCompletedItems; } return this.allItems; } get onlyActiveItems() { return this.allItems.filter((item: TodoItemModel) => item.isActive()); } get onlyCompletedItems() { return this.allItems.filter((item: TodoItemModel) => item.isCompleted()); }
}

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

// src/TodoItem/TodoItemModel.ts
import BaseModel from "../Base/BaseModel";
import TodoListModel from "../TodoList/TodoListModel"; export default class extends BaseModel { private completed: boolean = false; private todoList?: TodoListModel; id: number; text: string = ""; constructor(id: number, text: string, todoList?: TodoListModel) { super(); this.id = id; this.text = text; this.todoList = todoList; } switchStatus() { this.completed = !this.completed this.todoList ? this.todoList.todoUpdated() : this.updateViews(); } isActive() { return !this.completed; } isCompleted() { return this.completed; } remove() { this.todoList && this.todoList.removeTodo(this) }
}

К получившимся моделям можно добавлять любое количество View, которые будут обновляться сразу после изменений в Model. Добавим View для создания новой задачи:

// src/TodoList/TodoListInputView.tsx
import * as React from "react";
import BaseView from "../Base/BaseView";
import TodoListModel from "./TodoListModel"; export default class extends BaseView<TodoListModel, {}> { render() { return ( <input type="text" className="new-todo" placeholder="What needs to be done?" onKeyDown={(e: any) => { const enterPressed = e.which === 13; if (enterPressed) { this.model.addTodo(e.target.value); e.target.value = ""; } }} /> ); }
}

Зайдя в такой View, мы сразу видим, как Controller (props onKeyDown) взаимодействует с Model и View, и какая конкретно Model используется. Нам не нужно отслеживать всю цепочку передачи props'ов от компонента к компоненту, что уменьшает когнитивную нагрузку.

Реализуем еще один View для модели TodoListModel, который будет отображать список задач:

// src/TodoList/TodoListView.tsx
import * as React from "react";
import BaseView from "../Base/BaseView";
import TodoListModel from "./TodoListModel";
import TodoItemModel from "../TodoItem/TodoItemModel";
import TodoItemView from "../TodoItem/TodoItemView"; export default class extends BaseView<TodoListModel, {}> { render() { return ( <ul className="todo-list"> {this.model.shownItems.map((item: TodoItemModel) => <TodoItemView model={item} key={item.id}/>)} </ul> ); }
}

И создадим View для отображения одной задачи, который будет работать с моделью TodoItemModel:

// src/TodoItem/TodoItemView.jsx
import * as React from "react";
import BaseView from "../Base/BaseView";
import TodoItemModel from "./TodoItemModel"; export default class extends BaseView<TodoItemModel, {}> { render() { return ( <li className={this.model.isCompleted() ? "completed" : ""}> <div className="view"> <input type="checkbox" className="toggle" checked={this.model.isCompleted()} onChange={() => this.model.switchStatus()} /> <label>{this.model.text}</label> <button className="destroy" onClick={() => this.model.remove()}/> </div> </li> ); }
}

TodoMVC готов. Мы использовали только собственные абстракции, которые заняли меньше 60 строк кода. Мы работали в один момент времени с двумя движущимися частями: Model и View, что снизило когнитивную нагрузку. Мы также не столкнулись с проблемой отслеживания функций через props'ы, которая быстро превращается в ад. А еще нам не пришлось создавать фэйковые Container-компоненты.

Что не так с Redux?

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

  • уметь сохранять свое состояние в local storage и стартовать, используя сохраненное состояние
  • уметь заполнять свое состояние на сервере и передавать его клиенту внутри HTML
  • передавать Action'ы по сети
  • поддерживать undo состояния приложения

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

Redux слишком сложный, и я говорю не про количество строк кода в репозитории библиотеки, а про те подходы к разработке ПО, которые он проповедует. Redux возводит indirection в абсолют, предлагая начинать разработку приложения с одних лишь Presentation Components и передавать все, включая Action'ы для изменения State, через props. Большое количество indirection'ов в одном месте делает код сложным. А создание переиспользуемых и настраиваемых компонентов в начале разработки приводит к преждевременному обобщению, которое делает код еще более сложным для понимания и модификации.

Для демонстрации indirection'ов можно посмотреть на такой же TodoMVC, который расположен в официальном репозитории Redux. Какие изменения в State приложения произойдут при вызове callback'а onSave, и в каком случае они произойдут?

При отсутствии желания устраивать расследование самостоятельно, можно заглянуть под спойлер

  1. hadleSave из TodoItem передается как props onSave в TodoTextInput
  2. onSave вызывается при нажатии Enter или, если не передан props newTodo, на действие onBlur
  3. hadleSave вызывает props deleteTodo, если заметка изменилась на пустую строку, или props editTodo в ином случае
  4. props'ы deleteTodo и editTodo попадают в TodoItem из MainSection
  5. MainSection просто проксирует полученные props'ы deleteTodo и editTodo в TodoItem
  6. props'ы в MainSection попадают из контейнера App с помощью bindActionCreator, а значит являются диспетчеризацией action'ов из src/actions/index.js, которые обрабатываются в src/reducers/todos.js

И это простой случай, потому что callback'и, полученные из props'ов, оборачивались в дополнительную функциональность только 2 раза. В реальном приложении можно столкнуться с ситуацией, когда таких изменений гораздо больше.

При использовании оригинального MVC, понимать, что происходит с моделью приложения гораздо проще. Такое же изменение заметки не содержит ненужных indirection'ов и инкапсулирует всю логику изменения в модели, а не размазывает ее по компонентам.

Создание Flux и Redux было мотивировано немасштабируемостью MVC, но эта проблема исчезает, если применять оригинальный MVC. Redux пытается сделать изменение состояния приложения предсказуемым, но водопад из callback'ов в props'ах не только не способствует этому, но и приближает вас к потере контроля над вашим приложением. Возросшей сложности frontend-приложений, о которой говорят авторы Flux и Redux, не было. Было лишь неправильное использование подхода к разработке. Facebook сам создал проблему и сам же героически ее решил, объявив на весь мир о "новом" подходе к разработке. Большая часть frontend-сообщества последовала за Facebook, ведь мы привыкли доверять авторитетам. Но может настало время остановиться на мгновение, сделать глубокий вдох, отбросить хайп и дать оригинальному MVC еще один шанс?

UPD

Изменил изначальные view.setState({}) на view.forceUpdate(). Спасибо, kahi4.

Теги
Показать больше

Похожие статьи

Кнопка «Наверх»
Закрыть