Immer: новый подход к иммутабельности в JavaScript

https://hackernoon.com/introducing-immer-immutability-the-easy-way-9d73d8f71cb3
  • Перевод
Иммутабельные структуры данных, реализующие методику совместного использования неизменяемых фрагментов информации (structural sharing), выглядят как отличная технология для хранения состояния приложения. Особенно — в комбинации с архитектурой, основанной на событиях. Однако за всё надо платить. В языке вроде JavaScript, где возможности по обеспечению иммутабельности не являются стандартными, создание нового состояния из предыдущего — это скучная, шаблонная задача. Для того чтобы осознать масштаб проблемы, и силы, брошенные на её решение, взгляните на эту страницу, где имеется список из 67 пакетов, предназначенных для упрощения работы с иммутабельными структурами данных в Redux.



К сожалению, все эти библиотеки не решают основную проблему: отсутствие поддержки иммутабельности языком. Например, в то время как update-in — это красивая конструкция языка ClojureScript, любые аналогичные идеи, реализованные на JavaScript, будут, в основном, полагаться на неудобные строковые пути. Такой подход подвержен ошибкам, он усложняет проверку типов и требует изучения особого API.

Как решить проблему иммутабельности в JavaScript? Пожалуй, стоит прекратить сражаться с языком, воспользовавшись вместо этого его возможностями. Такой подход позволит не терять удобство и простоту, которые дают стандартные структуры данных. Собственно говоря, библиотека immer, о которой мы сегодня поговорим, направлена то, чтобы использовать стандартные средства JS при работе с иммутабельными состояниями.

Продьюсеры


В основе практического использования immer лежит создание продьюсеров (producers). Вот как выглядит очень простой продьюсер:

import produce from "immer"

const nextState = produce(currentState, draft => {
  // пустая функция
})

console.log(nextState === currentState) // true

Единственная задача, которую решает этот пустой продьюсер — возврат исходного состояния.

Функция produce принимает два аргумента. Это currentState, текущее состояние, и функция-продьюсер. Текущее состояние — это исходная позиция, а продьюсер выражает те изменения, которые надо внести в текущее состояние.

Функция-продьюсер принимает один аргумент, draft, он представляет собой нечто вроде черновика будущего состояния и является прокси-объектом для переданного текущего состояния. Изменения, вносимые в draft, будут записаны и использованы для создания нового состояния. Текущее состояние, currentState, в ходе выполнения этого процесса, не будет подвергаться каким-либо изменениям.

В вышеприведённом примере, так как в immer применяются общие структуры данных, и продьюсер ничего не модифицирует, следующее состояние будет представлено тем же, которое поступило на вход функции produce.

Посмотрим теперь на то, что произойдёт, если в продьюсере модифицировать объект draft. Обратите внимание на то, что функция-продьюсер ничего не возвращает, единственное, что играет роль — это выполняемые в ней изменения.

import produce from "immer"

const todos = [ /* тут 2 объекта todo */ ]

const nextTodos = produce(todos, draft => {
    draft.push({ text: "learn immer", done: true })
    draft[1].done = true
})

// старое состояние не модифицировано
console.log(todos.length)        // 2
console.log(todos[1].done)       // false

// новое состояние отражает модификации, внесённые в draft
console.log(nextTodos.length)    // 3
console.log(nextTodos[1].done)   // true

// общие структуры
console.log(todos === nextTodos)       // false
console.log(todos[0] === nextTodos[0]) // true
console.log(todos[1] === nextTodos[1]) // false

Здесь можно видеть пример реального продьюсера. Все изменения в draft отражены в новом состоянии, которое использует неизменные элементы вместе с предыдущим состоянием.

Тут можно наблюдать функцию produce в действии. Мы создали новое дерево состояния, которое содержит один дополнительный элемент todo. Кроме того, второй элемент был изменён. Это изменения, внесённые в объект draft и отражённые в результирующем состоянии.

Однако, это ещё не всё. Последнее выражение в листинге демонстрирует на практике то, что части состояния, которые были изменены в draft, оказались в новых объектах. Однако неизменные части новое и предыдущее состояния используют совместно. В данном случае это первый элемент todo.

Редьюсеры и продьюсеры


Теперь, когда мы изучили основы создания новых состояний с помощью продьюсеров, используем эти знания при создании типичного редьюсера Redux. Следующий пример основан на официальном примере корзины покупок, он загружает в состояние сведения о (возможно) новых товарах. Сведения о товарах приходят в виде массива, трансформированного с использованием reduce, и затем сохраняются в коллекции с использованием их ID в качестве ключей. Ниже приведена сокращённая версия кода, полную версию можно увидеть здесь.

const byId = (state, action) => {
  switch (action.type) {
    case RECEIVE_PRODUCTS:
      return {
        ...state,
        ...action.products.reduce((obj, product) => {
          obj[product.id] = product
          return obj
        }, {})
      }
    default:      
      return state
  }
}

Здесь, в этом вполне обычном редьюсере Redux, нужно, во-первых, сконструировать новый объект состояния, в котором сохраняется базовое состояние и к которому добавляется коллекция новых товаров. В данном простом случае это не так уж и плохо, но этот процесс нужно повторять для каждого действия, и на каждом уровне, где нужно что-то изменить. Во-вторых, тут требуется обеспечить возврат существующего состояния, если редьюсер не внёс в состояние никаких изменений.

Если применить в описанной ситуации immer, то единственное решение, которое нам нужно принять — это то, какие изменения надо внести в текущее состояние. Дополнительных усилий по созданию нового состояния не потребуется. В результате, если использовать функцию produce в редьюсере, мы приходим к следующему коду:

const byId = (state, action) =>
  produce(state, draft => {
    switch (action.type) {
      case RECEIVE_PRODUCTS:
        action.products.forEach(product => {
          draft[product.id] = product
        })
        break
    }
  })

Здесь показано упрощение редьюсера с использованием возможностей immer. Обратите внимание на то, насколько теперь легче понять, какую роль играет RECEIVE_PRODUCTS. Код, который лишь добавляет информационный шум, устранён. Кроме того, учтите то, что тут мы не обрабатываем действие, выполняемое по умолчанию. Если объект draft не будет изменён, это эквивалентно возврату базового состояния. И исходный редьюсер, и новый, выполняют совершенно одинаковые действия.

О строковых идентификаторах, которые не использует immer


Идея производить следующее иммутабельное состояние, модифицируя временный «черновой» объект, не нова. Например, в ImmutableJS имеется похожий механизм — withMutations. Значительное преимущество immer, однако, заключается в том, что для использования этой библиотеки не нужно изучать (или загружать) целую новую библиотеку структур данных. Immer работает с обычными объектами и массивами JavaScript.

Сильные стороны immer на этом не заканчиваются. Для уменьшения шаблонного кода ImmutableJS и многие другие библиотеки позволяют выражать глубокие обновления (и множество других операций) с помощью специальных методов. Однако тут используются обычные строковые идентификаторы, что делает невозможной работу систем проверки типов. Такой подход является источником ошибок. В следующем листинге, например, тип list не может быть выведен при использовании ImmutableJS. Другие библиотеки расширяют подобные механизмы, позволяя с их помощью выполнять более сложные команды. Цена всех этих возможностей — создание особого мини-языка в существующем языке программирования. Вот примеры кода, использующего возможности ImmutableJS и immer.

// ImmutableJS
const newMap = map.updateIn(['inMap', 'inList'], list => list.push(4))

// Immer
draft.inMap.inList.push(4)

Тут показано, как, работая с immer, мы не теряем информацию о типах в ходе глубоких обновлений. Как можно заметить, immer не страдает от проблем с типами. Эта библиотека работает со встроенными структурами данных JavaScript, модификация данных выполняется с использованием стандартных механизмов. Всё это отлично воспринимается любыми системами проверки типов.

Автоматическая заморозка объектов


Ещё одна отличная возможность immer заключается в том, что эта библиотека автоматически замораживает любые структуры данных, созданные с помощью функции produce. (В режиме разработки). В результате данные становятся по-настоящему иммутабельными. Об этом пишет в своём твите Майкл Тиллер. В то время как замораживание всего состояния может быть довольно ресурсоёмкой процедурой, тот факт, что immer может заморозить изменённые части, делает эту библиотеку весьма эффективной. И, если всё состояние получено с помощью функций produce, итоговый результат будет заключаться в том, что всё состояние всегда будет замороженным. Это означает, что вы получите исключение, когда попытаетесь любым способом его модифицировать.

Каррирование


Итак, вот последняя возможность immer, которую мы рассмотрим. До сих пор мы всегда вызывали функцию produce с двумя аргументами — baseState и функцией-продьюсером. Однако в некоторых случаях может быть удобно использовать механизм частичного применения функции. При таком подходе можно вызвать функцию produce, передав ей лишь функцию-продьюсер. Это создаст новую функцию, которая исполнит продьюсер, когда ей передадут состояние. Такая функция, кроме того, принимает произвольное количество дополнительных аргументов и передаёт их продьюсеру.

Самое важное тут заключается в том, что каррирование позволяет ещё сильнее сократить шаблонный код редьюсеров:

const byId = produce((draft, action) => {
  switch (action.type) {
    case RECEIVE_PRODUCTS:
      action.products.forEach(product => {
        draft[product.id] = product
      })
      break
  }
})

Тут показан каррированный продьюсер. Для того чтобы лучше понять этот код, взгляните на один из предыдущих примеров.

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

