ReCA: React Clean Architecture state manager -2


Что будет если объединить Функциональное Программирование и Объектно-Ориентированное Программирование в одном веб приложении? Получится мощный инструмент для написания веб приложений объединяющий всю простоту написания верстки в функциональном стиле и мощь ООП для написания бизнес логики сложного приложения! А произвести такое объединение позволяет библиотека ReCA. Которая позволяет использовать в одном приложении оба подхода при это разделяя зоны ответственности и не создавая конфликтов стилей, а также решающая множество повседневных задач.

Данный подход является логическим продолжением подхода Чистой Архитектуры описанной в прошлой статье. С тех пор в Реакте появились функциональные компоненты и хуки, которые сильно упрощают верстку приложений, поэтому тот подход надо было подтянуть до современных реалий. Так и родилась библиотека Reca.

Кроме того, если та статья была ориентирована на продвинутых разработчиков и чтение толстой книжки, то эта статья ориентирована на самых начинающих разработчиков. Ведь ReCA сильно упрощает применение Чистой Архитектуры избавляя от чтения сложных книг и позволяет писать приложение сразу после прочтения README.md. В связи с этим, я надеюсь, мне простят некоторые допущение сделанные для большей доступности статьи.

Преимущества

При создании ReCA в него были заложены следующие идеи:

Микросторы - вместо создание одного большого сложного стора, вы создаете множество маленьких независимых сторов. Такие сторы гораздо проще разрабатывать и поддерживать. А с ростом их количества приложение не начинает тормозить, как это происходит с моносторами.

Dependency Injection - из коробки поставляется механизм внедрения зависимостей, который позволяет переопределить часть функционала для определенного теста, заказчика, платформы. Например вы можете использовать один и тот же функционал стора на веб странице и в мобильном приложении на реакт нейтив, при этом использовать в них разные сервисы уведомления пользователей.

Микрофронтенды - ReCA создавалась для работы во встраиваемых микрофронтендах. Микросторы обеспечивают равномерное распределение сторов по чанкам. Для работы ReCA не используются провайдеры, по этому на одной странице может быть множество React приложений.

Прямой вызов функций - вместо медленного поиска по всем функциям в редюсоре используется быстрый прямой вызов функции, что обеспечивает скорость работы ReCA. Так же обновление микростора почти не занимает время, в отличии от обновлений моностора.

Нет бойлерплейта - совсем нет. Просто пишите логику на обычном typescript.

Простой флоу данных - Не надо разбираться в сложных цепочках экшенов. Вся логика размещена в 4 очевидных слоях с быстрыми переходами по горячей клавише в ИДЕ.

Очень маленький размер - менее 1 кб в минифицированном коде. Кроме того он еще и легко расширяется.

В целом библиотека ReCA очень похожа на библиотеку MobX, но имеет ряд улучшений позволяющие использовать подход ООП гораздо более лучше.

Простой пример

Пользоваться ReCA очень просто, достаточно взглянуть на пример:

// todo.store.ts
import {AutoStore} from "reca";
import type {FormEvent} from "react";

export class ToDoStore extends AutoStore {

    public currentTodo: string = "";

    public todos: string[] = [];

    public handleAddTodo (): void {
        this.todos.push(this.currentTodo);
    }

    public handleDeleteTodo (index: number): void {
        this.todos.splice(index, 1);
    }

    public handleCurrentEdit (event: FormEvent<HTMLInputElement>): void {
        this.currentTodo = event.currentTarget.value;
    }

}


// todo.component.ts
import {useStore} from "reca";
import {ToDoStore} from "../stores/todo.store";

export const ToDoComponent = (): JSX.Element => {
    const store = useStore(ToDoStore);

    return (
        <div className="todos">
            <div className="todos-list">
                {
                    store.todos.map((todo, index) => (
                        <div className="todo">
                            {todo}

                            <button
                                className="todo-delete"
                                onClick={() => store.handleDeleteTodo(index)}
                                type="button"
                            >
                                X
                            </button>
                        </div>
                    ))
                }
            </div>

            <div className="todos-input">
                <input
                    onInput={store.handleCurrentEdit}
                    value={store.currentTodo}
                />

                <button
                    onClick={store.handleAddTodo}
                    type="button"
                >
                    add
                </button>
            </div>
        </div>
    );
};

