Как стать автором
Обновить

WebComponents как фреймворки, взаимодействие компонентов

Время на прочтение8 мин
Количество просмотров5.6K
Когда разговор заходит о веб-компонентах, часто говорят: «Ты что хочешь без фреймворков? Там же все готовое». На самом деле есть фреймворки созданные на основе реализаций стандартов входящих в группу веб-компонентов. Есть даже относительно неплохие, такие как X-Tag. Но сегодня мы все равно будем разбираться насколько простым, элегантным и мощным стало браузерное API для решения повседневных задач разработки в том числе организации взаимодействия компонентов между собой и с другими объектами из контекста выполнения браузера, а использовать фреймворки вместе с веб-компонентами всегда можно, даже те, которые разрабатывались поперек стандартов в том числе через механизмы, которые мы сегодня разберем, по крайней мере так утверждается на сайте.

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

У каждого элемента есть атрибуты значения которых мы можем менять. И если перечислить имена в хуке 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.
Теги:
Хабы:
Всего голосов 13: ↑11 и ↓2+9
Комментарии34

Публикации

Истории

Работа

Ближайшие события

27 августа – 7 октября
Премия digital-кейсов «Проксима»
МоскваОнлайн
28 сентября – 5 октября
О! Хакатон
Онлайн
3 – 18 октября
Kokoc Hackathon 2024
Онлайн
10 – 11 октября
HR IT & Team Lead конференция «Битва за IT-таланты»
МоскваОнлайн
25 октября
Конференция по росту продуктов EGC’24
МоскваОнлайн
7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн