Skip to main content
JavaScript 14 mins read Devs2

Web Components: Hướng Dẫn Hoàn Chỉnh Từ Custom Elements Đến Shadow DOM

Học Web Components từ A-Z: Custom Elements, Shadow DOM, template & slot, lifecycle callbacks, attributeChangedCallback, ::part, ElementInternals, declarative shadow DOM, SSR và cách dùng chung với React, Vue hay Astro.

#Web Components #Custom Elements #Shadow DOM #JavaScript #Browser API #Component #Frontend #Best Practices

Minh họa Web Components với custom element, shadow DOM và slot

Bạn đã bao giờ copy một đoạn component từ project này sang project khác, rồi phát hiện nó phụ thuộc vào ba thư viện khác, một build config riêng và một global CSS mà bạn không dám xoá? Hoặc bạn muốn viết một widget nhỏ để nhúng vào website của khách hàng, nhưng không thể bắt khách hàng cài React?

Đó chính là bài toán mà Web Components sinh ra để giải quyết.

Web Components không phải một framework mới. Nó là một bộ chuẩn của nền tảng web — nghĩa là trình duyệt hiểu nó trực tiếp, không cần build step, không cần runtime, và một component viết ra có thể chạy trong HTML thuần, React, Vue, Angular hay Astro.

Trong bài viết này, chúng ta sẽ đi từ bốn trụ cột của Web Components, viết một component thực tế từ đầu, rồi đi tới những phần ít người dạy nhưng cực kỳ quan trọng khi làm sản phẩm thật: ::part, ElementInternals, Declarative Shadow DOM, SSR và cách phối hợp với các framework.

Web Components là gì?

Web Components là một tập hợp các tiêu chuẩn W3C/WHATWG cho phép bạn tạo ra thẻ HTML của riêng mình với hành vi và giao diện được đóng gói hoàn toàn.

Nó gồm bốn phần, thường được gọi là “bốn trụ cột”:

Trụ cộtVai trò
Custom ElementsĐịnh nghĩa thẻ HTML mới (<my-card>) và vòng đời của nó
Shadow DOMCô lập DOM và CSS — “DOM con” riêng, không rò rỉ ra ngoài
HTML Templates<template> và <slot> để khai báo markup và cho phép tuỳ biến
ES ModulesĐóng gói, import/export và phân phối component như mọi module JS

Điểm mấu chốt cần hiểu ngay từ đầu:

Framework là thư viện chạy trong trang. Web Components là phần của trang. Đó là lý do một Web Component không cần ai “host” nó.

Web Components so với framework

Hãy so sánh thẳng thắn để bạn biết khi nào nên chọn gì:

Tiêu chíWeb ComponentsReact / Vue / Svelte
RuntimeKhông có (chuẩn trình duyệt)Có (vài chục KB+)
Đóng gói CSS✅ Tự nhiên nhờ Shadow DOM⚠️ Cần CSS-in-JS, scoped CSS, hoặc thủ công
Dùng trong app khác framework✅ Chỉ cần một thẻ HTML⚠️ Cần mount, adapter, hoặc iframe
Trải nghiệm phát triển⚠️ Verbose, phải tự lo reactivity✅ JSX/SFC, reactive, devtools tốt
Hệ sinh thái / thư việnNhỏ hơnRất lớn
Hợp SSR⚠️ Cần Declarative Shadow DOM✅ Framework có tooling sẵn

Kết luận thực dụng: Web Components mạnh ở lớp “phân phối và cô lập”, framework mạnh ở lớp “tổ chức ứng dụng”. Nhiều công ty dùng design system viết bằng Web Components rồi tiêu thụ nó từ React, Vue và cả trang marketing tĩnh.

Hỗ trợ trình duyệt

Tin tốt: bạn không cần polyfill nữa. Custom Elements v1 và Shadow DOM v1 hiện có mặt ở Chrome 54+, Firefox 63+, Safari 10.1+, Edge 79+ — tương đương hơn 98% người dùng toàn cầu. Riêng vài tính năng mới hơn có mốc thấp hơn:

  • Declarative Shadow DOM: Chrome 111+, Safari 16.4+, Firefox 123+
  • Form-associated custom elements (ElementInternals): Chrome 77+, Firefox 93+, Safari 16.4+
  • adoptedStyleSheets: Chrome 73+, Firefox 101+, Safari 16.4+
  • Customized built-in elements (<button is="my-button">): Safari không hỗ trợ — hãy tránh nếu cần đa nền tảng

