Решения навроде реакта приучили нас, что должен быть один некий большой стор с данными и компоненты подписанные на его изменения и в веб-компонентах тоже пытаются углядеть отсутствие такой подсистемы на самом деле подразумевая даже может неосознанно обратный биндинг и в веб-компонентах он уже есть.
У каждого элемента есть атрибуты значения которых мы можем менять. И если перечислить имена в хуке observedAttributes, то при их изменении будет автоматически вызываться attributeChangedCallback() в котором мы можем определить поведение компонента при изменении атрибута. Используя магию геттеров нетрудно сделать обратный биндинг схожим образом.
Мы уже набросали кое-какой проект в первой части и сегодня продолжим пилить его дальше.
Сразу стоит оговорить одно ограничение, по крайней мере в текущей реализации заключающееся в том, что значением атрибута может быть только примитивное значение, задаваемое литералом и приводимое к строке, но вообще можно таким образом передавать и “обджекты” используя одинарные кавычки снаружи и двойные для определения значений и полей обжекта.
<my-component my='{"foo": "bar"}'></my-component>
для использования этого значения в коде можно реализовать автомагический геттер который будет вызывать у него JSON.parse().
Но пока что нам хватит числового значения счетчика.
Добавим нашему элементу новый атрибут count, укажем его как observed, обработчик клика заставим инкрементировать этот счетчик, а на хук изменения добавим обновление отображаемого значения по прямой ссылке логику которого реализуем в отдельном переисползуемом методе updateLabel().
export class MyWebComp extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
let html = document.importNode(myWebCompTemplate.content, true);
this.attachShadow({mode: 'open'});
this.shadowRoot.appendChild(html);
this.updateLabel();
}
updateLabel() {
this.shadowRoot.querySelector('#helloLabel').textContent = 'Hello ' +
this.getAttribute('greet-name') + ' ' + this.getAttribute('count');
}
static get observedAttributes() {
return ['count'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'count') {
this.updateLabel();
}
}
showMessage(event) {
this.setAttribute('count', this.getAttribute('count') + 1);
}
}
Каждый элемент получил независимый автоматически обновляемый счетчик.
Домашнее задание: реализуйте приведение значения счетчика к числу и использование через авто-геттер;)
Возможны конечно и более продвинутые варианты. Например, если не брать в расчет простые колбеки и евенты, с помощью нативного Proxy и тех же геттеров и сеттеров можно реализовать функционал обратного биндинга.
Добавим файл my-counter.js с классом такого вида
export class MyCounter extends EventTarget {
constructor() {
super();
this.count = 0;
}
increment() {
this.count++;
this.dispatchEvent(new CustomEvent('countChanged', {
detail: { count: this.count }
}));
}
}
Мы унаследовали класс от EventTarget, чтобы другие классы могли подписываться на события выбрасываемые объектами этого класса и определили свойство count которое будет хранить значение счетчика.
Теперь добавим инстанс этого класса как статическое свойство для компонента.
<script type="module">
import { MyWebComp } from "./my-webcomp.js";
import { MyCounter } from "./my-counter.js";
let counter = new MyCounter();
Object.defineProperty(MyWebComp.prototype, 'counter', { value: counter });
customElements.define('my-webcomp', MyWebComp);
</script>
А в коде компонента подпишем на изменение значения метод обновления лейбла updateLabel(), в который добавим отображение значения из глобального расшаренного счетчика. А в обработчик нажатия прямой вызов инкрементирующего метода.
export class MyWebComp extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
let html = document.importNode(myWebCompTemplate.content, true);
this.attachShadow({mode: 'open'});
this.shadowRoot.appendChild(html);
this.updateLabel();
this.counter.addEventListener('countChanged', this.updateLabel.bind(this));
}
updateLabel() {
this.shadowRoot.querySelector('#helloLabel').textContent = 'Hello ' +
this.getAttribute('greet-name') + ' ' + this.getAttribute('count') + ' ' + this.counter.count;
}
static get observedAttributes() {
return ['count'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'count') {
if (this.shadowRoot) {
this.updateLabel();
}
}
}
showMessage(event) {
this.setAttribute('count', parseInt(this.getAttribute('count')) + 1);
this.counter.increment();
}
}
Несмотря на то, что каждый элемент получил два счетчика, каждое его значение будет инкрементироваться независимо, но значение расшаренного счетчика будет одинаковым у обоих элементов, а значения индивидуальных будет определятся количеством кликов только по ним.
Таким образом, мы получили прямое связывание в прямом биндинге и за счет применения эвентов слабое в обновлении этого значение. Ничто конечно не мешает и инкремент реализовать через эвенты, сбиндив метод increment() к лисенеру одноименного эвента:
export class MyCounter extends EventTarget {
constructor() {
super();
this.count = 0;
this.addEventListener('increment', this.increment.bind(this));
}
increment() {
this.count++;
this.dispatchEvent(new CustomEvent('countChanged', {
detail: { count: this.count }
}));
}
}
и заменив вызов метода на выброс евента:
export class MyWebComp extends HTMLElement {
...
showMessage(event) {
this.setAttribute('count', parseInt(this.getAttribute('count')) + 1);
this.counter.dispatchEvent(new CustomEvent('increment'));
}
}
Что это меняет? теперь если в ходе развития метод increment() будет убран или изменен, корректность нашего кода нарушится, но ошибок интерпретатора возникать не будет, т.е. сохранится работоспособность. Такая характеристика называется слабой связностью.
В разработке нужны как прямое так и слабое связывание, обычно внутри логики одного модуля-компонента принято реализовывать прямое связывание, а между различными модулями и компонентами — слабое, чтобы обеспечить гибкость и расширяемость всей системы. Семантика HTML предполагает высокий уровень такой гибкости, т.е. предпочтение слабой связности, но приложение будет работать надежнее если связи внутри компонентов будут прямыми.
Мы можем по старинке вешать обработчики и вызывать события на глобальном объекте document.
export class MyWebComp extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
let html = document.importNode(myWebCompTemplate.content, true);
this.attachShadow({mode: 'open'});
this.shadowRoot.appendChild(html);
this.updateLabel();
this.counter.addEventListener('countChanged', this.updateLabel.bind(this));
document.addEventListener('countChanged', this.updateLabel.bind(this));
}
disconnectedCallback() {
document.removeEventListener(new CustomEvent('increment'));
document.removeEventListener(new CustomEvent('countChanged'));
}
...
showMessage(event) {
this.setAttribute('count', parseInt(this.getAttribute('count')) + 1);
this.counter.dispatchEvent(new CustomEvent('increment'));
document.dispatchEvent(new CustomEvent('increment'));
}
}
export class MyCounter extends EventTarget {
constructor() {
super();
this.count = 0;
this.addEventListener('increment', this.increment.bind(this));
document.addEventListener('increment', this.increment.bind(this));
}
increment() {
this.count++;
this.dispatchEvent(new CustomEvent('countChanged', {
detail: { count: this.count }
}));
document.dispatchEvent(new CustomEvent('countChanged', {
detail: { count: this.count }
}));
}
}
Поведение никак не отличается, но теперь нам надо подумать о том, чтобы убрать обработчик события с глобального объекта если сам по себе элемент удален из дерева. Благо веб-компоненты предоставляют нам не только хуки конструкции, но и деструкции позволяя избегать утечек памяти.
Также есть еще один немаловажный момент: эвенты элементов дерева могут взаимодействовать между собой посредствам механизма называющегося “баблинг”.
Когда пользователь кликает мышкой на нашем элементе, эвент отработав на самом элементе, начинает словно круги на воде вызываться у его родителя, затем — у родителя этого родителя и так до самого корневого элемента.
В любой точке этой цепочки вызовов эвент может быть перехвачен и обработан.
Это конечно не совсем один и тот же эвент, а его производные и контексты хоть и будут содержать ссылки друг на друга как например в event.path, но не будут полностью совпадать.
Тем не менее, этот механизм позволяет связать дочерние элементы с родительскими таким образом, чтобы эта связать не нарушала инженерных паттернов, т.е. без прямых ссылок, а только за счет собственного поведения.
В стародавние времена это также порождало и немало хлопот, когда события одних элементов вызывали “круги” или эхо в каких-то нежелательных для реакций участках документа.
Годами веб-разработчики боролись с “пропагацией” (распространением или всплытием) евентов и стопили и превентили их как могли, а для их комфортного использования как и в случае с айдишниками не хватало только изоляции теневого дерева.
Тут все волшебство в том, что эвенты не баблятся за пределы Теневого Дерева. Но вы тем не менее можете поймать эвент выброшенный дочерними элементами его в хостовом компоненте и прокинуть выше по дереву в виде какого-то другого по смыслу эвента, ну или просто обработать.
Чтобы понять как это все работает обернем наши элементы webcomp в контейнер вот так:
<my-webcont count=0>
<my-webcomp id="myWebComp" greet-name="John" count=0 onclick="this.showMessage(event)"></my-webcomp>
<my-webcomp id="myWebComp2" greet-name="Josh" count=0 onclick="this.showMessage(event)"></my-webcomp>
</my-webcont>
У контейнера будет вот такой код:
export class MyWebCont extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.addEventListener('increment', this.updateCount.bind(this));
}
updateCount(event) {
this.setAttribute('count', parseInt(this.getAttribute('count')) + 1);
}
static get observedAttributes() {
return ['count'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'count') {
this.querySelectorAll('my-webcomp').forEach((el) => {
el.setAttribute('count', newValue);
});
}
}
}
В connectedCallback() мы повесим обработчик на событие increment, которое будут выбрасывать дочерние элементы. Обработчик будет инкрементировать собственный счетчик элемента, а колбек на изменение его значения будет проходить по всем дочерним элементам и инкрементировать их счетчики, на которых уже висят ранее разработанные нами обработчики.
Код дочерних элементов изменится незначительно, собственно говоря все что нам нужно это чтобы событие increment выбрасывал сам элемент а не его агрегаты, и делал он это с атрибутом bubbles: true.
export class MyWebComp extends HTMLElement {
...
showMessage(event) {
this.setAttribute('count', parseInt(this.getAttribute('count')) + 1);
this.counter.dispatchEvent(new CustomEvent('increment'));
this.dispatchEvent(new CustomEvent('increment', { bubbles: true }));
document.dispatchEvent(new CustomEvent('increment'));
}
}
Теперь, несмотря на некоторую общую хаотичность, первые счетчики элементов всегда будут показывать значение родителя. Все вместе, что у нас тут сегодня получилось это конечно не пример для подражания, а лишь обзор возможностей и техник, которыми вы можете организовать по-настоящему модульное взаимодействие между компонентами, обходясь минимумом инструментов.
Готовый код для референса вы можете найти все в том же репозитории, в бранче events.
Комментарии (34)
epishman
24.07.2019 14:26+1Сама идея, что у приложения есть данные (State) и представление (DOM) — создает дополнительные сложности, которые вынужден был решать Реакт. Но если мы примем другой подход — данные приложения, состояние приложения — это и есть DOM, и нет ничего кроме DOM — тогда все становится сильно проще — не нужен двусторонний биндинг, не нужен искусственный глобальный стейт и т.д. Одна проблемка — в DOM лежат строки, но и JS динамический язык, так что штепсель соответствует розетке, а если хочется извлекать из DOM типизированные данные и в удобном виде — пишем геттеры и все. Все это можно сделать даже без кастом-элементов, на старых стандартах.
syncro Автор
24.07.2019 14:38атрибуты это строки, да, и в DOM вы можете зарендерить строку, но это не означает, что браузер хранит все в виде текстового файла. Для создания можно использовать document.createElement или просто конструкторы и аттрибуты. В дереве есть и индексация, по айди выборка быстрее, чем селектором или даже элемент можно сразу получить из глобального пространства, есть и типизация самих элементов за счет того, что за каждым элементом вы можете закрепить свой тип-класс, и это все позволит просто и изящно структурировать код, так что-бы не было огромных портянок с вермишелями.
epishman
24.07.2019 14:58+1Все верно. Если у меня приложение многостраничное, и пользователь по ссылке переходит на другую страницу — я просто сохраняю DOM текущей страницы в глобальный массив, и в итоге у меня массив живых страниц, уже отрендеренных, с живыми данными в формах, и навигация по таким готовым страницам заключается всего-лишь в подмене DOM — никакого повторного рендера, все мгновенно.
justboris
24.07.2019 23:08А как это будет работать на практике? Возьмем, к примеру, вот этот самый блок комментариев на Хабре. Каким образом вы бы собрали из DOM его состояние, чтобы реализовать редактирование комментария, например?
epishman
24.07.2019 23:53Я больше это к формам применял, а тут… текст достану из innerHTML, автора отсюда. <span class=''user-info__nickname''>epishman
Естественно, когда все данные приходится доставать из DOM, верстку делаешь с учетом этого требования, ID где надо прописать, иногда скрытые спаны приходится вставлять, пишется одна JS-функция на все это.justboris
25.07.2019 11:06+1Да, есть такая библиотека, Stimulus, работает именно как вы и описываете. Я даже опубликовал на Хабре перевод их анонса.
Однако, в массы такой подход не идет почему-то.
epishman
25.07.2019 12:02Спасибо за ссылку!
PS
Массы всегда следуют широкой дорогой, легче ведь 10 понятных кодеров нанять, чем с двумя непонятными дизайнить, тем более если деньги есть :)
i360u
25.07.2019 08:29+1Идея о том, что DOM может быть основным хранилищем данных, поскольку он в любом случае их хранит, имеет под собой веские основания. Тем более, что помимо данных, пришедших, к примеру с сервера, есть еще положение скролла, элементы в фокусе и так далее, то есть все то, чего нет обычно в реализациях внешних хранилищ, и DOM, вроде как, имеет больше прав называться «стейтом». Однако есть одно большое «но»: DOM — это древовидная структура, а ваши реальные данные — чаще являются графом. И если любое дерево может быть представлено в виде графа, то граф в виде дерева — уже нет. На практике это означает, что если у вас есть, к примеру, некая зависимость в интерфейсе от того, залогинен пользователь или нет — вы не можете рассчитывать на достоверность этого флага в одной из нод вашего DOM, пока вы не выделите для хранения этого значения единый «источник правды». Насколько я помню, именно об это обожглись, в свое время, разработчики Facebook, перед тем как начали пропагандировать flux-архитектуру.
Все это можно сделать даже без кастом-элементов, на старых стандартах.
— и в итоге вы получите толстый оверхед в js как в React.epishman
25.07.2019 08:38Я реакта не знаю, но насколько понимаю, там байндинги нужны на любые данные, даже на статику, в моем случае — только на то, что изменяется, или используется при изменении чего-то другого, остальные 80% статики формируются один раз, и забыли.
PS
Кстати, спасибо за наводку, изучу историю этого дела…
epishman
25.07.2019 08:47Про безопасность честно не думал, для внутри-корпоративного ПО это решалось другими средствами, а тут даже не знаю, возможно, что-то и придется хранить в jS.
leybniz
Для чего так утруждать руки набором кода? Есть же lit element. Всё это уже прожевано там.
syncro Автор
в LitElement вертска пишется прямо в джаваскрипт код, что нарушает инженерный принцип разделения ответственностей и лишает разработчика гибкости получаемой от использования нативных шаблонов. Например, с помощью веб-компонентов я легко могу сделать систему, которая будет поддерживать скины и кастомизацию не только за счет изменения стилей, но и самого лейаута.
epishman
Это пунктик гугла, во Flutter они вообще верстку выпилили, оставив голые классы.
syncro Автор
на мой взгляд это скорее поветрие, они обожглись с Polymer, когда все вроде было сделано не плохо, но за счет HTML Imports получался код в верстке и маятник качнуло в другую крайность, уже утвержденную популярностью реакта.
epishman
А html импорты разве реализованы, вроде там проблема с синхронными конструкторами была, тогда вариантов нет — скрипт дает больше гибкости.
syncro Автор
отдельные браузерные вендоры от импортов отказались «на отрез», поэтому в стандарты W3C они не пошли, а команда полимера на тот момент уже вовсю готовила 3ю версию фреймворка серьезно базировавшуюся на механизме импортов и все дальнейшее развитие тоже пришлось отменить в пользу LitElement
epishman
Спасибо!
i360u
В 3-м Полимере основным нововведением как раз был переход от html-импортов к ES-модулям. Вполне закономерно и грамотно. Но Lit — оказался быстрее… Я лично не сравнивал, но судя по блогу разрабов — это был основной аргумент перехода. Сейчас Polymer — это скорее экосистема, чем библиотека. И LitElement входит в нее. Но из-за всех этих метаний, веб-разработчики отвернулись от этого минизоопарка, хотя и стоит отдать должное сообществу Полимера — они сделали очень многое для внедрения новых стандартов.
i360u
Принцип разделения ответственности не определяет критерии разделения. То есть вы сами вольны делить свой код в согласии с вашими приоритетами и в в случае с LitElement никаких особых проблем с этим нет. Как нет их и с возможностью поддержки скинов и другой кастомизации: вам доступны те-же самые пользовательские свойства CSS и кастомные атрибуты. Но мне самому LitElement не нравится, более всего потому, что вместо того, чтобы быть альтернативой реакту, он пошел той-же скользкой дорожкой на поводу у фанатов функциональщины. И из за этого, к примеру, он медленнее, чем мог бы быть.
syncro Автор
у меня был кейс angular material/cdk, когда мне надо было сделать другой пейджер и свой фильтр, легко переопределял классы и использовал свои шаблоны, менял на совершенно иной принцип работы и заодно локализовал, не уверен, что такое было бы возможно с фреймворком предполагающим прямую линковку и хардкод верстки
i360u
Но в Lit шаблон — это же просто js-строка, которую вы можете генерить на лету, как вам угодно, или брать целиком из различных источников. Совсем не обязательно что-то хардкодить.
syncro Автор
как я понимаю, из источников не получится подгрузить асинхронно, т.е. по сети (динамически), только если предзагрузить шаблоны до инициализации самих компонентов и в этом случае конвертация обратно в строку тоже будет лишней. Единственный вариант тут будет дорендеривать шаблоны в какие-нибудь слоты, но это все будет запутаннее чем даже продолжать наворачивать html-строки в коде, поэтому никто в сторону шаблонов отделенных от кода и не посмотрит.
i360u
Не совсем понял вашу проблему, но мне почему-то кажется что это решается проще. В любом случае, для работы с темами, скинами и вариантами отображения мне всегда хватало CSS-переменных и кастомных атрибутов. Если изменения компонента должны быть более глобальными — поможет создание отдельного компонента или старое доброе наследование.
syncro Автор
если у вас шаблоны отдельно, то их надо загружать, загружать их надо будет по сети, особенно если вы имеете дело с SPA и некоторой модульной архитектурой на приличном размере, а по сети значить асинхронный запрос с отложенным результатом, т.е. это надо будет делать либо до инициализации/рендера самих компонентов, либо как-то хитрить. Видимо так же нетривиально, как и в случае если вам пейджер с контролами < — -> надо будет перескинить на пейджер с пейджанацией 1 2 3 используя только css. Насчет наследования я не уверен, тут штука в том, что компоненты могут находится в тесном взаимодействии друг с другом которое будет полагаться на конкретные класс, а не их даже наследники. Например у вас есть такой лейаут:
если разработчик реализовал прямое связывывание всех этих компонентов (хардкод) без возможности подменить скажем pager, своей реализацией, с доработкой такого кода могут быть проблемы, в то же время если он разрабатывал уважая необходимость выносить верстку-лейаут в отдельные шаблоны, эти шаблоны вы переопределить скорее всего сможете.
i360u
Не совсем понимаю зачем загружать шаблоны отдельно… Почему сразу нельзя создать несколько шаблонов и передавать в render нужный? Почему нельзя переопределить класс-конструктор?
syncro Автор
отдельно загружать придется потому что они структурированы отдельно, потому что все вместе их будет неудобно поддерживать даже может более неудобно, чем если они захардкожены в джаваскрипте. Можно конечно склеить, но тогда либо опять же все придется загружать и разбирать, а их может быть очень много либо полагаясь на запрашиваемый роут, но и в этом случае при переходе на другой, надо будет загрузить шаблоны для добавляемых элементов. Переопределить что-то и встроить в инфраструктуру можно восновном когда разработчики фреймворка и решения на нем об этом как-то подумали, но на деле я такой уровень видел только в ангуляре и то вот у него конфигурирующие директивы вынесены в аннотации, что осложняет решение задачи переопределения, т.е. вы можете у своего наследника сделать другой темплейт, но переконфигурировать имеющийся компонент на использование вашего темплейта задача уже нетривиальная.
i360u
Если вы код контролируете — проблемы нет вообще. Если не контролируете — тогда не понятно как именно вы интегрируетесь. Почему нельзя сделать так:
Тут ведь даже нет разницы как именно реализован рендер, и если в верстке был использован какой-то конкретный компонент, и вы не можете это место никак изменить, в итоге все равно будет использован именно ваш.
syncro Автор
чаще есть возможность поменять конфигурацию, чем код, а системы которые позволяют своими изменениями переопределить базовые вообще представляют исключительные примеры хорошего проектирования. В вашем примере вы определяете новый элемент-тег. То есть вы сможете использовать его только для своих кнопок, а все остальные кнопки абстрактной многомодульной системы этими изменениями затронуты не будут, а часто именно это и требуется.
justboris
Про производительность интересно. Что могли в lit-element сделать по-другому, чтобы стать быстрее?
i360u
Для ускорения первичной отрисовки, могли исключить этап обработки строки шаблона в js-рантайме для привязки данных перед парсингом в DOM. Вариант с клонированием шаблона из template-контейнера и заполнением его данными, но уже через DOM API работает быстрее почти в 2 раза, судя мо моим тестам. Даже если template-контейнер (элемент ) создавать динамически из той-же js-строки и хранить в памяти. Для ускорения рендера изменений — могли бы не делать этого асинхронно. Синхронный рендер работает быстрее на 20% примерно (браузер умный, он не перерисовывает документ сразу если изменение элементов идет в цикле). Как то так.
justboris
Насколько я понимаю, предлагается класть шаблон элемента в
<template>
тэг и тем самым немножечко сэкономить, избавив lit-html от этапа инициализации. Как я вижу из исходников lit-html, они кешируют результат, поэтому затраты на создание шаблона одноразовые, от числа ре-рендеров не зависят.С другой стороны, если вынести шаблоны наружу, то начинаются другие проблемы. Например, ваш фронтенд начинает зависеть от бекенда, который ему эти шаблоны будет будет рендерить в изначальном html. С ленивой загрузкой компонентов тоже непонятно как быть.
Синхронное обновление свойств – это проклятие веб-компонентов в целом. Свойства обновляются поштучно, и изнутри компонента вы не можете предугадать, закончили вам передавать свойства или нет. Я уже писал об этом подробнее. И в статье Рича Харриса, почему Svelte не использует веб-компоненты, об этом тоже есть. В общем, lit-element тут не одинок, все веб-компоненты этим страдают.
i360u
Я прекрасно помню ту вашу статью и нахожу ее тезисы ошибочными. В прошлый раз у нас не получилось какого-то особо конструктивного диалога, не думаю что нам стоит тратить время на это снова. Мне лучше потратить время на документацию своего велосипеда и выкатить статью про него. К сожалению пока не получилось выделить на это окно в рабочем графике, но я это сделаю в любом случае.
syncro Автор
>> то начинаются другие проблемы
грузить шаблоны можно и фронтенд кодом, а их нахождение в дереве является отличным механизмом кеширования, т.к. когда вы кешируете строку или функцию ее генерации, вы все равно эту строку потом рендерите в дерево, а иначе рендера не происходит, что оптимальнее
>> С ленивой загрузкой компонентов тоже непонятно как быть
с ленивой загрузкой в веб-компонентах ситуация лучше всех, т.к. для браузера динамически дорендеренный <my-custom-element> преобразуется в ваш кастомный компонент без особенных препятствий и с выполнением полагающихся хуков
>> Свойства обновляются поштучно, и изнутри компонента вы не можете предугадать
можно передавать джейсон или не использовать атрибуты, а использовать эвенты или прямое связывание
justboris
Собственно, lit-html это сейчас и делает. Если я правильно понял i360u, то он предлагает следующий уровень оптимизации — вынести это из JS совсем