jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Web Components · Phần 9 — Vanilla capstone: một component hoàn chỉnh

Checkpoint ghép Custom Elements, Shadow DOM, slots, public events, theming, accessibility, ElementInternals và Declarative Shadow DOM thành một toggle dùng được.

Tám phần đầu đã tách riêng từng mảnh của component model gốc trong browser: lifecycle, public API, render, Shadow DOM, slot, CSS boundary, event, accessibility và form. Bây giờ ta ghép chúng lại trong một checkpoint vanilla, trước khi viết contract test và chuyển implementation sang Lit.

Bài này cố ý nhìn lại toàn bộ bản đồ trong một luồng. Đầu ra là <toggle-switch> có API HTML/JavaScript rõ ràng, theme được từ bên ngoài, tham gia FormData, dùng được bằng bàn phím và không rò listener khi reconnect. Nếu một mảnh bên dưới còn mơ hồ, mục tương ứng cũng là chỉ dẫn để quay lại Phần 1–8.

Mini Kanban vẫn là case study chính của series. Toggle là vertical slice nhỏ, độc lập để ta stress-test boolean state, slot label, keyboard, form và DSD mà không lẫn thêm collection state; Phần 10 quay lại khóa contract task card bằng browser test.

The three pillars

 ┌──────────────────────────────────────────────────────────────┐
 │                       WEB COMPONENTS                          │
 ├────────────────────┬───────────────────┬─────────────────────┤
 │  Custom Elements   │   Shadow DOM       │  HTML Templates     │
 │  ────────────────  │   ─────────────    │  ────────────────   │
 │  Định nghĩa thẻ    │  DOM + CSS         │  <template> &       │
 │  HTML riêng + vòng │  đóng gói, cô lập  │  <slot>: markup     │
 │  đời (lifecycle)   │  khỏi phần còn lại │  tái dùng, chèn nội │
 │                    │  của trang         │  dung (projection)  │
 └────────────────────┴───────────────────┴─────────────────────┘

Mỗi trụ cột tự nó đã hữu ích, nhưng khi kết hợp chúng cho phép bạn tạo một thẻ như <user-card> hoàn toàn khép kín: markup riêng, style riêng không rò rỉ, và hành vi riêng.


1. Custom Elements — define your own HTML tags

Một custom element là một class kế thừa HTMLElement, đăng ký với một tên thẻ. Tên thẻ bắt buộc có dấu gạch ngang — đó là cách parser phân biệt thẻ của bạn với thẻ gốc và tránh xung đột trong tương lai.

class GreetingBox extends HTMLElement {
  constructor() {
    super();
    // Chỉ khởi tạo state ở đây. KHÔNG đụng attributes/children/DOM
    // ngoài — lúc constructor chạy, element có thể chưa được gắn vào cây.
  }
}

customElements.define('greeting-box', GreetingBox);
<greeting-box></greeting-box>

The lifecycle callbacks

Trình duyệt gọi các method này tại những thời điểm xác định rõ:

CallbackKhi nào chạyDùng để
constructor()Khi instance được tạoKhởi tạo state, attach shadow root
connectedCallback()Mỗi lần element được gắn vào DOMRender, add event listener, fetch data
disconnectedCallback()Khi element bị gỡ khỏi DOMCleanup: remove listener, hủy timer
attributeChangedCallback(name, old, new)Khi một observed attribute đổiĐồng bộ attribute → state/UI
adoptedCallback()Khi element bị move sang document khácHiếm dùng (iframe, document.adoptNode)
class CountdownTimer extends HTMLElement {
  static observedAttributes = ['seconds'];

  #timerId = 0; // private field

  connectedCallback() {
    // connectedCallback có thể chạy NHIỀU lần (nếu element bị gỡ rồi
    // gắn lại). Luôn cleanup tương ứng ở disconnectedCallback.
    this.#render();
    this.#timerId = window.setInterval(() => this.#tick(), 1000);
  }

