В интернетах давно ведётся священная война между адептами Функционального Программирования и ООП, Redux и MobX, React и Angular. Многие годы я обходил её стороной, но теперь эта тема коснулась и меня.
Сейчас Redux используется в сотнях тысяч проектах, сообщество постепенно становится менее гибким. Если ты приходишь в компанию, где, к примеру, активно используется React, то скорее всего, к нему в комплекте как правило идёт и Redux. К этой библиотеке накопилось достаточно много хейта, но использовать что-то другое уже сложнее в силу многих причин. Чем так плох Redux и почему без него ещё хуже, хорошо выражено во многих статьях, но как правило, авторы эмоционально навязывают использовать что-то, что им самим нравится больше. Пожалуй, наиболее объективно написано в переводе "Совершенствуем Redux". Но особенно хочется выделить одну цитату:
Redux, в принципе, простая и небольшая библиотека с крутой кривой обучения. На каждого разработчика, который овладел и извлек выгоду из Redux, погрузившись в функциональное программирование, найдется другой потенциальный разработчик, запутавшийся и думающий «это все не для меня, я возвращаюсь к jQuery».
И я один из последних, к слову.
Цель любой библиотеки: сделать сложное простым при помощи абстракции.
На момент написания статьи я использую React не более месяца, так что возможны подводные камни, о которых я могу не знать и вы любезно напишете мне о них в комментариях. Не то, чтобы это было поводом для статьи, к тому же, есть MobX, который как раз про ООП, так что...
Зачем
… придумывать новый велосипед?
На самом деле, я недавно проникся самой важной фишкой ФП (и Redux) — иммутабельностью состояния со всеми вытекающими из неё плюсами. Но моё ООП головного мозга не позволяет мне писать столько лишнего и запутанного кода, который заставляет писать Redux.
Кстати, в этом плане мне гораздо больше нравится Vuex, код получается приближенным к тому, что описано в статье выше, но даже он не идеален. Для vuex существует несколько @декораторов, которые представляют модули в виде класса, но ни один из них не разрешает наследование, поэтому я написал свой собственный декоратор с поддержкой такой возможности. Как видите, моя погоня за совершенным кодом началась уже тогда. Наследование — такой же важный аспект ООП, как и полиморфизм, и я постараюсь показать в этой статье пример из реальной жизни, почему стейты должны его поддерживать.
Store
Итак, идеальный код, по моему мнению, выглядит примерно так:
import {Store, state, Mutation, Action} from "@disorrder/storm";
export default class Account extends Store {
@state amount = 0
@Mutation setAmount(val = 0) {
return {amount: val};
}
@Action async updateAmount() {
let {data} = await api.get("/user/account");
// Вариант 1
this.setAmount(data);
// Вариант 2
this.amount = data;
// Вариант 3
this.mutate({amount: data}); // Вообще, этот метод использовать не придётся.
// Все 3 варианта делают одно и то же, но лучше использовать первые два.
}
}
Это библиотека, которую я написал, вдохновляясь всем этим зоопарком технологий и (теперь) ненавистью к Redux. Терминология vuex кажется мне наиболее понятной и подходящей, поэтому я выбрал её.
Класс Store
создаёт внутри себя иммутабельный state, к которому нет доступа напрямую. Изменить состояние можно с помощью внутреннего метода mutate(), который создаёт новую изменённую копию state, но в большинстве случаев пользоваться им не придется.
Декоратор @state
создаёт интерфейс (геттер и сеттер) для управления значениями в state.
Mutation
— примерно то же самое, что и reducer в терминологии Redux, отвечает за изменение информации в state. В идеале, никогда не должно быть "пустых" изменений. В отличие от Redux, вызов какого-либо action влияет только на тот класс, в котором он был вызван. В том же Redux мне никогда не доводилось видеть, чтобы один action.type использовался в нескольких редьюсерах. Mutation стоит воспринимать исключительно как минорное изменение инкапсулированного состояния.
Action
же — почти всегда асинхронное действие. Вызов метода из API, например, или даже другого action. Конечно, он может быть и синхронным, никаких особых ограничений нет, но тогда стоит задать себе вопрос, не должна ли это быть мутация.
По непонятной мне причине в vuex и других подобных библиотеках нельзя вызывать action внутри другого action, хотя иногда это бывает удобно. Так же здесь возможно менять состояния других модулей, но не думаю, что это хорошая практика. Если возникла такая необходимость, лучше лишний раз подумайте над архитектурой приложения.
Контекст приложения
Библиотека разработана так, чтобы не зависеть от других библиотек и фреймворков вроде React или Vue. Не нужно вызвать функцию connect (как в Redux). Достаточно импортировать модуль в нужное место и просто работать с ним!
// src/store/index.js
import User from "./User"
export const user = new User()
. . .
// src/store/User.js
export default class User extends Store {
// ...
}
// src/App.js
import user from "./store";
class App extends Component {
componentDidMount() {
user.subscribe((mutation, oldState) => {
this.setState({});
});
}
}
Вот теперь всё заработает. Метод subscribe
подписывает на любые мутации именно модуля user, но в первом аргументе mutation
можно проверить, какие именно объекты были изменены.
Collection pattern
Наконец, добрались до примера, где у меня появилась острая необходимость в наследовании. Пока что это единственный пример из моего опыта, но даже его уже хватает. Уж слишком часто этот код приходится копипастить из проекта в проект, поэтому ему уделяется подробное внимание. А именно, хранение многочисленных коллекций, с которыми можно производить CRUD. Полный код примера можно посмотреть здесь. (Возможно, в первые дни после публикации полного примера нет, но он обязательно появится)
export default class Collection extends Store {
url = "/"
pk = "id" // Primary key
@state items = {}
@state indices = [] // itemIds
// Из-за обращения к объектам в стейте напрямую получилось две мутации. Это может привести к лишнему срабатыванию рендера.
@Mutation __add(item) {
const id = item[this.pk];
this.items = {...this.items, [id]: item};
this.indices = [...this.indices, id];
}
// Так-то лучше
@Mutation add(item) {
const id = item[this.pk];
const items = {...this.items, [id]: item};
let mutation = {items};
const rewrite = id in this.items;
if (!rewrite) {
mutation.indices = [...this.indices, id];
}
return mutation;
}
@Action async create(data) {
let item = await api.post(this.url, data);
this.add(item);
}
@Action async getById(id) {
let item = await api.get(`${this.url}/${id}`);
this.add(item);
}
@Action async getList(id, params) {
let items = await api.get(this.url, {params});
items.forEach(this.add.bind(this));
}
}
Далее можно создавать столько дочерних классов, сколько нужно и добрая часть из них будет выглядеть примерно так:
export default class Users extends Collection {
url = "/users"
}
Всё. В этом прелесть и сила наследования. Такой подход оставляет и пространство для манёвра: можно переопределять отдельные методы, сохраняются парадигмы SOLID, DRY, KISS — всё, как мы любим.
Преимущества
- Исходный код укладывается всего в 150 строк. Возможно, ещё столько же добавится в будущем, но не сильно больше. Однажды мой знакомый сказал, что "Redux прост, как палка". Однажды я попрошу метафору для своего велосипеда!
- Стиль кода навязывает модульную систему. В проектах с Redux часто reducers и actions лежат в разных папках.
- Никаких switch-case
- Никаких проблем с асинхронными функциями
Недостатки
- К сожалению, декораторы ещё не работают из коробки. Придётся настроить Babel. Но есть функция createStore, которая позволяет обойтись без них.
- Слабая функция subscribe, возможно стоит добавить watcher на отдельный объект стейта
был ещё один, но пока писал, успел забыть
P.S.: Не претендую на звание прорывной библиотеки, отталкивался от самого кода, который мне кажется наиболее удобным. Это мой личный эксперимент и мысли, которыми я хочу поделиться. Любая критика принимается, просто держу в курсе.
VolCh
Насколько я помню семантику, action — атомарная мутация состояния, о которой уведомляются подписчики и по окончанию которой объект находится в консистентном состоянии. Если позволить их делать вложенными, то
Скорее для редких случаев нужен какой-то отдельный batchAction, который доступа к изменению стейта не имеет, но агрегирует сообщения об изменении стейта в одно.
Disorrder Автор
Подписчики уведомляются после любой мутации, не на экшн.
Согласен, что вложенные action — не православно, но у меня был момент в vuex, когда было 2 похожих экшена, но с разными аргументами. Не хотелось дублировать код, но пришлось, т.к. не было возможности выносить общую логику.
Если экшн не вызывает мутацию, а вместо этого вызывает другой экшн, является ли он экшеном по семантике?