paint-brush
Quatro coisas que fiz diferente ao escrever um framework de front-endpor@hacker-ss4mpor
Novo histórico

Quatro coisas que fiz diferente ao escrever um framework de front-end

por 17m2024/08/27
Read on Terminal Reader

Muito longo; Para ler

Quatro ideias que você talvez nunca tenha ouvido falar, no contexto de frameworks frontend: - Literais de objeto para modelagem HTML. - Um armazenamento global endereçável por meio de caminhos. - Eventos e respondedores para lidar com todas as mutações. - Um algoritmo de diff textual para atualizar o DOM.
featured image - Quatro coisas que fiz diferente ao escrever um framework de front-end
undefined HackerNoon profile picture
0-item
1-item

Em 2013, comecei a construir um conjunto minimalista de ferramentas para desenvolver aplicativos da web. Talvez a melhor coisa que saiu desse processo foi gotoB , um framework frontend JS puro do lado do cliente escrito em 2k linhas de código.


Fiquei motivado a escrever este artigo depois de mergulhar fundo na leitura de artigos interessantes de autores de frameworks de frontend muito bem-sucedidos:


O que me deixou animado sobre esses artigos é que eles falam sobre a evolução das ideias por trás do que eles constroem; a implementação é apenas uma maneira de torná-las reais, e os únicos recursos discutidos são aqueles que são tão essenciais a ponto de representar as próprias ideias.


De longe, o aspecto mais interessante do que saiu do gotoB são as ideias que se desenvolveram como resultado de enfrentar os desafios de construí-lo. É isso que quero cobrir aqui.


Como criei o framework do zero e estava tentando alcançar tanto o minimalismo quanto a consistência interna, resolvi quatro problemas de uma forma que considero diferente da forma como a maioria dos frameworks resolve os mesmos problemas.


Essas quatro ideias são o que eu quero compartilhar com você agora. Não faço isso para convencê-lo a usar minhas ferramentas (embora você seja bem-vindo!), mas sim, esperando que você possa se interessar pelas ideias em si.

Ideia 1: literais de objetos para resolver modelos

Qualquer aplicativo web precisa criar marcação (HTML) dinamicamente, com base no estado do aplicativo.


Isso é melhor explicado com um exemplo: em um aplicativo de lista de tarefas ultrasimples, o estado poderia ser uma lista de tarefas: ['Item 1', 'Item 2'] . Como você está escrevendo um aplicativo (em oposição a uma página estática), a lista de tarefas deve ser capaz de mudar.


Como o estado muda, o HTML que compõe a UI do seu aplicativo precisa mudar com o estado. Por exemplo, para exibir seus todos, você pode usar o seguinte HTML:

 <ul> <li>Item 1</li> <li>Item 2</li> </ul>


Se o estado mudar e um terceiro item for adicionado, seu estado ficará assim: ['Item 1', 'Item 2', 'Item 3'] ; então, seu HTML deverá ficar assim:

 <ul> <li>Item 1</li> <li>Item 2</li> <li>Item 3</li> </ul>


O problema de gerar HTML com base no estado do aplicativo geralmente é resolvido com uma linguagem de modelagem , que insere construções de linguagem de programação (variáveis, condicionais e loops) em pseudo-HTML que é expandido para HTML real.


Por exemplo, aqui estão duas maneiras de fazer isso em diferentes ferramentas de modelagem:

 // Assume that `todos` is defined and equal to ['Item 1', 'Item 2', 'Item 3'] // Moustache <ul> {{#todos}} <li>{{.}}</li> {{/todos}} </ul> // JSX <ul> {todos.map((item, index) => ( <li key={index}>{item}</li> ))} </ul>


Eu nunca gostei dessas sintaxes que trouxeram lógica para HTML. Percebendo que a modelagem exigia programação, e querendo evitar ter uma sintaxe separada para ela, decidi, em vez disso, trazer HTML para js, usando literais de objeto . Então, eu poderia simplesmente modelar meu HTML como literais de objeto:

 ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ['li', 'Item 3'], ]]


