Aguarde...

30 de agosto de 2024

Implementando React do Zero

Implementando React do Zero

Meu objetivo aqui é percorrer meu processo de construção do React do zero, espero que lhe dê uma intuição do porquê as coisas se comportam da maneira que se comportam no React. Há muitos casos em que o React vaza sua abstração na interface, então aprender como os internos podem ser implementados é extremamente útil para entender a motivação por trás desses designs de interface.

Mas, não estou tentando seguir a mesma implementação que a equipe do React fez. Eu nem conhecia a arquitetura interna antes de codificar isso. Apenas conceitos de alto nível, como doms virtuais e reconciliação.

Isso também não é suposto ser uma implementação ótima. Há várias otimizações muito impressionantes que o React implementa que eu não tentarei – como renderização simultânea/cancelável.

O que eu quero fazer aqui é:

  • Modelo de renderização principal (os componentes na árvore de componentes devem ser renderizados novamente a mesma quantidade de vezes entre as implementações)
  • A saída deve ter a mesma aparência, dada a mesma entrada
  • Os ganchos principais são implementados (useState, useRef, useEffect, useContext, useMemo, useCallback)
  • Atualizações precisas do Dom

Renderizando algo na tela

Vamos começar com o primeiro objetivo, renderizar algo na tela, usando a API do React. O React é tradicionalmente escrito por meio de jsx, uma sintaxe semelhante a html para instanciar componentes. Mas, a biblioteca real não tem ideia dessa representação. Toda a sintaxe jsx é transformada em chamadas de função.

Por exemplo, o seguinte trecho:

< div  id = " pai " > < span > olá </ span > </ div >
  

Será transformado em:

Reagir . createElement ( "div" ,  { id : "pai"  } , Reagir . createElement ( "span" ,  nulo ,  "olá" ) )

Não vou explicar como essa transformação acontece, pois este artigo não é sobre analisadores :).

Para começar, vamos criar nosso primeiro exemplo para o qual criaremos uma implementação mínima:

const  App  =  ( )  =>  { 
	return React . createElement ( "div" ,  { innerText :  "olá mundo"  } , React . createElement ( "span" ,  { innerText :  "filho"  } ) ) 
}

A primeira coisa imediatamente óbvia é que temos uma estrutura semelhante a uma árvore/recursiva para construir nossa hierarquia de visualização – createElementaceita parâmetros rest, onde os parâmetros são o tipo de retorno do elemento create

Então, vamos em frente e modelar essa hierarquia de visualização e o tipo de entrada de createElement.

// o que createElement aceitará como entrada 
export  type  ReactComponentExternalMetadata < T  extends AnyProps >  =  { 
  component :  keyof HTMLElementTagNameMap | ReactComponentFunction < T > ; 
  props :  T ; 
  children :  Array < ReactComponentInternalMetadata > ; 
} ;

// representação interna dos metadados do componente para facilitar o processamento 
export  type  TagComponent  =  { 
  kind :  "tag" ; 
  tagName :  keyof HTMLElementTagNameMap ; 
} ;

exportar  tipo  FunctionalComponent  =  { 
  tipo :  "função" ; 
  nome :  string ; 
  função :  ( 
    adereços : Registro < string ,  desconhecido >  |  nulo 
  )  => ReactComponentInternalMetadata ; 
} ;

 tipo  de exportação ReactComponentInternalMetadata  =  { 
  id :  string ; 
  componente : TagComponent | FunctionalComponent ; 
  adereços : AnyProps ; 
  filhos :  Array < ReactComponentInternalMetadata > ; 
} ;

E então podemos converter trivialmente a entrada de createElement em nossa representação interna:

exportar  const createElement =  < T  estende  AnyProps > (
  componente: ReactComponentExternalMetadata<T>["componente"],
  adereços: T,
  ...crianças: Array<ReactComponentInternalMetadata>
): ReactComponentInternalMetadata => ( { 
  component :  mapComponentToTaggedUnion ( externalMetadata . component ) ,  // impl deixado como um exercício para o leitor 
  children : externalMetadata . children , 
  props : externalMetadata . props , 
  id : crypto . randomUUID ( ) , 
} );

Agora que temos uma representação da hierarquia de visualizações, podemos aplicá-la com sucesso ao DOM com o seguinte código:

const  applyComponentsToDom  =  ( metadados : ReactComponentInternalMetadata , parentElement : HTMLElement |  null )  =>  { 
    if  ( metadados . component . kind ===  "tag" )  { 
        const element = document . createElement ( metadados . component . tagName ) ; 
        Object . assign ( elemento , metadados . props ) ; 
        parentElement ?. appendChild ( elemento ) ; 
        metadata . childNodes . forEach ( childNode =>  appendTagsToDOM ( childNode , elemento ) ) ; 
    }  else  { 
		throw  Error ( "Não implementado" ) 
	} 
}

Atualmente, applyComponentsToDomsó atravessa elementos de tag. Isso ocorre porque a árvore de elementos de tag é avaliada ansiosamente, pois são apenas strings passadas para createElement, então não há esforço necessário para gerar a árvore que atravessamos. Ela já está construída para nós.

Mas, se quisermos começar a renderizar componentes funcionais e compor diferentes componentes funcionais juntos, não teremos mais uma árvore avaliada ansiosamente. Ela se parecerá mais com:

Onde cada função precisa ser executada manualmente para avaliar o restante da árvore.

Outra maneira de dizer isso é que a propriedade “children” nos metadados do componente não representa mais uma estrutura válida em forma de árvore que podemos percorrer trivialmente. Teremos que usar as informações da tag HTML, junto com a função associada ao componente, para gerar uma árvore completa que podemos percorrer.

Para fazer isso, vamos modelar como será um nó nesta árvore que precisamos construir

tipo  ReactViewTreeNode  =  { 
  id :  string ; 
  childNodes :  Matriz < ReactViewTreeNode > ; 
  metadados : ReactComponentInternalMetadata ; 
} ;

Com este tipo de árvore, teremos explicitamente nós totalmente executados mantidos dentro da propriedade “childNodes”

Nosso próximo desafio será realmente gerar esta árvore de visualização, dados os metadados dos componentes raiz. O componente raiz seria aquele passado para

ReactDom . render ( < App  /> , document . getElementById ( "root" ) ! ) ;

Nesta árvore que estamos tentando criar, a hierarquia pai-filho é determinada pela propriedade “children” nos metadados do componente.

Para criar uma função que possa transformar os metadados internos preguiçosos em uma árvore de visualização completa, precisaremos fazer o seguinte:

  • crie um novo nó para cada metadado interno (que será retornado no final da função)
  • se os metadados representam uma função, execute a função, percorra recursivamente sua saída e anexe o resultado da chamada recursiva aos nós filhos do novo nó
    • Isso nos dá a árvore totalmente executada sob a função (no máximo como 1 filho)
  • Se os metadados representam uma tag, obtenha o nó de visualização para todos os metadados dos filhos chamando generateViewTree recursivamente. Defina essa saída como os nós filhos do novo nó criado para os metadados da tag
    • Isso nos dá a árvore totalmente executada sob o nó da tag (pode ter muitos filhos)

O que isso parece no código é:

const generateViewTree =  ( { 
  internalMetadata , 
} :  { 
  internalMetadata : ReactComponentInternalMetadata ; 
} ) : ReactViewTreeNode =>  { 
  const newNode : ReactViewTreeNode =  { 
    id : crypto . randomUUID ( ) , 
    metadados : internalMetadata , 
    childNodes :  [ ] , 
  } ;

  switch  ( internalMetadata . component . kind )  { 
    case  "function" :  { 
      const outputInternalMetadata = internalMetadata . component . function ( { 
        ... internalMetadata . props , 
        children : internalMetadata . children , 
      } ) ;

      const subViewTree =  generateViewTree ( { 
        internalMetadata : nextNodeToProcess , 
      } ) ;

      newNode . childNodes . push ( subViewTree ) ; 
      quebrar ; 
    } 
    case  "tag" :  { 
      newNode . childNodes = renderNode . internalMetadata . children . map (
        gerarViewTree
      ) ; 
      quebrar ; 
    } 
  }

  retornar novoNode ; 
} ;

E então precisamos atualizar nossa applyComponentsToDomfunção para que ela percorra essa nova árvore

const  applyComponentsToDom  =  ( 
  viewNode : ReactViewNode , 
  parentElement : HTMLElement |  null 
)  =>  { 
  switch  ( viewNode . internalMetadata . kind )  { 
    case  "tag" :  { 
      const element = document . createElement ( 
        viewNode . metadata . component . tagName
       ) ; 
      Object . assign ( element , viewNode . metadata . props ) ; 
      parentElement ?. appendChild ( element ) ; 
      viewNode . metadata . childNodes . forEach ( ( childNode )  => 
        appendTagsToDOM ( childNode , element ) 
      ) ; 
      break ; 
    } 
    case  "function" :  { 
      applyComponentsToDom ( viewNode . childNodes [ 0 ] ) ;  // um componente funcional tem no máximo 1 filho, pois cada elemento deve ter um pai quando retornado 
    } 
  } 
} ;

Isso nos leva longe o suficiente para aplicar a view tree que geramos ao dom, permitindo-nos compor componentes funcionais juntos. Nosso próximo objetivo será adicionar alguma interatividade a esses componentes

useEstado

A vinculação de estado a um componente no React é usada usando o gancho useState.

A primeira vez useStateque é chamado em um componente, ele vinculará o estado ao componente em que foi chamado e associará o estado criado com essa chamada de hook específica. Permitindo que o componente leia o valor mais recente do estado em renderizações subsequentes.

A parte difícil desse processo é:

  1. Como useStatesaber de qual instância do componente ele está sendo chamado?
  2. Se houver vários useState‘s’ em um componente, como ele se lembra entre as renderizações de qual estado ele está associado?
const  Component  =  ( )  =>  { 
  const  [ a , setA ]  =  useState ( 0 ) ;  // como ele sabe que foi chamado dentro do Component? 
  const  [ b , setB ]  =  useState ( "b" ) ;  // como ele saberá que deve retornar "b" na próxima renderização e não 0? 
} ;

Para resolver o primeiro problema, não é totalmente difícil. Precisamos apenas rastrear globalmente qual componente estamos chamando quando percorremos os metadados internos do componente para gerar a árvore de visualização. Antes de chamá-lo, atualizamos algum objeto disponível globalmente para manter os metadados desse componente. Então, dentro da useStatedefinição, ele pode ler essa variável global e saber de qual componente ela está sendo chamada.

Agora, para o problema nº 2. Já sabemos como o React implementa isso sem olhar para o código. Uma grande regra do React é que os hooks não podem ser chamados condicionalmente. Mas por que isso? É porque o React usa a ordem em que os hooks são chamados para determinar a igualdade de chamadas de hooks entre renderizações.

Se o React afirma que nenhum hook pode ser chamado condicionalmente e diz que há um componente que fez as seguintes chamadas de hook:

usarEstado ( ) ; 
usarEstado ( ) ; 
usarEfeito ( ) ; 
usarEstado ( ) ;

e então na próxima renderização ele fez as seguintes chamadas de hook:

usarEstado ( ) ; 
usarEstado ( ) ; 
usarEfeito ( ) ; 
usarEstado ( ) ;

Podemos dizer trivialmente que as i’ésimas chamadas de gancho em cada renderização estão associadas entre si, onde i = o índice do gancho na ordem em que foi invocado.

Podemos rastrear a ordem atual de uma chamada de gancho incrementando um contador acessível globalmente toda vez que ele for chamado dentro da definição do gancho e também lendo o valor do contador quando ele for chamado.

Em código pseudo ficaria assim

deixe currentHookOrder =  0 ;

const  useState  =  ( )  =>  { 
  let useStateHookOrder = currentHookOrder ; 
  currentHookOrder +=  1 ; 
  // faça algumas coisas 
} ;

Uma maneira de usarmos essas informações é mantendo uma matriz de “estado de gancho” em nosso nó de visualização de componentes. Onde o item na i’ésima posição pertence ao gancho chamado i’ésima vez naquele componente.

Como o componente de renderização atual é acessível globalmente, podemos acessar seu array de hooks a partir da useStatedefinição da função e, então, indexá-lo usando nosso contador global. Se o hook estiver sendo executado pela primeira vez, simplesmente enviamos o estado inicial para esse array.

Então a tupla retornada por useState é:

  • o valor armazenado na i’ésima posição na matriz de estados do gancho
  • um fechamento que tem a capacidade de mutar essa matriz de ganchos com um valor fornecido e disparar uma nova renderização do componente que definiu o estado (capturando o componente de renderização atual no fechamento)

Isso seria algo como

const  useState  =  ( initialState )  =>  { 
  const currentlyRenderingComponent = 
    someGlobalObject . currentlyRenderingComponent ; 
  const useStateHookOrder = currentHookOrder ; 
  currentHookOrder +=  1 ; 
  if  ( ! currentlyRenderingComponent . hasRendered )  { 
    currentlyRenderingComponent . hookState . push ( initialState ) ; 
  } 
  const state = currentlyRenderingComponent . hookState [ currentHookOrder ] ; 
  return  [ 
    state , 
    ( value )  =>  { 
      currentlyRenderingComponent . hookState [ useStateHookOrder ]  = value ; 
      triggerReRender ( currentlyRenderingComponent ) ;  // TODO 
    } , 
  ] ; 
} ;

Agora que definimos nosso primeiro hook, temos uma maneira legal de definir o que é um hook. É apenas uma função, mas é uma função que depende de informações vinculadas a um componente.

Re-renderizando um componente

Agora vamos passar para o nosso próximo objetivo: implementar de fatotriggerReRender()

No final, terá 3 etapas:

  1. regenerar a árvore de visualização, começando pelo currentlyRenderingComponent capturado no fechamento do estado definido
  2. uma vez que a subárvore de visualização é gerada, aplique patch na árvore de visualização existente (que armazenamos em um objeto global) para usar a subárvore recém-gerada, transferindo seu estado para a nova árvore de visualização
  3. Dada a nova árvore de visualização corrigida, atualize o dom

O passo 1 não será tão desafiador. A generateViewTreefunção que mostrei acima é uma função pura e não opera de forma diferente se for passada a raiz da árvore ou uma subárvore que faz parte de uma árvore maior. Então podemos simplesmente passar a variável capturada no closure retornado useState currentlyRenderingComponent– e obter nossa nova árvore de visualização, renderizando novamente todos os filhos automaticamente. Como mutamos o array de estado do hook antes de renderizar novamente os filhos, eles lerão o valor mais novo passado para a função set state.

Agora podemos passar para a etapa 2, corrigindo a árvore de visualização existente. Isso também é bem simples. Precisamos apenas fazer uma travessia da árvore de visualização existente para encontrar o pai dos componentes de renderização atuais e, em seguida, substituir o nó anterior pelo nó recém-gerado. Para transferir o estado, podemos simplesmente copiar o estado dos componentes para o novo nó da árvore (essa não é a maneira correta de fazer isso, abordaremos a maneira correta mais tarde).

Então, usando essa view tree corrigida, atualizaremos o dom de uma maneira hilariamente ineficiente e, depois, voltaremos e faremos uma implementação mais eficiente. Vamos derrubar o DOM inteiro começando pela raiz do nosso aplicativo React e, então, reconstruí-lo usando nossa nova view tree. Já temos uma função que pode aplicar a view tree inteira ao dom, dado um elemento root dom, então podemos reutilizá-la.

Essas 3 etapas da função juntas teriam a seguinte aparência:

const  triggerReRender  =  ( 
  capturadoAtualmenteRenderingRenderNode : ReactViewTreeNode
 )  = >  { 
  const novaÁrvoreDeVisualização =  gerarÁrvoreDeVisualização ( capturadoAtualmenteRenderingRenderNode ) ; 
  const paiNó =  encontrarNóParente ( capturadoAtualmenteRenderingRenderNode ) ; 
  substituirNó ( { 
    pai : NóParente , 
    NóAntigo : NóRecuperadoAtualmenteRenderingRenderNode , 
    novoNó : novaÁrvoreDeVisualização , 
  } ) ; 
  enquanto  ( globalState.roomDomNode.primeiroFilho ) { removerÁrvoreDeVisualização ( nó.primeiroFilho ) ; }​​​​​ 
    
  

  applyComponentsToDom ( novaÁrvoreDeVisualização , globalState . roomDomNode ) ; 
} ;

E isso é tudo o que precisamos para uma estratégia de re-renderização incrivelmente ineficiente.

Agora que podemos renderizar novamente os componentes, quero revisitar um dos nossos objetivos originais:

  • os componentes na árvore de componentes devem ser renderizados novamente a mesma quantidade de vezes entre as implementações

Se nossa view tree for construída corretamente, isso deve ser verdade. Quando um nó pai muda no react, ele incondicionalmente re-renderiza seus filhos por padrão.

Mas, para o código a seguir, o que esperamos que seja o relacionamento pai-filho em termos de renderização (usando JSX aqui para resumir):

