Перейти к основному содержимому

Redux Essentials, Часть 1: Обзор и Основные Концепции Redux

Неофициальный Бета-перевод

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

Что вы узнаете
  • Что такое Redux и зачем его использовать
  • Ключевые термины и концепции Redux
  • Как данные передаются в приложении с Redux

Введение

Добро пожаловать в учебник Redux Essentials! Этот учебник познакомит вас с Redux и научит использовать его правильно, применяя новейшие рекомендуемые инструменты и лучшие практики. К моменту завершения вы сможете создавать собственные приложения с Redux, используя изученные инструменты и шаблоны.

В Части 1 мы рассмотрим ключевые концепции и термины, необходимые для работы с Redux. В Части 2: Структура приложения с Redux мы разберём типичное React + Redux приложение, чтобы понять взаимодействие компонентов.

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

Как работать с этим учебником

Этот учебник фокусируется на том, чтобы показать вам как правильно использовать Redux, параллельно объясняя концепции, чтобы вы понимали принципы построения Redux-приложений.

Мы старались делать объяснения понятными для новичков, но предполагаем, что вы уже знакомы со следующим:

Предварительные требования

Если вы ещё не уверенно владеете этими темами, мы рекомендуем сначала изучить их, а затем вернуться к изучению Redux. Мы будем ждать вашего возвращения!

Также убедитесь, что в вашем браузере установлены расширения React и Redux DevTools:

Что такое Redux?

Для начала стоит понять, что вообще представляет собой "Redux". Что он делает? Какие проблемы помогает решить? Зачем его использовать?

Redux — это шаблон и библиотека для управления и обновления глобального состояния приложения, где UI инициирует события под названием "actions" для описания происходящего, а отдельная логика обновления под названием "reducers" изменяет состояние в ответ. Он служит централизованным хранилищем для состояния, используемого во всём приложении, с правилами, гарантирующими предсказуемое обновление состояния.

Почему стоит использовать Redux?

Redux помогает управлять "глобальным" состоянием — состоянием, которое требуется во многих частях приложения.

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

Когда следует использовать Redux?

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

Redux особенно полезен когда:

  • У вас есть большой объём состояния приложения, который требуется во многих частях приложения

  • Состояние приложения часто обновляется со временем

  • Логика обновления этого состояния может быть сложной

  • Приложение имеет средний или крупный кодобазу, над которым могут работать многие разработчики

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

Хотите узнать больше?

Библиотеки и инструменты Redux

Ядро Redux — это небольшая автономная JS-библиотека. Обычно её используют вместе с несколькими дополнительными пакетами:

Redux Toolkit

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

React-Redux

Redux можно интегрировать с любым UI-фреймворком, но чаще всего его используют с React. React-Redux — наш официальный пакет, позволяющий React-компонентам взаимодействовать с Redux-хранилищем: читать состояние и отправлять действия для его обновления.

Расширение Redux DevTools

Расширение Redux DevTools показывает историю изменений состояния в вашем Redux-хранилище. Это позволяет эффективно отлаживать приложения, используя такие мощные техники как "отладка путешествием во времени".

Термины и концепции Redux

Прежде чем перейти к коду, разберём основные термины и концепции, необходимые для работы с Redux.

Управление состоянием

Рассмотрим небольшой React-компонент счётчика. Он отслеживает число в локальном состоянии компонента и увеличивает его при клике на кнопку:

function Counter() {
// State: a counter value
const [counter, setCounter] = useState(0)

// Action: code that causes an update to the state when something happens
const increment = () => {
setCounter(prevCounter => prevCounter + 1)
}

// View: the UI definition
return (
<div>
Value: {counter} <button onClick={increment}>Increment</button>
</div>
)
}

Это самодостаточное приложение состоит из следующих частей:

  • Состояние (state) — источник истины, управляющий приложением

  • Представление (view) — декларативное описание UI на основе текущего состояния

  • Действия (actions) — события, происходящие в приложении (например, действия пользователя), которые запускают обновление состояния

Это пример "однонаправленного потока данных":

  • Состояние описывает состояние приложения в определённый момент времени

  • Пользовательский интерфейс отрисовывается на основе этого состояния

  • При возникновении события (например, клике пользователя) состояние обновляется

  • Пользовательский интерфейс перерисовывается с учётом нового состояния

Однонаправленный поток данных

Однако эта простота может нарушаться, когда несколько компонентов должны совместно использовать одно и то же состояние, особенно если эти компоненты расположены в разных частях приложения. Иногда это можно решить "поднятием состояния" в родительские компоненты, но это помогает не всегда.

Один из способов решения — вынести общее состояние из компонентов и поместить его в централизованное хранилище вне дерева компонентов. Таким образом, наше дерево компонентов становится единым "представлением", и любой компонент может получить доступ к состоянию или инициировать действия, независимо от своего положения в дереве!

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

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

Неизменяемость (Immutability)

