Componentes da Web em vez de reagir - outra tentativa

Olá Habr!

Recentemente, decidi descobrir como seria um aplicativo da Web do lado do cliente escrito inteiramente em componentes da Web baunilha sem usar estruturas. Acabou sendo uma coisa bastante funcional, no final, esbocei o modelo de PWA móvel , que agora uso em protótipos. Procedendo das seguintes premissas:

  • DOM é um estado. Como não temos uma estrutura, esquecemos imediatamente o funcionalismo e retornamos ao OOP imperativo. Os componentes da Web são nós DOM de longa duração que encapsulam seu estado e têm uma API pública. Eles não são recriados, mas alterados. Portanto, devemos considerar o DOM não apenas como uma representação, mas como um repositório de objetos de negócios e, portanto, precisamos construir uma hierarquia de componentes levando em consideração a conveniência de sua interação.
  • A interação dos componentes. Os componentes podem interagir por meio de chamadas diretas, trocas de retorno de chamada ou eventos de DOM do usuário ativo / inativo. O último método é o mais preferível, pois reduz o envolvimento mútuo (acoplamento) e ordena o gráfico de títulos (veja o exemplo abaixo).
    Os eventos DOM funcionam apenas dentro da hierarquia - eles podem aparecer de baixo para cima na cadeia de ancestrais ou transmitir para todos os descendentes. Em outros casos, a API padrão do navegador é usada para endereçar o componente: document.querySelector ('page-home'), e alguns componentes podem se registrar na janela e ser usados ​​globalmente: APP.route ('page-login').
  • Estilos internos. O Shadow DOM não é usado; portanto, os componentes herdam os estilos globais, mas também podem ter os seus. Como é improvável que o <estilo com escopo> seja implementado em um futuro próximo, é necessário usar o prefixo do nome do componente para declarar o estilo interno; no entanto, este é lacônico e funciona bem (veja o exemplo abaixo).
  • HTML/DOM. DOM — , HTML (value, checked, innerHTML contenteditable=«true» ..). JS , — / , ( ). , , this.pass — , <input> . , DOM, , , , .
  • Navegação. Os componentes da página residem no contêiner <main> e, uma vez criados, não são excluídos, mas simplesmente ocultos. Isso permite implementar a navegação usando location.hash e os botões padrão do navegador para frente e para trás funcionam corretamente. Ao navegar para um componente existente, o método onRoute () é chamado, onde você pode atualizar os dados.

Estrutura de aplicação


Nossa aplicação consiste em:

  • o componente raiz <app-app>, acessível via window.APP, contendo um roteador de páginas e funcionalidade global;
  • painéis com botões contextuais (não os coloquei em um componente separado, mas os fiz parte do layout <app-app> para simplificar a manipulação de eventos);
  • menu suspenso (componente separado);
  • o contêiner <main> no qual os componentes da página serão adicionados: <page-home>, <page-login>, <page-work> conforme eles se abrem.

As páginas são empilhadas com a navegação para frente e para trás. Além disso, demonstramos fluxos de dados ascendentes e descendentes:

  • O status da autorização e o nome de usuário atual são armazenados no componente <app-app>, mas vêm do componente <page-login> por meio de um evento pop-up.
  • Um timer marca no componente <app-app>, que envia o valor atual para baixo por meio de um evento de transmissão que é interceptado apenas no descendente de <page-work>.

Por meio de eventos pop-up, também é implementada a instalação de botões sensíveis ao contexto no painel do aplicativo - os próprios botões, juntamente com os manipuladores, são criados nos componentes da página e enviados ao topo, onde são interceptados no nível do aplicativo e inseridos no painel.

Implementação


Para trabalhar com o DOM interno do componente e enviar eventos a jusante, a pequena biblioteca WcMixin.js é usada - menos de 200 linhas de código, metade das quais (unificação de eventos de entrada do usuário) também pode ser descartada. Tudo o resto é baunilha pura. Um componente típico (página de autorização) fica assim:

import wcmixin from './WcMixin.js'

