Оптимизация в JS
-приложениях играет важную роль, так как мощная система и хороший интернет – это, скорее, исключения, чем правило. Согласно отчёту GlobalWebIndex
за 2020-й год, более половины времени проводимого в интернете приходится на мобильные телефоны (интересная статья с habr). Средняя скорость мобильного интернета хоть и медленно растёт, но всё ещё недостаточна чтобы комфортно загружать огромный пакет JS
-кода. Пользователи ждут, им неудобно пользоваться приложением, они уходят.
Так что, возвращаемся к старым привычным технологиям выдачи html
-разметки сервером? Можно даже и не к старым, а совсем новым: React
-фреймворки (NextJS
и GatsbyJS
) так умеют. Тем более, что они сами разбивают приложение по кусочкам. Но, всё же, это не единственный выход. За годы своего существования команда React
добавила несколько важных для оптимизации методов. А раз упомянутые фрейморки их тоже имеют, так как основаны на этой технологии, оптимизацию можно делать и в них! И не просто «можно» - нужно. Ведь все мы хотим хороший и плавный пользовательский опыт.
Для удобства чтения советы разбиты на секции:
- Оптимизация подходом в написании кода компонента;
- Оптимизация обновления компонентов;
- Импорт компонентов (разбивка кода);
- Оптимизация работы внутри компонентов;
- Полезные ссылки.
Перечисленные в этой статье советы не единственные, как можно оптимизировать приложение. У каждого фреймворка есть свои инструменты. У самих html
/css
/js
есть приёмы. Появляются новые технологии. В этой же статье внимание уделено только тому, что можно сделать силами самого React
.
Оптимизация подходом в написании кода компонента
Лучше функциональные компоненты, чем компоненты-классы
Раньше почти любой компонент приходилось писать, создавая класс для наследования React.Component
, ведь терялся весь смысл React
: состояние компонента, жизненный цикл. Но всё изменилось в 2018, когда в React
внедрили хуки. Теперь у нас есть, как минимум, useState
и useEffect
, которые позволяют пользоваться ключевыми преимуществами React
, не перетаскивая всю громоздкую функциональность класса.
Класс – хорошо. От него отказываться не стоит. Но если вам нужно написать условный счётчик с хранением одного значения и одной функцией обновления, зачем тянуть функционал жизненного цикла и пр.? Тем более, что это будет короче и яснее, взгляните.
Компонент-класс:
import React from 'react';
class Counter extends React.Component {
constructor(props) {
super(props);
this.state = {
counter: 0,
}
this.update_counter = this.update_counter.bind(this);
}
update_counter = () => {
const { counter } = this.state;
this.setState({
counter: counter + 1
})
}
render() {
return(
<div>
<p>Counter: {this.state.counter}</p>
<button
type='button'
onClick={this.update_counter}
aria-labelledby='counter updater'>Update counter</button>
</div>
)
}
}
export default Counter;
Функциональный компонент:
import React from 'react';
const Counter = () => {
const [counter, update_counter] = React.useState(0);
return(
<div>
<p>Counter: {counter}</p>
<button
type='button'
onClick={() => update_counter(counter + 1)}
aria-labelledby='counter updater'>Update counter</button>
</div>
)
}
export default Counter;
Согласитесь, выглядит гораздо легче и приятнее. Вдвойне приятно от того, что меньше кода – быстрее загружается приложение.
Используйте локальное состояние
Когда можно хранить состояние компонента внутри дочернего компонента, всегда старайтесь его хранить там. Писать обработку в родителе с передачей вычисленного значения – плохая идея.
Это плохо по двум причинам: нарушает изолированность компонента и мешает быстрой масштабируемости; ставит ребёнка в зависимость обновления от параметра родителя. Учитывая, что у родителя может быть несколько детей, они все будут пересчитываться и обновляться из-за того, что обновилось состояние, нужное лишь в одном.
Пример для наглядности. Приложение со счётчиком и приветственным словом:
import React from 'react';
import Counter from './Counter';
import Hello from './Hello';
const Container = () => {
const [counter, update_counter] = React.useState(0);
return(
<div>
<Counter
counter={counter}
update={update_counter} />
<Hello />
</div>
)
}
export default Container;
Как можно увидеть, тут внутри содержатся два ребёнка, но выполняемое обновление состояния counter
нужно лишь одному. Компонент Hello
будет обновляться бесполезно. Это крошечная трата ресурсов, но, если таких компонентов будет десять, сто, тысяча? Никогда не стоит забывать о масштабируемости – да и просто о правилах хорошего написания кода.
Также у нас может быть случай:
import React from 'react';
import Gallery from './Gallery';
import Hello from './Hello';
const Container = () => {
const [images, update_update_images] = React.useState([]);
// call fetch method when component is mount
React.useEffect(() => {
fetch_images();
}, [])
const fetch_images = () => {
let new_images = [];
// ...fetch data
update_update_images(new_images);
}
return(
<div>
<Gallery images={images} />
<Hello images={images} />
</div>
)
}
export default Container;
Здесь уже у двух дочерних компонентов используются данные из состояния images
, поэтому мы не можем перенести их внутрь какого-то из компонентов (иначе нам придётся повторять логику и делать запросы по нескольку раз).
Допустим, структура ответа такая, что какие-то изображения имеют свойство active: false
, а какие-то – active: true
. То есть, какие-то нужно показывать в компоненте Gallery
, а какие-то нет. Как нам это оформить?
Мы можем создать локальное состояние active_images
и проверять приходящий от родителя массив images
, выделяя только объекты со свойством true
. Далее выводить только данные из active_images
.
<div>
{
active_images.map((image, index) => (
<img key={index} src={image.src} alt="gallery image" />
))
}
</div>
Выглядит логично, верно? Мы создаём новую сущность и её обрабатываем. Но это будет ошибочно с точки зрения оптимизации. Зачем нам заводить состояние active_images
, если изначально можно выводить только объекты с параметром active: true
из родительского массива?
{
images.map((image, index) => {
if (image.active === true) {
return(
<img key={index} src={image.src} alt="gallery image" />
)
} else return null;
})
}
Таким образом, мы избегаем ненужных для работы вычислений, экономим ресурсы пользователя и наше приложение работает быстрее.
Передавайте цельный метод, а не стрелочную функцию напрямую в компонент
Допустим, нам нужно хранить данные родителя с обработкой в дочернем компоненте. Как это сделать? Быстро на ум приходит конструкция такого вида:
const Container = () => {
const [local_state, update_local_state] = React.useState(null);
return(
<div>
<Hello action={(data) => update_local_state(data)} />
</div>
)
}
export default Container;
Что же здесь неправильного? Дело в анонимной стрелочной функции. В такой конструкции мы не привязываем функцию к переменной, а каждый раз переопределяем её (из-за анонимности), что влечёт к лишним вычислениям и, в редких случаях, ошибкам.
Как исправить? Вынести в полноценный метод:
const Container = () => {
const [local_state, update_local_state] = React.useState(null);
const some_work = (data) => {
update_local_state(data);
}
return(
<div>
<Hello action={some_work} />
</div>
)
}
Всё, теперь при каждом обновлении компонента Container
, дочернему компоненту Hello
передаётся одна и та же функция.
(на самом деле нет, это справедливо только для классов, но об этом читайте ниже в секции «Оптимизируем вычисления на построение DOM
сетки»)
Не используйте index
как уникальный ключ (key) элемента при работе с массивами
Дело в том, что при обновлении массива, могут удаляться/добавляться компоненты и на место одного из элементов придёт другой. Но внутри структуры массива (т. е. для React
) index
у этих разных элементов одинаковый – значит, это один и тот же элемент, ему не нужно обновление.
Например, у вас было четыре элемента с разным именем. Вы удалили третий, и на его место пришёл четвёртый (сместился на один вниз). Так как его index
стал третьим, React
не увидит, что в списке поменялся третий элемент – он просто удалит четвёртый, а третий оставит со старым названием. Ошибка.
Как быть, если в списке нет уникальных id
? Тут нам приходит на помощь модуль Nano ID
. Он позволяет быстро и легко создавать уникальные ключи при разборе массива.
Этот пункт не относится к оптимизации напрямую, но помогает ускорить разработку за счёт исключения мелких ошибок.
Оптимизация обновления компонентов
В качестве одного из преимуществ системы жизненного цикла, React
предоставляет нам возможность контролировать обновление компонента. В случаях с громоздкими сложными вычислениями это особенно важно, так как помогает экономить кучу ресурсов пользователя на пересчёт того, что пересчитывать не нужно.
Да, речь идёт о shouldComponentUpdate()
. Этот метод позволяет нам сравнивать текущие данные и приходящие новые, чтобы решить: нужно ли обновлять компонент.
Однако, здесь есть два момента:
- Это структура громоздкая;
- Как её использовать в функциональных компонентах?
К счастью, команда React
подумала об этом.
Используем упрощённую реализацию для компонентов-классов
React.PureComponent
– позволяет нам добавить встроенную проверку обновляемых данных в props
компонента.
Чтобы его использовать, нужно лишь указать PureComponent
вместо Component
:
class Container extends React.PureComponent {
Всё, теперь в вашем компоненте есть встроенная проверка данных: если произошло обновление родителя, но сами данные для компонента не обновились – компонент решит не обновляться и ничего не пересчитает.
Используем проверку обновления в функциональных компонентах
React.memo
– хук, который встраивает в функциональный компонент проверку внешних данных (props
).
И снова никакой боли, просто оборачиваете export
компонента – и всё работает!
export default React.memo(Container);
Однако, как вы видите, явно метод остаётся недоступен. Казалось бы, нет возможности гибко настроить, когда обновлять, а когда нет. Однако, это не так.
Остаётся только один маленький нюанс: это работает только для примитивных данных (строки и числа). Объекты, массивы и функции эта оптимизация не проверяет и считает, что такие данные новые при каждом обновлении.
Поэтому, если вы не хотите в этом глубоко разбираться, просто используйте методы в случаях:
- если передаёте примитивные данные;
- если не передаёте никаких данных;
- если этот компонент не имеет дочерних или во всех них используется хук (так как
React
рекомендует использоватьmemo
хук только для всей ветки компонентов).
Однако, это не единственное решение проблемы. Если вы всё же хотите проверять массивы и объекты, то есть такие решения:
Про проблему переопределения функции при каждом обновлении будет ниже.
Импорт компонентов (разбивка кода)
Для работы с дочерними компонентами, нам нужно импортировать их в родительский. Иногда эти компоненты нужны не сразу, иногда они просто могут быть отодвинуты на второй план, пока не загружены компоненты первого плана – зачем пользователю скачивать цельную сборку со всеми вложенными компонентами? Пусть скачает только необходимый минимум, а потом, по частям, остальное.
Об этом тоже подумали ребята из React
и создали поддержку обоих случаев.
Динамический импорт
Как и написано выше, зачем пользователю компонент, который сейчас не будет использоваться? Пусть он импортируется потом.
Для этого вместо привычного import { some_func } from ‘./func’
используйте в нужном месте import(‘./func’).then(some_func => …)
.
«Ленивая» загрузка компонента
Все компоненты важны, но не все одинаково. Поэтому загрузка каких-то может быть отложена на «потом» (как, например, мобильное меню, выезжающее из-за границы окна по нажатию кнопки). Это не значит, что компонент не будет загружаться – он загрузится, но во вторую очередь, когда загружены компоненты первой важности.
Для этого просто используйте вместо import Hello from ‘./Hello’
синтаксис const Hello = React.lazy(() => import(‘./Hello’))
.
Теперь этот компонент автоматически будет загружаться позже. У React.lazy
есть и другие настройки, настоятельно советуем почитать документацию.
Оптимизация работы внутри компонентов
Оптимизируем вычисления
Писать по каждому значению состояние компонента – это делать дополнительные его обновления с перерисовкой дочерних компонентов. Большие затраты. Поэтому некоторые значения можно сохранять «по-старинке» - в переменные. Достаточно внутри компонента (для функционального компонента) или функции render()
(для компонента-класса) объявить let some_var
.
Например, нам нужно обработать свойство компонента (приходящее от родителя):
import React from 'react';
const Counter = ({
products_counter = 0,
another_prop = null,
}) => {
const [calculated_counter, update_calculated_counter] = React.useState(0);
React.useEffect(() => {
let new_counter;
// some processing prop `products_counter` into `new_counter` variable
update_calculated_counter(new_counter);
}, [products_counter])
return(
<div>
...
</div>
)
}
export default Counter;
Здесь вычисления можно перенести в переменную.
const Counter = ({
products_counter = 0,
another_prop = null,
}) => {
let new_counter;
// some processing prop `products_counter` into `new_counter` variable
return(
<div>
...
</div>
)
}
export default Counter;
Однако, в таком случае при каждом обновлении мы делаем два действия: объявляем переменную new_counter
и, если далее для неё идёт вычисление, вычисляем значение. Этого можно избежать. Чтобы избавиться от переопределения/объявления переменной, вынесите её из компонента.
import React from 'react';
let new_counter;
const Counter = ({
products_counter = 0,
another_prop = null,
}) => {
// some processing prop `products_counter` into `new_counter` variable
return(
<div>
...
</div>
)
}
export default Counter;
Теперь у нас объявления переменной при каждом обновлении не будет происходить. Однако, остаётся ещё вычисление. Вдруг значение products_counter
не будет меняться, а только another_prop
- вычисления для значения new_counter
всё равно будут происходить, так как произошло обновление компонента.
Тут нам приходит на помощь хук useMemo
. Используя его, мы можем сохранять значение вычисления в кэш React
, чтобы не проводить вычисления ещё раз (если не меняются переменные для высчитывания).
import React from 'react';
let new_counter;
const Counter = ({
products_counter = 0,
}) => {
new_counter = React.useMemo(() => {
// some processing prop `products_counter` into `new_counter` variable
}, [products_counter]);
return(
<div>
...
</div>
)
}
export default Counter;
Оптимизируем обновление дочернего компонента
Если родительский компонент не класс, то в нём будет переопределяться функция при каждом обновлении. Это также вызовет обновление во всех дочерних компонентах, в которые передаётся эта функция.
const Container = (props) => {
const some_action = () => {
// ...
}
return(
<div>
<Hello action={some_action} />
</div>
)
}
export default React.memo(Container);
Но этого можно избежать. React.useCallback
позволяет сохранить значение функции и переопределения переменной не происходит.
const Container = () => {
const some_action = React.useCallback(() => {
// ...
});
return(
<div>
<Hello action={some_action} />
</div>
)
}
export default React.memo(Container);
Также вторым аргументом React.useCallback
принимает массив значений, по которым можно добавить зависимость и позволить переопределить функцию в узких случаях. Подробнее здесь.
Оптимизируем вычисления на построение DOM
сетки
Если вам просто нужно объединить содержимое, лучше не использовать лишний div
(или другую метку для обозначения контейнера), а обернуть всё в React.Fragment
. В этом случае мы не создаём компонент дерева, на который тратятся вычисления при обновлении.
Полезные ссылки:
- https://webformyself.com/5-metodov-optimizacii-proizvoditelnosti-react/
- https://reactjs.org/docs/optimizing-performance.html
- https://reactjs.org/docs/code-splitting.html
- https://reactjs.org/docs/hooks-reference.html
- https://reactjs.org/docs/react-api.html#reactpurecomponent
- https://reactjs.org/docs/react-api.html#reactmemo
- https://aglowiditsolutions.com/blog/react-performance-optimization/