Como o navegador monta a árvore DOM a partir do seu HTML
Do texto do arquivo até a árvore de nós: tokenização, pilha de elementos abertos e as correções que o parser faz no seu markup sem avisar.
O navegador transforma o texto do HTML em uma estrutura de objetos chamada DOM, ou Document Object Model. Para chegar lá, ele decodifica os bytes, reconhece tokens como tags e texto, e usa regras de construção para ligar os nós numa árvore. É essa árvore — não o arquivo bruto — que o CSS estiliza e o JavaScript consulta.
Pense no HTML como a planta entregue a uma equipe de obra. Os tokens são as instruções identificadas na planta; a pilha de elementos abertos acompanha qual cômodo está sendo montado; o DOM é o prédio resultante. Se a planta tem uma instrução fora do lugar, a equipe aplica regras para continuar a construção. No navegador acontece o mesmo: o parser fecha elementos, insere nós implícitos e reposiciona conteúdo conforme a especificação, sem editar o seu arquivo.
Quando arquivo e árvore discordam, o comportamento da página segue a árvore. Este artigo mostra cada etapa com o catálogo da Livraria Colibri e com a saída real do parser.
Tudo aqui foi executado no Node 24.16.0, com o jsdom 30.0.1 e o parse5 8.0.1
— o mesmo parser que o jsdom usa por dentro, e que implementa o algoritmo da
especificação que os navegadores seguem.
As quatro etapas entre os bytes e a árvore
Entre o arquivo salvo no disco e o document que você manipula existem quatro
transformações, nesta ordem:
| etapa | entra | sai |
|---|---|---|
| decodificação | bytes | caracteres |
| tokenização | caracteres | tokens (tag de abertura, texto, tag de fechamento…) |
| construção da árvore | tokens | nós, empilhados e ligados |
| o DOM pronto | nós | document, com API de consulta e de alteração |
A primeira etapa é a que ninguém lembra que existe, até quebrar. O arquivo é uma
sequência de bytes, e byte não é letra. Quem decide qual byte vira qual letra é a
codificação declarada no charset:
const linha = '<h2>Título: Dom Casmurro</h2>';
const bytes = Buffer.from(linha, 'utf8');
console.log('caracteres:', linha.length);
console.log('bytes: ', bytes.length);
console.log('os 8 primeiros bytes:', [...bytes.subarray(0, 8)].join(' '));
console.log('lido como utf-8: ', bytes.toString('utf8'));
console.log('lido como latin1:', bytes.toString('latin1'));Vinte e nove caracteres, trinta bytes. A diferença é o í, que em UTF-8 ocupa
dois bytes (195 173). Lidos como latin1, esses dois bytes viram duas letras
separadas — e aparece o famoso TÃtulo. Nada disso é culpa do parser de HTML:
quando ele começa a trabalhar, o estrago já está feito. Por isso o charset é a
primeira coisa dentro do head, como mostra a
estrutura de uma página HTML.
Tokenizar: o parser lê caractere por caractere, não tag por tag
A segunda etapa é uma máquina de estados. O tokenizador não procura tags com expressão regular: ele avança um caractere por vez, e cada caractere pode mudar o estado em que ele está (dentro de texto, dentro do nome de uma tag, dentro do valor de um atributo…). Quando um estado termina, ele emite um token.
Dá para ver a fila de tokens diretamente, ligando um espião no tokenizador do parse5:
import { Tokenizer } from 'parse5';
const html = '<h2 class="destaque">Dom Casmurro</h2>';
const handler = {
onStartTag: (t) => console.log('tag de abertura ', t.tagName, JSON.stringify(t.attrs)),
onEndTag: (t) => console.log('tag de fechamento', t.tagName),
onCharacter: (t) => console.log('texto ', JSON.stringify(t.chars)),
onWhitespaceCharacter: (t) => console.log('espaço ', JSON.stringify(t.chars)),
onComment: () => {},
onDoctype: () => {},
onNullCharacter: () => {},
onEof: () => console.log('fim do arquivo'),
onParseError: () => {},
};
new Tokenizer({ sourceCodeLocationInfo: false }, handler).write(html, true);Repare em duas coisas que a saída entrega. O atributo não chegou como texto: ele
já vem separado em nome e valor, porque o tokenizador tinha um estado só para
ler isso. E Dom Casmurro não virou um token só — o espaço em branco é emitido
à parte, porque em vários pontos do algoritmo o espaço tem tratamento próprio.
O tokenizador também é quem decide onde um atributo começa e termina. Sem aspas, o critério é o espaço — e aí acontece o acidente mais silencioso do HTML. Trocando a entrada do mesmo espião e imprimindo um atributo por linha:
const html = '<IMG SRC=capa-dom-casmurro.jpg ALT=Capa do livro>';
const handler = {
onStartTag: (t) => {
console.log('tag:', t.tagName);
for (const a of t.attrs) console.log(' atributo:', JSON.stringify(a.name), '=', JSON.stringify(a.value));
},
onEndTag: () => {},
onCharacter: () => {},
onWhitespaceCharacter: () => {},
onComment: () => {},
onDoctype: () => {},
onNullCharacter: () => {},
onEof: () => {},
onParseError: () => {},
};
new Tokenizer({ sourceCodeLocationInfo: false }, handler).write(html, true);Você escreveu um alt com três palavras. O parser entendeu um alt de uma
palavra e mais dois atributos vazios chamados do e livro. Nenhum erro, nenhum
aviso: a imagem simplesmente fica com o texto alternativo errado. Aspas em valor
de atributo não são preciosismo — são o que delimita o valor.
A pilha de elementos abertos, token a token
A terceira etapa é a construção da árvore, e o coração dela é uma estrutura única: a pilha de elementos abertos. A regra é curta:
- token de abertura → cria o nó, pendura no elemento que está no topo da pilha e empilha o novo nó;
- token de texto → vira nó de texto dentro do elemento do topo;
- token de fechamento → desempilha até tirar o elemento correspondente.
O topo da pilha é sempre o pai do próximo nó. É só isso — e é daí que sai todo o resto do comportamento.
Esse desenho não é uma metáfora: é o que o parser realmente faz. Dá para
imprimir a pilha depois de cada token, estendendo a classe Parser do parse5 e
numerando cada elemento criado, para saber quando um nó foi trocado por outro.
Este é o arquivo pilha.mjs, que aceita o HTML como argumento da linha de
comando e cai no catálogo da livraria quando você não passa nada:
import { Parser, defaultTreeAdapter } from 'parse5';
const html = process.argv[2] ?? '<ul class=catalogo><li>Dom Casmurro<li>Vidas Secas</ul>';
let contador = 0;
const adaptador = {
...defaultTreeAdapter,
createElement(tagName, ns, attrs) {
const el = defaultTreeAdapter.createElement(tagName, ns, attrs);
el.numero = ++contador;
return el;
},
};
class ParserEspiao extends Parser {
_mostrar(rotulo) {
const pilha = this.openElements.items
.slice(0, this.openElements.stackTop + 1)
.map((el) => el.tagName + '#' + el.numero)
.join(' > ');
console.log(rotulo.padEnd(24), pilha || '(vazia)');
}
onStartTag(token) {
super.onStartTag(token);
this._mostrar('<' + token.tagName + '>');
}
onEndTag(token) {
super.onEndTag(token);
this._mostrar('</' + token.tagName + '>');
}
onCharacter(token) {
super.onCharacter(token);
this._mostrar('texto "' + token.chars + '"');
}
}
console.log('token'.padEnd(24), 'pilha de elementos abertos');
ParserEspiao.parse(html, { treeAdapter: adaptador });Três leituras dessa saída, e todas importam.
O arquivo começa em <ul>, mas a pilha já aparece com html#1 e body#3
dentro dela: um único token de abertura obrigou o parser a criar o html, o
head (que é o #2 que não aparece, criado e desempilhado antes do ul) e o
body. A numeração denuncia o elemento invisível.
O segundo <li> não empilhou por cima do primeiro: a pilha continua com quatro
níveis, mas o topo mudou de li#5 para li#6. Ou seja, o parser desempilhou
o li anterior antes de empilhar o novo. Foi assim que a sua lista sem
</li> saiu certa.
E o </ul> tirou dois elementos de uma vez. O algoritmo manda desempilhar até
remover o ul, e o li#6 estava no caminho.
Fechamento automático: as tags que a pilha encerra por você
A regra “desempilhe até tirar” é o que faz o HTML perdoar tag esquecida. E é também o que faz ele te trair, porque alguns elementos são fechados por elementos de abertura, não só por tags de fechamento.
O caso clássico: o p não pode conter um bloco. Quando um token <div> chega
com um p aberto na pilha, o algoritmo fecha o p primeiro. Esta linha está no
rodapé da Livraria Colibri:
<p class="aviso">Frete grátis <div class="selo">acima de R$ 99</div> só hoje</p>Rodando o espião da seção anterior nessa linha:
node pilha.mjs '<p class=aviso>Frete grátis <div class=selo>acima de R$ 99</div> só hoje</p>'Olhe a linha do <div>: o p#4 sumiu da pilha antes de o div entrar. A
partir dali, a div é irmã do parágrafo, não filha. E o </p> do fim chegou com
a pilha sem nenhum p aberto.
Para ver o resultado, um pequeno desenhador de árvore salvo como arvore.mjs,
que vai reaparecer no resto do artigo:
import { JSDOM } from 'jsdom';
export function desenhar(no, prefixo = '') {
const filhos = [...no.childNodes].filter(
(f) => f.nodeType === 1 || (f.nodeType === 3 && f.textContent.trim() !== ''),
);
filhos.forEach((filho, i) => {
const ultimo = i === filhos.length - 1;
const galho = ultimo ? '└── ' : '├── ';
if (filho.nodeType === 1) {
const classe = filho.className ? '.' + filho.className.split(' ').join('.') : '';
console.log(prefixo + galho + filho.tagName.toLowerCase() + classe);
desenhar(filho, prefixo + (ultimo ? ' ' : '│ '));
} else {
console.log(prefixo + galho + '"' + filho.textContent.trim() + '"');
}
});
}
export function arvoreDe(html) {
const { document } = new JSDOM(html).window;
console.log('html');
desenhar(document.documentElement);
}Chamando arvoreDe com aquela mesma linha do rodapé, a árvore que sai é esta:
Você escreveu um parágrafo com uma div dentro. A árvore tem quatro coisas: um
parágrafo com duas palavras, uma div solta, um texto órfão sem elemento nenhum
e um parágrafo vazio no fim — o </p> sem par virou um p novo. Qualquer regra
de CSS escrita para .aviso vai pegar só Frete grátis.
Foster parenting: o conteúdo que a tabela expulsa
Dentro de uma tabela o algoritmo muda de modo, e passa a aceitar só um conjunto
fechado de tags: tr, td, th, tbody, caption e poucas outras. O que não
está na lista não é descartado nem aceito no lugar onde foi escrito — ele é
adotado para fora, inserido logo antes da tabela. A especificação chama isso
de foster parenting.
O detalhe que quase ninguém sabe é que o mesmo <div>, no mesmo lugar do
arquivo, dá dois resultados diferentes conforme a célula anterior esteja fechada
ou não:
import { JSDOM } from 'jsdom';
import { desenhar } from './arvore.mjs';
const casos = {
'célula fechada com </td>': '<table><tr><td>Dom Casmurro</td></tr><div class=aviso>Atualizado hoje</div></table>',
'célula ainda aberta': '<table><tr><td>Dom Casmurro<div class=aviso>Atualizado hoje</div></table>',
};
for (const [nome, html] of Object.entries(casos)) {
const { document } = new JSDOM(html).window;
console.log(nome + ':');
desenhar(document.body, ' ');
console.log(' pai da .aviso: ' + document.querySelector('.aviso').parentElement.tagName);
console.log('---');
}No primeiro caso a div foi parar antes da tabela, como irmã dela — na tela,
o aviso aparece acima do estoque, e não embaixo. No segundo, como o td ainda
estava na pilha, o parser estava no modo “dentro de célula”, onde conteúdo comum
é permitido: a div ficou dentro da célula, exatamente onde foi escrita.
Duas árvores diferentes para dois arquivos que diferem em um </td>. Repare
também no tbody das duas saídas: ninguém escreveu essa tag, e ela existe nas
duas. O parser insere um tbody sempre que um tr aparece solto dentro de
table, e é por isso que os detalhes de
tabelas em HTML merecem um capítulo próprio.
HTML não tem erro de sintaxe — tem recuperação
Aqui está a diferença mais importante entre HTML e as linguagens que você já
programou. Em JavaScript, um parêntese sobrando é SyntaxError e o programa não
roda. Em HTML não existe “não roda”: existe um algoritmo de recuperação que
sempre produz alguma árvore.
Isso não quer dizer que o parser não perceba nada. A especificação dá nome a algumas dezenas de parse errors, quase todos da etapa de tokenização, e o parse5 reporta essa lista inteira — mais alguns códigos próprios para problemas de estrutura, como a falta do doctype. Rodando isso sobre a página quebrada da Livraria Colibri:
import { readFileSync } from 'node:fs';
import { parse } from 'parse5';
const html = readFileSync(new URL('./pagina.html', import.meta.url), 'utf8');
const erros = [];
parse(html, {
sourceCodeLocationInfo: true,
onParseError: (e) => erros.push(e),
});
console.log('erros que a especificação manda registrar:', erros.length);
for (const e of erros) {
console.log(` linha ${e.startLine}, coluna ${e.startCol}: ${e.code}`);
}Uma página inteira com li sem fechar, p sem fechar, div dentro de tabela,
tag em caixa alta e atributo sem aspas produz um erro registrado: a falta do
doctype. Todo o resto é comportamento previsto, não defeito.
Vale conferir caso a caso o que conta e o que não conta como erro para o parser:
import { parse } from 'parse5';
const casos = {
'li sem fechar': '<!DOCTYPE html><ul><li>Dom Casmurro<li>Vidas Secas</ul>',
'div dentro de p': '<!DOCTYPE html><p>Frete <div>grátis</div></p>',
'tag que nunca foi aberta': '<!DOCTYPE html><p>Dom Casmurro</section>',
'< solto no texto': '<!DOCTYPE html><p>Estoque < 3 unidades</p>',
'aspas dentro do atributo': '<!DOCTYPE html><a href=/livro/"1">Dom Casmurro</a>',
};
for (const [nome, html] of Object.entries(casos)) {
const erros = [];
parse(html, { sourceCodeLocationInfo: true, onParseError: (e) => erros.push(e) });
console.log(nome + ': ' + (erros.length === 0 ? 'nenhum erro registrado' : erros.map((e) => e.code).join(', ')));
}Os três primeiros — justamente os que mais estragam a árvore — passam limpos, e
por dois motivos diferentes. Omitir o </li> não é erro nem para a
especificação: ela manda o parser desempilhar o li anterior e seguir em frente,
sem registrar nada. Já o </p> e o </section> sem par a especificação chama,
sim, de parse error — em letras miúdas, dentro do algoritmo de construção da árvore
(“this is a parse error; insert an HTML element for a p start tag token”) — só
que esse erro não ganhou nome, e o que não tem nome nenhuma ferramenta reporta.
Os dois casos que devolvem um código são de tokenização, e nem esses aparecem no
console do navegador.
A árvore não espera o arquivo acabar
O parser é incremental. Ele trabalha sobre os bytes que já chegaram pela rede, e o DOM vai existindo aos pedaços — é por isso que uma página grande já começa a aparecer antes de terminar de baixar.
Dá para simular a rede escrevendo no tokenizador em dois pedaços e serializando o documento no meio do caminho:
import { Parser, serialize } from 'parse5';
const parser = new Parser({});
const pedaco1 = '<ul class=catalogo><li>Dom Casmurro';
const pedaco2 = '<li>Vidas Secas</ul>';
parser.tokenizer.write(pedaco1, false);
console.log('depois do 1º pedaço:', serialize(parser.document));
parser.tokenizer.write(pedaco2, true);
console.log('depois do 2º pedaço:', serialize(parser.document));Com metade do arquivo, a árvore já tem html, head, body, ul e um li. E
tem um detalhe fino: o texto parou em Dom , sem o Casmurro. O tokenizador
guardou a palavra incompleta no buffer, porque não tem como saber se o próximo
pedaço traz mais letras. Os </li> e </ul> que aparecem na saída são invenção
do serializador, que precisa fechar o que ainda está aberto na pilha para
imprimir.
É esse mesmo mecanismo que explica por que um <script> clássico no meio do
body custa caro: ele para a construção da árvore até o script baixar e
executar. A saída é declarar o carregamento como
script com defer ou async.
O que isso muda no seu dia: seletor que não acha nada
Toda essa mecânica desemboca em dois sintomas muito comuns: “meu querySelector
retorna null” e “meu CSS não pega”. Nos dois casos, o seletor está descrevendo
o arquivo, e não a árvore.
Esta é a página da Livraria Colibri como sai da mão de quem está começando:
<html>
<title>Livraria Colibri</title>
<h1>Livraria Colibri</h1>
<P>Sebo de livros usados na Vila Madalena.
<UL CLASS=catalogo>
<li>Dom Casmurro — R$ 24,00
<li>Vidas Secas — R$ 19,00
</ul>
<table class=estoque>
<tr><th>Título<th>Em estoque
<tr><td>Dom Casmurro<td>3
<div class=aviso>Atualizado hoje</div>
</table>
</html>Contando quantos elementos cada seletor encontra nessa página:
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
const html = readFileSync(new URL('./pagina.html', import.meta.url), 'utf8');
const { document } = new JSDOM(html).window;
const testar = (seletor) =>
console.log(seletor.padEnd(28), document.querySelectorAll(seletor).length + ' elemento(s)');
testar('table > tr');
testar('table tr');
testar('table > tbody > tr');
testar('.estoque .aviso');
testar('.estoque > .aviso');
testar('body > .aviso');
testar('td .aviso');table > tr encontra zero elementos numa página que tem duas linhas de tabela —
o tbody fantasma está no meio. E a .aviso, que no arquivo parece filha da
table, é filha do segundo td — três níveis mais fundo do que você escreveu,
o que explica o td .aviso achar e o .estoque > .aviso não achar nada. Nenhum
desses seletores está errado como CSS: eles
estão descrevendo uma árvore que não existe. Vale o mesmo para
querySelector e getElementById.
O segundo sintoma vem da normalização de caixa. Nome de tag e de atributo viram minúsculas; valor de atributo, não:
import { JSDOM } from 'jsdom';
const html = '<ARTICLE CLASS="Livro" DATA-ISBN="9788535910663"><H2>Dom Casmurro</H2></ARTICLE>';
const { document } = new JSDOM(html).window;
const artigo = document.querySelector('article');
console.log('tagName: ', artigo.tagName);
console.log('nome dos atributos: ', artigo.getAttributeNames());
console.log('valor de data-isbn: ', artigo.dataset.isbn);
console.log('class no DOM: ', JSON.stringify(artigo.className));
console.log('acha com .Livro? ', document.querySelector('.Livro') !== null);
console.log('acha com .livro? ', document.querySelector('.livro') !== null);
console.log('outerHTML: ', artigo.outerHTML);O tagName volta em maiúsculas (herança dos tempos do HTML 4), mas o
outerHTML sai todo em minúsculas, e o seletor article funciona
independentemente de como você escreveu. Já .livro não acha nada: o valor de
class continua sendo Livro, com L maiúsculo. Classe com maiúscula não é erro,
é só uma armadilha a mais para você mesmo.
Ler a árvore real: DevTools, outerHTML e jsdom
O primeiro reflexo quando algo não bate deve ser olhar a árvore, não o arquivo. Três formas, da mais rápida para a mais precisa.
No navegador, a aba Elements do DevTools mostra o DOM, não o código-fonte.
Ctrl+U (View Source) mostra o texto que veio do servidor — os dois são
diferentes de propósito. Para levar a árvore para fora do navegador, o console
resolve:
copy(document.documentElement.outerHTML);No terminal, o jsdom monta a mesma árvore de um arquivo local. Comparando o arquivo da Livraria Colibri com o DOM que ele produz:
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
const arquivo = readFileSync(new URL('./pagina.html', import.meta.url), 'utf8');
const dom = new JSDOM(arquivo);
const { document } = dom.window;
const noArquivo = new Set([...arquivo.matchAll(/<([a-zA-Z][a-zA-Z0-9]*)/g)].map((m) => m[1].toLowerCase()));
const noDom = new Set([...document.querySelectorAll('*')].map((e) => e.tagName.toLowerCase()));
console.log('caracteres no arquivo: ', arquivo.length);
console.log('caracteres no DOM: ', dom.serialize().length);
console.log('tags escritas por você:', [...noArquivo].join(', '));
console.log('tags que o DOM tem: ', [...noDom].join(', '));
console.log('tags que ninguém escreveu:', [...noDom].filter((t) => !noArquivo.has(t)).join(', '));Noventa caracteres a mais e três elementos que não estão no arquivo. E, para
fechar, a árvore inteira daquela página, com um
arvoreDe(readFileSync('pagina.html', 'utf8')):
O title foi movido para um head que não existe no arquivo. Os p e li
foram fechados. O tbody apareceu. E o aviso, que você escreveu como filho da
tabela, é filho da segunda célula da segunda linha — vai herdar o alinhamento e a
borda daquele td, e você vai procurar o erro no CSS.
O próximo passo
Sabendo que o navegador monta uma árvore e nunca reclama, a conclusão prática é
uma só: escreva o HTML de um jeito que a árvore seja igual ao arquivo. Feche toda
tag que abrir, use aspas em todo atributo, e nunca ponha um bloco dentro de p
ou fora de uma célula de tabela.
Se você chegou aqui pelo caminho do JavaScript, o passo seguinte é aprender a mexer nessa árvore depois de montada: é o assunto de o que é o DOM em JavaScript. Se o seu caminho é o HTML, comece pelo começo com o que é HTML e siga a ordem de estudo do guia completo de HTML.
Faça uma inspeção curta: escreva um <table> sem <tbody> e um <p> contendo
uma <div>. Abra a aba Elements e compare a árvore com o código-fonte. O teste
está completo quando você localiza o tbody inserido e explica onde o parser
colocou a div. Depois corrija o HTML até fonte e DOM terem a mesma hierarquia.
Perguntas frequentes
Todos os navegadores montam a mesma árvore para o mesmo HTML?
É obrigatório fechar as tags li e p?
O parser para quando encontra uma tag script?
Dá para confiar na árvore que o jsdom monta?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em Node 24.16.0 (jsdom 30.0.1, parse5 8.0.1), e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- WHATWG HTML Standard — Parsing HTML documents — html.spec.whatwg.org
- MDN — Introdução ao DOM — developer.mozilla.org
- parse5 — HTML parser conforme a especificação — parse5.js.org


