Доброго времени суток, друзья

Представляю Вашему вниманию перевод статьи Kingsley Silas «Understanding Immutability in JavaScript».

Неизменность данных (immutability) в JavaScript


Если Вы раньше не сталкивались с неизменяемостью или неизменностью данных в JS, то легко можете спутать данное явление с присвоением переменной нового значения (переопределением переменной). Возможность переопределять переменные и значения зависит от того, с помощью какого ключевого слова были объявлены эти переменные или значения, «let», «var» или «const».

Допустим, мы присваиваем переменной firstName значение Kingsley:
let firstName = 'Kingsley'

Мы легко можно присвоить переменной firstName новое значение:
firstName = 'John'

Это возможно потому, что мы объявили переменную «firstName» с помощью ключевого слова «let». Теперь объявим переменную с помощью ключевого слова «const»:
const lastName = 'Silas'

Если мы попробуем присвоить переменной «lastName» новое значение, то получим ошибку:
lastName = 'Doe'
// TypeError: Assignment to constant variable

Это не неизменность данных.

Одной из наиболее важных концепций работы с фреймворками, такими как React, является то, что изменять состояние приложения — это плохая идея. Это также касается свойств объекта. «Иммутабельность» — это не концепция React. React лишь используют данную идею при работе с такими вещами, как состояние приложения и свойства объекта.

Что же такое «иммутабельность» (да простит мне благосклонный читатель употребление сего англицизма — прим. пер.)? Давайте разбираться.

Придерживаемся фактов


Иммутабельность не позволяет изменять структуру или содержание данных. Присвоенное переменной значение не может меняться, переменная становится фактом, своего рода источником истины (a source of truth). Помните сказку, где принцесса целует лягушку в надежде, что та превратится в прекрасного принца. Так вот, иммутабельность утверждает, что лягушка всегда будет лягушкой.

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

Предположим, у нас есть такой объект:
let user = { name: 'James Doe', location: 'Lagos' }

Создадим новый объект newUser на основе объекта user:
let newUser = user

Допустим, user сменил место своего нахождения. Изменения, внесенные в объект user, отразятся на объекте newUser:
user.location = 'Abia'
console.log(newUser.location) // Abia

Возможно, это не совсем то, чего мы хотели. Вот к каким неожиданным последствиям может приводить переопределение значения.

Работаем с иммутабельными объектами


Итак, мы хотим создать иммутабельный объект. Для этого используем метод, возвращающий новый объект. По сути, нам нужно нечто вроде чистой функции (pure function).

Чистая функция обладает двумя уникальными свойствами:

  1. Возвращаемое этой функцией значение зависит от передаваемых параметров. При этом, возвращаемое значение не изменится, пока не изменятся параметры.
  2. Данная функция не может изменять значения, находящиеся за пределами ее области видимости.

С помощью метода Object.assign() мы можем создать функцию, которая не меняет передаваемый ей объект. Она будет создавать новый объект, копируя второй и третий аргументы в пустой объект, передаваемый в качестве первого аргумента. Затем новый объект возвращается:
const updateLocation = (data, newLocation) => {
    return {
        Object.assign({}, data, {
            location: newLocation
        })
    }
}

updateLocation() — чистая функция. Если мы передадим ей объект user в качестве первого параметра, она вернет новый объект с новым значением свойства location.

Другим решением является spread-оператор:
const updateLocation = (data, newLocation) => {
    return {
        ...data,
        location: newLocation
    }
}

Хорошо, как нам реализовать это в React?

Иммутабельность в React


В стандартном React-приложении состояние является объектом (Redux использует иммутабельный объект в качестве основы хранилища приложения). Процесс согласования (reconciliation process) в React определяет, необходимо ли производить повторный рендеринг объекта, поэтому ему нужно следить за изменениями этого объекта.

Другими словами, если React не сможет определить изменение объекта, он не обновит виртуальный DOM.

Иммутабельность позволяет наблюдать за такими изменениями. Это, в свою очередь, позволяет React сравнивать старое и новое состояния объекта и на основе этого сравнения перерисовывать объект.

Вот почему прямое изменение состояния в React не рекомендуется:
this.state.username = 'jamesdoe'

React «не заметит» изменения состояния и не обновит объект.

Immutable.js


