Redux Toolkit¶
Redux Toolkit - это пакет, облегчающий работу с Redux
. Он был разработан для решения трех главных проблем:
- Слишком сложная настройка хранилища (store)
- Для того, чтобы заставить
Redux
делать что-то полезное, приходится использовать дополнительные пакеты - Слишком много шаблонного кода (boilerplate)
Redux Toolkit
предоставляет инструменты для настройки хранилища и выполнения наиболее распространенных операций, а также содержит полезные утилиты, позволяющие упростить код.
Установка¶
Создание нового приложения с помощью Create React App
1 2 3 4 |
|
TypeScript
1 2 3 |
|
Добавление пакета в существующее приложение
1 2 3 |
|
Состав пакета¶
Redux Toolkit
включает в себя следующие API:
configureStore()
: обертка дляcreateStore()
, упрощающая настройку хранилища с настройками по умолчанию. Позволяет автоматически комбинировать отдельные частичные редукторы (slice reducers), добавлять промежуточные слои или посредников (middlewares), по умолчанию включаетredux-thunk
(преобразователя), позволяет использовать расширениеRedux DevTools
(инструменты разработчикаRedux
)createReducer()
: позволяет использовать таблицу поиска (lookup table) операций для редукторов случая (case reducers) вместо инструкцийswitch
. В данном API используется библиотекаimmer
, позволяющая напрямую изменять иммутабельный код, например, так:state.todos[3].completed = true
createAction()
: генерирует создателя операции (action creator) для переданного типа операции. Функция имеет переопределенный методtoString()
, что позволяет использовать ее вместо константы типаcreateSlice()
: принимает объект, содержащий редуктор, название части состояния (state slice), начальное значение состояния, и автоматически генерирует частичный редуктор с соответствующими создателями и типами операцииcreateAsyncThunk()
: принимает тип операции и функцию, возвращающую промис, и генерируетthunk
, отправляющий типы операцииpending/fulfilled/rejected
на основе промисаcreateEntityAdapter()
: генерирует набор переиспользуемых редукторов и селекторов для управления нормализованными данными в хранилище- утилита
createSelector()
из библиотекиReselect
Настройка хранилища (store setup)¶
Разработка любого Redux
-приложения предполагает создание и настройку хранилища. Как правило, данный процесс состоит из следующих этапов:
- Импорт или создание корневого редуктора (root reducer)
- Настройка
middleware
, как минимум, для работы с асинхронным кодом - Настройка инстурментов разработчика
Redux
- Возможно, программное изменение кода в зависимости от режима разработки
Ручная настройка¶
Приведенный ниже пример демострирует типичный процесс настройки хранилища:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
|
Проблемы данного подхода:
- Аргументы
(rootReducer, preloadedState, enhancer)
должны быть переданы функцииcreateStore()
в правильном порядке - Процесс настройки
middlewares
иenhancers
(усилителей) может быть сложным, особенно, при попытке добавить несколько частей конфигурации - Документация
Redux DevTools Extension
рекомендует использовать некоторый код для проверки глобального пространства имен для опеределения доступности расширения. Копирование/вставка предложенного сниппета усложняет последующее изучение кода
Упрощение настройки с помощью configureStore()
¶
configureStore()
помогает решить названные проблемы следующим образом:
- Принимает объект с "именованными" параметрами, что облегчает изучение кода
- Позволяет передавать массив
middlewares
иenhancers
, автоматически вызываяapplyMiddleware()
иcompose()
- автоматически включает расширение
Redux DevTools
Кроме того, configureStore()
автоматически добавляет следующих посредников:
redux-thunk
- наиболее часто используемый промежуточный слой для работы с синхронной и асинхронной логикой за пределами компонентов- в режиме разработки, промежуточный слой для обнаружения распространенных ошибок, вроде мутирования состояния или использования несериализуемых значений
Простейшим способом создания и настройки хранилища является передача в configureStore()
корневого редуктора в качестве аргумента reducer
:
1 2 3 4 5 6 7 8 |
|
Также допускается передавать объект с частичными редукторами, в этом случае configureStore()
автоматически вызывает combineReducers()
:
1 2 3 4 5 6 7 8 9 |
|
Обратите внимание, что это работает только для одного уровня вложенности. Если требуются вложенные редукторы, придется вызывать combineReducers()
самостоятельно.
Для кастомизации настройки хранилища, можно передать дополнительные опции. Вот пример "горячей" перезагрузки:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
Создание редукторов¶
Редукторы - это самая важная часть Redux
. Как правило, редуктор отвечает за:
- Определение характера ответа на основе поля
type
объекта операции - Обновление состояния посредством копирования части состояния и модификации этой копии
Хотя в редукторе можно использовать любую условную логику, наиболее распространенным и простым способом является использование инструкции switch
. Однако, многим не нравится switch
. В документации по Redux
приводится пример создания функции, выступающей в роли поисковой таблицы на основе типов операции.
Другой проблемой, возникающей при написании редукторов, является необходимость "иммутабельного" обновления состояния. JavaScript - это язык, допускающий мутации, ручное обновление вложенных структур - задача не из простых, легко допустить ошибку.
Упрощение создания редукторов с помощью createReducer()
¶
Функция createReducer()
похожа на функцию создания поисковой таблицы из документации по Redux
. В ней используется библиотека immer
, что позволяет писать "мутирующий" код, обновляющий состояние иммутабельно. Это защищает от непреднамеренного мутирования состояния в редукторе.
Любой редуктор, в котором используется инструкция switch
, может быть преобразован с помощью createReducer()
. Каждый case
становится ключом объекта, передаваемого в createReducer()
. Иммутабельные обновления, такие как распаковка объектов или копирование массивов, могут быть преобразованы в "мутации". Но это не обязательно: можно оставить все как есть.
Ниже приводится пример использования createReducer()
. Начнем с типичного редуктора для списка задач, в котором используется инструкция switch
и иммутабельные обновления:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
Обратите внимание, что мы вызываем state.concat()
для получения копии массива с новой задачей, state.map()
также для получения копии массива, и используем оператор spread
для создания копии задачи, подлежащей обновлению.
С помощью createReducer()
мы можем сократить приведенный пример следующим образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
Возможность "мутировать" состояние особенно полезна при необходимости обновления глубоко вложенных свойств. Например, такой код:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
Можно упростить так:
1 2 3 4 |
|
Гораздо лучше!
Функция createReducer()
может быть очень полезной, но следует помнить о том, что:
- "Мутирующий" код правильно работает только внутри
createReducer()
Immer
не позволяет смешивать "мутирование" черновика (draft
) состояния и возвращение нового состояния
Определение создателей операции (action creators)¶
Redux
рекомендует использовать "создателей операции" для инкапсуляции процесса создания объектов операции. Это не является обязательным.
Большинство создателей операции очень простые. Они принимают некоторые параметры и возвращают объект операции с определенным полем type
и параметрами, необходимыми для выполнения операции. Данные параметры, обычно, помещаются в поле payload
. Типичный создатель операции выглядит так:
1 2 3 4 5 6 |
|
Определение создателей операции с помощью createAction()
¶
Написание создателей операции вручную может быть утомительным. Redux Toolkit
предоставляет функцию createAction()
, которая генерирует создателя операции с указанным типом операции и преобразует переданные аргументы в поле payload
:
1 2 3 |
|
createAction()
также принимает аргумент-колбек prepare
, позволяющий кастомизировать результирующее поле payload
и добавлять поле meta
, при необходимости.
Использование создателей в качестве типов операции (action types)¶
Для определения того, как должно быть обновлено состояние, редукторы полагаются на тип операции. Обычно, это делается посредством раздельного определения типов и создателей операции. createAction()
позволяет упростить данный процесс.
Во-первых, createAction()
перезаписывает метод toString()
генерируемых создателей. Это означает, что создатель может использовать в качестве ссылки на "тип операции", например, в ключах, передаваемых в builder.addCase()
или объектной нотации createReducer()
.
Во-вторых, тип операции также определяется как поле type
создателя.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
Это означает, что нам не нужно создавать отдельную переменную для типа операции или дублировать название и значение типа, например: const SOME_ACTION_TYPE = 'SOME_ACTION_TYPE'
.
К сожалению, неявного приведения к строке не происходит в инструкции switch
. Приходится делать это вручную:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
При использовании Redux Toolkit
с TypeScript
, принимайте во внимание, что компилятор TypeScript
может не осуществлять неявного преобразования в строку, когда создатель используется как ключ объекта. В этом случае также может потребоваться прямое указание типа создателя (actionCreator as string
) или использование поля type
в качестве ключа объекта.
Создание частей состояния (slices of state)¶
В Redux
состояние, обычно, делится на "части", определяемые редукторами, передаваемыми в combineReducers()
:
1 2 3 4 5 6 7 8 |
|
В приведенном примере users
и posts
являются "частями". Оба редуктора:
- "Владеют" частью состояния, включая его начальное значение
- Определяют, как состояние обновляется
- Определяют, какие операции приводят к обновлению состояния
Общий подход состоит в определении частичного редуктора в одном файле, а создателей - в другом. Поскольку и редуктор, и создатели используют одни и те же типы операции, эти типы, как правило, определяются в третьем файле и импортируются в другие файлы:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
|
Единственная по-настоящему полезная часть - это редуктор. Что касается других частей, то:
- Мы могли бы указывать типы операции как строки в обоих местах
- Создатели - полезная штука, но они не являются обязательными - компонент может "пропустить" аргумент
mapDispatch
вconnect()
и просто вызватьprops.dispatch({ type: 'CREATE_POST', payload: { id: 123, title: 'Привет, народ!' } })
- Единственная причина создания нескольких файлов заключается в практике разделения кода по принципу того, что он делает
"Утиная" структура файла предлагает размещать логику, связанную с Redux
, для определенной части в одном файле:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
Это облегчает задачу, но нам по-прежнему приходится вручную писать типы и создателей операции.
Определение функций в объектах¶
В современном JS существует несколько способов определения ключей и функций в объекте:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
Упрощение создания частей состояния с помощью createSlice()
¶
createSlice()
автоматически генерирует типы и создателей операции на основе переданного названия редуктора:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
|
createSlice()
анализирует функции, определенные в поле reducers
, создает редуктор для каждого случая и генерирует создателя, использующего название редуктора в качестве типа операции. Таким образом, редуктор createPost
становится типом операции posts/createPost
, а создатель createPost()
возвращает операцию с этим типом.
Экспорт и использование частей¶
Обычно, мы определяем часть и экспортируем создателей и редукторы:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
Асинхронная логика и получение данных¶
Использование посредника для работы с асинхронным кодом¶
Хранилище Redux
ничего не знает об асинхронной логике. Оно знает только о том, как синхронно отправлять операции, обновлять состояние посредством вызова корневого редуктора и уведомлять UI об изменениях. Любые асинхронные операции должны выполняться за пределами хранилища.
Но что если нам нужна асинхронная логика, которая взаимодействует с хранилищем, отправляя операции или проверяя текущее состояние хранилища? Здесь в игру вступают посредники. Они расширяют хранилище, позволяя делать следующее:
- Выполнять дополнительную логику при отправке операции (например, выводить информацию об операции и состоянии)
- Приостанавливать, модифицировать, задерживать выполнение или полностью останавливать отправку операции
- Писать дополнительный код, имеющий доступ к
dispatch()
иgetState()
- "Обучать"
dispatch()
принимать значения, отсутствующие в объектах операции, такие как функции и промисы, перехватывая их и отправляя "настоящие" объекты операции
Ниболее частым случаем использования посредников является обеспечение взаимодействия асинхронной логики с хранилищем. Это позволяет писать код, отправляющий операции и проверяющий хранилище, сохраняя данную логику независимой от UI.
Существует несколько посредников для реализации асинхронности в Redux
. Рекомендуемым является Redux Thunk
. Он прекрасно подходит для большинства случаев, а использование синтаксиса async/await
делает его еще лучше.
configureStore()
устанавливает thunk
автоматически.
Определение асинхронной логики в частях¶
Преобразователи не могут быть определены в createSlice()
. Их нужно писать отдельно от логики редуктора.
Преобразователи, как правило, отпрвляют обычные операции, такие как dispatch(dataLoaded(response.data))
.
Многие Redux
-приложения структурируются по принципу "директория-тип". В такой структуре преобразователи, обычно, определяются в файле "actions", отдельно от обычных создателей.
Поскольку у нас таких файлов нет, имеет смысл определять преобразователей прямо в файлах "slice":
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
|
Шаблоны получения данных в Redux
¶
Логика получения данных в Redux
, обычно, следует такому шаблону:
- Перед запросом в качестве индикатора его выполнения отправляется операция "start". Это может использоваться для отслеживания состояния загрузки во избежание дублирования запросов или для отображения индикаторов загрузки в UI
- Выполнение асинхронного запроса
- В зависимости от результата запроса, отправляется либо операция "success" с данными, либо операция "failure" с ошибкой. В обоих случаях редуктор очищает состояние загрузки и либо обрабатывает данные либо сохраняет ошибку для ее потенциального отображения
Стандартная реализация может выглядеть следующим образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
Написание кода указанным способом может быть утомительным. Каждый тип запроса требует повторения одинаковой реализации:
- Уникальные типы операции должны быть определены для трех разных случаев
- Каждый их этих типов, обычно, имеет соответствующего создателя
- Преобразователь должен отправлять правильные операции в правильном порядке
createAsyncThunk()
абстрагирует данный паттерн, генерируя типы, создателей операции и преобразователя, отправляющего эти операции.
Выполнение асинхронных запросов с помощью createAsyncThunk()
¶
createAsyncThunk()
упрощает процесс выполнения асинхронных запросов - мы передаем ему строку для префикса типа операции и колбек создателя полезной нагрузки (payload), выполняющего реальную асинхронную логику и возвращающего промис с результатом. createAsyncThunk()
возвращает преобразователя, который заботится об отправке правильных операций на основе возвращенного промиса, и типы операции, которые можно обработать в редукторах:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
|
Создатель операции преобразователя принимает единственный аргумент, который он передает в качестве первого аргумента в колбек создателя полезной нагрузки.
Последний также принимает объект thunkAPI
, содержащий параметры, которые передаются стандартному thunk
, а также авто-генерируемый уникальный идентификатор запроса и объект AbortController.signal
:
1 2 3 4 5 6 7 |
|
Управление нормализованными данными¶
Большинство приложений имеют дело с глубоко вложенными и связанными между собой данными. Цель нормализации данных состоит в эффективной организации данных состояния. Как правило, это реализуется посредством создания коллекции объектов с id
в качестве ключей и отсортированного массива этих ключей.
Ручная нормализация¶
Нормализация данных не требует использования специальной библиотеки. Ниже приводится пример ручной нормализации ответа от API fetchAll
, возвращающего данные в виде { users: [{id: 1, first_name: 'normalized', last_name: 'person'}] }
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
|
Нормализация с помощью normalizr
¶
normalizr
- это популярная библиотека для нормализации данных. Она очень часто используется с Redux
. Типичным случаем ее использования является форматирование ответа от API и последующая обработка данных в редукторах:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
|
Нормализация с помощью createEntityAdapter()
¶
createEntityAdapter()
предоставляет стандартизированный способ хранения данных путем преобразования коллекции в форму { ids: [], entities: {} }
. Кроме предопределения формы состояния, эта функция генерирует набор редукторов и селекторов, которые знают, как работать с такими данными.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
|
Использование createEntityAdapter()
совместно с библиотеками для нормализации¶
По умолчанию методы setAll()
, addMany()
и upsertMany()
ожидают получения массива сущностей (entities). Тем не менее, они также позволяют передавать объекты формы { 1: { id: 1, ... }}
, что облегчает добавление предварительно нормализованных данных.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
|
Использование селекторов с createEntityAdapter()
¶
Адаптер сущностей предоставляет фабрику селекторов, генерирующую наиболее востребованные селекторы. Мы можем создать селекторы для usersSlice
из приведенного выше примера следующим образом:
1 2 3 4 5 6 7 8 |
|
Пример использования селекторов в компоненте:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
|
Определение альтернативных полей id
¶
По умолчанию createEntityAdapter()
предполагает, что данные имеют уникальные идентификаторы в поле entity.id
. Если данные хранят идентификаторы в другом поле, можно передать аргумент selectId
, возвращающий соответствующее поле:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
Сортировка сущностей¶
createEntityAdapter()
предоставляет аргумент sortComparer
, который можно использовать для сортировки коллекции id
. Это может быть полезным в случае, когда мы хотим обеспечить правильный порядок сортировки и наши данные приходят неотсортированными.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
configureStore()
¶
Абстракция над стандартной функцией createStore()
, добавляющая полезные "дефолтные" настройки хранилища для лучшего опыта разработки.
Параметры¶
cofigureStore()
принимает следующий объект:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
|
Примеры использования¶
Основной
1 2 3 4 5 6 |
|
Полный
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
|
getDefaultMiddleware()
¶
Возвращает список дефолтных посредников.
Случаи использования¶
По умолчанию configureStore()
добавляет некоторых посредников автоматически:
1 2 3 4 5 |
|
Для кастомизации списка посредников можно передать массив в configureStore()
:
1 2 3 4 5 6 |
|
Обратите внимание, при кастомизации списка в хранилище будут добавлены только указанные посредники.
Для добавления посредников в дополнение к "дефолтным" следует использовать getDefaultMiddleware()
.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
Посредники по умолчанию¶
Режим разработки
immutableStateInvariant
- осуществляет глубокое сравнение значений состояния для обнаружения мутаций (прямых изменений значений состояния)serializableStateInvariant
- осуществляет глубокую проверку дерева состояния и операций для обнаружения несериализуемых значений, таких как функции, промисы, символы и т.д.thunk
- позволяет запускать побочные эффекты, такие как выполнение асинхронных запросов
Производственный режим
thunk
Кастомизация дефолтных посредников¶
getDefaultMiddleware()
принимает объект, позволяющий кастомизировать каждого посредника двумя способами:
- Исключение из результирующего массива путем передачи
false
для соответствующего поля - Настройка путем передачи объекта
В следующем примере мы отключаем посредника, отвечающего за обнаружение несериализуемых значений, и передаем значение для "дополнительного аргумента" (extra argument) преобразователя:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
createReducer()
¶
Обзор¶
createReducer()
- это утилита, упрощающая создание редукторов. Благодаря использованию библиотеки immer
, она позволяет напрямую "мутировать" состояние. Также она поддерживает преобразование типов операции в соответствующие редукторы, которые обновляют состояние при отправке этих типов.
Редукторы часто реализуются с помощью инструкции switch
с одним case
для каждого типа операции:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
Много шаблонного кода, подверженного ошибкам. Например, легко забыть указать default
или начальное состояние.
createReducer()
предлагает две формы создания редукторов: нотация "колбека строителя" ("builder callback" notaion) и нотация "объекта коллекции" ("map object" notation). Названные подходы являются эквивалентными, рекомендуется использовать первый вариант.
Создание редуктора с помощью createReducer()
выглядит так:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
|
Нотация "builder callback"¶
В данном случае createReducer()
принимает функцию обратного вызова, получающую объект builder
в качестве аргумента. "Строитель" предоставляет методы addCase()
, addMatcher()
и addDefaultCase()
, которые могут вызываться для определения действий, выполняемых редуктором.
Параметры¶
initialState
- начальное состояние, используемое при первом вызове редуктораbuilderCallback
- колбек, принимающий объектbuilder
для определения редуктора случая путемbuilder.addCase(actionCreatorOrType, reducer)
Пример использования¶
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
|
Методы "строителя"¶
addCase()
- добавляет редуктора случая для определенного типа операции. Вызов данного метода должен предшествовать вызову любогоaddMatcher()
илиaddDefaultCase()
. Параметры:actionCreator
- тип или создатель операции, сгенерированный с помощьюcreateAction()
, который может использоваться для определения типа операцииreducer
- логика редуктора для указанного случаяaddMacther()
- позволяет осуществлять проверку входящей операции с помощью собственных фильтров в дополнение к проверке типа. При регистрации нескольких совпадений, соответствующие функции выполняются в порядке определения. ВызовыaddMacther()
должны следовать после вызововaddCase()
, но перед вызовамиaddDefaultCase()
. Параметры:matcher
- функция поиска совпаденийreducer
- логика редуктора для указанного случая
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
|
addDefaultCase()
- добавляет дефолтный случай, когда для операции не были выполнены другие редукторы. Параметры:reducer
- логика редуктора для случая по умолчанию
1 2 3 4 5 6 7 8 9 10 |
|
Нотация "map object"¶
В данном случае createReducer()
принимает объект, в котором ключи являются типами операции, а значения - функциями для обработки этих типов.
Параметры¶
initialState
- начальное состояние, используемое при первом вызове редуктораactionsMap
- объект, связывающий типы операции с соответствующими редукторами, каждый из которых обрабатывает определенный типactionMatchers
- массив определенийmatcher
в форме{matcher, reducer}
. Все совпадения обрабатываются последовательно, независимо от совпадения с редуктором случаяdefaultCaseReducer
- дефолтный редуктор
Пример использования¶
1 2 3 4 |
|
Создатели операции, сгенерированные с помощью createAction()
, могут использоваться в качестве ключей с помощью синтаксиса вычисляемых свойств:
1 2 3 4 5 6 7 8 |
|
Поиск совпадений и дефолтный случай¶
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
Прямое изменение состояния¶
Redux
требует, чтобы редукторы были чистыми функциями, а значения состояния - иммутабельными. Несмотря на то, что это обеспечивает предсказуемость и отслеживаемость обновлений состояния, порой это делает реализацию таких обновлений неудобной. Рассмотрим следующий пример:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
createReducer()
использует immer
, библиотеку, позволяющую изменять состояние напрямую. В действительности, редуктор получает проксированное состояние, преобразующее все мутации в эквивалентные операции копирования.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
Использование immer
накладывает некоторые ограничения, наиболее важным из которых является то, что мы не должны одновременно мутировать состояние и возвращать новое состояние. Например, следующий редуктор выбросит исключение при передаче операции toggleTodo
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
Выполнение нескольких редукторов¶
Изначально createReducer()
сопоставляет тип операции и редуктор, и только совпавший редуктор выполняется.
Использование нескольких matcher
изменяет это поведение, несколько matcher
могут обрабатывать одну операцию.
Для любой отправленной операции характерно следующее:
- Если имеет место точное совпадение типа операции, выполняется соответствующий редуктор случая
- Любой
matcher
, возвращающийtrue
, выполняется в порядке определения - Если указан дефолтный редуктор и не запущено выполнение другого редуктора, будет выполнен данный редуктор
- Если ни один редуктор случая или совпадения не запущен, значение состояния возвращается без изменения
Редукторы выполняются последовательно, каждый следующий редуктор получает результат от предыдущего:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
Логгирование "черновых" значений состояния¶
В createSlice()
или createReducer()
для получения удобочитаемой копии текущего значения состояния Draft
(черновика - объекта, предоставляемого immer
) можно использовать current
:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
createAction()
¶
Вспомогательная функция для определения типа и создателя операции.
1 |
|
Обычным способом определения операции в Redux
является объявление константы типа операции и функции создателя операции для конструирования операций данного типа.
1 2 3 4 5 6 7 8 9 10 11 |
|
createAction()
объединяет эти два объявления в одно. Она принимает тип операции и возвращает создателя операции для этого типа. Создатель может вызываться без аргументов или с аргументом payload
, добавляемым к операции. Кроме того, создатель перезаписывает метод toString()
, поэтому тип операции становится его строковым представлением.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
Использование подготовительных колбеков (prepare callbacks) для кастомизации контента операции¶
По умолчанию генерируемый создатель операции принимает один аргумент, который становится action.payload
. Это предполагает некоторую подготовку полезной нагрузки.
Во многих случаях нам может потребоваться дополнительная логика для кастомизации создания значения payload
, например, обеспечение возможности получения создателем нескольких параметров, генерация случайного id
, получение текущей даты и времени и т.д. Для этого createAction()
принимает второй аргумент: "prepare callback", который используется для формирования значения полезной нагрузки:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
Все аргументы из создателя передаются в подготовительный колбек. Колбек должен вернуть объект с полем payload
(в противном случае, полезная нагрузка будет иметь значение undefined
). Данный объект также может иметь поля meta
и/или error
, которые также будут добавлены в создателя. meta
может содержать дополнительную информацию об операции, error
может содержать подробности о провале операции.
Обратите внимание: поле type
добавляется автоматически.
Использование с createReducer()
¶
Благодаря перезаписи toString()
создатели, возвращаемые createAction()
, могут использоваться в качестве ключей в редукторах случая, передаваемых в createReducer()
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
Важно: несмотря на то, что Redux
позволяет использовать в качестве типа операции любое значение, настоятельно рекомендуется использовать только строки. Это объясняется дополнительным функционалом перезаписанного toString()
.
actionCreator.match¶
Каждый создатель, сгенерированный с помощью createAction()
, имеет метод match(action)
, который может использоваться для определения того, что переданная операция имеет такой же тип, что и операция, возвращаемая создателем.
createSlice()
¶
Функция, принимающая начальное состояние, объект с редукторами и "название части", и автоматически генерирующая создателей и типы операции, связанные с редукторами и состоянием.
Данный API является стандартным подходом к написанию логики Redux
.
Он использует createAction()
и createReducer()
, что позволяет использовать immer
для "мутирования" состояния:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
|
Параметры¶
createSlice()
принимает следующий объект:
1 2 3 4 5 6 7 8 9 10 11 |
|
Кастомизация генерируемых создателей операции¶
Для кастомизации создания значения полезной нагрузки создателя операции с помощью подготовительного колбека значением соответствующего поля аргумента reducers
должен быть объект, а не функция. Данный объект должен содержать два свойства: reducer
и prepare
. Значением reducer
должна быть функция редуктора случая, а значением prepare
- подготовительная функция обратного вызова:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
extraReducers
¶
Одной из ключевых концепций Redux
является то, что каждый частичный редуктор "владеет" определенной частью состояния и несколько частичных редукторов могут независимо обрабатывать один тип операции. extraReducers
позволяет createSlice()
обрабатывать дополнительные типы операции.
Поскольку редукторы случая, определенные с помощью extraReducers
, считаются обработчиками "внешних" операций, их операции не попадают в slice.actions
.
Такие редукторы также передаются в createReducer()
и могут безопасно "мутировать" их состояние.
Если два поля из reducers
и extraReducers
регистрируют один и тот же тип операции, для обработки данного типа будет вызвана функция из reducers
.
Использование "builder callback" для extraReducers
¶
Рекомендуемый способ использования extraReducers
заключается в передаче ему колбека, принимающего экземпляр ActionReducerMapBuilder
.
Нотация "строителя" - это также единственный способ добавления редукторов совпадения и дефолтного редуктора для части состояния:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
|
Использование нотации "map object" для extraReducers
¶
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
Возвращаемое значение¶
createSlice()
возвращает такой объект:
1 2 3 4 5 6 |
|
Каждая функция, определенная в reducers
, получает соответствующего создателя операции, генерируемого с помощью createAction()
, и включается в actions
под тем же названием.
Генерируемый редуктор подходит для передачи в функцию combineReducers()
в качестве "частичного редуктора".
Создателей операции можно деструктурировать и экспортировать по отдельности.
Полный пример¶
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
|
createAsyncThunk()
¶
Обзор¶
Функция, которая принимает тип операции и колбек, возвращающий промис. Генерирует типы операции, соответствующие жизненному циклу промиса на основе переданного префикса типа операции, и возвращает преобразователь создателя операции, который запускает колбек промиса и отправляет операции жизненного цикла на основе возвращенного промиса.
Данная абстракция является рекомендуемым подходом к обработке жизненных циклов асинхронных запросов.
Она не генерирует редукторы, поскольку не знает, какие данные запрашиваются, как отслеживается состояние загрузки или как данные будут обрабатываться. Поэтому для обработки этих операций нужны отдельные редукторы.
Простой пример использования:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
|
Параметры¶
createAsyncThunk()
принимает три параметра: значение type
, колбек payloadCreator
и объект options
.
type
¶
Строка, которая используется для генерации дополнительных констант типа операции, представляющих жизненный цикл асинхронного запроса.
Например, аргумент type
users/requestStatus
сгенерирует следующие типы операции:
pending
: users/requestStatus/pendingfulfilled
: users/requestStatus/fulfilledrejected
: users/requestStatus/rejected
payloadCreator()
¶
Колбек, возвращающий промис, содержащий результат некоторой асинхронной логики. Может возвращать значение синхронно. При возникновении ошибки, должен вернуть отклоненный промис, содержащий экземпляр Error
или обычное значение, такое как описательное сообщение об ошибке, или же разрешенный промис с аргументом RejectWithValue
- результатом вызова функции thunkAPI.rejectWithValue
.
payloadCreator()
может содержать любую логику, необходимую для вычисления результата. Это может включать стандартный AJAX-запрос данных или несколько вызовов AJAX с результатами, объединяемыми в результирующее значение, взаимодействие с AsyncStorage
React Native и т.д.
payloadCreator()
принимает два аргумента:
arg
: простое значение, содержащее первый параметр, переданныйthunk
при его отправке. Это может быть полезным для отправки идентификаторов, включаемых в запрос. Если требуется передать несколько значений, это можно сделать с помощью объекта, например:dispatch(fetchUsers({status: 'active', sortBy: 'name'}))
thunkAPI
: объект, содержащий все параметры, обычно передаваемый вthunk
, а также дополнительные опции:dispatch
: методdispatch
хранилищаRedux
getState
: методgetState
хранилищаRedux
extra
: "дополнительный аргумент", переданный посредникуthunk
в момент настройки хранилища, если доступенrequestId
: уникальныйid
, автоматически генерируемый для идентификации данной последовательности запросаsignal
: объектAbortController.signal
, который может использоваться для обнаружения отмены запроса другой частью приложенияrejectWithValue
: утилита, которую можно вернуть в создателя операции для получения отклоненного промиса с определенной полезной нагрузкой. Она передаст любое указанное значение и вернет его в виде полезной нагрузки отклоненной операции
Options¶
Объект, содержащий следующие опциональные поля:
condition
: колбек, который может использоваться для пропуска выполнения создателя полезной нагрузки и всех отправляемых операцийdispatchConditionRejection
: еслиcondition()
возвращаетfalse
, поведением по умолчанию является отмена отправки всех операций. Если требуется отправить "отклоненную" операцию при отменеthunk
, данному полю необходимо присвоить значениеtrue
Возвращаемое значение¶
createAsyncThunk()
возвращает стандартного создателя операции thunk
. thunk
включает создателей для случаев pending
, fulfilled
и rejected
в виде вложенных полей.
В приведенном выше примере createAsyncThunk()
создает четыре функции:
fetchUserById
- преобразователь, запускающий все указанные асинхронные колбеки полезной нагрузкиfetchUserById.pending
- создатель, отправляющий операциюusers/fetchByIdStatus/pending
fetchUserById.fulfilled
- создатель, отправляющий операциюusers/fetchByIdStatus/fulfilled
fetchUserById.rejected
- создатель, отправляющий операциюusers/fetchByIdStatus/rejected
При отправке преобразователь делает следующее:
- отправляет операцию
pending
- вызывает колбек
payloadCreator
и ждет возвращения промиса - при разрешении промиса:
- если промис успешно разрешен, отправляет операцию
fulfilled
со значением промиса в видеaction.payload
- если промис разрешился со значением, возвращенным
rejectWithValue(value)
, отправляет операциюrejected
со значением, переданным вaction.payload
, и "отклоняется" какaction.error.message
- если промис провалился и не был обработан с помощью
rejectWithValue
, отправляет операциюrejected
с сериализованной версией ошибки какaction.error
- возвращает разрешенный промис, содержащий финальную отправленную операцию (объект операции
fulfilled
илиrejected
)
Операции жизненного цикла промиса¶
createAsyncThunk()
генерирует трех создателей операции с помощью createAction()
: pending
, fulfilled
и rejected
. Каждый создатель жизненного цикла привязывается к возвращаемому thunk
, поэтому логика редуктора может ссылаться на типы операции и реагировать на их отправку. Каждый объект операции содержит текущий уникальный requestId
и args
в action.meta
.
Создатели операции имеют такую сигнатуру:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
|
Эти операции могут обрабатываться через ссылки на создателей операции с createReducer()
или createSlice()
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
|
Обработка результатов преобразования¶
Распаковка результирующих операций
Преобразователи могут возвращать значение при отправке. Распространенной практикой является возвращение промиса из thunk
, его отправка из компонента и ожидание разрешения промиса перед выполнением каких-либо действий:
1 2 3 4 5 |
|
Преобразователи, созданные с помощью createAsyncThunk()
всегда возвращают разрешенный промис либо с объектом операции fulfilled
, либо с объектом операции rejected
.
Redux Toolkit
экспортирует функцию unwrapResult
, которая может использоваться для извлечения payload
операции fulfilled
или для выбрасывания error
или payload
, созданного rejectWithValue
из операции rejected
:
1 2 3 4 5 6 7 8 9 |
|
Отмена¶
Отмена перед выполнением¶
Для отмены thunk
перед вызовом создателя нагрузки можно передать колбек condition
после создателя операции. Колбек получит аргумент thunk
и объект с параметрами {getState, extra}
, который он использует для определения того, следует продолжать или нет. Если выполнение должно быть отменено, колбек condition()
должен вернуть значение false
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
Если condition()
возвращает false
, никакие операции не отправляются. Если требуется отправить операцию "отклонения" при отмене thunk
, следует передать {condition, dispatchConditionRejection: true}
.
Отмена в процессе выполнения¶
Для отмены запущенного thunk
можно использовать метод abort
промиса, возвращаемого dispatch(fetchUserById(userId))
. Пример:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
После отмены thunk
таким способом, он отправит (и вернет) операцию thunkName/rejected
с AbortError
в свойстве error
. Другие операции отправлены не будут.
payloadCreator()
может использовать AbortSignal
, переданный через thunkAPI.signal
для отмены "дорогостоящих" асинхронных операций.
Fetch API
поддерживает AbortSignal
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
Примеры¶
- Получение данных пользователя по идентификатору с состоянием загрузки и отправкой одного запроса за раз:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
|
- Использование
rejectWithValue
для доступа к кастомной отклоненной нагрузке в компоненте
Обратите внимание: это надуманный пример, наш userAPI
всегда выбрасывает ошибки валидации
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
|
createEntityAdapter()
¶
Обзор¶
Функция, генерирующая набор встроенных редукторов и селекторов для выполнения GRUD-операций с нормализованной структурой состояния определенного типа объекта данных. Эти редукторы могут передаваться в качестве редукторов случая в createReducer()
и createSlice()
. Они также могут использоваться как помощники "мутации" ("mutation" helpers) внутри createReducer()
и createSlice()
.
Данный API был портирован из библиотеки @ngrx/entity
, созданной командой NgRx
, но существенно модифицирован для лучшей интеграции с Redux Toolkit
.
Обратите внимание: термин "сущность" (entity) означает уникальный тип объекта данных приложения. Например, в блоге такими объектами данных могут быть User
, Post
и Comment
, каждый с несколькими экземплярами, хранящимися на клиенте и на сервере. User
- это "сущность", уникальный тип объекта данных, используемый приложением. Каждый экземпляр сущности имеет уникальный id
в соответствующем поле.
В хранилище могут передаваться только обычные JS-объекты и массивы, но не экземпляры классов.
В дальнейшем термин Entity
будет использоваться для обозначения специфического типа данных, управляемого копией редуктора в определенной части дерева состояния, а термин entity
- для обозначения единичного экземпляра этого типа. Например: в state.users
Entity
указывает на тип User
, а state.users.entities[123]
- это единичный entity
.
Методы, генерируемые createEntityAdapter()
, манипулируют структурой "состояния сущности", которая выглядит так:
1 2 3 4 5 6 7 |
|
createEntityAdapter()
может вызываться несколько раз в приложении. Определение адаптера можно повторно использовать для нескольких типов сущности, если они в достаточной степени похожи между собой (например, все имеют поле entity.id
).
Простой пример:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
|
Параметры¶
createEntityAdapter()
принимает объект с двумя полями:
selectId
- функция, принимающая экземплярEntity
и возвращающая значение уникального поля. Реализацией по умолчанию являетсяentity => entity.id
. Если сущность хранит уникальные значения в поле, отличающемся отentity.id
, необходимо добавить реализациюselectId
.sortComparer
- колбек, принимающий два экземпляра сущности и возвращающий числовой результат стандартного методаArray.sort()
(1, 0, -1) для определения порядка сортировки.
Возвращаемое значение¶
Экземпляр "адаптера сущности". Объект, содержащий сгенерированные редукторы, переданные selectId
и sortComparer
, метод для инициализации начального значения "состояния сущности" и функцию для генерации набора мемоизированных селекторов для данного типа сущности.
CRUD функции¶
Основное содержимое адаптера сущности - это набор редукторов для добавления, обновления и удаления экземпляров из объекта состояния:
addOne
- принимает единичную сущность и добавляет ееaddMany
- принимает массив сущностей или объект определенной формы и добавляет ихsetAll
- принимает массив сущностей или объект определенной формы и заменяет контент существующих сущностей значениями из массиваremoveOne
- принимает единичное значениеid
и удаляет соответствуюую сущность, если она имеетсяremoveMany
- принимает массив значенийid
и удаляет соответствующие сущностиupdateOne
- принимает "объект обновления", содержащийid
сущности и объект с одним и более новыми значениями полей в полеchanges
, и выполняет поверхностное обновление соответствующей сущностиupdateMany
- принимает массив объектов обновления и выполняет поверхностное обновление соответствующих сущностейupsertOne
- принимает единичную сущность. Если сущность с указаннымid
существует, выполняется ее поверхностное обновление и объединение полей. Значения совпадающих полей перезаписываются. Если сущность с указаннымid
отсутствует, она добавляетсяupsertMany
- принимает массив сущностей или объект определенной формы и выполняетupsertOne
для каждой сущности
Сигнатура каждого метода выглядит так:
1 |
|
Другими словами, методы принимают состояние в виде {ids: [], entities: {}}
, вычисляют и возвращают новое состояние.
Эти методы могут использоваться разными способами:
- Могут передаваться в редукторы напрямую в
createReducer()
иcreateSlice()
- Могут использоваться в качестве помощников "мутации", когда вызываются вручную, например, когда
addOne()
вызывается в существующем редукторе дляstate
, которое на самом деле является значениемDraft
изimmer
- Могут использоваться как методы иммутабельного обновления, когда вызываются вручную, если
state
является обычным JS-объектом или массивом
Обратите внимание: данные методы не имеют соответствующих операций - они представляют собой автономную логику редукторов / обновления. Где и как их использовать, зависит только от нас. В большинстве случаев они передаются в createSlice()
или используются внутри других редукторов.
Каждый метод проверяет, является ли state
черновиком. Если является, метод решает, что дальнейшее мутирование является безопасным. Если не является, метод передает объект в createNextState()
и возвращает иммутабельно обновленный результат.
argument
может быть обычным значением (таким как единичный объект Entity
для addOne()
или массив Entity
для addMany()
) или объектом операции PayloadAction
со значением, аналогичным action.payload
. Это позволяет использовать их и как вспомогательные функции, и как редукторы.
Обратите внимание: методы updateOne()
, updateMany()
, upsertOne()
и upsertMany()
выполняют поверхностное обновление. Это означает, что если мы обновляем/заменяем содержимое объекта, включающего вложенные свойства, эти свойства будут перезаписаны переданными значениями. Поэтому указанные методы могут использоваться только для нормализованных данных, не имеющих вложенных свойств.
getInitialState()
¶
Возвращает новый объект состояния сущности вида {ids: [], entities: {}}
.
Принимает опциональный объект в качестве аргумента. Поля этого объекта будут объединены с возвращаемым начальным состоянием. Предположим, что мы хотим, чтобы наша часть также отслеживала состояние загрузки:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
Селекторы¶
Адаптер содержит функцию getSelectors()
, возвращающую набор селекторов, которые умеют читать содержимое объекта состояния:
selectIds
- возвращает массивstate.ids
selectEntities
- возвращает поисковую таблицуstate.entities
selectAll
- проходится по массивуstate.ids
и возвращает массив сущностей в том же порядкеselectTotal
- возвращает общее количество сущностей, хранящихся в состоянииselectById
- принимает состояние иid
, возвращает сущность с даннымid
илиundefined
Каждый селектор создается с помощью функции createSelector()
из Reselect
, поэтому может мемоизировать вычисления результатов.
Поскольку селекторы полагаются на нахождение указанного объекта состояния сущности в определенном месте дерева состояния, getSelectors()
может вызываться двумя способами:
- Если вызывается без аргументов, возвращает "неглобализированный" набор селекторов, предполагающих, что аргумент
state
- это актуальный объект состояния сущности для чтения - Может вызываться с селектором, принимающим определенную сущность дерева состояния. В этом случае он возвращает правильный объект состояния сущности
Например, состояние для типа Book
может храниться в дереве состояния как state.books
. Мы можем использовать getSelectors()
для чтения состояния двумя способами:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
Применение нескольких обновлений¶
Если updateMany()
вызван с несколькими обновлениями для одной сущности, эти обновления будут объединены в одно, последующие обновления перезапишут предыдущие.
Изменение идентификатора существующей сущности с помощью updateOne()
или updateMany()
, приводящее к совпадению с идентификатором другой существующей сущности, заканчивается тем, что первая сущность полностью заменяет вторую.
Пример¶
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
|