Достаточно создать ваш класс отнаследовав его от объекта AutoStore библиотеки ReCA и описать там всю логику. В функциональном компоненте достаточно использовать хук useStore из той же библиотеки. И все. Никакого бойлерплейта. Только логика.

Обратите внимание что методы объекта можно передавать в события элементов как через стрелочную функцию, так и напрямую по ссылке. ReCA сама позаботиться о сохранении контента.

Кроме того в одном функциональном компоненте может быть множество сторов, а также любых других хуков, контентов и других компонентов. ReCA не только не конфликтует с ними, но и отлично интегрируется для совместной работы.

Тут есть важное отличие от сторов MobX и контекстов React. В данном случае у каждого компонента будет свой экземпляр стора. Один компонент ничего не знает об изменениях в другом компоненте использующий тот же стор. Изменения стора компонента затрагивает перерисовку только того компонента, в котором он используется. ReCA не предоставляет инструментов глобального стора т.к. ориентирован на микрофронтенды, но в тоже время и не ограничивает вас в выборе глобального стора. Это может быть EventBus, Observer, React Context или любой другой. Так же ниже будет пример переиспользуемых состояний с использованием Сервисов.

Как происходит обновление стейта?

Магия на самом деле очень проста. При создании стора происходит его трансформация. Все свойства и методы подменяются на специальные функции. Стор превращается в нечто похожее на Observable. В результате ReCA знает когда вы меняете какое либо свойство стора, вызываете метод стора, или заканчивает отрабатывать асинхронный метод стора. В любом из этих случаев запускается процесс перерисовки.

Но это еще не все. Процесс перерисовки запускается не сразу, а с задержкой на requestAnimationFrame, для синхронизации с циклом обновления монитора. В результате такой оптимизации даже если будет вызвано десятки методов и заменено десятки свойств стора, перерисовка реакта произойдет только одна, которую уже и увидит пользователь. Что очень хорошо влияет на производительность даже на самых слабых устройствах.

Пример для энтерпрайза

А теперь давайте посмотрим пример созданный по заветам Чистой Архитектуры:

// SpaceXCompanyInfo.ts
export class SpaceXCompanyInfo {

    public name: string = "";

    public founder: string = "";

    public employees: number = 0;

    public applyData (json: object): this {
        Object.assign(this, json);
        return this;
    }

}


// SpaceXService.ts
import {reflection} from "first-di";
import {SpaceXCompanyInfo} from "../models/SpaceXCompanyInfo";

@reflection
export class SpaceXService {

    public async getCompanyInfo (): Promise<SpaceXCompanyInfo> {
        const response = await fetch("https://api.spacexdata.com/v3/info");
        const json: unknown = await response.json();

        // ... and manies manies lines of logics

        if (typeof json === "object" && json !== null) {
            return new SpaceXCompanyInfo().applyData(json);
        }
        throw new Error("SpaceXService.getCompanyInfo: response object is not json");
    }

}


// SpaceXStore.ts
import {reflection} from "first-di";
import {AutoStore} from "reca";
import {SpaceXCompanyInfo} from "../models/SpaceXCompanyInfo.js";
import {SpaceXService} from "../services/SpaceXService.js";

@reflection
export class SpaceXStore extends AutoStore {

    public companyInfo: SpaceXCompanyInfo = new SpaceXCompanyInfo();

    public constructor (
        private readonly spaceXService: SpaceXService,
        // private readonly logger: Logger
    ) {
        super();
    }

    public activate (): void {
        this.fetchCompanyInfo();
    }

    private async fetchCompanyInfo (): Promise<void> {
        try {
            this.companyInfo = await this.spaceXService.getCompanyInfo();
        } catch (error) {
            // Process exceptions, ex: this.logger.error(error.message);
        }
    }

}


// SpaceXComponent.tsx
import {useStore} from "reca";
import {SpaceXStore} from "../stores/SpaceXStore.js";

