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

В этой статье речь пойдет об управлении клиентским стейтом одностраничного WEB-приложения, разработанного целиком на ванильных веб-компонентах, без использования фреймворков. Ранее, до появления Custom Elements и классов JavaScript, управление стейтом действительно представляло проблему. С одной стороны мы имели дерево DOM, в котором можно было хранить лишь текстовую информацию (включая результаты пользовательского ввода), с другой строны нам нужны сложные структуры данных (Array, Map), которые хранились в переменных JavaScript разной степени «глобальности». Чтобы подружить эти 2 мира, был необходим 2-х сторонний байндинг, различные реализации которого и предлагали веб-фреймворки. При этом, естественно, основным «источником правды», то есть стейтом, считались объекты JS, тогда как дерево DOM рассматривалось исключительно как отображение (view), которое должно пересоздаваться при каждом изменении стейта. Алгоритмы байндинга описывались декларативно, что позволяло их кэшировать, а следовательно минимизировать трансформации реального DOM.

Современные веб-стандарты все упростили. Веб-компонент — это экземпляр класса JavaScript, который может иметь публичные свойства (или приватные — с геттерами / сеттерами), и эти свойства доступны через DOM Properties, что позволяет рассматривать дерево DOM как распределенный стейт, не имеющий ограничений на типы данных. Например, если в каком-то месте нашего документа размещен веб-компонент login-session, отвечающий за вывод формы авторизации, а также за хранение и отображение регистрационной информации, то текущее имя пользователя можно получить примерно так:

document.querySelector('login-session').login_name

Другой пример — компонент item-list хранит в себе массив сообщений, и выводит их в нумерованный список HTML, но мы можем работать напрямую с его данными, например так:

document.querySelector('item-list').items[0]
document.querySelector('item-list').items.filter(v => v.includes('some text'))

При создании веб-компонента можно использовать фабричные методы (поскольку параметры конструктора не предусмотрены):

document.createElement('item-view').build('my message')

Поскольку веб-компоненты могут наследоваться, мы получаем классический ООП с инкапсуляцией данных внутри веб-компонента, с доступом через querySelector(), и гибким управлением вложенным стейтом (родительский веб-компонент сам решает — сохранять дочерние элементы в дереве, или пересоздавать их заново). Дополнительный бонус — формы HTML можно не пересоздавать, а значит результаты пользовательского ввода сохраняются непосредственно в контролах. С точки зрения управления состоянием, необходимость использования фреймворков становится неочевидной, а что касается моделей реактивности — да, стандарты ничего не говорят по этому поводу, и мы можем либо использовать обычные колбэки (push-реактивность), либо любой другой метод.

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

Приложение состоит из следующих компонентов:

  • login-session — компонент верхнего уровня, работает в 2-х режимах: в первом он выводит форму авторизации, во втором — информацию о пользователе (с гиперссылкой для смены логина), а также 2 дочерних компонента — список сообщений и строку поиска. Предоставляет геттер login_name().
  • item-list — компонент выводит форму для отправки сообщения, и список уже отправленных. Предоставляет геттеры для полного и отфильтрованного списка.
  • item-view — компонент, отображающий одно сообщение. При создании сохраняет в своих свойствах текущее имя пользователя, дату создания и текст сообщения. Предоставляет геттер для текста.
  • item-search — форма поиска, обращается к компоненту item-list и выводит результат фильтрации в модальное окно.
  • input-text — расширение стандартного HTML-элемента input, используется для предварительной обработки пользовательского ввода.

Мы видим, что код получается предельно читаемым, инкапсуляция данных и алгоритмов внутри компонента устраняет возможные побочные эффекты, а самое главное — это работает быстро, и не требует дополнительных ресурсов в виде виртуального DOM и рантайма.

UPD
Товарищи правильно заметили, что у меня отсутствует защита от XSS. Для демонстрации подхода я создал расширение стандартного элемента input, в обработчике которого фильтруется нежелательный пользовательский ввод. Примерно так:

customElements.define('input-text',
    class extends HTMLInputElement {
        constructor() {
            super()
            this.setAttribute('type', 'text')
            this.addEventListener('input', ev => {
                this.value = this.value.replace(/</g, '&lt;')
            })
        }
    },
    {extends:'input'}
)

Теперь достаточно везде использовать input-text вместо стандартного input, и враг не пройдет.
Прошу прощения за несколько искусственный пример, в реальной жизни эскейпить ввод нужно в момент приема из сети, но в нашем примере отсутствует серверная часть, плюс хотелось продемонстрировать наследование от стандартных элементов HTML.

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