Web Components — Component Native Không Cần Framework
Web Components là tiêu chuẩn browser cho reusable component — Custom Elements, Shadow DOM, Slot. Không React, không Vue, không build step.
Web Components là tiêu chuẩn browser cho phép tạo reusable component — không cần framework nào.
<!-- Dùng component — chỉ là HTML tag -->
<star-rating value="4" max="5"></star-rating>
<user-avatar src="/avatar.jpg" size="large"></user-avatar>
<confirm-dialog>
Bạn chắc chắn muốn xoá?
</confirm-dialog>htmlChạy trong mọi browser hiện đại, kết hợp được với React/Vue/Svelte/Astro.
Core Technologies#
Web Components gồm 3 tiêu chuẩn:
Custom Elements → Định nghĩa HTML tag mới
Shadow DOM → Encapsulation (style + DOM riêng)
HTML Templates → Declarative markup (slot, template)plaintextCustom Elements#
Định nghĩa#
class StarRating extends HTMLElement {
constructor() {
super()
this._value = 0
this._max = 5
}
// Attribute nào được observe
static get observedAttributes() {
return ['value', 'max']
}
// Khi attribute thay đổi
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'value') this._value = parseInt(newValue)
if (name === 'max') this._max = parseInt(newValue)
this.render()
}
// Gọi khi element được thêm vào DOM
connectedCallback() {
this.render()
}
render() {
this.innerHTML = ''
for (let i = 1; i <= this._max; i++) {
const star = document.createElement('span')
star.textContent = i <= this._value ? '★' : '☆'
star.style.cursor = 'pointer'
star.addEventListener('click', () => {
this.setAttribute('value', i)
this.dispatchEvent(new CustomEvent('rate', { detail: i }))
})
this.appendChild(star)
}
}
}
customElements.define('star-rating', StarRating)javascriptDùng#
<star-rating value="3" max="5"></star-rating>
<script>
document.querySelector('star-rating')
.addEventListener('rate', (e) => {
console.log('Rated:', e.detail)
})
</script>htmlLifecycle#
class LifecycleDemo extends HTMLElement {
constructor() {
super()
console.log('1. constructor — khởi tạo state')
}
connectedCallback() {
console.log('2. connectedCallback — thêm vào DOM')
// Setup event listeners, fetch data
}
disconnectedCallback() {
console.log('3. disconnectedCallback — xoá khỏi DOM')
// Cleanup: remove listeners, release resources
}
attributeChangedCallback(name, oldVal, newVal) {
console.log(`4. attributeChanged: ${name} ${oldVal} → ${newVal}`)
}
adoptedCallback() {
console.log('5. adoptedCallback — move between documents')
}
static get observedAttributes() {
return ['disabled', 'value', 'label']
}
}javascriptShadow DOM#
Shadow DOM cô lập style và DOM — không bị ảnh hưởng bởi CSS global.
class UserCard extends HTMLElement {
constructor() {
super()
// Attach shadow root — DOM riêng, style riêng
this.attachShadow({ mode: 'open' })
}
connectedCallback() {
const name = this.getAttribute('name') || 'Guest'
const avatar = this.getAttribute('avatar') || '/default-avatar.png'
this.shadowRoot.innerHTML = `
<style>
/* Style này chỉ áp dụng trong shadow DOM */
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
display: flex;
align-items: center;
gap: 12px;
font-family: system-ui;
}
img {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
}
.name {
font-weight: 600;
font-size: 16px;
}
.role {
color: #666;
font-size: 14px;
}
</style>
<div class="card">
<img src="${avatar}" alt="${name}">
<div>
<div class="name">${name}</div>
<div class="role"><slot name="role">User</slot></div>
</div>
</div>
`
}
}
customElements.define('user-card', UserCard)javascript<!-- CSS global không ảnh hưởng đến card -->
<style>
.card { background: red; } /* Không ảnh hưởng */
img { filter: blur(10px); } /* Không ảnh hưởng */
</style>
<user-card name="Alice" avatar="/alice.jpg">
<span slot="role">Admin</span>
</user-card>htmlSlots#
Slot cho phép component nhận children từ bên ngoài:
class ConfirmDialog extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' })
}
connectedCallback() {
const title = this.getAttribute('title') || 'Confirm'
this.shadowRoot.innerHTML = `
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
}
.dialog {
background: white;
border-radius: 12px;
padding: 24px;
min-width: 300px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
h2 { margin: 0 0 16px; }
.actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
button {
padding: 8px 16px;
border-radius: 6px;
border: 1px solid #ddd;
cursor: pointer;
}
.confirm { background: #0066ff; color: white; border: none; }
</style>
<div class="overlay" part="overlay">
<div class="dialog">
<h2>${title}</h2>
<slot></slot> <!-- Nội dung từ bên ngoài -->
<div class="actions">
<button class="cancel" part="cancel-btn">Cancel</button>
<button class="confirm" part="confirm-btn">Confirm</button>
</div>
</div>
</div>
`
this.shadowRoot.querySelector('.cancel')
.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('cancel'))
this.remove()
})
this.shadowRoot.querySelector('.confirm')
.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('confirm'))
this.remove()
})
}
}
customElements.define('confirm-dialog', ConfirmDialog)javascript<confirm-dialog title="Delete Post" id="dialog">
<p>Bạn có chắc muốn xoá bài viết này?</p>
<p style="color: red;">Hành động này không thể hoàn tác.</p>
</confirm-dialog>
<script>
const dialog = document.getElementById('dialog')
dialog.addEventListener('confirm', () => deletePost(postId))
dialog.addEventListener('cancel', () => console.log('Cancelled'))
</script>htmlHTML Template#
<!-- Template — không render cho đến khi dùng -->
<template id="tooltip-template">
<style>
.tooltip {
background: #333;
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 14px;
position: absolute;
z-index: 1000;
white-space: nowrap;
}
</style>
<div class="tooltip">
<slot></slot>
</div>
</template>htmlclass TooltipElement extends HTMLElement {
connectedCallback() {
const template = document.getElementById('tooltip-template')
const clone = template.content.cloneNode(true)
this.appendChild(clone)
const text = this.getAttribute('text') || ''
this.querySelector('.tooltip').textContent = text
}
}
customElements.define('tooltip-element', TooltipElement)javascriptState Management#
class CounterElement extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' })
this._count = 0
}
connectedCallback() {
this.render()
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host { display: inline-flex; align-items: center; gap: 8px; }
button {
width: 32px; height: 32px;
border: 1px solid #ddd;
border-radius: 6px;
cursor: pointer;
font-size: 18px;
}
span { font-size: 18px; min-width: 40px; text-align: center; }
</style>
<button id="dec">−</button>
<span>${this._count}</span>
<button id="inc">+</button>
`
this.shadowRoot.getElementById('inc')
.addEventListener('click', () => {
this._count++
this.render()
this.dispatchEvent(new CustomEvent('change', {
detail: this._count,
}))
})
this.shadowRoot.getElementById('dec')
.addEventListener('click', () => {
this._count--
this.render()
this.dispatchEvent(new CustomEvent('change', {
detail: this._count,
}))
})
}
}
customElements.define('counter-element', CounterElement)javascriptTích Hợp Với Framework#
Web Components tương thích mọi framework:
// React — dùng như HTML tag thường
function App() {
return (
<div>
<star-rating value={rating} max={5}
onRate={(e) => setRating(e.detail)}
/>
<user-card name="Alice" avatar="/alice.jpg" />
</div>
)
}tsx<!-- Svelte -->
<counter-element
on:change={(e) => count = e.detail}
/>svelte<!-- Astro — zero JS mặc định -->
---
import Layout from '../layouts/Layout.astro'
---
<Layout>
<star-rating value="4" max="5" />
<user-card name="Alice" avatar="/alice.jpg">
<span slot="role">Admin</span>
</user-card>
</Layout>astroLit — Thư Viện Cho Web Components#
Lit giúp viết Web Components dễ hơn:
import { LitElement, html, css } from 'lit'
import { customElement, property } from 'lit/decorators.js'
@customElement('my-button')
export class MyButton extends LitElement {
static styles = css`
button {
background: #0066ff;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
&:hover { opacity: 0.9; }
}
`
@property({ type: String }) variant: 'primary' | 'danger' = 'primary'
render() {
return html`<button class=${this.variant}>
<slot></slot>
</button>`
}
}typescript<my-button variant="danger">Delete</my-button>htmlKhi Nào Dùng Web Components?#
✅ Nên dùng#
- Design system / Component library — dùng được mọi framework
- Micro-frontend — team khác nhau dùng framework khác nhau
- Embedded widget — third-party widget chạy trong trang bất kỳ
- Legacy app — thêm component mới vào app jQuery/SPA cũ
- Framework-agnostic library — muốn ai cũng dùng được
❌ Không nên#
- App chính — React/Vue/Svelte có developer experience tốt hơn
- Complex state — Web Components không có state management built-in
- SSR — Web Components cần JavaScript để hydrate
- Small team, one framework — không cần abstraction này
Tổng Kết#
Web Components là tiêu chuẩn, không phải thư viện. Nó giải quyết bài toán “component dùng được ở mọi nơi”.
// Web Component — 3 API, không dependency
customElements.define('my-component', MyComponent)
this.attachShadow({ mode: 'open' })
<slot></slot>javascript| Tiêu chí | React/Vue/Svelte | Web Components |
|---|---|---|
| Dependencies | ~10-40KB runtime | 0KB — browser API |
| Học | Framework-specific | HTML/CSS/JS thuần |
| Reuse | Cùng framework | Mọi framework |
| SSR | ✅ | ❌ (cần JS) |
| Shadow DOM | ❌ | ✅ Native |
| State | Built-in | Tự làm |
| Build step | ✅ | Có thể không cần |
Web Components không thay thế framework — nó là lớp tương thích giữa các framework. Dùng cho component dùng chung, design system, widget third-party.