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.