Вложите душу в проект React-Redux (использование акторов для бизнес-логики)

Мы представляем, что у нас есть ReactJS для пользовательского интерфейса и Redux для хранения данных. Пользователи видят кнопку, нажимают на нее, происходит действие Redux, данные хранятся в глобальном состоянии Redux, а кнопка меняет визуальный стиль. Это выглядит как просто причина и следствие. Это кажется безжизненным. Такие приложения похожи на примитивные виды.

Другое дело — кнопка, которая после нажатия ждет секунду, имитируя мыслительный процесс, а затем меняет UI. А потом ждет еще секунду и выключается.
В этой статье я попытаюсь объяснить, как добавить мозг в приложение и сделать его умнее. Вы можете назвать это «добавлением души в проект React+Redux» или «использованием акторов для бизнес-логики». Я называю это «Призрак в React».

Пользователь взаимодействует с компонентами React. React-компоненты вызывают действия. Редуктор изменяет состояние в Redux.

Теперь в игру вступает Ghost. Ghost видит, что состояние изменилось, выполняет некоторую работу и вызывает действия для сохранения результата в Redux. Компоненты React видят, что состояние изменилось, и показывают пользователю обновленный пользовательский интерфейс.

Шаг 1. Установите react с помощью typescript

npx create-react-app rg_example --template typescript

Вы можете найти множество руководств по этому вопросу. Поэтому переходите к следующему шагу.

Шаг 2. Подготовьте страницу.

Очистим файл App.tsx.

import React, {createContext, useContext, useMemo, useState} from 'react';
import './App.css';

type ContextInterface = {
    state: boolean;
    setState: (state: boolean) => void;
};

const Context = createContext<ContextInterface>({
    state: false,
    setState: (state: boolean) => undefined,
});

const App = () => {
    const [state, setState] = useState(false);
    const contextValue = useMemo(() => ({state, setState}), [state]);
    return <Context.Provider value={contextValue}>
        <Panel />
    </Context.Provider>;
};

const Panel = () => {
    const {state, setState} = useContext(Context);
    return (
        <div className='App'>
            <button onClick={() => {
                setState(!state);
            }} >{state ? 'disable' : 'enable'}</button>
            <span>{state ? 'enabled' : 'disabled'}</span>
        </div>
    );
};

export default App;
Вход в полноэкранный режим Выход из полноэкранного режима

Мы добавили кнопку. При нажатии — состояние меняется, а кнопка просто меняет текст.

Шаг 3. Добавление призрака.


const ButtonGhost = () => {
    const {state, setState} = useContext(Context);
    useEffect(() => {
        if (state) {
            const id = setTimeout(() => {
                setState(false);
            }, 1000);
            return () => {
                clearTimeout(id);
            };
        }
    }, [state, setState]);
    return null;
};

const App = () => {
    const [state, setState] = useState(false);
    const contextValue = useMemo(() => ({state, setState}), [state]);
    return <Context.Provider value={contextValue}>
        <Panel />
        <ButtonGhost />
    </Context.Provider>;
};

Вход в полноэкранный режим Выйдите из полноэкранного режима

Вот и все. Если вы запустили этот код, то уже видели живую панель.

Вы нажимаете кнопку «включить», и она меняет состояние на «включено». Через 1 секунду Ghost меняет состояние обратно на «disabled».

Шаг 3. Время добавить redux.

Прежде мы хранили состояние с помощью React Context и хука useState.
Давайте перенесем состояние панели в Redux.

Сначала установите пакеты:

Мне не нравится пакет react-redux, потому что мне не нужна вся функциональность этого пакета. Мне достаточно просто подписываться на изменение состояния. Поэтому вместо него я использую use-store-path.

import React, {createContext, useContext, useEffect} from 'react';
import {createSlice, configureStore} from '@reduxjs/toolkit';
import type {PayloadAction} from '@reduxjs/toolkit';
import {getUseStorePath} from 'use-store-path';
import './App.css';

export const stateSlice = createSlice({
    name: 'flag',
    initialState: false,
    reducers: {
        set: (state, action: PayloadAction<boolean>) => action.payload,
    },
});

const store = configureStore({reducer: stateSlice.reducer});

const exStore = {
    ...store,
    useStorePath: getUseStorePath(store),
};

