Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIniciantecódigo testado

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.

Rodolfo Mori7 min de leitura

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:

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

js
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);
Teclado mecânico <b>Teclado mecânico</b> &lt;b&gt;Teclado mecânico&lt;/b&gt; elementos filhos: 0

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 &lt;b&gt; — 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:

js
const lista = document.querySelector('.itens');

console.log(JSON.stringify(lista.textContent));
"\n Teclado mecânico 289.90\n Mouse sem fio 149.90\n Cadeira gamer 1199.00\n "

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:

js
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);
elementos filhos: 2 Teclado mecânico 2 unidades B

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:

js
const nomeDoCliente = '<img src=x onerror="document.title = \'INVADIDO\'">';

document.querySelector('#saudacao').innerHTML = 'Olá, ' + nomeDoCliente;

setTimeout(() => console.log('document.title agora:', document.title), 100);
document.title agora: INVADIDO

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:

js
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);
scripts na página: 2 document.title : Club Store — Carrinho

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:

js
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);
Olá, <img src=x onerror="document.title = 'INVADIDO'"> document.title: Club Store — Carrinho

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:

css
.esgotado .preco { display: none; }
.nome { text-transform: uppercase; }

O mesmo elemento dá dois resultados diferentes:

js
const item = document.querySelector('.esgotado');

console.log('textContent:', JSON.stringify(item.textContent));
console.log('innerText  :', JSON.stringify(item.innerText));
textContent: "Cadeira gamer 1199.00" innerText : "CADEIRA GAMER"

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

js
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(' / '));
Brinde: mousepad / Teclado mecânico / Mouse sem fio / Cadeira gamer / Headset

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:

js
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');
clicou no teclado é o mesmo elemento? false 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:

js
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);
500x innerHTML += : 28.6 ms 1x innerHTML = : 0.2 ms itens no final : 500

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:

js
const resumo = document.querySelector('#resumo-pedido');

resumo.innerHTML = '<p>Total: R$ 1.638,80</p>';
Uncaught TypeError: Cannot set properties of null (setting 'innerHTML') at resumo.js:3:18

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: innerHTML está de bom tamanho.
  • HTML com dado externo dentro: monte a estrutura com createElement e preencha cada texto com textContent. É o assunto de createElement: criar e remover elementos, a próxima lição.
  • Acrescentar sem destruir o que já existe: insertAdjacentHTML, nunca innerHTML +=.
  • 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.

  • dom
  • innerhtml
  • textcontent
  • xss
  • innertext

Perguntas frequentes

innerHTML é sempre inseguro?
Não. É inseguro quando a string contém algo que veio de fora — formulário, URL, API, banco. Com HTML que você escreveu inteiro no código, e valores que você mesmo gerou, não há vetor. O risco está na origem do texto.
Por que o script que injetei com innerHTML não executou?
A especificação do HTML manda o parser marcar como já executado todo script criado por innerHTML. Isso não torna innerHTML seguro: atributos como onerror e onload continuam disparando normalmente.
innerText ou textContent para ler o texto de um elemento?
textContent, em quase todo caso. Ele é mais rápido, não depende do CSS e devolve o mesmo resultado sempre. innerText só compensa quando você quer exatamente o que o usuário está vendo na tela.
Como escapar HTML antes de jogar no innerHTML?
A forma mais confiável é não escapar nada: use textContent para o texto e monte a estrutura com createElement. Se precisar mesmo de string, escape &, menor-que, maior-que e as aspas antes de concatenar.

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 Chromium 151.0.7922.34, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. MDN — Element: innerHTML — developer.mozilla.org
  2. MDN — Node: textContent — developer.mozilla.org

Continue por aqui