Редукторы в Redux - это место, где функциональное программирование и концепция неизменяемости очень заметны. Неизменяемость в Javascript неестественна, поэтому некоторые прибегают к библиотекам вроде Immutable. Мне? Я предпочитаю собственные массивы, объекты и примитивы структурам данных, определяемым библиотекой, поэтому я прибегаю к различным шаблонам ES6 для реализации неизменяемости.
Моя проблема в том, что неизменяемость объекта довольно проста, но неизменность массива сложнее. Чтобы понять проблему и ее контекст, давайте подведем итоги проблемы неизменяемости Redux.
Редукторы и неизменяемость Redux
В Redux редюсер - это функция, которая принимает «состояние» приложения, «действие» и возвращает новое состояние. Приведем пример:
const intReducer = (state, action) => { switch (action.type) { case 'increment': return state + action.delta case 'decrement': return state - action.delta } }
Примеры использования:
console.log(intReducer(5, {type: 'increment', delta: 2})) // 7 console.log(intReducer(5, {type: 'decrement', delta: 2})) // 3
Давайте попробуем более сложный пример, где состояние - это объект:
const personReducer = (state, action) => { switch (action.type) { case 'upperCaseFirstName': return Object.assign({}, state, {firstName: state.firstName.upper()}) case 'upperCaseLastName': return Object.assign({}, state, {lastName: state.lastName.toUpper()}) } } const person = {firstName: 'Gil', lastName: 'Tayar'} console.log(personReducer(person, {type: 'upperCaseFirstName'})) // {fistName: 'GIL', lastName: 'Tayar'}
Обратите внимание на использование Object.assign
для создания нового состояния, а не на изменение предыдущего. Object.assign
идеально подходит для этого, и когда JS получит новый оператор распространения для объектов, этот метод будет еще лучше:
{...state, firstName: state.firstName.upper}
Неизменяемость массивов
Но что, если мое состояние - это массив? Возьмем пример прямо из TodoMVC:
const todoReducer = (state, action) => { switch (action.type) { case 'markDone': const newState = [...state] // clone the array newState[action.index].done = true return newState } } const todos = [{title: 'laundry', done: false}, {title: 'dishes', done: false}] console.log(todoReducer(todos, {type: 'markDone', index: 1})) // [{title: 'laundry', done: false}, // {title: 'dishes', done: true}]
Ой. Больно! Вместо красивого однострочника у меня есть процедурный и нефункциональный код. В частности, я смотрю на это:
const newState = [...state] // clone the array newState[action.index].done = true return newState
Я мог сделать это:
return [...state.slice(0, action.index), Object.assign({}, state[action.index], {done: true}), ...state.slice(action.index + 1)]
Я использую Array.slice
и оператор распространения, но уверен, что лекарство не хуже болезни!
Решение
Я читал электронные письма из списка esdiscuss и нашел такой вопрос:
Кроме того: если вы интересуетесь будущим Javascript или хотите глубже понять Javascript, то список рассылки es-Discussion для вас. [Обновление (декабрь 2017 г.): к сожалению, как и многие другие полезные вещи в Интернете, этот список рассылки стал немного утомительным. Много троллей, много дискуссий, ведущих в никуда. Тем не менее, у него все еще есть хорошая информация, и я еще не отписался!]
Джереми Мартин хотел найти способ установить значение в массиве неизменяемо и предложил будущую функцию, которая включила бы эту функцию.
Бац! В точности то, что я искал. Но на этот вопрос быстро был дан ответ, который (i) поразил меня и (ii) заставил меня задуматься: «Почему же теперь я не подумал об этом?». Давайте воспользуемся методом и напишем код, который неизменяемо изменяет 3-й элемент в массиве:
const array = ['a', 'b', 'c', 'd'] console.log(Object.assign([...array], {2: 'x'})) // ['a','b','x','d']
Хм? Object.assign
? И мы используем его в массиве? Давайте на секунду проигнорируем эту странность и разберемся, что мы здесь делаем:
Сначала мы клонируем массив: [...array]
. Затем мы заменяем объект , который является массивом, на {2: 'x'}
. И о чудо - работает.
Массивы - это объекты
Почему это работает? Потому что массивы - это объекты. В чем-то массив ['a', 'b']
похож на объект
{0: 'a', 1: 'b', length: 2}
Например, этот код будет работать для обоих:
for (let i = 0; i < a.length; ++i) console.log(a[i])
Кроме того, этот код будет работать для обоих:
a.foo = 'hi' console.log(a.foo) // hi
Но объекты могут быть только массивами
Итак, массив является объектом. Но может ли объект быть массивом?
Неа. Например:
console.log(a.length) // 2 a[2] = 'c' console.log(a.length) // ???
Если a
- массив, a.length
будет обновлен с 2
до 3
. Но попробуйте это сделать с созданным нами выше объектом, и a.length
останется 2
.
Также этот код не будет работать для объекта:
for (let i in a) console.log(a[i])
И еще много всего кода не будет работать. Поскольку массив является объектом, но объект может быть только подобным массиву. Он может иметь те же поля, что и массив, но не имеет поведения Array .
Почему решение работает
Вернемся к нашему примеру:
const array = ['a', 'b', 'c', 'd'] console.log(Object.assign([...array], {2: 'x'}))
Поскольку мы можем думать о массиве как об объекте, ключи которого являются индексами массива, мы можем использовать Object.assign
для установки этих «ключей», что мы и делаем здесь.
Вернуться к нашему редуктору
Если мы хотим написать функцию, которая неизменно устанавливает значение индекса:
const setArrayImmutable = (arr, i, value) => Object.assign([...arr], {[i]: value})
Обратите внимание на использование синтаксиса ES6 для определения ключа поля с помощью expression[i]: value
.
Теперь мы можем сделать что-то подобное в нашем редукторе:
const todoReducer = (state, action) => { switch (action.type) { case 'markDone': return Object.assign( [...state], {[action.index]: Object.assign({}, state[action.index], {done: true})) } }
И в этом трюк. Теперь неизменяемое изменение элемента массива так же просто, как неизменное изменение свойства объекта.