Сокращение избыточного кода
Redux частично вдохновлен Flux, и наиболее распространённой жалобой на Flux является то, как он заставляет писать много избыточного кода. В этом рецепте мы рассмотрим, как Redux позволяет нам выбирать, насколько подробным должен быть наш код, в зависимости от личного стиля, предпочтений команды, долгосрочной поддерживаемости и так далее.
Действия
Действия — это обычные объекты, описывающие то, что произошло в приложении, и служат единственным способом описания намерения изменить данные. Важно, что объекты-действия, которые вы должны отправлять, — это не избыточный код, а один из основных принципов проектирования Redux.
Существуют фреймворки, которые претендуют на сходство с Flux, но без понятия об объектах действий. С точки зрения предсказуемости это шаг назад по сравнению с Flux или Redux. Если нет сериализуемых обычных объектов-действий, невозможно записать и воспроизвести сессии пользователей или реализовать горячую перезагрузку с путешествием во времени. Если вам нужно непосредственно изменять данные, вам не нужен Redux.
Действия выглядят так:
{ type: 'ADD_TODO', text: 'Use Redux' }
{ type: 'REMOVE_TODO', id: 42 }
{ type: 'LOAD_ARTICLE', response: { ... } }
Согласно общепринятой конвенции, действия имеют постоянный тип, который помогает редукторам (или хранилищам в Flux) идентифицировать их. Мы рекомендуем использовать строки, а не символы для типов действий, потому что строки сериализуемы, а использование символов усложняет запись и воспроизведение.
В Flux традиционно считается, что вы должны определить каждый тип действия как строковую константу:
const ADD_TODO = 'ADD_TODO' const REMOVE_TODO = 'REMOVE_TODO' const LOAD_ARTICLE = 'LOAD_ARTICLE'
В чём заключается польза? Часто утверждают, что константы не нужны, и для небольших проектов это может быть верно. Для больших проектов существуют некоторые преимущества определения типов действий как констант:
- Это помогает поддерживать согласованность именования, так как все типы действий объединены в одном месте.
- Иногда вам нужно увидеть все существующие действия перед работой над новой функцией. Возможно, действие, которое вам нужно, уже было добавлено кем-то из команды, но вы об этом не знали.
- Список типов действий, которые были добавлены, удалены и изменены в запросе на вытягивание, помогает всем членам команды отслеживать масштаб и реализацию новых функций.
- Если вы сделаете опечатку при импорте константы действия, у вас будет
undefined. Redux немедленно выдаст ошибку при отправке такого действия, и вы быстрее найдёте ошибку.
Вы сами выбираете конвенции для своего проекта. Вы можете начать с использования строковых значений встроек, а затем перейти к константам и, возможно, позже сгруппировать их в один файл. Redux не имеет здесь своего мнения, поэтому используйте свой здравый смысл.
Создатели действий
Ещё одной распространённой конвенцией является создание функций, генерирующих действия, вместо создания объектов-действий непосредственно в местах отправки действий.
Например, вместо вызова dispatch с объектом-литералом:
// somewhere in an event handler
dispatch({
type: 'ADD_TODO',
text: 'Use Redux'
})
Вы можете написать создатель действий в отдельном файле и импортировать его в свой компонент:
actionCreators.js
export function addTodo(text) {
return {
type: 'ADD_TODO',
text
}
}
AddTodo.js
import { addTodo } from './actionCreators'
// somewhere in an event handler
dispatch(addTodo('Use Redux'))
Создатели действий часто подвергались критике за избыточный код. Но вы не обязаны их писать! Вы можете использовать объекты-литералы, если считаете, что это лучше подходит для вашего проекта. Тем не менее, существуют некоторые преимущества написания создателей действий, о которых вам следует знать.
Предположим, дизайнер возвращается к нам после проверки прототипа и сообщает, что нам нужно разрешить не более трёх задач. Мы можем это обеспечить, переписав создателя действий в форме обратного вызова с помощью redux-thunk middleware и добавив ранний выход:
function addTodoWithoutCheck(text) {
return {
type: 'ADD_TODO',
text
}
}
export function addTodo(text) {
// This form is allowed by Redux Thunk middleware
// described below in “Async Action Creators” section.
return function (dispatch, getState) {
if (getState().todos.length === 3) {
// Exit early
return
}
dispatch(addTodoWithoutCheck(text))
}
}
Мы только что изменили поведение создателя действий addTodo, что совершенно невидимо для вызывающего кода. Нам не нужно беспокоиться о каждом месте добавления задач, чтобы убедиться в наличии этой проверки. Создатели действий позволяют отделить дополнительную логику отправки действия от самих компонентов, которые генерируют эти действия. Это очень полезно, когда приложение активно разрабатывается, и требования часто меняются.
Генерация создателей действий
Некоторые фреймворки, такие как Flummox, автоматически генерируют константы типов действий из определений функций создателя действий. Идея в том, что вам не нужно определять одновременно ADD_TODO константу и addTodo() создателя действия. Внутренне такие решения всё равно генерируют константы типов действий, но их создание неявное, что может быть путаницей. Мы рекомендуем явно создавать свои константы типов действий.
Написание простых создателей действий может быть утомительным и часто приводит к избыточному коду:
export function addTodo(text) {
return {
type: 'ADD_TODO',
text
}
}
export function editTodo(id, text) {
return {
type: 'EDIT_TODO',
id,
text
}
}
export function removeTodo(id) {
return {
type: 'REMOVE_TODO',
id
}
}
Вы всегда можете написать функцию, которая генерирует создателя действия:
function makeActionCreator(type, ...argNames) {
return function (...args) {
let action = { type }
argNames.forEach((arg, index) => {
action[argNames[index]] = args[index]
})
return action
}
}
const ADD_TODO = 'ADD_TODO'
const EDIT_TODO = 'EDIT_TODO'
const REMOVE_TODO = 'REMOVE_TODO'
export const addTodo = makeActionCreator(ADD_TODO, 'text')
export const editTodo = makeActionCreator(EDIT_TODO, 'id', 'text')
export const removeTodo = makeActionCreator(REMOVE_TODO, 'id')
Также существуют библиотеки утилит, которые помогают при генерации создателей действий, такие как redux-act и redux-actions. Они могут помочь сократить избыточный код и обеспечить соблюдение стандартов, таких как Flux Standard Action (FSA).
Асинхронные создатели действий
Middleware позволяет вставлять пользовательскую логику, которая интерпретирует каждый объект действия перед его отправкой. Асинхронные действия — наиболее распространённый случай использования middleware.
Без middleware, dispatch принимает только обычный объект, поэтому нам приходится выполнять вызовы AJAX внутри наших компонентов:
actionCreators.js
export function loadPostsSuccess(userId, response) {
return {
type: 'LOAD_POSTS_SUCCESS',
userId,
response
}
}
export function loadPostsFailure(userId, error) {
return {
type: 'LOAD_POSTS_FAILURE',
userId,
error
}
}
export function loadPostsRequest(userId) {
return {
type: 'LOAD_POSTS_REQUEST',
userId
}
}
UserInfo.js
import { Component } from 'react'
import { connect } from 'react-redux'
import {
loadPostsRequest,
loadPostsSuccess,
loadPostsFailure
} from './actionCreators'
class Posts extends Component {
loadData(userId) {
// Injected into props by React Redux `connect()` call:
let { dispatch, posts } = this.props
if (posts[userId]) {
// There is cached data! Don't do anything.
return
}
// Reducer can react to this action by setting
// `isFetching` and thus letting us show a spinner.
dispatch(loadPostsRequest(userId))
// Reducer can react to these actions by filling the `users`.
fetch(`http://myapi.com/users/${userId}/posts`).then(
response => dispatch(loadPostsSuccess(userId, response)),
error => dispatch(loadPostsFailure(userId, error))
)
}
componentDidMount() {
this.loadData(this.props.userId)
}
componentWillReceiveProps(nextProps) {
if (nextProps.userId !== this.props.userId) {
this.loadData(nextProps.userId)
}
}
render() {
if (this.props.isFetching) {
return <p>Loading...</p>
}
let posts = this.props.posts.map(post =>
<Post post={post} key={post.id} />
)
return <div>{posts}</div>
}
}
export default connect(state => ({
posts: state.posts
}))(Posts)
Однако это быстро становится повторяющимся, поскольку разные компоненты запрашивают данные из одних и тех же конечных точек API. Кроме того, мы хотим повторно использовать часть этой логики (например, ранний выход, когда данные доступны в кэше) из многих компонентов.
Middleware позволяет нам писать более выразительные, потенциально асинхронные создатели действий. Он позволяет отправлять не только обычные объекты, но и интерпретировать значения. Например, middleware может «перехватывать» отправленные обещания и превращать их в пару действий запроса и успеха/ошибки.
Самый простой пример middleware — redux-thunk. Middleware «Thunk» позволяет писать создатели действий как «thunk», то есть функции, возвращающие функции. Это меняет контроль: вы получите dispatch в качестве аргумента, поэтому вы можете написать создателя действия, который отправляет несколько раз.
Примечание
Thunk middleware — это всего лишь один пример middleware. Middleware не заключается в «возможности отправки функций». Речь идёт о возможности отправлять всё, что конкретная используемая вами middleware может обработать. Middleware Thunk добавляет определённое поведение при отправке функций, но это действительно зависит от используемой middleware.
Рассмотрим код выше, переписанный с помощью redux-thunk:
actionCreators.js
export function loadPosts(userId) {
// Interpreted by the thunk middleware:
return function (dispatch, getState) {
let { posts } = getState()
if (posts[userId]) {
// There is cached data! Don't do anything.
return
}
dispatch({
type: 'LOAD_POSTS_REQUEST',
userId
})
// Dispatch vanilla actions asynchronously
fetch(`http://myapi.com/users/${userId}/posts`).then(
response =>
dispatch({
type: 'LOAD_POSTS_SUCCESS',
userId,
response
}),
error =>
dispatch({
type: 'LOAD_POSTS_FAILURE',
userId,
error
})
)
}
}
UserInfo.js
import { Component } from 'react'
import { connect } from 'react-redux'
import { loadPosts } from './actionCreators'
class Posts extends Component {
componentDidMount() {
this.props.dispatch(loadPosts(this.props.userId))
}
componentWillReceiveProps(nextProps) {
if (nextProps.userId !== this.props.userId) {
this.props.dispatch(loadPosts(nextProps.userId))
}
}
render() {
if (this.props.isFetching) {
return <p>Loading...</p>
}
let posts = this.props.posts.map(post =>
<Post post={post} key={post.id} />
)
return <div>{posts}</div>
}
}
export default connect(state => ({
posts: state.posts
}))(Posts)
Это значительно меньше набора символов! Если хотите, вы всё ещё можете иметь «обычные» создатели действий, такие как loadPostsSuccess, которые вы бы использовали из контейнера loadPosts создателя действий.
Наконец, вы можете написать свою собственную middleware. Допустим, вы хотите обобщить описанную выше схему и описать свои асинхронные создатели действий следующим образом:
export function loadPosts(userId) {
return {
// Types of actions to emit before and after
types: ['LOAD_POSTS_REQUEST', 'LOAD_POSTS_SUCCESS', 'LOAD_POSTS_FAILURE'],
// Check the cache (optional):
shouldCallAPI: state => !state.posts[userId],
// Perform the fetching:
callAPI: () => fetch(`http://myapi.com/users/${userId}/posts`),
// Arguments to inject in begin/end actions
payload: { userId }
}
}
Middleware, которая интерпретирует такие действия, может выглядеть следующим образом:
function callAPIMiddleware({ dispatch, getState }) {
return next => action => {
const {
types,
callAPI,
shouldCallAPI = () => true,
payload = {}
} = action
if (!types) {
// Normal action: pass it on
return next(action)
}
if (
!Array.isArray(types) ||
types.length !== 3 ||
!types.every(type => typeof type === 'string')
) {
throw new Error('Expected an array of three string types.')
}
if (typeof callAPI !== 'function') {
throw new Error('Expected callAPI to be a function.')
}
if (!shouldCallAPI(getState())) {
return
}
const [requestType, successType, failureType] = types
dispatch(
Object.assign({}, payload, {
type: requestType
})
)
return callAPI().then(
response =>
dispatch(
Object.assign({}, payload, {
response,
type: successType
})
),
error =>
dispatch(
Object.assign({}, payload, {
error,
type: failureType
})
)
)
}
}
После однократного передачи в applyMiddleware(...middlewares) вы можете написать все создатели действий для вызова API таким же образом:
export function loadPosts(userId) {
return {
types: ['LOAD_POSTS_REQUEST', 'LOAD_POSTS_SUCCESS', 'LOAD_POSTS_FAILURE'],
shouldCallAPI: state => !state.posts[userId],
callAPI: () => fetch(`http://myapi.com/users/${userId}/posts`),
payload: { userId }
}
}
export function loadComments(postId) {
return {
types: [
'LOAD_COMMENTS_REQUEST',
'LOAD_COMMENTS_SUCCESS',
'LOAD_COMMENTS_FAILURE'
],
shouldCallAPI: state => !state.comments[postId],
callAPI: () => fetch(`http://myapi.com/posts/${postId}/comments`),
payload: { postId }
}
}
export function addComment(postId, message) {
return {
types: [
'ADD_COMMENT_REQUEST',
'ADD_COMMENT_SUCCESS',
'ADD_COMMENT_FAILURE'
],
callAPI: () =>
fetch(`http://myapi.com/posts/${postId}/comments`, {
method: 'post',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ message })
}),
payload: { postId, message }
}
}
Редукторы
Redux значительно сокращает объём кода хранилищ Flux, описывая логику обновления как функцию. Функция проще, чем объект, и намного проще, чем класс.
Рассмотрим это хранилище Flux:
let _todos = []
const TodoStore = Object.assign({}, EventEmitter.prototype, {
getAll() {
return _todos
}
})
AppDispatcher.register(function (action) {
switch (action.type) {
case ActionTypes.ADD_TODO:
let text = action.text.trim()
_todos.push(text)
TodoStore.emitChange()
}
})
export default TodoStore
В Redux та же логика обновления может быть описана как функция редукции:
export function todos(state = [], action) {
switch (action.type) {
case ActionTypes.ADD_TODO:
let text = action.text.trim()
return [...state, text]
default:
return state
}
}
Выражение switch — не является настоящим избыточным кодом. Настоящий избыточный код Flux — концептуальный: необходимость отправлять обновление, необходимость регистрации хранилища с диспетчером, необходимость того, чтобы хранилище было объектом (и сложности, возникающие, когда вам нужно универсальное приложение).
К сожалению, многие по-прежнему выбирают фреймворк Flux, исходя из того, используются ли switch выражения в документации. Если вам не нравятся switch, вы можете решить эту проблему с помощью одной функции, как показано ниже.
Генерация редукторов
Давайте напишем функцию, которая позволяет нам выражать редукторы как объект, сопоставляющий типы действий с обработчиками. Например, если мы хотим, чтобы наши todos редукторы были определены так:
export const todos = createReducer([], {
[ActionTypes.ADD_TODO](state, action) {
let text = action.text.trim()
return [...state, text]
}
})
Мы можем написать следующую вспомогательную функцию для достижения этой цели:
function createReducer(initialState, handlers) {
return function reducer(state = initialState, action) {
if (handlers.hasOwnProperty(action.type)) {
return handlers[action.type](state, action)
} else {
return state
}
}
}
Это не было сложно, не так ли? Redux по умолчанию не предоставляет такую вспомогательную функцию, потому что существует много способов её написать. Возможно, вы хотите автоматически преобразовывать обычные объекты JavaScript в неизменяемые объекты для гидратации состояния сервера. Возможно, вы хотите объединить возвращаемое состояние с текущим состоянием. Может быть, существуют разные подходы к обработчику «catch all». Всё это зависит от конвенций, которые вы выбираете для своей команды в конкретном проекте.
API редуктора Redux — (state, action) => state, но как создавать эти редукторы — решать вам.
© 2015–2017 Dan Abramov
Licensed under the MIT License.
http://redux.js.org/docs/recipes/ReducingBoilerplate.html