DocumentFragment e batch mutations
TL;DR
Cada mutação ao DOM ao vivo pode disparar reflow (recalcular layout) — inserir 1000 elementos um a um é 1000 reflows.
DocumentFragmenté um container fora do DOM: você monta toda a subárvore nele e insere uma única vez, causando apenas um reflow. O mesmo princípio vale para qualquer estratégia de batch: acumular → inserir. Frameworks como React fazem isso automaticamente com Virtual DOM; no DOM puro, você faz manualmente.
O problema: mutações individuais causam reflows
Cada vez que você modifica o DOM ao vivo, o browser pode precisar recalcular layout (reflow) e repintar (repaint). O custo por operação é pequeno; em loop, se acumula:
const list = document.querySelector('ul');
// ❌ Inserção individual — pode causar N reflows
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
list.appendChild(li); // cada append pode disparar reflow
}O browser é inteligente o suficiente para batchar algumas dessas operações, mas não todas. Quando você lê propriedades de layout (como offsetHeight, clientWidth, getBoundingClientRect) entre escritas, você força o browser a fazer o reflow imediatamente (layout thrashing).
DocumentFragment — o container fora do DOM
DocumentFragment é um nó especial que existe em memória, mas não faz parte do DOM. Mutações nele não causam reflow. Quando você insere um fragment no DOM, todos os seus filhos são transferidos em uma única operação:
const list = document.querySelector('ul');
// ✅ Acumula em fragment fora do DOM
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
fragment.appendChild(li); // sem reflow — fragment fora do DOM
}
// Uma única inserção no DOM real — um reflow
list.appendChild(fragment);
// Após a inserção, o fragment está vazio (os filhos foram *movidos*, não copiados)replaceChildren() — substituir tudo de uma vez
Para re-renderizar uma lista inteira, replaceChildren() é mais limpo e eficiente que limpar com innerHTML = '' e depois appender:
function renderList(container, items) {
// Criar todos os elementos em memória
const newItems = items.map(item => {
const li = document.createElement('li');
li.textContent = item.label;
li.dataset.id = item.id;
return li;
});
// Substituir todos os filhos em uma operação
container.replaceChildren(...newItems);
}Equivalente a criar um fragment manualmente e inserir — mas mais idiomático.
innerHTML como forma de batch — cuidados
Setar innerHTML de uma só vez é outra forma de inserção em batch:
// ✅ Gera HTML como string e insere tudo de uma vez
const html = items.map(item =>
`<li data-id="${item.id}">${escapeHtml(item.label)}</li>`
).join('');
list.innerHTML = html; // um único parse e re-renderMas tem dois problemas:
- Perde event listeners — todos os filhos existentes são destruídos e recriados
- Risco de XSS se
item.labelvier do usuário sem sanitização
// Função de escape obrigatória ao usar innerHTML com dados externos
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}Prefira replaceChildren() ou DocumentFragment quando os elementos precisam ter event listeners ou quando o conteúdo é dado de usuário.
Layout thrashing — o inimigo silencioso
Layout thrashing acontece quando você alterna leitura e escrita de propriedades de layout no mesmo frame:
// ❌ Layout thrashing — força reflow em cada iteração
const boxes = document.querySelectorAll('.box');
boxes.forEach(box => {
const height = box.offsetHeight; // LEITURA — força reflow se houve escrita
box.style.height = (height + 10) + 'px'; // ESCRITA
// Na próxima iteração: LEITURA depois de ESCRITA → reflow forçado novamente
});
// ✅ Batch: todas as leituras primeiro, depois todas as escritas
const boxes = document.querySelectorAll('.box');
const heights = [...boxes].map(box => box.offsetHeight); // todas as LEITURAS
boxes.forEach((box, i) => {
box.style.height = (heights[i] + 10) + 'px'; // todas as ESCRITAS
});Propriedades que forçam reflow quando lidas (após uma escrita pendente):
offsetWidth,offsetHeight,offsetTop,offsetLeftclientWidth,clientHeight,clientTop,clientLeftscrollWidth,scrollHeight,scrollTop,scrollLeftgetBoundingClientRect()getComputedStyle()
requestAnimationFrame para sincronizar com o frame
Para animações ou mudanças visuais, execute as escritas no callback de requestAnimationFrame — assim você garante que a escrita acontece no início do próximo frame, não no meio de um:
function updateLayout(elements, newSizes) {
// Ler tudo fora do rAF (antes do próximo paint)
const reads = elements.map(el => el.getBoundingClientRect());
// Escrever dentro do rAF (garantindo execução antes do próximo paint)
requestAnimationFrame(() => {
elements.forEach((el, i) => {
el.style.width = newSizes[i] + 'px';
});
});
}Diagrama: fragment vs DOM direto
sequenceDiagram participant JS as JavaScript participant Frag as DocumentFragment (memória) participant DOM as DOM ao vivo participant Layout as Browser Layout Note over JS,Layout: ❌ Inserção direta — N reflows loop para cada item JS->>DOM: appendChild(item) DOM->>Layout: reflow() end Note over JS,Layout: ✅ DocumentFragment — 1 reflow loop para cada item JS->>Frag: appendChild(item) Note over Frag: sem reflow end JS->>DOM: appendChild(fragment) DOM->>Layout: reflow() — uma vez
Pattern completo: render de lista com fragment
function renderProductList(container, products) {
const fragment = document.createDocumentFragment();
products.forEach(product => {
// Criar estrutura do item
const article = document.createElement('article');
article.className = 'product-card';
article.dataset.productId = product.id;
const title = document.createElement('h3');
title.className = 'product-card__title';
title.textContent = product.name; // textContent — seguro para user data
const price = document.createElement('p');
price.className = 'product-card__price';
price.textContent = `R$ ${product.price.toFixed(2)}`;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn--add-cart';
btn.textContent = 'Adicionar ao carrinho';
btn.addEventListener('click', () => addToCart(product.id));
// Event listeners no fragment são preservados quando inserido no DOM
article.append(title, price, btn);
fragment.appendChild(article);
});
// Substituir o conteúdo anterior — um reflow
container.replaceChildren(fragment);
}Para fixar
- Por que inserir 1000 elementos um a um no DOM pode ser lento? O que o browser faz a cada inserção?
- Quando você insere um
DocumentFragmentno DOM, o que acontece com os filhos do fragment depois da inserção?- O que é layout thrashing? Escreva um exemplo que causa thrashing e corrija-o.
container.innerHTML = ''vscontainer.replaceChildren()— qual é mais eficiente e por quê?- Se você adiciona event listeners a elementos dentro de um
DocumentFragmentantes de inserir, os listeners são preservados?
Veja também
- 05 — Atributos e dataset — anterior
- 07 — template e cloneNode — próxima
- Rendering Pipeline 04 — Reflow e repaint — a teoria por trás deste padrão