Привет, Хабр!

Недавно я решил выяснить, как должно выглядеть клиентское веб-приложение, написанное целиком на ванильных веб-компонентах, без использования фреймворков. Получилась вполне рабочая штука, в итоге я набросал шаблон мобильного PWA, который теперь использую в прототипах. Исходил из следующих предпосылок:

  • DOM — это стейт. Поскольку у нас нет фреймворка, сразу забываем про функциональщину, и возвращаемся к императивному ООП. Веб-компоненты — это долгоживущие узлы DOM, инкапсулирующие свой стейт и имеющие публичное API. Они не пересоздаются, а изменяются. Значит DOM мы должны рассматривать не только как представление, но как хранилище бизнес-объектов, а значит и строить иерерхию компонентов нужно с учетом удобства их взаимодействия.
  • Взаимодействие компонентов. Компоненты могут взаимодействовать посредством прямых вызовов, обмена колбэками, или посредством восходящих / нисходящих пользовательских событий DOM. Последний способ наиболее предпочтителен, так как снижает взаимную зацепленность (coupling), и упорядочивает граф связей (см. пример ниже).
    События DOM работают только в пределах иерархии — они могут всплывать снизу вверх по цепочке предков, либо широковещательно опускаться вниз до всех потомков. В остальных случаях для адресации компонента используется стандартное браузерное API: document.querySelector('page-home'), а некоторые компонены могут прописать себя в window, и использоваться глобально: APP.route('page-login').
  • Внутренние стили. Shadow DOM не используется, поэтому компоненты наследуют глобальные стили, но также могут иметь свои собственные. Поскольку <style scoped> вряд-ли будет в ближайшем будущем реализован, для объявления внутреннего стиля приходится использовать префикс имени компонента, однако это немногословно и отлично работает (см. пример ниже).
  • Взаимодействие с HTML/DOM. Поскольку DOM — это стейт, то источником данных являются сами значения элементов HTML (value, checked, innerHTML для contenteditable=«true» и т.д.). Дополнительные переменные JS не нужны, а для удобства доступа к значениям формы — мы просто создаем геттеры / сеттеры, и добавляем их в объект предка (для чего нужна маленькая библиотека). Адресация значений формы теперь ничем не отличается от адресации переменных класса, например, this.pass — значение пароля, введенное в дочерний элемент <input> текущего компонента. Таким образом, не нужен ни виртуальный DOM, ни двусторонняя привязка, перерисовка форм при их повторном открытии также не нужна, а введенные в форму данные сохраняются при навигации, если их специально не очищать.
  • Навигация. Компоненты страниц живут внутри контейнера <main>, и однажды будучи созданы, не удаляются, а просто скрываются. Это позволяет реализовать навигацию с использованием location.hash, и стандартные кнопки браузера назад-вперед отрабатывают правильно. При навигации на уже существующий компонент вызывается метод onRoute(), где можно обновить данные.

Структура приложения


Наше приложение состоит из:

  • корневого компонента <app-app>, доступного по window.APP, содержащего роутер страниц и глобальный функционал;
  • панели с контекстными кнопками (не стал выносить в отдельный компонент, а сделал частью верстки <app-app> для упрощения обработки событий);
  • выпадающего меню (отдельный компонент);
  • контейнера <main>, в который будут добавляться компоненты страниц: <page-home>, <page-login>, <page-work> по мере их открытия.

Страницы организованы в стек с навигацией «назад-вперед». Кроме того, мы демонстрируем потоки данных «снизу вверх» и «сверху вниз»:

  • Статус авторизации и текущее имя пользователя хранится в компоненте <app-app>, но приходит из компонента <page-login> посредством всплывающего события.
  • В компоненте <app-app> тикает таймер, который отправляет текущее значение вниз посредством широковещательного события, перехватываемого только в потомке <page-work>.

Через всплывающие события также реализована установка контекстно-зависимых кнопок на панели приложения — сами кнопки вместе с обработчиками создаются в компонентах страниц, затем отправляются наверх, где перехватываются на уровне app и вставляются в панель.

Реализация


Для работы с внутренним DOM компонента, и отправки нисходящих событий используется крохотная библиотека WcMixin.js — менее 200 строчек кода, половину из которых (унификация событий пользовательского ввода) можно и выкинуть. Все остальное — чистая ваниль. Типовой компонент (страница авторизации) выглядит так:

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

Во-первых, мы здесь видим локальные стили компонента. Во-вторых, в разметку HTML добавлен единственный нестандартный атрибут w-id = ''userInp/user''. Функция wcmixin() обрабатывает все элементы, помеченные таким атрибутом, и добавляет в текущий компонент переменные: this.userInp ссылается на сам элемент <input> (что позволяет повесить обработчик), а this.user — это значение элемента (имя пользователя). Если доступ к элементу не нужен, можно указать w-id = ''/user'', и будет создано только значение.

При вводе имени пользователя мы отправляем текущее значение наверх посредством всплывающего события, контекстно-зависимую кнопку создаем в методе onRoute(), и также отправляем наверх.

Важно, что компонент авторизации ничего не знает о вышестоящих компонентах приложения / панели, то есть он не зацеплен. Он просто передает наверх события, а кто их перехватит — решает разработчик. Таким же образом реализован прием событий от приложения <app-app> в компоненте <page-work>:

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

А в компоненте <app-app> мы пишем:

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

Компонент <app-app> также ничего не знает про компоненты страниц, которые хотят использовать его счетчик, то есть он не зацеплен за своих потомков. Разработчику достаточно согласовать сигнатуры событий. События DOM легковесны, нисходящие направляются только веб-компонентам (а не простым элементам), восходящие стандартно проходят через всю цепочку предков.

Собственно, это все, что я хотел сказать.

Полный код проекта

Возражения и предложения


Мне часто возражают, что такой подход смешивает бизнес-логику и отображение в одном «толстом» компоненте, что нарушает общепринятые паттерны. Однако мы говорим только о логике отображения и валидации пользовательского ввода, остальная бизнес-логика может быть легко вынесена в отдельные классы JS или даже сервисы — со своей иерархией и способами взаимодействия.

Зачем это нужно. Проблема производительности рендеринга все-таки существует (сборка мусора небесплатна), и императивный подход с использованием нативных инструментов всегда будет быстрее и менее требователен к ресурсам чем декларативный / функциональный с использованием JS-библиотек и VDOM. Если есть желание — готов посоревноваться с представителем любого фреймворка, на согласованном ТЗ, если вы возьмете на себя функцию бенчмаркинга (я это плохо умею делать).

Спасибо за внимание.