chevron-left chevron-right

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">&times;</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=&quot;title&quot;>{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