Здравствуйте :)

Коротко о чем тут, чтобы вы могли понять нужно ли оно вам или нет.

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

Материал может быть полезен как для новичков, так и для более опытных.

Примеры будут на React и Effector, но это не важно, потому что тут важна идея, а не реализация. К тому же это вездебудет примерно одинаково выглядеть.В конце будут так же ссылки на примеры с svelte + effector и react + redux thunk

Перед тем как это всё начать писать, я изучил похожие подходы и да, они есть.Есть FLUX (там еще Dispatcher), MVI, может еще что-то.

Да, я опять не открыл Америку, но попытаюсь понятно объяснить свой подход и описать его плюсы.

И да, весь код дальше считайте псевдокодом, там могут быть ошибки, я писал его сюда сразу.


А теперь к сути. В чем идея?

Я предлагаю организовать весь поток данных не относящийся к UI таким образом:

  • UI - подписывается на изменения Model и рендерит их.

  • UI - вызывает Action.

  • Model - подписывается на Action.

Что это значит?

  • UI только рендерит данные и вызывает какие-то экшены.

  • Model сама себя обновляет в зависимости от того какой экшен был вызван.

Вариант реализации

Давайте представим такое простое приложение.

Допустим у нас есть:

  1. Форма создания новой задачи

  2. Список задач

Тогда нам нужно иметь, допустим, 3 поля:

  1. Состояние добавления новой задачи (Boolean)

  2. Состояние загрузки задач (Boolean)

  3. Список задач (Array<Todo>)

Так же нам нужны будут 2 экшена:

  1. Создать новую задачу (createTodo)

  2. Получить список всех задач (getTodos)

И тут начинается самое интересное.

Давайте создадим эти Action-ы.

