Guia Para Criar o Seu Próprio Virtual DOM

javascript 30 de Mar de 2020
Disclaimer: Esse artigo é um guia para te ensinar a criar o esqueleto de um virtual DOM. Não irei cobrir técnicas aplicadas dentro do React ou tópicos mais avançados como eventos ou lifecycles. Estarei disponibilizando artigos que me serviram de fonte de inspiração a estudar sobre o tópico e também direcionamento caso você queira se aprofundar mais no assunto.

Índice

  1. O que é Virtual DOM?
  2. Configurando o projeto
    1. Bibliotecas
    2. O que é pragma h?
    3. Estrutura do projeto
  3. Estrutura de dados do Virtual DOM
  4. Conversão do nosso JSX
  5. Transformando elementos na nossa estrutura de dados
  6. Renderizando elementos na página
  7. De objetos para elementos HTML
  8. Calculando a diferença entre um estado e outro
    1. Calculando diferença entre atributos
    2. Calculando diferença entre filhos (Children)
  9. Aplicando alterações no DOM
  10. Simulando alterações no Virtual DOM
  11. Próximos passos

O que é Virtual DOM?

Virtual DOM é uma representação em estrutura de dados (Objetos plenos) do DOM. É como se fosse uma cópia em memória do que vemos no nosso navegador.

O DOM (Document Object Model) é uma interface programável de documentos HTML. Se você gostaria de aprender mais sobre seu funcionamento, recomendo o seguinte curso gratuito.

Por exemplo, se fizermos isso:

const div = document.getElementById("container");

Você terá a referência do DOM para <div id=“container”></div> na página. Essa referência também vem com uma interface para você poder manipular ela. Exemplo:

div.innerHTML = "Texto dentro do container";

A maior motivação para a criação do Virtual DOM é que fazer essas manipulações no HTML da página é muito custoso para o browser. Conforme as aplicações vão ficando maiores, fica quase que inviável (Ainda mais se o Chrome já tiver comido metade da sua memória) fazermos toda essa dinâmica que temos hoje, diretamente no DOM.

Por conta disso, mantemos uma cópia virtual do DOM que podemos manipular com custos baixíssimos e depois fazemos uma sincronização super otimizada pro DOM, alterando apenas o necessário.

Com isso em mente, criaremos uma versão super básica de um Virtual DOM (baseado no ReactJS), para entendermos melhor o seu funcionamento.

Configurando o projeto

Nosso projeto será super simples. Iremos configurar o ambiente de desenvolvimento para que nosso Virtual DOM tenha bastante similaridade com a API do React.

Vamos iniciar o projeto criando a nossa pasta e iniciando o package.json. Irei utilizar o Yarn para isso, mas você pode usar o NPM se preferir:

mkdir vdom && cd vdom
yarn init -y

Bibliotecas

Faremos o uso de 2 bibliotecas importantíssimas no nosso projeto. Vamos começar instalando as duas:

yarn add @babel/plugin-transform-react-jsx parcel-bundler --dev

Utilizaremos o Parcel para gerar o bundle do nosso app. O Parcel é um bundler zero configuração, que vai tornar nosso desenvolvimento muito produtivo. Você pode se aprofundar mais um pouco no site dele.

A segunda biblioteca é um plugin do babel utilizado para fazer o transpile de JSX para JavaScript. Exemplo:

// Componente React
const App = () => (
    <div className="container"></div>
);

Se transformará nisso:

// Código transpilado
const App = React.createElement('div', { className: 'container' }, null);

Isso é o que acontece normalmente nos projetos React quando você utiliza JSX. Acontece por de baixo dos panos, sem mágica, já que o browser não conseguiria entender o código JSX.

O que é pragma?

A mágica está no uso da propriedade pragma do nosso plugin do babel. Fica bem óbvio se olharmos para o nome dele (React) que quando ele transpilar nosso código JSX, resultará no exemplo que citei acima.

A propriedade pragma deixa a gente especificar uma diretiva, dizendo para o babel como processar o nosso JSX.

Para isso funcionar, devemos criar um .babelrc na nossa pasta:

touch .babelrc

E depois editarmos o arquivo com a seguinte configuração:

{
  "plugins": [
    ["transform-react-jsx", {
      "pragma": "vdom.createElement"
    }]
  ]
}

Com isso, em vez de termos nosso código transpilado para isso:

// Código transpilado
const App = React.createElement('div', { className: 'container' }, null);

Teremos isso:

// Código transpilado com pragma:h
const App = vdom.createElement('div', { className: 'container' }, null);

Abrindo caminho para criarmos a nossa própria função createElement(), excluindo qualquer dependência do React.

Estrutura do projeto

Para a estrutura do nosso projeto, eu vou fazer da seguinte forma:

mkdir public src src/vdom
touch public/index.html src/index.js

Nos deixando com a seguinte estrutura:

.
├── package.json
├── node_modules
├── public
│   └── index.html
├── src
│   ├── index.js
│   └── vdom
└── yarn.lock

Agora precisamos configurar o nosso index.html:

<!DOCTYPE html>
<html lang="pt-br">
<head>
  <title>Virtual DOM</title>
</head>
<body>
  <script src="../src/index.js"></script>
</body>
</html>

Incluímos o script dessa maneira de forma proposital, já que o Parcel é capaz de resolver o caminho e fazer o transpile do script.

Para isso acontecer, precisamos configurar o package.json para rodar o Parcel, dessa maneira:

{
  "name": "vdom",
  "version": "1.0.0",
  "main": "src/index.js",
  "license": "MIT",
  "scripts": {
    "dev": "parcel public/index.html",
    "build": "parcel build public/index.html"
  },
  "devDependencies": {
    "@babel/plugin-transform-react-jsx": "^7.9.4",
    "parcel-bundler": "^1.12.4"
  }
}

E assim podemos rodar o projeto com yarn dev e vermos o nosso projeto rodando em http://localhost:1234.

Estrutura de dados do Virtual DOM

Como eu havia comentado no início do artigo, o Virtual DOM é representado por uma estrutura de dados. Um objeto nesse caso.

Também não há nenhuma regra específica de como esse objeto deve ser construído. Aqui vai o exemplo do nosso component App do exemplo acima, representado no virtual DOM:

const vApp = {
    tag: 'div',
    props: {
        children: null,
        id: 'container',
    },
};

Conversão do nosso JSX

Se abrirmos o nosso index.js e digitarmos o seguinte código:

const App = () => (
  <div id="container">hello</div>
);

console.log(App);

Veríamos o seguinte output:

ƒ App() {
  return vdom.createElement("div", {
    id: "container"
  }, "hello");
}

O nosso JSX foi convertido para a nossa função createElement() com sucesso!

Agora precisamos construir a nossa função para começar a construir o nosso virtual DOM.

Transformando elementos na nossa estrutura de dados

Agora iremos criar o nosso primeiro arquivo, que será responsável pela criação da nossa estrutura de dados.

Dentro da pasta vdom crie o arquivo createElement.js e coloque o seguinte código:

function createElement(component, props, ...children) {
  if (typeof component === 'function') {
    return component();
  }

  const obj = Object.create(null);
  const objProps = Object.create(null);

  return Object.assign(obj, {
    type: component,
    props: Object.assign(objProps, props, {
      children,
    })
  });
}

export default createElement;

E se você estiver se perguntando o porque de eu utilizar o Object.create(null) é apenas para fazer com que tenhamos um Object puro, sem os métodos que ele herda do construtor do Object.

Nosso próximo passo é criar o arquivo index.js dentro da nossa pasta vdom também, que vai servir como ponto de entrada na hora de importar a nossa biblioteca.

import createElement from './createElement';

export default {
  createElement,
};

E agora no nosso index.js na pasta src podemos importar o nosso vdom e fazer o primeiro teste:

import vdom from './vdom';

const App = () => (
  <div id="container">hello</div>
);

console.log(App());

Reparem que dessa vez eu executo o App(). Não fiz isso no último exemplo pois ainda não tínhamos declarado nossa função createElement e o código iria quebrar.

Abrindo o console no nosso navegador, vamos ver a estrutura do nosso Virtual DOM magicamente criada.

Também podemos reparar na verificação do component no createElement. Isso se torna necessário caso queremos suportar a seguinte maneira:

console.log(<App />);

Isso porque o JSX te retorna a declaração da função, mas não executa. Nesse caso, o primeiro parâmetro do createElement vem com essa declaração, aí apenas retornamos a sua execução, que irá chamar o createElement de novo e , dessa vez, retornará corretamente a nossa representação.

OBS: Dentro dessa verificação, poderíamos instanciar a classe Component (que não iremos implementar) e retornar um componente com lifecycle, props etc. Escolhi não fazer isso, para não deixar o artigo tão longo e complexo. Sinta-se livre para estudar e implementar.

Renderizando elementos na página

Precisamos agora criar a nossa função de render, responsável por osquestrar a renderização do nosso virtual DOM na nossa página.

Vamos então criar o arquivo render.js dentro da pasta vdom:

import mount from './mount';

function render(element, container) {
  if (!container) {
    throw new Error('O container não existe. Você precisa passar um container para renderizar seu app.');
  }

  container.appendChild(mount(element));
}

export default render;

Apenas verificamos se o container passado existe no DOM. Caso exista, injetamos o HTML gerado pela função mount() (que criaremos em seguida) no elemento.

Antes de partirmos para a criação do mount(), precisamos adicionar o render nos nossos 2 arquivos index.js, dessa maneira:

import createElement from './createElement';
import render from './render';

export default {
  createElement,
  render,
};

E no outro index.js:

import vdom from './vdom';

const App = () => (
  <div id="container">hello</div>
);

vdom.render(<App />, document.getElementById('app'));

E também não podemos esquecer de criar a div no nosso HTML com o ID "app":

<!DOCTYPE html>
<html lang="pt-br">
<head>
  <title>Virtual DOM</title>
</head>
<body>
  <div id="app"></div>
  <script src="../src/index.js"></script>
</body>
</html>

E se você usa React, você deve estar achando essa nossa interface bem familiar. A idéia é essa mesmo.

Vamos agora a nossa próxima função!

De objetos para elementos HTML

Agora essa é a hora da verdade! Iremos fazer a nossa primeira renderização na página.

Vamos criar o arquivo mount.js dentro da pasta vdom:

const RESERVED_KEYS = {
  className: 'class',
};

function replaceReservedKeys(word) {
  return RESERVED_KEYS[word] || word;
}

function mount(vNode) {
  if (typeof vNode === 'string' || typeof input === 'number') {
    return document.createTextNode(vNode);
  }

  const el = document.createElement(vNode.type);

  for (const [k, v] of Object.entries(vNode.props)) {
    if (k === 'children') {
      continue;
    }

    el.setAttribute(replaceReservedKeys(k), v);
  }

  for (const child of vNode.props.children) {
    el.appendChild(mount(child));
  }

  return el;
}

export default mount;

Podemos fazer algumas observações em relação a essa função:

  • Verificamos se o vNode é uma string ou um número. Caso seja, retornamos um TextNode, senão, construimos o nosso NodeElement normalmente.
  • Reparamos também que no primeiro for loop, verificamos se a chave é o children que, no caso, vem dentro de props mas não é uma propriedade HTML.
  • Ainda no primeiro for loop, fazemos o replace de props especiais, nesse caso, o className, trocando ele para class.
  • Próximo, fazemos um loop em todos os filhos do elemento, de forma recursiva. Ou seja, faremos esse mesmo procedimento para todos os filhos do nosso elemento.
  • Por fim, retornamos o nosso elemento já construído e a nossa função render o insere na página.

Existem 8 tipos de elementos no DOM. No nosso caso, estamos apenas cobrindo 2 deles: ELEMENT_NODE e TEXT_NODE. Caso esteja curioso, vale a pena dar uma olhada na documentação da MDN.

O nosso resultado até agora:

Calculando a diferença entre um estado e outro

Chegamos na segunda metade do nosso Virtual DOM e também a mais importante. Sem ela, tudo que fizemos é completamente inútil.

O algoritmo de diffing é responsável por calcular as diferenças entre o estado anterior e o novo estado, sincronizando isso com o nosso DOM e fazendo apenas as alterações realmente necessárias.