Внутреннее устройство immer


В основе immer лежат два понятия. Первое — копирование при записи. Второе — прокси-объекты. Проиллюстрируем это.


Текущее состояние, прокси-объект и модифицированных прокси-объект

Зелёное дерево — это дерево исходного состояния. Можно заметить, что некоторые кружки в зелёном дереве имеют синие рамки. Они называются прокси-объектами. В самом начале, когда продьюсер начинает работу, существует лишь один такой прокси. Это — объект draft, который передаётся в функцию. Когда вы читаете любое непримитивное значение из этого первого прокси, он, в свою очередь, создаёт прокси для этого значения. Это означает, что в итоге у нас оказывается дерево прокси, являющееся чем-то вроде «теневой копии» базового состояния. Это — те части состояния, к которым были зафиксированы обращения в продьюсере.


Внутренние механизмы принятия решений в прокси. Запросы направляются либо к базовому дереву, либо к клонированному узлу базового дерева

Теперь, как только вы пытаетесь изменить что-то в прокси (напрямую, или через любое API), он немедленно создаёт мелкую копию узла в исходном дереве, к которому он относится, и устанавливает флаг modified. С этого момента любые следующие операции чтения и записи, направленные на этот прокси, будут вести не к исходному дереву, а к его копии. Кроме того, родительские объекты, которые до сих пор были не модифицированы, будут помечены как modified.

Когда продьюсер наконец завершит работу, он просто обойдёт дерево прокси, и, если прокси модифицирован, возьмёт копию. Или, если прокси не модифицирован, просто вернёт исходный узел. Этот процесс приводит к появлению дерева состояния, у которого есть общие части с предыдущим состоянием.

Immer без прокси-объектов


Прокси-объекты доступны во всех современных браузерах. Однако они всё ещё имеются не везде. Самые заметные исключения — это Microsoft Internet Explorer и React Native для Android. Для работы в подобных средах в immer имеется ES5-реализация его механизмов. С точки зрения практического применения, это то же самое, но работает это немного медленнее. Применять эти механизмы можно, воспользовавшись командой import produce from "immer/es5".

Производительность


Как immer влияет на производительность проектов, созданных с его помощью? Как показали бенчмарки, immer работает примерно так же быстро, как и ImmutableJS, и вдвое медленнее чем эффективный, созданный вручную редьюсер. Это вполне приемлемо. Реализация на ES5, однако, намного медленнее, в результате вполне оправдан отказ от использования immer в сильно нагруженных редьюсерах на платформах, для которых предназначен его ES5-вариант. К счастью, если вы используете immer в своём проекте, это не значит, что вы должны применять эту библиотеку во всех редьюсерах, и вы можете решить, в каких конкретно редьюсерах и действиях нужны возможности immer.


Тестирование производительности immer

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

Итоги


Работа над immer была начата как небольшой эксперимент с объектами Proxy. Появившаяся в результате библиотека, в первую неделю после появления, собрала более тысячи звёзд на GitHub. Immer, в деле работы с иммутабельными данными в JavaScript, обладает некоторыми уникальными особенностями. Вероятно, они понравились сообществу разработчиков. Возможно, дело ещё и в том, что immer не борется с языком, а использует его стандартные возможности. Среди сильных сторон immer можно отметить следующие:

  • Использование стандартных механизмов JS.
  • Поддержка типов.
  • Организация совместного использования данных, разделяемых между исходным и новым состояниями.
  • Заморозка объектов.
  • Отличные возможности по уменьшению объёма шаблонного кода.

Поэкспериментируйте с immer. Вполне возможно, что вам пригодится эта библиотека.

Уважаемые читатели! Планируете ли вы использовать immer в своих проектах?

  • +22
  • 5,7k
  • 2
RUVDS.com 541,17
RUVDS – хостинг VDS/VPS серверов
Поделиться публикацией
Комментарии 2
  • 0
    Здесь immer подход поддерживается как частный случай github.com/hydux/hydux-mutator
    • 0
      Шикарная библиотека! Можно в 5 строчек расширить стандартный реактовский компонент и спокойно мутировать стейт не боясь побочных эффектов.

      import React, { Component } from "react";
      import produce from "immer";
      
      class ImmerComponent extends Component {
        editState(fn) {
          this.setState(produce(fn));
        }
      }
      
      class List extends ImmerComponent {
        state = [];
      
        addItem = () => {
          this.editState(state => {
            state.push(state.length);
          });
        };
      
        render() {
          const list = this.state.map(item => {
            <li key={item}>
              {item}
            </li>;
          });
      
          return (
            <div>
              <ul>
                {list}
              </ul>
              <button onClick={this.addItem}>Add</button>
            </div>
          );
        }
      }
      
      

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое