Web Components Instead of React - Another Attempt

Hello, Habr!

I recently decided to figure out what a client-side web application written entirely on vanilla web components should look like without using frameworks. It turned out to be quite a working thing, in the end I sketched the mobile PWA template , which I now use in prototypes. Proceeding from the following premises:

  • DOM is a state. Since we do not have a framework, we immediately forget about functionalism and return to the imperative OOP. Web components are long-lived DOM nodes that encapsulate their state and have a public API. They are not recreated, but changed. This means that we should consider the DOM not only as a representation, but as a repository of business objects, and therefore we need to build a hierarchy of components taking into account the convenience of their interaction.
  • The interaction of the components. Components can interact through direct calls, callback exchanges, or through up / down user DOM events. The latter method is most preferable, as it reduces the mutual engagement (coupling), and orders the graph of bonds (see example below).
    DOM events work only within the hierarchy - they can pop up from the bottom up the chain of ancestors, or broadcast down to all descendants. In other cases, the standard browser API is used to address the component: document.querySelector ('page-home'), and some components can register themselves in the window and used globally: APP.route ('page-login').
  • Inner styles. Shadow DOM is not used, therefore components inherit global styles, but can also have their own. Since <style scoped> is unlikely to be implemented in the near future, you have to use the component name prefix to declare the internal style, however this is laconic and works fine (see the example below).
  • Interaction with HTML / DOM. Since the DOM is a state, the data source is the values ​​of the HTML elements themselves (value, checked, innerHTML for contenteditable = β€œtrue”, etc.). Additional JS variables are not needed, and for convenience of accessing the form values ​​- we just create getters / setters and add them to the ancestor object (for which a small library is needed). Addressing form values ​​is now no different from addressing class variables, for example, this.pass is the password value entered in the <input> child of the current component. Thus, neither a virtual DOM nor a two-way binding is needed, redrawing forms when they are reopened is also not necessary, and the data entered into the form is saved during navigation, unless you specifically purge them.
  • Navigation. The page components live inside the <main> container, and once created, they are not deleted, but simply hidden. This allows you to implement navigation using location.hash, and the standard browser buttons back and forth work correctly. When navigating to an existing component, the onRoute () method is called, where you can update the data.

Application structure


Our application consists of:

  • the root component <app-app>, accessible via window.APP, containing a page router and global functionality;
  • panels with contextual buttons (I didn’t put them into a separate component, but made them part of the <app-app> layout to simplify event handling);
  • drop-down menu (separate component);
  • the <main> container into which the page components will be added: <page-home>, <page-login>, <page-work> as they open.

Pages are stacked with back and forth navigation. In addition, we demonstrate bottom-up and top-down data flows:

  • The authorization status and current username is stored in the <app-app> component, but comes from the <page-login> component through a pop-up event.
  • A timer ticks in the <app-app> component, which sends the current value down through a broadcast event that is caught only in the descendant of <page-work>.

Through pop-up events, the installation of context-sensitive buttons on the application panel is also implemented - the buttons themselves, together with the handlers, are created in the page components, then sent to the top, where they are intercepted at the app level and inserted into the panel.

Implementation


To work with the internal DOM of the component, and send downstream events, the tiny WcMixin.js library is used - less than 200 lines of code, half of which (unification of user input events) can also be thrown out. Everything else is pure vanilla. A typical component (authorization page) looks like this:

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

First, here we see the local styles of the component. Secondly, the only non-standard attribute w-id = `` userInp / user '' has been added to the HTML markup. The wcmixin () function processes all the elements marked with this attribute and adds variables to the current component: this.userInp refers to the <input> element itself (which allows you to hang the handler), and this.user is the value of the element (username). If access to the element is not needed, you can specify w-id = `` / user '', and only the value will be created.

When entering a username, we send the current value up through a pop-up event, create a context-sensitive button in the onRoute () method, and also send it up.

It is important that the authorization component does not know anything about the higher components of the application / panel, that is, it is not hooked. He simply sends events upstairs, and whoever intercepts them is up to the developer. The reception of events from the <app-app> application in the <page-work> component is implemented in the same way:

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

And in the <app-app> component we write:

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

The <app-app> component also does not know anything about page components that want to use its counter, that is, it is not hooked on its descendants. It is enough for the developer to agree on the event signatures. DOM events are lightweight, descending events are sent only to web components (and not simple elements), and ascending events pass through the entire chain of ancestors as standard.

Actually, that’s all I wanted to say.

β†’  Full project code

Objections and suggestions


It is often objected to me that such an approach mixes business logic and display in one β€œthick” component, which violates generally accepted patterns. However, we are only talking about the logic for displaying and validating user input, the rest of the business logic can be easily moved to separate JS classes or even services - with its own hierarchy and interaction methods.

Why is this necessary. There is still a rendering performance problem (garbage collection is not free), and an imperative approach using native tools will always be faster and less demanding on resources than declarative / functional using JS libraries and VDOM. If you want, I’m ready to compete with a representative of any framework, on an agreed TOR, if you take on the benchmarking function (I can do this poorly).

Thank you for the attention.

All Articles