const  ComponentA  =  ( { filhos } )  =>  { 
  const  [ contagem , setCount ]  =  useState ( 0 ) ; 
  return  < div > { filhos } </ div > ; 
} ; 
const  ComponentB  =  ( )  =>  { 
  return  < div > Eu sou o componente B </ div > ; 
} ; 
const  App  =  ( )  =>  { 
  return  ( 
    < ComponentA > < ComponentB /> </ ComponentA > ) ; } ;
       
    
  

Outra maneira de fazer essa pergunta é, se ComponentAre-renderiza, deve ComponentBre-renderizar? Vamos dar uma olhada em como seria nossa árvore de visualização atual:

Isso implica que ComponentAa re-renderização deve re-renderizar ComponentB. Mas precisa? ComponentBnunca aceitará nenhum props de ComponentA, pois foi criado em App.

Se dissermos que a possibilidade de um componente aceitar props de outro componente cria uma dependência entre os dois componentes, uma árvore de dependência para o aplicativo React acima ficaria assim:

Observe que no meu código atual no GitHub, eu me refiro à árvore de dependências como “RenderTree”

Esta árvore de dependência está mais alinhada com a forma como o react determina a re-renderização de componentes. Isso significa que temos um bug, pois nossa implementação seria re-renderizada ComponentBquando um irmão (na árvore de dependência) fosse alterado, porque era um filho na árvore de visualização.

Agora temos 2 representações de árvore que precisamos referenciar para renderizar novamente um componente corretamente. Uma que determina como o DOM deve ficar, e a outra que determina quando os componentes precisam renderizar novamente.

A única informação que precisamos para construir essa árvore de dependência é saber em qual componente um dado componente foi chamado. O componente em que algum dado componente foi chamado será marcado como pai. Isso funciona porque depois que você chama createElement, não há como atualizar as propriedades dos elementos:

const elemento =  createElement ( SomeComponent ,  { someProp :  2  } ) ;

return  < div > { element } </ div >  // não há como passar props para um elemento já criado

Então, tudo o que temos que fazer é rastrear em qual componente a createElementchamada aconteceu para construir uma árvore de dependência válida. Podemos fazer isso sem muito esforço:

  • Antes de um componente ser renderizado, crie um nó de árvore de dependência para ele e armazene-o globalmente para que createElementseja possível acessá-lo (essencialmente o mesmo que o componente renderizado atualmente mencionado anteriormente)
  • Para cada createElementchamada, envie um novo nó de renderização, representando o componente para o qual um elemento foi criado, como um filho do nó de renderização acessível globalmente

Nota: Esta estratégia é apenas próxima do que acabei fazendo. Acabei implementando um método mais indireto para que funcionasse bem com meu código existente. A ideia geral do que realmente fiz foi:

  • pegue os metadados internos de saída de um componente (uma estrutura semelhante a uma árvore) e achate-os em uma matriz
  • Se algum nó dentro do array já estiver na árvore de dependências, ele deve ter sido chamado anteriormente, então filtre-o. Isso funciona porque o primeiro valor de retorno em que o elemento está deve ser o componente em que ele foi criado.

Usando essa estratégia, podemos construir a árvore de dependências que estávamos procurando. No entanto, como não a estamos usando para nada, nossos componentes ainda são renderizados incorretamente.

Precisaremos usar esta árvore de dependências ao percorrer a árvore preguiçosa retornada por um componente:

Uma maneira simples de usar a árvore de dependências é, ao gerar a árvore de visualização, renderizar novamente um componente somente se ele depender do componente que acionou a renderização novamente (ou se ele nunca tiver sido renderizado antes).

Se um componente não for dependente do nó que acionou a renderização e não for a primeira renderização, podemos pular a renderização desse componente e usar a saída da árvore de visualização anterior desse componente, basicamente armazenando a saída em cache.

Se você quiser ver no código como isso se parece, aqui está a implementação real que usa essa estratégia

Reconciliando nós de visualização entre renderizações

Nosso modelo de renderização ainda está quebrado. Como mencionado anteriormente, a maneira como transferimos estado entre renderizações está muito errada!

Anteriormente, para garantir que o estado não fosse perdido entre as novas renderizações, apenas copiávamos o estado apenas para o componente que acionou a nova renderização.

Mas e quanto a todos os outros componentes? Todos eles terão estados reinicializados, sem lembrar de nada da re-renderização anterior.

Este é um problema bem difícil. Temos apenas a representação em tempo de execução de como a árvore “parece”. Não temos um compilador que execute o código do usuário que nos diga quais componentes são iguais

O que temos que fazer é determinar a igualdade entre as duas árvores para cada nó na árvore, não apenas para o nó raiz trivial.

Vamos ver como podemos fazer isso como um humano. Para o exemplo a seguir, como você determinaria a igualdade entre nós, se tivesse que tomar uma decisão:

Acho que a resposta é bem fácil aqui: basta combinar os nós na mesma posição:

E como definimos a posição programaticamente? Podemos simplesmente dizer o caminho para chegar ao nó

Quando um nó tem o mesmo caminho de índice entre árvores de visualização, podemos transferir o estado da árvore anterior. Se houver um novo caminho que não podemos mapear (o que implica que é um novo nó), não transferimos nenhum estado e deixamos que ele se inicialize.

É por isso que o React é tão louco em fornecer chaves ao renderizar listas. Se os nós forem reordenados, ele determinará incorretamente a igualdade entre os nós, e o estado será atribuído incorretamente aos componentes.

Elementos condicionais

Elementos condicionais são uma parte essencial da funcionalidade do react. Como você está apenas escrevendo uma função javascript que retorna elementos react, você pode, é claro, retornar elementos condicionalmente:

retornar  <div> { condição ?​​ < ComponentA /> : < ComponentB /> } </ div > ;       

Ou você pode renderizar um elemento condicionalmente

return  < div > { condição && < ComponentA /> } </ div > ;    

Onde quando a condição é false | null | undefinedreativada não renderá nada ao dom em seu lugar.

