DOM: поиск и изменение элементов без лишних перерисовок
Как искать узлы в DOM правильными методами и менять их так, чтобы браузер не пересчитывал раскладку на каждой строке цикла. Разбор приёмов и их цены.

Скрипт добавляет 500 строк в таблицу через appendChild в цикле — и вкладка подвисает на секунду. Проблема не в JavaScript, а в том, что браузер пересчитывает раскладку (layout) после каждой вставки. Тот же результат через DocumentFragment отрабатывает за один reflow. Ниже — как искать элементы в DOM без лишних обходов и как менять их так, чтобы не платить перерисовкой за каждое действие.
Материал для тех, кто уже пишет на ванильном JavaScript и упёрся в тормоза или в неожиданное поведение при работе с узлами. Актуально для современных браузеров (Chrome, Firefox, Safari 2023 года и новее), где доступны все перечисленные методы.
Поиск: чем querySelector отличается от getElementById
Способов найти узел несколько, и они не взаимозаменяемы. Разница — в возвращаемом типе, в живости коллекции и в скорости.
getElementById('id')— самый быстрый поиск по одному идентификатору. Возвращает элемент илиnull.getElementsByClassName('cls')иgetElementsByTagName('div')— возвращают живую HTMLCollection. Она обновляется автоматически, если DOM изменился.querySelector('.cls > span')— первый элемент по CSS-селектору. Гибко, но медленнее прямого поиска по id.querySelectorAll('.cls')— статический NodeList. Снимок на момент вызова, изменения DOM его не трогают.
Живость коллекции — не мелочь. Цикл по живой HTMLCollection с одновременным удалением элементов из неё — классический источник пропущенных узлов: коллекция сжимается прямо под итератором.
// Опасно: коллекция живая, при удалении индексы съезжают
const items = document.getElementsByClassName('row');
for (let i = 0; i < items.length; i++) {
items[i].remove(); // часть строк останется
}
// Безопасно: статический снимок
const rows = document.querySelectorAll('.row');
rows.forEach((row) => row.remove());NodeList из querySelectorAll поддерживает forEach, но не остальные методы массива. Чтобы применить map или filter, оберните в массив: Array.from(document.querySelectorAll('.row')).
Ищите внутри узла, а не по всему документу
Все методы поиска работают не только от document, но и от любого элемента. Если вы уже нашли контейнер, ищите внутри него — это ограничивает область обхода:
const table = document.getElementById('report');
// поиск только внутри table, а не по всему документу
const cells = table.querySelectorAll('td.numeric');Правило простое: чем уже область поиска и чем точнее селектор, тем меньше узлов браузер обойдёт. По id — всегда дешевле, чем по сложному селектору.
Изменение: где браузер платит перерисовкой
Браузер выполняет две дорогие операции: reflow (пересчёт геометрии и раскладки) и repaint (перерисовку пикселей). Reflow дороже. Он срабатывает при вставке и удалении узлов, изменении размеров, а также при чтении свойств, которые зависят от актуальной раскладки — offsetHeight, offsetWidth, getBoundingClientRect().
Самая частая ошибка — чередование записи и чтения в цикле. Браузер вынужден сбрасывать очередь изменений и пересчитывать раскладку синхронно на каждой итерации. Это называется layout thrashing.
// Плохо: чтение offsetWidth заставляет reflow на каждой итерации
const boxes = document.querySelectorAll('.box');
boxes.forEach((box) => {
box.style.width = box.offsetWidth + 10 + 'px';
});
// Лучше: сначала все чтения, потом все записи
const boxes2 = document.querySelectorAll('.box');
const widths = Array.from(boxes2).map((box) => box.offsetWidth);
boxes2.forEach((box, i) => {
box.style.width = widths[i] + 10 + 'px';
});Массовая вставка: DocumentFragment
DocumentFragment — контейнер, который живёт в памяти и не является частью документа. Вы наполняете его узлами без единого reflow, а затем вставляете разом. Reflow происходит один раз.
// Вставка 500 строк за один reflow
const fragment = document.createDocumentFragment();
for (let i = 0; i < 500; i++) {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.textContent = 'Строка ' + i;
row.appendChild(cell);
fragment.appendChild(row);
}
// один reflow вместо пятисот
document.getElementById('report').appendChild(fragment);innerHTML против createElement
innerHTML — короткий способ собрать разметку строкой. Но у него две цены. Первая: браузер парсит строку как HTML, и любая подстановка пользовательских данных без экранирования открывает XSS. Вторая: присваивание innerHTML уничтожает все дочерние узлы и создаёт их заново — навешанные обработчики событий на старых узлах теряются.
// Опасно, если userName пришёл от пользователя
container.innerHTML = '<span>' + userName + '</span>';
// Безопасно: textContent не парсит HTML
const span = document.createElement('span');
span.textContent = userName;
container.appendChild(span);Для вставки текста всегда предпочитайте textContent — он не интерпретирует теги и работает быстрее, чем innerHTML, потому что не запускает HTML-парсер.
Сравнение способов вставки
| Способ | Reflow | Сохраняет обработчики | Риск XSS | Когда применять |
|---|---|---|---|---|
appendChild в цикле | на каждый узел | да | нет | один-два узла |
DocumentFragment | один на вставку | да | нет | массовая вставка узлов |
innerHTML | один | нет, пересоздаёт | да, при подстановке данных | статичная разметка без данных пользователя |
insertAdjacentHTML | один | да, соседние не трогает | да | добавить разметку рядом, не разрушая контейнер |
Смена классов и стилей
Менять оформление лучше через классы, а не через style по одному свойству. Одна смена класса — один набор изменений, который браузер применяет пакетно. Для работы с классами есть classList:
el.classList.add('active');
el.classList.remove('hidden');
el.classList.toggle('open');
el.classList.contains('active'); // true/falseЕсли нужно изменить сразу несколько свойств стиля, соберите их в класс в CSS и переключайте класс, а не присваивайте style.color, style.padding и style.border по очереди.
Когда что выбирать
- Один узел по идентификатору —
getElementById. Не усложняйте селектором. - Нужен снимок для перебора с удалением —
querySelectorAll(статический NodeList). - Нужна коллекция, которая сама отслеживает добавление узлов, —
getElementsByClassName(живая, но осторожно с удалением в цикле). - Много узлов за раз —
DocumentFragment. - Разметка с пользовательскими данными — только
createElement+textContent, никогда неinnerHTMLсо склейкой строк.
Что мерить и что настроить дальше
Тормоза при работе с DOM видны в панели Performance браузера: длинные фиолетовые полосы Layout и Recalculate Style в записи профиля — это и есть reflow. Запишите профиль до и после переноса вставки на DocumentFragment и сравните суммарное время Layout.
Следующий шаг — посмотреть в сторону группировки чтений и записей через requestAnimationFrame: браузер сам сгруппирует изменения к следующему кадру. Отдельная тема — делегирование событий: вместо навешивания обработчика на каждый из 500 узлов вешают один на контейнер. Здесь мы её не разворачиваем.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: MDN — Document Object Model (DOM), MDN — DocumentFragment
Частые вопросы
Чем HTMLCollection отличается от NodeList на практике?
querySelectorAll всегда медленнее getElementById?
А если у меня старый браузер и forEach у NodeList не работает?
innerHTML безопасен, если данные не от пользователя?
Как понять, что тормозит именно из-за reflow, а не из-за самого JS?
Нужно ли вручную удалять узлы из DocumentFragment после вставки?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.