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

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

Сокращение шаблонного кода

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

Действия (Actions)

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

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

Действия выглядят так:

{ type: 'ADD_TODO', text: 'Use Redux' }
{ type: 'REMOVE_TODO', id: 42 }
{ type: 'LOAD_ARTICLE', response: { ... } }

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

Во Flux традиционно принято определять каждый тип действия как строковую константу:

const ADD_TODO = 'ADD_TODO'
const REMOVE_TODO = 'REMOVE_TODO'
const LOAD_ARTICLE = 'LOAD_ARTICLE'

Зачем это нужно? Часто утверждают, что константы избыточны, и для небольших проектов это может быть верно. В крупных проектах определение типов действий через константы даёт преимущества:

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

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

  • Список добавленных, удалённых и изменённых типов действий в Pull Request помогает команде отслеживать объём и реализацию новых функций.

  • При опечатке в импорте константы действия вы получите undefined. Redux немедленно выдаст ошибку при отправке такого действия, что ускорит обнаружение ошибки.

Выбор соглашений для проекта остаётся за вами. Можно начать с использования строк напрямую, затем перейти к константам, а позже — собрать их в одном файле. Redux не навязывает мнения, поэтому руководствуйтесь здравым смыслом.

Создатели действий (Action Creators)

Ещё одно распространённое соглашение: вместо создания объектов действий непосредственно в местах отправки использовать генерирующие их функции.

Например, вместо вызова 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'))

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

Предположим, дизайнер после просмотра прототипа сообщает, что нужно ограничить количество задач (todos) тремя. Мы можем реализовать это, переписав создателя действия в виде колбэка с middleware redux-thunk и добавив проверку условия:

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, и это осталось полностью незаметным для вызывающего кода. Нам не нужно проверять каждое место, где добавляются задачи, чтобы убедиться в наличии этой проверки. Создатели действий позволяют отделить дополнительную логику, связанную с отправкой действия, от самих компонентов, которые инициируют эти действия. Это особенно удобно при активной разработке приложения, когда требования часто меняются.

Генерация создателей действий (Action Creators)

Некоторые фреймворки, такие как 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) {
const 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).

Асинхронные создатели действий (Async Action Creators)

Промежуточное ПО (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:
const { 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)
}

componentDidUpdate(prevProps) {
if (prevProps.userId !== this.props.userId) {
this.loadData(this.props.userId)
}
}

render() {
if (this.props.isFetching) {
return <p>Loading...</p>
}

const posts = this.props.posts.map(post => (
<Post post={post} key={post.id} />
))

return <div>{posts}</div>
}
}

export default connect(state => ({
posts: state.posts,
isFetching: state.isFetching
}))(Posts)

Однако это быстро приводит к дублированию кода, поскольку разные компоненты обращаются к одним и тем же API-эндпоинтам. Более того, нам может потребоваться повторно использовать часть этой логики (например, ранний выход при наличии кэшированных данных) во многих компонентах.

Middleware позволяет писать более выразительных, потенциально асинхронных создателей действий. Оно позволяет отправлять не только простые объекты и интерпретирует передаваемые значения. Например, middleware может "перехватывать" отправленные промисы и преобразовывать их в пару экшенов запроса и успеха/ошибки.

Самый простой пример middleware — redux-thunk. "Thunk" middleware позволяет писать создателей действий как "санки" (thunks), то есть функции, возвращающие функции. Это инвертирует контроль: вы получаете dispatch в качестве аргумента, что позволяет создавать экшены с множественной отправкой.

Примечание

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

Рассмотрим переписанный код с использованием redux-thunk:

actionCreators.js

export function loadPosts(userId) {
// Interpreted by the thunk middleware:
return function (dispatch, getState) {
const { 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))
}

componentDidUpdate(prevProps) {
if (prevProps.userId !== this.props.userId) {
this.props.dispatch(loadPosts(this.props.userId))
}
}

render() {
if (this.props.isFetching) {
return <p>Loading...</p>
}

const posts = this.props.posts.map(post => (
<Post post={post} key={post.id} />
))

return <div>{posts}</div>
}
}

export default connect(state => ({
posts: state.posts,
isFetching: state.isFetching
}))(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:

const _todos = []

const TodoStore = Object.assign({}, EventEmitter.prototype, {
getAll() {
return _todos
}
})

AppDispatcher.register(function (action) {
switch (action.type) {
case ActionTypes.ADD_TODO:
const 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:
const 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) => {
const 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 не предоставляет такую функцию по умолчанию, потому что существует множество способов её реализации. Возможно, вы захотите автоматически преобразовывать обычные JS-объекты в Immutable-объекты для гидратации состояния сервера. Или объединять возвращаемое состояние с текущим. Могут быть разные подходы к обработчику "catch all". Всё зависит от соглашений, выбранных вашей командой для конкретного проекта. API редьюсера в Redux — (state, action) => newState, но способ создания этих редьюсеров полностью зависит от вас.