No React, por exemplo, você vai ouvir muito sobre o termo "reconciler". Muita coisa acontece nesse processo, para tornar o React a ferramenta tão otimizada que é hoje.

A idéia aqui é fazer o "diffing" mais simples possível, apenas para entendermos o seu funcionamento.

Com isso em mente, vamos criar o nosso próximo arquivo, chamado de diff.js na pasta vdom:

export const OPERATIONS = {
  CREATE: 'CREATE',
  REMOVE: 'REMOVE',
  REPLACE: 'REPLACE',
  UPDATE: 'UPDATE',
  SET_PROP: 'SET_PROP',
  REMOVE_PROP: 'REMOVE_PROP',
};

function diffProps(oldVNode, newVNode) {
  const patches = [];
  const props = Object.assign({}, newVNode.props, oldVNode.props);

  Object.keys(props).forEach(name => {
    if (name === 'children') return;

    const newVal = newVNode.props[name];
    const oldVal = oldVNode.props[name];

    if (!newVal) {
      patches.push({ type: OPERATIONS.REMOVE_PROP, name, value: oldVal });
    } else if (newVal !== oldVal) {
      patches.push({ type: OPERATIONS.SET_PROP, name, value: newVal });
    } else {
      return;
    }
  });

  return patches;
}

function diffChildren(oldVNode, newVNode) {
  const patches = [];
  const patchesLength = Math.max(
    newVNode.props.children.length,
    oldVNode.props.children.length,
  );

  for (let i = 0; i < patchesLength; i++) {
    const changes = diff(
      oldVNode.props.children[i],
      newVNode.props.children[i],
    );

    if (changes) {
      patches[i] = changes;
    }
  }

  return patches;
}

function changed(node1, node2) {
  return typeof node1 !== typeof node2 ||
         typeof node1 === 'string' && node1 !== node2 ||
         node1.type !== node2.type;
}

function diff(oldVNode, newVNode) {
  if (!oldVNode) {
    return { type: OPERATIONS.CREATE, newVNode };
  }

  if (!newVNode) {
    return { type: OPERATIONS.REMOVE };
  }

  if (changed(oldVNode, newVNode)) {
    return { type: OPERATIONS.REPLACE, newVNode };
  }

  if (newVNode.type) {
    return {
      type: OPERATIONS.UPDATE,
      props: diffProps(oldVNode, newVNode),
      children: diffChildren(oldVNode, newVNode),
    };
  }

  return null;
}

export default diff;

Agora fica tranquilo. Realmente é muita coisa pra entender aqui. Vou explicar por partes tudo que está acontecendo:

  • Verificamos se o elemento antigo existe. Caso não, devemos retornar uma ação de criar o elemento novo.
  • Verificamos se o novo elemento existe. Caso não, devemos remover o elemento do DOM.
  • Próximo verificamos se o elemento antigo mudou em relação ao novo. Comparamos o tipo do elemento e se eles ainda são iguais. Caso tenha mudado, retornamos a ação de replace, para substituirmos o elemento inteiro e seus filhos.
  • Por último, verificamos se o elemento não é um texto (certificando de que ele tem um type) e lançamos uma ação de update.

A nossa função de diff é apenas isso. Retornar uma ação a ser tomada, para depois podermos processar todas as ações em batch.

Próximo vou explicar como funciona o diff de atributos e o diff de children (filhos) dos nossos elementos.

Calculando diferença entre atributos

Quando verificamos se o elemento tem atualizações nas suas props, chamamos a função diffProps:

function diffProps(oldVNode, newVNode) {
  const patches = [];
  const props = Object.assign({}, newVNode.props, oldVNode.props);

  Object.keys(props).forEach(name => {
    if (name === 'children') return;

    const newVal = newVNode.props[name];
    const oldVal = oldVNode.props[name];

    if (!newVal) {
      patches.push({ type: OPERATIONS.REMOVE_PROP, name, value: oldVal });
    } else if (newVal !== oldVal) {
      patches.push({ type: OPERATIONS.SET_PROP, name, value: newVal });
    } else {
      return;
    }
  });

  return patches;
}

