Перейти к основному содержимому
Неофициальный Бета-перевод

Эта страница переведена PageTurner AI (бета). Не одобрена официально проектом. Нашли ошибку? Сообщить о проблеме →

Middleware

Вы уже видели middleware в действии в туториале «Основы Redux». Если вы использовали серверные библиотеки вроде Express или Koa, вы также наверняка знакомы с концепцией middleware. В этих фреймворках middleware — это код, который можно поместить между получением запроса фреймворком и формированием ответа. Например, middleware для Express или Koa может добавлять CORS-заголовки, логирование, сжатие данных и многое другое. Главное преимущество middleware — его композируемость в цепочку. Вы можете использовать несколько независимых сторонних middleware в одном проекте.

Middleware в Redux решает другие задачи, чем в Express или Koa, но концептуально работает похожим образом. Оно предоставляет точку расширения между отправкой действия и моментом его попадания в редюсер. Разработчики используют Redux middleware для логирования, отчетов об ошибках, взаимодействия с асинхронными API, маршрутизации и многого другого.

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

Понимание Middleware

Хотя middleware можно использовать для решения множества задач, включая асинхронные API-вызовы, крайне важно понимать, как возникла эта концепция. Мы проведем вас через мыслительный процесс, который привел к появлению middleware, на примерах логирования и отчетов об ошибках.

Проблема: Логирование

Одно из преимуществ Redux — предсказуемость и прозрачность изменений состояния. Каждый раз при отправке действия новое состояние вычисляется и сохраняется. Состояние не может измениться само по себе — только как следствие конкретного действия.

Было бы здорово логировать каждое действие в приложении вместе с состоянием, вычисленным после него? Если что-то пойдет не так, мы сможем заглянуть в лог и определить, какое действие привело к повреждению состояния.

Как реализовать это в Redux?

Попытка #1: Ручное логирование

Наивное решение — самостоятельно логировать действие и следующее состояние при каждом вызове store.dispatch(action). Это не полноценное решение, а лишь первый шаг к пониманию проблемы.

Примечание

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

Например, вы вызываете этот код при создании задачи:

store.dispatch(addTodo('Use Redux'))

Чтобы залогировать действие и состояние, можно изменить код так:

const action = addTodo('Use Redux')

console.log('dispatching', action)
store.dispatch(action)
console.log('next state', store.getState())

Это дает нужный эффект, но вряд ли вы захотите делать так каждый раз.

Попытка #2: Обертка для Dispatch

Можно вынести логирование в отдельную функцию:

function dispatchAndLog(store, action) {
console.log('dispatching', action)
store.dispatch(action)
console.log('next state', store.getState())
}

Теперь можно использовать её везде вместо store.dispatch():

dispatchAndLog(store, addTodo('Use Redux'))

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

Попытка #3: Модификация Dispatch (Monkeypatching)

Что если просто заменить функцию dispatch в экземпляре хранилища? Хранилище Redux — это простой объект с несколькими методами, а поскольку мы пишем на JavaScript, можем напрямую модифицировать реализацию dispatch:

const next = store.dispatch
store.dispatch = function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}

Это уже ближе к желаемому! Где бы мы ни отправляли действие, оно гарантированно будет залогировано. Monkeypatching — не самое элегантное решение, но пока можно с этим смириться.

Проблема: Отчеты об ошибках

Что если мы хотим применить несколько таких преобразований к dispatch?

Другое полезное преобразование, которое приходит на ум — это отслеживание JavaScript-ошибок в продакшене. Глобальное событие window.onerror ненадежно, потому что в старых браузерах оно не предоставляет информацию о стеке вызовов, которая критически важна для понимания причин ошибки.

Было бы полезно, если бы при любой ошибке, возникшей после диспатча экшена, мы отправляли её в сервис отслеживания ошибок вроде Sentry вместе со стектрейсом, экшеном, вызвавшим ошибку, и текущим состоянием? Так будет гораздо проще воспроизвести ошибку в разработке.

Однако важно разделять логирование и отслеживание ошибок. Идеально, чтобы они были разными модулями, возможно, в разных пакетах. Иначе у нас не получится создать экосистему таких утилит. (Подсказка: мы постепенно приближаемся к понятию middleware!)

Если логирование и отслеживание ошибок — это отдельные утилиты, они могут выглядеть так:

function patchStoreToAddLogging(store) {
const next = store.dispatch
store.dispatch = function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
}