const Context = createContext(exStore);

const App = () => <Context.Provider value={exStore}>
    <Panel />
    <ButtonGhost />
</Context.Provider>;

const Panel = () => {
    const {useStorePath, dispatch} = useContext(Context);
    const flag = useStorePath([]);
    return (
        <div className='App'>
            <button onClick={() => {
                dispatch(stateSlice.actions.set(!flag));
            }} >{flag ? 'disable' : 'enable'}</button>
            <span>{flag ? 'enabled' : 'disabled'}</span>
        </div>
    );
};

const ButtonGhost = () => {
    const {useStorePath, dispatch} = useContext(Context);
    const flag = useStorePath([]);
    useEffect(() => {
        if (flag) {
            const id = setTimeout(() => {
                dispatch(stateSlice.actions.set(false));
            }, 1000);
            return () => {
                clearTimeout(id);
            };
        }
    }, [flag, dispatch]);
    return null;
};

export default App;

Вход в полноэкранный режим Выход из полноэкранного режима

Обратите внимание, что в этой статье мы храним только один флаг, чтобы упростить примеры. В реальном проекте каждый reducer будет иметь больше значений и действий.

Шаг 4. Динамический редуктор.

Динамический редуктор очень полезен в большом приложении.
Я использую для этого пакет @simprl/dynamic-reducer.

npm i @simprl/dynamic-reducer

Добавьте хук useReducer в контекст приложения.

import {reducer as dynamicReducer} from '@simprl/dynamic-reducer';
import {Reducer} from 'redux';

const {reducer, addReducer} = dynamicReducer();

// before: const store = configureStore({reducer: stateSlice.reducer});
const store = configureStore({reducer});

const useReducer = (name: string, reducer: Reducer) => {
    useEffect(
        () => addReducer(name, reducer, store.dispatch),
        [name, reducer],
    );
};

const exStore = {
    ...store,
    useStorePath: getUseStorePath(store),
    useReducer,
};
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь мы можем динамически создавать редуктор в Ghost с помощью useReducer. Нам просто нужно каждый раз определять пробел («flag1») при диспетчеризации действия и подписываться на магазин Redux.

const ButtonGhost = () => {
    useReducer('flag1', stateSlice.reducer);
    const {useStorePath, dispatch} = useContext(Context);
    const flag = useStorePath(['flag1']);
    useEffect(() => {
        if (flag) {
            const id = setTimeout(() => {
                dispatch({...stateSlice.actions.set(false), space: 'flag1'});
            }, 1000);
            return () => {
                clearTimeout(id);
            };
        }
    }, [flag, dispatch]);
    return null;
};
Вход в полноэкранный режим Выход из полноэкранного режима

Шаг 5. Множество пространств.

Теперь мы можем повторно использовать один и тот же reducer в двух пространствах («flag1» и «flag2»).

const App = () => <Context.Provider value={exStore}>
    <Panel space='flag1' />
    <Panel space='flag2' />
    <ButtonGhost space='flag1' />
    <ButtonGhost space='flag2' />
</Context.Provider>;

type WithSpace = {
    space: string;
};

const Panel = ({space}: WithSpace) => {
    const {useStorePath, dispatch} = useContext(Context);
    const flag = useStorePath([space]);
    return (
        <div className='App'>
            <button onClick={() => {
                dispatch({...stateSlice.actions.set(!flag), space});
            }} >{flag ? 'disable' : 'enable'}</button>
            <span>{flag ? 'enabled' : 'disabled'}</span>
        </div>
    );
};

const ButtonGhost = ({space}: WithSpace) => {
    useReducer(space, stateSlice.reducer);
    const {useStorePath, dispatch} = useContext(Context);
    const flag = useStorePath([space]);
    useEffect(() => {
        if (flag) {
            const id = setTimeout(() => {
                dispatch({...stateSlice.actions.set(false), space});
            }, 1000);
            return () => {
                clearTimeout(id);
            };
        }
    }, [flag, dispatch]);
    return null;
};

export default App;
Войти в полноэкранный режим Выйти из полноэкранного режима

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

Шаг 6. Обработчик клика.

Но это еще не все. Вы могли заметить, что мы всегда воссоздаем указатель на функцию click. Это приводит к повторному рендерингу мемоизированного компонента, потому что каждый раз мы помещаем новое свойство.

