Voltar ao blog
vue-jsjavascriptfrontendperformance

Virtual DOM por baixo dos panos: construindo um mini-framework do zero

08 de março de 2026 às 22:05 - Por Larissa Santos

Virtual DOM por baixo dos panos: construindo um mini-framework do zero

Se você trabalha com Vue ou React, já usa Virtual DOM todo dia. Mas provavelmente nunca parou pra pensar no que acontece de verdade quando você muda um ref() e a tela atualiza.

Neste artigo vamos desmistificar o Virtual DOM construindo um mini-framework do zero, com reatividade, ciclo de vida e componentes. Sem dependências, só JavaScript puro.

Por que o DOM real é lento?

Antes de entender o Virtual DOM, precisamos entender o problema que ele resolve.

O DOM não é JavaScript. Ele é implementado em C++ no browser, e toda vez que você o toca, há uma travessia de fronteira entre o engine JS (V8, SpiderMonkey) e o engine de renderização (Blink, WebKit). Essa travessia tem custo.

Além disso, certas operações disparam processos caros em cascata:

text
Mudança no DOM
  → Style recalculation  (quais regras CSS se aplicam agora?)
  → Layout / Reflow      (onde cada elemento fica na tela?)
  → Paint                (como cada pixel fica?)
  → Composite            (juntar as layers)

Ler propriedades como offsetHeight ou getBoundingClientRect força o browser a executar todo esse processo de forma síncrona. Isso se chama forced reflow, e é um dos maiores vilões de performance em aplicações web.

O problema não é criar elementos. É atualizar desnecessariamente: pintar a parede inteira quando só um cantinho mudou.

O que é o Virtual DOM?

Como o nome já diz, se trata de uma representação virtual (em memória) do DOM real, um objeto JavaScript que representa um elemento da UI.

js
const vnode = {
  tag: 'p',
  props: { style: 'color: red' },
  children: 'Contador: 1'
}

Isso é um VNode. Um objeto plain que descreve como um <p> deveria parecer.

O nome "VNode" foi popularizado pelos frameworks, mas você poderia chamar de qualquer coisa. O Vue usa h() para criá-los (a letra vem de hyperscript, uma convenção antiga da comunidade). Quando você escreve um template Vue como este:

html
<div class="box">
  <p>{{ msg }}</p>
</div>

O compilador transforma isso em:

js
h('div', { class: 'box' }, [h('p', null, msg)])

Que por sua vez retorna exatamente aquele objeto simples. O framework inteiro gira em torno de criar, comparar e aplicar esses objetos no DOM real.

As três peças fundamentais

1. h(): criar o VNode

js
function h(tag, props, children) {
  return { tag, props: props || {}, children: children || [] }
}

Parece inútil, só retorna o que recebe. Mas é uma conveniência: sem ela você repetiria { tag, props, children } em cada lugar. Em árvores aninhadas a diferença é enorme:

js
// sem h()
{ tag: 'div', props: {}, children: [
  { tag: 'p', props: {}, children: [
    { tag: 'span', props: {}, children: 'texto' }
  ]}
]}

// com h()
h('div', {}, [
  h('p', {}, [
    h('span', {}, 'texto')
  ])
])

2. mount(): primeira montagem

Pega um VNode e cria os elementos reais no DOM. Só é chamado uma vez, quando o componente aparece pela primeira vez na página.

js
function mount(vnode, container) {
  const el = document.createElement(vnode.tag)
  vnode._el = el // guarda a referencia que liga o objeto ao elemento real

  for (const [k, v] of Object.entries(vnode.props || {})) {
    el.setAttribute(k, v)
  }

  for (const child of vnode.children || []) {
    mount(child, el)
  }
  container.appendChild(el)
}

O vnode._el é a peça central: ele conecta o objeto JS ao elemento real da página. Sem ele, nas próximas atualizações você não saberia qual elemento do DOM alterar.

3. patch(): atualizar só o que mudou

Compara dois VNodes e aplica no DOM só as diferenças. Nunca recria o elemento, apenas edita o que existe.

js
function patch(oldV, newV) {
  newV._el = oldV._el
  const el = newV._el

  const oldProps = oldV.props || {}
  const newProps = newV.props || {}

  for (const [k, v] of Object.entries(newProps)) {
    if (oldProps[k] !== v) el.setAttribute(k, v)
  }

  for (const k of Object.keys(oldProps)) {
    if (!(k in newProps)) el.removeAttribute(k)
  }

  const oc = oldV.children || []
  const nc = newV.children || []
  const len = Math.max(oc.length, nc.length)

  for (let i = 0; i < len; i++) {
    if (!oc[i]) mount(nc[i], el)
    else if (!nc[i]) el.removeChild(oc[i]._el)
    else patch(oc[i], nc[i])
  }
}