"Изменяемый" (mutable) означает "поддающийся изменениям". Если что-то "неизменяемое" (immutable), это нельзя изменить.

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

const obj = { a: 1, b: 2 }
// still the same object outside, but the contents have changed
obj.b = 3

const arr = ['a', 'b']
// In the same way, we can change the contents of this array
arr.push('c')
arr[1] = 'd'

Это называется мутацией (mutating) объекта или массива. Это тот же объект или массив в памяти, но теперь его внутреннее содержимое изменилось.

Чтобы обновлять значения неизменяемым способом, ваш код должен создавать копии существующих объектов/массивов и затем изменять эти копии.

Мы можем делать это вручную с помощью операторов расширения (spread) JavaScript для массивов/объектов, а также методов массивов, которые возвращают новые копии вместо изменения оригинала:

const obj = {
a: {
// To safely update obj.a.c, we have to copy each piece
c: 3
},
b: 2
}

const obj2 = {
// copy obj
...obj,
// overwrite a
a: {
// copy obj.a
...obj.a,
// overwrite c
c: 42
}
}

const arr = ['a', 'b']
// Create a new copy of arr, with "c" appended to the end
const arr2 = arr.concat('c')

// or, we can make a copy of the original array:
const arr3 = arr.slice()
// and mutate the copy:
arr3.push('c')

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

Хотите узнать больше?

Дополнительная информация о работе неизменяемости в JavaScript:

Терминология

Прежде чем продолжить, ознакомьтесь с ключевыми терминами Redux:

Действия (Actions)

Действие (action) — это простой JavaScript-объект с полем type. Можно представить действие как событие, описывающее что-то произошедшее в приложении.

Поле type должно быть строкой с описательным именем, например "todos/todoAdded". Обычно мы пишем этот тип в формате "domain/eventName", где первая часть указывает на функциональность или категорию, а вторая — на конкретное произошедшее событие.

Объект действия может содержать другие поля с дополнительной информацией о событии. По соглашению, мы помещаем эту информацию в поле payload.

Типичный объект действия выглядит так:

const addTodoAction = {
type: 'todos/todoAdded',
payload: 'Buy milk'
}

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

Создатель действия (action creator) — это функция, которая создает и возвращает объект действия. Обычно их используют, чтобы не писать объект действия вручную каждый раз:

const addTodo = text => {
return {
type: 'todos/todoAdded',
payload: text
}
}

Редюсеры (Reducers)

Редюсер (reducer) — это функция, которая получает текущее state и объект action, решает, нужно ли обновлять состояние, и возвращает новое состояние: (state, action) => newState. Можно представить редюсер как обработчик событий, который реагирует на полученный тип действия (события).

Информация

Название "редюсер" происходит от схожести с функцией обратного вызова, передаваемой в метод Array.reduce().

Редюсеры должны всегда соблюдать определённые правила:

  • Они должны вычислять новое значение состояния только на основе аргументов state и action

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

  • Они должны быть "чистыми" — не могут содержать асинхронную логику, вычислять случайные значения или вызывать "побочные эффекты"

Позже мы подробнее обсудим правила для редюсеров, включая их важность и корректное применение.

Логика внутри функций-редюсеров обычно следует одинаковой последовательности шагов:

  • Проверить, обрабатывает ли редюсер это действие

    • Если да, создать копию состояния, обновить её новыми значениями и вернуть
  • В противном случае вернуть существующее состояние без изменений

Небольшой пример редюсера, демонстрирующий типичные шаги:

const initialState = { value: 0 }

function counterReducer(state = initialState, action) {
// Check to see if the reducer cares about this action
if (action.type === 'counter/increment') {
// If so, make a copy of `state`
return {
...state,
// and update the copy with the new value
value: state.value + 1
}
}
// otherwise return the existing state unchanged
return state
}

Редюсеры могут использовать любую логику для определения нового состояния: if/else, switch, циклы и т.д.

Detailed Explanation: Why Are They Called 'Reducers?'

The Array.reduce() method lets you take an array of values, process each item in the array one at a time, and return a single final result. You can think of it as "reducing the array down to one value".

Array.reduce() takes a callback function as an argument, which will be called one time for each item in the array. It takes two arguments:

  • previousResult, the value that your callback returned last time
  • currentItem, the current item in the array

The first time that the callback runs, there isn't a previousResult available, so we need to also pass in an initial value that will be used as the first previousResult.

If we wanted to add together an array of numbers to find out what the total is, we could write a reduce callback that looks like this:

const numbers = [2, 5, 8]

const addNumbers = (previousResult, currentItem) => {
console.log({ previousResult, currentItem })
return previousResult + currentItem
}

const initialValue = 0

const total = numbers.reduce(addNumbers, initialValue)
// {previousResult: 0, currentItem: 2}
// {previousResult: 2, currentItem: 5}
// {previousResult: 7, currentItem: 8}

console.log(total)
// 15

Notice that this addNumbers "reduce callback" function doesn't need to keep track of anything itself. It takes the previousResult and currentItem arguments, does something with them, and returns a new result value.