Обычно это исправляется с помощью useCallback.

const Panel = ({space}: WithSpace) => {
    const {useStorePath, dispatch} = useContext(Context);
    const flag = useStorePath([space]);

    const clickHandler = useCallback(() => {
        dispatch({...stateSlice.actions.set(!flag), space});
    }, [space, flag]);

    return (
        <div className='App'>
            <button onClick={clickHandler} >{flag ? 'disable' : 'enable'}</button>
            <span>{flag ? 'enabled' : 'disabled'}</span>
        </div>
    );
};
Вход в полноэкранный режим Выйти из полноэкранного режима

Но вы можете увидеть свойства ‘flag’ и ‘space’ в deps. Поэтому у нас все еще есть подобная проблема. При каждом клике это свойство ‘flag’ меняется, и мы снова пересоздаем функцию. На самом деле указатель на обработчик не должен зависеть от свойств ‘flag’ и ‘space’. Но при вызове обработчика нам нужно получить значения этих свойств из последнего рендера.
Я использую хук useConstHandler из пакета use-constant-handler.

npm i use-constant-handler

import {useConstHandler} from 'use-constant-handler';

const clickHandler = useConstHandler(() => {
    dispatch({...stateSlice.actions.set(!flag), space});
});
Вход в полноэкранный режим Выйти из полноэкранного режима

useConstHandler каждый раз возвращает постоянный указатель на функцию, но контекст этой функции меняется каждый рендер — это значит, что все свойства в этой функции всегда актуальны.

Шаг 7. Хук useAction.

Обычно в своих проектах я создаю хук для вызова действия.

const useSpaceAction = (
    space: string,
    actionCreator: () => AnyAction
) => useConstHandler(() => {
    store.dispatch({space, ...actionCreator()});
});

const exStore = {
    ...store,
    useStorePath: getUseStorePath(store),
    useReducer,
    useSpaceAction,
};
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь компонент Panel выглядит следующим образом

const Panel = ({space}: WithSpace) => {
    const {useStorePath, useSpaceAction} = useContext(Context);
    const flag = useStorePath([space]);

    const clickHandler = useSpaceAction(space, () => stateSlice.actions.set(!flag));

    return (
        <div className='App'>
            <button onClick={clickHandler} >{flag ? 'disable' : 'enable'}</button>
            <span>{flag ? 'enabled' : 'disabled'}</span>
        </div>
    );
};

Вход в полноэкранный режим Выйти из полноэкранного режима

Шаг 8. Разделение на пользовательский интерфейс и бизнес-логику.

Хорошей практикой является разделение пользовательского интерфейса и бизнес-логики. Давайте сделаем это.

const App = () => <Context.Provider value={exStore}>
    <div className='App'>
        <AppUi />
    </div>
    <AppGhost/>
</Context.Provider>;
Войти в полноэкранный режим Выход из полноэкранного режима

Теперь у нас есть два слоя приложения:

  • AppUi для пользовательского интерфейса
  • AppGhost для бизнес-логики.

Давайте реализуем их и добавим больше интерактивности

const AppGhost = () => {
    const {useStorePath} = useContext(Context);
    const flag1 = useStorePath(['flag1']);
    return <>
        <ButtonGhost space='flag1' />
        {flag1 && <ButtonGhost space='flag2' />}
    </>;
};

const AppUi = () => {
    const {useStorePath} = useContext(Context);
    const flag1 = useStorePath(['flag1']);
    return <>
        <Panel space='flag1' />
        {flag1 && <Panel space='flag2' />}
    </>;
};
Вход в полноэкранный режим Выход из полноэкранного режима

Вторая панель и призрак рендерятся только если флаг1 === true.

Обратите внимание, что это также пример преимущества динамического редуктора. Редуктор для флага2 будет добавляться, только если флаг1 истинен, и удаляться, если флаг1 === false.

Шаг 9. Не используйте JSX для бизнес-логики.

Использование JSX для бизнес-логики выглядит странно. Мы можем запутаться, если будем использовать JSX в обоих случаях. Нам нужно как-то отличить код бизнес-логики от кода пользовательского интерфейса. Я предлагаю использовать ключевые слова ‘ghost’ и ‘ghosts’.

import { createElement, Fragment } from 'react';