export const TestStoreComponent = (): JSX.Element => {
    const store = useStore(SpaceXStore);

    return (
        <div>
            <p>
                Company:
                {" "}

                {store.companyInfo.name}
            </p>

            <p>
                Founder:
                {" "}

                {store.companyInfo.founder}
            </p>
        </div>
    );
};

В данном примере мы создали дополнительный класс SpaceXService куда вынесли логику пере используемую в нескольких сторах. В данном случае это получение данных из API компании SpaceX. И приведение этих данных к классу.

Спойлер для тех кто хочет правильнее

На самом деле так json к классу лучше не приводить, а для таких операций использовать библиотеку TS-Serializable которой я писал ранее. Данный пример упрощен для наглядности.

Кроме того данные в сервисах лучше не запрашивать, а сам запрос вынести в слой Repository согласно заветам Чистой Архитектуры. А в слое Service оставить только логику.

Далее сервис необходимо сделать доступным в сторе. Для этого просто определяем его как свойство в конструкторе стора, а на сам стор вешаем декоратор @reflection. Декоратор заставит typescript сгенерировать метаинформацию благодаря которой механизм Dependency Injection поймет какого типа зависимости используется в конструкторе и встроит их туда.

Тут важно понимать что на всех экзмеплярах всех сторов будет встроен один и тот же экземпляр сервиса, т.е. как Singleton. В качестве механизма встраивания используется библиотека First DI о которой я недавно писал. Так же в ReCA заложен функционал по опциональной смене DI библиотеки на аналогичную по функциональности.

Как правильно написать Сервис

Сервисы организуются по методологии SOLID

Обратите внимание что при использовании асинхронных запросов в Сторах и Сервисах нету необходимости в сторонних библиотеках вроде Thunk, Saga и подобных. Используется простой, красивый и стандартный синтаксис async / await.

Так же Синглтон Сервисы можно использовать для организации состояний которые шарятся между разными сторами и использовать их вместо глобальных сторов в т.ч. для микрофронтендов. Для этого достаточно разместить в Сервисе EventBus или Observer.

Методы жизненного цикла

Методы жизненного цикла очень похожи на те что есть в классовых компонентах React.

  • constructor - вызывается при создании объекта. В нем рекомендуется инициализировать свойства стора, но не делать никаких асинхронных запросов, т.к. асинхронные запросы плохо влияют на скорость первой отрисовки в React. В классовых компонентах имеет аналог constructor, в функциональных имеет аналог хук useState.

  • activate - вызывается после первой отрисовки вьюхи. В этом методе рекомендуется запускать асинхронные запросы, в т.ч. для данных с сервера, а так же навешивать логику на ref элементы если это необходимо. В классовых компонентах имеет аналог componentDidMount, в функциональных имеет аналог хук useEffect(() => store.activate(), []).

  • update - вызывается при перерисовке компонента. В этом методе можно обновить состояние компонента если в этом есть необходимость. В классовых компонентах имеет аналог shouldCompoonentUpdate, в функциональных не имеет прямого аналога.

  • afterUpdate - вызывается после перерисовке компонента. От activate отличается тем что activate вызывается только один первый раз отрисовки компонента, а afterUpdate второй и последующие разы. В классовых компонентах прямого аналога нет, в функциональных имеет аналог useEffect(() => store.afterUpdate())

  • dispose - вызывается при удалении компонента. Туда рекомендуется добавлять код который зачищает логику от своего присутствия. Например отписку от html элементов, удаление внешних скриптов завязанных на компонент.

Так же хук useStore принимает второй параметр, который называется props. Этот параметр передается во все методы жизненного цикла в качестве параметра.

Особенности АвтоСтора

Иногда есть необходимость не перерисовывать компонент при изменении некоторых свойств или вызовов некоторых методов. Для этого на это свойство или метод необходимо накинуть декоратор @notRedraw:

import {AutoStore, notRedraw, reflection} from "reca";

@reflection
export class SemiAuto extends AutoStore {

    public refElement: HTMLDivElement | null = null;

    public activate(): void {
        window.addEventListener("mousemove", this.onMouseMove);
    }

    public dispose(): void {
        window.removeEventListener("mousemove", this.onMouseMove);
    }

    @notRedraw()
    private onMouseMove(event: MouseEvent): void {
        // логика обработки события мыши
		    if (this.refElement) {
			      this.refElement.style.top = ...
		    }
    }
}