A linha newV._el = oldV._el merece atenção: o newVnode é criado do zero sem nenhuma referência ao DOM. O patch transfere a referência do nó antigo pro novo antes de jogar o antigo fora.

Um detalhe importante: nós de texto

Strings primitivas em JavaScript não aceitam propriedades. Se você tentar salvar ._el numa string, a propriedade some:

js
const texto = 'Contador: 0'
texto._el = document.createTextNode('...')
console.log(texto._el) // undefined

A solução é envolver o texto num objeto, igual a qualquer outro VNode:

js
function t(text) {
  return { tag: '#text', text: String(text), _el: null }
}

h('p', {}, [t(`Contador: ${contador}`)])

Agora o _el é salvo normalmente e o patch consegue encontrar e atualizar o nó de texto exato no DOM.

Adicionando reatividade com Proxy

O Virtual DOM resolve o "como atualizar". Mas quem decide "quando atualizar" e "quais componentes precisam re-renderizar"?

No Vue 3, isso é feito com Proxy, uma feature nativa do JavaScript que intercepta leitura e escrita de propriedades de um objeto. Vale mencionar que o Vue 2 resolvia isso com Object.defineProperty (getter/setter), mas com limitações: não conseguia detectar adição de novas propriedades nem mudanças em arrays por índice. O Proxy do Vue 3 resolve esses problemas.

A ideia é simples:

js
const estado = { contador: 0 }

const reativo = new Proxy(estado, {
  get(target, key) {
    console.log(`alguém leu "${key}"`)
    return target[key]
  },
  set(target, key, value) {
    console.log(`alguém escreveu "${key}" = ${value}`)
    target[key] = value
    return true
  }
})

reativo.contador // log: alguém leu "contador"
reativo.contador = 5 // log: alguém escreveu "contador" = 5

O Vue usa esse mecanismo para montar um mapa de dependências: quando um componente renderiza, o get do Proxy registra quais propriedades foram lidas. Quando uma delas muda, o set notifica só os componentes que a leram.

text
Header leu → estado.titulo
Main   leu → estado.conteudo
Footer leu → estado.ano

estado.titulo muda → só o Header re-renderiza

A estrutura que usamos para guardar as dependências importa. Um objeto simples como const deps = {} funciona para um estado global único, mas causa dois problemas em escala: dois objetos reativos com uma propriedade nome conflitariam na mesma chave, e a referência ficaria presa na memória mesmo após o componente ser destruído.

A solução é usar WeakMap, que aceita objetos como chave e não impede o garbage collector de limpar o objeto quando ele não é mais usado. A estrutura fica WeakMap<objeto, Map<propriedade, Set<componentes>>>:

js
const deps = new WeakMap()
let componenteAtivo = null

function registrarDependencia(obj, propriedade) {
  if (!componenteAtivo) return

  let propriedades = deps.get(obj)
  if (!propriedades) {
    propriedades = new Map()
    deps.set(obj, propriedades)
  }

  let componentes = propriedades.get(propriedade)
  if (!componentes) {
    componentes = new Set()
    propriedades.set(propriedade, componentes)
  }

  componentes.add(componenteAtivo)
}

function notificarDependentes(obj, propriedade) {
  const propriedades = deps.get(obj)
  if (!propriedades) return

  const componentes = propriedades.get(propriedade)
  if (componentes) componentes.forEach(c => c._enfileirar())
}

function reativo(obj) {
  return new Proxy(obj, {
    get(target, key) {
      registrarDependencia(target, key)
      return target[key]
    },
    set(target, key, value) {
      if (target[key] === value) return true
      target[key] = value
      notificarDependentes(target, key)
      return true
    }
  })
}

O get chama registrarDependencia, que anota qual componente está lendo aquela propriedade. O set chama notificarDependentes, que avisa só quem precisa re-renderizar.

Batch update: o nextTick por baixo

Se você mudar três propriedades seguidas, não faz sentido re-renderizar três vezes. O Vue acumula todas as mudanças do ciclo síncrono atual e processa tudo de uma vez. Isso é o nextTick.

Por baixo, ele usa Promise.resolve().then(), que agenda a execução depois que todo o código síncrono terminar:

js
let fila = new Set()
let flushAgendado = false