Se eu quisesse usar a iteração para gerar a lista, eu poderia simplesmente escrever:

 ['ul', items.map ((item) => ['li', item])]


E então use uma função que converteria esse literal de objeto em HTML. Dessa forma, todo o template pode ser feito em JS, sem nenhuma linguagem de template ou transpilação. Eu uso o nome liths para descrever esses arrays que representam HTML.


Até onde sei, nenhuma outra estrutura JS aborda a criação de modelos dessa forma. Eu fiz algumas pesquisas e encontrei JSONML , que usa quase a mesma estrutura para representar HTML em objetos JSON (que são quase os mesmos que literais de objeto JS), mas não encontrei nenhuma estrutura construída em torno dele.


Mithril e Hyperapp chegam bem perto da abordagem que usei, mas ainda usam chamadas de função para cada elemento.

 // Mithril m("ul", [ m("li", "Item 1"), m("li", "Item 2") ]) // hyperapp h("ul", [ h("li", "Item 1"), h("li", "Item 2") ])


A abordagem de usar literais de objeto funcionou bem para HTML, então eu a estendi para CSS e agora gero todo meu CSS por meio de literais de objeto também.


Se por algum motivo você estiver em um ambiente onde não é possível transpilar JSX ou usar uma linguagem de template, e não quiser concatenar strings, você pode usar esta abordagem.


Não tenho certeza se a abordagem Mithril/Hyperapp é melhor que a minha; eu acho que ao escrever literais de objetos longos representando liths, às vezes esqueço uma vírgula em algum lugar e isso pode ser difícil de encontrar. Fora isso, não tenho reclamações. E eu adoro o fato de que a representação para HTML é 1) dados e 2) em JS. Essa representação pode realmente funcionar como um DOM virtual, como veremos quando chegarmos à Ideia nº 4.


Detalhe bônus: se você quiser gerar HTML a partir de literais de objetos, você só precisa resolver os dois problemas a seguir:

  1. Entityify strings (ou seja: escape de caracteres especiais).
  2. Saiba quais tags fechar e quais não.

Ideia 2: um armazenamento global endereçável por meio de caminhos para armazenar todos os estados do aplicativo

Nunca gostei de componentes. Estruturar um aplicativo em torno de componentes requer colocar os dados pertencentes ao componente dentro do próprio componente. Isso torna difícil ou até mesmo impossível compartilhar esses dados com outras partes do aplicativo.


Em todos os projetos em que trabalhei, descobri que sempre precisei que algumas partes do estado do aplicativo fossem compartilhadas entre componentes que estão bem distantes uns dos outros. Um exemplo típico é o nome de usuário: você pode precisar disso na seção de conta e também no cabeçalho. Então, onde o nome de usuário pertence?


Portanto, decidi logo no início criar um objeto de dados simples ( {} ) e colocar todo o meu estado lá. Eu o chamei de store . O store mantém o estado de todas as partes do aplicativo e pode ser usado, portanto, por qualquer componente.


Essa abordagem era um tanto herética em 2013-2015, mas desde então ganhou prevalência e até domínio.


O que eu acho que ainda é bem novo é que eu uso caminhos para acessar qualquer valor dentro do store. Por exemplo, se o store for:

 { user: { firstName: 'foo' lastName: 'bar' } }


Posso usar um caminho para acessar (digamos) o lastName , escrevendo B.get ('user', 'lastName') . Como você pode ver, ['user', 'lastName'] é o caminho para 'bar' . B.get é uma função que acessa o store e retorna uma parte específica dele, indicada pelo caminho que você passa para a função.


Em contraste com o acima, a maneira padrão de acessar propriedades reativas é referenciá-las por meio de uma variável JS. Por exemplo:

 // Svelte let { firstName, lastName } = $props(); firstName = 'foo'; lastName = 'bar'; // Knockout const firstName = ko.observable('foo'); const lastName = ko.observable('bar'); // mobx class UserStore { firstName = 'foo'; lastName = 'bar'; constructor() { makeAutoObservable(this); } } const userStore = new UserStore(); // SolidJS const [firstName, setFirstName] = createSignal('foo'); const [lastName, setLastName] = createSignal('bar');


Isso, no entanto, requer que você mantenha uma referência a firstName e lastName (ou userStore ) em qualquer lugar que você precise desse valor. A abordagem que eu uso requer apenas que você tenha acesso ao store (que é global e disponível em todos os lugares) e permite que você tenha acesso refinado a ele sem definir variáveis JS para eles.


Immutable.js e o Firebase Realtime Database fazem algo muito mais próximo do que eu fiz, embora estejam trabalhando em objetos separados. Mas você poderia potencialmente usá-los para armazenar tudo em um único lugar que poderia ser granularmente endereçável.

 // Immutable.js let store = Map({ user: Map({ firstName: 'foo', lastName: 'bar' }) }); const firstName = store.getIn(['user', 'firstName']); // 'foo' // Firebase const db = firebase.database(); db.ref('user').set({ firstName: 'foo', lastName: 'bar' }); db.ref('user/firstName').once('value').then(snapshot => { const firstName = snapshot.val(); // 'foo' });


Ter meus dados em um armazenamento globalmente acessível que pode ser acessado granularmente por meio de caminhos é um padrão que achei extremamente útil. Sempre que escrevo const [count, setCount] = ... ou algo assim, parece redundante. Sei que poderia simplesmente fazer B.get ('count') sempre que precisasse acessá-lo, sem ter que declarar e passar count ou setCount .

Ideia 3: cada mudança é expressa por meio de eventos

Se a Ideia #2 (um armazenamento global acessível por caminhos) libera dados de componentes, a Ideia #3 é como eu liberei código de componentes. Para mim, essa é a ideia mais interessante neste artigo. Aqui vai!


Nosso estado são dados que, por definição, são mutáveis (para aqueles que usam imutabilidade, o argumento ainda se mantém: você ainda quer que a versão mais recente do estado mude, mesmo se você mantiver snapshots de versões mais antigas do estado). Como mudamos o estado?


Decidi ir com eventos. Eu já tinha caminhos para o store, então um evento poderia ser simplesmente a combinação de um verbo (como set , add ou rem ) e um caminho. Então, se eu quisesse atualizar user.firstName , eu poderia escrever algo assim:

 B.call ('set', ['user', 'firstName'], 'Foo')


Isso é definitivamente mais prolixo do que escrever:

 user.firstName = 'Foo';


Mas isso me permitiu escrever um código que responderia a uma mudança em user.firstName . E essa é a ideia crucial: em uma UI, há diferentes partes que são dependentes de diferentes partes do estado. Por exemplo, você poderia ter essas dependências:

  • Cabeçalho: depende do user e currentView
  • Seção de conta: depende do user
  • Lista de tarefas: depende dos items


A grande questão que enfrentei foi: como atualizar o cabeçalho e a seção de conta quando user muda, mas não quando items mudam? E como gerenciar essas dependências sem ter que fazer chamadas específicas como updateHeader ou updateAccountSection ? Esses tipos de chamadas específicas representam a "programação jQuery" no seu estado mais insustentável.