Redux строго придерживается принципов иммутабельности. Его редукторы (reducers) — чистые функции, которые не меняют состояния, а возвращают новый объект, основываясь на текущем состоянии и действии. Мы могли бы использовать spread-оператор, как мы это делали ранее, но давайте в этот раз воспользуемся библиотекой Immutable.js.

Несмотря на то, что иммутабельность вполне достижима на чистом JS, по пути можно столкнуться с несколькими подводными камнями. Использование Immutable.js гарантирует неизменность, предоставляя высокопроизводительный и разнообразный API. Мы не будем изучать все детали Immutable.js в рамках данной статьи, но ограничимся демонстрацией использования этой библиотеки в приложении, основанном на React и Redux.

Начнем с импорта необходимых модулей и установки компонента Todo:
const { List, Map } = Immutable
const { Provider, connect } = ReactRedux
const { createStore } = Redux

Чтобы запускать приложение на локальной машине, необходимо установить следующие пакеты:
npm install redux react-redux immutable

Импорт выглядит так:
import { List, Map } from 'immutable'
import { Provider, connect } from 'react-redux'
import { createStore } from 'redux'

Далее устанавливаем компонент Todo с некоторыми настройками:
const Todo = ({ todos, handleNewTodo }) => {
    const hadleSubmit = event => {
        const text = event.target.value
        if(event.keyCode === 13 && text.length > 0){
            handleNewTodo(text)
            event.target.value = ''
        }
    }

    return (
        <section className="section">
          <div className="box field">
            <label className="label">Todo</label>
            <div className="control">
              <input
                type="text"
                className="input"
                placeholder="Add todo"
                onKeyDown={handleSubmit}
              />
            </div>
          </div>
          <ul>
            {todos.map(item => (
              <div key={item.get("id")} className="box">
                {item.get("text")}
              </div>
            ))}
          </ul>
    </section>
    )
}

Мы используем метод handleSubmit() для создания нового элемента to-do. В нашем примере у пользователя будет только одна возможность — создавать новые элементы to-do. Для этого нам потребуется лишь одно действие:
const actions = {
    handleNewTodo(text){
        return {
            type: 'ADD-TODO',
            payload: {
                id: uuid.v4(),
                text
            }
        }
    }
}

Payload содержит идентификатор и текст элемента to-do. Затем мы настраиваем редуктор и передаем ему действие:
const reducer = function(state = List(), action){
    switch(action.type){
        case 'ADD_TODO':
            return state.push(Map(action.payload))
        default:
            return state
    }
}

Создаем контейнер компонента для помещения его в хранилище, используя connect. Передаем connect функции mapStateToProps() и mapDispatchToProps():
const mapStateToProps = state => {
    return {
        todos: state
    }
}

const mapDispatchToProps = dispatch => {
    return {
        handleNewTodo: text => dispatch(actions.handleNewTodo(text))
    }
}

const store = createStore(reducer)

const App = connect(
    mapStateToProps,
    mapDispatchToProps
)(Todo)

const rootElement = document.getElementById('root')

ReactDOM.render(
    <Provider store={store}>
    <App />
    </Provider>,
    rootElement
)

Мы используем mapStateToProps() для наполнения компонента данными из хранилища. Затем мы используем mapDispatchToProps(), чтобы обеспечить возможность создания действия через свойства компонента.

В редукторе мы используем List из Immutable.js для инициализации начального состояния приложения:
const reducer = function(state = List(), action){
    switch(action.type){
        case 'ADD_TODO':
            return state.push(Map(action.payload))
        default:
            return state
    }
}

Думайте о List как о массиве, в который с помощью .push() записываются состояния. Значение, используемое для обновления состояния, является объектом. Поэтому у нас нет необходимости использовать Object.assign() или spread-оператор для обеспечения иммутабельности. Так код выглядит гораздо чище, особенно если состояние является глубоко вложенным (в случае глубокой вложенности состояния нам нужно использовать spread-оператор на каждом уровне).

Результат можно посмотреть здесь.

Иммутабельность обеспечивает быструю реакцию программы на изменения. Для этого нам не требуется проводить рекурсивное сравнение данных. Следует понимать, что при больших объемах данных, могут возникнуть проблемы с производительностью — это цена, которую приходится платить за копирование больших объектов с данными.

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

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

Благодарю за внимание. До новых встреч.