React, Redux и Immutable.js: составляющие эффективных веб-приложений
Опубликовано: 2022-03-11React, Redux и Immutable.js в настоящее время являются одними из самых популярных библиотек JavaScript и быстро становятся приоритетным выбором разработчиков, когда дело доходит до разработки интерфейса. В нескольких проектах React и Redux, над которыми я работал, я понял, что многие разработчики, начинающие работать с React, не полностью понимают React и то, как писать эффективный код, чтобы использовать весь его потенциал.
В этом руководстве по Immutable.js мы создадим простое приложение с использованием React и Redux, а также определим некоторые наиболее распространенные случаи неправильного использования React и способы их избежать.
Проблема со ссылкой на данные
React — это производительность. Он был создан с нуля, чтобы быть чрезвычайно производительным, повторно отображая только минимальные части DOM для удовлетворения новых изменений данных. Любое приложение React должно в основном состоять из небольших простых компонентов (или функций без сохранения состояния). О них легко рассуждать, и большинство из них могут иметь функцию shouldComponentUpdate , возвращающую false .
shouldComponentUpdate(nextProps, nextState) { return false; }С точки зрения производительности наиболее важной функцией жизненного цикла компонента является shouldComponentUpdate , и, если возможно, она всегда должна возвращать false . Это гарантирует, что этот компонент никогда не будет повторно рендериться (кроме начального рендеринга), эффективно делая приложение React очень быстрым.
Если это не так, наша цель состоит в том, чтобы сделать дешевую проверку на равенство старых реквизитов/состояния и новых реквизитов/состояния и пропустить повторный рендеринг, если данные не изменились.
Давайте сделаем шаг назад на секунду и посмотрим, как JavaScript выполняет проверки на равенство для разных типов данных.
Проверка равенства для примитивных типов данных, таких как boolean , string и integer , очень проста, поскольку они всегда сравниваются по их фактическому значению:
1 === 1 'string' === 'string' true === trueС другой стороны, проверка на равенство для сложных типов, таких как объекты , массивы и функции , совершенно другая. Два объекта одинаковы, если они имеют одинаковую ссылку (указывающую на один и тот же объект в памяти).
const obj1 = { prop: 'someValue' }; const obj2 = { prop: 'someValue' }; console.log(obj1 === obj2); // falseНесмотря на то, что obj1 и obj2 кажутся одинаковыми, их ссылки разные. Поскольку они разные, их наивное сравнение в функции shouldComponentUpdate приведет к ненужному повторному рендерингу нашего компонента.
Важно отметить, что данные, поступающие от редукторов Redux, если они не настроены правильно, всегда будут обслуживаться с другой ссылкой, что приведет к повторному рендерингу компонента каждый раз.
Это основная проблема в нашем стремлении избежать повторного рендеринга компонентов.
Обработка ссылок
Давайте возьмем пример, в котором у нас есть глубоко вложенные объекты, и мы хотим сравнить его с предыдущей версией. Мы могли бы рекурсивно пройтись по вложенным реквизитам объекта и сравнить каждый из них, но очевидно, что это было бы чрезвычайно дорого и не может быть и речи.
Это оставляет нам только одно решение — проверить ссылку, но быстро появляются новые проблемы:
- Сохранение ссылки, если ничего не изменилось
- Изменение ссылки, если изменилось какое-либо из значений вложенных объектов/массивов.
Это непростая задача, если мы хотим сделать ее красивым, чистым и оптимизированным для производительности способом. Facebook давно осознал эту проблему и призвал на помощь Immutable.js.
import { Map } from 'immutable'; // transform object into immutable map let obj1 = Map({ prop: 'someValue' }); const obj2 = obj1; console.log(obj1 === obj2); // true obj1 = obj1.set('prop', 'someValue'); // set same old value console.log(obj1 === obj2); // true | does not break reference because nothing has changed obj1 = obj1.set('prop', 'someNewValue'); // set new value console.log(obj1 === obj2); // false | breaks reference Ни одна из функций Immutable.js не выполняет прямое изменение заданных данных. Вместо этого данные клонируются внутри, видоизменяются, и если были какие-либо изменения, возвращается новая ссылка. В противном случае он возвращает начальную ссылку. Новая ссылка должна быть установлена явно, например, obj1 = obj1.set(...); .
Примеры React, Redux и Immutable.js
Лучший способ продемонстрировать возможности этих библиотек — создать простое приложение. А что может быть проще приложения todo?
Для краткости в этой статье мы рассмотрим только те части приложения, которые имеют решающее значение для этих концепций. Весь исходный код кода приложения можно найти на GitHub.
Когда приложение запущено, вы заметите, что вызовы console.log удобно размещены в ключевых областях, чтобы четко показать количество повторного рендеринга DOM, которое минимально.
Как и в любом другом приложении todo, мы хотим показать список элементов todo. Когда пользователь нажимает на элемент задачи, мы помечаем его как завершенный. Также нам нужно небольшое поле ввода вверху для добавления новых задач и внизу 3 фильтра, которые позволят пользователю переключаться между:
- Все
- Завершенный
- Активный
редукс редуктор
Все данные в приложении Redux находятся внутри одного объекта хранилища, и мы можем рассматривать редукторы как просто удобный способ разбить хранилище на более мелкие части, о которых легче рассуждать. Поскольку редуктор — это тоже функция, его тоже можно разбить на еще более мелкие части.
Наш редуктор будет состоять из 2-х небольших деталей:
- список дел
- активный фильтр
// reducers/todos.js import * as types from 'constants/ActionTypes'; // we can look at List/Map as immutable representation of JS Array/Object import { List, Map } from 'immutable'; import { combineReducers } from 'redux'; function todoList(state = List(), action) { // default state is empty List() switch (action.type) { case types.ADD_TODO: return state.push(Map({ // Every switch/case must always return either immutable id: action.id, // or primitive (like in activeFilter) state data text: action.text, // We let Immutable decide if data has changed or not isCompleted: false, })); // other cases... default: return state; } } function activeFilter(state = 'all', action) { switch (action.type) { case types.CHANGE_FILTER: return action.filter; // This is primitive data so there's no need to worry default: return state; } } // combineReducers combines reducers into a single object // it lets us create any number or combination of reducers to fit our case export default combineReducers({ activeFilter, todoList, });Подключение к Редукс
Теперь, когда мы настроили редьюсер Redux с данными Immutable.js, давайте соединим его с компонентом React для передачи данных.
// components/App.js import { connect } from 'react-redux'; // ….component code const mapStateToProps = state => ({ activeFilter: state.todos.activeFilter, todoList: state.todos.todoList, }); export default connect(mapStateToProps)(App);В идеальном мире соединение должно выполняться только для компонентов маршрута верхнего уровня, извлекая данные в mapStateToProps, а остальное — это базовая передача реквизитов React дочерним элементам. В крупномасштабных приложениях сложно отслеживать все подключения, поэтому мы хотим свести их к минимуму.
Очень важно отметить, что state.todos — это обычный объект JavaScript, возвращаемый функцией CombineReducers Redux (todos — это имя редьюсера), но state.todos.todoList — это неизменяемый список, и очень важно, чтобы он оставался в таком виде. form, пока не пройдет проверку shouldComponentUpdate .
Как избежать повторного рендеринга компонентов
Прежде чем копнуть глубже, важно понять, какой тип данных должен передаваться компоненту:
- Примитивные типы любого вида
- Объект/массив только в неизменяемой форме
Наличие этих типов данных позволяет нам поверхностно сравнивать свойства, которые входят в компоненты React.
В следующем примере показано, как различать пропсы самым простым способом:
$ npm install react-pure-render import shallowEqual from 'react-pure-render/shallowEqual'; shouldComponentUpdate(nextProps, nextState) { return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState); }Функция smallEqual проверит разницу в реквизитах/состоянии только на 1 уровень вглубь. Он работает очень быстро и идеально сочетается с нашими неизменяемыми данными. Было бы очень неудобно писать это shouldComponentUpdate в каждом компоненте, но, к счастью, есть простое решение.