O que me pareceu uma ideia melhor foi fazer algo assim:

 B.respond ('set', [['user'], ['currentView']], function (user, currentView) { // Update the header }); B.respond ('set', ['user'], function (user) { // Update the account section }); B.respond ('set', ['items'], function (items) { // Update the todo list });


Então, se um evento set for chamado para user , o sistema de eventos notificará todas as visualizações que estão interessadas nessa mudança (cabeçalho e seção de conta), enquanto deixa as outras visualizações (lista de tarefas) inalteradas. B.respond é a função que eu uso para registrar respondedores (que geralmente são chamados de "ouvintes de eventos" ou "reações"). Observe que os respondedores são globais e não vinculados a nenhum componente; eles estão, no entanto, ouvindo apenas eventos set em certos caminhos.


Agora, como um evento change é chamado em primeiro lugar? Foi assim que eu fiz:

 B.respond ('set', '*', function () { // Assume that `path` is the path on which set was called B.call ('change', path); });


Estou simplificando um pouco, mas é basicamente assim que funciona no gotoB.


O que torna um sistema de eventos mais poderoso do que meras chamadas de função é que uma chamada de evento pode executar 0, 1 ou vários pedaços de código, enquanto uma chamada de função sempre chama exatamente uma função . No exemplo acima, se você chamar B.call ('set', ['user', 'firstName'], 'Foo'); , dois pedaços de código são executados: aquele que altera o cabeçalho e aquele que altera a visualização da conta. Observe que a chamada para atualizar firstName não "se importa" com quem está ouvindo isso. Ela apenas faz o que tem que fazer e deixa o respondente pegar as alterações.


Eventos são tão poderosos que, na minha experiência, eles podem substituir valores computados, assim como reações. Em outras palavras, eles podem ser usados para expressar qualquer mudança que precise acontecer em um aplicativo.


Um valor computado pode ser expresso com um respondedor de evento. Por exemplo, se você quiser computar um fullName e não quiser usá-lo no store, você pode fazer o seguinte:

 B.respond ('set', 'user', function () { var user = B.get ('user'); var fullName = user.firstName + ' ' + user.lastName; // Do something with `fullName` here. });


Similarmente, reações podem ser expressas com um respondedor. Considere isto:

 B.respond ('set', 'user', function () { var user = B.get ('user'); var fullName = user.firstName + ' ' + user.lastName; document.getElementById ('header').innerHTML = '<h1>Hello, ' + fullName + '</h1>'; });


Se você ignorar por um minuto a concatenação constrangedora de strings para gerar HTML, o que você vê acima é um respondedor executando um "efeito colateral" (nesse caso, atualizando o DOM).


(Observação: qual seria uma boa definição de efeito colateral, no contexto de um aplicativo web? Para mim, isso se resume a três coisas: 1) uma atualização no estado do aplicativo; 2) uma alteração no DOM; 3) enviar uma chamada AJAX).


Descobri que não há realmente necessidade de um ciclo de vida separado que atualize o DOM. Em gotoB, há algumas funções de resposta que atualizam o DOM com a ajuda de algumas funções auxiliares. Então, quando user muda, qualquer resposta (ou mais precisamente, função de visualização , já que esse é o nome que dou aos respondedores que são encarregados de atualizar uma parte do DOM) que depende dele será executada, gerando um efeito colateral que acaba atualizando o DOM.


Tornei o sistema de eventos previsível ao fazê-lo executar as funções de resposta na mesma ordem, e uma de cada vez. Respostas assíncronas ainda podem ser executadas como síncronas, e as respostas que vêm "depois" delas esperarão por elas.


Padrões mais sofisticados, onde você precisa atualizar o estado sem atualizar o DOM (geralmente para fins de desempenho) podem ser adicionados adicionando verbos mute , como mset , que modificam o store mas não acionam nenhum respondedor. Além disso, se você precisar fazer algo no DOM depois que um redraw acontecer, você pode simplesmente certificar-se de que esse respondedor tenha uma prioridade baixa e seja executado após todos os outros respondedores:

 B.respond ('set', 'date', {priority: -1000}, function () { var datePicker = document.getElementById ('datepicker'); // Do something with the date picker });


A abordagem acima, de ter um sistema de eventos usando verbos e caminhos e um conjunto de respondedores globais que são correspondidos (executados) por certas chamadas de eventos, tem outra vantagem: cada chamada de evento pode ser colocada em uma lista. Você pode então analisar essa lista quando estiver depurando seu aplicativo e rastrear alterações no estado.


No contexto de um frontend, aqui está o que eventos e respondedores permitem:

  • Para atualizar partes da loja com muito pouco código (apenas um pouco mais detalhado do que a mera atribuição de variáveis).
  • Para que partes do DOM sejam atualizadas automaticamente quando houver uma alteração nas partes do armazenamento das quais essa parte do DOM depende.
  • Para não ter nenhuma parte do DOM sendo atualizada automaticamente quando não for necessária.
  • Ser capaz de ter valores e reações computados que não estejam relacionados à atualização do DOM, expressos como respondedores.


Isto é o que eles permitem (na minha experiência) fazer sem:

  • Métodos de ciclo de vida ou ganchos.
  • Observáveis.
  • Imutabilidade.
  • Memorização.


Na verdade, são apenas chamadas de eventos e respondedores, alguns respondedores estão preocupados apenas com visualizações, e outros estão preocupados com outras operações. Todos os internos do framework estão apenas usando o espaço do usuário .


Se você estiver curioso sobre como isso funciona no gotoB, você pode conferir esta explicação detalhada .

Ideia 4: um algoritmo de diferença de texto para atualizar o DOM

A vinculação de dados bidirecional agora parece bem datada. Mas se você pegar uma máquina do tempo de volta para 2013 e abordar desde os primeiros princípios o problema de redesenhar o DOM quando o estado muda, o que soaria mais razoável?

  • Se o HTML mudar, atualize seu estado em JS. Se o estado em JS mudar, atualize o HTML.
  • Toda vez que o estado em JS mudar, atualize o HTML. Se o HTML mudar, atualize o estado em JS e então atualize novamente o HTML para corresponder ao estado em JS.


De fato, a opção 2, que é o fluxo de dados unidirecional do estado para o DOM, parece mais complicada e ineficiente.


Vamos agora tornar isso bem concreto: no caso de um <input> interativo ou <textarea> que é focado, você precisa recriar partes do DOM com cada pressionamento de tecla do usuário! Se você estiver usando fluxos de dados unidirecionais, cada mudança na entrada aciona uma mudança no estado, que então redesenha o <input> para fazê-lo corresponder exatamente ao que deveria ser.


Isso define um padrão muito alto para atualizações de DOM: elas devem ser rápidas e não atrapalhar a interação do usuário com elementos interativos. Esse não é um problema fácil de resolver.


Agora, por que dados unidirecionais do estado para o DOM (JS para HTML) venceram? Porque é mais fácil raciocinar sobre isso. Se o estado muda, não importa de onde essa mudança veio (pode ser um retorno de chamada AJAX trazendo dados do servidor, pode ser uma interação do usuário, pode ser um timer). O estado muda (ou melhor, é mutado ) da mesma forma sempre. E as mudanças do estado sempre fluem para o DOM.


Então, como alguém faz para executar atualizações de DOM de uma forma eficiente que não atrapalhe a interação do usuário? Isso geralmente se resume a executar a quantidade mínima de atualizações de DOM que farão o trabalho. Isso geralmente é chamado de "diffing", porque você está fazendo uma lista de diferenças que precisa para pegar uma estrutura antiga (o DOM existente) e convertê-la em uma nova (o novo DOM após o estado ser atualizado).


Quando comecei a trabalhar nesse problema por volta de 2016, trapaceei dando uma olhada no que o React estava fazendo. Eles me deram a percepção crucial de que não havia um algoritmo generalizado de desempenho linear para diferenciar duas árvores (o DOM é uma árvore). Mas, teimoso se tanto, eu ainda queria um algoritmo de propósito geral para executar a diferenciação. O que eu particularmente não gostei no React (ou em quase qualquer framework, nesse caso) é a insistência de que você precisa usar chaves para elementos contíguos:

 function MyList() { const items = ['Item 1', 'Item 2', 'Item 3']; return ( <ul> {items.map((item, index) => ( <li key={index}>{item}</li> ))} </ul> ); }


Para mim, a diretiva key era supérflua, porque não tinha nada a ver com o DOM; era apenas uma dica para o framework.


Então pensei em tentar um algoritmo de diff textual em versões achatadas de uma árvore. E se eu achatasse as duas árvores (o pedaço antigo do DOM que eu tinha e o novo pedaço do DOM que eu queria substituir) e calculasse um diff nele (um conjunto mínimo de edições), para que eu pudesse ir do antigo para o novo em um número menor de passos?


Então eu peguei o algoritmo de Myers , aquele que você usa toda vez que executa git diff , e o coloquei para funcionar em minhas árvores achatadas. Vamos ilustrar com um exemplo:

 var oldList = ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ]]; var newList = ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ['li', 'Item 3'], ]];


Como você pode ver, não estou trabalhando com o DOM, mas com a representação literal do objeto que vimos na Ideia 1. Agora, você notará que precisamos adicionar um novo <li> ao final da lista.


As árvores achatadas ficam assim:

 var oldFlattened = ['O ul', 'O li', 'L Item 1', 'C li', 'O li', 'L Item 2', 'C li', 'C ul']; var newFlattened = ['O ul', 'O li', 'L Item 1', 'C li', 'O li', 'L Item 2', 'C li', 'O li', 'L Item 3', 'C li', 'C ul'];


O O significa "tag aberta", o L significa "literal" (nesse caso, algum texto) e o C significa "tag fechada". Note que cada árvore agora é uma lista de strings, e não há mais arrays aninhados. É isso que quero dizer com achatamento.


Quando executo uma comparação em cada um desses elementos (tratando cada item na matriz como se fosse uma unidade), obtenho:

 var diff = [ ['keep', 'O ul'] ['keep', 'O li'] ['keep', 'L Item 1'] ['keep', 'C li'] ['keep', 'O li'] ['keep', 'L Item 2'] ['keep', 'C li'] ['add', 'O li'] ['add', 'L Item 3'] ['add', 'C li'] ['keep', 'C ul'] ];


Como você provavelmente deduziu, estamos mantendo a maior parte da lista e adicionando um <li> no final dela. Essas são as entradas add que você vê.


Se agora alterássemos o texto do terceiro <li> do Item 3 para Item 4 e executássemos uma comparação nele, obteríamos:

 var diff = [ ['keep', 'O ul'] ['keep', 'O li'] ['keep', 'L Item 1'] ['keep', 'C li'] ['keep', 'O li'] ['keep', 'L Item 2'] ['keep', 'C li'] ['keep', 'O li'] ['rem', 'L Item 3'] ['add', 'L Item 4'] ['keep', 'C li'] ['keep', 'C ul'] ];


Não sei quão matematicamente ineficiente essa abordagem é, mas na prática ela tem funcionado muito bem. Ela só tem um desempenho ruim ao diferenciar árvores grandes que têm muitas diferenças entre elas; quando isso acontece ocasionalmente, eu recorro a um tempo limite de 200 ms para interromper a diferenciação e simplesmente substituir inteiramente a parte ofensiva do DOM. Se eu não usasse um tempo limite, o aplicativo inteiro pararia por algum tempo até que a diferenciação fosse concluída.


Uma vantagem de sorte de usar o diff Myers é que ele prioriza exclusões sobre inserções: isso significa que se houver uma escolha igualmente eficiente entre remover um item e adicionar um item, o algoritmo removerá um item primeiro. Praticamente, isso me permite pegar todos os elementos DOM eliminados e ser capaz de reciclá-los se eu precisar deles mais tarde no diff. No último exemplo, o último <li> é reciclado alterando seu conteúdo de Item 3 para Item 4 . Ao reciclar elementos (em vez de criar novos elementos DOM), melhoramos o desempenho a um grau em que o usuário não percebe que o DOM está sendo constantemente redesenhado.


Se você está se perguntando o quão complexo é implementar esse mecanismo de achatamento e diferenciação que aplica mudanças ao DOM, eu consegui fazer isso em 500 linhas de javascript ES5, e ele até roda no Internet Explorer 6. Mas, admito, foi talvez o pedaço de código mais difícil que eu já escrevi. Ser teimoso tem um custo.

Conclusão

Essas são as quatro ideias que eu queria apresentar! Elas não são totalmente originais, mas espero que sejam novas e interessantes para alguns. Obrigado por ler!