
Como entender e dominar a essência do DOM virtual? Recomendo a todos que aprendam o projeto Snabbdom.
Snabbdom é uma biblioteca de implementação de DOM virtual. Os motivos da recomendação são: primeiro, o código é relativamente pequeno e o código principal tem apenas algumas centenas de linhas, em segundo lugar, o Vue baseia-se nas ideias deste projeto para implementar o DOM virtual; as ideias de concepção/implementação e ampliação deste projeto Valem a sua referência.
snab /snab/, sueco, significa rápido.
Ajuste sua postura confortável ao sentar e anime-se. Vamos começar. Para aprender o DOM virtual, devemos primeiro conhecer o conhecimento básico do DOM e os pontos problemáticos da operação direta do DOM com JS.
DOM (Document Object Model) é um modelo de objeto de documento que usa uma estrutura de árvore de objetos para representar um documento HTML/XML. O final de cada ramo da árvore é um nó. Os métodos da API DOM permitem manipular esta árvore de maneiras específicas. Com esses métodos, você pode alterar a estrutura, o estilo ou o conteúdo do documento.
Todos os nós na árvore DOM são primeiro Node Node é uma classe base. Element , Text e Comment são todos herdados dele.
Em outras palavras, Element , Text e Comment são três Node especiais, chamados ELEMENT_NODE respectivamente.
TEXT_NODE e COMMENT_NODE representam nós de elementos (tags HTML), nós de texto e nós de comentários. Element também possui uma subclasse chamada HTMLElement . Qual é a diferença entre HTMLElement e Element ? HTMLElement representa elementos em HTML, como: <span> , <img> , etc., e alguns elementos não são padrão HTML, como <svg> . Você pode usar o seguinte método para determinar se este elemento é HTMLElement :
document.getElementById('myIMG') instanceof HTMLElement É “caro” para o navegador criar o DOM. Vejamos um exemplo clássico. Podemos criar um elemento p simples através de document.createElement('p') e imprimir todos os atributos:

Você pode ver que há muitos atributos impressos. Ao atualizar árvores DOM complexas com frequência, ocorrerão problemas de desempenho. O Virtual DOM usa um objeto JS nativo para descrever um nó DOM, portanto, criar um objeto JS é muito mais barato do que criar um objeto DOM.
VNode é uma estrutura de objeto que descreve o DOM virtual no Snabbdom. O conteúdo é o seguinte:
type Key = string number |
interface VNode {
// Seletor CSS, como: 'p#container'.
sel: string | indefinido;
// Manipule classes CSS, atributos, etc. através de módulos.
dados: VNodeData | indefinido;
// Matriz de nó filho virtual, os elementos da matriz também podem ser strings.
filhos: Array<VNode | string> |
// Aponta para o objeto DOM real criado.
olmo: Nó | indefinido;
/**
* Existem duas situações para o atributo texto:
* 1. O seletor sel não está definido, indicando que o próprio nó é um nó de texto.
* 2. sel é definido, indicando que o conteúdo deste nó é um nó de texto.
*/
texto: string | indefinido;
// Usado para fornecer um identificador para o DOM existente, que deve ser único entre os elementos irmãos para evitar efetivamente operações de reconstrução desnecessárias.
chave: Chave | indefinida;
}
// Algumas configurações em vnode.data, ganchos de função de classe ou ciclo de vida, etc.
interfaceVNodeData{
adereços?: adereços;
atributos?: atributos;
aula?: Aulas;
estilo?: VNodeStyle;
conjunto de dados?: Conjunto de dados;
ligado?: ligado;
anexarData?: AttachData;
gancho?: Ganchos;
chave?: Chave;
ns?: string; // para SVGs
fn?: () => VNode; // para conversões
args?: any[]; // para conversões
is?: string; // para elementos personalizados v1
[key: string]: any; // para qualquer outro módulo de terceiros
} Por exemplo, defina um objeto vnode como este:
const vnode = h(
'p#contêiner',
{classe: {ativo: verdadeiro}},
[
h('span', { style: { fontWeight: 'bold' } }, 'Isso é negrito'),
'e este é apenas um texto normal'
]); Criamos objetos vnode através da função h(sel, b, c) . A implementação do código h() determina principalmente se os parâmetros b e c existem e os processa em dados e os filhos eventualmente estarão na forma de uma matriz. Finalmente, o formato do tipo VNode definido acima é retornado através da função vnode() .
Primeiro vamos pegar um diagrama de exemplo simples do processo em execução e primeiro ter um conceito geral do processo:

O processamento diferencial é o processo usado para calcular a diferença entre nós novos e antigos.
Vejamos um exemplo de código executado pelo Snabbdom:
import {
iniciar,
módulo de classe,
adereçosMódulo,
estiloMódulo,
eventListenersModule,
h,
} de 'snabbdom';
const patch = init([
// Inicializa a função de patch classModule passando o módulo, // Habilita a função de classes propsModule, // Suporta passagem de adereços
styleModule, // Suporta estilos embutidos e animação eventListenersModule, // Adiciona escuta de eventos]);
// <p id="container"></p>
const contêiner = document.getElementById('container');
const nó = h(
'p#container.duas.classes',
{em: {clique: someFn}},
[
h('span', { style: { fontWeight: 'bold' } }, 'Isso é negrito'),
'e este é apenas um texto normal',
h('a', { props: { href: '/foo' } }, "Vou levar você a alguns lugares!"),
]
);
// Passa um nó de elemento vazio.
patch(contêiner, vnode);
const novoVnode = h(
'p#container.duas.classes',
{ em: {clique: outroEventHandler } },
[
h(
'período',
{ estilo: { fontWeight: 'normal', fontStyle: 'itálico' } },
'Agora está em itálico'
),
'e este ainda é apenas um texto normal',
h('a', { props: { href: ''/bar' } }, "Vou levar você a alguns lugares!"),
]
);
// Chame patch() novamente para atualizar o nó antigo para o novo nó.
patch(vnode, newVnode); Como pode ser visto no diagrama do processo e no código de exemplo, o processo de execução do Snabbdom é descrito a seguir:
primeiro chame init() para inicialização, e os módulos a serem usados precisam ser configurados durante a inicialização. Por exemplo, classModule é usado para configurar o atributo de classe de elementos na forma de objetos; o módulo eventListenersModule é usado para configurar ouvintes de eventos, etc. A função patch() será retornada após init() ser chamado.
Crie o objeto vnode inicializado por meio da função h() , chame a função patch() para atualizá-lo e, finalmente, crie o objeto DOM real por meio de createElm() .
Quando uma atualização for necessária, crie um novo objeto vnode, chame patch() para atualizar e conclua a atualização diferencial deste nó e dos nós filhos por meio de patchVnode() e updateChildren() .
Snabbdom usa design de módulo para estender a atualização de propriedades relacionadas em vez de escrever tudo no código principal. Então, como isso é projetado e implementado? A seguir, vamos primeiro ao conteúdo central do design de Kangkang, Hooks – funções de ciclo de vida.
Snabbdom fornece uma série de funções de ciclo de vida ricas, também conhecidas como funções de gancho. Essas funções de ciclo de vida são aplicáveis em módulos ou podem ser definidas diretamente no vnode. Por exemplo, podemos definir a execução do gancho no vnode assim:
h('p.row', {
chave: 'minhaLinha',
gancho: {
inserir: (vnode) => {
console.log(vnode.elm.offsetHeight);
},
},
}); Todas as funções do ciclo de vida são declaradas da seguinte forma:
| nome | acionador nó | parâmetros de retorno de chamada |
|---|---|---|
pre | patch início da execução | nenhum |
init | vnode é adicionado | vnode |
create | um elemento DOM baseado em vnode é criado | emptyVnode, vnode |
insert | é inserido no DOM | vnode |
prepatch | é prestes a corrigir | oldVnode, vnode |
update | vnode foi atualizado | oldVnode, vnode |
postpatch | foi corrigido | oldVnode, vnode |
destroy | foi removido direta ou indiretamente | vnode |
remove | element removeu vnode do DOM | vnode, removeCallback |
post | removeCallback concluiu o processo de patch | nenhum |
que se aplica para o módulo: pre , create , update , destroy , remove , post . Aplicáveis às declarações vnode são: init , create , insert , prepatch , update , postpatch , destroy , remove .
Vejamos como Kangkang é implementado. Por exemplo, tomemos classModule como exemplo:
import { VNode, VNodeData } from "../vnode";
importar {Módulo} de "./module";
tipo de exportação Classes = Record<string, boolean>;
function updateClass(oldVnode: VNode, vnode: VNode): void {
// Aqui estão os detalhes da atualização do atributo de classe, ignore-o por enquanto.
// ...
}
export const classModule: Module = { create: updateClass, update: updateClass } Module pode ver que a última definição do módulo exportado é um objeto. A chave do objeto é o nome da função de gancho. da seguinte forma:
importar {
Pré-gancho,
CriarHook,
AtualizaçãoHook,
Destruir Gancho,
Remover Gancho,
PostHook,
} de "../hooks";
tipo de exportação Módulo = Parcial<{
pré: PréHook;
criar: CriarHook;
atualização: UpdateHook;
destruir: DestroyHook;
remover: RemoveHook;
postagem: PostHook;
}>; Partial em TS significa que os atributos de cada chave no objeto podem estar vazios. Ou seja, basta definir qual gancho você se preocupa na definição do módulo. Agora que o gancho está definido, como ele é executado no processo? A seguir vamos dar uma olhada na função init() :
// Quais são os ganchos que podem ser definidos no módulo.
ganchos const: Array<keyof Module> = [
"criar",
"atualizar",
"remover",
"destruir",
"pré",
"publicar",
];
função de exportação init(
módulos: Array<Partial<Módulo>>,
domApi?: DOMAPI,
opções?: Opções
) {
//A função hook definida no módulo será finalmente armazenada aqui.
const cbs: ModuleHooks = {
criar: [],
atualizar: [],
remover: [],
destruir: [],
pré: [],
publicar: [],
};
// ...
// Percorra os ganchos definidos no módulo e armazene-os juntos.
for (const gancho de ganchos) {
for (módulo const de módulos) {
const currentHook = módulo[hook];
if (currentHook! == indefinido) {
(cbs[hook] como qualquer[]).push(currentHook);
}
}
}
// ...
} Você pode ver que init() primeiro percorre cada módulo durante a execução e depois armazena a função de gancho no objeto cbs . Ao executar, você pode usar patch() :
export function init(
módulos: Array<Partial<Módulo>>,
domApi?: DOMAPI,
opções?: Opções
) {
// ...
função de retorno patch(
oldVnode: Elemento VNode |
vnode: VNode
): VNode {
// ...
// patch inicia, executa pre hook.
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
// ...
}
} Aqui tomamos o pre gancho como exemplo. O tempo de execução do pre gancho é quando o patch começa a ser executado. Você pode ver que patch() chama ciclicamente os ganchos pre relacionados armazenados em cbs no início da execução. As chamadas para outras funções de ciclo de vida são semelhantes a esta. Você pode ver as chamadas de função de ciclo de vida correspondentes em outras partes do código-fonte.
A ideia de design aqui é o padrão observador . Snabbdom implementa funções não essenciais distribuindo-as em módulos. Combinado com a definição do ciclo de vida, o módulo pode definir os ganchos nos quais está interessado. Então, quando init() é executado, ele é processado em objetos cbs para registrar esses ganchos; quando chegar o tempo de execução, chame Esses ganchos são usados para notificar o processamento do módulo. Isso separa o código principal e o código do módulo. A partir daqui, podemos ver que o padrão observador é um padrão comum para dissociação de código.
Em seguida, chegamos à função principal do Kangkang patch() . Esta função é retornada após a chamada init() . Sua função é montar e atualizar o VNode. A assinatura é a seguinte:
function patch(oldVnode: VNode | Element. DocumentFragment , vnode: VNode): VNode { |
// Por uma questão de simplicidade, não preste atenção em DocumentFragment.
// ...
} O parâmetro oldVnode é o elemento VNode ou DOM antigo ou fragmento de documento, e o parâmetro vnode é o objeto atualizado. Aqui posto diretamente uma descrição do processo:
chamar o pre hook cadastrado no módulo.
Se oldVnode for Element , ele será convertido em um objeto vnode vazio e elm será registrado no atributo.
O julgamento aqui é se é Element (oldVnode as any).nodeType === 1 é concluído. nodeType === 1 indica que é um ELEMENT_NODE, que é definido aqui.
Em seguida, determine se oldVnode e vnode são iguais. sameVnode() será chamado aqui para determinar:
function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
//Mesma chave.
const isSameKey = vnode1.key === vnode2.key;
// Componente Web, nome da tag do elemento personalizado, veja aqui:
// https://developer.mozilla.org/zh-CN/docs/Web/API/Document/createElement
const isSameIs = vnode1.data?.is === vnode2.data?.is;
//Mesmo seletor.
const isSameSel = vnode1.sel === vnode2.sel;
// Todos os três são iguais.
return isSameSel && isSameKey && isSameIs;
} patchVnode() para atualização de diferenças.createElm() para criar um novo nó DOM após a criação, insira o nó DOM e exclua o nó DOM antigo;Novos nós podem ser inseridos chamando a fila de ganchos insert registrada no objeto vnode envolvido na operação acima, patchVnode() createElm() . Quanto ao motivo disso ser feito, será mencionado em createElm() .
Por fim, o post hook registrado no módulo é chamado.
O processo consiste basicamente em fazer a comparação se os vnodes são iguais, e se forem diferentes, criar novos e deletar os antigos. A seguir, vamos dar uma olhada em como createElm() cria nós DOM.
createElm() cria um nó DOM baseado na configuração do vnode. O processo é o seguinte:
chame o gancho init que pode existir no objeto vnode.
A seguir trataremos de diversas situações:
se vnode.sel === '!' , este é o método usado pelo Snabbdom para deletar o nó original, para que um novo nó de comentário seja inserido. Como os nós antigos serão excluídos após createElm() , essa configuração pode atingir o objetivo de desinstalação.
Se a definição do seletor vnode.sel existir:
analise o seletor e obtenha id , tag e class .
Chame document.createElement() ou document.createElementNS para criar um nó DOM, registre-o em vnode.elm e defina id , tag e class com base nos resultados da etapa anterior.
Chame o gancho create no módulo.
Processe a matriz children :
se children for uma matriz, chame createElm() recursivamente para criar o nó filho e, em seguida, chame appendChild para montá-lo em vnode.elm .
Se children não for um array, mas vnode.text existir, significa que o conteúdo deste elemento é texto. Neste momento, createTextNode é chamado para criar um nó de texto e montado em vnode.elm .
Chame o gancho create no vnode. E adicione o gancho insert no vnode à fila de ganchos insert .
A situação restante é que vnode.sel não existe, indicando que o nó em si é texto, então chame createTextNode para criar um nó de texto e registre-o em vnode.elm .
Finalmente retorne vnode.elm .
Pode-se observar em todo o processo que createElm() escolhe como criar nós DOM com base nas diferentes configurações sel . Há um detalhe a ser adicionado aqui: a fila de ganchos insert mencionada em patch() . A razão pela qual esta fila de ganchos insert é necessária é que ela precisa esperar até que o DOM seja realmente inserido antes de executá-lo, e também precisa esperar até que todos os nós descendentes sejam inseridos, para que possamos calcular as informações de tamanho e posição do elemento na insert para ser preciso. Combinado com o processo de criação de nós filhos acima, createElm() é uma chamada recursiva para criar nós filhos, de modo que a fila registrará primeiro os nós filhos e depois a si mesma. Desta forma a ordem pode ser garantida ao executar a fila no final do patch() .
A seguir, vamos ver como o Snabbdom usa patchVnode() para fazer diff, que é o núcleo do DOM virtual. O fluxo de processamento de patchVnode() é o seguinte:
primeiro execute o gancho prepatch no vnode.
Se oldVnode e vnode forem a mesma referência de objeto, eles serão retornados diretamente sem processamento.
Chame ganchos update em módulos e vnodes.
Se vnode.text não estiver definido, vários casos de children serão tratados:
se oldVnode.children e vnode.children existirem e não forem iguais. Em seguida, chame updateChildren para atualizar.
vnode.children existe, mas oldVnode.children não existe. Se oldVnode.text existir, limpe-o primeiro e depois chame addVnodes para adicionar novo vnode.children .
vnode.children não existe, mas oldVnode.children existe. Chame removeVnodes para remover oldVnode.children .
Se nem oldVnode.children nem vnode.children existirem. Limpe oldVnode.text se existir.
Se vnode.text estiver definido e for diferente de oldVnode.text . Se oldVnode.children existir, chame removeVnodes para limpá-lo. Em seguida, defina o conteúdo do texto por meio de textContent .
Por fim, execute o gancho postpatch no vnode.
Pode-se observar no processo que as alterações nos atributos relacionados de seus próprios nós no diff, como class , style , etc., são atualizadas pelo módulo. pode dar uma olhada no código relacionado ao módulo. O principal processamento do diff é focado nos children . Em seguida, o Kangkang diff processa várias funções relacionadas dos children .
é muito simples. Primeiro chame createElm() para criá-lo e depois insira-o no pai correspondente.
destory remove
destory , esse gancho é chamado primeiro. A lógica é primeiro chamar o gancho no objeto vnode e depois chamar o gancho no módulo. Então esse gancho é chamado recursivamente em vnode.children nesta ordem.remove , este gancho só será acionado quando o elemento atual for excluído de seu pai. Os elementos filhos no elemento removido não serão acionados e este gancho será chamado no módulo e no objeto vnode. o módulo primeiro e depois chame o vnode. O que é mais especial é que o elemento não será realmente removido até que todas remove sejam chamadas. Isso pode atingir alguns requisitos de exclusão atrasada.Pode-se ver acima que a lógica de chamada desses dois ganchos é diferente. Em particular, remove só será chamado em elementos que estão diretamente separados do pai.
updateChildren() é usado para processar a diferença do nó filho e também é uma função relativamente complexa no Snabbdom. A ideia geral é definir um total de quatro ponteiros iniciais e finais para oldCh e newCh . Esses quatro ponteiros são oldStartIdx , oldEndIdx , newStartIdx e newEndIdx respectivamente. Em seguida, compare as duas matrizes no while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) para encontrar as mesmas partes para reutilização e atualização e mova até um par de ponteiros para cada comparação. O processo de travessia detalhado é processado na seguinte ordem:
Se algum dos quatro ponteiros apontar para vnode == null, então o ponteiro se move para o meio, como: start++ ou end--, a ocorrência de null será explicada posteriormente.
Se os nós iniciais antigos e novos forem iguais, ou seja, sameVnode(oldStartVnode, newStartVnode) retornar verdadeiro, use patchVnode() para realizar a comparação e ambos os nós iniciais se moverão um passo em direção ao meio.
Se os nós finais antigos e novos forem iguais, patchVnode() também será usado e os dois nós finais retrocederão um passo para o meio.
Se o nó inicial antigo for igual ao novo nó final, use patchVnode() para processar a atualização primeiro. Em seguida, o nó DOM correspondente a oldStart precisa ser movido. A estratégia de movimentação é mover-se antes do próximo nó irmão do nó DOM correspondente a oldEndVnode . Por que ele se move assim? Em primeiro lugar, oldStart é igual a newEnd, o que significa que no processamento do loop atual, o nó inicial do array antigo é movido para a direita porque cada processamento move os ponteiros inicial e final para o meio, estamos atualizando o; array antigo para o novo. Neste momento, oldEnd pode não ter sido processado ainda, mas neste momento oldStart foi determinado como o último no processamento atual do novo array, portanto, é razoável passar para o próximo irmão. nó de oldEnd. Após a conclusão da movimentação, oldStart++ e newEnd-- movem uma etapa para o meio de seus respectivos arrays.
Se o nó final antigo for igual ao novo nó inicial, patchVnode() será usado para processar a atualização primeiro e, em seguida, o nó DOM correspondente a oldEnd será movido para o nó DOM correspondente a oldStartVnode . igual ao passo anterior. Após a conclusão da movimentação, oldEnd--, newStart++.
Se nenhuma das opções acima for o caso, use a chave newStartVnode para encontrar o subscrito idx em oldChildren . Existem duas lógicas de processamento diferentes dependendo da existência do subscrito:
Se o subscrito não existir, significa que newStartVnode foi criado recentemente. Crie um novo DOM através de createElm() e insira-o antes do DOM correspondente a oldStartVnode .
Se o subscrito existir, ele será tratado em dois casos:
se o sel dos dois vnodes for diferente, ainda será considerado como recém-criado, crie um novo DOM através de createElm() e insira-o antes do DOM correspondente a oldStartVnode .
Se sel for o mesmo, a atualização será processada por meio de patchVnode() e o vnode correspondente ao subscrito de oldChildren será definido como indefinido. É por isso que == null aparece na travessia de ponteiro duplo anterior. Em seguida, insira o nó atualizado no DOM correspondente a oldStartVnode .
Após a conclusão das operações acima, newStart++.
Após a conclusão da travessia, ainda há duas situações a serem resolvidas. Uma é que oldCh foi completamente processado, mas ainda há novos nós em newCh e um novo DOM precisa ser criado para cada newCh restante; a outra é que newCh foi completamente processado e ainda há nós antigos em oldCh ; Nós redundantes precisam ser removidos. As duas situações são tratadas da seguinte forma:
function updateChildren(
parentElm: Nó,
canal antigo: VNode[],
novoCh: VNode[],
inseridoVnodeQueue: VNodeQueue
) {
// Processo de passagem de ponteiro duplo.
// ...
// Existem novos nós em newCh que precisam ser criados.
if (newStartIdx <= newEndIdx) {
//Precisa ser inserido antes do último newEndIdx processado.
antes = newCh[newEndIdx + 1] == nulo? nulo: newCh[newEndIdx + 1].elm;
adicionarVnodes(
paiElm,
antes,
novoCh,
novoStartIdx,
novoEndIdx,
inseridoVnodeQueue
);
}
// Ainda existem nós antigos em oldCh que precisam ser removidos.
if (oldStartIdx <= oldEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
} Vamos usar um exemplo prático para observar o processo de processamento de updateChildren() :
o estado inicial é o seguinte, a matriz do nó filho antigo é [A, B, C] e a nova matriz do nó é [B, A, C , D]:

Na primeira rodada de comparação, os nós inicial e final são diferentes, então verificamos se newStartVnode existe no nó antigo e encontramos a posição de oldCh[1]. Em seguida, executamos patchVnode() para atualizar primeiro e, em seguida, definimos oldCh[1]. ] = undefined e insira o DOM antes de oldStartVnode , newStartIdx retrocede um passo e o status após o processamento é o seguinte:

Na segunda rodada de comparação, oldStartVnode e newStartVnode são iguais. Quando patchVnode() é executado para atualização, oldStartIdx e newStartIdx passam para o meio. Após o processamento, o status é o seguinte:

Na terceira rodada de comparação, oldStartVnode == null , oldStartIdx passa para o meio e o status é atualizado da seguinte forma:

Na quarta rodada de comparação, oldStartVnode e newStartVnode são iguais. Quando patchVnode() é executado para atualização, oldStartIdx e newStartIdx passam para o meio. Após o processamento, o status é o seguinte:

Neste momento, oldStartIdx é maior que oldEndIdx e o loop termina. Neste momento, ainda existem novos nós que não foram processados em newCh e você precisa chamar addVnodes() para inseri-los.

, o conteúdo principal do DOM virtual foi resolvido aqui. Acho que os princípios de design e implementação do Snabbdom são muito bons. Se você tiver tempo, poderá consultar os detalhes do código-fonte do Kangkang para dar uma olhada mais de perto. vale a pena aprender ideias.