Trụ cột 1 — Custom Elements

Custom Element là một class JavaScript kế thừa HTMLElement, được đăng ký với trình duyệt bằng customElements.define().

Hai quy tắc bắt buộc khi đặt tên:

  1. Tên phải có dấu gạch ngang (my-card ✅, mycard ❌) — để không bao giờ trùng với thẻ HTML tương lai.
  2. Phải là chữ thường, không có ký tự đặc biệt hay chữ in hoa.

Component đầu tiên

class HelloWorld extends HTMLElement {
  connectedCallback() {
    this.innerHTML = '<p>Xin chào từ Web Component! </p>';
  }
}

customElements.define('hello-world', HelloWorld);
<!-- Dùng ở bất kỳ đâu, kể cả trong React hay Vue -->
<hello-world></hello-world>

Đó là toàn bộ “hello world”. Không cần import runtime, không cần build.

Vòng đời (lifecycle callbacks)

Đây là phần quan trọng nhất của Custom Elements — nó thay thế cho useEffect/onMounted mà bạn quen dùng:

class MyCard extends HTMLElement {
  // 1. Chạy khi element được tạo (kể cả do document.createElement)
  //    ⚠️ Chưa có attribute con, chưa nằm trong DOM
  constructor() {
    super();
    this._title = '';
  }

  // 2. Chạy khi element được chèn vào DOM (có thể nhiều lần nếu bị di chuyển)
  connectedCallback() {
    this.render();
    this.addEventListener('click', this._onClick);
  }

  // 3. Chạy khi element bị xoá khỏi DOM — nơi dọn dẹp
  disconnectedCallback() {
    this.removeEventListener('click', this._onClick);
    clearInterval(this._timer);
  }

  // 4. Chạy khi attribute trong observedAttributes thay đổi
  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue === newValue) return; // tránh render thừa
    this.render();
  }

  // 5. Chạy khi element được di chuyển sang document khác (iframe, popup)
  adoptedCallback() {
    // hiếm khi dùng
  }

  _onClick = () => {
    this.dispatchEvent(new CustomEvent('card-click', { bubbles: true, composed: true }));
  };
}

Một vài điều cần ghi nhớ về vòng đời:

  • constructor không được đọc attribute hay chèn con. Khi constructor chạy, element chưa có ngữ cảnh DOM. Trình duyệt sẽ cảnh báo nếu bạn set attribute trong đó. Chỉ nên super(), khởi tạo state và attachShadow.
  • connectedCallback có thể chạy nhiều lần. Nếu ai đó appendChild element sang chỗ khác, disconnectedCallback rồi connectedCallback sẽ chạy lại. Đừng giả định nó chỉ chạy một lần.
  • Element tồn tại trước khi được define. Nếu bạn viết <my-card> trong HTML rồi mới load script, trình duyệt sẽ nâng cấp (upgrade) element khi define() chạy — và khi đó constructor + attributeChangedCallback (cho mọi attribute có sẵn) sẽ chạy ngay.

Attribute, property và reflection

Đây là chỗ dễ gây bug nhất khi mới học Custom Elements. Cần phân biệt rõ:

  • Attribute nằm trong HTML, luôn là chuỗi: <my-card title="Xin chào">
  • Property nằm trên object DOM, giữ mọi kiểu dữ liệu: card.settings = { theme: 'dark' }

Mẫu code “reflect” chuẩn nên viết như sau:

class MyCard extends HTMLElement {
  // Khai báo những attribute cần theo dõi
  static observedAttributes = ['title', 'count'];

  // Getter/setter cho kiểu dữ liệu phong phú — không map được vào attribute
  set settings(value) {
    this._settings = value;
    this.render();
  }
  get settings() {
    return this._settings;
  }

  // Boolean attribute: sự tồn tại mới là true, không phải giá trị
  get disabled() {
    return this.hasAttribute('disabled');
  }
  set disabled(value) {
    this.toggleAttribute('disabled', Boolean(value));
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue === newValue) return;

    if (name === 'count') {
      // Attribute là chuỗi → phải parse
      this._count = Number(newValue ?? 0);
    }
    this.render();
  }
}

Quy tắc vàng để quyết định:

Dữ liệuNên truyền qua
Chuỗi đơn giản, có thể xuất hiện trong HTML/SSRAttribute
BooleanAttribute (dùng hasAttribute/toggleAttribute)
Object, array, function, instanceProperty
Dữ liệu lớn hoặc thay đổi thường xuyênProperty