O que acontece aqui é o seguinte:

  • Unimos as props do elemento antigo com o novo, para que podemos fazer um loop só por todas elas.
  • Verificamos se a prop é o children e pulamos ela (explicarei a seguir).
  • Pegamos o valor da prop no novo elemento e no elemengo antigo.
  • Verificamos se a prop no elemento novo existe. Caso não, faremos uma ação de remover essa prop.
  • Caso ela exista mas os valores sejam diferentes, faremos uma ação de setar (atualizar) essa prop.
  • No final, retornamos uma lista de ações a serem executadas.

Calculando diferença entre filhos (children)

A função diffChildren não tem segredo. Ela também retornará uma lista de ações a serem tomadas:

function diffChildren(oldVNode, newVNode) {
  const patches = [];
  const patchesLength = Math.max(
    newVNode.props.children.length,
    oldVNode.props.children.length,
  );

  for (let i = 0; i < patchesLength; i++) {
    const changes = diff(
      oldVNode.props.children[i],
      newVNode.props.children[i],
    );

    if (changes) {
      patches[i] = changes;
    }
  }

  return patches;
}

Começamos chamando a função Math.max() que apenas retorna o maior número. Nesse caso, queremos pegar o elemento que tem o maior número de props, para que o nosso loop seja efetivo e não deixemos nenhuma de fora.

No nosso loop, fazemos uma chamada para a própria função diff(). Isso torna a nossa função diff recursiva. Essa é a maneira que verificamos todos os elementos dentro da nossa árvore do Virtual DOM.

Por último, caso a função diff retorne alguma lista de ação, colocamos essa lista dentro da lista da nossa própria função e retornamos.

Assim temos a nossa lista de alterações do nosso virtual DOM.

Para fazermos apenas um teste rápido, vamos alterar o nosso arquivo render dessa maneira:

import mount from './mount';
import diff from './diff';

function render(element, nextElement, container) {
  if (!container) {
    throw new Error('O container não existe. Você precisa passar um container para renderizar seu app.');
  }

  container.appendChild(mount(element));

  const patches = diff(element, nextElement);
  console.log(patches);
}

export default render;

E nosso arquivo index.js dessa maneira:

import vdom from './vdom';

const App = () => (
  <div id="container" className="fluid">hello</div>
);

const App2 = () => (
  <div id="container2" className="static">hello</div>
);

vdom.render(<App />, <App2 />, document.getElementById('app'));

Muito importante desfazer essas alterações depois, já que isso é apenas um teste.

Dessa maneira, estamos fazendo com que o <App2 /> fosse como um próximo estado no nosso DOM e nossa função de diff compara o App com o novo App2.

Se olharmos no console, teremos a seguinte lista de alterações:

{
    type: "UPDATE",
    props: [
            { type: "SET_PROP", name: "id", value: "container2"},
            { type: "SET_PROP", name: "className", value: "static"}
    ],
    children: []
}

Aplicando alterações no DOM

A parte difícil já foi, agora ficamos só com a parte divertida. Vamos pegar essa lista de alterações que é gerada e vamos aplicar todas as ações.

Para isso, vamos criar o arquivo patch.js na nossa pasta vdom:

import { OPERATIONS } from './diff'
import mount from './mount';

function setProp(target, name, value) {
  if (name === 'className') {
    return target.setAttribute('class', value);
  }
  target.setAttribute(name, value);
}

function removeProp(target, name) {
  if (name === 'className') {
    return target.removeAttribute('class');
  }
  target.removeAttribute(name);
}

function patchProps(parentElement, patches) {
  for (let i = 0; i < patches.length; i++) {
    const propPatch = patches[i];
    const { type, name, value } = propPatch
    if (type === OPERATIONS.SET_PROP) {
      setProp(parentElement, name, value);
    }
    if (type === OPERATIONS.REMOVE_PROP) {
      removeProp(parentElement, name);
    }
  }
}