Nossa implementação já lida com o primeiro caso automaticamente – como os nomes são diferentes, determinaríamos corretamente a inicialização ComponentB(se os componentes fossem ambos ComponentA, nossa implementação os consideraria iguais, mas o React também o faria se você não fornecesse chaves).

Em relação ao segundo caso, atualmente, nossa createElementfunção não recebe null | false | undefinedvalores como filhos. E é isso que o render condicional compila:

retornar React . createElement ( 
  "div" , 
  nulo , 
  condição && React . createElement ( ComponentA ,  nulo ) 
) ;

Então precisamos atualizar createElementpara permitir null | false | undefinede lidar com isso de alguma forma.

A solução mais simples seria filtrar todos false | null | undefinedos valores. Então, sempre que um for retornado, nenhum nó será criado para o valor, e ele é aparentemente deletado como esperávamos?

Mas não podemos fazer isso. Imagine as seguintes árvores geradas entre 2 renderizações, onde o nó no caminho [0] é renderizado condicionalmente:

Se, na segunda renderização, [0] fosse substituído por false | undefined | null, poderíamos omitir a criação de um nó para ele. Mas então o nó em [1] deslizaria de volta para a posição [0], fazendo com que parecesse o [0]’ésimo nó, e o mesmo vale para seus filhos.

Mas isso obviamente seria errado, e significaria que teríamos que exigir chaves para componentes sempre que o usuário quisesse renderizá-los condicionalmente. O que o React não pede que os usuários façam.

Em vez disso, poderíamos representar false | undefined | nullcomo um slot vazio em nossa árvore. Onde false | null | undefinedcriar elementos react válidos para colocar na árvore. Eles simplesmente não têm metadados e nem filhos. Dessa forma, nossa árvore será estável entre renderizações:

Sempre que tentamos renderizar um slot vazio, simplesmente o pulamos. Ele não tem filhos e não pode gerar saída. Todos os nossos nós ainda existem na mesma posição, então podemos transferir corretamente o estado entre renderizações.

Atualizações de DOM eficientes

Atualmente, derrubamos o dom inteiro toda vez que algo é renderizado novamente. Obviamente, o React não faz isso. Ele só atualizará os nós do dom necessários.

Já vimos como podemos determinar igualdade entre árvores – o caminho para o nó. Essa estratégia será muito útil para atualizar o dom.

Compararemos as árvores de visualização novas e antigas após renderizar novamente um componente. Conforme encontramos nós de tag que têm caminhos de índice correspondentes, passamos os props do novo nó de visualização diretamente para a tag HTML. O React tem uma abstração leve disso com seu sistema de eventos sintéticos. Mas essencialmente faz a mesma coisa.

Se um novo nó não tiver nenhum nó correspondente na árvore anterior, ele deverá ser um novo nó, e criaremos um novo elemento HTML para ele.

Se um nó antigo não tiver nenhum nó correspondente na árvore anterior, ele não deverá mais existir, então excluímos o elemento HTML existente para ele.

Isso é realmente tudo o que é necessário para ter atualizações de dom bem eficientes. O código real que escrevi para isso parece um pouco mais complicado do que o que descrevi, já que há alguma contabilidade necessária por causa de elementos condicionais (slots vazios).

Se um nó da view tree se tornar um slot vazio entre renderizações, temos que excluir o nó dom associado. E se o slot vazio se tornar um elemento real, temos que inseri-lo no dom na posição correta.

Mais ganchos

Agora que nosso modelo de renderização principal está quase pronto, podemos implementar alguns ganchos divertidos

usarRef

Este é um hook extremamente fácil de implementar. Ele simplesmente vincula uma referência imutável ao seu componente, permitindo que você altere o conteúdo na referência arbitrariamente, sem causar uma nova renderização.

Para adaptar o código de exemplo para useState, seria algo como

const  useRef  =  ( initialState )  =>  { 
  const currentlyRenderingComponent = 
    someGlobalObject . currentlyRenderingComponent ; 
  const useRefHookOrder = currentHookOrder ; 
  currentHookOrder +=  1 ; 
  if  ( ! currentlyRenderingComponent . hasRendered )  { 
    currentlyRenderingComponent . hookState . push ( 
      Object . seal ( { current : initialState } ) 
    ) ; 
  } 
  const ref = currentlyRenderingComponent . hookState [ useRefHookOrder ] ; 
  return ref ; 
} ;

A parte principal que falta é que não há gatilho de re-renderização. Caso contrário, ainda é um dado vinculado ao componente e age de forma muito similar ao estado retornado por useState.

useEfeito

Agora vamos para useEffectuseEffecttem 3 componentes principais:

  • O efeito de retorno de chamada
  • As dependências do efeito
  • O efeito de limpeza

Toda vez que as dependências mudam ou o componente é montado pela primeira vez, o callback do efeito é chamado. Se o callback do efeito retornar uma função, ela será chamada antes do próximo efeito, agindo como uma função de limpeza para qualquer lógica de configuração realizada no callback do efeito.

O hook useEffect também é bastante simples de implementar, especialmente por causa de quão complexo ele pode parecer em algumas bases de código.

Seguimos um processo semelhante ao useRef e useState: lemos o componente atualmente renderizado, indexamos em seus ganchos e o inicializamos se o componente ainda não tiver sido renderizado.

Mas temos um passo extra. Se as dependências mudaram em comparação com a renderização anterior, medida com igualdade superficial (===), então atualizamos o callback de efeito do estado do hook e suas dependências. Se as dependências mudarem, então o callback anterior tem um closure sobre valores obsoletos, então precisamos do closure recém-calculado (lembre-se, um closure é apenas um objeto especial, e queremos o mais recente).