Таким образом не будет происходить лишних React перерисовок на каждое шевеление мышки.

Итог

Получился инструмент который использует лучшие стороны Функциональной и ООП парадигмы разработки. Сторонники обоих парадигм могут одновременно работать на одном проекте. Функциональные разработчике в области верстки и простой логики, а ООП в области сложной логики.

Данный подход не залочен на библиотеку React и может быть распространён на любой другой. Так же библиотека легко расширяется для интеграции с вашими особенностями логики.

Если вы еще пишите на Angular, вы без проблем сможете перенести свою логику на связку React + Reca. Сохранив большую часть вашей логики, фактически меняется только слой view.

Описанные здесь фичи далеко не предел возможностей ReCA, очень много функционала осталась за пределом этой статьи. А еще больше идей по упрощению бытовых операций будет реализовано в будущих версиях по мере необходимости.

Понравился подход? Помогите распространить материал. Звезды на гитхабе также помогут сделать его более заметным.

Спасибо за внимание! =)




Комментарии (23):

  1. md_backend_binance
    /#24608244

    vue state manager:

    import { reactive } from 'vue';

    const state = reactive({

        jobs: [],

    });

    const actions = {

        addJob(job) {

            state.jobs.push(job);

        },

    };

    export default {

        state: state,

        ...actions,

    };

    • Pab10
      /#24608286

      Дружище, ты ошибся топиком.

      • md_backend_binance
        /#24608326

        Поверь, я попал очень больно. И Clear и Architecture и state maanger и главное чистота и выразительность

    • LabEG
      /#24608314

      А наследование работает? Можно сделать второй компонент отнаследовав от первого и добавив логики? )

      А хуки есть?) С хуками например очень просто делать такие вещи:

      export const Product = (): JSX.Element => {
          const store = useStore(SomeStore);
          const {isSmallScreen} = useScreenSize();
      
          return (
              <Swiper
                  slidesPerView={isSmallScreen ? "auto" : 2}
                  spaceBetween={isSmallScreen ? "20" : "40"}
                  updateOnWindowResize
              >
                  ...
              </Swiper>
          )
      };

      • nin-jin
        /#24608446 / -2

        Зачем хуки, если можно просто использовать классы?

        export class Product extends View {
            
            @mem store() { return new SomeStore }
            isSmallScreen() { return ScreenSize.isSmall() }
            slidesPerView() { return this.isSmallScreen() ? "auto" : 2 }
            spaceBetween() { return this.isSmallScreen() ? "20" : "40" }
            
            render() {
                return (
                    <Swiper
                        slidesPerView={ this.slidesPerView }
                        spaceBetween={ this.isSmallScreen }
                    >
                        ...
                    </Swiper>
                )
            }
            
        }

        • LabEG
          /#24608510 / -3

          До перехода на функциональные компоненты так и делали. Но сейчас все же используем хуки. Причины:

          • На лендингах как правило нету логики и классовый компонент излишне сложные для джунов, поэтому функциональный компонент и хук полностью решают проблемы,

          • Про хуки знает каждый джун разработчик, они подробно описаны в документации,

          • В хуке скрыта логика перерисовки компонента при ресайзе окна и ее легко реюзать в компонентах, не нужно наследование и композиция.

          В итоге сильно понижаем порог входа не жертвую качеством.

          На вопрос ниже отвечу вечером как буду посвободнее.

          • nin-jin
            /#24609460 / +1

            • Эмуляция классов в принципе не может быть проще использования нативных средств выражения тех же абстракций.

            • А про классы он не знает и в документации они не описаны?

            • Хуки - это и есть композиция.

  2. nin-jin
    /#24608360 / +2

    Выглядит интересно. Можете классифицировать своё решение по этим 12 апектам?

    К ФП всё это не имеет никакого отношения всё же.

    • LabEG
      /#24610846

      Я сыграл в твою игру по твоим правилам. К сожалению новый редактор хабры не может в таблицы, поэтому так.

      Style
      ????✅

      Watch
      ????❌ Вызываются не все подписчики

      Dupes
      ????‍❌ Equality перекладывается на React

      Origin
      ????✅

      Tonus
      ????❌

      Order
      ????❌

      Flow
      ????✅

      Error
      ????❌ Обработка ошибок на стороне пользователя и фреймворка

      Cycle
      ????✅ Сторы не могут попасть в цикл, ибо изолированы, в крайнем случае словится лимит стека

      Depth
      ????✅

      Atomic
      ????❌

      Extern
      ????✅

      • LabEG
        /#24610874

        Но в этой либо не все вписывается в идеологию той статьи. В частности пункты Watch Dupes написаны для настоящей реактивности, а не то что в реакте. ReCA работает на событиях от пользователя, но он не рассылает изменения подписчикам, а использует механизм отрисовки реакта. Dupes аналогично, сама ReCA не занимается сравнением состояния, этим так же занимается механизм отрисовки реакта.

        В целом в той статье упор идет на Mono Reaction Runtime. В ReCA же используются микро реакции с микро сторами, фактически каждый компонент является независимым микро приложением, за счет чего идеально подходит для микро фронтендов.

        • nin-jin
          /#24611142

          А, все сторы только в хуках компонента живут? Это ж очень не эффективно. Если будет стор, данные из которого нужны многим компонентам в глубине, то это будет приводить к ререндеру корневого компонента с последующим ререндером всех промежуточных из-за прокидывания данных через пропсы.

          • LabEG
            /#24611270

            Это эффективно поскольку данные для мертвых компонентов не потребляют память, а следовательно и электричество. Нету компонента, нету стора, нету потребления памяти.

            Случаи с общими данными между сторами решаются несколькими приемами:

            • Очень часто разработчики в принципе создают лишнюю связанность в коде. Это проблема не стейт менеджера, а организации кода. И характерная как голым классовым так и голым функциональным компонентам. Тут приходиться рассказывать по разделение ответственности компонентов.

            • Код организуется согласно концепции атомарного дизайна, который решает проблему как слишком крупных, так и слишком мелких компонентов.

            • После чего остается очень мало связанных компонентов и данных, вот для них уже используются сервисы, евент басы, обсерверы, контексты или что то свое. Лично я предпочитаю евент бас, как самое практичное решение.

            Соответственно данные по дереву прокидываются только в рамках родитель-ребенок и к лишним перерисовкам не приводит.

            • nin-jin
              /#24611494

              Как будет выглядеть типовая задача: есть компонент формы, а есть независимый от неё компонент превьюхи. При изменении формы должна обновляться и превьюха.

              • LabEG
                /#24614668

                Задача совсем не типовая, особенно с учетом что в 2022 году редактор и превью объединены. Хабр тому пример.

                Но да ладно, два примера решения задачи.

                1. Они имеют связь родитель-ребенок:

                export const ArticleEditPage = (): JSX.Element => {
                    const store = useStore(ArticleStore);
                
                    return (
                        <div>
                            ...
                            <WYSIWYGEditor onChange={store.handleArticleChange} />
                            <WYSIWYGPreview article={store.article} />
                            ...
                            <Button onClick={store.handleSaveArticle}>
                                Сохранить
                            </Button>
                        </div>
                    );
                }

                2. Компоненты расположены очень далеко:

                // Компонент редактор
                export class WYSIWYGEditorStore extends AutoStore {
                
                    public constructor (
                        private readonly eventBusService: EventBusService
                    ) {
                        super();
                    }
                
                    public handleArticleChange(state: ArticleState): void {
                        this.eventBusService.dispatch(BusEvent.ARTICLE_CHANGE, state);
                    }
                }
                
                // Компонент превью
                export class WYSIWYGPreviewStore extends AutoStore {
                
                    public constructor (
                        private readonly eventBusService: EventBusService
                    ) {
                        super();
                    }
                
                    public activate (): void {
                        this.eventBusService.addEventListener(BusEvent.ARTICLE_CHANGE, this.processArticleState);
                    }
                
                    public dispose (): void {
                        this.eventBusService.removeEventListener(BusEvent.ARTICLE_CHANGE, this.processArticleState);
                    }
                
                    private processArticleState (state: ArticleState): void {
                        // apply new state
                    }
                
                }

                Вместо EventBus можно использовать что угодно Observer, RxJS, MobX, React Context, Redux... но почему конкретно мне нравится именно EventBus.

                Преимущества:

                1. Позволяет обмениваться состояниями между разными React приложениями на одной странице, с разными рутами, без провайдеров. Актуально для микрофронтов.

                2. Позволяет обменивать состояниями даже между разными вкладками браузера. Редактируешь на одной вкладке в админке, на второй смотришь как выглядет в продакшене.

                3. Обменивание состоянием не перерисовывает все дерево как в случаях с Контекстом.

                4. Умершие компоненты перестают потреблять память на состояния.

                5. Паттерн в целом хорошо себя показал на бекенде на микросервисах. Тут по аналогии каждый компонент это независимое приложение микрофронт и общаются они также.

      • nin-jin
        /#24611114

        ????‍❌ Equality перекладывается на React

        Тогда это: ????Identity

        ????❌ Instant

        А что же тогда откладывается на rAF?

        ????✅ Async

        А как прекращается отслеживание зависимостей перед await и возобновляется после?

        • LabEG
          /#24611246

          Во всех случаях нет. Поскольку изменения пересчитываются мгновенно, а с задержкой только перерисовка. Это примерно как в компьютерных играх, видеокарта может считать 200 кадров в секунду, но ограничивают только на 60 (по частоте монитора), что бы она лишнее электричество не потребляла. В ReCA примерно тот же подход.

          ReCA вообще не следит за зависимостями, там основная идея в том что изменения может произойти только от внешнего воздействия, например действие пользователя, события, таймера. Вот они побуждают обновления состояния и последующего перерисовку. Поэтому ReCA отлавливает эти события, а не сами данные.

          • nin-jin
            /#24611278

            Похоже на дефолтный ченж детекшен в ангуляре. И типичная оптимизация там - выпиливание его.

            • LabEG
              /#24611284

              Ничего общего с ченж детектором. Все гораздо проще и быстрее.

                • LabEG
                  /#24614600

                  Разве только то что они оба ловят события, но они ловят разные события и обрабатывают их совершенно по разному.

                  ReCA не использует аналог Zone.JS и манкипатчинга, не использует обработку состояний, не использует глобальный Reaction Runtime.

                  Вместо этого ловится события вызова логики стора, все сторы независимы, происходит только локальное обновление компонента.

  3. VasiliyShiryaev
    /#24608544 / +1

    Или я чего-то не понимаю, или кто-то изобрёл mobx?

    • LabEG
      /#24608602

      Подход действительно похож и является скорее логическим развитием чем аналогом. И тем кто работал на MobX будет проще всего переключиться на Reca. MobX так же имеет зачатки так называемой чистой архитектуры, но он почему то остановился на сторах, и не стал идти дальше.

      В частности он не отвечает на вопрос "А где хранить пере используемую в сторах логику?" и имеет проблему с жизненным циклом. В Reca все эти проблемы решены.

      А именно:

      • Встроен DI, теперь переиспользуемую логику можно выносить в сервисы, фронтам может быть знакомо по Angular и NestJS,

      • Сильно упрощен механизм инъекции стора в компоненты,

      • Лишен проблем с жизненным циклом,

      • Улучшенный механизм перерисовки, что хорошо сказывается на производительности на слабых устройствах,

      • Легко кастомизируется, можно легко создать свою реализацию стора отнаследовав от базового стора.

      • JustDont
        /#24608662 / +3

        MobX остановился на реактивности, а не на сторах. Сторы — это mobx-react, а не mobx.
        А остановился он там потому, что гораздо лучше делать одну вещь хорошо, чем 20 вещей средненько.


        • DI может быть нужен, а может быть и совершенно излишен. Реактовский context вполне справляется с проектами даже энтерпрайзово-среднего размера (десятки kLOC).
        • Что может быть проще "как угодно"? Именно так сторы MobX внедряются в компоненты.
        • ЖЦ сторов совсем не обязан как-то совпадать с ЖЦ компонентов, и MobX это позволяет реализовать без лишних движений. Равно как и позволяет обратное — просто создавай сторы из кода компонент, и вот уже ЖЦ совмещен.