Redux — технология относительно молодая. Чётких правил что, как и где использовать нет.
Есть рекомендации, но и их не все читают. Очень многие вообще используют Redux исключительно потому что «все так делают», что зачастую сводит его полезность к нулю, или вообще просто бессмысленно усложняет приложение и добавляет ему лишних ошибок.
Возможно, то что я здесь пишу, покажется для очень многих очевидным, но лично для меня таковым это не было — до всего пришлось доходить изрядно потоптавшись по граблям. И, как я сейчас замечаю, не только для меня: в последние полгода мне пришлось доделывать или модифицировать несколько начатых другими людьми проектов на React и ReactNative, которые использовали Redux.
И, так как заботливо разложенные грабли, которые пришлось вычищать из этих проектов, были практически те же самые, мне захотелось рассказать об этом.
Давайте рассмотрим несколько примеров, я их специально брал из разных проектов, они написаны разными людьми из разных стран.
Синхронный action:
export const DISABLE_WELCOME = 'DISABLE_WELCOME';
export function disableWelcome() {
return {
type: DISABLE_WELCOME,
payload: {}
}
}
и reducer для него:
export default function (state = INITIAL_STATE, action) {
switch (action.type) {
// много разных других reducer'ов
case DISABLE_WELCOME:
return {
...state, auth: {
loggedIn: true,
errorMessage: null,
welcome: false
}
};
default:
return state;
}
}
В общем, всё просто и очевидно, никаких подводных камней особо нет.
Теперь посмотрим на асинхронный вызов из того же проекта, в административной части в таблицу добавляется новый вид некой «подписки»:
export function createSubscription(props) { // Create Subscription
const request = axios.post(`${ROOT_URL}/subscription`, props,
{
headers: { Authorization: getAuthToken() }
});
return {
type: CREATE_SUBSCRIPTION,
payload: request
}
}
и простенький reducer
case CREATE_SUBSCRIPTION:
return {...state, subscription:action.payload};
Всё сделано совершенно по инструкции. В качестве action передаётся promise, при инициализации redux'a в него добавляется библиотечное middleware redux-promise, reducer вызывается после получения ответа от сервера, вроде как всё нормально.
Но сразу бросаются в глаза две проблемы:
- Reducer вызывается только после получения ответа от сервера, а хорошо бы звать его два раза, в начале запроса и в конце (например чтобы сразу добавить в таблицу значение, которое создал пользователь, а не ждать ответа от сервера и только после этого обновлять UI — это чисто внешне выглядит значительно лучше)
- Те же самые строки с функцией getAuthToken() повторяются в том же файле actions.js около 40 раз.
В данном проекте первая проблема была решена достаточно брутальным образом, программист просто вызывал два action подряд, один с простым объектом для обновления UI, второй с промизом.
Варварство? Варварство.
Работает? Работает.
Более правильный и чаще встречающийся подход предполагает использование асинхронного middleware которое бы вызывало бы reducer два раза, при вызове action и после получения ответа:
export default function promiseMiddleware({ getState }) {
return next => action => {
if (isPromise(action.payload)) {
const { type, payload, meta = {} } = action;
next({
...action,
payload,
meta: {
...meta,
sequence: 'begin'
}});
payload.then(
result => next({
...action,
payload: result,
meta: {
...meta,
sequence: 'complete'
}}
))
.catch(err => next({
...action,
payload: err,
meta: {
...action.meta,
sequence: 'error'
}}
));
} else {
next(action);
}
};
}
То есть что мы здесь получаем: reducer вызывается два раза, один раз вначале (meta.sequence === 'begin') и в конце при успешном завершении запроса (sequence: 'complete') или при ошибке (sequence: 'error').
Пару раз я встречался с выносом сюда же функций для управления аутентификацией, что-то вроде такого:
const token = getState().getIn(['session', 'token']);
if (token && typeof action.payload.set !== 'undefined') {
action.payload.set('Authorization', token);
};
Понятно, что выглядит не очень аппетитно — привязано к конкретному месту хранения токена в конкретном store (в данном случае это ещё и Immutable), привязано к конкретной библиотеке для http запросов, если в качестве action будет передана какая-нибудь другая функция с методом set (а название метода, признаемся, не самое редко встречающееся), последствия могут быть самыми непредсказуемыми, ну и так далее. Однако, тоже работает.
И, к сожалению, стандартные и везде описанные возможности для работы с асинхронными вызовами на этом заканчиваются. Дальше начинается кто во что горазд.
Первая проблема возникает, когда нужно скомбинировать несколько асинхронных функций, причём нужно чтобы они вызывали разные reducer'ы.
Другая проблема, когда у функции не один callback, а несколько. Всё, промизом здесь уже не обойдёшься. В результате я вижу dispatch, передающийся в функцию createAction чтобы создавать при поступлении нового callback'a ещё больше action'ов. Или вижу как весь кусок с websocket'ами, например, переезжает в какой-нибудь реактовский компонент из action'ов, в результате чего впоследствии несколько дней тратится на то чтобы понять, почему вдруг всё иногда перестаёт работать (а всего лишь компонент решил перемонтироваться).
Я сейчас покажу вам пару реальных примеров реального кода, и постараемся забыть об этом как о страшном сне.
const disconnect = createAction('DISCONNECT_MESSENGER',
() => {
client.unsubscribe('/messenger')
client.disconnect();
});
const wasDisconnected = createAction('WAS_DISCONNECTED_MESSENGER',
() => client.unsubscribe('/messenger'));
const receive = createAction('RECEIVE_MESSAGE');
const connect = createAction('CONNECT_MESSENGER',
( token, dispatch ) => client.connectAsync({ auth: { headers: { Authorization: token } } })
.then(() => {
client.onDisconnect((willReconnect, log ) => dispatch(wasDisconnected({willReconnect, log})))
return client.subscribeAsync('/messenger', message => dispatch(receive(message)))
}),
( payload ) => payload );
dispatch(editStory(formData, id)).then((results) => {
if (results.payload.data.error === true) {
alert.error('Error', results.payload.data.message)
}
else if (results.payload.data.error === false) {
this.setState({ loadingClassName: '' });
this.close();
this.props.fetchStory();
dispatch(push('/story'));
alert.success('Success', results.payload.data.message)
}
}).catch((err) => {
dispatch(push('/story'));
alert.error('Error', 'Oooops! Looks like something went wrong. Please try again after sometime.');
})
Да, .then() от dispatch(). Оно работает, кстати…
Да, есть варианты использовать что-нибудь для создания последовательности action'ов или Thunk, но почему-то в реальности каждый раз я вижу именно такое.
Что делать
Отправляем всё в middleware. Целиком.
Action — это действие. Действие — это отправить данные на сервер или получить данные с сервера, а не «создать promise» — promise, как и библиотека для асинхронных запросов, будь это хоть fetch, хоть axios, хоть superagent — это всё внутренняя кухня, давайте уберём её из логики:
const requestMiddleware =
({ getToken } = {}) => // Давайте передадим тут функцию для получения токена, всего один раз
({ getState }) => // И у нас здесь есть полный доступ к state!
next =>
action => {
// обозначим то, что мы хотим обратиться именно к нашему middleware
// с указав в payload use: 'request'. Можно придумать что-нибудь получше
if (action.payload && action.payload.use === 'request') {
const {
payload,
type,
meta = {}
} = action;
const {
url,
method,
data,
query
} = payload;
// И нам без разницы какая библиотека!
// Заменим здесь - заменится для всех запросов.
const myRequest = request(method, url);
if (typeof getToken === 'function' ) {
// токен прикладываем тоже здесь. Так как нам удобно.
// А для его получения используем внешнюю функцию, чтоб особо не хардкодить
myRequest.set('Authorization', getToken(getState()));
}
// Выставляем всё что надо
if (query) {
myRequest.query(query);
}
if (data) {
requestObject.send(data);
}
myRequest.then(response => next({ // Зовём reducer если всё успешно
...action,
payload: response.body,
meta: {
...meta,
sequence: 'complete'
}
})
).catch(err => next({ // Зовём reducer если всё плохо
...action,
payload: err,
meta: {
...action.meta,
sequence: 'error'
}}
));
next({ // Зовём reducer в самом начале
...action,
meta: {
...meta,
sequence: 'begin'
}});
} else {
next(action); // Не забываем об остальных
}
}
Теперь добавим наш middleware:
const options = {
// наша функция для получения токена
getToken = store => store.getIn(['session', 'token'])
}
const store = applyMiddleware(
requestMiddleware(options)
)(createStore)(reducer);
И всё. Можно закрывать этот файл навсегда и пользоваться:
const myAction = ({
type: 'GET_REMOTE_URL',
payload: {
use: 'request',
method: 'GET',
url: 'https://your.site/api'
}
});
Не сомневаюсь, что всё можно сделать и изящнее, и удобнее.
Но это самая простая часть, а если нам надо добавить ещё какие-то callback'и? Например, мы загружаем длинный файл и нам нужно обновлять store в зависимости от прогресса загрузки?
Да не вопрос:
const handleProgress = progress => next({
...action,
request,
payload: {
progress: progress.percent
},
meta: {
...action.meta,
sequence: 'progress'
}
})
requestObject.on('progress', progress => handleProgress(progress))
Всё. Готово. Проверяйте в своём reducer'e sequence === 'progress'
Нужно добавить socket.io или что-нибудь подобное? Пожалуйста, точно так же.
Создайте, на все события вызывайте next() с нужными вам параметрами, и всё.
И точно так же можно поступать с абсолютно любыми асинхронными функциями, которые вы вызываете более одного раза (всё-таки если один раз — смысла особого нет).
Очень удобно сделать отдельный middleware для задержек и таймеров — всё-таки всякие setInterval() в аккуратном коде аккуратного компонента смотрятся довольно чужеродно.
В ReactNative вы можете отправить туда Alert или вызов какого-нибудь нативного компонента типа react-native-image-picker — и если вдруг вам срочно придётся заменять его на какой-нибудь react-native-image-crop-picker (а подобные необходимости обычно возникают чуть чаще, чем хотелось бы...) — вы замените его всего в одном месте, а не в десятке.
Сделайте разные middleware для LocalStorage для веб-страницы и для AsyncStorage в ReactNative, но сделайте способ обращения к ним одинаковым — и вы сможете использовать одни и те же action и для сайта и для приложения.
В общем, в action оставляем только логику. Чем меньше там конкретной реализации — тем лучше. Оставим всю грязную работу для middleware.
Я собрал несколько своих middleware, которыми я пользуюсь постоянно, в библиотечку Redux Kittens, может быть кому-нибудь тоже пригодится.
Автор: Андрей Коринский