jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Web Components · Phần 1 — Mental Model nền tảng & Custom Element đầu tiên

Phân biệt Web Components, Custom Elements, Shadow DOM và template; xây kb-task-card đầu tiên theo hướng progressive enhancement bằng API gốc của trình duyệt.

Bạn đang làm một bảng Kanban nhỏ. Task card ban đầu chỉ xuất hiện trong một trang, rồi dashboard, trang tìm kiếm và một ứng dụng khác cũng cần nó. Copy HTML, CSS và JavaScript sang ba nơi thì nhanh ở ngày đầu, nhưng từ ngày thứ hai bạn đã có ba phiên bản khác nhau của cùng một UI.

Framework giải quyết bài toán component rất tốt bên trong ứng dụng của nó. Khó khăn xuất hiện khi card phải sống qua nhiều stack, hoặc cần tồn tại lâu hơn một lần thay framework. Web Components cho ta một điểm ghép ở tầng nền tảng: một thẻ HTML có tên, API DOM và vòng đời do trình duyệt quản lý.

Series này sẽ xây dần một Accessible Mini Kanban. Mọi thẻ tự tạo dùng prefix kb-: <kb-task-card>, <kb-task-column>, <kb-task-board> và các thành phần tiếp theo. Ta bắt đầu với phiên bản nhỏ nhất có thể chạy trong một file HTML, chưa dùng Shadow DOM và chưa cần thư viện.


1. Web Components không phải một API duy nhất

“Web Components” là tên gọi chung cho một nhóm khả năng của web platform. Ba mảnh thường đi cùng nhau nhưng không bắt buộc phải xuất hiện cùng lúc:

Khả năngTrả lời câu hỏi nào?Có thể dùng độc lập?
Custom ElementsLàm sao định nghĩa một thẻ HTML có hành vi riêng?
Shadow DOMLàm sao tạo cây DOM và phạm vi style riêng cho một host?
<template><slot>Làm sao giữ markup chưa kích hoạt và tạo điểm ghép nội dung?

Custom Element là phần cho ta identity và lifecycle. Một class JavaScript kế thừa HTMLElement được đăng ký dưới tên như kb-task-card; từ đó browser biết phải tạo hoặc nâng cấp các thẻ cùng tên bằng class ấy.

Shadow DOM là một ranh giới cây DOM. Nó giúp implementation bên trong ít bị CSS bên ngoài tác động và ngược lại. Nhưng một Custom Element hoàn toàn có thể chỉ dùng light DOM. Ngược lại, Shadow DOM không biến host thành Custom Element.

<template> chứa một DocumentFragment chưa tham gia cây document. Ta có thể clone nó để tạo nhiều instance. <slot> là cơ chế phân phối light-DOM child vào Shadow DOM; vì vậy slot chỉ thật sự có ý nghĩa khi có shadow tree.

Mental model ngắn gọn:

Web Components (hộp công cụ của platform)
├── Custom Elements  → tên thẻ + class + lifecycle
├── Shadow DOM       → ranh giới DOM/CSS
└── Template/slot    → mẫu inert + composition

Phần này chỉ chọn mảnh đầu tiên. Việc chưa dùng Shadow DOM là có chủ đích: ta cần hiểu contract của một element trước khi thêm encapsulation.


2. Platform primitive khác framework component ở đâu?

Custom Element không thay React, Vue, Svelte hay Lit. Nó sống ở lớp thấp hơn:

  • Browser nhận ra <kb-task-card> như một DOM element, không cần JSX compiler.
  • Consumer thao tác nó qua attribute, property, method và DOM event.
  • Element có thể được render bởi HTML tĩnh, template server, React hoặc Vue.
  • Framework vẫn hữu ích cho routing, state toàn ứng dụng, data fetching và hệ sinh thái tooling.

Ranh giới này tạo một trade-off. API nền tảng có tuổi thọ và khả năng interop tốt, nhưng bạn phải tự thiết kế state, render, cleanup và accessibility. Một framework hoặc Lit có thể giảm phần lặp; nó không xóa contract của DOM bên dưới.

Hãy chọn Web Components khi điểm ghép HTML/DOM mang lại giá trị: design system dùng qua nhiều stack, widget nhúng, extension UI, CMS block, hoặc thành phần cần phân phối độc lập. Nếu toàn bộ sản phẩm là một React app và component không bao giờ rời app đó, React component thường đã là abstraction vừa đủ.


