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

Основы Redux, Часть 2: Концепции и Поток Данных

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

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

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

Введение

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

В этом разделе мы подробнее разберём эти термины и концепции, а также глубже изучим, как данные циркулируют в приложении на Redux.

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

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

Внимание!

Обратите внимание, что в этом руководстве намеренно используются устаревшие шаблоны логики Redux, требующие больше кода, чем "современные Redux" шаблоны с Redux Toolkit, которые мы рекомендуем как правильный подход для создания приложений на Redux сегодня. Это сделано для объяснения принципов и концепций Redux. Данный материал не предназначен для использования в production-проектах.

Чтобы изучить "современный Redux" с Redux Toolkit, перейдите по ссылкам:

Базовые концепции

Прежде чем перейти к коду, разберём основные термины и концепции, необходимые для работы с 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')

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

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

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

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

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

Действия (Actions)

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

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

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

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

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

Редюсеры (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/incremented') {
// 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/incremented' },
{ type: 'counter/incremented' },
{ type: 'counter/incremented' }
]

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/incremented' })

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

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

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

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

const selectCounterValue = state => state.value

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

Основные концепции и принципы

Дизайн Redux можно свести к трём основным концепциям:

Единственный источник истины (Single Source of Truth)

Глобальное состояние вашего приложения хранится в виде объекта внутри единого хранилища. Любые данные должны существовать только в одном месте, а не дублироваться.

Это упрощает отладку и анализ состояния приложения при изменениях, а также централизует логику, работающую со всем приложением.

Совет

Это не означает, что каждое состояние должно попадать в хранилище Redux! Решайте, где хранить состояние — в Redux или компонентах UI — в зависимости от того, где оно требуется.

Состояние доступно только для чтения (State is Read-Only)

Единственный способ изменить состояние — отправить действие, объект, описывающий произошедшее событие.

Так UI не сможет случайно перезаписать данные, а отслеживание причин обновлений упрощается. Поскольку действия — это обычные JS-объекты, их можно логировать, сериализовать, сохранять и воспроизводить для отладки или тестирования.

Изменения выполняются чистыми функциями-редюсерами (Changes are Made with Pure Reducer Functions)

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

Поток данных в приложении Redux

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

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

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

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

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

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

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

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

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

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

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

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

Краткое содержание
  • Основные принципы Redux можно свести к трём пунктам
    • Глобальное состояние приложения хранится в едином хранилище
    • Состояние хранилища доступно остальным частям приложения только для чтения
    • Для обновления состояния в ответ на действия используются функции-редюсеры
  • Redux использует архитектуру приложения с «однонаправленным потоком данных»
    • Состояние описывает текущее положение приложения, а UI отрисовывается на его основе
    • При возникновении события:
      • UI диспетчеризует действие
      • Хранилище запускает редюсеры, обновляя состояние согласно произошедшему событию
      • Хранилище уведомляет UI об изменении состояния
    • UI перерисовывается с учётом нового состояния

Что дальше?

Теперь вы знакомы с ключевыми концепциями и терминами, описывающими компоненты Redux-приложения.

Давайте посмотрим, как эти элементы работают вместе, когда мы начнём создавать Redux-приложение в Части 3: Состояние, действия и редюсеры.