Оптимизируем операции с DOM

Если я общаюсь с джуниор разработчками, всегда интересно узнать, зачем по их мнению мы используем фреймворки в повседневной разработке? Мне нравится когда люди не просто используют какой-то инструмент потому что "так надо", а еще и знают его плюсы и минусы. В первую очередь фреймворки используют потому что большие приложения написанные на ванильном JS получают по большей степени сильно не оптимизированными. DOM дерево - это большой обьект, и каждое взаимодействие с ним неплохо нагружает браузер, в связи с этим в фреймворках и появилась такая вещь как Virtual DOM Легковесная копия ДОМ дерева, созданная для того чтобы производить все манипуляции (read/write/update/delete) с ней, а в основной DOM вносить уже точечные изменения.

В этой статье я расскажу почему именно операции с DOM становятся тяжеловесными и как их оптимизировать.

Рассмотрим пример

Приложение на скрине ниже рендерит в div с id app 1000 div элементов с рандомным текст контентом. Сейчас особо никаких проблем нет

Приложение Результат

Единственное, в JS один поток, соответственно пока эта функция работает, интерфейс заблокирован, а мы ведь хотим работать с отзывчивым интерфейсом, соответственно чем быстрее она выполнится, тем лучше.

Понимаем проблематику

В браузере происходят такие стадии отрисовки контента как Reflow и Repaint. Они происходят на этапе первого рендеринга страницы и во время жизненного цикла. Repaint - довольно легковесная операция, которая вызывается например при изменении каких-то ксс свойств, она просто немного перекрашивает страницу и не забирает много производительности. В отличие от Repaint, Reflow забирает много производительности, Reflow вызывается:

  1. При изменении размера окна
  2. При считывании стилей элемента
  3. При удалении либо добавлении элементов в DOM дерево

Reflow каждый раз заново пересчитывает размеры окна и так же размеры и позицию каждого элемента, это сложное вычисление которое нагружает браузер

Приведу пример случая, когда Reflow вызывается лишний раз

Тут вызов Reflow будет один раз, производится так называемая WRITE операция - запись стилей элемента

div.style.color = 'blue';
div.style.marginTop = '30px';

Здесь вызов Reflow произойдет дважды, обьясняю почему Write операция записи стилей элемента DIV прервана READ операцией - считыванием стилей. До момента этой операции все чем занимался браузер - это Repaint, он просто менял стили и ему не нужен был лишний рефлоу, он сделает его когда завершит Write операции, полностью изменит стили Но юзер как бы форсирует Reflow потому что просит у браузера посчитать высоту элемента когда браузер еще не закончил менять стили, и ему приходится произвести пересчет размеров и позиций всех элементво чтобы дать юзеру актуальную информацию

div.style.color = 'blue';
const margin = div.getBoundingClientRect().height;
div.style.marginTop = (margin + 10) + 'px';

Один из способов оптимизации read/write оптимизаций с дом деревом - некий batching То есть вам рекомендуется производить сначала все write а потом все read операции, не прерывая их, чтобы не вызывать лишние Reflow

// Этот способ быстрее

const width = element.clientWidth + 10;
const width2 = element.clientWidth + 20;

element.style.width = width + 'px';
element.style.width = width2 + 'px';

// Чем этот

const width = element.clientWidth + 10;
element.style.width = width + 'px';
const width2 = element.clientWidth + 10;
element.style.width = width2 + 'px';

Другие способы оптимизации:

  1. Разделение батчингом
  2. Можно пробовать изменять все стили за один раз добавляя css class
  3. Используем document.fragment как временное решение, генерируем все что нам нужно туда, а потом один раз в body добавляем фрагмент (сниппет кода ниже)
  4. Как вариант можно задать элементу display: none чтобы он не влиял на layout страницы и каждое его изменение не вызывало reflow (кроме одного когда меняется дисплей), потом произвести с ним все js манипуляции и снова сетать display: block

Сниппет к 4 пункту, тут тоже мы каждый раз вызываем лишний Reflow когда добавляем элемент в DOM

// Неверно
for(let i = 0; i < 100; i++){
    const element = document.createElement('div');
    document.body.appendChild(element);
}

// Верно
const fragment = document.createDocumentFragment();
for(let i = 0; i < 100; i++){
    const element = document.createElement('div');
    fragment.appendChild(element);
}
document.body.appendChild(fragment);

Benchmark приложения

Представим что зачем-то у нас появилась необходимость собрать ширину каждого div, а она у нас разная тк я задал в styles.css свойство

div {
  width: min-content;
}

Теперь приложение будет выглядеть так, сразу же добавлю строчки для замера времени выполнения функции

Новое

Результаты перфоманса которые мы видим в консоли могут довести разработчика до обморочного состояния, на выполнение функции уходит 130-140мс, учитывая что без замера width на нее уходит +-3мс

Консоль

Решение

Выше я уже предлагал способы решения этой проблемы, я выберу тот - который я назвал "batching"

Сначала произведу все Write операции, а потом все Read, тогда не будет происходить так называемый forced reflow который сильно влияет на перфоманс

Решение

Делаем код вот таким и получаем перфоманс как на скрине ниже

Перфоманс

Спасибо всем за внимание, не забывайте следить за обращением к DOM дереву, не обращайтесь к нему лишний раз либо старайтесь оптимизировать эти действия, иначе интерфейс вашего приложения будет заблокирован для юзера надолго :)