Jak wykorzystać Web Components w różnych frameworkach JavaScript?
Technologia Web Components pozwala na budowanie elementów interfejsu lub nawet całych aplikacji, które można wykorzystać w wielu różnych, niepowiązanych ze sobą projektach. Dzięki temu zyskujemy elementy, które będą zawsze działały w ten sam sposób niezależnie od użytej platformy.
W jednym z poprzednich tekstów dotyczących Web Components opisałem jak zacząć korzystać z tej technologii. Natomiast po przeczytaniu tego tekstu będziesz w stanie używać elementów Web Components w różnych projektach bazujących na różnych frameworkach i bibliotekach JavaScript, takich jak React, Angular czy też Vue.
Przykładowy Custom Element
Na potrzeby tego artykułu przygotowałem bardzo prosty Web Component, którego zadaniem jest pokazywanie powiadomień z aplikacji. Oto jego kod:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 | // my-notification.js (function() { const template = document.createElement('template'); template.innerHTML = ` <style> *, *:after, *:before { box-sizing: border-box; } #container { position: relative; padding: 8px 16px; border: 1px solid #ddd; color: #231231; display: grid; grid-template-areas: 'title btn' 'message btn'; grid-gap: 16px; } #title { grid-area: title; } #title-text { margin-top: 0; } #message { grid-area: message; } #btn { grid-area: btn; position: absolute; top: 0; right: -8px; font-size: 24px; background: none; border: 0; border-radius: 50%; padding: 0; display: flex; align-items: center; justify-content: center; line-height: 32px; height: 32px; width: 32px; cursor: pointer; transition: background .2s ease-in-out, color .2s ease-in-out; } #btn:hover, #btn:focus { background: #eee; color: #fff; } #container[type="error"] { background: #dc3545; color: #fff; } #container[type="info"] { background: #17a2b8; color: #fff; } #container[type="success"] { background: #28a745; color: #fff; } #container[type="default"] { background: #6c757d; color: #fff; } #container[type="warning"] { background: #ffc107; } </style> <section id="container"> <div id="title"><slot name="title"><h3 id="title-text">Notification title</h3></slot></div> <div id="message"><slot>Sample message</slot></div> <button type="button" id="btn">×</button> </section>`; class Notification extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.container = null; this.shadowRoot.appendChild(template.content.cloneNode(true)); } get type() { return this.getAttribute('type'); } set type(newValue) { this.setAttribute('type', newValue); } get hidden() { return this.getAttribute('hidden'); } set hidden(newValue) { this.setAttribute('hidden', !!newValue); } static get observedAttributes() { return ['type']; } connectedCallback() { this.btnClose = this.shadowRoot.querySelector('#btn'); this.container = this.shadowRoot.querySelector('#container'); this.btnClose.addEventListener('click', this._onClose, false); if (!this.hasAttribute('type')) { this.setAttribute('type', 'default'); } this._updateNotificationState(); } _updateNotificationState = () => { if (!this.container) { return; } this.container.setAttribute('type', this.getAttribute('type')); }; disconnectedCallback() { this.btnClose.removeEventListener('click', this._onClose); } attributeChangedCallback(name, oldVal, newVal) { if (name === 'type' && oldVal !== newVal) { this._updateNotificationState(); this.type = newVal; } } _onClose = () => { this.dispatchEvent( new CustomEvent('close', { bubbles: true, cancelable: false, composed: true, detail: {} }) ); }; } window.customElements.define('my-notification', Notification); })(); |
Głównymi zadaniami tego komponentu są:
- wyświetlanie komunikatu w jednym z pięciu dostępnych stanów,
- wysyłanie eventu w momencie kliknięcia w przycisk zamykający powiadomienie, na który można będzie zareagować wedle uznania,
- komponent sam siebie nie jest w stanie usunąć z drzewa DOM
Korzystanie z Web Components w React
Dla ułatwienia wykorzystamy z narzędzia Create React App, które pozwoli nam na postawienie przykładowej aplikacji bez trudu.
Ładowanie komponentu do aplikacji React
Z racji tego, że będziemy korzystać z Web Componentu, który powstał poza bazą
React (założenie jest takie że dany Web Component można wykorzystać w wielu
różnych projektach niezależnie), to musimy plik z komponentem dodać do folderu
public
w folderze wygenerowanym przez Create React App.
Następnie, wystarczy już załadować plik w szablonie gdzie będzie renderowana
aplikacja React: public/index.html
, wstawiając następujący
kawałek kodu:
1 | <script src="%PUBLIC_URL%/my-notification.js"></script> |
Użycie Web Componentu w aplikacji React
Mając tak przygotowany kod, możemy przystąpić do korzystania z niego w aplikacji React. W skrócie, ogranicza się on do wstawienia kodu w podobnej postaci do następującej:
1 2 3 4 | <my-notification type={type}> <h3 slot="title">{title}</h3> <p>{message}</p> </my-notification> |
Jednakże chcemy obsłużyć również zdarzenie odpalane w momencie kliknięcia w przycisk zamykający powiadomienie. Ze względu na fakt, że za pomocą atrybutów HTML możemy przekazywać tylko wartości typu string, to nie możemy przekazać callbacków czyli funkcji do Web Componentu.
W tym celu musimy utworzyć referencję do elementu w drzewie DOM. Korzystając z możliwości jakie dają nam najnowsze implementacje ReactJS, wykorzystam do tego celu hooki:
-
React.useRef()
- do trzymania referencji do elementu w drzewie DOM, -
React.useState()
- do zarządzania stanem widzialności powiadomienia, -
React.useEffect()
- do podpinania i odpinania obsługi zdarzeń na elemencie zawierającym powiadomienie
Kod, który zapewni nam obsługę tych wszystkich rzeczy wygląda następująco:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | import React from 'react'; // tworzymy przykładowy komponent React const App = () => { // inicjalizujemy stan dla `showNotification` i uzyskujemy dostęp do settera wartości const [showNotification, setShowNotification] = React.useState(false); // inicjalizujemy referencję do węzła dostępowego do elementu wyświetlającego powiadomienia const refNotification = React.useRef(null); const hideNotification = () => setShowNotification(false); // przy każdym re-renderze komponentu podpinamy od nowa obsługę zdarzeń React.useEffect(() => { const notificationNode = refNotification.current; if (!notificationNode) { return; } notificationNode.addEventListener('close', hideNotification, false); // przekazujemy funkcję, która odepnie obsługę zdarzenia w momencie usunięcia komponentu z drzewa DOM return () => { notificationNode.removeEventListener('close', hideNotification); }; }); if (showNotification) { return ( <sunpietro-notification ref={refNotification} type={type}> <h3 slot="title">{title}</h3> <p>{message}</p> </sunpietro-notification> ); } return ( <button type="button" onClick={() => setShowNotification(true)}> Pokaż powiadomienie </button> ); }; export { App }; |
To co jest ważne w powyższym kodzie to przypinanie i odpinanie obsługi zdarzeń po każdym odświeżeniu widoku z powiadomieniem. Potrzebne jest to dlatego, że każde odświeżenie może wygenerować nowy kod HTML, przez co obsługa zdarzeń podpięta do starego węzła drzewa DOM przestanie działać, bo już nie będzie dostępne z poziomu tego drzewa.
Przekazywanie zmiennych do Web Componentu
W przypadku React to w zasadzie byłoby tyle. Gdyby jednak zależało nam na
przesłaniu obiektu z danymi, to musielibyśmy się posiłkować konwersją obiektu
do typu string
a różnego rodzaju callbacki przekazywać za pomocą
zdarzeń JS, które byłyby obsługiwane przez dany Web Component.
W tym celu musielibyśmy w naszym przykładowym Custom Elemencie dodać obsługę przykładowego zdarzenia:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | connectedCallback() { this.btnClose = this.shadowRoot.querySelector('#btn'); this.container = this.shadowRoot.querySelector('#container'); this.btnClose.addEventListener('click', this._onClose, false); // dodajemy obsługę niestandardowego zdarzenia this.addEventListener('someCustomEvent', this._handleSomeCustomEvent, false); if (!this.hasAttribute('type')) { this.setAttribute('type', 'default'); } this._updateNotificationState(); } |
W powyższym kodzie dodano obsługę zdarzenia someCustomEvent
. To
na co trzeba zwrócić uwagę to miejsce do którego została przypięta obsługa
zdarzenia. W tym przypadku jest to this
, które wskazuje na
tag: <my-notification></my-notification>
.
Gdybyśmy podpięli obsługę zdarzenia do this.shadowRoot
to w tym
momencie zdarzenia nie byłyby obsługiwane, ponieważ
nie można dokonać żadnej interakcji z ShadowDOM z zewnątrz.
Użycie Web Components w aplikacji VueJS
Podobnie jak w przykładzie bazującym na React, tutaj też skorzystamy z dostępnego narzędzia które pozwoli nam szybko zbudować przykładową aplikację VueJS. Skorzystamy z Vue CLI dzięki czemu możemy szybko przejść do kolejnego punktu, czyli wstawiania kodu Web Components do aplikacji VueJS.
Ładowanie komponentu do aplikacji VueJS
W przypadku biblioteki VueJS postępowanie jest takie samo jak w przypadki
aplikacji bazującej na React. Plik z Web Componentem przenosimy do katalogu
public
, a następnie w pliku
public/index.html
wstawiamy następujący element:
<script src="<%= BASE_URL
%>my-notification.js"></script>
i gotowe.
Użycie Web Componentu w aplikacji VueJS
Kolejnym krokiem jest napisanie przykładowego komponentu w Vue lub użycie istniejącego. W naszym przypadku stworzymy nowy:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | // src/components/SomeComponent.vue <template> <div class="SomeComponent" @close="onClose"> <button v-if="!showNotification" type="button" v-on:click="showNotification = true">Show</button> <my-notification v-if="showNotification" type="info"> <h3 slot="title">An example of Web Components usage in Vue</h3> <p>It's just working!</p> </my-notification> </div> </template> <script> export default { name: 'SomeComponent', data: function() { return { showNotification: false }; }, methods: { onClose: function() { this.showNotification = false; } } }; </script> |
W porównaniu do przykładu bazującego na React, ten przykład wydaje się prostszy, m.in. ze względu na fakt, że logika wyświetlania znajduje się wewnątrz szablonu komponentu.
Przekazywanie zmiennych do Web Componentu
Zasada przekazywania zmiennych różnego typu do Web Componentu jest taka sama
jak w React (i uwaga spoiler! - w Angular). Wszystkie wartości przekazywane
przez atrybuty elementu
<my-notification></my-notification>
przyjmują postać
zmiennej typu string
. Jeśli chcemy przekazać wartości w bardziej
zaawansowanych typach, np. funkcję to musimy użyć sposobu bazującego na
uruchamianiu zdarzeń JavaScript.
Użycie Web Componentu w aplikacji Angular
Ten przypadek będzie inny w porównaniu do przykładów z React i VueJS, dlatego że Angular sam w sobie implementuje Web Componenty do budowania interfejsu użytkownika. Jeśli załadujemy przykładowy Web Component zbudowany poza aplikacją napisaną w Angular, to musimy odpowiednio o tym poinformować kod naszej aplikacji.
Dla ułatwienia skorzystałem z Angular CLI i z jego pomocą zbudowałem przykładową aplikację.
Przygotowanie Angular do użycia Web Components z zewnątrz
Pierwszym krok to jest poinformowanie modułu aplikacji, że będziemy korzystać
z Web Componentów zdefiniowanych poza aplikacją angularową. W tym celu w
pliku: src/app/app.module.ts
musimy dodać nowy schemat, który
będzie obsługiwany przez moduł:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | import { BrowserModule } from '@angular/platform-browser'; // importujemy schemat dla Custom Elements import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; @NgModule({ declarations: [AppComponent], imports: [BrowserModule, AppRoutingModule], providers: [], bootstrap: [AppComponent], // przekazujemy informację o tym, że będziemy używać zewnętrznych // Web Componentów w module schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class AppModule {} |
To co musimy zrobić w definicji modułu to dodać
CUSTOM_ELEMENTS_SCHEMA
do własności schemas
. Od tego
momentu, moduł będzie wiedział, że oprócz komponentów zbudowanych wewnątrz
aplikacji Angular pojawią się komponenty pochodzące spoza aplikacji.
Wstawianie pliku z Web Componentem do aplikacji Angular
Po aktualizacji definicji modułu nadeszła pora na dołączenie pliku z Web
Componentem do aplikacji Angular. W tym celu należy otworzyć plik:
angular.json
i wewnątrz niego dopisujemy pod kluczem
projects.<project-name>.architect.build.options.scripts
:
1 2 3 | "scripts": [ "src/my-notification.js" ] |
Tym samym dołożyliśmy plik z komponentem powiadomień do skompilowanego kodu Angular i możemy zacząć korzystać z tego elementu wewnątrz komponentów angularowych.
Użycie Web Componentu w aplikacji Angular
W ostatnim kroku pozostało nam już wykorzystanie Web Componentu wewnątrz szablonu komponentu angularowego:
1 2 3 4 5 6 | <sunpietro-notification> <h3 slot="title">Sample usage of Web Components in Angular</h3> <p>It is great to be here!</p> </sunpietro-notification> <router-outlet></router-outlet> |
Jak widać, koniec końców wykorzystanie Web Componentu w kodzie Angular jest tak samo proste jak w React czy VueJS.
Podsumowanie
Celem tego artykułu było pokazanie jak można korzystać z Web Components w różnych bibliotekach i frameworkach JavaScript. W niektórych przypadkach potrzeba nieco więcej zachodu (np. w Angular), ale koniec końców samo korzystanie z Web Components jest takie samo. Różnice się pojawiają w momencie gdy trzeba zaimplementować komunikację między kodem Web Components a wybraną biblioteką czy frameworkiem.
Na 4Developers Katowice 2019 miałem okazję omawiać tematykę budowania elementów interfejsu użytkownika niezależnych od platformy i tam też przedstawiłem sposoby implementacji Custom Elements z wykorzystaniem różnych narzędzi JavaScript. Zapraszam do przejrzenia slajdów z tego wystąpienia