Извлеките shouldComponentUpdate в специальный отдельный компонент:
// components/PureComponent.js import React from 'react'; import shallowEqual from 'react-pure-render/shallowEqual'; export default class PureComponent extends React.Component { shouldComponentUpdate(nextProps, nextState) { return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState); } }Затем просто расширьте любой компонент, в котором требуется эта логика shouldComponentUpdate:
// components/Todo.js export default class Todo extends PureComponent { // Component code }Это очень чистый и эффективный способ избежать повторного рендеринга компонентов в большинстве случаев, а позже, если приложение становится более сложным и внезапно требует пользовательского решения, его можно легко изменить.
Существует небольшая проблема при использовании PureComponent при передаче функций в качестве свойств. Поскольку React с классом ES6 не привязывает это автоматически к функциям, нам приходится делать это вручную. Мы можем добиться этого, выполнив одно из следующих действий:
- использовать привязку стрелочной функции ES6:
<Component onClick={() => this.handleClick()} /> - используйте привязку :
<Component onClick={this.handleClick.bind(this)} />
Оба подхода приведут к повторному рендерингу компонента , поскольку каждый раз в onClick передается другая ссылка.
Чтобы обойти эту проблему, мы можем предварительно связать функции в методе конструктора следующим образом:
constructor() { super(); this.handleClick = this.handleClick.bind(this); } // Then simply pass the function render() { return <Component onClick={this.handleClick} /> }Если вы обнаружите, что большую часть времени предварительно связываете несколько функций, мы можем экспортировать и повторно использовать небольшую вспомогательную функцию:
// utils/bind-functions.js export default function bindFunctions(functions) { functions.forEach(f => this[f] = this[f].bind(this)); } // some component constructor() { super(); bindFunctions.call(this, ['handleClick']); // Second argument is array of function names }Если ни одно из решений вам не подходит, вы всегда можете написать условия shouldComponentUpdate вручную.
Обработка неизменяемых данных внутри компонента
С текущей настройкой неизменяемых данных удалось избежать повторного рендеринга, и у нас остались неизменяемые данные внутри свойств компонента. Есть несколько способов использовать эти неизменяемые данные, но наиболее распространенная ошибка заключается в том, чтобы сразу преобразовать данные в простой JS с помощью функции immutable toJS .
Использование toJS для глубокого преобразования неизменяемых данных в простой JS сводит на нет всю цель предотвращения повторного рендеринга, потому что, как и ожидалось, он очень медленный, и его следует избегать. Так как же нам обращаться с неизменяемыми данными?
Его нужно использовать как есть, поэтому Immutable API предоставляет широкий спектр функций, map и get , которые чаще всего используются внутри компонента React. Структура данных todoList , полученная от Redux Reducer, представляет собой массив объектов в неизменной форме, каждый объект представляет собой один элемент todo:
[{ id: 1, text: 'todo1', isCompleted: false, }, { id: 2, text: 'todo2', isCompleted: false, }]API Immutable.js очень похож на обычный JavaScript, поэтому мы будем использовать todoList, как и любой другой массив объектов. Функция карты оказывается лучшей в большинстве случаев.
Внутри обратного вызова карты мы получаем todo , объект, который все еще находится в неизменной форме, и мы можем безопасно передать его в компонент Todo .
// components/TodoList.js render() { return ( // …. {todoList.map(todo => { return ( <Todo key={todo.get('id')} todo={todo}/> ); })} // …. ); }Если вы планируете выполнять несколько связанных итераций с неизменяемыми данными, такими как:
myMap.filter(somePred).sort(someComp)… затем очень важно сначала преобразовать его в Seq с помощью toSeq, а после итераций вернуть обратно в желаемую форму, например:
myMap.toSeq().filter(somePred).sort(someComp).toOrderedMap()Поскольку Immutable.js никогда напрямую не изменяет данные, ему всегда нужно делать еще одну их копию, выполнение нескольких итераций, подобных этой, может быть очень дорогим. Seq — это ленивая неизменяемая последовательность данных, то есть она будет выполнять как можно меньше операций для выполнения своей задачи, пропуская создание промежуточных копий. Seq был создан для использования таким образом.
Внутри компонента Todo используйте get или getIn для получения реквизита.
Достаточно просто, верно?
Что ж, я понял, что в большинстве случаев это может стать очень нечитаемым из-за большого количества get() и особенно getIn() . Поэтому я решил найти золотую середину между производительностью и удобочитаемостью, и после нескольких простых экспериментов я обнаружил, что функции Immutable.js toObject и toArray работают очень хорошо.
Эти функции поверхностно преобразуют (на уровне 1) объекты/массивы Immutable.js в простые объекты/массивы JavaScript. Если у нас есть какие-либо данные, глубоко вложенные внутрь, они останутся в неизменном виде, готовые к передаче в
Он медленнее, чем get() , лишь незначительно, но выглядит намного чище:
// components/Todo.js render() { const { id, text, isCompleted } = this.props.todo.toObject(); // ….. }Давайте посмотрим все это в действии
Если вы еще не клонировали код с GitHub, самое время это сделать:
git clone https://github.com/rogic89/ToDo-react-redux-immutable.git cd ToDo-react-redux-immutableЗапустить сервер так же просто (убедитесь, что установлены Node.js и NPM):
npm install npm start Перейдите по адресу http://localhost:3000 в веб-браузере. Открыв консоль разработчика, наблюдайте за логами, когда вы добавляете несколько элементов списка задач, отмечаете их как выполненные и меняете фильтр:
- Добавить 5 задач
- Измените фильтр с «Все» на «Активные», а затем обратно на «Все».
- Нет задачи повторного рендеринга, просто смена фильтра
- Отметить 2 задачи как выполненные
- Две задачи были перерисованы, но только по одной за раз.
- Измените фильтр с «Все» на «Активные», а затем обратно на «Все».
- Только 2 завершенных элемента todo были смонтированы/размонтированы
- Активные не перерисовывались
- Удалить один элемент списка дел из середины списка
- Затронут только удаленный элемент задачи, остальные не были перерисованы.
Заворачивать
Синергия React, Redux и Immutable.js при правильном использовании предлагает несколько элегантных решений многих проблем с производительностью, которые часто встречаются в больших веб-приложениях.
Immutable.js позволяет нам обнаруживать изменения в объектах/массивах JavaScript, не прибегая к неэффективности глубоких проверок на равенство, что, в свою очередь, позволяет React избегать дорогостоящих операций повторного рендеринга, когда они не требуются. Это означает, что производительность Immutable.js, как правило, хорошая в большинстве сценариев.
Надеюсь, вам понравилась статья, и вы сочтете ее полезной для создания инновационных решений React в ваших будущих проектах.
