18 июня

Реактивные веб-технологии излишне переоценены

Разработка веб-сайтовJavaScriptReactJSVueJS
Привет, Хабр!

Я еще помню времена, когда принудительное ООП было доминирующим паттерном. Сейчас это очевидно не так, и все современные ЯП предлагают намного больше парадигм. Однако в области веб-разработки тотально (и на мой взгляд неоправданно) доминирует реактивность, которая в свое время эффективно решила проблему несовершенного DOM API, попутно создав несколько архитектурных проблем вроде централизованного хранилища данных (что вообще-то нарушает принципы SOLID), или переусложненного механизма взаимодействия компонентов. В условиях современных WEB-стандартов, реактивность нуждается как минимум в некотором переосмыслении. Например, реактивная парадигма прекрасно выглядит, если наш стейт централизован (не случайно самый популярный стек это react / redux), а если он распределен по дереву компонентов (что архитектурно правильней), то зачастую нам нужно меньше реактивности, а больше аккуратной императивности.

Свои проекты я пишу на ванильных веб-компонентах, в стиле императивного ООП, с минимальным количеством библиотечного кода, и очень редко действительно скучаю по реактивности. Если бы чистая реактивность покрывала все потребности разработчика, не пришлось бы в каждом фрейморке создавать императивные лазейки, позволяющие модифицировать компонент вместо его пересоздания (рефы, неуправляемые формы, $parent и т.д.). А когда стоит задача получить экстремально-отзывчивое приложение, то волей-неволей приходится думать (и вручную контролировать) момент и способ обновления DOM, как собственно и сделано в большинстве хороших PWA (например Twitter) и не сделано в менее хороших PWA (например VK). Так, большие списки выгодней формировать методом insertAdjacentHTML(), который вполне способен работать с текстово-параметризуемыми веб-компонентами, но вряд-ли применим к управляемым компонентам, и таких примеров достаточно.

Какие проблемы решает реактивность, и как их можно решить иначе:

  1. Состояние веб-приложения — это переменные JS, а DOM является лишь отображеним, которое нужно уметь «умно» пересоздавать. Идея хорошая, но почему собственно данные должны храниться в объектах JS, а не напрямую в узлах DOM? Когда мы говорим «данные веб-приложения», мы же имеем в виду не базу данных, и не бизнес-логику, а исключительно пользовательский интерфейс, где все данные уже так или иначе относятся к слою View. Так почему нельзя изначально организовать дерево DOM таким образом, чтобы оно отражало структуру предметной области? Компонентный подход тут как нельзя кстати — веб-компоненты могут гибко инкапсулировать собственный стейт (приватные члены классов JS), иметь документированное публичное API в виде геттеров/сеттеров, в пределах своей иерархии эмитировать и перехватывать пользовательские события DOM, адресоваться с помощью querySelector(), регистрировать себя глобально в window, либо в пользовательской «шине событий» — и все это стандартными средствами, без сторонних концепций, привносимых различными фреймворками. В первой статье я пытался это сказать, попробую еще раз.
  2. Данные изменяются в одном месте, и автоматически отображаются везде. Это сильное преимущество, но вообще-то грамотно-спроектированное приложение строится не из стандартных элементов HTML, а из пользовательских компонентов, которые к тому же (теоретически) могут управляться разными фреймворками, и поэтому могут иметь несовместимое стейт-API. Так зачем мне в этом случае React или Vue? Мне же нужна реактивность компонентов, а не реактивность HTML. С компонентами я могу использовать любую библиотеку реактивности, или обходиться без нее — в зависимости от масштаба приложения.

    Кроме того, подобная реактивность вообще не часто востребована. Я помню сайты с «дизайном 90-х», где действительно, одни и те же данные могли многократно отображаться на странице в разных видах. Однако, сейчас дизайн тяготеет к минимализму, а в мобильных PWA тем более — ввиду малости диагонали сложные формы приходится разбивать на несколько последовательных экранов, и у нас всегда есть событие смены экрана, в котором можно обновить нужную часть DOM. То есть вместо push-реактивности нам достаточно иметь набор геттеров к данным, где бы они реально не хранились.
  3. Функциональный (декларативный) код проще тестировать и поддерживать. С этим невозможно спорить, однако к сожалению функциональщина + виртуальный DOM — вещи не бесплатные, они существенно нагружают и процессор и сборщик мусора. Иначе бы не придумали SSR.
    UPD
    Релизная сборка демо-приложения Ionic-React представляет собой 2.3 Мб минифицированного JS, тогда как ванильное приложение, имея функционал в несколько раз больше, весит в 85 раз (!) меньше.

