- PVSM.RU - https://www.pvsm.ru -
Не так давно React позиционировал себя как "V in MVC". После этого коммита [1] маркетинговый текст изменился, но суть осталась той же: React отвечает за отображение, разработчик — за все остальное, то есть, говоря в терминах MVC, за Model и Controller.
Одним из решений для управления Model (состоянием) вашего приложения стал Redux. Его появление мотивировано [2] возросшей сложностью frontend-приложений, с которой не способен справиться MVC.
Главный Технический Императив Разработки ПО — управление сложностью
— Совершенный код [3]
Redux предлагает управлять сложностью с помощью предсказуемых изменений состояния. Предсказуемость достигается за счет трех фундаментальных принципов [4]:
Смог ли Redux побороть возросшую сложность и было ли с чем бороться?
Redux вдохновлен Flux'ом [5] — решением от Facebook. Причиной создания Flux, как заявляют разработчики Facebook [6] (видео [7]), была проблема масштабируемости архитектурного шаблона MVC.
По описанию Facebook, связи объектов в больших проектах, использующих MVC, в конечном итоге становятся непредсказуемыми:
О проблеме непредсказуемости изменений в MVC также написано в мотивации Redux'a. Картинка ниже иллюстрирует как видят эту проблему разработчики Facebook'а.
Flux, в отличии от описанного MVC, предлагает понятную и стройную модель:
Кроме того, используя Flux, несколько Views могут подписаться на интересующие их Stores и обновляться только тогда, когда в этих Stores что-нибудь изменится. Такой подход уменьшает количество зависимостей и упрощает разработку.
Реализация MVC от Facebook полностью отличается от оригинального MVC, который был широко распространен в Smalltalk-мире. Это отличие и является основной причиной заявления "MVC не масштабируется".
MVC — это основной подход к разработке пользовательских интерфейсов в Smalltalk-80. Как Flux и Redux, MVC создавался для уменьшения сложности ПО и ускорения разработки. Я приведу краткое описание основных принципов MVC-подхода, более детальный обзор можно почитать здесь [8] и здесь [9].
Ответственности MVC-сущностей:
А теперь то, что упустил Facebook, реализуя MVC — связи между этими сущностями:
Посмотрите на изображение ниже. Стрелки, направленные от Model к Controller'у и View — это не попытки изменить их состояние, а оповещения об изменениях в Model.
Оригинальный MVC совершенно не похож на реализацию Facebook'a, в которой View может изменять множество Model, Model может изменять множество View, а Controller не образует тесную связь один-к-одному с View. Более того, Flux — это MVC, в котором роль Model играют Dispatcher и Store, а вместо вызова методов происходит отправка Action'ов.
Давайте посмотрим на код простого 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 и получать уведомления при обновлении ее состояния.
Для удобства работы с 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 [10].
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-компоненты [11].
Меня удивило, что найти истории с негативным опытом [12] использования Redux [13] проблематично, ведь даже автор библиотеки говорит [14], что Redux подходит не для всех приложений. Если ваше frontend-приложения должно:
То можете выбирать Redux в качестве паттерна работы с моделью, в ином случае стоит задуматься об уместности его применения.
Redux слишком сложный, и я говорю не про количество строк кода в репозитории библиотеки, а про те подходы к разработке ПО, которые он проповедует. Redux возводит indirection в абсолют [11], предлагая начинать разработку приложения с одних лишь Presentation Components и передавать все, включая Action'ы для изменения State, через props. Большое количество indirection'ов в одном месте делает код сложным [15]. А создание переиспользуемых и настраиваемых компонентов в начале разработки приводит к преждевременному обобщению [16], которое делает код еще более сложным для понимания и модификации.
Для демонстрации indirection'ов можно посмотреть на такой же TodoMVC, который расположен в официальном репозитории Redux. Какие изменения в State приложения произойдут при вызове callback'а onSave [17], и в каком случае они произойдут?
hadleSave
из TodoItem
передается как props onSave
в TodoTextInput
onSave
вызывается при нажатии Enter
или, если не передан props newTodo
, на действие onBlur
hadleSave
вызывает props deleteTodo
, если заметка изменилась на пустую строку, или props editTodo
в ином случаеdeleteTodo
и editTodo
попадают в TodoItem
из MainSection
MainSection
просто проксирует полученные props'ы deleteTodo
и editTodo
в TodoItem
MainSection
попадают из контейнера App
с помощью bindActionCreator
, а значит являются диспетчеризацией action'ов из src/actions/index.js
, которые обрабатываются в src/reducers/todos.js
И это простой случай, потому что callback'и, полученные из props'ов, оборачивались в дополнительную функциональность только 2 раза. В реальном приложении можно столкнуться с ситуацией, когда таких изменений гораздо больше.
При использовании оригинального MVC, понимать, что происходит с моделью приложения гораздо проще. Такое же изменение заметки [18] не содержит ненужных indirection'ов и инкапсулирует всю логику изменения в модели, а не размазывает ее по компонентам.
Создание Flux и Redux было мотивировано немасштабируемостью MVC, но эта проблема исчезает, если применять оригинальный MVC. Redux пытается сделать изменение состояния приложения предсказуемым, но водопад из callback'ов в props'ах не только не способствует этому, но и приближает вас к потере контроля над вашим приложением. Возросшей сложности frontend-приложений, о которой говорят авторы Flux и Redux, не было. Было лишь неправильное использование подхода к разработке. Facebook сам создал проблему и сам же героически ее решил, объявив на весь мир о "новом" подходе к разработке. Большая часть frontend-сообщества последовала за Facebook, ведь мы привыкли доверять авторитетам [19]. Но может настало время остановиться на мгновение, сделать глубокий вдох, отбросить хайп и дать оригинальному MVC еще один шанс?
Изменил изначальные view.setState({})
на view.forceUpdate()
. Спасибо, kahi4 [20].
Автор: Роман Теличкин
Источник [21]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/275079
Ссылки в тексте:
[1] этого коммита: https://github.com/facebook/react/commit/c7868cc7411694a7fd3b80aea589e280e88d926c#diff-1a523bd9fa0dbf998008b37579210e12L12
[2] мотивировано: https://redux.js.org/introduction/motivation
[3] Совершенный код: https://www.ozon.ru/context/detail/id/138437220/
[4] трех фундаментальных принципов: https://redux.js.org/introduction/three-principles
[5] Flux'ом: https://facebook.github.io/flux/
[6] заявляют разработчики Facebook: https://www.infoq.com/news/2014/05/facebook-mvc-flux
[7] видео: https://youtu.be/nYkdrAPrdcw?t=10m23s
[8] здесь: http://www.global-webnet.com/Adventures/Files/DescriptionOfMvcUi-KrasnerPope.pdf
[9] здесь: http://www.math.sfedu.ru/smalltalk/gui/mvc.pdf
[10] Github: https://github.com/Telichkin/react-without-redux/tree/master/todomvc/src
[11] фэйковые Container-компоненты: https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0
[12] негативным опытом: https://medium.com/@gianluca.guarini/things-nobody-will-tell-you-about-react-js-3a373c1b03b4
[13] использования Redux: https://codeburst.io/the-ugly-side-of-redux-6591fde68200
[14] говорит: https://medium.com/@dan_abramov/you-might-not-need-redux-be46360cf367
[15] делает код сложным: https://jaysoo.ca/2015/11/21/avoid-unnecessary-indirection/
[16] преждевременному обобщению: http://wiki.c2.com/?PrematureGeneralization
[17] при вызове callback'а onSave: https://github.com/reactjs/redux/blob/master/examples/todomvc/src/components/TodoItem.js#L39
[18] Такое же изменение заметки: https://github.com/Telichkin/react-without-redux/blob/master/todomvc/src/TodoItem/TodoItemView.tsx#L38
[19] мы привыкли доверять авторитетам: https://ru.wikipedia.org/wiki/%D0%AD%D0%BA%D1%81%D0%BF%D0%B5%D1%80%D0%B8%D0%BC%D0%B5%D0%BD%D1%82_%D0%9C%D0%B8%D0%BB%D0%B3%D1%80%D1%8D%D0%BC%D0%B0
[20] kahi4: https://habrahabr.ru/users/kahi4/
[21] Источник: https://habrahabr.ru/post/350850/?utm_source=habrahabr&utm_medium=rss&utm_campaign=350850
Нажмите здесь для печати.