Vibe.js — попытка сделать state management без боли
Всем йо, хабражители.
В общем, так вышло, что я пишу на JavaScript уже довольно долго, и одной из самых главных задач всегда была организация состояния приложения.
Что-то хочется кешировать, что-то обновлять, причем обновлять везде, а не только в локальном компоненте, не хочется перерисовывать весь компонент если поменялся весь 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 организован так, что сущность или директория будет подписана только на те сущности, которые присутствуют на текущий момент в их состоянии. То есть, если мы в директории имеем массив отображаемых элементов и заменяем его, то после изменения состояния она отпишется от сущностей из предыдущего массива и будет подписана только на новые.
Ну и все это протестировано
Или не все. Я люблю писать тесты и хочу написать их побольше, потому что мне кажется, что их недостаточно. В том плане, они не охватывают все, что может пойти не так.
Ссылки
Github репозиторий библиотеки
NPM модуль
Репозиторий с примером Todo list на react
Github pages с генерированной документацией ESDOC