export  const  useEffect  =  ( cb :  ( )  =>  void , deps :  Array < desconhecido > )  =>  { 
  const useEffectHookOrder = currentHookOrder ; 
  currentHookOrder +=  1 ;

  se  ( ! atualmenteRenderizando . hasRendered )  { 
    atualmenteRenderizando . hooks . push ( { 
      cb , 
      deps , 
      cleanup :  null , 
      kind :  "efeito" 
    } ) ; 
  } 
  const efeito = atualmenteRenderizando . hooks [ currentStateOrder ] ;

  se  ( 
    efeito . deps . comprimento !== deps . comprimento || 
    ! efeito . deps . cada ( ( dep , índice )  =>  { 
      const novoDep = deps [ índice ] ; 
      retornar novoDep === dep ; 
    } ) 
  )  { 
    efeito . deps = deps ; 
    efeito . cb = cb ; 
  } 
} ;

Terminamos de configurar os efeitos para os componentes, mas esta definição de função não lida com a chamada do efeito ou limpeza do efeito. Isso acontece depois que a renderização é concluída.

Não é de surpreender que o local real no código onde chamaremos os efeitos seja depois de chamarmos a função de renderização do componente.

Teremos que mapear todos os efeitos que o componente contém, verificar se as dependências mudaram em comparação à renderização anterior e, se isso aconteceu:

  • chame a limpeza do efeito se houver uma
  • chame o efeito
  • se o efeito retornar um retorno de chamada, defina-o como a nova função de limpeza no estado dos ganchos

E este é o código exato necessário para implementar isso:

const outputInternalMetadata = internalMetadata . componente . função ( { 
  ... renderNode . internalMetadata . props , 
  filhos : internalMetadata . filhos , 
} ) ; 
const currentRenderEffects = outputInternalMetadata . ganchos . filtro ( 
  ( gancho )  => gancho . tipo ===  "efeito" 
) ;

currentRenderEffects . forEach ( ( efeito , índice )  =>  { 
  const didDepsChange = Utils . run ( ( )  =>  { 
    if  ( ! hasRendered )  { 
      return  true ; 
    } 
    const currentDeps = efeito . deps ; 
    const previousDeps = previousRenderEffects [ índice ] ;

    se  ( currentDeps . length !== previousDeps . length )  { 
      return  true ; 
    }

    retornar  ! currentDeps . every ( ( dep , índice )  =>  { 
      const previousDep = previousDeps [ índice ] ; 
      retornar dep === previousDep ; 
    } ) ; 
  } ) ;

  se  ( didDepsChange )  { 
    efeito . limpeza ?. ( ) ; 
    const limpeza = efeito . cb ( ) ; 
    se  ( typeof limpeza ===  "função" )  { 
      efeito . limpeza  =  ( )  =>  limpeza ( ) ;  // coisas do typescript 
    } 
  } 
} ) ;

nada mal

useMemo

Costuma haver muita desinformação sobre useMemoe como você deve evitá-los por causa da sobrecarga que eles geram. Mas vamos ver se esse é realmente o caso.

useMemo aceita uma função que emite um valor e retorna esse valor chamando a função. A parte especial é useMemoque ele também aceita uma matriz de dependências. Se essas dependências não mudarem entre as renderizações, ele não chamará a função novamente; em vez disso, ele reutilizará a última saída da função. Se elas mudarem, a função será executada novamente, e esse valor será retornado daqui para frente.

Esta é uma maneira de otimizar seus componentes porque você pode evitar recalcular valores desnecessariamente. Mas, isso só é verdade se a sobrecarga da verificação de memorização for menor que a sobrecarga de chamar a função. Mas podemos definir bem qual é essa sobrecarga, já que estamos implementando o hook:

Será muito semelhante à useEffectimplementação. Precisamos fazer toda a lógica de configuração inicial para obter o estado do hook, ou inicializá-lo se for a primeira renderização. Então verificamos se as dependências mudaram, assim como useEffect. A diferença é que se as dependências mudarem em useMemo, chamamos a função fornecida. Caso contrário, retornamos o valor anterior:

export  const  useMemo  =  ( cb :  ( )  =>  void , deps :  Array < desconhecido > )  =>  { 
  const useMemoHookOrder = currentHookOrder ; 
  currentHookOrder +=  1 ;

  se  ( ! atualmenteRenderizando . hasRendered )  { 
    atualmenteRenderizando . hooks . push ( { 
      cb , 
      deps , 
      cleanup :  null , 
    } ) ; 
  } 
  const memo = atualmenteRenderizando . hooks [ useMemoHookOrder ] ;

  se  ( 
    memo . deps . comprimento !== deps . comprimento || 
    ! memo . deps . cada ( ( dep , índice )  =>  { 
      const novoDep = deps [ índice ] ; 
      retornar novoDep === dep ; 
    } ) 
  )  { 
    const valor =  cb ( ) ; 
    memo . valor = valor ; 
    retornar valor ; 
  } 
  retornar memo . valor ; 
} ;

Em nossa implementação, a sobrecarga para chamar useMemoé bem baixa! Tudo o que fazemos são algumas instruções if baratas em um array pequeno mais do que provável. Mesmo em javascript, isso é bem rápido.

Tomei a liberdade de verificar a implementação do react depois do fato. E é basicamente idêntico

Aqui está o código caso você não queira clicar em links:

function  updateMemo < T > ( 
  nextCreate :  ( )  =>  T , 
  deps :  Array < mixed >  |  void  |  null 
) :  T  { 
  const hook =  updateWorkInProgressHook ( ) ; 
  const nextDeps = deps ===  undefined  ?  null  : deps ; 
  const prevState = hook . memoizedState ; 
  // Suponha que eles estejam definidos. Se não estiverem, areHookInputsEqual avisará. 
  if  ( nextDeps !==  null )  { 
    const prevDeps :  Array < mixed >  |  null  = prevState [ 1 ] ; 
    if  ( areHookInputsEqual ( nextDeps , prevDeps ) )  { 
      return prevState [ 0 ] ; 
    } 
  } 
  const nextValue =  nextCreate ( ) ; 
  se  ( shouldDoubleInvokeUserFnsInHooksDEV )  { 
    setIsStrictModeForDevtools ( true ) ; 
    nextCreate ( ) ; 
    setIsStrictModeForDevtools ( false ) ; 
  } 
  gancho . memoizedState =  [ nextValue , nextDeps ] ; 
  retornar nextValue ; 
}