function patchStoreToAddCrashReporting(store) {
const next = store.dispatch
store.dispatch = function dispatchAndReportErrors(action) {
try {
return next(action)
} catch (err) {
console.error('Caught an exception!', err)
Raven.captureException(err, {
extra: {
action,
state: store.getState()
}
})
throw err
}
}
}

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

patchStoreToAddLogging(store)
patchStoreToAddCrashReporting(store)

Всё равно это не выглядит элегантно.

Попытка №4: Скрытие манкипатчинга

Манкипатчинг — это костыль. "Замени любую метод, как хочешь" — что это за API? Давайте лучше разберемся в сути. Ранее наши функции заменяли store.dispatch. А что если они будут возвращать новую функцию dispatch?

function logger(store) {
const next = store.dispatch

// Previously:
// store.dispatch = function dispatchAndLog(action) {

return function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
}

Мы могли бы предоставить в Redux хелпер, который применяет манкипатчинг как деталь реализации:

function applyMiddlewareByMonkeypatching(store, middlewares) {
middlewares = middlewares.slice()
middlewares.reverse()

// Transform dispatch function with each middleware.
middlewares.forEach(middleware => (store.dispatch = middleware(store)))
}

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

applyMiddlewareByMonkeypatching(store, [logger, crashReporter])

Но это всё ещё манкипатчинг. Тот факт, что мы скрыли его внутри библиотеки, ничего не меняет.

Попытка №5: Убираем манкипатчинг

Зачем мы вообще перезаписываем dispatch? Конечно, чтобы иметь возможность вызвать его позже, но есть и другая причина: чтобы каждая мидлвар имела доступ (и могла вызвать) предыдущую обёрнутую версию store.dispatch:

function logger(store) {
// Must point to the function returned by the previous middleware:
const next = store.dispatch

return function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
}

Это ключевой момент для цепочки мидлваров!

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

Но есть и другой способ организовать цепочку. Мидлвар может принимать функцию next() как параметр вместо чтения её из экземпляра store.

function logger(store) {
return function wrapDispatchToAddLogging(next) {
return function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
}
}

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

const logger = store => next => action => {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}

const crashReporter = store => next => action => {
try {
return next(action)
} catch (err) {
console.error('Caught an exception!', err)
Raven.captureException(err, {
extra: {
action,
state: store.getState()
}
})
throw err
}
}

Именно так выглядят мидлвары в Redux.

Теперь мидлвар принимает функцию next() и возвращает функцию dispatch, которая в свою очередь служит как next() для мидлвара слева, и так далее. Доступ к методам стора вроде getState() всё ещё полезен, поэтому store остаётся доступным как аргумент верхнего уровня.

Попытка №6: Наивное применение мидлваров

Вместо applyMiddlewareByMonkeypatching() мы могли бы написать applyMiddleware(), которая сначала создаёт финальную обёрнутую функцию dispatch(), а затем возвращает копию стора с её использованием:

// Warning: Naïve implementation!
// That's *not* Redux API.
function applyMiddleware(store, middlewares) {
middlewares = middlewares.slice()
middlewares.reverse()
let dispatch = store.dispatch
middlewares.forEach(middleware => (dispatch = middleware(store)(dispatch)))
return Object.assign({}, store, { dispatch })
}

Реализация applyMiddleware() в Redux похожа, но отличается тремя важными аспектами:

  • Она предоставляет мидлварам только подмножество API стора: dispatch(action) и getState().

  • Здесь используется небольшая хитрость: если в вашем middleware вызывается store.dispatch(action) вместо next(action), действие пройдёт заново через всю цепочку middleware, включая текущее. Это полезно для асинхронных middleware. Однако есть нюанс при вызове dispatch во время настройки, описанный ниже.

  • Чтобы гарантировать однократное применение middleware, функция работает с createStore(), а не напрямую с store. Вместо сигнатуры (store, middlewares) => store используется (...middlewares) => (createStore) => createStore.

Поскольку применение функций к createStore() перед использованием неудобно, createStore() принимает необязательный последний аргумент для указания таких функций.

Нюанс: Диспетчеризация во время настройки

Во время выполнения applyMiddleware и настройки ваших middleware функция store.dispatch указывает на базовую версию из createStore. Вызов диспетчеризации в этот момент не задействует другие middleware. Если вы ожидаете взаимодействия с другим middleware во время настройки, это может привести к неожиданностям. Из-за такого поведения applyMiddleware выбросит ошибку при попытке диспетчеризации до завершения настройки. Вместо этого следует либо взаимодействовать напрямую через общий объект (например, через клиент API для middleware работы с API), либо использовать колбэк после полной настройки middleware.

