Я еще помню времена, когда принудительное ООП было доминирующим паттерном. Сейчас это очевидно не так, и все современные ЯП предлагают намного больше парадигм. Однако в области веб-разработки тотально (и на мой взгляд неоправданно) доминирует реактивность, которая в свое время эффективно решила проблему несовершенного DOM API, попутно создав несколько архитектурных проблем вроде централизованного хранилища данных (что вообще-то нарушает принципы SOLID), или нестандартного и переусложненного механизма взаимодействия компонентов (контекст-провайдер и т.д.). Сейчас, в условиях современных WEB-стандартов, реактивность нуждается как минимум в некотором переосмыслении. Например, реактивная парадигма прекрасно выглядит, если наш стейт централизован (не случайно самый популярный стек это react / redux), а если он распределен по дереву компонентов (что архитектурно правильней), то зачастую нам нужно меньше реактивности, а больше аккуратной императивности.
Свои проекты я пишу на ванильных веб-компонентах, в стиле императивного ООП, с минимальным количеством библиотечного кода, и очень редко действительно скучаю по реактивности. Если бы чистая реактивность покрывала все потребности разработчика, не пришлось бы в каждом фрейморке создавать императивные лазейки, позволяющие модифицировать компонент вместо его пересоздания (рефы, неуправляемые формы, $parent и т.д.). А когда стоит задача получить экстремально-отзывчивое приложение, то волей-неволей приходится думать (и вручную контролировать) момент и способ обновления DOM, как собственно и сделано в большинстве хороших PWA (например Twitter) и не сделано в менее хороших PWA (например VK). Так, большие списки выгодней формировать методом insertAdjacentHTML(), который вполне способен работать с текстово-параметризуемыми веб-компонентами, но вряд-ли применим к управляемым компонентам, и таких примеров достаточно.
Какие проблемы решает реактивность, и как их можно решить иначе:
- Состояние веб-приложения — это переменные JS, а DOM является лишь отображеним, которое нужно уметь «умно» пересоздавать. Идея хорошая, но почему собственно данные должны храниться в объектах JS, а не напрямую в узлах DOM? Когда мы говорим «данные веб-приложения», мы же имеем в виду не базу данных, и не бизнес-логику, а исключительно пользовательский интерфейс, где все данные уже так или иначе относятся к слою View. Так почему нельзя изначально организовать дерево DOM таким образом, чтобы оно отражало структуру предметной области? Компонентный подход тут как нельзя кстати — веб-компоненты могут гибко инкапсулировать собственный стейт (приватные члены классов JS), иметь документированное публичное API в виде геттеров/сеттеров, в пределах своей иерархии эмитировать и перехватывать пользовательские события DOM, адресоваться с помощью querySelector(), регистрировать себя глобально в window, либо в пользовательской «шине событий» — и все это стандартными средствами, без сторонних концепций, привносимых различными фреймворками. В первой статье я пытался это сказать, попробую еще раз.
- Данные изменяются в одном месте, и автоматически отображаются везде. Это сильное преимущество, но вообще-то грамотно-спроектированное приложение строится не из стандартных элементов HTML, а из пользовательских компонентов, которые к тому же (теоретически) могут управляться разными фреймворками, и поэтому могут иметь несовместимое стейт-API. Так зачем мне в этом случае React или Vue? Мне же нужна реактивность компонентов, а не реактивность HTML. С компонентами я могу использовать любую библиотеку реактивности, или обходиться без нее — в зависимости от масштаба приложения.
Кроме того, подобная реактивность вообще не часто востребована. Я помню сайты с «дизайном 90-х», где действительно, одни и те же данные могли многократно отображаться на странице в разных видах. Однако, сейчас дизайн тяготеет к минимализму, а в мобильных PWA тем более — ввиду малости диагонали сложные формы приходится разбивать на несколько последовательных экранов, и у нас всегда есть событие смены экрана, в котором можно обновить нужную часть DOM. То есть вместо push-реактивности нам достаточно иметь набор геттеров к данным, где бы они реально не хранились.
- Функциональный (декларативный) код проще тестировать и поддерживать. С этим невозможно спорить, однако к сожалению функциональщина + виртуальный 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 могут отличаться по своей структуре до полной неузнаваемости. С другой стороны, веб-компоненты настолько просты, что кроме паттернов ООП вам больше ничего не нужно знать.
Спасибо за внимание.
MaZaAa
Жесть, прямо сплю и вижу чтобы писать вот такой код и чтобы все вокруг так писали:
https://github.com/balajahe/balajahe.github.io/blob/master/real_agent/obj-list.js
Я даже не знаю что и сказать не используя матных слов, в принципе этот код сам за себя говорит) Представляю когда проект написанный вот так попадется в чьи-то руки на доработку, вот кто-то обрадуется))
balajahe Автор
Что конкретно вам не нравится? Внутренние стили? Ну используйте внешние. Работу с БД в отдельный компонент? Никто не мешает. Только зачем.
MaZaAa
Мне тут не нравится абсолютно все, вообще весь код, а не что-то конкретное.
nin-jin
Мы не в детском саду, чтобы оперировать понятиями "нравится / не нравится".
Конкретно этот код плох следующим:
balajahe Автор
Все что вы перечислили, имеет место быть. Но как только вы устраните все эти проблемы, у вас появятся другие, например количество файлов в проекте, и вообще кода — утроится. Покажите мне совершенный реактивный код подобного приложения. Можно и бенчмарк сделать, на предмет медленного DOM.
TheShock
nin-jin
https://github.com/hyoo-ru/notes.hyoo.ru
https://bench.hyoo.ru/app/#sample=mol~native-html/sort=fill
andreymal
Не по теме поста, но на дисплее 1600x900 оказывается невозможно удалить заметку, потому что кнопка удаления не влезает, а горизонтальная прокрутка не работает
nin-jin
А какой браузер/ось у вас? Можно скриншот?
andreymal
Гифка с тщетными попытками проскроллить https://habrastorage.org/webt/tw/jp/zq/twjpzqqlmdgljgionq3zwir2im0.gif
nin-jin
Выкатил фикс для ФФ, попробуйте теперь.
andreymal
Ага, теперь я худо-бедно научился пользоваться этой прокруткой, но такое использование scroll-snap всё равно жутко неудобно
nin-jin
А в чём не удобство?
balajahe Автор
Спасибо, мол вижу впервые, вечером изучу !
pfffffffffffff
Такое ощущение что вы не умеете готовить реактивный код)
nkdab
Извините за, возможно, неуместный вопрос. Но в чем проблема количества файлов в проекте, когда они удобно организованы?
balajahe Автор
Я очень ценю локальность. Когда весь код компонента перед глазами, и стили и разметка и логика. И желательно не более 200 строк на компонент. Когда-то такой паттерн явисты продвигали, и я с тех пор стараюсь следовать.
justboris
В этом коде перемешано создание UI и логика работы с данными. Это плохо, потому что не позволяет менять одну часть, не трогая другую.
Кроме того, а как вы тестируете компоненты написанные таким образом?
balajahe Автор
Положить объект в БД в исходном виде это не логика работы с данными, это часть интерфейса, имхо. Не представляю кейса когда придется менять одно, не трогая другое. В сложных формах это бы имело смысл.
TheShock
Найболее отвратительно — полное отсутствие статики. Куча каких-то непонятных строк, которые вызывают строки и которые возвращают строки. Что это за кошмар?
А также «sep», «new», «msg», «obj-new-medias» и так далее — строки, строки, строки, строки. Все одинаковые, все ничего не говорят, все захардкоджены прям внутри кода. Некоторые ещё и повторяются несколько раз.
Как только я такое вижу — помечаю код как жуткое легаси с которым запрещено работать до полного и нормального переписывания.
babylon
Дело тут не столько в кодировании, сколько в отсутствии даже намёка на проектирование. Грамотное проектирование в принципе не допускает появление подобного кода. Фреймворк должен ограничивать такое кодирование определяя некоторые правила для написания сигнатур. В крайнем случае предлагать автоматизировать рутинные операции.
Проектирование как бы подразумевает декларативный подход. Если много синтаксического шума, то это явный и чёткий признак некачественного проекта. Это можно увидеть даже не понимая о чём собственно код. Насчёт коротких алиасов. Я — за! Но только в локальных нейсмпейсах. vintage знает о чём пишет.
Только для меня — избыточность, линейность и императивность наиболее яркие признаки плохого ПТУ стайл кодирования. api github это хороший пример
TheShock
balajahe Автор
Все короткие имена — локальные, внутри класса. Кроме собственно имен веб- компонентов, которые через дефис, на это я повлиять не могу — стандарт.
nin-jin
Вот не сказал бы. Они вываливают клиенту 100500 полей, часть из которых дублируется. А потом прикрывают это дело довольно жёсткими лимитами.
babylon
Вываливают сопоставимо с https://core.telegram.org/schema :)
Chamie