function agendarFlush() {
  if (flushAgendado) return
  flushAgendado = true
  Promise.resolve().then(() => {
    fila.forEach(c => c._rerender())
    fila.clear()
    flushAgendado = false
  })
}

Muda 10 propriedades no mesmo tick, re-renderiza uma vez só.

Juntando tudo: o mini-framework

Com reatividade e Virtual DOM prontos, a função criarComponente é a cola entre os dois. O ponto central é marcar o componente como efeitoAtivo antes de renderizar, para que o track saiba a quem atribuir as dependências:

js
function criarComponente(opcoes, container) {
  const componente = {
    props: opcoes.props || {},
    estado: reativo(opcoes.estado || {}),
    _vnode: null,

    _enfileirar() {
      fila.add(componente)
      agendarFlush()
    },

    _rerender() {
      efeitoAtivo = componente._enfileirar
      const novoVnode = opcoes.render.call(componente)
      efeitoAtivo = null

      if (!componente._vnode) {
        mount(novoVnode, container)
        componente._vnode = novoVnode
        if (opcoes.onMount) opcoes.onMount.call(componente)
      } else {
        patch(componente._vnode, novoVnode)
        componente._vnode = novoVnode
        if (opcoes.onUpdate) opcoes.onUpdate.call(componente)
      }
    },

    destruir() {
      if (opcoes.onDestroy) opcoes.onDestroy.call(componente)
      if (componente._vnode) {
        container.removeChild(componente._vnode._el)
        componente._vnode = null
      }
    }
  }

  componente._rerender()
  return componente
}

O uso fica separado em dois arquivos. O framework.js contém toda a lógica acima. O index.html contém apenas os componentes:

js
const app = document.getElementById('app')
const estadoGlobal = reativo({ tema: 'claro' })

criarComponente(
  {
    props: { titulo: 'Meu App' },
    render() {
      return h('div', {}, [
        h('h2', {}, [t(this.props.titulo)]),
        h('small', {}, [t(`Tema: ${estadoGlobal.tema}`)])
      ])
    },
    onMount() {
      console.log('Header montado')
    }
  },
  app
)

const contador = criarComponente(
  {
    estado: { valor: 0 },
    render() {
      return h('div', {}, [
        h('p', {}, [t(`Contador: ${this.estado.valor}`)]),
        h('button', { onclick: () => this.estado.valor++ }, [t('+1')])
      ])
    },
    onDestroy() {
      console.log('Contador removido')
    }
  },
  app
)

Abre o DevTools na aba Elements e clica no botão. Você vai ver só o texto do <p> sendo alterado, nada mais sendo recriado.

O que o Vue faz além disso

O que construímos aqui é o núcleo real. O Vue adiciona em cima:

Static hoisting: o compilador identifica VNodes que nunca mudam e os cria uma única vez fora da função de render, reutilizando na memória em vez de recriar a cada ciclo.

Patch flags: o compilador anota em tempo de build o que pode mudar em cada VNode. Em runtime, o patch pula a comparação do que está marcado como estático, tornando o diff cirúrgico por design.

Keys em listas: sem key, o diff compara filhos por posição. Com key, o Vue identifica qual item é qual e consegue reordenar sem recriar. É por isso que o Vue reclama quando você usa v-for sem :key.

ref() vs reactive(): o reactive() do Vue é exatamente o Proxy que implementamos. O ref() é diferente: é implementado com getter/setter em volta de um objeto { value }, por isso você acessa .value em vez de acessar direto. São duas formas de criar reatividade, com trade-offs diferentes.

Slots e Teleport: abstrações em cima do sistema de componentes que o criarComponente simples não suporta.

A diferença entre o nosso mini-framework e o Vue não é conceitual, é o quanto de borda foi resolvida.

Conclusão

Virtual DOM é um objeto JavaScript que representa um elemento da UI. O framework mantém uma cópia desse objeto, compara com o estado anterior a cada mudança e aplica no DOM real só o que diferiu.

Reatividade com Proxy é o mecanismo que decide quem re-renderizar. Os dois sistemas juntos, Virtual DOM e Proxy, são o coração do Vue 3.

Entender isso muda como você usa o framework no dia a dia. Quando você vê nextTick, sabe que é o Promise.resolve().then() aguardando o flush. Quando o Vue reclama de :key, você sabe por que o diff precisa dela. Quando um componente re-renderiza mais do que deveria, você sabe onde procurar.

O framework não é mágica. É só JavaScript bem organizado.

Referências

Feito com e Vue.js
2026 © Larissa Santos