Лучшие редукторы с Immer
Опубликовано: 2022-03-10Как разработчик React, вы уже должны быть знакомы с принципом, согласно которому состояние не должно изменяться напрямую. Вам может быть интересно, что это значит (у большинства из нас была такая путаница, когда мы только начинали).
Этот туториал отдаст должное этому: вы поймете, что такое неизменяемое состояние и зачем оно нужно. Вы также узнаете, как использовать Immer для работы с неизменяемым состоянием и узнаете о преимуществах его использования. Вы можете найти код в этой статье в этом репозитории Github.
Неизменяемость в JavaScript и почему это важно
Immer.js — это крошечная библиотека JavaScript, написанная Мишелем Вестстратом, чья заявленная миссия — позволить вам «работать с неизменяемым состоянием более удобным способом».
Но прежде чем погрузиться в Immer, давайте быстро вспомним неизменность в JavaScript и почему это важно в приложении React.
Последний стандарт ECMAScript (он же JavaScript) определяет девять встроенных типов данных. Из этих девяти типов шесть называются primitive
значениями/типами. Эти шесть примитивов undefined
, number
, string
, boolean
, bigint
и symbol
. Простая проверка с помощью оператора JavaScript typeof
покажет типы этих типов данных.
console.log(typeof 5) // number console.log(typeof 'name') // string console.log(typeof (1 < 2)) // boolean console.log(typeof undefined) // undefined console.log(typeof Symbol('js')) // symbol console.log(typeof BigInt(900719925474)) // bigint
primitive
— это значение, которое не является объектом и не имеет методов. Наиболее важным для нашего сегодняшнего обсуждения является тот факт, что значение примитива не может быть изменено после его создания. Таким образом, примитивы называются immutable
.
Остальные три типа — null
, object
и function
. Мы также можем проверить их типы с помощью оператора typeof
.
console.log(typeof null) // object console.log(typeof [0, 1]) // object console.log(typeof {name: 'name'}) // object const f = () => ({}) console.log(typeof f) // function
Эти типы mutable
. Это означает, что их значения могут быть изменены в любое время после их создания.
Вам может быть интересно, почему у меня там массив [0, 1]
. Ну, в JavaScriptland массив — это просто особый тип объекта. В случае, если вам также интересно узнать о null
и о том, чем он отличается от undefined
. undefined
просто означает, что мы не установили значение для переменной, а null
— это особый случай для объектов. Если вы знаете, что что-то должно быть объектом, но объекта там нет, вы просто возвращаете null
.
Чтобы проиллюстрировать это на простом примере, попробуйте запустить приведенный ниже код в консоли браузера.
console.log('aeiou'.match(/[x]/gi)) // null console.log('xyzabc'.match(/[x]/gi)) // [ 'x' ]
String.prototype.match
должен возвращать массив, который является типом object
. Когда он не может найти такой объект, он возвращает null
. Возврат undefined
здесь также не имеет смысла.
Хватит с этого. Вернемся к обсуждению неизменности.
Согласно документам MDN:
«Все типы, кроме объектов, определяют неизменяемые значения (то есть значения, которые нельзя изменить)».
Этот оператор включает функции, поскольку они представляют собой особый тип объекта JavaScript. См. определение функции здесь.
Давайте кратко рассмотрим, что на практике означают изменяемые и неизменяемые типы данных. Попробуйте запустить приведенный ниже код в консоли браузера.
let a = 5; let b = a console.log(`a: ${a}; b: ${b}`) // a: 5; b: 5 b = 7 console.log(`a: ${a}; b: ${b}`) // a: 5; b: 7
Наши результаты показывают, что хотя b
«производно» от a
, изменение значения b
не влияет на значение a
. Это происходит из-за того, что когда движок JavaScript выполняет оператор b = a
, он создает новую, отдельную ячейку памяти, помещает туда 5
и указывает b
на это место.
Как насчет объектов? Рассмотрим приведенный ниже код.
let c = { name: 'some name'} let d = c; console.log(`c: ${JSON.stringify(c)}; d: ${JSON.stringify(d)}`) // {"name":"some name"}; d: {"name":"some name"} d.name = 'new name' console.log(`c: ${JSON.stringify(c)}; d: ${JSON.stringify(d)}`) // {"name":"new name"}; d: {"name":"new name"}
Мы видим, что изменение свойства name через переменную d
также меняет его в c
. Это происходит из-за того, что когда движок JavaScript выполняет оператор c = { name: 'some name
'
}
, движок JavaScript создает пространство в памяти, помещает внутрь объект и указывает c
на него. Затем, когда он выполняет оператор d = c
, движок JavaScript просто указывает d
на то же место. Он не создает новую ячейку памяти. Таким образом, любые изменения элементов в d
неявно являются операцией над элементами в c
. Без особых усилий мы можем понять, почему это создает проблемы.
Представьте, что вы разрабатываете приложение React и где-то хотите показать имя пользователя как some name
, читая из переменной c
. Но где-то еще вы внесли ошибку в свой код, манипулируя объектом d
. Это приведет к тому, что имя пользователя появится как new name
. Если бы c
и d
были примитивами, у нас не было бы этой проблемы. Но примитивы слишком просты для типов состояний, которые должно поддерживать типичное приложение React.
Это основные причины, по которым важно поддерживать неизменное состояние в вашем приложении. Я рекомендую вам ознакомиться с некоторыми другими соображениями, прочитав этот короткий раздел из файла README Immutable.js: аргументы в пользу неизменности.
Поняв, зачем нам нужна неизменяемость в приложении React, давайте теперь посмотрим, как Immer решает эту проблему с помощью своей функции produce
.
produce
Immer Функция
Основной API Immer очень мал, и основная функция, с которой вы будете работать, — это функция produce
. produce
просто принимает начальное состояние и обратный вызов, который определяет, как состояние должно быть изменено. Сам обратный вызов получает черновую (идентичную, но все же копию) копию состояния, до которого он делает все предполагаемое обновление. Наконец, он produce
новое неизменное состояние со всеми примененными изменениями.
Общий шаблон для такого рода обновления состояния:
// produce signature produce(state, callback) => nextState
Давайте посмотрим, как это работает на практике.
import produce from 'immer' const initState = { pets: ['dog', 'cat'], packages: [ { name: 'react', installed: true }, { name: 'redux', installed: true }, ], } // to add a new package const newPackage = { name: 'immer', installed: false } const nextState = produce(initState, draft => { draft.packages.push(newPackage) })
В приведенном выше коде мы просто передаем начальное состояние и обратный вызов, который указывает, как мы хотим, чтобы мутации происходили. Это так просто. Нам не нужно трогать никакую другую часть штата. Он оставляет initState
нетронутым и структурно делит те части состояния, которые мы не трогали, между начальным и новым состояниями. Одной из таких частей в нашем состоянии является массив pets
. nextState
produce
это неизменяемое дерево состояний, в котором есть как сделанные нами изменения, так и те части, которые мы не модифицировали.

