innerHTML e textContent: alterar HTML com JavaScript
Como trocar o conteúdo de um elemento com textContent, innerHTML e insertAdjacentHTML — com o XSS reproduzido e a medição do custo de concatenar.
Com um elemento do DOM selecionado, você pode trocar seu conteúdo com uma
atribuição. elemento.textContent = 'Carrinho vazio' insere texto literal;
elemento.innerHTML = '<b>Total</b>' entrega uma string ao parser de HTML, que
interpreta as tags e cria novos nós. As linhas se parecem, mas pedem níveis de
confiança bem diferentes.
Imagine dois balcões. No primeiro, a mensagem vai para uma placa e é exibida
exatamente como chegou: esse é o textContent. No segundo, a mensagem vai para
uma equipe com ordem de montar estruturas: esse é o innerHTML. Se alguém de
fora puder escrever nessa ordem, poderá incluir atributos e elementos que você
nunca planejou. Tecnicamente, essa interpretação de conteúdo não confiável é o
que abre espaço para XSS.
A regra prática é: use textContent quando o valor é texto e innerHTML
somente quando a estrutura HTML foi escrita e controlada por você. Dados de
formulário, URL, API ou banco entram como texto ou são combinados com elementos
criados via DOM.
A página dos exemplos é o carrinho da mesma loja:
<body>
<main id="carrinho">
<p id="saudacao">Olá</p>
<ul class="itens">
<li class="item" data-id="1"><span class="nome">Teclado mecânico</span> <span class="preco">289.90</span></li>
<li class="item" data-id="2"><span class="nome">Mouse sem fio</span> <span class="preco">149.90</span></li>
<li class="item esgotado" data-id="3"><span class="nome">Cadeira gamer</span> <span class="preco">1199.00</span></li>
</ul>
</main>
<script src="carrinho.js"></script>
</body>textContent: o texto entra literal
Tudo que você atribui a textContent vira texto. Tag, aspas, sinal de menor —
nada é interpretado:
const nome = document.querySelector('[data-id="1"] .nome');
console.log(nome.textContent);
nome.textContent = '<b>Teclado mecânico</b>';
console.log(nome.textContent);
console.log(nome.innerHTML);
console.log('elementos filhos:', nome.children.length);Leia as três últimas linhas com calma, porque elas contam a história inteira. O
textContent devolve o que você escreveu, com os sinais de menor e maior
intactos. O innerHTML do mesmo elemento mostra <b> — o navegador
guardou os sinais escapados, para que na hora de renderizar apareçam como texto.
E children.length é zero: nenhum elemento foi criado. Existe apenas um nó de
texto ali dentro.
Na leitura, textContent junta o texto de todos os descendentes, com os espaços
do HTML no meio:
const lista = document.querySelector('.itens');
console.log(JSON.stringify(lista.textContent));As quebras de linha e a indentação do arquivo .html estão ali dentro — são nós
de texto, como você viu em o que é o DOM. Por
isso, ler textContent de um container quase sempre exige .trim() ou uma
expressão regular depois.
innerHTML: o navegador parseia o que você escreve
Com innerHTML, a string passa pelo parser de HTML. As tags viram elementos de
verdade, com filhos, classes e tudo mais:
const nome = document.querySelector('[data-id="1"] .nome');
nome.innerHTML = '<b>Teclado mecânico</b> <em>2 unidades</em>';
console.log('elementos filhos:', nome.children.length);
console.log(nome.textContent);
console.log(nome.querySelector('b').tagName);Dois filhos onde antes não havia nenhum. E repare que textContent agora
devolve só o texto, sem as tags: ele lê o resultado da árvore, não a string que
você digitou.
Por que innerHTML com dado de fora é XSS
O parser não distingue o HTML que você escreveu do HTML que veio junto com o nome do cliente. Ele parseia tudo. Imagine que o campo “nome” do cadastro tenha sido preenchido assim:
const nomeDoCliente = '<img src=x onerror="document.title = \'INVADIDO\'">';
document.querySelector('#saudacao').innerHTML = 'Olá, ' + nomeDoCliente;
setTimeout(() => console.log('document.title agora:', document.title), 100);Não tem truque. A tag <img> aponta para um arquivo que não existe, a imagem
falha, o navegador dispara o onerror — e o código dentro do atributo roda com
todos os privilégios da sua página. Trocar o título é a versão inofensiva; a
versão real lê o document.cookie, o localStorage com o token de sessão e
manda tudo para outro servidor.
Isso é cross-site scripting, o XSS. E ele não depende de o atacante ser
sofisticado: basta um campo de texto que você exibe de volta com innerHTML.
O detalhe que engana: <script> injetado não roda
Aqui muita gente relaxa cedo demais. Um <script> inserido via innerHTML
realmente não executa:
document.querySelector('#saudacao').innerHTML =
'<scr' + 'ipt>document.title = "O SCRIPT RODOU"</scr' + 'ipt>';
setTimeout(() => {
console.log('scripts na página:', document.querySelectorAll('script').length);
console.log('document.title :', document.title);
}, 100);O elemento <script> foi criado — a contagem subiu de 1 para 2 — mas o código
dentro dele nunca rodou. A especificação do HTML manda marcar como “já
executado” todo script que nasce de innerHTML.
A conclusão errada seria “então innerHTML é seguro”. Não é: o exemplo anterior
provou que onerror, onload, onmouseover e companhia continuam disparando
normalmente. A tag <script> está bloqueada; os atributos de evento, não.
O conserto é trocar uma palavra:
const nomeDoCliente = '<img src=x onerror="document.title = \'INVADIDO\'">';
document.querySelector('#saudacao').textContent = 'Olá, ' + nomeDoCliente;
setTimeout(() => {
console.log(document.querySelector('#saudacao').textContent);
console.log('document.title:', document.title);
}, 100);O nome bizarro aparece na tela como texto, exatamente como foi cadastrado, e nada executa. Feio, e correto.
innerText não é sinônimo de textContent
Existe uma terceira propriedade, e ela se comporta de um jeito próprio:
innerText devolve o texto como ele aparece na tela, depois do CSS. Com
esta folha de estilo na página:
.esgotado .preco { display: none; }
.nome { text-transform: uppercase; }O mesmo elemento dá dois resultados diferentes:
const item = document.querySelector('.esgotado');
console.log('textContent:', JSON.stringify(item.textContent));
console.log('innerText :', JSON.stringify(item.innerText));O preço sumiu porque está com display: none, e o nome veio em maiúsculas
porque o CSS mandou. innerText respondeu sobre a página renderizada;
textContent respondeu sobre a árvore.
O preço disso é performance: para saber o que está visível, o navegador precisa
calcular o layout. Ler innerText num laço força um recálculo a cada volta —
exatamente o padrão que a última seção desta lição mede.
textContent |
innerText |
innerHTML |
|
|---|---|---|---|
| interpreta tags ao escrever | não | não | sim |
| enxerga texto escondido por CSS | sim | não | sim |
aplica text-transform |
não | sim | não |
| força recálculo de layout | não | sim | não |
| risco de XSS | nenhum | nenhum | alto |
insertAdjacentHTML: acrescentar sem reescrever
Quando você quer adicionar em vez de substituir, insertAdjacentHTML insere um
pedaço de HTML numa posição específica, sem tocar no que já estava lá:
const lista = document.querySelector('.itens');
lista.insertAdjacentHTML('beforeend', '<li class="item"><span class="nome">Headset</span></li>');
lista.insertAdjacentHTML('afterbegin', '<li class="item"><span class="nome">Brinde: mousepad</span></li>');
console.log([...lista.children].map((li) => li.querySelector('.nome').textContent).join(' / '));As quatro posições possíveis, em relação ao elemento em que você chamou:
beforebegin (logo antes dele), afterbegin (primeiro filho), beforeend
(último filho) e afterend (logo depois dele). As duas do meio são as que você
vai usar.
innerHTML += recria tudo e mata os listeners
Este é o bug que faz gente passar uma tarde caçando. lista.innerHTML += '...'
parece “acrescentar”, mas não é: o navegador lê a string atual, concatena a
nova, joga fora todos os filhos e parseia tudo de novo. Os elementos que
saem do outro lado são novos objetos:
const lista = document.querySelector('.itens');
const teclado = lista.querySelector('[data-id="1"]');
teclado.addEventListener('click', () => console.log('clicou no teclado'));
teclado.click();
lista.innerHTML += '<li class="item" data-id="4"><span class="nome">Headset</span></li>';
const depois = lista.querySelector('[data-id="1"]');
console.log('é o mesmo elemento?', teclado === depois);
depois.click();
console.log('fim');O primeiro clique funcionou. Depois do +=, o <li> com data-id="1" continua
na tela, com o mesmo HTML — mas é outro objeto. O ouvinte ficou preso ao
elemento antigo, que foi descartado. O segundo clique não imprime nada.
O mesmo vale para qualquer estado que não esteja no HTML: campo de formulário preenchido, checkbox marcado, foco do teclado, posição de rolagem, referência guardada numa variável. Tudo isso se perde.
insertAdjacentHTML('beforeend', ...) faz o que você queria dizer com o +=,
sem destruir nada.
O custo de concatenar num laço
Além de quebrar listeners, o += é caro — o navegador serializa e reparseia a
lista inteira a cada volta. Medindo com 500 itens no Chromium 151:
const lista = document.querySelector('.itens');
const produtos = Array.from({ length: 500 }, (_, i) => 'Produto ' + (i + 1));
lista.innerHTML = '';
const t0 = performance.now();
for (const produto of produtos) {
lista.innerHTML += '<li class="item">' + produto + '</li>';
}
const t1 = performance.now();
lista.innerHTML = '';
const t2 = performance.now();
lista.innerHTML = produtos.map((produto) => '<li class="item">' + produto + '</li>').join('');
const t3 = performance.now();
console.log('500x innerHTML += :', (t1 - t0).toFixed(1), 'ms');
console.log('1x innerHTML = :', (t3 - t2).toFixed(1), 'ms');
console.log('itens no final :', lista.children.length);Mais de cem vezes mais lento, para o mesmo resultado na tela. E a diferença cresce junto com a lista, porque cada volta reprocessa tudo que já foi inserido — o custo é quadrático.
A correção é sempre a mesma: monte a string inteira, atribua uma vez.
map + join faz isso em duas linhas.
O erro: Cannot set properties of null
Quando a busca não acha o elemento, o erro muda de palavra em relação ao da lição anterior:
const resumo = document.querySelector('#resumo-pedido');
resumo.innerHTML = '<p>Total: R$ 1.638,80</p>';set, não read: você estava escrevendo. E a propriedade citada entre
parênteses é a que você tentou definir. Quando aparecer Cannot set properties of null, o problema nunca está no valor que você atribuiu — está no seletor da
linha de cima, que devolveu null. Vale tudo que já foi dito sobre
selecionar elementos:
confira o seletor e confira se o script rodou depois do HTML.
Qual usar, em uma linha
- Texto que veio de qualquer lugar que não seja o seu próprio código:
textContent. Sem exceção. - HTML fixo, escrito por você, sem interpolação de dado externo:
innerHTMLestá de bom tamanho. - HTML com dado externo dentro: monte a estrutura com
createElemente preencha cada texto comtextContent. É o assunto de createElement: criar e remover elementos, a próxima lição. - Acrescentar sem destruir o que já existe:
insertAdjacentHTML, nuncainnerHTML +=. - Ler o que o usuário está vendo, com o CSS aplicado:
innerText, sabendo que ele custa layout.
Um exercício que fecha a lição: pegue qualquer formulário do seu projeto, digite
<img src=x onerror="alert(1)"> num campo de nome e veja o que a sua tela faz
com isso. Se aparecer o alerta, você achou um XSS de verdade — e agora sabe
consertar. O caminho completo do assunto está no
guia de JavaScript e na
trilha de JavaScript.
Perguntas frequentes
innerHTML é sempre inseguro?
Por que o script que injetei com innerHTML não executou?
innerText ou textContent para ler o texto de um elemento?
Como escapar HTML antes de jogar no innerHTML?
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 Chromium 151.0.7922.34, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- MDN — Element: innerHTML — developer.mozilla.org
- MDN — Node: textContent — developer.mozilla.org


