Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
ConceitoIntermediáriocódigo testado

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.

Rodolfo Mori15 min de leitura

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:

js
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'));
caracteres: 29 bytes: 30 os 8 primeiros bytes: 60 104 50 62 84 195 173 116 lido como utf-8: <h2>Título: Dom Casmurro</h2> lido como latin1: <h2>Título: Dom Casmurro</h2>

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:

js
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);
tag de abertura h2 [{"name":"class","value":"destaque"}] texto "Dom" espaço " " texto "Casmurro" tag de fechamento h2 fim do arquivo

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:

js
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);
tag: img atributo: "src" = "capa-dom-casmurro.jpg" atributo: "alt" = "Capa" atributo: "do" = "" atributo: "livro" = ""

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.

token <ul> token <li> 2º token <li> token </ul> li li (novo) ul ul ul body body body body html html html html html, head e body nasceram sozinhos o li entra no topo e recebe o texto o li anterior sai, o novo entra no lugar ul e li saem juntos: a lista está fechada

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:

js
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 });
token pilha de elementos abertos <ul> html#1 > body#3 > ul#4 <li> html#1 > body#3 > ul#4 > li#5 texto "Dom" html#1 > body#3 > ul#4 > li#5 texto "Casmurro" html#1 > body#3 > ul#4 > li#5 <li> html#1 > body#3 > ul#4 > li#6 texto "Vidas" html#1 > body#3 > ul#4 > li#6 texto "Secas" html#1 > body#3 > ul#4 > li#6 </ul> html#1 > body#3

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:

html
<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:

bash
node pilha.mjs '<p class=aviso>Frete grátis <div class=selo>acima de R$ 99</div> só hoje</p>'
token pilha de elementos abertos <p> html#1 > body#3 > p#4 texto "Frete" html#1 > body#3 > p#4 texto "grátis" html#1 > body#3 > p#4 <div> html#1 > body#3 > div#5 texto "acima" html#1 > body#3 > div#5 texto "de" html#1 > body#3 > div#5 texto "R$" html#1 > body#3 > div#5 texto "99" html#1 > body#3 > div#5 </div> html#1 > body#3 texto "só" html#1 > body#3 texto "hoje" html#1 > body#3 </p> html#1 > body#3

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:

js
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:

html ├── head └── body ├── p.aviso │ └── "Frete grátis" ├── div.selo │ └── "acima de R$ 99" ├── "só hoje" └── p

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:

js
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('---');
}
célula fechada com </td>: ├── div.aviso │ └── "Atualizado hoje" └── table └── tbody └── tr └── td └── "Dom Casmurro" pai da .aviso: BODY --- célula ainda aberta: └── table └── tbody └── tr └── td ├── "Dom Casmurro" └── div.aviso └── "Atualizado hoje" pai da .aviso: TD ---

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:

js
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}`);
}
erros que a especificação manda registrar: 1 linha 1, coluna 1: missing-doctype

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:

js
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(', ')));
}
li sem fechar: nenhum erro registrado div dentro de p: nenhum erro registrado tag que nunca foi aberta: nenhum erro registrado < solto no texto: invalid-first-character-of-tag-name aspas dentro do atributo: unexpected-character-in-unquoted-attribute-value, unexpected-character-in-unquoted-attribute-value

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:

js
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));
depois do 1º pedaço: <html><head></head><body><ul class="catalogo"><li>Dom </li></ul></body></html> depois do 2º pedaço: <html><head></head><body><ul class="catalogo"><li>Dom </li><li>Vidas Secas</li></ul></body></html>

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
<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:

js
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 0 elemento(s) table tr 2 elemento(s) table > tbody > tr 2 elemento(s) .estoque .aviso 1 elemento(s) .estoque > .aviso 0 elemento(s) body > .aviso 0 elemento(s) td .aviso 1 elemento(s)

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:

js
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);
tagName: ARTICLE nome dos atributos: [ 'class', 'data-isbn' ] valor de data-isbn: 9788535910663 class no DOM: "Livro" acha com .Livro? true acha com .livro? false outerHTML: <article class="Livro" data-isbn="9788535910663"><h2>Dom Casmurro</h2></article>

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:

js
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:

js
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(', '));
caracteres no arquivo: 332 caracteres no DOM: 422 tags escritas por você: html, title, h1, p, ul, li, table, tr, th, td, div tags que o DOM tem: html, head, title, body, h1, p, ul, li, table, tbody, tr, th, td, div tags que ninguém escreveu: head, body, tbody

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')):

html ├── head │ └── title │ └── "Livraria Colibri" └── body ├── h1 │ └── "Livraria Colibri" ├── p │ └── "Sebo de livros usados na Vila Madalena." ├── ul.catalogo │ ├── li │ │ └── "Dom Casmurro — R$ 24,00" │ └── li │ └── "Vidas Secas — R$ 19,00" └── table.estoque └── tbody ├── tr │ ├── th │ │ └── "Título" │ └── th │ └── "Em estoque" └── tr ├── td │ └── "Dom Casmurro" └── td ├── "3" └── div.aviso └── "Atualizado hoje"

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.

  • html
  • dom
  • parser
  • navegador
  • arvore

Perguntas frequentes

Todos os navegadores montam a mesma árvore para o mesmo HTML?
Sim. O algoritmo de parsing é descrito passo a passo na especificação do HTML, incluindo a recuperação de markup quebrado. Chrome, Firefox e Safari chegam à mesma árvore. O que varia entre eles é o que vem depois da árvore: renderização, APIs e desempenho.
É obrigatório fechar as tags li e p?
A especificação permite omitir esses fechamentos, e o parser resolve. Mas quem lê o seu arquivo depois não tem o algoritmo na cabeça, e um bloco escrito no lugar errado muda a árvore em silêncio. Feche sempre: o custo é zero e o benefício é a árvore ser igual ao arquivo.
O parser para quando encontra uma tag script?
Para. Um script clássico sem defer nem async interrompe a construção da árvore, baixa o arquivo, executa e só então a montagem continua. É por isso que script no meio do body atrasa a primeira pintura da página.
Dá para confiar na árvore que o jsdom monta?
Para HTML estático, dá. O jsdom usa o parse5, que implementa o mesmo algoritmo da especificação que os navegadores seguem. O que o jsdom não faz é layout e pintura: ele monta a árvore, não desenha a página.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

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

  1. WHATWG HTML Standard — Parsing HTML documents — html.spec.whatwg.org
  2. MDN — Introdução ao DOM — developer.mozilla.org
  3. parse5 — HTML parser conforme a especificação — parse5.js.org

Continue por aqui