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

Основы

state - это объект. Он используется как значение где-то в пользовательском интерфейсе или для его рендеринга:

{
    username: "zerocool"
}

action - тоже объект. Он описывает событие (или команду), произошедшее в мире приложения. По соглашению он должен иметь свойство type, содержащее имя события, и может иметь некоторые другие данные:

{
    type: "ADD_TODO",
    text: "Hello"
}

reducer - это функция. Его подпись

(state, action) => state

В следующем примере есть функция с похожей сигнатурой и даже сравнимым именем метода «reduce»:

[1, 2, 3].reduce((acc, item) => acc + item, 0)

Фактически, именно это и происходит в Redux, но вместо массива чисел Redux получает бесконечный массив (поток) событий (действий), и его сокращение охватывает время жизни приложения. Конечно, state и action тоже могут быть примитивными типами в Redux, но в реальных приложениях это не очень полезно.

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

Разработчики используют Redux через store. Это объект, который запоминает вычисление (редуктор) и его первый аргумент (состояние), освобождая вас от его передачи каждый раз. Взаимодействия основаны на вызове метода dispatch() для выполнения вычисления и доступе к последнему вычисленному значению путем вызова getState(). Типы параметров не имеют отношения к dispatch(), потому что он просто передает их редуктору, dispatch() также не возвращает значения. Вот как может выглядеть и работать простой магазин Redux:

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

Кстати, оригинальный Redux не такой уж и маленький. Почему? Потому что он предоставляет различные утилиты: промежуточное ПО, расширение магазина и т. Д. Подробнее об этом позже.

Асинхронность

Если вы попытаетесь прочитать документацию Redux об асинхронности, первая страница, с которой вы столкнетесь, - это страница Асинхронные действия. Его название выглядит довольно странно, потому что мы знаем, что действия - это объекты, а объекты не могут быть асинхронными. Далее вы увидите Создатели асинхронных действий и промежуточное ПО для них.

Давайте сначала посмотрим, что такое обычные синхронные конструкторы действий. Из документов:

Создатели действий - это именно те функции, которые создают действия.

function addTodo(text) {
    return {
        type: "ADD_TODO",
        text
    }
}
 
dispatch(addTodo("Finish the article"));

Заводская функция для уменьшения дублирования кода при создании объектов действий, круто. Если в разных частях приложения отправляются одни и те же действия, Action Creators может помочь.

Промежуточное ПО. Это утилиты для переопределения поведения магазина в более функциональном стиле (например, Декораторы в ООП). Таким образом, вам не нужно писать это вручную, если вы хотите записывать каждое отправленное действие в консоль:

const originalDispatch = store.dispatch;
 
store.dispatch = function myCustomDispatch(action) {
    console.log(`action : ${action.type}`);
    originalDispatch.call(this, action);
};

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

Redux Thunk

Первым в списке стоит redux-thunk. Вот как может выглядеть преобразователь:

function addTodo(text) {
    return dispatch => {
        callWebApi(text)
        .then(() => dispatch({ type: "ADD_TODO", text }))
        .then(() => sendEmail(text));
    };
}
 
dispatch(addTodo(“Finish the article”));

Из описания библиотеки:

Промежуточное ПО Redux Thunk позволяет вам писать создателей действий, которые возвращают функцию вместо действия.

Возвращаете функцию от Action Creators? Создатели действий создают действия (объекты), это видно из их названия. Вместо этого должен быть новый термин.

Google сообщает, что, возвращая функции, вы можете продолжить отправку в обычном режиме, и компоненты не будут зависеть от реализации Action Creators. Но диспетчеризация «нормально» означает выполнение вычисления нового состояния и выполнение его синхронно. С этой новой «нормальной» отправкой вы не можете проверить getState(), чтобы увидеть изменения сразу после вызова, поэтому поведение другое. Это похоже на исправление Lodash.flatten(), чтобы вы могли продолжить «обычное» сглаживание промисов вместо массивов. Создатели действий возвращают объекты, поэтому реализации тоже нет. В то же время презентационные компоненты обычно ничего не знают о dispatch(), они работают с доступными обработчиками (переданными как реквизиты React). Кнопки универсальные. Именно страница Todo решает, что делает кнопка, и это решение указывается путем передачи правого обработчика onClick.

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

dispatch() - это вызов функции, как и sum(). Как задержать sum() в JavaScript? Используя setTimeout(). Как отложить нажатие кнопки? С setTimeout(), но внутри обработчика. Маловероятно, что нужно исправлять кнопку, чтобы знать, как задерживать щелчки (если это не кнопка, которая не анимирует обратный отсчет задержки, который отличается). Как вызвать функцию при соблюдении определенных условий? Добавляя блок «if-then-else» внутрь обработчика. Обычный JS.

Присмотревшись к предлагаемой рассылке, звоните. Меняется не только интерфейс рассылки:

dispatch(dispatch => { … });

Но мы передаем функцию, ожидающую dispatch в качестве аргумента, в функцию с именем dispatch. Это довольно сбивает с толку Слияние различных концепций устраняет простоту и порождает противоречия. Но какую проблему Redux Thunk пытается решить в первую очередь?

function handleAddTodo() {
    dispatch(addTodo(text));
}
 
<Button onClick={handleAddTodo}>Add Todo</Button>

Добавление некоторых асинхронных вызовов превращается в:

function handleAddTodo() {
    callWebApi(text)
        .then(() => dispatch(addTodo(text)));
}
 
<Button onClick={handleAddTodo}>Add Todo</Button>

Для кнопки ничего не изменилось, но проблема действительно возникает, если у вас есть несколько идентичных handleAddTodo() реализаций в разных частях приложения. Срезание углов с помощью Redux Thunk может показаться решением, но все же добавит все недостатки, которые представляет это промежуточное ПО. Этого можно избежать, имея только одну реализацию где-то на верхнем уровне и передав ее вниз, или извлекая dispatch() вызовов во внешние функции (в основном перемещая handleAddTodo() в другой файл).

Обещание Redux

Redux Promise поощряет вас отправлять обещания. По своему эффекту он очень похож на Redux Thunk, поэтому я его пропущу.

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

Бизнес-логика

Приложения реагируют на пользователей и окружающую среду. Сложность реакции растет с увеличением сложности приложения. Вместо простых вещей, таких как изменение цвета кнопки при нажатии, приложения начинают выполнять довольно сложные сценарии. Например, добавить в состояние запись Todo просто. Добавление его также в локальное хранилище, синхронизация с серверной частью, отображение уведомления на экране… это не так. Где-то между этими шагами может быть даже взаимодействие с пользователем.

Такие группы действий обычно представлены блок-схемами и имеют много имен: потоки, рабочие процессы, потоки управления, бизнес-процессы, конвейеры, сценарии, саги, эпики и т. Д. Я буду использовать термин «рабочий процесс». Простой перевод денег между двумя банковскими счетами внутри компании может быть огромной операцией, включающей распределенные транзакции между несколькими независимыми сторонами. Но рабочий процесс на изображении выше может быть простой функцией:

function addTodoWorkflow(text) {
    dispatch(addTodo(text));
    saveToLocalStorage(text);
 
    if (isSignedIn) {
        const response = syncWithServer(text);
 
        if (response.code === OK) {
            showSuccess();
            dispatch(todoSynced());
        } else {
            showError();
        }
    }
}

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

С точки зрения рабочего процесса dispatch(), syncWithServer () и Lodash.groupBy () одинаковы.

API-интерфейсы браузера, веб-клиенты, библиотеки, запускающие изменения пользовательского интерфейса, исходящие из импорта или прибытия аргументов, синхронизации или асинхронности. Все они - всего лишь некоторые службы, которые были объединены в рабочий процесс для выполнения своей работы. Даже если рабочий процесс асинхронный, вы все равно запускаете его следующим образом:

addTodoWorkflow(args...);

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

Рабочие процессы существуют во многих областях разработки программного обеспечения, и они не привязаны к управлению состоянием пользовательского интерфейса. Они также могут вызывать dispatch() несколько раз с совершенно разными типами действий или вообще не иметь индикации пользовательского интерфейса и изменения состояния. Рабочие процессы могут быть составными, как и функции в JS. Подобные концепции существуют даже высоко в облаках и в IoT.

Важно понимать, что рабочие процессы - это отдельная тема. При переносе бизнес-логики в Action Creators это разделение начинает исчезать. Redux не требует особого обращения и не является более важным, чем другие подсистемы в приложении.

Есть два способа выполнения рабочих процессов: прямо и косвенно.

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

function onAddTodoClick() {
    addTodoWorkflow(text);
}

Косвенный путь противоположен. Вы начинаете с фиктивного действия, такого как ADD_TODO, которое не должно изменять никакого состояния, но есть другая система, подписанная на действия Redux. Эта система запустит рабочий процесс, определенный для этого конкретного действия. Таким образом, вы можете добавить функциональность без обновления кода компонентов пользовательского интерфейса. Но теперь вы не представляете, что будет после отправки. Давайте посмотрим на промежуточное ПО.

Redux Saga

Redux Saga на самом деле не о шаблоне саги.

Шаблон «Распределенная сага» - это шаблон для управления сбоями, в котором каждое действие имеет компенсирующее действие для отката.

Это не поможет вам справиться с откатом состояния. Вместо этого он позволяет вам писать рабочие процессы в стиле CSP, но с мощью генераторов (что прекрасно). Redux очень мало упоминается в документации. 99% Redux Saga - это саги, спрятанные в подпакетах.

Саги - это чистые рабочие процессы, а документы учат вас управлять запущенными задачами, выполнять эффекты и обрабатывать ошибки. Часть Redux определяет только промежуточное ПО, которое будет репостить действия в корневую сагу. Вместо того, чтобы вручную строить карту [Action → Saga], вам нужно скомпоновать все саги в дерево, как в Redux с компоновкой редукторов. Код UI остался прежним:

function addTodo(text) {
    return {
        type: "ADD_TODO",
        text
    }
}
 
function handleAddTodo() {
    dispatch(addTodo(text));
}
 
<Button onClick={handleAddTodo}>Add Todo</Button>

Изменения происходят только в соответствующей саге:

function* addTodoSaga(action) {
    yield takeEvery("ADD_TODO", function* (action) {
        const user = yield call(webApi, action.text);
        yield put({ type: "ADD_TODO_SUCCEEDED" });
    });
}
  
function* rootSaga() {
    yield all([
      ...,
      addTodoSaga()
    ]);
}

Он кардинально отличается от Redux Thunk: dispatch() не изменился, Action Creators остаются синхронизированными и разумными, Redux остается простым и понятным.

Redux Observable

Redux Observable идентичен Redux Sagas, но вместо CSP и Sagas вы работаете с Observables и Epics, используя RxJS (более сложный, но даже более мощный).

Ретроспектива

Итак, как лучше всего справиться с асинхронностью в Redux?

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

Есть способы лучше реагировать на действия. Вы можете выбрать прямой способ вызова рабочих процессов вручную и / или переход по косвенному пути привязки рабочих процессов к действиям. У обоих способов есть свои сильные и слабые стороны.

Саги обеспечивают хороший баланс простоты использования, функциональности, тестируемости и могут быть хорошей отправной точкой. В то же время выбор Sagas вместо прямого вызова рабочих процессов похож на выбор между Redux и React State: первое вам не всегда нужно.

В расширенных сценариях с асинхронными модулями вы можете захотеть зарегистрировать новые саги / эпики по запросу вместо заранее созданных корневых саг / эпиков. Но обычно лучше не задумываться.