3. Progressive enhancement: HTML hữu ích trước, hành vi đến sau

Một trang không nên biến thành khoảng trắng chỉ vì module JavaScript tải chậm. Browser vẫn parse được một thẻ có tên hợp lệ trước khi class của nó được đăng ký. Attribute và child HTML vẫn ở đó; chỉ hành vi tùy chỉnh chưa tồn tại.

Ta tận dụng điều này bằng cách đặt nội dung Kanban có nghĩa ngay trong light DOM:

<kb-task-card data-task-id="KB-101">
  <article>
    <h3>Viết acceptance criteria</h3>
    <p>Ưu tiên cao · Người thực hiện: An</p>
    <button type="button" data-action="toggle" aria-pressed="false">
      Đánh dấu hoàn thành
    </button>
  </article>
</kb-task-card>

Khi JavaScript bị chặn, người đọc vẫn thấy task và một button gốc. Button chưa có hành vi toggle, nhưng nội dung không mất và semantic không bị biến thành một <div> vô nghĩa. Server cũng có thể sinh chính markup này cho tìm kiếm, đọc màn hình và lần paint đầu.

Đó là progressive enhancement:

HTML có nghĩa
    + CSS trình bày
    + Custom Element nâng cấp hành vi
    = cùng một tài liệu, nhiều mức năng lực

Không phải component nào cũng có fallback hoàn chỉnh. Một color picker phức tạp có thể cần thay bằng <input type="color">; một chart có thể cần bảng dữ liệu. Câu hỏi đúng không phải “chạy y hệt khi thiếu JS?” mà là “giá trị hoặc đường lui cốt lõi còn dùng được không?”.


4. Custom Element đầu tiên: <kb-task-card>

Ví dụ dưới đây chạy độc lập. Lưu thành index.html và mở qua một local server. Script type="module" được defer theo mặc định, nên parser hoàn thành trước khi module thực thi.

<!doctype html>
<html lang="vi">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>Accessible Mini Kanban</title>
    <style>
      kb-task-card {
        display: block;
        max-width: 28rem;
        padding: 1rem;
        border: 1px solid #8b8b8b;
        border-radius: 0.75rem;
        font: 1rem/1.5 system-ui;
      }

      kb-task-card[completed] h3 {
        text-decoration: line-through;
      }

      kb-task-card:not(:defined) {
        border-style: dashed;
      }
    </style>
  </head>
  <body>
    <main>
      <h1>Mini Kanban</h1>

      <kb-task-card data-task-id="KB-101">
        <article>
          <h3>Viết acceptance criteria</h3>
          <p>Ưu tiên cao · Người thực hiện: An</p>
          <button type="button" data-action="toggle" aria-pressed="false">
            Đánh dấu hoàn thành
          </button>
        </article>
      </kb-task-card>
    </main>

    <script type="module">
      class KbTaskCard extends HTMLElement {
        connectedCallback() {
          // Bản đầu tiên chỉ enhance markup sẵn có.
          if (this.dataset.enhanced === 'true') return;

          const button = this.querySelector('[data-action="toggle"]');
          if (!(button instanceof HTMLButtonElement)) return;

          this.dataset.enhanced = 'true';
          button.addEventListener('click', () => {
            const completed = !this.hasAttribute('completed');
            this.toggleAttribute('completed', completed);
            button.setAttribute('aria-pressed', String(completed));
            button.textContent = completed
              ? 'Đánh dấu chưa hoàn thành'
              : 'Đánh dấu hoàn thành';
          });
        }
      }

      customElements.define('kb-task-card', KbTaskCard);
    </script>
  </body>
</html>

Đọc code theo bốn bước:

  1. class KbTaskCard extends HTMLElement giữ mọi hành vi chuẩn của một HTML element rồi thêm hành vi riêng.
  2. customElements.define() ghép tên thẻ với constructor trong registry của document hiện tại.
  3. connectedCallback() chạy khi instance được nối vào document. Đây mới là lúc code tìm child markup và gắn hành vi.
  4. State hoàn thành hiện diện dưới dạng boolean attribute completed, nên cả CSS và DOM đều quan sát được.

Pseudo-class :defined chỉ match sau khi tên đã được đăng ký. Trong demo, border nét đứt là tín hiệu vô hại trước upgrade. Đừng mặc định ẩn toàn bộ kb-task-card:not(:defined): cách đó phủ nhận progressive enhancement và có thể tạo khoảng trống nếu module lỗi.

Đoạn code cố tình còn đơn giản. Nó chưa cleanup listener, chưa thiết kế API dữ liệu và vẫn cập nhật DOM trực tiếp. Ba phần tiếp theo sẽ giải quyết lần lượt những thiếu sót ấy.


5. Vì sao dùng native button bên trong?

Custom Element không tự có semantics của button chỉ vì tên hoặc CSS khiến nó trông giống button. Nếu cả card clickable bằng div, bạn phải tự tái tạo focus, phím Space/Enter, trạng thái disabled và accessible name. Đặt một <button> thật bên trong giúp browser cung cấp các hành vi đó.

Một nguyên tắc xuyên suốt series:

Custom Element nên kết hợp semantic HTML sẵn có trước khi tự phát minh widget.

Sau này <kb-task-card> sẽ có Shadow DOM, nhưng button bên trong vẫn là button. Encapsulation không thay thế accessibility.


6. Failure modes thường gặp ngay từ Phần 1

“Dùng Web Components” đồng nghĩa “bật Shadow DOM”

Không đúng. Shadow boundary thay đổi styling, event path, accessibility và cách test. Chỉ thêm khi component thật sự cần sở hữu implementation riêng. Light DOM là khởi đầu hợp lý cho component chuyên enhance markup server-rendered.

Render một thẻ rỗng rồi phụ thuộc hoàn toàn vào JavaScript

<kb-task-card task-id="101"></kb-task-card> có thể phù hợp trong app client thuần, nhưng không phải progressive enhancement. Nếu first content hoặc SEO quan trọng, server nên xuất nội dung hay fallback có nghĩa.

Dùng tên chung như <task-card>

Custom Element registry là không gian tên dùng chung trong document. Prefix kb- giảm khả năng hai package tranh cùng một tên và giúp DevTools cho biết element thuộc hệ nào. Phần 2 sẽ đi sâu vào quy tắc tên và registry.

Tưởng class tự động reactive

this.completed = true chỉ là phép gán JavaScript nếu bạn chưa thiết kế setter hoặc render pipeline. Browser không biết property nào cần đổi UI. Custom Elements cung cấp lifecycle, không cung cấp state manager hay virtual DOM.

Quên rằng connectedCallback() có thể chạy lại

Element có thể bị kéo từ cột “Todo” sang “Done”, bị detach rồi gắn lại. Code demo dùng cờ data-enhanced để tránh listener trùng, nhưng đó chưa phải cleanup hoàn chỉnh. Phần 2 sẽ xây lifecycle an toàn cho reconnect.


7. Bài tập: thêm task thứ hai mà không sửa class

  1. Copy markup <kb-task-card> và đổi data-task-id, tiêu đề, người thực hiện.
  2. Tắt JavaScript trong DevTools rồi reload. Kiểm tra cả hai task vẫn đọc được.
  3. Bật JavaScript, kiểm tra mỗi button chỉ thay đổi card chứa nó.
  4. Thêm CSS cho kb-task-card:defined để phân biệt trạng thái đã upgrade, nhưng không dùng display: none cho trạng thái chưa upgrade.
  5. Thay <button> bằng <div> trong một bản thử nghiệm và dùng Tab/Space. Ghi lại những hành vi bạn vừa đánh mất, sau đó trả lại button gốc.

Hoàn thành khi: hai card hoạt động độc lập, nội dung vẫn hữu ích khi thiếu JavaScript, và keyboard focus đi tới đúng button.


Checklist cốt lõi

  • Web Components là nhóm khả năng nền tảng, không phải một framework mới.
  • Custom Elements, Shadow DOM và template/slot liên quan nhưng dùng độc lập được.
  • Custom Element là contract DOM; framework vẫn có thể tạo và sử dụng nó.
  • Bắt đầu từ semantic HTML và nâng cấp hành vi khi bài toán cho phép.
  • Dùng prefix có chủ đích; series này dành namespace kb- cho Mini Kanban.
  • Native element giữ lại keyboard và accessibility tốt hơn bản mô phỏng bằng div.
  • Custom Elements không tự cung cấp reactivity, cleanup hay rendering strategy.

Phần 2, ta sẽ mở hộp đen phía sau customElements.define(): registry, upgrade, constructor và toàn bộ lifecycle. Ta cũng sửa component để kéo task qua các cột, detach và reconnect mà không nhân đôi listener hoặc reset state.

Nguồn chính thức