Финальное решение

Допустим, у нас есть такой middleware:

const logger = store => next => action => {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}

const crashReporter = store => next => action => {
try {
return next(action)
} catch (err) {
console.error('Caught an exception!', err)
Raven.captureException(err, {
extra: {
action,
state: store.getState()
}
})
throw err
}
}

Вот как применить его к Redux-хранилищу:

import { createStore, combineReducers, applyMiddleware } from 'redux'

const todoApp = combineReducers(reducers)
const store = createStore(
todoApp,
// applyMiddleware() tells createStore() how to handle middleware
applyMiddleware(logger, crashReporter)
)

Готово! Теперь все действия, отправленные в экземпляр хранилища, будут проходить через logger и crashReporter:

// Will flow through both logger and crashReporter middleware!
store.dispatch(addTodo('Use Redux'))

Семь примеров

Если ваша голова кипит после прочтения предыдущего раздела, представьте, каково было его писать. Этот раздел — передышка для нас обоих, которая поможет настроиться на рабочий лад.

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

/**
* Logs all actions and states after they are dispatched.
*/
const logger = store => next => action => {
console.group(action.type)
console.info('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
console.groupEnd()
return result
}

/**
* Sends crash reports as state is updated and listeners are notified.
*/
const crashReporter = store => next => action => {
try {
return next(action)
} catch (err) {
console.error('Caught an exception!', err)
Raven.captureException(err, {
extra: {
action,
state: store.getState()
}
})
throw err
}
}

/**
* Schedules actions with { meta: { delay: N } } to be delayed by N milliseconds.
* Makes `dispatch` return a function to cancel the timeout in this case.
*/
const timeoutScheduler = store => next => action => {
if (!action.meta || !action.meta.delay) {
return next(action)
}

const timeoutId = setTimeout(() => next(action), action.meta.delay)

return function cancel() {
clearTimeout(timeoutId)
}
}

/**
* Schedules actions with { meta: { raf: true } } to be dispatched inside a rAF loop
* frame. Makes `dispatch` return a function to remove the action from the queue in
* this case.
*/
const rafScheduler = store => next => {
const queuedActions = []
let frame = null

function loop() {
frame = null
try {
if (queuedActions.length) {
next(queuedActions.shift())
}
} finally {
maybeRaf()
}
}

function maybeRaf() {
if (queuedActions.length && !frame) {
frame = requestAnimationFrame(loop)
}
}

return action => {
if (!action.meta || !action.meta.raf) {
return next(action)
}

queuedActions.push(action)
maybeRaf()

return function cancel() {
queuedActions = queuedActions.filter(a => a !== action)
}
}
}

/**
* Lets you dispatch promises in addition to actions.
* If the promise is resolved, its result will be dispatched as an action.
* The promise is returned from `dispatch` so the caller may handle rejection.
*/
const vanillaPromise = store => next => action => {
if (typeof action.then !== 'function') {
return next(action)
}

return Promise.resolve(action).then(store.dispatch)
}

/**
* Lets you dispatch special actions with a { promise } field.
*
* This middleware will turn them into a single action at the beginning,
* and a single success (or failure) action when the `promise` resolves.
*
* For convenience, `dispatch` will return the promise so the caller can wait.
*/
const readyStatePromise = store => next => action => {
if (!action.promise) {
return next(action)
}

function makeAction(ready, data) {
const newAction = Object.assign({}, action, { ready }, data)
delete newAction.promise
return newAction
}

next(makeAction(false))
return action.promise.then(
result => next(makeAction(true, { result })),
error => next(makeAction(true, { error }))
)
}

/**
* Lets you dispatch a function instead of an action.
* This function will receive `dispatch` and `getState` as arguments.
*
* Useful for early exits (conditions over `getState()`), as well
* as for async control flow (it can `dispatch()` something else).
*
* `dispatch` will return the return value of the dispatched function.
*/
const thunk = store => next => action =>
typeof action === 'function'
? action(store.dispatch, store.getState)
: next(action)

// You can use all of them! (It doesn't mean you should.)
const todoApp = combineReducers(reducers)
const store = createStore(
todoApp,
applyMiddleware(
rafScheduler,
timeoutScheduler,
thunk,
vanillaPromise,
readyStatePromise,
logger,
crashReporter
)
)