Web components — czym są custom elements?
Przeglądarka pozwala ci definiować własne tagi HTML — dowiedz się, jak działają dzięki funkcjom cyklu życia, atrybutom i właściwościom.
Zaktualizowano: 18 marca 2026
Internetowe komponenty (ang. web components) to przeglądarkowe API, które pozwala ci tworzyć niestandardowe, zakapsułkowane elementy HTML wielokrotnego użytku. Są one niezależne od siebie. Oznacza to, że style w nich nie będą już przeciekać, ani nie zostaną nadpisane. W zasadzie nie jest to jedna rzecz. To zbiór 3 standardów internetowych, współpracujących ze sobą.
- Niestandardowe elementy (ang. custom elements) pozwalają ci tworzyć nowe tagi HTML z własnym zachowaniem i funkcjami callback.
- Cienisty DOM (ang. Shadow DOM) dostarcza wspomniane kapsułkowanie dla styli i znaczników; zapewnia klarowne granice.
- Szablony HTML (ang. HTML templates) oferują wydajne fragmenty kodu wielokrotnego użytku. Mogą być one wyrenderowane na żądanie przy pomocy języka JS.
Nie musisz używać wszystkich trzech, aby stworzyć taki komponent, ale pokryjemy je wszystkie. W tym wpisie skupię się na tych niestandardowych elementach. W kolejnych rozwinę dwa pozostałe tematy: Shadow DOM i HTML templates.
Dobra, ale możesz zapytać „co mnie to obchodzi? Nie wystarczy mi mój framework?” I to jest właśnie interesujące. Te komponenty nie zastępują frameworków takich jak React czy Vue — mogą z nimi współpracować. Nie ma w nich wbudowanej reaktywności czy zarządzania stanem. Są to komponenty niezależne od frameworków.
Podczas gdy frameworki przychodzą i odchodzą, standardy sieciowe trwają.
Najprawdopodobniej wykorzystujesz web components nawet nie zdając sobie sprawy z tego. Spójrz na element <input type="range" /> poniżej. Celowo umieściłem w tym poście natywny element. Bez dodatkowych elementów czy styli — tylko domyślne arkusze CSS.
Sprawdzenie tego elementu w konsoli deweloperskiej pokaże ci, że używa on Shadow DOM (upewnij się, że twoja przeglądarka ma włączoną opcję wyświetlania tych znaczników). Nie zarejestrowałem żadnego niestandardowego elementu. Przeglądarka sama używa tych standardów do tworzenia natywnych elementów. Ucząc się o tych komponentach, uczysz się o technologiach, które tworzą platformę internetową!
Niestandardowe elementy
Niestandardowe elementy są jak standardowe elementy HTML, tylko są… niestandardowe. Poza elementami takimi jak <article> czy <section> możesz teraz definiować swoje własne elementy jak np. <my-component>. Jednakże, zanim użyjemy naszego elementu, najpierw musimy go zdefiniować. Każdy komponent startuje przez rozszerzenie klasy HTMLElement.
class MyComponent extends HTMLElement {
constructor() {
super()
}
}
customElements.define('my-component', MyComponent)Dziedziczenie daje ci dostęp do całego API HTMLElement - do właściwości i metod takich jak:
innerHTMLinnerTextaddEventListener()click()- itp.
Powyżej stworzyliśmy nasz pierwszy autonomiczny niestandardowy element (ang. autonomous custom element). Rozszerza on klasę HTMLElement i jest użyty jako samodzielny tag. Istnieje jeszcze jeden typ.
Niestandardowe wbudowane elementy (ang. customized built-in elements) rozszerzają konkretny, natywny element (jak HTMLButtonElement) i są używane z atrybutem is.
class MyButton extends HTMLButtonElement {
connectedCallback() {
this.style.color = 'red'
}
}
customElements.define('my-button', MyButton, { extends: 'button' })<button is="my-button">Click me</button>Niemniej, Safari nie wspiera tych wbudowanych elementów i nie ma tego w planach. Dlatego większość programistów powinna trzymać się autonomicznych niestandardowych elementów, których to będziemy używać w tej krótkiej serii.
Rejestr niestandardowych elementów
customElements jest globalną właściwością na obiekcie window, który zwraca referencję do klasy CustomElementRegistry. Ten rejestr (ang. custom elements registry) jest interfejsem, którego używasz do tworzenia i wyszukiwania niestandardowych elementów. Metoda customElements.define() rejestruje twoją klasę w rejestrze przeglądarki.
Nazwa tagu musi zawierać myślnik, aby uniknąć kolizji ze standardowymi elementami HTML.
Po zarejestrowaniu możesz wstawić swój element jak każdy inny natywny element.
<my-component></my-component>Rejestr pozwala na dopasowanie jednej definicji do jednej nazwy, aby uniknąć kolizji z innymi niestandardowymi elementami. Próba rejestracji wielu elementów z tą samą nazwą wyrzuci błąd. Dobrą praktyką jest dodawanie prefiksu w postaci nazwy projektu czy firmy.
Istnieje tylko jeden rejestr na stronę.
Inne metody rejestru
Poza rejestracją rejestr oferuje metody dla innych akcji.
customElements.upgrade(elementReference)
customElements.whenDefined('my-component').then(() => {
console.log('defined')
})
customElements.get('my-component')customElements.upgrade(): Wymusza natychmiastową aktualizację niestandardowego elementu w danym drzewie DOM, nawet jeżeli nie został podpięty do dokumentu. W typowych przypadkach nie potrzebujesz tej metody, ponieważ element jest automatycznie zaktualizowany po wstawieniu do dokumentu. Niemniej, jeżeli tworzysz drzewko DOM poza dokumentem — z użycieminnerHTMLlub klonowania<template>— elementy czekają jako ogólne instancjeHTMLElementdopóki nie dodasz ich do dokumentu lub manualnie nie wywołaszupgrade().customElements.whenDefined(): Zwraca promise, który zostaje rozwiązany, gdy element zostaje zarejestrowany. Metoda ta jest przydatna, aby poczekać na definicję przed uruchomieniem zależnego od niej kodu.customElements.get(): Zwraca konstruktor dla zarejestrowanego elementu lub zwracaundefined, jeżeli jest niezarejestrowany. Przydatna do wykrywania funkcjonalności lub sprawdzania, czy komponent jest dostępny.
Funkcje cyklu życia
Możesz być zaznajomiony z pojęciem tych funkcji (ang. lifecycle methods) z frameworków. W skrócie są to funkcje, które uruchamiają się automatycznie w konkretnych momentach cyklu życia komponentu. W komponentach internetowych są to funkcje automatycznie wywoływane przez przeglądarkę w danych momentach życia niestandardowego elementu. Definiujesz te metody w swojej klasie, a przeglądarka wywołuje je w odpowiednich momentach.
class MyComponent extends HTMLElement {
static get observedAttributes() {
return ['attribute1', 'attribute2']
}
constructor() {
super()
}
connectedCallback() {}
disconnectedCallback() {}
connectedMoveCallback() {}
attributeChangedCallback(attributeName, oldValue, newValue) {}
adoptedCallback() {}
}- Każdy komponent zaczyna się od metody
constructor(). Jest wywoływana, gdy element jest tworzony. Jednakże element nie jest jeszcze w drzewie DOM. - Tu wchodzi do gry
connectedCallback(). Jest uruchamiana za każdym razem, gdy element jest umieszczany w drzewie — włączając w to ponowne wstawienia po usunięciu. Ogólnie operacje na DOM-ie powinny się tu znajdować. - I przeciwnie
disconnectedCallback()uruchamia się, kiedy przeglądarka usuwa elementy z drzewa DOM. Jest to miejsce na porządki — tutaj możesz usuwać nasłuchiwania na zdarzenia, anulować timery czy zamykać połączenia, aby unikać wycieku pamięci. - Ostatni dodatek do API -
connectedMoveCallback(). Przeglądarka uruchamia tę metodę, gdy niestandardowy element przemieszcza się w drzewie DOM wykorzystując nową metodęNode.prototype.moveBefore(). Ta metoda rozwiązuje wydajnościowy problem usuwania i ponownego wstawiania elementów DOM. Jeżeli jest zdefiniowana to jest wywoływana zamiastconnectedCallback()idisconnectedCallback()za każdym razem, gdy element przemieszcza się w inne miejsce (z pomocą metodymoveBefore()). - Metoda
attributeChangedCallback()odpala się warunkowo, tylko dla atrybutów umieszczonych w tablicy zwracanej przez statyczny getterobservedAttributes(). - Dla porządku muszę wspomnieć o
adoptedCallback(). Metoda ta odpala się, gdy twój niestandardowy element przemieszcza się pomiędzy dokumentami. Np. przechodząc pomiędzy<iframe>i głównym dokumentem albo gdy wykorzystujesz metodędocument.adoptNode(). Prawdopodobnie będziesz rzadko z niej korzystać.
Wspomniałem te metody w kolejności, ale nie jest to dokładna kolejność wykonywania. Jest tam twist, więc pochylmy się nad tym.
constructor()uruchamia się pierwszy, kiedy element jest stworzony w języku JavaScript.- I tu mamy twist — funkcja
attributeChangedCallback()następuje po konstruktorze. Jest tak, ponieważ parser ustawia atrybuty przed umieszczeniem elementu w drzewie DOM. - Później, element wyzwala
connectedCallback(), gdy trafia do DOM-u. connectedMoveCallback()odpala się, gdy element jest przenoszony metodąmoveBefore().- Przy opuszczaniu DOM-u, element w końcu uruchamia
disconnectedCallback(). - Czasami element może być przeniesiony do innego dokumentu, wyzwalając
adoptedCallback().
Kiedy niestandardowe elementy są zagnieżdżone, dzieci są dodawane przed rodzicami (w głąb najpierw).
Atrybuty kontra właściwości
We frameworkach JS, takich jak React, różnica pomiędzy atrybutami a właściwościami jest rozmyta. Jednakże w komponentach internetowych są to różne systemy, które musimy zrozumieć.
Atrybuty żyją wewnątrz znaczników HTML. Są częścią API drzewa DOM. Zawsze przyjmują postać łańcuchów znaków i nie mogą przyjmować złożonych typów danych takich jak tablice czy obiekty. Jeżeli są obserwowane, wyzwalają metodę attributeChangedCallback().
<my-component title="hello" count="420" active></my-component>Właściwości żyją wewnątrz obiektów JS. W przeciwieństwie do atrybutów mogą być dowolnego typu: liczba, wartość logiczna, obiekt itd. Nie wyzwalają metody attributeChangedCallback().
const element = document.querySelector('my-component')
element.title = 'hello'
element.active = ''
element.count = 420
element.data = { complex: true }
element.items = [1, 2, 3]Odzwierciedlenie
Odzwierciedlenie (ang. reflection) jest praktyką synchronizacji atrybutu i odpowiadającej mu właściwości, dzięki czemu zmiana jednego automatycznie aktualizuje drugi. Odzwierciedlenie właściwości do atrybutów jest istotne, ponieważ selektory CSS jak np. my-component[disabled] działają tylko z atrybutami. Ponadto atrybuty pojawiają się w konsoli deweloperskiej i serializacji outerHTML.
Atrybuty i właściwości nie synchronizują się automatycznie.
Nawet w natywnych elementach synchronizacja nie zawsze jest dwustronna. Spójrz na element <input/>.
input.setAttribute('value', 'hello')
console.log(input.value) // "hello" — atrybut odzwierciedlony do właściwości
input.value = 'world'
console.log(input.getAttribute('value')) // "hello" — właściwość NIE jest odzwierciedlona!W powyższym przykładzie ustawiamy atrybut value, który ustawia domyślną wartość elementu <input/>. Logując wartość, widzimy odzwierciedlenie we właściwości. Jednak, co zaskakujące, po zmianie właściwości po stronie JS-a, nie widzimy odzwierciedlenia w atrybucie HTML.
W niestandardowych elementach sytuacja jest bardziej klarowna, ale nadal problematyczna — żadna automatyczna synchronizacja nie zachodzi. Musisz sam ją podpiąć. Częstym wzorcem jest używanie metod get i set w niestandardowych elementach, aby dodawać (lub pomijać) odzwierciedlanie właściwości. Istnieją sytuacje, gdy nie chcesz synchronizacji.
class MyComponent extends HTMLElement {
#value
get value() {
return this.#value
}
set value(val) {
this.#value = val // Not reflected
}
get disabled() {
return this.hasAttribute('disabled')
}
set disabled(val) {
this.toggleAttribute('disabled', val) // Reflected
}
}Zauważ, że przełączamy wartość disabled. Wartości logiczne w HTML-u są obecne albo nie — wbrew pozorom nie są to wartości true lub false.
Słuchanie na zdarzenia
Zarządzanie zdarzeniami w internetowych komponentach wykorzystuje takie samo API jak regularny DOM. Jednakże Shadow DOM wprowadza drobne dziwactwa dotyczące propagacji i targetowania zdarzeń.
Standardowym wzorcem jest nasłuchiwanie na zdarzenia w connectedCallback() i usuwanie nasłuchiwania w disconnectedCallback(). Paruje to ustawianie z czyszczeniem i zapobiega wyciekom pamięci, jeżeli element jest usunięty i dodany ponownie.
class MyComponent extends HTMLElement {
connectedCallback() {
this.addEventListener('click', this.handleClick)
}
disconnectedCallback() {
this.removeEventListener('click', this.handleClick)
}
handleClick(event) {
console.log('Clicked!', this)
}
}Dla prostych komponentów, gdzie element jest stworzony raz i nie usuwany, możesz nasłuchiwać na zdarzenia także w konstruktorze. Jest on wywoływany raz, zapobiegając duplikowaniu nasłuchiwań. Gwarantuje to też automatyczne czyszczenie, kiedy komponent jest usuwany z pamięci (ang. garbage collected).
class MyComponent extends HTMLElement {
constructor() {
super()
this.addEventListener('click', this.handleClick)
}
handleClick(event) {
console.log('Clicked!', this) // "this" jest komponentem
}
}To podejście działa dobrze dla podstawowych komponentów, ale musi być zmodyfikowane, kiedy zaczynasz używać cienistego DOM-u. A zatem, w praktyce, wykorzystamy inne podejście. Więcej o nim w poście dedykowanym standardowi Shadow DOM.
Problem z wiązaniem
Istnieje inna pułapka z zarządzaniem zdarzeniami, którą warto zaadresować — problem z wiązaniem (ang. binding problem). Kiedy dodajesz nasłuchiwanie na elementy dzieci, this wewnątrz funkcji obsługi zdarzeń staje się celem zdarzenia (ang. event target) — przyciskiem, nie twoim komponentem. Twoje metody przestają być dostępne. Aby zrozumieć, dlaczego się tak dzieje, musimy porozmawiać o tym, jak JavaScript rozwiązuje this. Jest to problem związany z działaniem języka JavaScript, nie z komponentami per se. Tu wchodzą dwa modele zasięgu zmiennych.
Zasięg dynamiczny (ang. dynamic scope) występuje, kiedy rozwiązanie zależy od tego, jak funkcja jest wywołana w czasie wykonywania programu. JavaScript nie używa tego zasięgu dla zmiennych, ale this zachowuje się w ten sposób.
class MyComponent extends HTMLElement {
#button
constructor() {
super()
this.innerHTML = '<button>Click me</button>'
this.#button = this.querySelector('button')
this.#button.addEventListener('click', this.handleClick)
}
handleClick(event) {
console.log(this) // ❌ "this" jest przyciskiem, nie komponentem!
this.updateDisplay() // ❌ ERROR: updateDisplay is not a function
}
updateDisplay() {}
}Jeżeli handleClick() jest wywołana jako this.handleClick(), this jest komponentem. Jednak kiedy jest podana do addEventListener(), przeglądarka wywołuje ją z this ustawionym na cel zdarzenia, czyli przycisk. Ta sama funkcja, inne rezultaty, ponieważ this jest determinowane na podstawie wywołania funkcji, a nie jej definicji.
Zakres leksykalny (ang. lexical scope) oznacza, że zmienna jest rozwiązywana na podstawie tego, gdzie jest zapisana, a nie gdzie wywołana. JavaScript wykorzystuje zakres leksykalny dla wszystkich zmiennych. Możesz także wykorzystać funkcję strzałkową (ang. arrow function), aby „naprawić” this w naszym wypadku.
class MyComponent extends HTMLElement {
#button
constructor() {
super()
this.innerHTML = '<button>Click me</button>'
this.#button = this.querySelector('button')
this.#button.addEventListener('click', this.handleClick)
}
handleClick = (event) => {
console.log(this) // ✅ "this" jest zawsze komponentem
this.updateDisplay() // ✅ Działa
}
updateDisplay() {}
}Funkcje strzałkowe mają zakres leksykalny, więc this jest zawsze komponentem.
Programiści Reacta pewnie są zaznajomieni z kolejnym rozwiązaniem — było wykorzystywane w erze klasowych komponentów. Możesz celowo zablokować this. Metoda bind() tworzy nową funkcję, gdzie this jest na stałe ustawione na komponent.
class MyComponent extends HTMLElement {
#button
constructor() {
super()
this.innerHTML = '<button>Click me</button>'
this.#button = this.querySelector('button')
this.handleClick = this.handleClick.bind(this) // Celowo zablokuj "this"
this.#button.addEventListener('click', this.handleClick)
}
handleClick(event) {
console.log(this) // ✅ "this" jest zawsze komponentem
this.updateDisplay() // ✅ Działa
}
updateDisplay() {}
}Funkcje strzałkowe rozwiązują problem domyślnie przez zasięg leksykalny; metoda bind() robi to poprzez opakowanie funkcji w stały kontekst.
Podsumowanie
W tym wpisie dowiedzieliśmy się, czym są niestandardowe elementy — jeden z trzech filarów komponentów internetowych. Możemy wykorzystać je, aby „nauczyć” przeglądarkę nowych tagów z własnym zachowaniem, stanem i metodami cyklu życia. I to wszystko bez użycia frameworka. W następnym poście będziemy badać Shadow DOM — jak kapsułkować style i znaczniki, żeby nie wyciekały. Bądźcie cierpliwi!