Trụ cột 2 — Shadow DOM

Shadow DOM là tính năng khiến Web Components khác biệt so với mọi cách làm component trước đó. Nó cho element của bạn một cây DOM riêng, biệt lập:

class MyCard extends HTMLElement {
  constructor() {
    super();
    // Tạo shadow root — nên làm trong constructor
    const shadow = this.attachShadow({ mode: 'open' });

    shadow.innerHTML = `
      <style>
        :host { display: block; }
        .card {
          border: 1px solid #e5e7eb;
          border-radius: 12px;
          padding: 16px;
          font-family: system-ui, sans-serif;
        }
      </style>
      <div class="card" part="card">
        <slot name="title"></slot>
        <slot></slot>
      </div>
    `;
  }
}
customElements.define('my-card', MyCard);

Lợi ích cụ thể của Shadow DOM:

  • CSS không rò rỉ. .card { color: red } trong trang sẽ không ảnh hưởng .card bên trong shadow root, và ngược lại. Bạn hết cảnh component bị global CSS của “ai đó” phá.
  • Selector không đụng nhau. Bạn có thể đặt tên class ngắn gọn (.card, .title) mà không sợ trùng.
  • DOM API không thấy xuyên qua. document.querySelector('.card') không tìm được vào shadow root — tốt cho cả đóng gói lẫn việc script lạ khó can thiệp.

mode: 'open' cho phép truy cập qua element.shadowRoot (cần thiết cho testing và debugging). mode: 'closed' ẩn hoàn toàn — nhưng đừng xem nó là bảo mật, vì ai cũng có thể vượt qua bằng Element.prototype.attachShadow bị patch. Hãy dùng open cho hầu hết trường hợp.

Styling xuyên ranh giới: 3 công cụ bạn cần

Ranh giới style là điểm mạnh, nhưng bạn cần “cửa mở” để theme được. Có chính xác ba cách:

1. CSS custom properties — cách theming chính (kế thừa được luôn)

/* Bên ngoài */
my-card {
  --card-bg: #1e293b;
  --card-radius: 16px;
}
/* Bên trong shadow root */
shadow.innerHTML = `
  <style>
    .card {
      background: var(--card-bg, white);
      border-radius: var(--card-radius, 12px);
    }
  </style>
`;

Custom properties là thứ duy nhất kế thừa xuyên qua shadow boundary theo cách bạn mong đợi. Đây là lý do design system hiện đại (Shoelace, FAST, Spectrum) đều dùng CSS variables làm API theming.

2. part và ::part() — cho phép chọn phần tử bên trong

<!-- Bên trong shadow root -->
<button part="button primary">Lưu</button>
/* Bên ngoài style trực tiếp */
my-card::part(button) {
  font-weight: 600;
  padding: 10px 20px;
}
my-card::part(button primary) { background: #2563eb; color: white; }

::part() chỉ style được chính phần tử có part (không đi sâu xuống con của nó), và không thể tạo ra layout phá vỡ thiết kế — một lựa chọn API khá khéo léo.

3. ::slotted() — style nội dung do người dùng truyền vào

/* Chỉ tác động lên con trực tiếp được đưa vào slot */
::slotted(h2) {
  margin: 0 0 8px;
  color: #111827;
}
::slotted(strong) { color: #2563eb; }

Các selector đặc biệt cần nhớ

/* Style chính bản thân element */
:host { display: block; padding: 8px; }

/* Chỉ khi element khớp điều kiện */
:host([disabled]) { opacity: 0.5; pointer-events: none; }
:host(.featured) { border-color: gold; }

/* Theo ngữ cảnh cha (lưu ý: Firefox chưa hỗ trợ) */
:host-context(.dark-theme) { background: #0f172a; color: white; }

FOUC và :defined

Nếu component tải bằng JS, người dùng có thể thấy nội dung “thô” trước khi element được nâng cấp. Dùng :defined để xử lý:

/* Ẩn cho tới khi component sẵn sàng */
my-card:not(:defined) {
  display: block;
  min-height: 120px;
  background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%);
  background-size: 200% 100%;
  animation: shimmer 1.2s infinite;
  border-radius: 12px;
}
@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

Đây là “skeleton loading” chỉ bằng CSS, không tốn một dòng JavaScript — và giúp chống layout shift (CLS) rất tốt.

Trụ cột 3 — Template và Slot

<template>

<template> chứa markup không được render cho tới khi bạn clone nó. Nội dung bên trong template không tải ảnh, không chạy script, không ảnh hưởng layout — hoàn hảo để làm khuôn mẫu.

<template id="card-template">
  <style>
    :host { display: block; font-family: system-ui, sans-serif; }
    .wrap { border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; }
  </style>
  <div class="wrap" part="card">
    <header class="head" part="header">
      <slot name="title">Tiêu đề mặc định</slot>
    </header>
    <div class="body" part="body"><slot></slot></div>
  </div>
</template>

<script>
class MyCard extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    // cloneNode(true) — luôn dùng true để copy cả cây con
    shadow.append(document.getElementById('card-template').content.cloneNode(true));
  }
}
customElements.define('my-card', MyCard);
</script>

Lưu ý: khi dùng template.cloneNode(true), hai instance sẽ dùng chung stylesheet đó trong markup. Nếu bạn cần nhiều instance và muốn tối ưu, hãy chuyển sang adoptedStyleSheets (xem phần Hiệu năng) — trình duyệt sẽ chia sẻ một object CSS giữa mọi instance thay vì parse lại.

<slot> — cho phép người dùng tuỳ biến nội dung

Slot là “lỗ cắm” để bạn truyền nội dung vào từ bên ngoài, giống children trong React:

<my-card>
  <h2 slot="title">Doanh số Q3</h2>
  <p>Doanh số tăng <strong>24%</strong> so với quý trước.</p>
</my-card>

Hai loại slot:

  • Default slot — <slot></slot> nhận mọi nội dung không có thuộc tính slot
  • Named slot — <slot name="title"> nhận nội dung có <... slot="title">
  • Nội dung giữa <slot> chính là fallback khi không ai truyền gì vào.

Và đây là phần ít người biết: bạn có thể truy vấn ngược nội dung đã được cắm vào:

class MyTabs extends HTMLElement {
  connectedCallback() {
    this.shadowRoot.querySelector('slot')
      .addEventListener('slotchange', (e) => {
        // assignedElements() chỉ trả về element, bỏ qua text node
        const tabs = e.target.assignedElements();
        console.log(`Có ${tabs.length} tab được truyền vào`);
        tabs.forEach(tab => tab.setAttribute('role', 'tab'));
      });
  }
}

Đây là kỹ thuật nền tảng để làm tabs, accordion, carousel — nơi component cần đọc hiểu nội dung mà người dùng truyền vào.

Component thực tế: <copy-button>

Hãy ghép mọi thứ lại thành một component bạn dùng được ngay trong blog hoặc docs — nút “Copy” cho code block:

class CopyButton extends HTMLElement {
  static observedAttributes = ['value', 'label'];

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._resetTimer = null;
  }

  connectedCallback() {
    this.render();
    this.shadowRoot.querySelector('button')
      .addEventListener('click', this._copy);
  }

  disconnectedCallback() {
    clearTimeout(this._resetTimer);
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue && this.shadowRoot) this.render();
  }

  get value() {
    return this.getAttribute('value') ?? '';
  }
  set value(v) {
    this.setAttribute('value', v);
  }

  _copy = async () => {
    const text = this.value;
    if (!text) return;

    try {
      await navigator.clipboard.writeText(text);
    } catch {
      // Fallback cho môi trường không có clipboard API (http, iframe hạn chế)
      const ta = document.createElement('textarea');
      ta.value = text;
      ta.style.position = 'fixed';
      ta.style.opacity = '0';
      document.body.appendChild(ta);
      ta.select();
      document.execCommand('copy');
      ta.remove();
    }

    this._feedback('Đã copy!');
    this.dispatchEvent(new CustomEvent('copied', {
      detail: { text },
      bubbles: true,
      composed: true, // cần thiết để event thoát khỏi shadow DOM
    }));
  };

  _feedback(message) {
    const btn = this.shadowRoot.querySelector('button');
    btn.textContent = message;
    btn.classList.add('done');
    clearTimeout(this._resetTimer);
    this._resetTimer = setTimeout(() => this.render(), 1500);
  }

  render() {
    const label = this.getAttribute('label') || 'Copy';
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: inline-block; }
        button {
          font: inherit;
          font-size: 13px;
          padding: 6px 12px;
          border: 1px solid var(--copy-border, #d1d5db);
          background: var(--copy-bg, #ffffff);
          color: var(--copy-color, #111827);
          border-radius: 8px;
          cursor: pointer;
          transition: background-color .15s ease, transform .15s ease;
        }
        button:hover { background: var(--copy-hover-bg, #f3f4f6); }
        button:active { transform: scale(.97); }
        button.done { background: #dcfce7; border-color: #86efac; color: #166534; }
        button:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
      </style>
      <button type="button" part="button" aria-live="polite">${label}</button>
    `;
  }
}

customElements.define('copy-button', CopyButton);

Và dùng nó thế này:

<copy-button value="npx astro build" label="Copy lệnh"></copy-button>

<!-- Theme từ bên ngoài bằng CSS variables -->
<style>
  copy-button {
    --copy-bg: #1e293b;
    --copy-color: white;
    --copy-border: #334155;
  }
</style>

<!-- Hoặc style sâu vào bên trong bằng ::part -->
<style>
  copy-button::part(button) {
    text-transform: uppercase;
    letter-spacing: .04em;
  }
</style>

Component này đã có: đóng gói CSS, fallback clipboard, phản hồi trực quan, sự kiện tuỳ biến, và API theming qua hai cơ chế. Đó là hình mẫu tốt cho mọi Web Component bạn viết.

Sự kiện: composed là chìa khoá

Đây là lỗi phổ biến nhất khi lần đầu làm việc với Shadow DOM: bạn dispatch event trong component nhưng bên ngoài không nghe thấy gì.

Lý do: event mặc định không đi xuyên qua shadow boundary. Ba thuộc tính của CustomEvent bạn cần nắm:

this.dispatchEvent(new CustomEvent('value-change', {
  detail: { value: 42 },  // 1. Dữ liệu kèm theo — nhận qua e.detail
  bubbles: true,          // 2. Bong bóng lên cây DOM cha
  composed: true,         // 3. ✅ Đi xuyên qua shadow boundary
}));

// Parent bên ngoài shadow DOM vẫn nghe được
document.querySelector('my-slider')
  .addEventListener('value-change', (e) => console.log(e.detail.value));

Quy tắc: hầu như luôn set bubbles: true, composed: true cho custom event. Chỉ để false khi bạn muốn event riêng tư, nội bộ trong component.

Một lưu ý về detail: chỉ truyền dữ liệu đơn giản, đừng truyền tham chiếu tới DOM node nội bộ — cần thì truyền this (chính element) để bên ngoài tự truy vấn.

Nâng cao: Declarative Shadow DOM và SSR

Vấn đề lớn nhất của Web Components gốc là SSR. Nếu shadow root chỉ được tạo trong JavaScript, thì máy tìm kiếm, curl hay người dùng tắt JS đều thấy trang trống.

Declarative Shadow DOM (DSD) giải quyết đúng bài toán đó: khai báo shadow root ngay trong HTML.

<my-card>
  <template shadowrootmode="open">
    <style>
      :host { display: block; }
      .wrap { border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; }
    </style>
    <div class="wrap" part="card">
      <slot name="title">Tiêu đề</slot>
    </div>
  </template>

  <h2 slot="title">Doanh số Q3</h2>
</my-card>

Trình duyệt gặp <template shadowrootmode="open"> sẽ gắn shadow root ngay khi parse HTML — không cần đợi JavaScript. Nội dung đã có sẵn trong DOM nên:

  • Crawler và công cụ đọc màn hình đọc được nội dung ✅
  • Không có layout shift vì nội dung đã hiển thị ✅
  • Không có FOUC ✅
  • JavaScript chỉ còn nhiệm vụ “hydration” — gắn event listener khi element được nâng cấp ✅

Nếu bạn viết trong JS, attachShadow phải chấp nhận element đã có shadow root sẵn:

class MyCard extends HTMLElement {
  connectedCallback() {
    // Nếu DSD đã tạo shadow root (mode open), đừng attach lại
    if (!this.shadowRoot) {
      this.attachShadow({ mode: 'open' })
        .append(document.getElementById('card-template').content.cloneNode(true));
    }
    // Ở đây chỉ gắn behavior lên markup đã có
    this.shadowRoot.querySelector('button')?.addEventListener('click', this._onClick);
  }
}

Trong Astro, bạn có thể render DSD trực tiếp trong một .astro component — đây là cách Devs2.org dùng cho các widget cần SSR tốt. Với các framework khác, thư viện như @lit-labs/ssr có thể render ra DSD tự động.

Form, validation và accessibility với ElementInternals

Trước đây, một custom input không thể tham gia <form>: new FormData(form) không thấy nó, <label for> không trỏ được, validation API bỏ qua nó. ElementInternals sửa tất cả.

class StarRating extends HTMLElement {
  static formAssociated = true;           // 1. Đăng ký là form control
  static observedAttributes = ['name', 'value'];

  constructor() {
    super();
    this._internals = this.attachInternals(); // 2. Lấy "cửa" vào form
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() { this.render(); }

  attributeChangedCallback() { if (this.shadowRoot) this.render(); }

  get value() { return Number(this.getAttribute('value') || 0); }

  set value(v) {
    this.setAttribute('value', String(v));
    // 3. Đẩy giá trị vào form — xuất hiện trong FormData và form.reset()
    this._internals.setFormValue(String(v));

    // 4. Báo lỗi validation tuỳ chỉnh
    if (Number(v) === 0) {
      this._internals.setValidity(
        { customError: true },
        'Vui lòng chọn ít nhất 1 sao'
      );
    } else {
      this._internals.setValidity({});
    }
  }

  formResetCallback() { this.value = '0'; }   // 5. Hook đặc biệt cho form.reset()

  render() {
    const v = this.value;
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: inline-flex; gap: 4px; }
        button { all: unset; cursor: pointer; font-size: 24px; line-height: 1; }
        button[aria-checked="true"] { color: #f59e0b; }
        button[aria-checked="false"] { color: #d1d5db; }
        button:focus-visible { outline: 2px solid #2563eb; border-radius: 4px; }
      </style>
      ${[1, 2, 3, 4, 5].map(n => `
        <button type="button" role="radio"
                aria-checked="${n <= v}"
                aria-label="${n} sao"
                data-value="${n}">
          ${n <= v ? '★' : '☆'}
        </button>
      `).join('')}
    `;

    this.shadowRoot.querySelectorAll('button').forEach(btn => {
      btn.addEventListener('click', () => { this.value = btn.dataset.value; });
    });

    // 6. Truyền thông tin a11y cho screen reader đi qua shadow boundary
    this._internals.role = 'radiogroup';
    this._internals.ariaLabel = 'Đánh giá sao';
  }
}

customElements.define('star-rating', StarRating);
<form id="review">
  <label for="rating">Đánh giá của bạn</label>
  <star-rating id="rating" name="rating" value="0"></star-rating>
  <button type="submit">Gửi</button>
</form>

<script>
  const form = document.getElementById('review');
  form.addEventListener('submit', (e) => {
    e.preventDefault();
    // ✅ Custom element xuất hiện trong FormData như input thường
    console.log([...new FormData(form)]); // [['rating', '4']]
  });
  console.log(form.checkValidity()); // false nếu chưa chọn sao
</script>

ElementInternals cũng là công cụ chính để làm accessibility cho Web Components: internals.role, internals.ariaLabel, internals.ariaChecked, internals.labels… vì ARIA attribute đặt trong shadow DOM không được screen reader đọc từ element host.

Hiệu năng: 5 tối ưu quan trọng

Web Components rất nhẹ, nhưng có vài cái bẫy hiệu năng bạn nên biết trước khi lên production.

1. Dùng adoptedStyleSheets thay vì chèn <style> mỗi instance

Cách ngây thơ — mỗi instance parse lại CSS — sẽ tạo ra hàng nghìn stylesheet nếu bạn render nhiều row trong bảng:

// ❌ Mỗi instance tạo CSSStyleSheet mới → tốn bộ nhớ, parse lặp lại
this.shadowRoot.innerHTML = `<style>...</style><div>...</div>`;

Cách tối ưu: tạo một CSSStyleSheet ở module scope, chia sẻ cho tất cả:

// ✅ Một stylesheet, mọi instance dùng chung — parse chỉ 1 lần
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
  :host { display: block; }
  .row { display: flex; gap: 12px; padding: 8px 12px; border-bottom: 1px solid #f1f5f9; }
  .row:hover { background: #f8fafc; }
`);

class DataRow extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.adoptedStyleSheets = [sheet]; // chia sẻ reference, không copy
    shadow.innerHTML = '<div class="row"><slot></slot></div>';
  }
}

Đây là tối ưu lớn nhất và dễ làm nhất trong danh sách này.

2. Không render trong attributeChangedCallback một cách mù quáng

Mỗi setAttribute đều kích hoạt callback. Nếu ai đó set 5 attribute trong một loop, bạn sẽ render 5 lần. Hãy dùng cờ và gộp vào microtask:

_scheduleRender() {
  if (this._renderQueued) return;
  this._renderQueued = true;
  queueMicrotask(() => {
    this._renderQueued = false;
    this.render();
  });
}

3. Lazy define cho component nặng

Nếu một component kéo theo thư viện lớn (chart, editor), đừng import nó ngay từ đầu:

class LazyChart extends HTMLElement {
  async connectedCallback() {
    this.innerHTML = '<p>Đang tải biểu đồ…</p>';
    // Chỉ tải chunk code khi component thực sự xuất hiện
    const { renderChart } = await import('./chart-renderer.js');
    renderChart(this, this._data);
  }
}
customElements.define('lazy-chart', LazyChart);

Kết hợp với Intersection Observer để chỉ tải khi người dùng cuộn tới, và bạn có code splitting thật sự không cần bundler phức tạp.

4. Giữ :host có display

Mặc định Custom Element là display: inline — nguyên nhân của vô số bug layout khó hiểu (“sao nó không có chiều cao?”). Luôn khai báo:

:host { display: block; }       /* hoặc inline-block / flex / grid */

5. Tránh innerHTML trong constructor

innerHTML trên shadow root trong constructor buộc trình duyệt parse HTML ở thời điểm chưa cần thiết. connectedCallback là nơi thích hợp hơn — trừ khi bạn dùng <template> đã có sẵn trong document (parse sẵn, clone rẻ).

Dùng Web Components trong React, Vue và Astro

Đây là câu hỏi thực tế nhất: component của tôi dùng được trong app đang chạy framework không?

React 19+

React 19 hỗ trợ Custom Elements đúng cách: truyền được object/array qua property, và gắn được custom event bằng cú pháp on*. Trước đây (React ≤ 18) React chỉ set attribute — nên object bị ép thành "[object Object]".

import 'my-components/star-rating'; // đăng ký element

export function Review() {
  const [rating, setRating] = useState(0);

  return (
    <star-rating
      name="rating"
      value={rating}
      onValueChange={(e: CustomEvent) => setRating(e.detail.value)}
    />
  );
}

Với React 18 trở về trước, cần một wrapper nhỏ dùng ref để gán property:

function StarRating({ value, onChange }) {
  const ref = useRef(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    el.value = value;                        // gán property, không phải attribute
    const handler = (e) => onChange(e.detail.value);
    el.addEventListener('value-change', handler);
    return () => el.removeEventListener('value-change', handler);
  }, [value, onChange]);

  return <star-rating ref={ref} />;
}

Vue 3

Vue cần cấu hình để hiểu các thẻ có gạch ngang là custom element:

// vite.config.js
import vue from '@vitejs/plugin-vue';

export default {
  plugins: [
    vue({
      template: {
        compilerOptions: {
          // Coi mọi thẻ chứa '-' là custom element → không cảnh báo
          isCustomElement: (tag) => tag.includes('-'),
        },
      },
    }),
  ],
};

Với Vue, v-bind trên custom element đã tự động ưu tiên property khi cần, và @value-change hoạt động với custom event.

Astro

Astro là môi trường lý tưởng cho Web Components vì nó không “chiếm” cây DOM:

---
// Component Astro render markup sẵn, rồi Web Component chỉ hydration
const items = ['Doanh thu', 'Chi phí', 'Lợi nhuận'];
---
<div class="grid">
  {items.map(item => (
    <data-row>
      <span slot="label">{item}</span>
      <span slot="value">1.240.000đ</span>
    </data-row>
  ))}
</div>

<script>
  // Chỉ chạy trên client, đăng ký element một lần
  import '../components/data-row.js';
</script>

Best practices & những lỗi thường gặp

✅ Nên làm

  • Luôn có dấu gạch ngang trong tên thẻ — bắt buộc, và đặt tiền tố dự án (devs2-card) để tránh va chạm.
  • Reflect property ↔ attribute một cách nhất quán, và luôn kiểm tra oldValue === newValue để tránh vòng lặp render.
  • Chỉ khai báo attribute trong observedAttributes khi thực sự cần — mỗi attribute được theo dõi là một chi phí.
  • Dùng adoptedStyleSheets cho component render nhiều lần.
  • Set bubbles: true, composed: true cho custom event mà bạn muốn bên ngoài nghe.
  • Dùng part="..." cho mọi phần tử mà người dùng có thể muốn style — hãy coi đó là public API.
  • Dùng CSS custom properties làm API theming chính; đặt tên tiền tố (--devs2-card-bg).
  • Xử lý :not(:defined) để tránh FOUC và layout shift.
  • Dọn dẹp trong disconnectedCallback: clearTimeout, removeEventListener, observer.disconnect(), abortController.abort().
  • Dùng ElementInternals cho form component và cho ARIA — thay vì gắn ARIA trong shadow DOM.
  • Cân nhắc Declarative Shadow DOM nếu cần SSR/SEO.

❌ Tránh

  • Không đọc/ghi attribute hay chèn con trong constructor — trình duyệt sẽ cảnh báo và hành vi không đảm bảo.
  • Không dùng mode: 'closed' để “bảo mật” — nó chỉ gây khó khăn cho chính bạn khi debug và test.
  • Không giả định connectedCallback chạy một lần — element có thể bị di chuyển trong DOM.
  • Không dùng Customized Built-in Elements (<button is="x-btn">) nếu cần Safari — hãy dùng autonomous element + <slot>.
  • Không truyền object qua attribute — attribute là chuỗi; hãy dùng property.
  • Không style trực tiếp bằng selector xuyên shadow — nó không hoạt động; dùng ::part() hoặc CSS variable.
  • Không quên display: block cho :host khi component mang tính khối.
  • Không nhồi logic nghiệp vụ vào component — Web Component nên là lớp trình bày; logic nên nằm ở module JS thuần để test được bằng Node, không cần DOM.

Checklist trước khi ship một Web Component

  • Tên thẻ có dấu gạch ngang và có tiền tố dự án
  • static observedAttributes chỉ chứa những attribute thực sự cần theo dõi
  • attributeChangedCallback có guard oldValue === newValue
  • Property/setter cho dữ liệu phức tạp (object, array, function)
  • :host { display: ... } được khai báo rõ ràng
  • Theming qua CSS custom properties, có giá trị fallback (var(--x, mặc-định))
  • part="..." trên các phần tử thuộc public API styling
  • Custom event dùng bubbles: true, composed: true khi cần
  • disconnectedCallback dọn sạch timer, listener, observer, AbortController
  • Stylesheet dùng chung qua adoptedStyleSheets nếu component render nhiều lần
  • Có rule :not(:defined) để tránh FOUC / CLS
  • Hoạt động được chỉ với bàn phím, có :focus-visible
  • ARIA qua ElementInternals nếu component mang ngữ nghĩa đặc biệt
  • Có test: chạy customElements.whenDefined() trong test, kiểm tra attribute, property, event
  • Đã kiểm tra bundle size và không import thư viện nặng ở top-level

Kết luận

Web Components là một trong số ít công nghệ frontend mà độ “bền” tỉ lệ nghịch với độ “hot”. Trong khi các framework thay nhau lên ngôi mỗi vài năm, bốn trụ cột của Web Components vẫn là chuẩn trình duyệt — và trình duyệt thì không có breaking change.

Ba điều đáng nhớ nhất:

  • Shadow DOM là công cụ cô lập mạnh nhất mà nền tảng web có — hãy dùng nó, nhưng luôn mở hai “cửa” để theming: CSS custom properties và ::part().
  • Sự kiện cần composed: true để vượt qua ranh giới — đây là bug số một của người mới.
  • Web Components không thay thế framework, chúng bổ sung một tầng không phụ thuộc — design system, widget nhúng, thư viện cho khách hàng là ba use case mà framework không làm tốt bằng.

Nếu bạn muốn bắt đầu ngay hôm nay, hãy làm theo thứ tự này: viết một <copy-button> cho blog (như ví dụ ở trên), rồi một <star-rating> dùng ElementInternals, rồi chuyển một widget nặng trong dự án thành Web Component có lazy load. Sau ba lần đó, bạn sẽ có trực giác đúng về khi nào nên và không nên dùng.

Mẹo nhỏ: trước khi tự viết mọi thứ, hãy xem qua Lit (thư viện ~5KB của Google giúp viết Web Components gọn hơn nhiều) và Shoelace/Web Awesome (design system Web Components tham khảo cực tốt về cách thiết kế ::part và CSS variables).


Bài viết tiếp theo: Web Workers — đưa tác vụ nặng ra khỏi main thread để giao diện không bao giờ “đơ”.

Recently Used Tools