Всем йо, хабражители.
В общем, так вышло, что я пишу на JavaScript уже довольно долго, и одной из самых главных задач всегда была организация состояния приложения.
TL; DR;
Нет ничего привычнее на свете,
Чем писать колесо на велосипеда
Что-то хочется кешировать, что-то обновлять, причем обновлять везде, а не только в локальном компоненте, не хочется перерисовывать весь компонент если поменялся весь Store (shout out to Vuex), а хочется подписываться на то, что используешь (shout out to MobX).
В Redux мне очень не нравились несколько аспектов:
1) Слишком много boilerplate кода. Конечно, есть много способов и подходов сделать мутации приятнее для программистов, но тем не менее, все равно эта часть перегружена имхо.
2) Разрозненность сущностей. В свое время, когда я писал мобильные приложения на ReactNative, мы работали с JSON API сервером, то есть он возвращал ответ в формате json api спецификации, включая сущности и отношения этих сущностей. Мне спецификация очень понравилась, хоть сначала я не вдуплил. И сразу пример проблемы: у нас список диалогов, мы зашли в диалог — там пользователь онлайн. Вернулись в список диалогов — пользователь оффлайн. Думаю, знакомо юзерам ВК.
В Vuex мне в принципе, все нравится, но там не решена проблема разрозненности сущностей и есть нюансы.
В чем идея Vibe.js
Когда я писал proof of concept, я отталкивался от следующих идей:
1) Я хочу, чтобы сущность была в одном месте. Как в базе данных: 1 id = 1 сущность.
2) Я хочу, чтобы я мог подписываться только на нужные сущности
3) В то же время я хочу комбинировать различные сущности и атрибуты, чтобы не воротить каждый раз кучу подписок на нужные сущности.
4) Я хочу иметь возможность напрямую реактивно обновлять состояние — entity.name = "Vasiliy"
, но в то же время иметь возможность делать мутации с payload и как-то дебажить мутации, как минимум, например, добавляя к ним текстовый message.
Что получилось
Сейчас в Vibe.js есть следующие концепты:
Model, EntitySubject
Класс, который позволяет определить модель сущности.
Пример использования:
const User = new Model('User', {
structure: {
name: types.Attribute,
bestFriend: types.Reference('User'),
additionalInfo: {
age: types.Attribute
}
},
computed: {
bestFriendsName(){
return this.bestFriend && this.bestFriend.name || "No best friend :C"
}
},
mutations: {
setName(newName){
this.mutate({
name: newName
}, "User.setName")
}
}
});
Конструктор позволяет описать структуру сущности, вычисляемые значения, а так же мутации.
Структура может быть описана с помощью Атрибутов
, Ссылок
или вложенных объектов.
Замечу, что имя пользователя можно изменить и напрямую: someUser.name = "New name"
,
но мутации — более стандартизированный подход.
Сам экземпляр модели практически ничего не может — он только хранит структуры из конструктора.
Если мы хотим добавить сущность:
User.insertEntity(1, {
name: "Yura",
bestFriend: 1, // sad when the best friend of yourself is you
additionalInfo: {
age: 17
}
});
Если какие-то значения не будут указаны, будет использоваться дефолтный null
. Чтобы теперь пользоваться этой сущностью, вызовем метод observe
.
const entity = User.observe(1);
const user = entity.interface;
console.log(user.name) // -> "Yura"
Есть нюанс, да? Слишком много чего нужно написать, чтобы работать с сущностью. По строчкам.
1) entity
= Экземпляр EntitySubject
. Он подписывается на изменения сущности и обновляет interface
. На него так же можно подписаться.
2) interface
= Реактивный интерфейс для работы с сущностью. У него доступны значения состояния сущности, computed значения и мутации. Нужно заметить, что если сущность еще не существует в EntityStore
, то entity.interface
будет `null.
EntityStore
Это, как понятно из названия, хранилище сущностей. В нем хранятся все состояния, все observable
, модели и содержит методы, которыми пользуются Model
или Subject
.
const User = new Model('User', {
structure: {
name: types.Attribute,
bestFriend: types.Reference('User'),
additionalInfo: {
age: types.Attribute
}
},
computed: {
bestFriendsName(){
return this.bestFriend && this.bestFriend.name || "No best friend :C"
}
},
mutations: {
setName(newName){
this.mutate({
name: newName
}, "User.setName")
}
}
});
const Store = new EntityStore([User]);
Мы инициализируем наше хранилище сущностей массивом моделей, чтобы EntityStore
мог потом линковать все отношения, ссылки, подписки...
Directory, DirectorySubject
Директории похожи на сущности — синглтоны. У них не индентификаторов, они статичны. Также их не нужно указывать при инициализации EntityStore
, потому что сущности не могут на них подписаться. По сути директории — это "каталоги", которые имеют какое-то локальное состояние в виде атрибутов и ссылки на сущности.
К примеру, если мы смотрим книги в интернет магазине, то такая директория могла быть использована:
const Store = new EntityStore([Book]);
const BooksList = new Directory('BooksList', {
structure: {
page: types.Attribute,
searchWord: types.Attribute,
fetchedBooks: types.Array(types.Reference(Book.name))
}
}, Store);
Директории так же поддерживают computed значения и мутации, и на них так же можно подписаться.
А что насчет подписок?
Vibe.js организован так, что сущность или директория будет подписана только на те сущности, которые присутствуют на текущий момент в их состоянии. То есть, если мы в директории имеем массив отображаемых элементов и заменяем его, то после изменения состояния она отпишется от сущностей из предыдущего массива и будет подписана только на новые.
Ну и все это протестировано
Или не все. Я люблю писать тесты и хочу написать их побольше, потому что мне кажется, что их недостаточно. В том плане, они не охватывают все, что может пойти не так.
Ссылки
Репозиторий с примером Todo list на react
Комментарии (27)
bgnx
28.10.2017 00:36А чем вам библиотека mobx-state-tree не угодила? Она сильно похожа на ваш стор
enchantinggg Автор
28.10.2017 01:09Хм, я в замешательстве. За долгое время поиска решения я не нашел ее.
Чувствую себя немного опустошенным теперь
enchantinggg Автор
28.10.2017 01:14Чую, что MobX для меня снова в приоритете.
Хотя, я бы еще проверил производительность на всякий случай, если эта либа связана с мобксом, то это небольшой оверхед
Блин, классная либа
enchantinggg Автор
28.10.2017 01:16И еще я не знаю, решена ли у них проблема рекурсивных зависимостей. Если да, то это класс
enchantinggg Автор
28.10.2017 01:19Да, она решена. Чувствую себя еще более опустошенным))
RubaXa
28.10.2017 10:37Бывает, да и вы получили хороший опыт.
Жаль, что MobX так долго тянули с этим решением.
Riim
28.10.2017 11:43+1А зачем нужны отдельные секции
computed
,mutations
? Не проще так:
class User extends EventEmitter { @observable name: string; @observable bestFriend: User; @observable additionalInfo: string; @computed get bestFriendsName() { return (this.bestFriend && this.bestFriend.name) || 'No best friend :C'; } setName(newName: string) { this.name = newName; } }
?
enchantinggg Автор
28.10.2017 12:19А разницы никакой и нет. Если записать то же самое через types.model, получится схожий код
Riim
28.10.2017 12:25Так зачем ещё одна библиотека? Да и разница всё же есть, для моего варианта даже без typescript будет работать автодополнение.
enchantinggg Автор
28.10.2017 12:42Автодополнение — задача IDE. У меня vscode вполне нормально дополняет.
redyuf
28.10.2017 14:47Здесь не знаю, но например в mobx-state-tree есть аналогичные actions. Их суть в группировке операций по изменению стейта. Таким образом можно регулировать атомарность записи в лог транзакций. Всякие undo/redo работают с группами базовых операций по изменению стейта.
Интересно было бы добиться такой же группировки на экшенах, с сохранением нативных классов, не наворачивая всяких фабрик поверх. Иными словами, как получить фишки редукса и mst без «very specific state architecture».Riim
28.10.2017 15:26Интересно было бы добиться такой же группировки на экшенах, с сохранением нативных классов
декоратор
action
который будет заворачивать метод в транзакцию в конце которой будет делаться снимок изменений для undo/redo. Вроде легко должно реализовываться. Или я что-то забыл?redyuf
28.10.2017 16:12Думаю, как раз не легко. Не только в экшенах дело. Как, например в голом mobx узнать что изменилось в транзакции и получить патч изменений. Как генерить патчи без дупликации данных.
Пытаясь решить эти задачи, тащат и описание типов в run-time и описание ссылочности между ними и кучу всего, что уже сложно замаскировать под обычные объекты с декораторами.Riim
28.10.2017 18:43Как, например в голом mobx узнать что изменилось в транзакции и получить патч изменений
не знаю как в mobx, у меня в cellx все изменённые в транзакции атомы попадают в releasePlan, каждый атом кроме
_value
имеет_fixedValue
, который получит значение_value
при завершении транзакции прямо перед генерацией событияchange
, то есть прямо перед концом транзакции можно просто пройтись поreleasePlan
и запомнить эти два свойства. Если в mobx как-то так же, то проблем быть не должно, но если транзакции реализованы за счёт подавления вычисляемых, запоминания их и вычисления в конце транзакции, то да, вместо простейшего декоратора на 50-100 строк кода прийдётся писать хитрую библиотеку.
В более ранних версиях cellx были также транзакции в явном виде. В них как-раз в случае ошибки используется
releasePlan
для отката изменений банальнымпереписыванием _fixedValue
в_value
. То есть это настоящие атомарные транзакции и в случае ошибки где-то посреди транзакции все сделанные в ней изменения полностью откатывались. Mobx, насколько я знаю, всё ещё не умеет так, по крайней мере в чистом виде, без дополнительных библиотек.
TrueCarry
29.10.2017 00:29Когда смотрел ваш код, вспомнил observable из шарпа. Это было довольно удобно и необычно в то время, когда они появились. Но чтобы сделать нужное свойство observable, нужно было ему в set прописывать вызов ивента, который собственно оповещал об изменении. В итоге к каждой переменной добавлялся вызов метода NotifyPropertyChanged(Name). Такой подход мне как раз кажется этим лишним boilerplate из redux. Мне кажется библиотека должна брать на себя такие моменты, чтобы пользователь не писал каждый раз
setSomething(data) { this.mutate( ..., 'setSomething') }
Возможно мне просто не повезло понять удобство такого подхода, но мне кажется если мы хотим подписаться на изменение параметра, мы можем сделать это проще.
Сам пользуюсь Vuex, computed и watch вполне пока удовлетворяют мои потребности.AlexzundeR
29.10.2017 02:02Эту задачу решили в прикладных библиотеках .net. Например в EF это реализовано через proxy классы, которые на рантайме создаются.
TrueCarry
29.10.2017 03:28Я о том же. В итоге появились magic либы. Не стоит проходить весь этот путь сначала.
strannik_k
29.10.2017 22:17+1Тоже свой велосипед писал, т.к. Redux мне сразу не понравился. Хотелось писать поменьше кода.
Если автору статьи или кому-нибудь еще интересно, то state management у меня выглядит так:
//Задается стор для хранения глобального состояния приложения. Также можно создать много отдельных сторов, а не один общий. //Необязательно заранее инициализировать структуру вручную. Можно в любое время добавить любые данные в стор. Главное указывать правильный путь при обращении к нужному свойству. const Store = new UIStore({ booksList: [], selectedUser: { id: 1, name: "Yura", bestFriend: 1, additionalInfo: { age: 17 } } }); class AddtitionalUserInfo extends Component { componentWillMount() { //Подписка компонента на изменения стора. //Во втором параметре передается локальный state компонента, если он нужен. //В третьем параметре передается массив путей к свойствам объектов в сторе, на изменение которых нужно подписаться. new UIState(this, null, Store, [{path: 'selectedUser.additionalInfo'}]); } componentWillUnmount() { this.uiState.removeState(); } handleUpdateUserAge = (e) => { Store.update('selectedUser.additionalInfo.age', 20); }; render() { return <div> <div>User age: {this.uiState.getStoreData('selectedUser.additionalInfo.age')}</div> <button onClick={this.handleUpdateUserAge}>Update user age</button> </div> } }
bgnx
30.10.2017 00:22+1Ваш пример на mobx будет выглядеть намного короче и удобней:
const Store = new class { @observable booksList = []; @observable selectedUser = new class { @observable id = 1; @observable name = "Yura", @observable bestFriend = 1, @observable additionalInfo = new class { @observable age = 17 } } } @observer class AddtitionalUserInfo extends Component { handleUpdateUserAge = (e) => { Store.selectedUser.additionalInfo.age = 20; }; render() { return <div> <div>User age: {Store.selectedUser.additionalInfo.age}</div> <button onClick={this.handleUpdateUserAge}>Update user age</button> </div> } }
1) Нет болерплейта и ручных подписок на стор в
componentWillMount
иcomponentWillUnmount
2) Нет неудобной записи с получением значения через строкуthis.uiState.getStoreData('selectedUser.additionalInfo.age')
— мы можем прямо через стандартный js и точку обратится к любому свойствуStore.selectedUser.additionalInfo.age
3) Нет неудобной записи с обновлением свойстваStore.update('selectedUser.additionalInfo.age', 20);
— разве не удобней записать обновление через свойстваStore.selectedUser.additionalInfo.age = 20
а не возиться со строкой пути?
4) Как вы поступите если вам нужно будет в вашем сторе сохранять древовидные комментарии которые могут быть бесконечно вложенными? Если у вас стор можно считывать или обновлять только через указание пути к данным от корня то компонентам потребуется дополнительно еще передавать другу другу путь отдельным пропсом, когда же в mobx можно просто передать объект например<div>comment.children.map(comment=><Comment comment={comment}/></div>
а потом в компоненте комментария обновить данные прямо на объектеthis.props.comment.text = e.target.value
без возни с путями, независимо от глубины в котором хранится этот комментарий.
5) Получение и обновление свойств через стоковый путь в сторе лишает возможности протипизировать работу с состоянием и отловить ошибки на стадии компиляции.
В mobx есть только один момент которого стоит придерживаться — нужно чтобы свойство на которое будут подписываться компоненты было помечено декоратором
@observable
В примере, я записал вложенный объект через создание анонимного класса потому что декоратор можно объявить только в классе. В реальном приложении обычно синглтон-объектов очень мало а в процессе работы будут создаваться объекты с разным набором свойств и тогда логично и правильно эти типы вынести в отдельные классы. Например объектselectedUser
будет не литералом а создаем объекта класса User а объект вложенный объект additionalInfo будет объектом другого класса Profile.
const Store = new class { @observable booksList = []; @observable selectedUser = new User({id: 1, name: "Yura", bestFriend: 1, profile: {age: 17}}) } class User { @observable id; @observable name = "Yura", @observable bestFriend, @observable additionalInfo; constructor({id, name, bestFriend, profile}){ this.id = id; this.name = name; this.bestFriend = bestFriend; this.profile = new Profile(profile); } } class Profile { @observable age; constructor({age}){ this.age = age; } }
Тут примерно полная аналогия с таблицами в реляционных базах данных — там нельзя создать вложенный объект в таблице и для этого нужно создавать отдельную таблицу. На клиенте точно также — таблицы просто будут классами — User, Profile, Post, Comment и т.д где мы объявляем типы колонок (для статической типизации) и помечаем декоратором
@observable
свойства которые рендерим в компонентахstrannik_k
30.10.2017 02:081) Можно еще создать базовый компонент и в нем отписываться.
Это немного сократит код, но от подписки в componentWillMount у меня никуда не деться.
2-3) У меня можно тоже через стандартный js обратиться к любому свойству. Но как избежать ошибки, если свойства нет? Конечно, можно сделать проверку на существование каждого объекта перед конечным свойством. У меня же используется именно строка, чтобы рекурсивно проверить существование промежуточных объектов и конечного поля и, в случае их отсутствия, вернуть какое-нибудь дефолтное значение.
3) Ну да, стор у меня только при указании пути обновится.
4) То, что вы написали, у меня обновит только текущий компонент и вложенные. Плюс еще придется вручную вызвать его обновление.
Простой, но не оптимальный вариант — просто передать весь древовидный объект в стор и подписаться на изменения корня этого объекта. В случае изменения какого-нибудь комментария, перерисуются все компоненты, подписанные на корень древовидного объекта.
Если оптимизировать так, чтобы перерисовывались только компоненты, отображающие измененные комментарии, то да, тут придется повозиться.
5) По-моему, не лишает. У меня в стор можно передать не только значения отдельных полей, но и объекты. Правда методы объектов не сохраняться.
В mobx есть только один момент которого стоит придерживаться.
Я не так давно писал, скольких моментов там стоит придерживаться. И там не один.
Возможно, со временем привыкаешь, но по началу сложно что-нибудь не забыть.
Тут примерно полная аналогия с таблицами в реляционных базах данных — там нельзя создать вложенный объект в таблице и для этого нужно создавать отдельную таблицу.
Тут более к месту было бы сказать, что в объекте создается ссылка на другой объект, а не реальный объект. Основы программирования на примере баз данных объяснять как-то странновато)
redyuf
30.10.2017 13:50Добавлю свои 5 копеек.
1. От connect и подписки componentWillMount в коде приложения можно избавиться, переопределив createElement, который будет создавать обертку с подписками вокруг исходного компонента
Суть в автоматизации, биндинги за сцену выносятся. Все компоненты можно чистыми оставить./** @jsx lom_h */ window['lom_h'] = lomCreateElement
2-3. Избежать ошибки, если свойства нет, а также индикатор загрузки приделать, можно обработчиком try/catch в render оберткиclass Wrapped extends React.Component { render() { try { return this._origComponent(this.props, this.context) } catch(error) { if (error instanceof Wait) return this._loadingComponent({error}) return this._errorComponent({error}) } } }
4. Весь объект лучше не передавать в пропсы ради оптимизации, можно попробовать развить идею контекстов.
Обертка createElement по deps найдет Store, проинжектит в контекст. Идея в том, что если вам надо прокидывать до HelloView сторы транзитом сквозь кучу компонент, просто перенесите его в контекст. Т.е. контексты в приоритете над пропсами. Если надо переопеделить Store, то можно использовать что-то вроде:function HelloView(_, store) { return <div> <input value={store.message} onInput={({target}) => { store.message = target.value }} /> <br/>{store.message} </div> } HelloView.deps = [Store]
const ClonedHelloView = clone(HelloView, [ [Store, MyStore] ])
actions в MobX — нужен из-за оптимизации синхронной природы обновлений, в решениях с асинхронными обновлениями можно и без них. Однако actions позволяет лучше генерировать лог изменений стейта, когда понятно кто изменил состояние, это важнее. Также undo/redo можно построить на них.
Про наблюдаемый объект parentObj.level1Obj = observable({level2Obj: {propA: 10}}); вам ответили, что не обязательно в данном случае заворачивать в observable.
Для демонстрации работы с контекстами и обработки ошибок приведу пример на reactive-di.
parotikov
А как ваш стор работает с vue, вместо vuex?
enchantinggg Автор
Я еще не писал конечных модулей, но все просто — подписываемся на обновления состояния и $forceUpdate.