Всем йо, хабражители.


В общем, так вышло, что я пишу на 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 организован так, что сущность или директория будет подписана только на те сущности, которые присутствуют на текущий момент в их состоянии. То есть, если мы в директории имеем массив отображаемых элементов и заменяем его, то после изменения состояния она отпишется от сущностей из предыдущего массива и будет подписана только на новые.


Ну и все это протестировано


Или не все. Я люблю писать тесты и хочу написать их побольше, потому что мне кажется, что их недостаточно. В том плане, они не охватывают все, что может пойти не так.


Ссылки


Github репозиторий библиотеки


NPM модуль


Репозиторий с примером Todo list на react


Github pages с генерированной документацией ESDOC

Комментарии (27)


  1. parotikov
    27.10.2017 23:42

    А как ваш стор работает с vue, вместо vuex?


    1. enchantinggg Автор
      28.10.2017 01:10

      Я еще не писал конечных модулей, но все просто — подписываемся на обновления состояния и $forceUpdate.


  1. bgnx
    28.10.2017 00:36

    А чем вам библиотека mobx-state-tree не угодила? Она сильно похожа на ваш стор


    1. enchantinggg Автор
      28.10.2017 01:09

      Хм, я в замешательстве. За долгое время поиска решения я не нашел ее.
      Чувствую себя немного опустошенным теперь


    1. enchantinggg Автор
      28.10.2017 01:14

      Чую, что MobX для меня снова в приоритете.
      Хотя, я бы еще проверил производительность на всякий случай, если эта либа связана с мобксом, то это небольшой оверхед
      Блин, классная либа


    1. enchantinggg Автор
      28.10.2017 01:16

      И еще я не знаю, решена ли у них проблема рекурсивных зависимостей. Если да, то это класс


    1. enchantinggg Автор
      28.10.2017 01:19

      Да, она решена. Чувствую себя еще более опустошенным))


      1. RubaXa
        28.10.2017 10:37

        Бывает, да и вы получили хороший опыт.
        Жаль, что MobX так долго тянули с этим решением.


  1. keenondrums
    28.10.2017 08:57

    У редакса стор очень похоже выглядит с redux-orm


  1. 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;
        }
    }

    ?


    1. enchantinggg Автор
      28.10.2017 12:19

      А разницы никакой и нет. Если записать то же самое через types.model, получится схожий код


      1. Riim
        28.10.2017 12:25

        Так зачем ещё одна библиотека? Да и разница всё же есть, для моего варианта даже без typescript будет работать автодополнение.


        1. enchantinggg Автор
          28.10.2017 12:42

          Автодополнение — задача IDE. У меня vscode вполне нормально дополняет.


          1. Riim
            28.10.2017 13:13

            Допустим, но вопрос зачем, так и остался открыт.


    1. redyuf
      28.10.2017 14:47

      Здесь не знаю, но например в mobx-state-tree есть аналогичные actions. Их суть в группировке операций по изменению стейта. Таким образом можно регулировать атомарность записи в лог транзакций. Всякие undo/redo работают с группами базовых операций по изменению стейта.

      Интересно было бы добиться такой же группировки на экшенах, с сохранением нативных классов, не наворачивая всяких фабрик поверх. Иными словами, как получить фишки редукса и mst без «very specific state architecture».


      1. Riim
        28.10.2017 15:26

        Интересно было бы добиться такой же группировки на экшенах, с сохранением нативных классов

        декоратор action который будет заворачивать метод в транзакцию в конце которой будет делаться снимок изменений для undo/redo. Вроде легко должно реализовываться. Или я что-то забыл?


        1. redyuf
          28.10.2017 16:12

          Думаю, как раз не легко. Не только в экшенах дело. Как, например в голом mobx узнать что изменилось в транзакции и получить патч изменений. Как генерить патчи без дупликации данных.

          Пытаясь решить эти задачи, тащат и описание типов в run-time и описание ссылочности между ними и кучу всего, что уже сложно замаскировать под обычные объекты с декораторами.


          1. enchantinggg Автор
            28.10.2017 17:34

            По сути это git в state managemente


          1. Riim
            28.10.2017 18:43

            Как, например в голом mobx узнать что изменилось в транзакции и получить патч изменений

            не знаю как в mobx, у меня в cellx все изменённые в транзакции атомы попадают в releasePlan, каждый атом кроме _value имеет _fixedValue, который получит значение _value при завершении транзакции прямо перед генерацией события change, то есть прямо перед концом транзакции можно просто пройтись по releasePlan и запомнить эти два свойства. Если в mobx как-то так же, то проблем быть не должно, но если транзакции реализованы за счёт подавления вычисляемых, запоминания их и вычисления в конце транзакции, то да, вместо простейшего декоратора на 50-100 строк кода прийдётся писать хитрую библиотеку.


            В более ранних версиях cellx были также транзакции в явном виде. В них как-раз в случае ошибки используется releasePlan для отката изменений банальным переписыванием _fixedValue в _value. То есть это настоящие атомарные транзакции и в случае ошибки где-то посреди транзакции все сделанные в ней изменения полностью откатывались. Mobx, насколько я знаю, всё ещё не умеет так, по крайней мере в чистом виде, без дополнительных библиотек.


  1. PaulMaly
    28.10.2017 19:54

    RxJS, really? No, thanks…


  1. TrueCarry
    29.10.2017 00:29

    Когда смотрел ваш код, вспомнил observable из шарпа. Это было довольно удобно и необычно в то время, когда они появились. Но чтобы сделать нужное свойство observable, нужно было ему в set прописывать вызов ивента, который собственно оповещал об изменении. В итоге к каждой переменной добавлялся вызов метода NotifyPropertyChanged(Name). Такой подход мне как раз кажется этим лишним boilerplate из redux. Мне кажется библиотека должна брать на себя такие моменты, чтобы пользователь не писал каждый раз


    setSomething(data) {
      this.mutate( ..., 'setSomething')
    }

    Возможно мне просто не повезло понять удобство такого подхода, но мне кажется если мы хотим подписаться на изменение параметра, мы можем сделать это проще.
    Сам пользуюсь Vuex, computed и watch вполне пока удовлетворяют мои потребности.


    1. AlexzundeR
      29.10.2017 02:02

      Эту задачу решили в прикладных библиотеках .net. Например в EF это реализовано через proxy классы, которые на рантайме создаются.


      1. TrueCarry
        29.10.2017 03:28

        Я о том же. В итоге появились magic либы. Не стоит проходить весь этот путь сначала.


  1. 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>
        }
    }
    


  1. 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 свойства которые рендерим в компонентах


    1. strannik_k
      30.10.2017 02:08

      1) Можно еще создать базовый компонент и в нем отписываться.
      Это немного сократит код, но от подписки в componentWillMount у меня никуда не деться.

      2-3) У меня можно тоже через стандартный js обратиться к любому свойству. Но как избежать ошибки, если свойства нет? Конечно, можно сделать проверку на существование каждого объекта перед конечным свойством. У меня же используется именно строка, чтобы рекурсивно проверить существование промежуточных объектов и конечного поля и, в случае их отсутствия, вернуть какое-нибудь дефолтное значение.

      3) Ну да, стор у меня только при указании пути обновится.

      4) То, что вы написали, у меня обновит только текущий компонент и вложенные. Плюс еще придется вручную вызвать его обновление.
      Простой, но не оптимальный вариант — просто передать весь древовидный объект в стор и подписаться на изменения корня этого объекта. В случае изменения какого-нибудь комментария, перерисуются все компоненты, подписанные на корень древовидного объекта.
      Если оптимизировать так, чтобы перерисовывались только компоненты, отображающие измененные комментарии, то да, тут придется повозиться.

      5) По-моему, не лишает. У меня в стор можно передать не только значения отдельных полей, но и объекты. Правда методы объектов не сохраняться.

      В mobx есть только один момент которого стоит придерживаться.
      Я не так давно писал, скольких моментов там стоит придерживаться. И там не один.
      Возможно, со временем привыкаешь, но по началу сложно что-нибудь не забыть.

      Тут примерно полная аналогия с таблицами в реляционных базах данных — там нельзя создать вложенный объект в таблице и для этого нужно создавать отдельную таблицу.
      Тут более к месту было бы сказать, что в объекте создается ссылка на другой объект, а не реальный объект. Основы программирования на примере баз данных объяснять как-то странновато)


      1. 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. Весь объект лучше не передавать в пропсы ради оптимизации, можно попробовать развить идею контекстов.
        function HelloView(_, store) {
           return <div>
                <input
                    value={store.message}
                    onInput={({target}) => { store.message = target.value }}
                />
                <br/>{store.message}
            </div>
        }
        HelloView.deps = [Store]
        
        Обертка createElement по deps найдет Store, проинжектит в контекст. Идея в том, что если вам надо прокидывать до HelloView сторы транзитом сквозь кучу компонент, просто перенесите его в контекст. Т.е. контексты в приоритете над пропсами. Если надо переопеделить Store, то можно использовать что-то вроде:
        const ClonedHelloView = clone(HelloView, [
          [Store, MyStore]
        ])
        

        actions в MobX — нужен из-за оптимизации синхронной природы обновлений, в решениях с асинхронными обновлениями можно и без них. Однако actions позволяет лучше генерировать лог изменений стейта, когда понятно кто изменил состояние, это важнее. Также undo/redo можно построить на них.
        Про наблюдаемый объект parentObj.level1Obj = observable({level2Obj: {propA: 10}}); вам ответили, что не обязательно в данном случае заворачивать в observable.

        Для демонстрации работы с контекстами и обработки ошибок приведу пример на reactive-di.