const me = 'page-login'
customElements.define(me, class extends HTMLElement {
   _but = null

   connectedCallback() {
      this.innerHTML = `
         <style scoped>
            ${me} {
               height: 90%; width: 100%;
               display: flex; flex-direction: column;
               justify-content: center; align-items: center;
            }
            ${me} input { width: 60%; }
         </style>
         <input w-id='userInp/user' placeholder='user'/> 
         <input w-id='passInp/pass' type='password' placeholder='password'/>
      `
      wcmixin(this)

      this.userInp.oninput = (ev) => {
         this.bubbleEvent('login-change', {logged: false, user: this.user})
      }

      this.passInp.onkeypress = (ev) => {
         if (ev.key === 'Enter') this.login()
      }
   }

   onRoute() {
      this.userInp.focus()
      this._but = document.createElement('button')
      this._but.innerHTML = 'Log in<br>⇒'
      this._but.onclick = () => this.login()
      this.bubbleEvent('set-buts', { custom: [this._but] })
   }

   async login() {
      APP.msg = 'Authorization...'
      this._but.disabled = true
      setTimeout(() => {
         this._but.disabled = false
         if (this.user) {
            this.bubbleEvent('login-change', {logged: true, user: this.user})
            APP.route('page-work')
         } else {
            APP.msg = 'Empty user !'
            this.userInp.focus()
         }
      }, 1500)
   }
})

Primeiro, aqui vemos os estilos locais do componente. Em segundo lugar, o único atributo não padrão w-id = `` userInp / user '' foi adicionado à marcação HTML. A função wcmixin () processa todos os elementos marcados com este atributo e adiciona variáveis ​​ao componente atual: this.userInp refere-se ao elemento <input> (que permite desligar o manipulador), e this.user é o valor do elemento (nome de usuário). Se o acesso ao elemento não for necessário, você poderá especificar w-id = `` / user '' e apenas o valor será criado.

Ao inserir um nome de usuário, enviamos o valor atual por meio de um evento pop-up, criamos um botão sensível ao contexto no método onRoute () e também o enviamos.

É importante que o componente de autorização não saiba nada sobre os componentes superiores do aplicativo / painel, ou seja, ele não está conectado. Ele simplesmente envia eventos para o andar de cima, e quem os intercepta depende do desenvolvedor. A recepção de eventos do aplicativo <app-app> no componente <page-work> é implementada da mesma maneira:

import wcmixin from './WcMixin.js'

const me = 'page-work'
customElements.define(me, class extends HTMLElement {

   connectedCallback() {
      this.innerHTML = `
         <p w-id='/msg'>Enter text:</p>
         <p w-id='textDiv/text' contenteditable='true'>1)<br>2)<br>3)</p>
      `
      wcmixin(this)

      this.addEventListener('notify-timer', (ev) => {
         this.msg = `Enter text (elapsed ${ev.val}s):`
      })
   }

   async onRoute() {
      this.textDiv.focus()
      document.execCommand('selectAll',false,null)
      const but = document.createElement('button')
      but.innerHTML = 'Done<br>⇒'
      but.onclick = () => alert(this.text)
      this.bubbleEvent('set-buts', { custom: [but] })
   }
})

E no componente <app-app>, escrevemos:

setInterval(() => {
   this._elapsed += 1
   this.drownEvent('notify-timer', this._elapsed)
}, 1000)

O componente <app-app> também não sabe nada sobre os componentes da página que desejam usar seu contador, ou seja, não está viciado em seus descendentes. É suficiente para o desenvolvedor concordar com as assinaturas do evento. Eventos DOM são leves, eventos descendentes são enviados apenas para componentes da Web (e não elementos simples) e eventos ascendentes passam por toda a cadeia de ancestrais como padrão.

Na verdade, isso é tudo que eu queria dizer.

→  Código completo do projeto

Objeções e sugestões


Costuma-me objetar que essa abordagem mescla lógica de negócios e exibição em um componente "grosso", que viola padrões geralmente aceitos. No entanto, estamos falando apenas da lógica para exibir e validar a entrada do usuário, o restante da lógica de negócios pode ser facilmente movido para separar classes JS ou mesmo serviços - com sua própria hierarquia e métodos de interação.

Por que isso é necessário? Ainda existe um problema de desempenho de renderização (a coleta de lixo não é gratuita) e uma abordagem imperativa usando ferramentas nativas sempre será mais rápida e menos intensiva em recursos do que declarativa / funcional usando bibliotecas JS e VDOM. Se você quiser, estou pronto para competir com um representante de qualquer estrutura, em um TOR acordado, se você assumir a função de benchmarking (eu posso fazer isso mal).

Obrigado pela atenção.

All Articles