Одной из самых важных теоретических тем, понимание которой облегчает работу с библиотекой React является процесс рендеринга компонентов. Как React понимает, что пора обновить DOM-дерево? Как ререндеринг влияет на производительность и как ее улучшить? Что происходит под капотом React, когда мы решаем отобразить компонент на странице? Какую роль в этом всем играют хуки? Чтобы ответить на эти вопросы необходимо разобраться с такими понятиями, как рендеринг, жизненный цикл компонента, реконциляция, побочные эффекты и с некоторыми другими.
План статьи:
1) Рендеринг в контексте React
2) Жизненный цикл компонента
2.1) Mounting компонента
2.2) Update компонента
2.3) Unmounting компонента
3) Некоторые вопросы для самопроверки
1. Рендеринг в контексте React
Что для вас значит термин рендеринг? Мы привыкли, что в общем смысле этого слова подразумевается визуальное представление какой-либо просчитанной модели (рендер сцены, рендер 3d-модели, рендер видео и тд.). То есть рендеринг - это визуальное представление, полученное в результате каких-либо вычислений. Значит без расчетов не будет рендера, звучит очевидно.
Но React имеет свое понимание процесса рендеринга. Рендеринг в React - это вычисление того, что должно быть отображено. Когда React рендерит компоненты, он не занимается их отображением, он всего лишь узнает, из каких элементов должен состоять компонент, обращаясь к нему и анализируя код, который он возвращает.
Предположим, у нас имеется такой функциональный компонент:
const SomeComponent = () => {
return (
<div>
<h1>Text of SomeComponent</h1>
</div>
)
}
Также предположим, что React начал процесс рендеринга компонента (мы пока что не вдаемся в причины, которые заставили React отрендерить данный компонент). Что происходит под капотом?
SomeComponent - это функция. React вызывает ее. Вызов данной функции возвращает JSX-разметку компонента, которую React начинает анализировать. React понимает, какие реальные DOM-элементы содержит в себе данный компонент. Поняв, какие DOM-ноды содержатся в JSX-коде, React преобразует данный JSX в вызов функции React.createElement(), который вернет виртуальный DOM-объект, представляющий компонент.
То есть JSX, приведенный выше будет транспилирован в следующий вызов:
const SomeComponent = React.createElement(
‘div’, // тип элемента
null, // пропсы, в данном случае их нет, поэтому null
React.CreateElement(
‘h1’, // тип элемента
null, // пропсы, в данном случае их нет, поэтому null
‘Text of SomeComponent’ // дочерний элемент h1 - текст
)
);
Таким образом React получает объектное представление компонента, которое поможет ему построить virtual-DOM. Данный процесс и является рендерингом.
Очевидно, что получение представления компонента - это лишь пол дела. Что React делает дальше? А дальше происходит процесс, называемый commit’ом. Во время коммита React вставляет в реальный DOM данный объект компонента. Этот процесс управляется подмодулем React - react-DOM’ом. Чтобы эффективно вставлять и удалять виртуальные DOM объекты из virtual DOM React также инициализирует процесс реконциляции, во время которого сравнивает (дифферирует от англ. diffing) актуальный Virtual DOM с предыдущим. Сравнение двух деревьев позволяет ему эффективно решать, какие DOM-ноды обновить, удалить, добавить. О commit и reconciliation процессах я упоминаю вскользь, так как достаточно просто знать, что они есть и что из себя представляют.
Из приведенного выше объяснения можно увидеть такую связь: каждый рендер запускает цепочку процессов:
render -> reconciliation (diffing) -> commit
И теперь не должно возникнуть вопросов касательно того, почему React так хорош в эффективном построении DOM и почему эта операция может быть одновременно эффективной и дорогой (при неправильном подходе).
А теперь вопрос: в какой момент происходит рендеринг? Чем он триггерится? Для этого нужно познакомиться с жизненным циклом компонента.
2. Жизненный цикл компонента
Жизненный цикл компонента - это последовательность этапов, через которые проходит компонент от момента его создания до момента его удаления из DOM.
Состоит он из 3 этапов:
-
Mount (монтирование)
-
Update (обновление)
-
Unmount (демонтирование)
2.1 Mounting компонента
Фаза, во время которой компонент впервые добавляется в DOM.
В качестве примера возьмем такой компонент:
const SomeComponent = () => {
const [inputValue, setInputValue] = useState(() => getInitialValue());
const ref = useRef(null);
useEffect(() => {
… // какие-нибудь побочные эффекты (запрос данных с api, подписка на events и тд)
return () => {
… // clean-up функция
};
}, [inputValue]);
useLayoutEffect(() => {
…
return () => {
… // clean-up функция
}
})
const heavy = useMemo(() => {
return doSomethingHeavy(inputValue);
}, [inputValue]);
const id=”textInput”;
return (
<div>
<label htmlFor={id}>label for input</label>
<input
id={id}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder=”Type something to update”
ref={ref}
autoFocus
onFocus={(e) => {console.log(“onFocus”);}}
/>
<Child heavy={heavy} />
</div>
)
};
Поясню за UI представленного компонента: компонент возвращает controllable input и один дочерний компонент Child.
А теперь я опишу цепь процессов, происходящих во время первичного монтирования данного компонента.
1) Первый этап цикла компонента - его вмонтирование в DOM. Начинается процесс рендеринга: React вызывает компонент, чтобы проанализировать его JSX.
Во время рендеринга:
-
Происходит расчет локальных значений, объявленных в компоненте (переменная id).
-
Побочные эффекты, находящиеся в useEffect() будут помещены в список.
-
При первичном рендере хуки инициализируются в порядке объявления в компоненте.
-
Для хуков useState(), useReducer() и useRef() React сохранит первоначальные состояния и будет игнорировать их при последующих апдейтах компонента. Для улучшения производительности React позволяет прокинуть в useReducer и useState функцию получения первоначального состояния (getInitialValue()). Эту функцию получения он вызовет строго один раз во время маунта компонента.
-
Также будет вычислен результат, который возвращает хук useMemo(). Пока inputValue не изменится, функция doSomtehingHeavy() не будет вызвана, что улучшит производительность при дорогих вычислениях.
В данном компоненте я не объявлял такие хуки, как useCallback(), useDefferedValue().
В случае, если они присутствуют:
-
useCallback() сохранит ссылку на объявленную в нем функцию. Это нужно, чтобы не пересоздавать ссылку на эту же функцию при каждом апдейте компонента. Это помогает избежать лишних ререндеров компонентов, зависящих от этой функции.
-
useDefferedValue() вернет значение, переданное ему. Не буду вдаваться в подробности данного хука, скажу лишь, что данный хук в определенной мере похож на дебаунс.
-
React рекурсивно рендерит дочерние компоненты исходного (проделывает с ними те же действия).
2) React отрендерил компонент, получил его представление и переходит в commit-фазу.
- Полученные DOM-ноды вставляются в реальное DOM-дерево.
Чтобы убедиться, что DOM-элемент был создан, на input навешаны autoFocus и onFocus хендлер. Браузер автоматически сфокусируется на созданном элементе и вызовет хендлер, как только элемент появится в DOM.
- ref’ы позволяют получить доступ к узлам DOM. Во время первого рендера эти узлы еще не находятся в DOM, поэтому пока что имеют значение undefined или null. Рефы можно передать элементам и управлять ими, также в ref можно поместить коллбек, который будет вызван после добавления элемента в DOM.
3) React сетит ref’ы.
После коммита появляется доступ к DOM-нодам. Все рефы получают свое значение (перестают быть null или undefined).
4) Запустятся Layout эффекты.
После коммита, но перед пеинтом запустятся Layout эффекты. Ничто не должно мешать браузеру заниматься отрисовкой, поэтому они полезны, если необходимо совершить какие-либо действия прямо во время пеинта приложения.
5) Происходит отрисовка DOM браузером.
6) После небольшой задержки происходит запуск побочных эффектов, объявленных в useEffect().
Скрытый текст
1) Рендер компонента, анализ JSX -> вычисление локальных значений -> вычисление стейтов -> вычисление и мемоизация переменных (если есть) -> рекурсивный рендер дочерних компонентов.
2) Вставка элементов в DOM (commit).
3) Обновление рефов на DOM-узлы.
4) Запуск Layout эффектов.
5) Пеинт DOM дерева.
6) Запуск побочных эффектов после задержки.
2.2 Update компонента
Фаза, во время которой в ответ на изменения запускается ререндеринг компонента.
Перед объяснением этапа апдейта компонента необходимо пояснить 2 вещи:
1) Компонент может обновиться по 2-ум причинам:
- Обновились пропсы
- Обновился его стейт
2) Ререндеринг компонента неизбежно ведет к рекурсивному ререндерингу его дочерних компонентов, даже если их пропсы не были изменены (если они не мемоизированы при помощи React.memo()).
Берем все тот же вышеприведенный компонент для наглядности.
Цепь процессов следующая:
1) Как обычно начинается процесс рендеринга компонента.
- Происходит повторное вычисление новых пропсов, локальных переменных и стейтов.
- React сравнивает зависимости хуков на наличие изменения с зависимостями, полученными во время предыдущего рендера. Хуки без зависимостей или с измененными зависимостями будут вызваны повторно. Хуки без измененных зависимостей будут пропущены.
- Компонент возвращает новый JSX.
2) Обновление DOM. Происходит очередной commit, который обновляет DOM-дерево. Рендеринг не всегда приводит к обновлению всего DOM’a. Он обновляет только те элементы, которые нуждаются в этом.
3) Перед обновлением DOM ref’ы снова получат значение null. Нестабильные ref-коллбеки будут вызваны еще раз, в то время как коллбеки, сохраненные при помощи useCallback() вызваны не будут.
4) cleanup функции useLayoutEffect(), которые были возвращены в ходе предыдущего рендера отработают со стейтами или пропсами имеющими значения предыдущих рендеров.
5) После commit’a React снова засетит ref’ы.
6) Снова вызываются Layout эффекты (соответственно состоянию их зависимостей).
7) Происходит репеинт DOM.
8) Вызываются cleanup функции возвращенные useEffect’ом во время предыдущего рендера. Они так же будут иметь “устаревшие” пропсы или состояния (поэтому мы не можем сразу увидеть новый стейт если сначала обновим его в этом же эффекте, а потом попробуем вывести его через console.log()).
9) Произойдет очередной вызов побочных эффектов, если зависимости в useEffect() были изменены.
Скрытый текст
1) Рендеринг -> перевычисление стейтов, локальных переменных, пропсов -> вызов хуков с измененными зависимостями -> рендеринг дочерних компонентов.
2) Обновление DOM.
3) ref’ы зануляются.
4) Вызываются cleanup функции Layout эффектов с “несвежими” значениями.
5) После обновления DOM ref’ы получают новые значения.
6) Вызов Layout эффектов.
7) Перерисовка DOM.
8) Вызов cleanup функций побочных эффектов с “несвежими” значениями.
9) Вызов побочных эффектов, если изменены зависимости.
2.2 Unmount компонента
Этап размонтирования и удаления компонента из DOM.
1) Происходит финальный запуск cleanup-функций Layout эффектов.
2) Ансетятся ref’ы, так как удаляются DOM-элементы, на которые они ссылаются.
3) Удаляются DOM-узлы.
4) Наконец, запускаются cleanup-функции побочных эффектов в последний раз.
Тут короче не распишешь, самый короткий этап, тем и лучше! И от теории к вопросам.
3. Вопросы для самопроверки
1) Чем является рендеринг в React? Зачем нужны reconciliation и commit фазы?
2) Из чего состоит жизненный цикл React компонента?
3) Под действием каких обстоятельств запускается процесс ререндеринга?
4) Что помогает предотвратить лишние ререндеры дочерних компонентов?
На этом статья заканчивается и я буду рад услышать ваше мнение и получить конструктивную критику. Если вы заметили какие-либо неточности или ошибки, обязательно сообщите об этом — буду рад внести исправления и подискутировать)
Также для полного понимания советую пройтись по данному сайту, который наглядно показывает этапы жизненного цикла компонента. Пример компонента я взял оттуда. Также при написании статьи я частично руководствовался вот этой мощной статьей. Она подробно описывает процессы, происходящие у реакта под капотом.
Автор: Am-glitch