Эта страница переведена 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
)
)