A Redux reducer function is exactly the same idea as this "reduce callback" function! It takes a "previous result" (the state), and the "current item" (the action object), decides a new state value based on those arguments, and returns that new state.

If we were to create an array of Redux actions, call reduce(), and pass in a reducer function, we'd get a final result the same way:

const actions = [
{ type: 'counter/increment' },
{ type: 'counter/increment' },
{ type: 'counter/increment' }
]

const initialState = { value: 0 }

const finalResult = actions.reduce(counterReducer, initialState)
console.log(finalResult)
// {value: 3}

We can say that Redux reducers reduce a set of actions (over time) into a single state. The difference is that with Array.reduce() it happens all at once, and with Redux, it happens over the lifetime of your running app.

Хранилище (Store)

Текущее состояние приложения Redux хранится в объекте хранилище.

Хранилище создаётся путём передачи редюсера и содержит метод getState для получения текущего состояния:

import { configureStore } from '@reduxjs/toolkit'

const store = configureStore({ reducer: counterReducer })

console.log(store.getState())
// {value: 0}

Диспетчеризация (Dispatch)

Хранилище Redux имеет метод dispatch. Единственный способ обновить состояние — вызвать store.dispatch() и передать объект действия. Хранилище выполнит функцию-редюсер и сохранит новое состояние, после чего можно получить обновлённое значение через getState():

store.dispatch({ type: 'counter/increment' })

console.log(store.getState())
// {value: 1}

Диспетчеризацию действий можно представить как "триггер события" в приложении. Когда что-то происходит, мы уведомляем хранилище. Редюсеры действуют как обработчики событий — при получении соответствующего действия они обновляют состояние.

Обычно для диспетчеризации используют создатели действий (action creators):

const increment = () => {
return {
type: 'counter/increment'
}
}

store.dispatch(increment())

console.log(store.getState())
// {value: 2}

Селекторы (Selectors)

Селекторы — функции для извлечения конкретных данных из состояния хранилища. При росте приложения они помогают избежать дублирования логики чтения одних и тех же данных в разных компонентах:

const selectCounterValue = state => state.value

const currentValue = selectCounterValue(store.getState())
console.log(currentValue)
// 2

Поток данных в Redux

Ранее мы упоминали "однонаправленный поток данных", описывающий последовательность обновления приложения:

  • Состояние описывает состояние приложения в определённый момент времени

  • Пользовательский интерфейс отрисовывается на основе этого состояния

  • При возникновении события (например, клике пользователя) состояние обновляется

  • Пользовательский интерфейс перерисовывается с учётом нового состояния

В контексте Redux эти шаги можно детализировать:

  • Инициализация:

    • Создание хранилища Redux с корневым редюсером
    • Однократный вызов корневого редюсера с сохранением результата как начального state
    • При первичной отрисовке компоненты получают текущее состояние хранилища для определения внешнего вида. Они также подписываются на будущие обновления, чтобы отслеживать изменения состояния.
  • Обновление состояния:

    • В приложении происходит событие, например, пользователь нажимает кнопку
    • Код приложения диспатчит действие в Redux-хранилище, например: dispatch({type: 'counter/increment'})
    • Хранилище повторно запускает функцию редюсера с предыдущим state и текущим action, сохраняя возвращаемое значение как новый state
    • Хранилище уведомляет все подписанные части интерфейса об обновлении
    • Каждый UI-компонент, которому нужны данные из хранилища, проверяет, изменились ли требуемые ему части состояния
    • Каждый компонент, обнаруживший изменение данных, выполняет повторный рендеринг с новыми данными для обновления отображения

Визуально этот поток данных выглядит так:

Диаграмма потока данных в Redux

Итоги изученного

В Redux действительно есть ряд новых терминов и концепций для запоминания. Напомним ключевые моменты:

Резюме
  • Redux — это библиотека для управления глобальным состоянием приложения
    • Обычно используется вместе с React-Redux для интеграции с React
    • Redux Toolkit — стандартный способ написания Redux-логики
  • Паттерн обновлений в Redux разделяет «что произошло» и «как изменяется состояние»
    • Действия (Actions) — простые объекты с полем type, описывающие «что произошло»
    • Редюсеры (Reducers) — функции, вычисляющие новое состояние на основе предыдущего состояния + действия
    • Хранилище (Store) запускает корневой редюсер при диспатче действия
  • Redux использует архитектуру «однонаправленного потока данных»
    • Состояние описывает состояние приложения в конкретный момент, UI отображается на его основе
    • При возникновении события:
      • UI диспатчит действие
      • Хранилище запускает редюсеры, обновляя состояние
      • Хранилище уведомляет UI об изменении состояния
    • UI перерисовывается с учётом нового состояния

Что дальше?

Мы рассмотрели отдельные части Redux-приложения. Продолжайте изучение в Части 2: Структура приложения с Redux Toolkit, где мы разберём полноценный пример работы приложения и взаимодействие компонентов.