Composants Web au lieu de réagir - une autre tentative

Bonjour, Habr!

J'ai récemment décidé de comprendre à quoi devrait ressembler une application Web côté client entièrement écrite sur des composants Web vanilla sans utiliser de frameworks. Cela s'est avéré être une chose très fonctionnelle, à la fin j'ai esquissé le modèle PWA mobile , que j'utilise maintenant dans les prototypes. Partant des locaux suivants:

  • DOM est un état. Comme nous n'avons pas de cadre, nous oublions immédiatement le fonctionnalisme et revenons à l'impératif POO. Les composants Web sont des nœuds DOM à longue durée de vie qui encapsulent leur état et disposent d'une API publique. Ils ne sont pas recréés, mais modifiés. Cela signifie que nous devons considérer le DOM non seulement comme une représentation, mais comme un référentiel d'objets métier, et donc nous devons construire une hiérarchie de composants en tenant compte de la commodité de leur interaction.
  • L'interaction des composants. Les composants peuvent interagir via des appels directs, des échanges de rappel ou via des événements DOM utilisateur haut / bas. Cette dernière méthode est la plus préférable, car elle réduit l'engagement mutuel (couplage) et ordonne le graphique des liaisons (voir l'exemple ci-dessous).
    Les événements DOM ne fonctionnent que dans la hiérarchie - ils peuvent apparaître de bas en haut de la chaîne des ancêtres, ou être diffusés à tous les descendants. Dans d'autres cas, l'API de navigateur standard est utilisée pour adresser le composant: document.querySelector ('page-home'), et certains composants peuvent s'enregistrer dans la fenêtre et utilisés globalement: APP.route ('page-login').
  • Styles intérieurs. Le DOM fantôme n'est pas utilisé, donc les composants héritent des styles globaux, mais peuvent aussi avoir les leurs. Étant donné qu'il est peu probable que <style scoped> soit implémenté dans un avenir proche, vous devez utiliser le préfixe du nom du composant pour déclarer le style interne, mais cela est laconique et fonctionne correctement (voir l'exemple ci-dessous).
  • HTML/DOM. DOM — , HTML (value, checked, innerHTML contenteditable=«true» ..). JS , — / , ( ). , , this.pass — , <input> . , DOM, , , , .
  • La navigation. Les composants de la page vivent à l'intérieur du conteneur <main>, et une fois créés, ils ne sont pas supprimés, mais simplement masqués. Cela vous permet d'implémenter la navigation à l'aide de location.hash, et les boutons de navigateur standard d'avant en arrière fonctionnent correctement. Lorsque vous accédez à un composant existant, la méthode onRoute () est appelée, où vous pouvez mettre à jour les données.

Structure d'application


Notre application consiste en:

  • le composant racine <app-app>, accessible via window.APP, qui contient le routeur de page et la fonctionnalité globale;
  • des panneaux avec des boutons contextuels (je ne les ai pas placés dans un composant séparé, mais je les ai intégrés à la disposition <app-app> pour simplifier la gestion des événements);
  • menu déroulant (composant séparé);
  • le conteneur <main> dans lequel les composants de la page seront ajoutés: <page-home>, <page-login>, <page-work> lors de leur ouverture.

Les pages sont empilées avec une navigation dans les deux sens. De plus, nous montrons des flux de données ascendants et descendants:

  • Le statut d'autorisation et le nom d'utilisateur actuel sont stockés dans le composant <app-app>, mais proviennent du composant <page-login> via un événement contextuel.
  • Un temporisateur fait tic tac dans le composant <app-app>, qui envoie la valeur actuelle vers le bas au moyen d'un événement de diffusion qui n'est intercepté que dans le descendant de <page-work>.

Grâce à des événements contextuels, l'installation de boutons contextuels sur le panneau d'application est également implémentée - les boutons eux-mêmes, ainsi que les gestionnaires, sont créés dans les composants de la page, puis envoyés en haut, où ils sont interceptés au niveau de l'application et insérés dans le panneau.

la mise en oeuvre


Pour travailler avec le DOM interne du composant et envoyer des événements en aval, la minuscule bibliothèque WcMixin.js est utilisée - moins de 200 lignes de code, dont la moitié (unification des événements d'entrée utilisateur) peut également être supprimée. Tout le reste est de la vanille pure. Un composant typique (page d'autorisation) ressemble à ceci:

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)
   }
})

Tout d'abord, nous voyons ici les styles locaux du composant. Deuxièmement, le seul attribut non standard w-id = `` userInp / user '' a été ajouté au balisage HTML. La fonction wcmixin () traite tous les éléments marqués avec cet attribut et ajoute des variables au composant actuel: this.userInp fait référence à l'élément <input> lui-même (qui vous permet de bloquer le gestionnaire), et this.user est la valeur de l'élément (username). Si l'accès à l'élément n'est pas nécessaire, vous pouvez spécifier w-id = `` / user '', et seule la valeur sera créée.

Lorsque vous entrez un nom d'utilisateur, nous envoyons la valeur actuelle par le biais d'un événement contextuel, créons un bouton contextuel dans la méthode onRoute () et l'envoyons également.

Il est important que le composant d'autorisation ne sache rien des composants supérieurs de l'application / du panneau, c'est-à-dire qu'il n'est pas accroché. Il envoie simplement les événements à l'étage, et quiconque les intercepte dépend du développeur. La réception des événements de l'application <app-app> dans le composant <page-work> est implémentée de la même manière:

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] })
   }
})

Et dans le composant <app-app> nous écrivons:

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

Le composant <app-app> ne sait rien non plus sur les composants de page qui veulent utiliser son compteur, c'est-à-dire qu'il n'est pas accroché à ses descendants. Il suffit que le développeur se mette d'accord sur les signatures d'événement. Les événements DOM sont légers, les événements descendants sont envoyés uniquement aux composants Web (et non aux éléments simples), et les événements ascendants traversent toute la chaîne des ancêtres en standard.

En fait, c'est tout ce que je voulais dire.

→  Code de projet complet

Objections et suggestions


On m'objecte souvent qu'une telle approche mélange la logique métier et l'affichage dans un composant «épais», ce qui viole les schémas généralement acceptés. Cependant, nous ne parlons que de la logique d'affichage et de validation des entrées utilisateur, le reste de la logique métier peut être facilement déplacé vers des classes JS ou même des services séparés - avec sa propre hiérarchie et ses propres méthodes d'interaction.

Pourquoi est-ce nécessaire? Il y a toujours un problème de performances de rendu (la récupération de place n'est pas gratuite), et une approche impérative utilisant des outils natifs sera toujours plus rapide et moins gourmande en ressources que déclarative / fonctionnelle utilisant les bibliothèques JS et VDOM. Si vous le souhaitez, je suis prêt à concurrencer un représentant de n'importe quel cadre, sur un mandat convenu, si vous assumez la fonction d'analyse comparative (je peux le faire mal).

Merci pour l'attention.

All Articles