1. Pengenalan Web Components
Web Components adalah sekumpulan API browser-native yang memungkinkan Anda membuat custom HTML elements yang reusable, ter-enkapsulasi, dan bisa digunakan di framework apapun (React, Vue, Angular, atau vanilla JS). Web Components terdiri dari tiga teknologi utama:
| Teknologi | Fungsi | API |
|---|---|---|
| Custom Elements | Mendefinisikan elemen HTML baru | customElements.define() |
| Shadow DOM | Enkapsulasi style dan markup | element.attachShadow() |
| HTML Templates | Template yang tidak di-render | <template> dan <slot> |
Mengapa Web Components?
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ β WEB COMPONENTS ECOSYSTEM β ββββββββββββββββββββββββββββββββββββββββββββββββββββββ€ β β β Browser APIs (Native): β β ββββββββββββββββ ββββββββββββ βββββββββββββββ β β βCustom Elementsβ βShadow DOMβ βHTML Templatesβ β β ββββββββ¬ββββββββ ββββββ¬ββββββ ββββββββ¬βββββββ β β β β β β β βββββββββββββββββΌββββββββββββββββ β β β β β ββββββΌβββββ β β β Lit β β Library β β β (Google)β untuk memudahkan β β ββββββ¬βββββ pembuatan WC β β β β β ββββββββββββββββββββββββΌβββββββββββββββββββββββ β β β Bisa digunakan DI MANAPUN: β β β β β’ React β’ Vue β’ Angular β β β β β’ Svelte β’ Astro β’ Vanilla JS β β β β β’ HTML murni () β β β βββββββββββββββββββββββββββββββββββββββββββββββ β ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
2. Custom Elements API
Custom Elements memungkinkan Anda mendefinisikan tag HTML baru dengan JavaScript. Elemen bisa extended dari elemen HTML bawaan atau menjadi elemen baru sepenuhnya.
// Mendefinisikan custom element tanpa library
class MyGreeting extends HTMLElement {
// Lifecycle: dipanggil saat elemen ditambahkan ke DOM
connectedCallback() {
const name = this.getAttribute('name') || 'World';
this.innerHTML = `<h1>Hello, ${name}!</h1>`;
}
// Lifecycle: dipanggil saat elemen dihapus dari DOM
disconnectedCallback() {
console.log('Element dihapus dari DOM');
}
// Daftar attribute yang diamati perubahannya
static get observedAttributes() {
return ['name'];
}
// Lifecycle: dipanggil saat attribute berubah
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.innerHTML = `<h1>Hello, ${newValue}!</h1>`;
}
}
}
// Registrasi elemen
customElements.define('my-greeting', MyGreeting);
// Penggunaan di HTML:
// <my-greeting name="Budi"></my-greeting>
// Output: <h1>Hello, Budi!</h1>
3. Shadow DOM
Shadow DOM menyediakan enkapsulasi β style dan markup di dalam shadow root tidak bisa diakses dari luar, dan sebaliknya. Ini mencegah bentrok CSS dan memastikan komponen benar-benar terisolasi.
class MyCard extends HTMLElement {
constructor() {
super();
// Attach shadow root (mode: 'open' bisa diakses via JS)
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
/* Style ini TIDAK mempengaruhi elemen di luar */
:host {
display: block;
border: 1px solid #333;
border-radius: 8px;
padding: 16px;
margin: 8px;
background: #1a1a2e;
color: white;
}
:host(.featured) {
border-color: gold;
box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
}
:host([dark]) {
background: #0a0a1a;
}
h2 { margin: 0 0 8px; font-size: 1.2rem; }
p { margin: 0; opacity: 0.8; }
</style>
<h2><slot name="title">Default Title</slot></h2>
<p><slot>Default content</slot></p>
`;
}
}
customElements.define('my-card', MyCard);
// Penggunaan:
// <my-card class="featured" dark>
// <span slot="title">Judul Card</span>
// Konten card di sini...
// </my-card>
4. Memulai dengan Lit
Lit adalah library dari Google yang menyederhanakan pembuatan Web Components. Lit menambahkan reactive properties, efficient template rendering (hanya update yang berubah), dan decorator-based API yang elegan.
# Install via npm
npm install lit
# Atau CDN (tanpa build step)
# <script type="module">
# import { LitElement, html, css } from 'https://cdn.jsdelivr.net/gh/nicolo-ribaudo/lit-labs@3.1.0/packages/lit/index.js';
# </script>
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('my-counter')
export class MyCounter extends LitElement {
// Reactive property: perubahan otomatis re-render
@property({ type: Number })
count = 0;
@property({ type: String })
label = 'Hitung';
// Styles: di-inject ke Shadow DOM, ter-enkapsulasi
static styles = css`
:host {
display: inline-flex;
align-items: center;
gap: 12px;
font-family: system-ui;
}
button {
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid #444;
background: #222;
color: white;
font-size: 1.2rem;
cursor: pointer;
}
button:hover { background: #333; }
.count {
font-size: 1.5rem;
font-weight: bold;
min-width: 40px;
text-align: center;
}
`;
// Template: di-render ke Shadow DOM
render() {
return html`
<button @click=${() => this.count--}>β</button>
<span class="count">${this.count}</span>
<button @click=${() => this.count++}>+</button>
<span>${this.label}</span>
`;
}
}
// HTML: <my-counter count="5" label="Klik saya"></my-counter>
5. Reactive Properties
Lit properties secara otomatis re-render komponen saat nilainya berubah. Properties bisa di-set dari attribute HTML atau dari JavaScript.
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
@customElement('user-profile')
export class UserProfile extends LitElement {
// Public property (bisa di-set dari attribute HTML)
@property({ type: String }) name = '';
@property({ type: Number }) age = 0;
@property({ type: Boolean }) active = false;
@property({ type: Array }) skills: string[] = [];
@property({ type: Object }) social: { twitter?: string; github?: string } = {};
// Internal state (hanya bisa di-set dari dalam komponen)
@state() private isEditing = false;
@state() private editName = '';
static styles = css`
.profile { padding: 16px; border: 1px solid #333; border-radius: 8px; }
.active-badge { color: green; }
.inactive-badge { color: gray; }
.skills { display: flex; gap: 8px; flex-wrap: wrap; }
.skill { background: #222; padding: 4px 8px; border-radius: 4px; }
`;
render() {
return html`
<div class="profile">
<h2>${this.name}, ${this.age} tahun</h2>
<span class=${this.active ? 'active-badge' : 'inactive-badge'}>
${this.active ? 'π’ Aktif' : 'βͺ Nonaktif'}
</span>
<div class="skills">
${this.skills.map((s) => html`<span class="skill">${s}</span>`)}
</div>
${this.social.twitter ? html`<p>Twitter: @${this.social.twitter}</p>` : ''}
${this.social.github ? html`<p>GitHub: ${this.social.github}</p>` : ''}
</div>
`;
}
}
// HTML:
// <user-profile
// name="Budi"
// age="28"
// active
// skills='["JavaScript","TypeScript","Lit"]'
// .social=${{ twitter: 'budi_dev', github: 'budi123' }}
// ></user-profile>
Property Converter
@customElement('temp-converter')
export class TempConverter extends LitElement {
// Converter untuk menangani JSON dari attribute
@property({
type: Object,
converter: {
fromAttribute: (value: string | null) => value ? JSON.parse(value) : {},
toAttribute: (value: object) => JSON.stringify(value),
},
})
config = { unit: 'celsius', precision: 1 };
@property({ type: Number }) value = 0;
render() {
const display = this.config.unit === 'fahrenheit'
? (this.value * 9/5 + 32).toFixed(this.config.precision)
: this.value.toFixed(this.config.precision);
const unit = this.config.unit === 'fahrenheit' ? 'Β°F' : 'Β°C';
return html`<span>${display}${unit}</span>`;
}
}
6. Templates & Directives
Lit menggunakan tagged template literal html`` untuk mendefinisikan template. Lit juga menyediakan directives untuk conditional rendering, loops, dan lain-lain.
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { until } from 'lit/directives/until.js';
interface TodoItem {
id: number;
text: string;
done: boolean;
}
@customElement('todo-app')
export class TodoApp extends LitElement {
@state() private todos: TodoItem[] = [];
@state() private newTodo = '';
@state() private filter: 'all' | 'active' | 'done' = 'all';
private get filteredTodos() {
switch (this.filter) {
case 'active': return this.todos.filter((t) => !t.done);
case 'done': return this.todos.filter((t) => t.done);
default: return this.todos;
}
}
static styles = css`
.todo-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-bottom: 1px solid #222;
}
.todo-item.done { text-decoration: line-through; opacity: 0.5; }
.filters { display: flex; gap: 8px; margin: 12px 0; }
.filters button.active { background: #0066ff; color: white; }
`;
private addTodo() {
if (!this.newTodo.trim()) return;
this.todos = [
...this.todos,
{ id: Date.now(), text: this.newTodo, done: false },
];
this.newTodo = '';
}
render() {
return html`
<h2>Todo App</h2>
<div>
<input
.value=${this.newTodo}
@input=${(e: Event) => this.newTodo = (e.target as HTMLInputElement).value}
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.addTodo()}
placeholder="Tambah todo..."
/>
<button @click=${this.addTodo}>Tambah</button>
</div>
<div class="filters">
<button class=${classMap({ active: this.filter === 'all' })}
@click=${() => this.filter = 'all'}>Semua</button>
<button class=${classMap({ active: this.filter === 'active' })}
@click=${() => this.filter = 'active'}>Aktif</button>
<button class=${classMap({ active: this.filter === 'done' })}
@click=${() => this.filter = 'done'}>Selesai</button>
</div>
${repeat(
this.filteredTodos,
(todo) => todo.id,
(todo) => html`
<div class=${classMap({ 'todo-item': true, done: todo.done })}>
<input
type="checkbox"
.checked=${todo.done}
@change=${() => this.toggleTodo(todo.id)}
/>
<span style=${styleMap({ fontWeight: todo.done ? 'normal' : 'bold' })}>
${todo.text}
</span>
<button @click=${() => this.removeTodo(todo.id)}>Γ</button>
</div>
`
)}
${this.filteredTodos.length === 0
? html`<p>Tidak ada todo.</p>`
: nothing}
`;
}
private toggleTodo(id: number) {
this.todos = this.todos.map((t) =>
t.id === id ? { ...t, done: !t.done } : t
);
}
private removeTodo(id: number) {
this.todos = this.todos.filter((t) => t.id !== id);
}
}
7. Events & Lifecycle
@customElement('rating-stars')
export class RatingStars extends LitElement {
@property({ type: Number }) rating = 0;
@property({ type: Number }) maxStars = 5;
@property({ type: Boolean, attribute: 'read-only' }) readOnly = false;
private setRating(value: number) {
if (this.readOnly) return;
const oldRating = this.rating;
this.rating = value;
// Dispatch custom event
this.dispatchEvent(new CustomEvent('rating-changed', {
detail: { rating: value, previousRating: oldRating },
bubbles: true, // Event naik ke parent
composed: true, // Event melewati shadow boundary
}));
}
render() {
return html`
<div class="stars">
${Array.from({ length: this.maxStars }, (_, i) => html`
<span
class="star ${i < this.rating ? 'filled' : ''}"
@click=${() => this.setRating(i + 1)}
style="cursor: ${this.readOnly ? 'default' : 'pointer'}"
>${i < this.rating ? 'β
' : 'β'}</span>
`)}
</div>
`;
}
}
// Penggunaan:
// <rating-stars
// max-stars="5"
// @rating-changed=${(e) => console.log('Rating:', e.detail.rating)}
// ></rating-stars>
@customElement('lifecycle-demo')
export class LifecycleDemo extends LitElement {
@property({ type: String }) value = '';
// 1. Constructor β inisialisasi
constructor() {
super();
console.log('1. constructor()');
}
// 2. connectedCallback β ditambahkan ke DOM
connectedCallback() {
super.connectedCallback();
console.log('2. connectedCallback()');
}
// 3. firstUpdated β render pertama selesai
firstUpdated(changedProperties: Map<string, unknown>) {
console.log('3. firstUpdated()');
// Aman untuk akses DOM (this.shadowRoot.querySelector, dll)
}
// 4. updated β setiap render selesai
updated(changedProperties: Map<string, unknown>) {
console.log('4. updated()', changedProperties);
// changedProperties berisi property yang berubah
}
// 5. disconnectedCallback β dihapus dari DOM
disconnectedCallback() {
super.disconnectedCallback();
console.log('5. disconnectedCallback()');
}
render() {
return html`<p>Value: ${this.value}</p>`;
}
}
8. Teknik Lanjutan
Menggunakan Lit di React/Vue
// React bisa langsung menggunakan custom elements!
import React from 'react';
function App() {
return (
<div>
<h1>Aplikasi React dengan Lit Components</h1>
{/* Menggunakan Web Component langsung di JSX */}
<my-counter count={5} label="Counter" />
{/* Menggunakan property (bukan attribute) dengan ref */}
<user-profile
name="Budi"
age={28}
active
skills={['React', 'Lit', 'TypeScript']}
/>
{/* Custom events */}
<rating-stars
max-stars={5}
onRating-changed={(e) => console.log(e.detail.rating)}
/>
</div>
);
}
9. Best Practices
- Kebab-case nama β selalu gunakan format
my-component - Shadow DOM β selalu gunakan untuk enkapsulasi style
- CSS :host β gunakan untuk styling komponen itu sendiri
- Events β gunakan
bubbles: true, composed: trueuntuk custom events - Avoid tight coupling β komunikasi via properties dan events, bukan langsung
- @state untuk internal, @property untuk public API
- CSS parts β gunakan
::part()untuk allow external styling - A11y β tambahkan ARIA attributes untuk aksesibilitas
- Bundle size β Lit hanya ~5KB gzipped, sangat efisien
- Design system β Web Components ideal untuk design system lintas-framework
10. Quiz: Uji Pemahamanmu!
Setelah membaca tutorial Web Components dengan Lit, jawablah 5 pertanyaan berikut: