Известно что самая медленная операция в барузере - изменение DOM дерева. И что реакт стремится светси эти обновления к минимуму. Для этого он:
- На стадии рендера - строит дешевый вируатльный vDOM (который браузер не ренедерит)
- На стадии сравнения - находит разницу между VDOM и текущим DOM (для этого у него есть копия последнего примененного VDOM, т.е. по сути VDOM-ов два - current и new).
- На стадии обновления - вызывает браузерную апи для того чтобы исправить разницу в реальном DOM и новом VDOM
Например:
function App() {
const [name, setName] = useState('world');
return <div>
<h1>Hello {name}</h1>
<button onClick={() => setName('react')}> I am react </button>
</div>
}
- Первая стадия:
div
├── h1
│ └── Hello world
└── button
└── A am react
- Вторая стадия пропускается (старого стостояния еще нет),
- Третья стадия создает div, h1, button с соотвествующим текстом и слушателем событий.
- Первая стадия:
div
├── h1
│ └── Hello react
└── button
└── A am react
- Вторая стадия сравнивает новый vdom со старым и находит разницу - отличается текст внутри
h1
тэга - Третья стадия находит этот тэг и меняет
innerText
у него на 'Hello React'.
Понятно что третья стадия вызывается когда есть изменения между старым и новым vdom, а вторая сразу после первой. Но ...
-
Если вызывается сеттер из
useState
эффекта (в нашем примереsetName
) который устанавливает новое значение* (Однако есть один нюанс к которому вернусь позже.) -
Если встроенный или custom effect (
useCallback
например) возращает новое значение*. (useEffect
не вызывает рендер т.к. ничего не возвращает) -
Если что-либо из выше-перечисленного произошло в родительском компоненте
- новое оно или нет определяется сравнением с помощью метода
Object.is(old, new)
Таким образом - изменения в верхушке дерева приводят к перезапуску render стадии у всех детей и детей его детей и так далее.
А теперь вернемся к нюансу о котором я говорил выше:
в случае установки того-же значения еще раз через сеттер useState - рендер выполнится один раз,
но только для компонента в котором он поменял значение.
Первое что надо сделать - выдохнуть и перестать волноваться - с учетом того что все это происходит в неблокирующем
и конкурентном режиме реакту удается быть таким же бы быстрым как и другим популярным библиотекам.
Причин так сделать более одной, но пока примем это как факт.
Более того реакт дает нам возможность - в случае если мы знаем что делаем, "замораживать" компонент используя React.memo
Например:
import { memo } from 'react';
function Hello({ name }) {
return <h1>{ name }</h1>
}
export memo(Hello);
Здесь мы оберунли компонент в memo
и тем самым создали компонент который запустит render стадию если:
-
Если вызывается сеттер из useState эффекта (в нашем примере
setName
) который устанавливает новое значение*, (Однако есть один нюанс к которому вернусь позже.) -
Если встроенный или custom effect (
useCallback
например) возращает новое значение*. (useEffect
не вызывает рендер т.к. ничего не возвращает)
3. Если что-либо из выше-перечисленного произошло в родительском компоненте
- Если у компонента изменится один из props
Это может ускорить отрисовку приложения, но только в том случае если в компоненте много jsx и мало props, в противном случае (много пропсов и мало jsx) расходы на сравнение старых и новых значений пропсов превысят время вызова хорошо оптимизированных функций построения vdom дерева.
Cтоит отметить, что в memo
можно передать свою функцию сравнения что иногда бывает полезно.
Еще одно замечание - библиотека react-redux поставляет функцию connect(mapStateToProps, mapDispatchToProps)
, которая автоматически мемоизирует обернутый компонент.
Из весего этого можно сделать важный вывод - уменьшить количество вызовов функции render не равно ускорить приложение.
- Не делать тяжелых вычислений в реакте, выносить их в store
- Не делать тяжелых вычислений на фронтенде - выносить их в бекенд
- Оборачивать компоненты в memo там где это необходимо
- Если вычисления все-же необходимо делать в компоненте - делать это внутри useEffect который защищен от лишних рендеров
Так же стоит заметить что функции обернутые в memo требуют особого обращения:
Как знаем, новое значение в пропсах или нет по умолчанию определяется с помощью Object.is()
- Довольно часто, например при написании своего эффекта - мы можем использовать конструктор обьекта или массива (в js - тоже обьекта :), например:
export function useConvertSomething(something = []) {
const [converted, setConverted] = useState([]);
useEffect(() => {
const result = convert(something);
setConverted(result);
}, [something])
return converted;
}
В данном случае мы использовали конструктор массива [] дважды - как значение по умолчанию для аругмента something
и как значение по умолчанию для useState
. С точки зрения Object.is([], []) === false
, так что будет меньше рендеров если написать так:
export function useConvertSomething(something) {
const [converted, setConverted] = useState([]); // useState установит значение по-умолчанию только при первом рендере
useEffect(() => {
if (Array.isArray(something)) {
const result = convert(something);
setConverted(result);
}
}, [something])
return converted;
}
Теперь эффект не будет возвращать новый массив каждый раз когда в него передали undefined. А значит не будут вызыватся эффекты которые от него зависят и компоненты обернутые в memo которые получают этот реультат как props.
Еще один способ убить мемоизацию - стрелочные функции в jsx, например
<ul>
{ items.map(item => <Row onClick={() => selectItem(item.id)}> {item.name} </Row>) }
<ul>
Обычно в этом нет ничего плохого, но если <Row>
это мемоизирвоанный компонент и/или в нем есть useEffect
зависящий от onClick
пропса, то в этом случае рендер/effect будет вызыватся каждый раз при рендере родителя по сколько мы каждый раз в цикле создаем новую функцию. В качестве оптимизации здесь можно применить useCallback