Cейчас я экспериментирую с противоположным подходом (условно «Анти-React»), который заключается в том, что дерево DOM является отличным местом для хранения распределенного стейта, а узлы DOM (обычно веб-компоненты, но иногда и простые HTML-элементы) являются главными (и единственными) строительными кирпичами приложения. Потому что:

  • К элементу DOM можно прицепить любые данные через пользовательские DOM Properties, коллекции таких элементов можно трансформировать в стиле filter/map/reduce, и даже передавать в качестве параметра другим компонентам.
  • Функция querySelector() представляет собой великолепное API для адресации компонентов (какого нет даже в JS) и нет смысл изобретать собственный велосипед внутри искусственно созданного «единого источника правды».
  • Система событий DOM является прекрасным механизмом взаимодействия компонентов в пределах иерархии (и что важно — без зацепления), и позволяет связать компоненты, управляемые разными реактивными библиотеками.
  • Новый синтаксис приватных свойств (#) и геттеры/сеттеры JS дают возможность гибко инкапсулировать стейт (в отличие от пропсов).

Чтобы не скатываться к совсем уж простым примерам, я выложил клиентскую часть одного заказа (дизайн CSS и обмен с сервером делал заказчик, поэтому без них). Это мобильное PWA для условного риэлтора — сотрудник пришел на точку, сделал фотки, записал видео, добавил описание с клавиатуры или голосом, расставил метки и сохранил карточку вместе с гео-координатами в локальную IndexedDB. При появлении связи — фоновая синхронизация с сервером. Попытаюсь продемонстрировать вышесказанное на примере следующих компонентов:

  • Форма списка. Список создается один раз, а далее ловит соответствующие события DOM, и на основании их данных — изменяет сам себя. Например, так обновляется и удаляется карточка объекта (ev.val содержит обновленный объект, а свойство created является ключом объекта в БД и одновременно ID узла). Первая строчка модифицирует БД, вторая модифицирует DOM компонента:

    this.addEventListener('save-item', async (ev) => {
       await this.saveExistObj(ev.val)
       this.querySelector('#' + ev.val.created).replaceWith(
          document.createElement('obj-list-item').build(ev.val)
       )
    })
    
    this.addEventListener('delete-item', async (ev) => {
       await this.deleteObj(ev.val)
       this.querySelector('#' + ev.val).remove()
    })

    Понимаю, что в случае с фреймворком я бы просто оперировал массивом, но приведенный код совсем не многословен, и к тому же понятен любому джуну. Да, у ванильных веб-компонентов есть досадный недостаток — нельзя использовать конструктор с параметрами, поэтому приходится использовать фабричный метод build(), но с этим вполне можно жить.
  • Форма редактирования объекта. Она вызывается по щелчку на элементе списка, и открывается «модально», то есть в абсолютно-позиционированном DIV, который полностью накрывает список. Это удобно, так как при закрытии формы, текущая позиция прокрутки списка сохраняется с точностью до пикселя, а если еще добавить CSS-анимацию, вообще будет красота. Важно, что с точки зрения дерева DOM — форма редактирования является потомком элемента списка, а значит события формы можно перехватывать как на уровне элемента списка, так и на уровне самого списка (как в нашем случае). Когда пользователь жмет кнопку «Save», формируется обновленный объект и эмитируется всплывающее событие, которое перехватывается кодом, приведенным выше. Таким образом форма редактирования не зацеплена за форму списка, она вообще не знает, что именно она редактирует. Вот фрагменты формы редактирования, включая описание кнопки Save:

    import * as WcMixin from '/WcApp/WcMixin.js'
    const me = 'obj-edit'
    customElements.define(me, class extends HTMLElement {
       obj = null
       props = null
       location = null
    
       connectedCallback() {
          WcMixin.addAdjacentHTML(this, `
             ...
             <div w-id='descDiv/desc' contenteditable='true'></div>
             ...
             <media-container w-id='mediaContainer/medias' add='true' del='true'/>
          `)
          ...
    
          this.appBar = [
             ...
             ['save', () => {
                if (!this.desc) {
                   APP.setMessage('EMPTY DESCRIPTION!', 3000)
                   this.descDiv.focus()
    
               } else {
                   const obj = {
                      created: this.obj.created,
                      modified: APP.now(),
                      location: this.location,
                      desc: this.desc,
                      props: this.props,
                      medias: this.medias
                   }
                   this.bubbleEvent('save-item', obj)
                   history.go(-1)
                   APP.setMessage('SAVED !', 3000)
                }
             }]
          ]
       }
       ...
    })

    HTML мы добавляем крохотной библиотекой WcMixin, это единственный библиотечный код в проекте, и все что он делает — для каждого элемента HTML, помеченного атрибутом w-id, создается геттер/сеттер его «значения» (тип значения зависит от типа элемента). Таким образом, this.deskDiv — это ссылка на элемент div, а this.desc — это «значение» элемента div (в данном случае innerHTML). То есть к значениям элементов HTML-формы (input, select, radio и т.д.) мы можем обращаться как к обычным переменным текущего класса. Для веб-компонентов это тоже работает, просто в компонент нужно добавить геттер val (см. ниже). Так, this.medias возвращает из элемента media-container массив медиа-объектов (фото, видео и аудио).
  • Компонент media-container содержит коллекцию медиа (фото, видео, аудио) в виде коллекции дочерних узлов img. В свойстве srс элемента img хранится только превью-картинка (в формате data-url для ускорения рендеринга), а полный объект медиа, содержащий тип, превью и оригинальный блоб, хранится в пользовательском свойстве _source того же самого элемента img. В итоге код геттера, возвращающего массив медиа, выглядит как трансформация списка узлов в массив. И зачем нам тут какой-то глобальный «стейт»?

    get val() {
       return Array.from(this.querySelectorAll('img')).map(el => el._source)
    }

    А так выглядит добавление нового медиа-элемента в контейнер (по щелчку на превью открывается «модальная» форма медиа-плеера):

    add(media) {
       const med = document.createElement('img')
       med._source = media
       med.src = media.preview
       this.addBut.before(med)
    
       med.onclick = (ev) => {
          ev.stopPropagation()
          APP.routeModal(
             'media-player', 
             document.createElement('media-player').build(media)
          )
       }
    }
  • Форма для добавления новых медиа. Также содержит элемент media-container, в момент съемки добавляет объект фотографии, используя приведенный выше метод add():

    this.imgBut.onclick = async () => {
       const blob = await this.imgCapturer.takePhoto(this.imgParams)
       this.mediaContainer.add(
          { 
             created: APP.now(), 
             tagName: 'img', 
             preview: this._takePreview('IMG'), 
             origin: await this._takeOrigin(blob) 
          }
       )
    }
  • Компонент app-app представляет собой каркас приложения, который обеспечивает навигацию страниц (линейную в стиле мастера и модальную в стиле стека), правильную обработку браузерной кнопки «назад» (навигация на основе хэшей), панель приложения с контекстно-зависимыми кнопками, и небольшой дополнительный сервис. Напрямую эти компоненты к теме статьи не относятся, я их оформил отдельным проектом, и использую как собственный мини-фреймворк.

Резюме


Собственно, это все, что я хотел сказать. На примере законченного мобильного приложения я постарался показать, что реактивный фреймворк — вещь совершенно необязательная, а архитектурно-приемлемый код можно получить просто используя современные «ванильные» веб-стандарты.

Я уважаю React, его концепции теоретически интересны и практически полезны, но их слишком много, в результате чего два приложения React могут отличаться по своей структуре до полной неузнаваемости. С другой стороны, веб-компоненты настолько просты, что кроме паттернов ООП вам больше ничего не нужно знать.

Спасибо за внимание.
Теги:Valilla JSвеб-компонентыPWAреактивность
Хабы: Разработка веб-сайтов JavaScript ReactJS VueJS
+7
8,4k 44
Комментарии 108
Лучшие публикации за сутки

Минуточку внимания

Разместить