Onde areHookInputsigual simplesmente faz:

para  ( deixe i =  0 ; i < prevDeps . length && i < nextDeps . length ; i ++ )  { 
  // $FlowFixMe[incompatible-use] encontrado ao atualizar o Flow 
  if  ( é ( nextDeps [ i ] , prevDeps [ i ] ) )  { 
    continue ; 
  } 
  retorne  falso ; 
} 
retorne  verdadeiro ;

Isso significa que se sua função faz mais cálculos do que o necessário para executar deps.lengthigualdades rasas, provavelmente é melhor usar useMemo.

Isso também foi provado em escala com o compilador React – um compilador que memoriza basicamente tudo. Eles aplicaram isso à base de código do Instagram e não viram aumentos na memória e grandes melhorias na interatividade:

usar retorno de chamada

Agora que implementamos useMemo, vamos implementar useCallback.

Rufem os tambores, por favor.

export  const useCallback =  < T , > ( 
  fn :  ( )  =>  T , 
  deps :  Matriz < desconhecido > 
) :  ( ( )  =>  T )  =>  { 
  return  useMemo ( ( )  => fn , deps ) ; 
} ;

useCallbacké, literalmente, useMemoque retorna uma função. É um pouco menos complicado de ler do que

useMemo ( ( )  =>  ( )  =>  {  } ,  [ ] )

useContext

useContexté especial comparado à maioria dos outros hooks. Isso vem de useContextnão ser responsável por criar nenhum estado quando chamado. Ele é responsável apenas por ler o estado, que está sendo compartilhado mais acima na árvore.

A funcionalidade necessária para compartilhar esse estado não faz parte do hook. Ela se origina por meio de um provedor de contexto – um componente especial.

Aqui está um exemplo de uso para garantir que estamos na mesma página

const CountContext =  createContext ( { contagem :  0  } ) ;

const  App  =  ( )  =>  { 
  const  [ contagem , setCount ]  =  useState ( 0 ) ;

  return  ( 
    < CountContext.Provider  value = { { count } } > o componente especial, feito por createContext
       < Child  /> </ CountContext.Provider > ) ; } ;
    
  


const  Criança  =  ( )  =>  { 
  const  { contagem }  =  useContext ( Contagem de contexto ) ; 
  retornar  < div > { contagem } </ div > ; } ;  

createContextatua como uma fábrica para criar um Providercomponente especial que contém dados. Os componentes leem esses dados chamando useContexte passando o valor de retorno de createContext.Telling useContext para pesquisar na árvore de visualização até encontrar o provedor criado pelo mesmo contexto.

Observe como eu disse que pesquisamos na árvore de visualização, não na árvore de dependência. Um componente pode ser irmão de outro componente em termos da hierarquia de dependência, mas um filho em termos da hierarquia de visualização e ainda seria capaz de ler o contexto. Por exemplo:

retornar  ( 
< div > < SomeContext.Provider > < SomeComponent /> </ SomeContext.Provider > < OtherComponent /> </ div > )

If SomeContext.Provideré um componente, é um irmão de SomeComponentna árvore de dependências. Mas, esperamos SomeComponentter o estado de contexto disponível para ele ao chamar useContext(SomeContext). Então podemos dizer que as useContextpesquisas na árvore devem ser baseadas na árvore de visualização.

Você pode pensar que isso significa que SomeComponentnow depende de SomeContext.Provider, e que deveríamos atualizar a árvore de dependências para representar isso, mas não é o caso.

Embora SomeComponetleia os dados fornecidos por SomeContext.ProviderSomeContext.Providerele simplesmente transmite um valor, não cria um estado de reação que pode ser atualizado + causa uma nova renderização (não há nenhum setter associado a ele).

Para que os dados dentro do provedor sejam alterados, deve haver uma mudança de estado acionada por um ancestral de SomeContext.Provider(todos os dados que o provedor mantém devem vir de um componente do qual ele depende, já que foram passados ​​como props). E como SomeComponentSomeContext.Providersão irmãos (na árvore de dependência), eles compartilham os mesmos ancestrais e serão renderizados novamente juntos. Permitindo SomeComponentler o valor mais novo transmitido por SomeComponent.

Se, em vez disso, SomeContext.Providerfosse transmitido um valor que não fosse o estado de reação, ele seria simplesmente estático, e não haveria razão para ter dependência de algo que nunca mudará.

Com isso resolvido, vamos começar implementando createContext, a função que cria o componente provedor especial.

Atualmente, createElementretorna apenas metadados sobre o componente que instancia. Esses metadados incluem props, a função ou tag html e seus filhos.

Para um componente provedor, podemos atribuir os dados que queremos distribuir aos descendentes do componente adicionando uma propriedade nos metadados internos que os armazenam. Então, nós os definimos somente quando criamos os metadados usando createContext.

Mais tarde, quando chamamos useContext(SomeContext), lemos a árvore de visualização e procuramos um nó que tenha um provedor igual a SomeContext.Provider e, se tiver, os dados estarão disponíveis para leitura.

Veja como é a implementação do createContext:

export  const createContext =  < T , > ( initialValue :  T )  =>  { 
  const contextId = crypto . randomUUID ( ) ;

  currentTreeRef . defaultContextState . push ( { 
    contextId , 
    state : initialValue , 
  } ) ;  // explicado mais tarde 
  return  { 
    Provider :  ( data :  { 
      value :  T ; 
      children :  Array < 
        ReactComponentInternalMetadata |  null  |  false  |  undefined 
      > ; 
    } )  =>  { 
      if  ( 
        typeof data . value ===  "object"  && 
        data . value && 
        "__internal-context"  in data . value
       )  { 
        // hack para associar um id a um provedor, permitindo-nos determinar se ProviderA === ProviderB. Poderíamos ter usado a referência de função, mas isso foi mais fácil para depuração 
        return contextId as  unknown  as ReturnType < typeof createElement > ; 
      } 
      const el =  createElement ( "div" ,  null ,  ... data . children ) ;  // pois pequei, o ideal seria ter usado um fragmento 
      if  ( ! ( el . kind ===  "real-element" ) )  { 
        throw  new  Error ( ) ; 
      } 
      el . provider =  { 
        state : data . value ,  // os dados que serão lidos por useContext 
        contextId , 
      } ; 
      return el ; 
    } , 
  } ; 
} ;
  • Note que o tipo de elemento que passei foi um div. Isso está obviamente errado. O que realmente queremos fazer aqui é algo como um fragmento. Por enquanto, não vou implementá-lo porque não acho que seja um recurso central do react.
  • Note que, anteriormente, nossos nós de árvore de visualização carregam apenas informações sobre seus nós filhos. Então, teríamos que fazer uma travessia potencialmente cara para subir na árvore e verificar se um nó ancestral tem o provedor que estamos procurando. Para evitar isso, acabei realizando uma otimização menor e desnormalizando a árvore de visualização armazenando o pai como uma propriedade no nó filho.

Se não encontrarmos o provedor na árvore, queremos retornar o valor padrão passado para createContext. Essa foi a parte não explicada sobre a createContextimplementação acima. O que podemos fazer é armazenar os valores padrão em um array global. Quando um provedor não é encontrado, a useContextfunção pode voltar a ler os valores padrão. Esse comportamento é muito semelhante a como as linguagens de programação se comportam quando um valor não é encontrado em nenhum escopo ancestral – fallback para olhar no escopo global.

Vamos agora ver como useContextficaria a implementação já que todas as peças necessárias estão finalizadas:

export  const useContext =  < T , > ( 
  context : ReturnType < typeof createContext < T >> 
)  =>  { 
  const providerId = context . Provider ( { 
    value :  { 
      "__internal-context" :  true , 
    } , 
  }  as  any )  as  unknown  as  string ; 
  const state =  searchForContextStateUpwards ( 
    currentlyRenderingComponent ,
    provedorId
  ) ; 
  retorna estado como  T ; 
} ;

Algo que você pode ter notado em nossa useContextimplementação é que nunca incrementamos os ganchos chamados por um ou mutamos o array de ganchos dos nós de renderização. Isso ocorre porque lemos os dados de contexto com base em um ID que é criado fora da árvore de reação (createContext) e, como useContext não cria dados que precisam ser persistidos em renderizações, ele apenas lê. Portanto, não estamos limitados pela ordem em que foi chamado.

Isso significa que podemos chamá-lo condicionalmente sem problemas. Isso é verdade mesmo no React normal – o seguinte é perfeitamente válido:

const testContext =  createContext ( 0 ) 
exportar  const  ContentNavbar  =  ( )  =>  {

  se  ( Math . random ( )  >  .5 )  { 
    console . log ( "Sendo chamado condicionalmente!" ,  useContext ( testContext ) ) ; 
  }

  retornar  < div > isso funciona! </ div >
 }

E esse é o mesmo comportamento do novo usehook do React , que pode ser usado para ler contexto.

Resultado final

Vamos finalmente ver um exemplo de tudo isso reunido!

O exemplo abaixo mostra um aplicativo de amostra com contexto, estado, memorização, busca em useEffects, renderização condicional, renderização de lista, propriedades profundas e atualizações de DOM eficientes!

Conclusão

Reconstruir bibliotecas do zero é uma ótima maneira de ter intuição sobre o porquê a biblioteca tomou certas decisões. Você também acaba construindo um modelo interno forte da biblioteca, e isso permite que você raciocine sobre cenários que você nunca encontrou. Também é uma ótima maneira de aprender sobre comportamentos ocultos que você definitivamente não teria pensado sem construir a biblioteca.

Se você acabar fazendo um mergulho profundo em toda a base de código, provavelmente notará que as coisas são mais complicadas do que parecem nas minhas explicações aqui. Há muita complexidade adicionada pelos meus problemas de habilidade/erros cometidos no início que levariam muito tempo para retificar. Se eu reescrevesse isso, acho que provavelmente conseguiria fazer em 1/3 das linhas.

De qualquer forma, fiz o meu melhor para destilar as ideias principais da base de código em algo explicável aqui. Se você tiver alguma dúvida sobre como algo é implementado, ou encontrar um bug (tenho certeza de que há muitos), sinta-se à vontade para criar um problema no GitHub, e eu responderei!

No futuro, quero levar isso mais adiante de algumas maneiras:

  • implementando renderização do lado do servidor
    • isso envolveria construir uma string, em vez de um dom, a partir da árvore de visualização
    • Teríamos que encontrar uma maneira de mapear o HTML gerado pelo servidor para as árvores de visualização/dependência geradas pelo react do lado do cliente. Isso seria formalmente conhecido como hidratação.
  • diferentes alvos de renderização
    • não há razão para termos que gerar um dom a partir da nossa árvore de visualização. Qualquer UI que tenha qualquer estrutura hierárquica pode facilmente usar os internos do react implementados aqui
  • reimplementar isso rapidamente
    • eu tive que usar o UIKit recentemente para um trabalho, e muito miss react. Algo dentro de mim quer muito portar isso para swift.
    • Em vez de elementos dom, a biblioteca criaria UIViews
Postado em BlogTags:
Escreva um comentário