Здравствуйте 🙂
Коротко о чем тут, чтобы вы могли понять нужно ли оно вам или нет.
Тут я описываю то к чему пришел в проектировании глобальных сторов и потока данных в приложении.
Материал может быть полезен как для новичков, так и для более опытных.
Примеры будут на React и Effector, но это не важно, потому что тут важна идея, а не реализация. К тому же это вездебудет примерно одинаково выглядеть.В конце будут так же ссылки на примеры с svelte + effector
и react + redux thunk
Перед тем как это всё начать писать, я изучил похожие подходы и да, они есть.Есть FLUX (там еще Dispatcher), MVI, может еще что-то.
Да, я опять не открыл Америку, но попытаюсь понятно объяснить свой подход и описать его плюсы.
И да, весь код дальше считайте псевдокодом, там могут быть ошибки, я писал его сюда сразу.
А теперь к сути. В чем идея?
Я предлагаю организовать весь поток данных не относящийся к UI таким образом:
-
UI — подписывается на изменения Model и рендерит их.
-
UI — вызывает Action.
-
Model — подписывается на Action.
Что это значит?
-
UI только рендерит данные и вызывает какие-то экшены.
-
Model сама себя обновляет в зависимости от того какой экшен был вызван.
Вариант реализации
Давайте представим такое простое приложение.
Допустим у нас есть:
-
Форма создания новой задачи
-
Список задач
Тогда нам нужно иметь, допустим, 3 поля:
-
Состояние добавления новой задачи (
Boolean
) -
Состояние загрузки задач (
Boolean
) -
Список задач (
Array<Todo>
)
Так же нам нужны будут 2 экшена:
-
Создать новую задачу (
createTodo
) -
Получить список всех задач (
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 компонента. (да, можно разбить на кучу разных, но тут это не важно, по этому опускаем)
-
Форма добавления новой задачи
-
Список задач
Давайте создадим форму добавления новой задачи
// /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
. Тогда элементы формы не будет задизейблены и мы сможем ввести что хотим и создать задачу.
-
Мы вводим в
input
новую задачу и отправляем форму. -
При отправке формы вызывается
addTodoEffect
. -
В модели по подписке на
addTodoEffect
значениеtodoAdding
изменится наtrue
-
Наш компонент начнет перерендер с новым значением
todoAdding
и элементы формы заблокируются. -
После завершения создания новой задачи, по подписке на
addTodoEffect.finally
значениеtodoAdding
поменяется наfalse
-
Ререндер со значением
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> ); });
Теперь давайте представим как этот компонент будет выглядеть при инициализации приложения, а следовательно, допустим,первом рендере.
-
Сверху будет поле ввода и кнопка создания (форма создания новой задачи)
-
Дальше текст «Задач нет»
-
Дальше кнопка «Загрузить список»
Теперь давайте подумаем что будет если мы нажмем на кнопку «Загрузить список»:
-
Выполняется эффект
getTodosEffect
-
По подписке на этот эффект
todoLoading
переходит вtrue
-
В
<TodoList/>
появляется<Loader/>
-
Кнопка «Загрузить список» блокируется
-
Отправляется запрос на сервер
-
Приходит ответ с сервера с задачами
-
Экшен завершается успешно
-
По подписке на
getTodosEffect.finally
—todoLoading
переходит обратно вfalse
-
По подписке на
getTodosEffect.done
—todoItems
в конец себя вставляет загруженные задачи -
Компонент
<TodoList/>
рендерит список -
Кнопка «Загрузить список» больше не блокируется
Мы из UI не меняем никаких параметров, ничего вообще. Мы только рендерим данные из модели и вызываем экшены.
В итоге мы имеем:
-
Множество разных Action-ов, которые просто выполняют какие-то свои задачи. Мы можем их даже из проекта в проект перетаскивать. Хоть он будет на
svelte + effector
хоть наreact + redux
. -
Model которая хранит данные и в зависимости от выполняемых действия меняет свое состояние.
-
UI который просто рендерит данные и выполняет экшены.
Какие у этого подхода есть плюсы?
-
Все изменения стора контролируются его подписками на эффекты. То есть мы не можем никак просто поменять стор как хотим из UI.
-
Понятный и простой поток данных во всем приложении.
Минусы? Пока не обнаружены.
В целом, можно и многие состояния 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-ами будет работать тоже удобно.
Модели лежат тут: /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; }); }, });
Ну и последний репозиторий где я только начал переписывать этот же проект, но там уже есть аутентификация, её достаточно для понимания того, что я имел в виду
Вот пример модели аутентификации оттуда:
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
Спасибо за внимание 🙂
ссылка на оригинал статьи https://habr.com/ru/articles/824842/
Добавить комментарий