const ghost = createElement;
const ghosts = (...children) => createElement(Fragment, null, ...children);
Вход в полноэкранный режим Выход из полноэкранного режима

Для стандартизации я использую один и тот же пакет react-ghost во всех своих проектах.

npm i react-ghost

Давайте перепишем AppGhost без jsx

import {ghost, ghosts} from 'react-ghost';

const AppGhost = () => {
    const {useStorePath} = useContext(Context);
    const flag1 = useStorePath<boolean>(['flag1']);
    return ghosts(
        ghost(ButtonGhost, {space: 'flag1'}),
        flag1 && ghost(ButtonGhost, {space: 'flag2'}),
    );
};
Вход в полноэкранный режим Выход из полноэкранного режима

Результат

Весь файл:

import React, {createContext, useContext, useEffect} from 'react';
import {createSlice, configureStore, AnyAction} from '@reduxjs/toolkit';
import type {PayloadAction} from '@reduxjs/toolkit';
import {reducer as dynamicReducer} from '@simprl/dynamic-reducer';
import {getUseStorePath} from 'use-store-path';
import './App.css';
import {Reducer} from 'redux';
import {useConstHandler} from 'use-constant-handler';
import {ghost, ghosts} from 'react-ghost';

export const stateSlice = createSlice({
    name: 'flag',
    initialState: false,
    reducers: {
        set: (state, action: PayloadAction<boolean>) => action.payload,
    },
});

const {reducer, addReducer} = dynamicReducer();
const store = configureStore({reducer});
const useReducer = (name: string, reducer: Reducer) => {
    useEffect(
        () => addReducer(name, reducer, store.dispatch),
        [name, reducer],
    );
};

const useSpaceAction = (space: string, actionCreator: () => AnyAction) => useConstHandler(() => {
    store.dispatch({space, ...actionCreator()});
});

const exStore = {
    ...store,
    useStorePath: getUseStorePath(store),
    useReducer,
    useSpaceAction,
};

const Context = createContext(exStore);

const App = () => <Context.Provider value={exStore}>
    <div className='App'>
        <AppUi />
    </div>
    <AppGhost/>
</Context.Provider>;

type WithSpace = {
    space: string;
};

const Panel = ({space}: WithSpace) => {
    const {useStorePath, useSpaceAction} = useContext(Context);
    const flag = useStorePath([space]);

    const clickHandler = useSpaceAction(space, () => stateSlice.actions.set(!flag));

    return (
        <div>
            <button onClick={clickHandler} >{flag ? 'disable' : 'enable'}</button>
            <span>{flag ? 'enabled' : 'disabled'}</span>
        </div>
    );
};

const ButtonGhost = ({space}: WithSpace) => {
    useReducer(space, stateSlice.reducer);
    const {useStorePath, dispatch} = useContext(Context);
    const flag = useStorePath([space]);
    useEffect(() => {
        if (!flag) {
            const id = setTimeout(() => {
                dispatch({...stateSlice.actions.set(true), space});
            }, 1000);
            return () => {
                clearTimeout(id);
            };
        }
    }, [flag, dispatch]);
    return null;
};

const AppUi = () => {
    const {useStorePath} = useContext(Context);
    const flag1 = useStorePath(['flag1']);
    return <>
        <Panel space='flag1' />
        {flag1 && <Panel space='flag2' />}
    </>;
};

const AppGhost = () => {
    const {useStorePath} = useContext(Context);
    const flag1 = useStorePath<string>(['flag1']);
    return ghosts(
        ghost(ButtonGhost, {space: 'flag1'}),
        flag1 && ghost(ButtonGhost, {space: 'flag2'}),
    );
};

export default App;
Войти в полноэкранный режим Выйти из полноэкранного режима

Вы можете поиграть с этим в codesandbox:

Исходный код

Исходный код доступен на github
Вы можете проверить каждый шаг в истории коммитов

FAQ

Почему бы вам просто не использовать промежуточное ПО redux (ваше собственное или Thunk или Saga)?

Middleware не имеет хуков.

Почему бы вам не использовать хуки вместо ghost?

У хуков не может быть условий. Вы не можете создать динамическую композицию из хуков. Поэтому пока это не проблема — вы можете использовать хуки.

Оцените статью
devanswers.ru
Добавить комментарий