  disconnectedCallback() {
    // Bắt buộc cleanup — nếu không, timer rò rỉ khi element bị gỡ.
    clearInterval(this.#timerId);
  }

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

  #tick() {
    /* ... */
  }
  #render() {
    this.textContent = `${this.getAttribute('seconds') ?? 0}s`;
  }
}
customElements.define('countdown-timer', CountdownTimer);

Hai quy tắc giúp tiết kiệm hàng giờ debug:

  • connectedCallback có thể chạy nhiều lần — mỗi lần element được chèn lại. Mỗi setup phải có teardown tương ứng trong disconnectedCallback.
  • attributeChangedCallback chỉ chạy cho attribute có trong observedAttributes. Quên liệt kê thì callback im lặng không bao giờ chạy.

Upgrade — when an element meets its definition

HTML có thể chứa <greeting-box> trước khi JS định nghĩa nó được tải. Cho tới lúc đó nó là element “chưa định nghĩa”, render như một element generic trơ. Khi customElements.define() chạy, trình duyệt upgrade mọi element khớp đã có sẵn trong DOM.

// Chờ tới khi element đã được định nghĩa và upgrade xong.
await customElements.whenDefined('greeting-box');

// :defined chỉ khớp element đã được định nghĩa.
greeting-box:not(:defined) {
  opacity: 0.75;
}
greeting-box:defined {
  opacity: 1;
}

Đừng mặc định dùng visibility: hidden: nó xoá cả fallback HTML trước khi JS tải. Chỉ ẩn một element chưa upgrade khi nội dung bên trong thật sự không có giá trị nếu thiếu JavaScript và bạn đã dành sẵn không gian để tránh layout shift.

Autonomous vs Customized built-in

Có hai loại:

  • Tự thân — kế thừa HTMLElement, thẻ hoàn toàn mới. Hỗ trợ ở mọi nơi.
  • Mở rộng thẻ gốc — kế thừa một thẻ gốc, dùng dạng <button is="...">. Bạn thừa hưởng hành vi và a11y của thẻ gốc miễn phí, nhưng Safari từ chối implement is=, nên thực tế hiếm dùng.
class FancyButton extends HTMLButtonElement {
  connectedCallback() {
    this.classList.add('fancy');
  }
}
// Tham số thứ 3 khai báo nó mở rộng thẻ <button>.
customElements.define('fancy-button', FancyButton, { extends: 'button' });

2. Shadow DOM — true encapsulation

Không có Shadow DOM, CSS của component rò ra ngoài và CSS của trang rò vào trong. Shadow DOM cho mỗi element một cây DOM riêng với style cô lập.

class UserCard extends HTMLElement {
  constructor() {
    super();
    // mode:'open' → truy cập được qua element.shadowRoot từ bên ngoài.
    // mode:'closed' → shadowRoot = null từ ngoài, chỉ giữ tham chiếu nội bộ.
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        /* Style NÀY chỉ áp dụng bên trong shadow root — không rò ra trang. */
        p { color: rebeccapurple; font: 600 14px system-ui; }
      </style>
      <p>Tôi sống trong shadow DOM.</p>
    `;
  }
}
customElements.define('user-card', UserCard);

Light DOM vs Shadow DOM

 <user-card>                      ← host element

   ├─ shadow root (shadow DOM)     ← DOM riêng, style cô lập
   │    └─ <style> + <p>…          ← "shadow tree"

   └─ <span>Nội dung người dùng</span>  ← "light DOM": con thật của host,
                                          hiển thị qua <slot> (xem mục 3)
  • Cây shadow — markup nội bộ bạn tạo; style ở đây bị cô lập.
  • Light DOM — các con mà người dùng component viết giữa thẻ; được chiếu qua slot.

open vs closed

closed trông “an toàn” hơn nhưng phần lớn là ảo giác: ai chạy được code trong trang đều có thể vá attachShadow để bắt root của bạn. Nó còn chặn cả truy cập chính đáng (test, công cụ a11y). Mặc định dùng open trừ khi có lý do cụ thể.


3. Reusable markup + projection

<template> — inert markup

Nội dung trong <template> được parse nhưng không render: không tải ảnh, không chạy script, cho tới khi bạn clone nó. Hoàn hảo để “dập khuôn” cấu trúc lặp lại một cách rẻ.

const tpl = document.createElement('template');
tpl.innerHTML = `<style>…</style><div class="card"><slot></slot></div>`;

class UserCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    // Clone 1 lần parse, dùng lại cho mọi instance → nhanh hơn innerHTML mỗi lần.
    this.shadowRoot.appendChild(tpl.content.cloneNode(true));
  }
}

<slot> — project user content

Một <slot> là chỗ giữ trong cây shadow nơi các con light DOM xuất hiện.

shadow.innerHTML = `
  <style>
    .name { font-weight: 700; }
    /* Slot mặc định hiện khi user không cung cấp nội dung */
  </style>
  <div class="card">
    <span class="name"><slot name="title">Người dùng ẩn danh</slot></span>
    <div class="body"><slot>Chưa có mô tả.</slot></div>
  </div>
`;
<user-card>
  <span slot="title">vinxi Nguyen</span>
  <p>Senior frontend engineer.</p>
  <!-- vào slot mặc định -->
</user-card>
  • Slot có tên — <slot name="title"> nhận các con có slot="title".
  • Slot mặc định — <slot> (không tên) hứng mọi thứ còn lại.
  • Nội dung dự phòng — chữ bên trong <slot> chỉ hiện khi không có gì được chiếu vào.
  • Sự kiện slotchange — kích hoạt khi các node được gán thay đổi; đọc bằng slot.assignedElements().

Quan trọng: nội dung được slot vẫn nằm ở light DOM. Nó chỉ hiển thị ở vị trí slot; CSS trang của người dùng vẫn style nó.


4. Attributes vs Properties — the biggest source of confusion

Điều này làm gần như ai cũng vấp:

  • Attribute nằm trong markup HTML và luôn là chuỗi<my-el count="5">. Đọc bằng getAttribute/setAttribute.
  • Property nằm trên object JS và có thể là bất kỳ kiểu nàoel.count = 5, el.user = obj. Đây là kênh mặc định cho object/array vì giữ type và identity.

Phản chiếu là việc giữ hai cái đồng bộ. Mẫu thường dùng:

class ToggleSwitch extends HTMLElement {
  static observedAttributes = ['checked'];

  // Getter/setter property phản chiếu xuống attribute boolean.
  get checked() {
    return this.hasAttribute('checked');
  }
  set checked(value) {
    // Boolean attribute: hiện diện = true, vắng mặt = false.
    this.toggleAttribute('checked', Boolean(value));
  }

  attributeChangedCallback(name) {
    if (name === 'checked') this.#render();
  }

  #render() {
    /* cập nhật UI theo this.checked */
  }
}

Quy tắc: phản chiếu config kiểu nguyên thủy xuống attribute để dùng được từ HTML và CSS; giữ dữ liệu phức tạp ở property. Attribute vẫn có thể mang JSON theo một codec được document, nhưng lúc đó phải trả chi phí parse, escaping, validation và một security boundary rõ.


5. Events — talking to the outside

Component giao tiếp ra ngoài bằng cách phát CustomEvent. Với event public, ổn định nhất là dispatch từ chính host:

this.dispatchEvent(
  new CustomEvent('toggle-switch-change', {
    detail: { checked: this.checked }, // payload tùy ý
    bubbles: true, // nổi từ host lên cây tổ tiên
  })
);
  • bubbles quyết định event có đi từ target lên ancestor hay không.
  • composed chỉ quyết định event có được vượt shadow boundary hay không. Event ở trên bắt đầu ngay tại host, nên không có boundary nào cần vượt.
  • Nếu một node bên trong shadow tree là nơi dispatch, public event thường cần cả bubbles: truecomposed: true; khi đi ra ngoài, target bị retarget về host.
// Bên ngoài lắng nghe như event thường:
document
  .querySelector('toggle-switch')
  .addEventListener('toggle-switch-change', (e) =>
    console.log(e.detail.checked)
  );

Đừng dùng event.composedPath() như public API để tìm class nội bộ; path đó là implementation detail. Component nên chuyển interaction bên trong thành một event có tên và payload domain rõ.


6. Styling — across (and not across) the boundary

Cô lập của Shadow DOM nghĩa là CSS trang thường không với vào trong được. Có một bộ công cụ riêng cho ranh giới:

/* Bên trong shadow root: */

:host {
  display: block;
} /* chính host element */
:host([checked]) {
  background: lime;
} /* host khi có attribute checked */
:host(.dark) {
  color: white;
} /* host khi khớp selector */

::slotted(p) {
  margin: 0;
} /* style node được slot (chỉ top-level) */
/* Bên ngoài shadow root: style đúng điểm component đã expose. */
user-card::part(label) {
  font-weight: 700;
}
Cơ chếHướngDùng để
:host / :host()Trong → chính hostStyle container của component
::slotted()Light DOM trong slotStyle nội dung user (giới hạn top-level)
::part() + part="…"Ngoài → vào trongCho phép user style phần được chỉ định
CSS custom propertiesHost/ancestor → trongTheming token (--card-bg)

Tránh xây contract mới bằng :host-context(): pseudo-class này đã bị đánh dấu deprecated. Nếu theme hoặc direction thuộc public state, owner có thể đặt attribute/class lên host; với design token, ưu tiên CSS custom properties.

CSS custom properties trên host/ancestor được kế thừa vào shadow tree — đây là kênh theming chính. Biến chỉ khai bên trong shadow tree không tự rò ngược ra document.

/* Trang ngoài đặt token: */
user-card {
  --card-bg: #111;
}
/* Trong shadow dùng token đó: */
.card {
  background: var(--card-bg, white);
}

Constructable Stylesheets — share CSS without repeating <style>

Đặt <style> trong mỗi instance tạo một style node cho từng root. Một CSSStyleSheet ở module scope làm việc chia sẻ explicit: khởi tạo một lần rồi được nhiều shadow root adopt.

// Parse 1 lần ở module scope.
const sheet = new CSSStyleSheet();
sheet.replaceSync(`.card { padding: 16px; border-radius: 12px; }`);

class UserCard extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    // Mọi instance share cùng 1 stylesheet object → tiết kiệm bộ nhớ + parse.
    shadow.adoptedStyleSheets = [sheet];
    shadow.innerHTML = `<div class="card"><slot></slot></div>`;
  }
}

7. Form-associated custom elements — integrate with <form>

Một custom element thường vô hình với <form>: không submit giá trị, không tham gia validation. ElementInternals khắc phục điều đó:

class RatingInput extends HTMLElement {
  static formAssociated = true;
  static observedAttributes = ['required'];

  #internals;
  #value = '';
  #defaultValue = '';
  #initialized = false;
  #effectiveDisabled = false;

  constructor() {
    super();
    this.#internals = this.attachInternals();
  }

  connectedCallback() {
    if (!this.#initialized) {
      this.#defaultValue = this.getAttribute('value') ?? '';
      this.#value = this.#defaultValue;
      this.#initialized = true;
    }
    this.#sync();
  }

  attributeChangedCallback() {
    this.#sync();
  }

  get value() {
    return this.#value;
  }
  set value(v) {
    this.#value = v == null ? '' : String(v);
    this.#sync();
  }

  get required() {
    return this.hasAttribute('required');
  }
  set required(v) {
    this.toggleAttribute('required', Boolean(v));
  }

  #sync() {
    this.#internals.setFormValue(
      this.#effectiveDisabled || !this.#value ? null : this.#value
    );

    if (!this.#effectiveDisabled && this.required && !this.#value) {
      this.#internals.setValidity(
        { valueMissing: true },
        'Vui lòng chọn số sao'
      );
    } else {
      this.#internals.setValidity({});
    }
  }

  formResetCallback() {
    this.value = this.#defaultValue;
  }
  formDisabledCallback(disabled) {
    // `disabled` có thể đến từ fieldset; không phản chiếu thành own attribute.
    this.#effectiveDisabled = disabled;
    this.#sync();
  }
  formStateRestoreCallback(state) {
    this.value = typeof state === 'string' ? state : '';
  }
}
customElements.define('rating-input', RatingInput);

Giờ <rating-input name="stars"> trong <form> sẽ submit giá trị qua FormData, tham gia style :invalid, và reset đúng. ElementInternals còn expose các thuộc tính ARIA cho a11y mà không làm bẩn attribute.


8. Declarative Shadow DOM — SSR-able Web Components

Nhiều năm liền, shadow DOM chỉ tạo được bằng JavaScript, nên HTML render từ server không thể chứa nó — xấu cho hiệu năng và SEO. Declarative Shadow DOM (DSD) cho phép server phát ra một shadow root dưới dạng HTML thuần:

<user-card>
  <template shadowrootmode="open">
    <style>
      .card {
        padding: 16px;
      }
    </style>
    <div class="card">
      <strong><slot name="title">Người dùng</slot></strong>
      <slot></slot>
    </div>
  </template>
  <span slot="title">vinxi</span>
</user-card>

Trình duyệt thấy <template shadowrootmode="open">gắn nó thành shadow root thật ngay khi parse HTML — không cần JS cho lần vẽ đầu. Sau đó component có thể progressive-enhance root đó khi JavaScript tải xong. Chỉ gọi là hydration khi một runtime thật sự re-associate template state/listener với các node cũ; Phần 14 sẽ tách rõ hai khái niệm này.

Để đọc DSD trên server hoặc hydrate cẩn thận, kiểm tra xem shadow root đã tồn tại chưa trước khi gọi attachShadow:

connectedCallback() {
  // Nếu DSD đã tạo sẵn shadow root từ server → tái dùng, đừng tạo lại.
  if (!this.shadowRoot) {
    this.attachShadow({ mode: 'open' }).innerHTML = TEMPLATE;
  }
  this.#bind(); // chỉ gắn event listener / logic
}

9. Accessibility — don’t lose it in encapsulation

Đóng gói là con dao hai lưỡi với a11y:

  • ARIA qua ranh giới shadow bị giới hạn: aria-labelledby/aria-describedby về lịch sử không thể tham chiếu ID ở cây khác. Dùng các thuộc tính ARIA của ElementInternals, hoặc giữ các node liên quan trong cùng một root.
  • Quản lý focus: đặt tabindex, xử lý bàn phím, và dùng delegatesFocus: true trong attachShadow để focus vào host sẽ chuyển focus tới phần tử focus được đầu tiên bên trong.
  • Ưu tiên kế thừa ngữ nghĩa: khi bọc các control tương tác, hãy chiếu một <button>/<input> thật qua slot thay vì tự chế lại role.

10. When to use, interop, and pitfalls

Use Web Components when: xây design system chia sẻ giữa nhiều framework, widget nhúng được (bên thứ ba), hoặc các primitive độc lập framework cần sống qua một lần migrate stack.

Think twice when: cả app đã là React/Vue và bạn cần state phức tạp, routing, hay reactivity tinh — framework làm tốt hơn.

Tương tác framework: với React hiện tại, value được gán vào property khi tên property tồn tại trên element; custom events có thể được nghe qua event prop. Vue cũng ưu tiên property khi field tồn tại và cho phép ép bằng .prop. Với object, event có dấu gạch ngang hoặc yêu cầu TypeScript chặt, một adapter mỏng vẫn đáng dùng và phải được test bằng đúng framework version.

Common pitfalls:

  • Đụng attributes/children trong constructor — quá sớm; hãy làm trong connectedCallback.
  • Dispatch public event từ node nội bộ mà quên composed: true → event dừng ở shadow boundary.
  • Không cleanup trong disconnectedCallback → rò rỉ timer/listener.
  • Truyền object qua attribute → bạn nhận "[object Object]"; dùng property.
  • innerHTML nặng mỗi instance → dùng <template> + Constructable Stylesheets.

11. Full example — <toggle-switch>

Ghép mọi trụ cột lại: một toggle tái dùng, accessible, form-associated.

// toggle-switch.js
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
  :host { display: inline-flex; align-items: center; gap: .5rem;
          cursor: pointer; --w: 44px; --h: 24px; }
  :host(:disabled) { opacity: .5; pointer-events: none; }
  :host(:focus-visible) { outline: 2px solid var(--focus, #4f46e5);
                          outline-offset: 3px; border-radius: 999px; }
  .track {
    width: var(--w); height: var(--h); border-radius: 999px;
    background: var(--off, #ccc); transition: background .2s;
  }
  :host([checked]) .track { background: var(--on, #22c55e); }
  .thumb {
    width: calc(var(--h) - 4px); height: calc(var(--h) - 4px);
    margin: 2px; border-radius: 50%; background: #fff;
    transform: translateX(0); transition: transform .2s;
  }
  :host([checked]) .thumb { transform: translateX(calc(var(--w) - var(--h))); }
  @media (prefers-reduced-motion: reduce) {
    .track, .thumb { transition: none; }
  }
`);

const template = document.createElement('template');
template.innerHTML = `
  <span class="label" part="label"><slot>Tùy chọn</slot></span>
  <div class="track" part="track">
    <div class="thumb" part="thumb"></div>
  </div>
`;

class ToggleSwitch extends HTMLElement {
  static formAssociated = true;
  static observedAttributes = ['checked', 'disabled', 'value'];

  #internals;
  #defaultChecked = false;
  #didCaptureDefault = false;
  #effectiveDisabled = false;
  #replayedPreUpgradeProperties = false;

  constructor() {
    super();
    const shadow =
      this.shadowRoot ??
      this.attachShadow({ mode: 'open', delegatesFocus: true });
    if (!shadow.adoptedStyleSheets.includes(sheet)) {
      shadow.adoptedStyleSheets = [...shadow.adoptedStyleSheets, sheet];
    }
    if (!shadow.querySelector('.track')) {
      shadow.appendChild(template.content.cloneNode(true));
    }
    this.#internals = this.attachInternals();
    this.#internals.role = 'switch';
  }

  connectedCallback() {
    if (!this.#replayedPreUpgradeProperties) {
      for (const name of ['checked', 'value', 'disabled']) {
        this.#upgradeProperty(name);
      }
      this.#replayedPreUpgradeProperties = true;
    }
    if (!this.#didCaptureDefault) {
      this.#defaultChecked = this.checked;
      this.#didCaptureDefault = true;
    }
    // Tab stop + bàn phím cho accessibility; role mặc định nằm ở internals.
    if (!this.hasAttribute('tabindex')) this.tabIndex = 0;
    this.#effectiveDisabled = this.matches(':disabled');
    this.#sync();
    this.addEventListener('click', this.#toggle);
    this.addEventListener('keydown', this.#onKey);
  }

  disconnectedCallback() {
    // Cleanup tương ứng với connectedCallback.
    this.removeEventListener('click', this.#toggle);
    this.removeEventListener('keydown', this.#onKey);
  }

  attributeChangedCallback() {
    this.#sync();
  }

  get checked() {
    return this.hasAttribute('checked');
  }
  set checked(v) {
    this.toggleAttribute('checked', Boolean(v));
  }

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

  get disabled() {
    return this.hasAttribute('disabled');
  }
  set disabled(v) {
    this.toggleAttribute('disabled', Boolean(v));
  }

  #toggle = () => {
    if (this.#effectiveDisabled) return;
    this.checked = !this.checked;
    this.dispatchEvent(new Event('input', { bubbles: true }));
    this.dispatchEvent(new Event('change', { bubbles: true }));
  };

  #onKey = (e) => {
    if (e.key === ' ' || e.key === 'Enter') {
      e.preventDefault();
      this.#toggle();
    }
  };

  #sync() {
    const disabled = this.#effectiveDisabled;
    // `null` làm field vắng khỏi FormData, giống checkbox native chưa checked.
    this.#internals.setFormValue(
      this.checked && !disabled ? this.value : null,
      this.checked ? 'checked' : 'unchecked'
    );
    this.#internals.ariaChecked = String(this.checked);
    this.#internals.ariaDisabled = String(disabled);
    this.tabIndex = disabled ? -1 : 0;
  }

  formResetCallback() {
    this.checked = this.#defaultChecked;
  }

  formDisabledCallback(disabled) {
    // Effective disabled có thể đến từ `<fieldset disabled>`.
    this.#effectiveDisabled = disabled;
    this.#sync();
  }

  formStateRestoreCallback(state) {
    this.checked = state === 'checked';
  }

  #upgradeProperty(name) {
    if (!Object.prototype.hasOwnProperty.call(this, name)) return;
    const value = this[name];
    delete this[name];
    this[name] = value;
  }
}
customElements.define('toggle-switch', ToggleSwitch);
<form>
  <label>
    <toggle-switch name="notify" checked>Nhận thông báo</toggle-switch>
  </label>
</form>
<script type="module" src="/toggle-switch.js"></script>
<style>
  /* Theming qua token và hai part đã expose. */
  toggle-switch {
    --on: #6366f1;
  }
  toggle-switch::part(thumb) {
    box-shadow: 0 1px 3px rgb(0 0 0 / 0.35);
  }
</style>

Server cũng có thể gửi đúng internal skeleton bằng DSD. Module tải sau sẽ reuse root và chỉ thêm stylesheet/listener thay vì xoá node đã render:

<toggle-switch name="notify" checked>
  <template shadowrootmode="open" shadowrootdelegatesfocus>
    <style>
      .track {
        inline-size: 44px;
        block-size: 24px;
        border-radius: 999px;
      }
    </style>
    <span class="label" part="label"><slot>Tùy chọn</slot></span>
    <div class="track" part="track">
      <div class="thumb" part="thumb"></div>
    </div>
  </template>
  Nhận thông báo
</toggle-switch>

Thẻ duy nhất này chạy trong HTML thuần hoặc được dùng từ framework mà không đổi public contract. Điểm quan trọng không phải số dòng code, mà là mọi trách nhiệm đã có một nơi rõ ràng: attribute giữ state public có thể serialize, event báo thay đổi, Shadow DOM giữ implementation, CSS token giữ theme và ElementInternals nối vào form.


Acceptance checklist cho capstone

  • checked property và attribute phản chiếu hai chiều đúng boolean semantics.
  • Toggle chưa checked vắng khỏi FormData; checked submit đúng value.
  • form.reset() quay lại trạng thái có trong markup ban đầu.
  • fieldset[disabled] làm control mất focus và không phát thay đổi.
  • Space/Enter, click và label đều có đường tương tác được kiểm chứng.
  • Gỡ rồi gắn element lại không nhân đôi listener hay event.
  • input/change có timing và propagation được document; consumer đọc event.target.checked.
  • Theme đổi qua custom property, không query class trong shadow tree.
  • Label light DOM đi qua slot, làm accessible name và không cần clone text.
  • DSD path vẫn có nội dung trước khi module JavaScript tải xong.

Bài tập thực hành

  1. Thêm part="track"part="thumb", rồi theme từ trang ngoài mà không expose class nội bộ.
  2. Phát cả event input khi state đổi tức thì và change khi interaction hoàn tất; giải thích semantics thay vì chỉ đổi tên.
  3. Viết fallback dùng <button role="switch"> thật bên trong shadow root và so sánh focus/keyboard với host tự mang role.
  4. Tạo hai instance trong một form, dùng FormData chứng minh disabled và unchecked control không đóng góp entry.
  5. Tải definition sau markup hai giây, kiểm tra :defined, property pre-upgrade và layout shift.

Điều cốt lõi

Web Components là một primitive của nền tảng, không phải framework. Vanilla code làm lộ rõ lifecycle và boundary, đổi lại bạn phải tự giữ nhiều invariant: setup/cleanup đối xứng, attribute/property đồng bộ, update giữ focus, event có contract và control tương tác phải có semantics đầy đủ.

Phần 10 sẽ biến các invariant đó thành contract test chạy trên browser thật. Sau khi test khóa hành vi public, Phần 11–13 mới thay boilerplate bằng Lit mà không vô tình đổi API của component.

Nguồn chính thức