// /action/todo/createTodo.ts
export const craeteTodo = function (title: string): Promise<Todo> {
    return fetch(`/api/v1/todo`, { method: 'POST', body: title })
        .then((response) => response.json());
};
// /action/todo/getTodos.ts
export const getTodos = function (): Promise<Array<Todo>> {
    return fetch(`/api/v1/todo`, { method: 'GET' })
        .then((response) => response.json());

Отлично. Как вы видите это просто обычные функции, всё просто.

Теперь давайте создадим Model.

// /model/todo/todo.model.ts

/* 
 * Для того чтобы связать action-ы с нашими сторами 
 * мы будем использовать createEffect из effector. 
 * Все сигнатуры фунций останутся, но теперь мы можем подписаться на них
 */
export const createTodoEffect = createEffect(craeteTodo);
export const getTodosEffect   = createEffect(getTodos);


/*
 * todoLoading - состояние загрузки списка задач
 * Что тут происходит?
 * Мы подписываемся на эффекты которые только что создали и:
 * Когда мы вызовем getTotosEffect - состояние изменится на true
 * Когда getTodosEffect выполнится - состояние поменяется на false
 * 
 * Таким образом можно подписываться на множество разных экшенов 
 * или на один и тот же, но использовать разные состояния 
 * (done, fail, finally, ...)
 */
export const todoLoading = createStore<boolean>(false)
    .on(getTodosEffect, () => true) // Подписываемся на начало выполнения
    .on(getTodosEffect.finally, () => false); // Подписываемся на окончание выполнения

/*
 * todoAdding - состояние добавления новой задачи
 * Логика работы такая же
 */
export const todoAdding = createStore<boolean>(false)
    .on(addTodoEffect, () => true)
    .on(addTodoEffect.finally, () => false);

/*
 * todoItems - список задач
 * Логика работы такая же, но тут мы уже работаем с состоянием успешного завершения.
 * В payload.result будет храниться результат вернувшийся из нашего action-а
 * который просто просто разворачиваем в наш список
 */
export const todoItems = createStore<Array<Todo>>([])
    .on(addTodoEffect.done, (state, payload) => [ ...state, payload.result ])
    .on(getTodosEffect.done, (state, payload) => [ ...state, ...payload.result ]);

А теперь давайте напишем простенький UI.

Нам нужны будут 2 компонента. (да, можно разбить на кучу разных, но тут это не важно, по этому опускаем)

  1. Форма добавления новой задачи

  2. Список задач

Давайте создадим форму добавления новой задачи

// /ui/widget/todo/AddTodoForm.tsx

import { FC, memo } from 'react';
import { useUnit } from 'effector-react';
import { todoAdding } from '@/model/todo/todo.model';


export const AddTodoForm: FC = memo(function AddTodoForm () {
    // Для начала получим состояние добавления задачи с помощью useUnit
    const adding = useUnit(todoAdding);

    // Так же создам ref для хранения ссылки на input для получения value
    const inputRef = useRef<HTMLInputElement | null>(null);

    // Ну и функцию которая сработает при отправке формы
    const onSubmit = function (event: FormEvent) {
        event.preventDefault();

        // Проверяем что есть инпут, значение, и новая задача не создается в данный момент
        if (input.current && input.current.value && !adding) {
            // И просто вызываем наш эффект как экшен.
            addTodoEffect(input.current.value)
                .then(() => {
                    if (input.current) {
                        input.current.value = '';
                    }
                });
        }
    };

    return (
        <form onSubmit={ onSubmit }>
            <input ref={ inputRef } disabled={ adding }/>
            <button type="submit" disabled={ adding }>Создать</button>
        </form>
    );
});

Давайте разберем этот компонент и его поведение.

Изначально он рендерится и, предположим, что состояние todoAdding будет false. Тогда элементы формы не будет задизейблены и мы сможем ввести что хотим и создать задачу.

  1. Мы вводим в input новую задачу и отправляем форму.

  2. При отправке формы вызывается addTodoEffect.

  3. В модели по подписке на addTodoEffect значение todoAdding изменится на true

  4. Наш компонент начнет перерендер с новым значением todoAdding и элементы формы заблокируются.

  5. После завершения создания новой задачи, по подписке на addTodoEffect.finally значение todoAdding поменяется на false

  6. Ререндер со значением todoAdding - false, форма опять доступна.

Вернемся к тому что я писал в начале.

  • UI - подписывается на изменения Model и рендерит их.

  • UI - вызывает Action.

  • Model - подписывается на Action.

Как вы видите всё очень легко и просто (надеюсь).

Теперь давайте, точно так же создадим второй компонент, для отображения списка задач.

// /ui/widget/todo/TodoList.tsx

import { FC, memo } from 'react';
import { useUnit } from 'effector-react';
import { todoAdding } from '@/model/todo/todo.model';


export const TodoList: FC = memo(function TodoList () {
    // Получим состояние загрузки и список задач
    const [ loading, items ] = useUnit([ todoLoading, todoItems ]);

    // Давайте если у нас загрузка (todoLoading === true) - покажем лоадер
    if (loading) {
        return <Loader/>;
    }

    // Если задач нет
    if (items.length === 0) {
        return 'Задач нет';
    }

    return (
        <section>
            <h1>Список задач</h1>
            {
                // Просто рендерим список задач из нашего стора
                items.map((item) => (
                    <article key={ item.id }>
                        <h2>{ item.title }</h2>
                    </article>
                ))
            }
        </section>
    );
});

Отлично, теперь у нас так же есть компонент который просто рендерит список задач.

Можно было бы внутрь него добавить

useEffect(() => {
    getTodosEffect();
}, []);

и всё бы отлично работало, но мы вызовем это совсем в другом месте.

Давайте создадим еще один root компонент для того, чтобы показать на сколько это всё классно работает

import { FC, memo } from 'react';
import { useUnit } from 'effector-react';
import { todoAdding, getTodosEffect } from '@/model/todo/todo.model';


export const TodosApp: FC = memo(function TodosApp (props) {
    const loading = useUnit(todoLoading);

    return (
        <div>
            <AddTodoForm/>
            <TodoList/>
            { /* С помощью этой кнопки будем загружать задачи */ }
            <button onClick={ getTodosEffect } disabled={ loading }>
                Загрузить список
            </button>
        </div>
    );
});

Теперь давайте представим как этот компонент будет выглядеть при инициализации приложения, а следовательно, допустим,первом рендере.

  • Сверху будет поле ввода и кнопка создания (форма создания новой задачи)

  • Дальше текст "Задач нет"

  • Дальше кнопка "Загрузить список"

Теперь давайте подумаем что будет если мы нажмем на кнопку "Загрузить список":

  1. Выполняется эффект getTodosEffect

  2. По подписке на этот эффект todoLoading переходит в true

  3. В <TodoList/> появляется <Loader/>

  4. Кнопка "Загрузить список" блокируется

  5. Отправляется запрос на сервер

  6. Приходит ответ с сервера с задачами

  7. Экшен завершается успешно

  8. По подписке на getTodosEffect.finally - todoLoading переходит обратно в false

  9. По подписке на getTodosEffect.done - todoItems в конец себя вставляет загруженные задачи

  10. Компонент <TodoList/> рендерит список

  11. Кнопка "Загрузить список" больше не блокируется

Мы из UI не меняем никаких параметров, ничего вообще. Мы только рендерим данные из модели и вызываем экшены.

В итоге мы имеем:

  • Множество разных Action-ов, которые просто выполняют какие-то свои задачи. Мы можем их даже из проекта в проект перетаскивать. Хоть он будет на svelte + effector хоть на react + redux.

  • Model которая хранит данные и в зависимости от выполняемых действия меняет свое состояние.

  • UI который просто рендерит данные и выполняет экшены.

Какие у этого подхода есть плюсы?

  1. Все изменения стора контролируются его подписками на эффекты. То есть мы не можем никак просто поменять стор как хотим из UI.

  2. Понятный и простой поток данных во всем приложении.

Минусы? Пока не обнаружены.

В целом, можно и многие состояния UI так же хранить в таких же сторах и изменять их через другие action-ы, но я так еще не делал и не знаю на сколько это будет удобно и вообще нужно. Но, как вариант, иногда, некоторые, можно. Представить случаи такие могу.

Какую структуру папок вы выберете - не важно.

Я делаю примерно так:

  • /src

    • /ui

      • /shared

      • /entity

      • ....

    • /action

    • /model

    • ...

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

Инструменты

В качестве UI - тут много что подойдет. Очевидные варианты React, Svelte. К сожалению насчет других не знаю, но думаю везде будет +- одно и тоже.

В качестве Model - тут из того что я пробовал и в чем уверен - Redux, Effector. В zustand вроде таких подписок нет.. В mobx тоже.. Но это не значит, что этот подход на них не реализовать..

Ну а для экшенов используйте что хотите, это просто javascript


Так же перед тем как это всё написать - я это тестировал и получилось несколько репозиториев. Кому интересно посмотреть больше примеров - пожалуйста, ссылки ниже.


Маленькие одинаковые todo


Что-то типа социальной сети

Именно когда я делал этот проект - я дошел до этого подхода и он не весь выполнен в таком стиле.Я его переделывал, но лишь частично. Но вы все равно можете посмотреть как это можно сделать на Redux через Thunk-и.

В этом проекте многое переделывалось на extraReducers и Thunk-и, но выделять экшены я не стал, они прям внутри thunk-ов. Как я понимаю, сигнатура сохраняется как и в effector, по этому с thunk-ами будет работать тоже удобно.

React + Redux Thunk

Модели лежат тут: /src/app/redux/slices/[name]/slice

Thunk(Action) лежат тут: /src/app/redux/slices/[name]/thunk

Вот пример модели на redux и thunk-ах.

const initialState: AuthSchema = {
    isPending: false,
    error    : null,
    user     : null,
};

export const authSlice = createSlice({
    name         : 'auth',
    initialState : initialState,
    reducers     : {},
    extraReducers: (builder) => {
        // authByUsername
        builder.addCase(authByUsername.fulfilled, (state, action) => {
            state.isPending = false;
            state.error     = null;
            state.user      = action.payload ?? null;
        });
        builder.addCase(authByUsername.pending, (state) => {
            state.isPending = true;
            state.error     = null;
            state.user      = null;
        });
        builder.addCase(authByUsername.rejected, (state, action) => {
            state.isPending = false;
            state.error     = action.payload;
            state.user      = null;
        });

        // authByTokens
        builder.addCase(authByTokens.fulfilled, (state, action) => {
            state.isPending = false;
            state.error     = null;
            state.user      = action.payload ?? null;
        });
        builder.addCase(authByTokens.pending, (state) => {
            state.isPending = true;
            state.error     = null;
            state.user      = null;
        });
        builder.addCase(authByTokens.rejected, (state, action) => {
            state.isPending = false;
            state.error     = action.payload;
            state.user      = null;
        });

        // logout
        builder.addCase(logout.fulfilled, (state) => {
            state.isPending = false;
            state.error     = null;
            state.user      = null;
        });
    },
});

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

Svelte + Effector

Вот пример модели аутентификации оттуда:

export const loginEffect        = createEffect(loginAction);
export const registrationEffect = createEffect(registrationAction);
export const logoutEffect       = createEffect(logoutAction);
export const refreshEffect      = createEffect(refreshAuthAction);

export const authPending = createStore<boolean>(false)
    .on(loginEffect, () => true)
    .on(registrationEffect, () => true)
    .on(logoutEffect, () => true)
    .on(refreshEffect, () => true)
    .on(loginEffect.finally, () => false)
    .on(registrationEffect.finally, () => false)
    .on(logoutEffect.finally, () => false)
    .on(refreshEffect.finally, () => false);

export const authError = createStore<DomainServiceResponseError | null>(null)
    .on(loginEffect.fail, (_, payload) => returnValidErrors(payload.error))
    .on(registrationEffect.fail, (_, payload) => returnValidErrors(payload.error))
    .on(refreshEffect.fail, (_, payload) => returnValidErrors(payload.error));

export const authData = createStore<DomainUser | null>(null)
    .on(loginEffect, () => null)
    .on(loginEffect.done, (_, payload) => payload.result ?? null)
    .on(registrationEffect, () => null)
    .on(registrationEffect.done, (_, payload) => payload.result ?? null)
    .on(logoutEffect.finally, () => null)
    .on(refreshEffect.done, (_, payload) => payload.result ?? null);

Так же буду рад вопросам, критике, дополнениям итд. Может вы уже давно используете такой подход или похожий и есть какие-то не очевидные подводные камни, буду рад, если поделитесь в комментариях.

Так же можете написать в личку в tg: https://t.me/VanyaMate

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

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


  1. nin-jin
    27.06.2024 07:33
    +1

    Спасибо за вредные советы.


    1. VanyaMate Автор
      27.06.2024 07:33

      Здравствуйте.

      Это не реклама эффектора) И я не говорю, что используйте его) Причина почему примеры на нем - они простые и понятные. Я лишь упомянул, что с его помощью это легко реализуется. Есть другие инструменты.

      Всё что мне нужно от эффектора: createStore, createEffect. Всё. Для реакта еще из effector-react импортировать хук useUnit.

      Но спасибо за видос, гляну) Полезно, думаю, будет)


  1. alexMolex
    27.06.2024 07:33

    Зачем в примере с effecror делать такой финт ушами с добавлением состояния запроса в стор, когда у эффектов есть состояние pending?


    1. VanyaMate Автор
      27.06.2024 07:33

      Здравствуйте.

      1. Я не знаю эффектор так хорошо. В первую очередь я взял его потому что на нем можно легко и быстро реализовать то что я имел ввиду, и я взял только тот функционал который мне необходим (createStore, createEffect), а для этого "pending" не нужен.
      2. То как эта идея будет реализована - не важно, это уже относится к реализации, а я предлагаю абстракцию, поток данных. Так же отслеживание конкретного эффекта выбивается из идеи.
      3. Подписываться на конкретный эффект, в каких то случаях, действительно может быть лучше. В том же редаксе, можно сделать dispatch.unwrap и получить промис, состояние которого можно отслеживать. Но, допустим я решил так же делать todoAdding в true когда у меня вызывается эффект getTodos. Тогда мне нужно будет идти в тот(те) компонент(ы) где я отслеживаю этот effect и менять там что-то.. А это уже именно то от чего я и хочу полностью избавиться..
      С todoAdding плохой пример, потому что это буквально стор для добавления новой задачи, и, скорее всего, у нас будет только один такой эффект который будет добавлять задачу, тут с вами согласен.

      Я пытался создать максимально простые примеры содержащие только суть моей идеи. UI просто рендерит данные и вызывает экшены. Без отслеживания состояния этих экшенов и всего такого. (да, для оптимизации иногда будет лучше отслеживать экшены которые вызываются, но это достаточно редкие случаи, от которых лучше отказаться, если есть возможность)


  1. kellas
    27.06.2024 07:33
    +1

    Фронтендеры с этими многочисленными стейт-менеджерами из пустого в порожнее уже лет 15 переливают... и везде в примерах плохо работающая тудушка...

    Минусы? Пока не обнаружены.

    Минус вашего и многочисленных других подходов например в отсутствии синхронизации стейта между вкладками. Открыл пользователь пару вкладок с приложением - в одной например задачу добавил в тудушку - закрыл - в соседней вкладке все по старому. Если где-то какие-то данные регулярно обновляются тащатся с сервера через long-pooling например - то сколько вкладок открыто - столько запросов и будет отправляться. Какой-то недоглобальный стейт получается. Тут sharedWorker в помощь и слушатели изменения localStorage.
    Данные скорее всего одни и те же вытаскиваются по сто раз, не предусмотрено никакого кэширования. А что если еще и данных прям много? Нужна поддержка OPFS или может indexedDB. И как чистить стейт в таком случае от данных которые уже не нужны / не рендерятся больше?

    Нет поддержки offline-mode - что если я хочу сидя в самолете набросать себе задач в туду-лист и чтобы они потом когда появится интернет отправились на сервер, а пока я бы мог их редактировать, может что-то бы уже отметил как выполненное пока летел.

    Ну и пуш данных с сервера - чтобы если я с телефона что-то добавил - то оно в открытом сайте на компе отобразилось, опять же одного только сообщения через websocket тут не достаточно - крышка ноута то закрыта и он спит - нужно чтобы когда я открою ноут - сайт полез за обновлениями, а заодно отправил свои(ну те, из самолета).

    Вот это всё сложные вопросы, не ясно как это лучше организовать, решений хороших и доступных просто нет, что-то платное(rxdb) или c vendor-lock(firebase) и еще с кучей своих ограничений.
    И если это все внедрять, то есть делать просто нормальное приложение которое не вводит в ступор пользователя, все подобные подходы превращаются в запутанного монстра, они не масштабируются нормально до такого уровня. А если не делать - то простите, но просто забрать фетчем json с сервака и не запутаться в том в какую переменную его засунул ума много не надо так что и проблем требующих какого-то решения и особенного подхода тоже нет.

    То есть основной минус - невозможность масштабировать данный подход до уровня нормального приложения с синхронизацией большим объемом данных итп.


    1. VanyaMate Автор
      27.06.2024 07:33
      +1

      Здравствуйте.

      Я говорил о другом и этот подход никак с этими проблемами не связан, а писал я про взаимодействие UI, Model, Action. Всё. То есть то есть UI просто рендерит и вызывает эффекты. А модель сама себя обновляет подписками на эффекты.

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

      Но давайте рассмотрим все проблемы которые вы описали в рамках самого простого примера (плохо работающей тудушки).

      Минус вашего и многочисленных других подходов например в отсутствии синхронизации стейта между вкладками

      Я скажу больше. Тут об этом и других задачах - даже ни слова. Так что же с этим делать? Да куча есть всего. Как вы правильно написали - стореджи, воркеры, броадкасты, постпесседжи и что угодно.

      Давайте представим, что у нас есть только хранилище статуса добавления задачи.

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

      Мы берем и из вкладки которая начала процесс - отправляем через, например, броадкаст это уведомление.

      Другие вкладки получают это сообщение, понимают что это такое и выполняют какой-то свой экшен, на который подписана модель. И обновляет свое состояние.

      // Модель во всех вкладках
      
      export const todoAdding = createStore(false)
        .on(addTodoEffect, () => true)
        .on(addTodoEffect.finally, () => false);

      Как мы свяжем первичный экшен с отправкой уведомления - не важно. Мы для это можем например написать свой интерфейс или сделать обертку над экшеном или над эффектом итд. Опять же, это уже детали реализации.

      Но в момент вызова effect-а у нас будет отправляться postMessage всем слушателям с типом сообщения и данными.

      На другом конце провода (в другой вкладке) ловится это сообщение, берется тип сообщения и вызывается соответсвующий effect с этим payload. На этот экшен была подписана модель - модель обновляется.

      Вот у нас есть общение между вкладками которое соответствует тому что я писал. Модель сама себя обновляет подпиской на эффекты. А UI просто отрендерит новые данные.

      ------------

      Нет поддержки offline-mode

      Ну тогда заменяем броадкаст на локалсторейдж и вот у нас есть локальное хранилище.

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

      Пихаем их в один, но на "запрос" делаем условие, что мы онлайн.

      И вот мы получили поддержку offline. Так же понадобится сделать синхронизацию с сервером при появлении интернета.

      Делаем отдельный экшен, который будет это делать. Брать данные из LS и отправлять на сервер. Если мы захотим - еще подпишемся и на него и будем тоже как-то показывать.

      ------------

      Ну и пуш данных с сервера.

      В примере с "социальной" сетью это даже реализовано. Есть SSE соединение с сервером через который идут все уведомления и о моих действиях и о действиях других пользователей (сообщения для меня, добавления в друзья итд) и я просто подписываюсь на определенные уведомления и вызываю соответствующие эффекты на которые подписана модель. Модель обновляет себя -> UI просто себя рендерит. Всё.

      Вот вам как это было сделано у меня (хоть это и было сделано временно, но суть отражает)

      Вызов thunk-ов: https://github.com/VanyaMate/product/blob/master/src/features/notification/hooks/useFriendsStoreUpdaterByNotifications.ts

      Модель: https://github.com/VanyaMate/product/blob/master/src/app/redux/slices/friends/slice/friends.slice.ts

      Приходит уведомление -> вызывается определенный thunk -> модель обновляет себя из-за подписки -> UI рендерит.

      ------------

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

      Тут не про эффектор, не про реакт, не про то как вам реализовать это всё. Тут принципы и абстракция. Всё.

      Я вообще свой стор написал из 3-х функций store, effect и сombine . Больше и не нужно.

      UI - рендерит
      Model - сама себя обновляет
      Action - просто выполняют какую то задачу

      UI - не меняет стор, не вызывает редюсеры, а просто рендерит и вызывает action-ы (через эффекты). Эффекты это связь экшена и модели.

      Почему я показал на react? Просто он самый популярный и очень понятный код.

      Почему эффектор? Не такой популярный, но очень понятный код.

      Почему плохо работающая тудушка? Очень простой пример.

      ------------

      Просто попробуйте представить себе любой атомарный компонент (widget).

      Вся его задача сводится к рендеру данных и вызову какого либо эффекта. Всё.

      Вызываем действия, которые просто что-то делают. Например создают новую задачу. Как это будет сделано - не важно. Локал сторейдж или fetch или комбинация этих действий.. Мы просто подписываемся на начало этого действия и конец, с ожиданием того, что нам вернется новая задача которую модель сама в себя вставит, а UI получит автоматически новые данные и отрендерит их. Не важно где был вызван этот эффект. Через UI, из пуш уведомления, из какого-то евента.. Он вызвался - по подписке обновились данные - рендер. Всё. Это вся идея. Удобно. Понятно. Просто.


      1. kellas
        27.06.2024 07:33

        Cпасибо за подробный ответ! Я понял что нужно все что я описал реализовывать в модели, а статья у вас не про её внутренности, согласен.
        Мне тяжело вот так в уме представить всё это, я не видел подобных реализаций и вот показалось что это превратится в неповоротливого монстра если добавить всё что вы описали для работы с данными, будет огромное количество экшенов, частично или полностью дублирующих друг друга, раздутый стэйт и т п. но да - наверно это можно сделать более-менее красиво всё


    1. nin-jin
      27.06.2024 07:33
      +1

      Всё то, о чём вы говорите, мы пилим тут: crus.hyoo.ru


  1. Luzinov
    27.06.2024 07:33

    Зумеры изобрели MVC


    1. VanyaMate Автор
      27.06.2024 07:33

      Здравствуйте.

      MVC - это совсем другое. Это скорее MVI.