function patch(parentElement, patches, index = 0) {
  if (!patches) return;

  const el = parentElement.childNodes[index];

  switch (patches.type) {
    case OPERATIONS.CREATE: {
      const newElement = mount(patches.newVNode);
      parentElement.appendChild(newElement);
      break;
    }
    case OPERATIONS.REMOVE: {
      parentElement.removeChild(el);
      break;
    }
    case OPERATIONS.REPLACE: {
      const newElement = mount(patches.newVNode);
      parentElement.replaceChild(newElement, el);
      break;
    }
    case OPERATIONS.UPDATE: {
      const { props, children } = patches;

      patchProps(el, props);

      for (let i = 0; i < children.length; i++) {
        patch(el, children[i], i);
      }

      break;
    }
    default: { break; }
  }
}

export default patch;

A nossa função de patch também será recursiva, por isso sempre passamos a referência do elemento pai que estamos alterando e também o index dos irmãos do elemento sendo alterado.

Fazemos um early return caso não haja mudanças a serem feitas.

Começando pegando o elemento (filho do parent) no index determinado. No caso, vamos começar sempre no 0. Assim conseguimos fazer as alterações em todos os irmãos e filhos do elemento.

Criamos um switch (como no Redux) para determinar qual ação devemos tomar para cada alteração.

Devemos prestar mais atenção da parte de update, já que ela também faz os procedimentos nas props do elemento e também faz as chamadas recursivas para os filhos do elemento.

A função patchProps é bem parecida com a patch, mas resolvi separar sua lógica para o switch não ficar muito grande e para que também fique mais fácil de testar.

Com isso, temos a nossa implementação de Virtual DOM, de uma maneira bem simples.

Agora, como sabemos se está funcionando? Não implementamos nenhum event system, hooks ou lifecycles. Isso significa que nosso Virtual DOM não está reativo e se não desencadearmos nenhuma ação, não servirá de nada.

No nosso último passo, vamos fazer um pequeno hack para podermos ver o nosso vdom funcionando.

Simulando alterações no Virtual DOM

Nosso hack será bem parecido com o que fizemos acima para testar nossa lista de alterações.

Vamos começar alterando o nosso index.js para o seguinte:

import vdom from './vdom';

const App = () => (
  <div id="container" className="fluid">
    <span>Hello</span>
    <div className="menu">Menu</div>
  </div>
);

const NextApp = () => (
  <div id="container" className="static">
    <span>Hello</span>
    <div className="list">
      <ul>
        <li>Menu 1</li>
        <li>Menu 2</li>
        <li>Menu 3</li>
      </ul>
    </div>
  </div>
);

vdom.render(<App />, <NextApp />, document.getElementById('app'));

Adicionei um pouco mais de complexidade no nosso componente e criei um componente novo chamado de NextApp para representar o próximo estado do nosso DOM.

Passamos ele como segundo parâmetro para a função render, que vamos alterar para o seguinte:

import mount from './mount';
import diff from './diff';
import patch from './patch';

function render(element, nextElement, container) {
  if (!container) {
    throw new Error('O container não existe. Você precisa passar um container para renderizar seu app.');
  }

  container.appendChild(mount(element));

  setTimeout(() => {
    const patches = diff(element, nextElement);
    patch(container, patches);
  }, 2000)
}

export default render;

Modificamos o render para receber o "próximo estado" da nossa aplicação.

Criamos um setTimeout de 2 segundos, para que seja possível vermos as alterações acontecendo.

Usamos a nossa função diff passando o "estado atual" (nosso element) e o nosso "próximo estado".

Agora apenas chamamos a nossa função de patch, passando as alterações a serem feitas.

Se fizemos tudo certo até aqui, podemos notar que a primeira renderização do app é feita e depois de 2 segundos nosso vdom faz apenas as modicações necessárias para renderizar o próximo estado:

E você pode pegar aqui o resultado final também!

Próximos passos

Se você ficou super animado depois de criar o seu próprio Virtual DOM, eu tenho uma listinha que pode te aprofundar mais nesse assunto:

Não tenha medo de se aprofundar. Sempre desmonte as coisas para saber como elas funcionam, por mais ridícula que sua implementação seja.

Eu nunca aprendi tanto sobre arquitetura front-end quanto fazendo um projeto desses. Pode ter certeza de que irei continuar investigando e, quem sabe, não tento implementar a nova arquitetura Fiber do React?

Grande abraço.

Marcadores