От автора: что такое изменяющийся объект? Когда вы начнете изучать React, первое, что вы узнаете – это что вам не стоит мутировать (изменять) список.
// This is bad, push modifies the original array items.push(newItem); // This is good, concat doesn’t modify the original array const newItems = items.concat([newItem]);
Но… Вы знаете почему? Вы знаете, что не так с мутирующими объектами?
Ничего. Мутирующие объекты – это нормально.
Да, при распараллеливании это может вызвать проблемы. Но это самый легкий подход к разработке. Это компромисс, как и множество других вещей в программировании.
Функциональное программирование и концепции типа неизменяемости популярны, можно сказать, это «крутые» темы. Но в случае с React неизменяемость дает реальные преимущества. Это не просто модно, это реально полезно.
Что такое неизменность?
Неизменность означает, что что-либо не может изменить значение или состояние. Концепция простая, но, как обычно, дьявол кроется в деталях. Неизменные типы можно найти в JS. Хороший пример – тип значения String. Если определить строку следующим образом:
var str = 'abc';
Вы не можете напрямую менять символы строки. В JS строки не являются массивами, поэтому можно делать следующее:
str[2] = 'd';
А такая запись:
str = 'abd';
Присвоит str другую строку. Ссылку str можно объявить константой:
const str = 'abc'
Тогда присвоение новой строки этой переменной выбросит ошибку (это не относится к неизменности). Если необходимо изменить значение String, придется использовать методы манипулирования типа replace, toUpperCase или trim. Все эти методы возвращают новые строки, они не меняют оригинальную строку.
Тип значения
Вы могли не заметить, но ранее я сделал акцент на типе значения. Значения String неизменны. Объекты String поддаются мутации.
Если объект неизменен, вы не можете менять его состояние (значение его свойств). Это также значит, что вы не можете добавлять новые свойства в объект. Посмотрите демо:
Если запустить, появится окно alert с сообщением undefined. Новое свойство не добавилось. А теперь попробуйте так:
Строки неизменны.
Последний пример создает объект с помощью конструктора String(), который оборачивает (неизменное) значение String. Но к этому контейнеру можно добавить новые свойства, так как это объект, и он не заморожен.
Это наводит нас на важную концепцию, которую необходимо понять. Разница между ссылочным равенством и равенством значений.
Равенство ссылок и равенство значений
При ссылочном равенстве вы сравниваете ссылки объектов с помощью операторов === и !== (или == и !=). Если ссылки ведут на один объект, они считаются равными:
var str1 = ‘abc’; var str2 = str1; str1 === str2 // true
В примере сверху обе ссылки (str1 и str2) равны, так как ссылаются на один объект (‘abc’).
Две ссылки также равны, если ссылаются на одно неизменное значение:
var str1 = ‘abc’; var str2 = ‘abc’; str1 === str2 // true var n1 = 1; var n2 = 1; n1 === n2 // also true
Но с объектами это не работает:
var str1 = new String(‘abc’); var str2 = new String(‘abc’); str1 === str2 // false var arr1 = []; var arr2 = []; arr1 === arr2 // false
В этом случае создано 2 разных объекта. Поэтому их ссылки не равны:
Если нужно проверить 2 объекта на содержание одинакового значения, сравнивайте значения свойств объекта.
В JS нет прямого способа проводить равенство значений с объектами и массивами.
При работе с объектами String можно использовать методы valueOf или trim, которые возвращают значение String:
var str1 = new String(‘abc’); var str2 = new String(‘abc’); str1.valueOf() === str2.valueOf() // true str1.trim() === str2.trim() // true
Для объектов других типов придется реализовать свой метод равенства или подключить стороннюю библиотеку.
Как это относится к неизменности в React?
Проверить равенство двух объектов легче через неизменность. React использует это преимущество для оптимизации производительности.
Поговорим об этом.
Оптимизация производительности в React
React поддерживает внутреннее представление UI, так называемый виртуальный DOM.
При изменении свойства или состояния компонента виртуальный DOM обновляется, отражая эти изменения. Манипулировать виртуальным DOM легче и быстрее, так как в UI ничего не меняется.
Далее React сравнивает виртуальный DOM с предыдущей версией, чтобы понять, что изменилось. Это процесс согласования.
Таким образом, в реальном DOM обновляются только измененные элементы. Но иногда части DOM подвергаются повторному рендеру, даже если они не менялись. Это происходит совместно с другими частями, где изменения были.
В такой ситуации можно реализовать функцию shouldComponentUpdate и проверять, действительно ли изменились свойства и/или состояние, и возвращать true, оставляя обновление для React:
class MyComponent extends Component { // ... shouldComponentUpdate(nextProps, nextState) { if (this.props.myProp !== nextProps.color) { return true; } return false; } // ... }
Если свойства и состояние компонента – это неизменные объекты или значения, проверить их изменение можно с помощью простого оператора равенства.
С этой точки зрения неизменность устраняет сложность. Потому что иногда очень тяжело узнать, что изменилось. Представьте глубокие поля:
myPackage.sender.address.country.id = 1;
Как эффективно отслеживать, какой вложенный объект изменился?
Подумайте о массивах. Узнать, равны 2 массива или нет, можно, сравнивая их элементы. Для крупных массивов это тяжелая операция. Самое простое решение – использовать неизменные объекты.
Если объект нужно обновить, необходимо создать новый объект с новым значением, так как старый объект неизменен. Проверить то, что объект изменился можно с помощью равенства ссылок. Но некоторым эта концепция может показаться слегка непостоянной или противоречащей идее производительности и простоты. Давайте рассмотрим варианты, где нужно создавать новые объекты и реализовывать неизменность.
Реализация неизменности
В реальных приложениях состояние и свойства будут представлены объектами и массивами. В JS есть методы для создания их новых версий. Для объектов, чтобы не создавать их вручную с новым свойством:
const modifyShirt = (shirt, newColor, newSize) => { return { id: shirt.id, desc: shirt.desc, color: newColor, size: newSize }; }
Можно использовать Object.assign, чтобы не создавать неизменных свойств:
const modifyShirt = (shirt, newColor, newSize) => { return Object.assign( {}, shirt, { color: newColor, size: newSize }); }
Object.assign копирует все свойства объектов, переданные в качестве параметров (начиная со второго параметра) в объект, указанный в первом параметре.
Точно так же можно использовать оператор расширения (разница в том, что Object.assign() использует методы-сеттеры для присвоения новых значений, а этот оператор нет):
const modifyShirt = (shirt, newColor, newSize) => { return { ...shirt, color: newColor, size: newSize }; }
Для массивов также можно использовать оператор расширения, чтобы создавать массивы с новыми значениями:
const addValue = (arr) => { return [...arr, 1]; };
Или же можно использовать методы типа concat или slice, которые возвращают новый массив, не изменяя старый:
const addValue = (arr) => { return arr.concat([1]); }; const removeValue = (arr, index) => { return arr.slice(0, index) .concat( arr.slice(index+1) ); };
В gist можно посмотреть, как комбинировать оператор расширения с этими методами, чтобы не прибегать к неизменным массивам в общих операциях.
У этих нативных подходов есть 2 главных минуса:
Они копируют свойства/элементы из одного объекта/массива в другой. На больших объектах/массивах эта операция будет выполняться медленно.
Объекты и массивы мутируемые по умолчанию. Нужно помнить о том, что используешь один из этих методов.
Поэтому лучше использовать внешнюю библиотеку, обрабатывающую неизменность.
Команда React рекомендует Immutable.js и immutability-helper, но по ссылке можно найти множество библиотек с похожей функциональностью. Есть 3 основных типа:
Библиотеки, работающие со специализированными структурами данных.
Библиотеки, замораживающие объекты.
Библиотеки с хелпер-функциями для выполнения неизменных операций.
Большинство этих библиотек работают с постоянными структурами данных.
Постоянные структуры данных
Постоянная структура данных создает новую версию при любом изменении (что делает данные неизменными) и позволяет обращаться ко всем версиям.
Если структура данных частично постоянна, обратиться можно ко всем версиям, но менять можно только последнюю. Если структура данных полностью постоянна, обращаться и менять можно все версии.
Создание новых версий реализовано эффективно. В основе лежит 2 концепции – деревья и разделение.
Структура данных ведет себя как список или карта, но внутри она реализована как тип дерева trie (если точнее, bitmapped vector trie), где значения хранятся только в листьях дерева, а внутренними узлами дерева являются ключи в бинарном представлении.
Например, для массива:
[1, 2, 3, 4, 5]
Индексы можно преобразовать в 4-битные двоичные числа:
0: 0000 1: 0001 2: 0010 3: 0011 4: 0100
И представить массив в виде дерева:
Где у каждого уровня есть 2 байта, чтобы сформировать путь к значению. Скажем, вы хотите обновить значение 1 на 6.
Вместо прямого обновления значения в дереве узлы по пути к значению от корня копируются:
В новом узле значение обновляется:
Остальные узлы используются повторно:
Другими словами, неизмененные узлы разделяются двумя версиями.
Конечно, такое 4-битное ветвление обычно не используются для этих структур данных. Но это общая концепция разделения структур.
Недостатки
У неизменности есть свои проблемы.
Как я сказал ранее, вы либо должны помнить о том, что нужно использовать методы, обеспечивая этим неизменность при работе с объектами и массивами, либо использовать сторонние библиотеки.
Но многие библиотеки работают со своими типами данных.
Несмотря на то, что они предоставляют совместимые API и способы конвертации этих типов в нативные JS типы, вам придется аккуратно проектировать приложение, чтобы:
Избежать высокого сцепления
Не уронить производительность методами типа toJs()
Если библиотека не реализует новые структуры данных (если она замораживает объекты, например), от разделения структур не будет пользы. Скорее всего, при обновлении объекты будут копироваться. В некоторых случаях это будет бить по производительности.
Также нужно учесть сложность изучения этих библиотек. Аккуратно выбирайте метод неизменности.
Заключение
Программистам React необходимо понять концепцию неизменности.
Неизменное значение или объект нельзя изменить, каждое обновление создает новое значение, старое остается нетронутым.
Например, если состояние приложения неизменно, можно сохранить все объекты состояний в одном месте и легко реализовать функциональность undo/redo.
Знакомо? Должно быть знакомо. Git работает схожим образом. Redux основан на том же принципе.
Однако Redux больше сосредоточен на чистых функциях и скриншотах состояния приложения. Ответ на StackOverflow отлично объясняет связь между Redux и неизменностью.
У неизменности есть другие преимущества, как отсутствие сторонних эффектов или уменьшение сцепки. Но есть и недостатки.
Не забывайте, что это компромисс, как и многое другое в программировании.
Автор: Esteban Herrera
Источник: https://blog.logrocket.com/
Редакция: Команда webformyself.