Вооружившись этим простым, но полезным знанием, давайте посмотрим, как produce
может помочь нам упростить наши редукторы React.
Написание редукторов с помощью Immer
Предположим, у нас есть объект состояния, определенный ниже
const initState = { pets: ['dog', 'cat'], packages: [ { name: 'react', installed: true }, { name: 'redux', installed: true }, ], };
И мы хотели добавить новый объект и на следующем шаге установить его installed
ключ в true
const newPackage = { name: 'immer', installed: false };
Если бы мы сделали это обычным способом с синтаксисом распространения объекта и массива JavaScript, наш редуктор состояния мог бы выглядеть так, как показано ниже.
const updateReducer = (state = initState, action) => { switch (action.type) { case 'ADD_PACKAGE': return { ...state, packages: [...state.packages, action.package], }; case 'UPDATE_INSTALLED': return { ...state, packages: state.packages.map(pack => pack.name === action.name ? { ...pack, installed: action.installed } : pack ), }; default: return state; } };
Мы можем видеть, что это излишне многословно и подвержено ошибкам для этого относительно простого объекта состояния. Мы также должны коснуться каждой части состояния, в чем нет необходимости. Давайте посмотрим, как мы можем упростить это с помощью Immer.
const updateReducerWithProduce = (state = initState, action) => produce(state, draft => { switch (action.type) { case 'ADD_PACKAGE': draft.packages.push(action.package); break; case 'UPDATE_INSTALLED': { const package = draft.packages.filter(p => p.name === action.name)[0]; if (package) package.installed = action.installed; break; } default: break; } });
И с помощью нескольких строк кода мы значительно упростили наш редьюсер. Кроме того, если мы попадаем в случай по умолчанию, Immer просто возвращает состояние черновика, и нам не нужно ничего делать. Обратите внимание, что стало меньше шаблонного кода и устранено распространение состояний. С Immer нас интересует только та часть состояния, которую мы хотим обновить. Если мы не можем найти такой пункт, как в действии `UPDATE_INSTALLED`, мы просто идем дальше, ничего не трогая. Функция `produce` также подходит для каррирования. Передача обратного вызова в качестве первого аргумента для `produce` предназначена для каррирования. Подпись карри `produce` //curried produce signature produce(callback) => (state) => nextState
Давайте посмотрим, как мы можем обновить наше предыдущее состояние с помощью карри. Наши карри-продукты будут выглядеть так: const curriedProduce = produce((draft, action) => { switch (action.type) { case 'ADD_PACKAGE': draft.packages.push(action.package); break; case 'SET_INSTALLED': { const package = draft.packages.filter(p => p.name === action.name)[0]; if (package) package.installed = action.installed; break; } default: break; } });
Каррированная функция продукта принимает функцию в качестве первого аргумента и возвращает каррированный продукт, который только теперь требует состояния, из которого можно создать следующее состояние. Первым аргументом функции является состояние черновика (которое будет получено из состояния, которое будет передано при вызове этого каррированного продукта). Затем следует любое количество аргументов, которые мы хотим передать функции.
Все, что нам нужно сделать сейчас, чтобы использовать эту функцию, — это передать состояние, из которого мы хотим создать следующее состояние, и объект действия, подобный этому.
// add a new package to the starting state const nextState = curriedProduce(initState, { type: 'ADD_PACKAGE', package: newPackage, }); // update an item in the recently produced state const nextState2 = curriedProduce(nextState, { type: 'SET_INSTALLED', name: 'immer', installed: true, });
Обратите внимание, что в приложении React при использовании хука useReducer
нам не нужно явно передавать состояние, как я сделал выше, потому что он позаботится об этом.
Вам может быть интересно, получит ли Immer hook
, как и все в React в наши дни? Ну, вы в компании с хорошими новостями. В Immer есть два хука для работы с состоянием: useImmer
и useImmerReducer
. Давайте посмотрим, как они работают.
Использование useImmer
и useImmerReducer
Лучшее описание хука useImmer
в файле README use-immer.
useImmer(initialState)
очень похож наuseState
. Функция возвращает кортеж, первое значение кортежа — текущее состояние, второе — функция обновления, которая принимает функцию иммерс-продюсера, в которойdraft
можно свободно мутировать, пока не закончится производитель и не будут внесены изменения неизменяемый и становится следующим состоянием.
Чтобы использовать эти хуки, вы должны установить их отдельно, в дополнение к основной библиотеке Immer.
yarn add immer use-immer
В терминах кода хук useImmer
выглядит следующим образом:
import React from "react"; import { useImmer } from "use-immer"; const initState = {} const [ data, updateData ] = useImmer(initState)
И это так просто. Можно сказать, что это useState React, но с небольшим количеством стероидов. Использовать функцию обновления очень просто. Он получает состояние черновика, и вы можете изменить его так, как хотите, как показано ниже.
// make changes to data updateData(draft => { // modify the draft as much as you want. })
Создатель Immer предоставил пример кода и окна, с которым вы можете поиграть, чтобы увидеть, как это работает.
useImmerReducer
также прост в использовании, если вы использовали хук React useReducer
. Там похожая подпись. Давайте посмотрим, как это выглядит в терминах кода.
import React from "react"; import { useImmerReducer } from "use-immer"; const initState = {} const reducer = (draft, action) => { switch(action.type) { default: break; } } const [data, dataDispatch] = useImmerReducer(reducer, initState);
Мы видим, что редюсер получает состояние draft
, которое мы можем изменять сколько угодно. Здесь также есть пример codeandbox, с которым вы можете поэкспериментировать.
И вот как просто использовать крючки Immer. Но если вы все еще задаетесь вопросом, почему вы должны использовать Immer в своем проекте, вот краткое изложение некоторых из наиболее важных причин, которые я нашел для использования Immer.
Почему вы должны использовать Иммер
Если вы писали логику управления состоянием какое-то время, вы быстро оцените простоту, которую предлагает Immer. Но это не единственное преимущество Immer.
Когда вы используете Immer, вы в конечном итоге пишете меньше стандартного кода, как мы видели с относительно простыми редьюсерами. Это также делает глубокие обновления относительно простыми.
С такими библиотеками, как Immutable.js, вам нужно изучить новый API, чтобы воспользоваться преимуществами неизменности. Но с Immer вы достигаете того же самого с обычными Objects
, Arrays
, Sets
и Maps
JavaScript. Там нет ничего нового, чтобы узнать.
Immer также обеспечивает совместное использование структур по умолчанию. Это просто означает, что когда вы вносите изменения в объект состояния, Immer автоматически разделяет неизмененные части состояния между новым состоянием и предыдущим состоянием.
С Immer вы также получаете автоматическое замораживание объектов, что означает, что вы не можете вносить изменения в produced
состояние. Например, когда я начал использовать Immer, я попытался применить метод sort
к массиву объектов, возвращаемых функцией производства Immer. Он выдал ошибку, говорящую мне, что я не могу внести какие-либо изменения в массив. Мне пришлось применить метод среза массива перед применением sort
. Еще раз, созданное nextState
является неизменяемым деревом состояний.
Immer также строго типизирован и очень мал — всего 3 КБ в сжатом виде.
Заключение
Когда дело доходит до управления обновлениями состояния, использование Immer для меня не составляет труда. Это очень легкая библиотека, которая позволяет вам продолжать использовать все, что вы узнали о JavaScript, не пытаясь изучить что-то совершенно новое. Я рекомендую вам установить его в свой проект и начать использовать прямо сейчас. Вы можете использовать его в существующих проектах и постепенно обновлять свои редукторы.
Я также рекомендую вам прочитать вводную запись в блоге Immer Майкла Вестстрата. Часть, которую я нахожу особенно интересной, — это «Как работает Immer?» раздел, в котором объясняется, как Immer использует языковые функции, такие как прокси, и такие концепции, как копирование при записи.
Я также рекомендую вам взглянуть на эту запись в блоге: Неизменяемость в JavaScript: противоречивый взгляд, где автор, Стивен де Салас, излагает свои мысли о достоинствах стремления к неизменности.
Я надеюсь, что благодаря тому, что вы узнали из этого поста, вы сможете сразу начать использовать Immer.
Связанные ресурсы
-
use-immer
, GitHub - Иммер, GitHub
-
function
, веб-документы MDN, Mozilla -
proxy
, веб-документы MDN, Mozilla - Объект (информатика), Википедия
- «Неизменяемость в JS», Орджи Чиди Мэтью, GitHub
- «Типы данных и значения ECMAScript», Ecma International
- Неизменяемые коллекции для JavaScript, Immutable.js, GitHub
- «Случай неизменности», Immutable.js, GitHub