Web Components · Phần 5 — Shadow DOM, slots và composed tree
Hiểu host, light DOM, shadow tree và composed tree; dùng slot mặc định, named slot, fallback và slotchange để xây kb-task-column có API composition rõ ràng.
Ở phần trước, Mini Kanban đã biết render state mà không cần framework. Nhưng nếu <kb-task-column> tự render cả tiêu đề, nút thao tác lẫn mọi task bằng innerHTML, người dùng component chỉ còn hai lựa chọn không tốt: chấp nhận đúng markup đó hoặc truyền một object cấu hình ngày càng lớn.
Ta cần một ranh giới khác:
- component sở hữu khung và hành vi của cột;
- trang sử dụng sở hữu nội dung như tiêu đề, task và nút thao tác;
- hai bên không vô tình sửa DOM hoặc CSS của nhau.
Shadow DOM tạo ranh giới. <slot> tạo các cổng có tên đi qua ranh giới ấy.
1. Bốn khái niệm, không phải bốn cây độc lập
Xét markup sau:
<kb-task-column>
<span slot="heading">Đang làm</span>
<kb-task-card>Viết test</kb-task-card>
</kb-task-column>
Và shadow root của component:
<section class="column">
<h2><slot name="heading">Cột chưa đặt tên</slot></h2>
<div class="tasks"><slot>Chưa có công việc</slot></div>
</section>
Mental model cần giữ là:
document
└─ <kb-task-column> host
├─ light DOM node thật do người dùng truyền vào
│ ├─ <span slot="heading">…
│ └─ <kb-task-card>…
│
└─ #shadow-root shadow root
└─ shadow tree cấu trúc nội bộ của component
└─ <section>
├─ <h2><slot name="heading"></slot></h2>
└─ <div><slot></slot></div>
composed tree mà người dùng nhìn thấy
└─ <kb-task-column>
└─ <section>
├─ <h2><span>Đang làm</span></h2>
└─ <div><kb-task-card>Viết test</kb-task-card></div>
Host là element mang shadow root. Light DOM vẫn là con thật của host: column.children thấy chúng, còn column.shadowRoot.querySelector() thì không. Shadow tree là DOM nội bộ. Composed tree là cách trình duyệt ghép hai phía để render, tính style, focus và phân phối event.
Slot không di chuyển node sang shadow tree. Nó chỉ chỉ định vị trí node light DOM được trình bày. Đây là lý do quyền sở hữu vẫn rõ ràng: trang có thể cập nhật task của nó, component có thể thay layout nội bộ mà không nhận task làm con vật lý.
2. Tạo shadow root: mặc định chọn open
Một autonomous custom element có thể gắn shadow root trong constructor:
class KbTaskColumn extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
root.append('Nội dung nội bộ');
}
}
Với mode: 'open', consumer, test và DevTools có thể truy cập element.shadowRoot. Với closed, thuộc tính đó trả về null; code nội bộ vẫn dùng được tham chiếu mà attachShadow() trả về.
closed là giới hạn API, không phải sandbox bảo mật. Code chạy cùng trang vẫn có nhiều cách quan sát hoặc can thiệp trước khi root được tạo. Đổi lại, closed root làm test, debug và công cụ hỗ trợ khó làm việc hơn. Series này dùng open; chỉ chọn closed khi bạn có một lý do tích hợp cụ thể và đã chấp nhận chi phí đó.
Mỗi host chỉ có một shadow root. Gọi attachShadow() lần hai thường ném NotSupportedError, vì vậy đừng gắn root trong connectedCallback() — callback ấy có thể chạy nhiều lần.
3. Slot là public API cho markup
Slot mặc định, named slot và fallback
Slot không có name nhận các node light DOM không khai báo slot:
<!-- Trong shadow tree -->
<slot>Chưa có task nào.</slot>
<!-- Khi sử dụng -->
<kb-task-column>
<kb-task-card>Task A</kb-task-card>
<kb-task-card>Task B</kb-task-card>
</kb-task-column>
Named slot nhận con trực tiếp có tên khớp:
<!-- Trong shadow tree -->
<slot name="heading">Cột chưa đặt tên</slot>
<slot name="actions"></slot>
<!-- Khi sử dụng -->
<kb-task-column>
<span slot="heading">Hoàn thành</span>
<button slot="actions" type="button">Xóa task đã xong</button>
</kb-task-column>
Nội dung nằm giữa hai thẻ <slot> là fallback. Nó chỉ được trình bày khi slot không có node được gán. Một text node chỉ chứa khoảng trắng cũng có thể làm slot mặc định không còn rỗng, nên hãy format markup consumer có chủ ý nếu fallback mang ý nghĩa quan trọng.
Đừng coi tên slot như class CSS tùy tiện. heading và actions là contract công khai: đổi tên chúng là breaking change giống như đổi tên property hoặc event.
Quan sát phép phân phối
slotchange chạy trên chính HTMLSlotElement khi tập node được gán thay đổi. Hai API thường dùng là:
slot.assignedNodes();
slot.assignedElements({ flatten: true });
assignedNodes() gồm cả text node. assignedElements() chỉ trả element nên tiện hơn khi cần đếm <kb-task-card>. flatten: true đi qua các slot lồng nhau; không có nghĩa là nó quét mọi descendant bình thường.
slotchange phản ứng khi thêm, bỏ hoặc đổi phép gán node. Nếu chỉ sửa card.textContent mà card vẫn nằm ở cùng slot, tập node không đổi nên event không chạy. Khi cần biết state bên trong task đổi, task card nên phát custom event; đừng bắt slot gánh vai trò state manager.
4. Xây <kb-task-column> runnable
Ví dụ dưới đây không phụ thuộc build tool. Lưu class vào kb-task-column.js, import bằng <script type="module">, rồi dùng như HTML bình thường.
const template = document.createElement('template');
template.innerHTML = `
<style>
:host { display: block; }
.column {
border: 1px solid #cbd5e1;
border-radius: 0.75rem;
padding: 1rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
h2 { margin: 0; font: 700 1rem/1.3 system-ui; }
.count { color: #475569; font: 500 0.875rem/1.3 system-ui; }
.tasks { display: grid; gap: 0.75rem; margin-top: 1rem; }
.empty { margin: 0; color: #64748b; }
</style>
<section class="column">
<header class="header">
<h2><slot name="heading">Cột chưa đặt tên</slot></h2>
<div>
<span class="count" aria-live="polite"></span>
<slot name="actions"></slot>
</div>
</header>
<div class="tasks">
<slot class="task-slot"></slot>
<p class="empty" hidden>Chưa có công việc.</p>
</div>
</section>
`;
class KbTaskColumn extends HTMLElement {
#taskSlot;
#count;
#empty;
#handleSlotChange = () => this.#syncTaskCount();
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
root.append(template.content.cloneNode(true));
this.#taskSlot = root.querySelector('.task-slot');
this.#count = root.querySelector('.count');
this.#empty = root.querySelector('.empty');
}
connectedCallback() {
this.#taskSlot.addEventListener('slotchange', this.#handleSlotChange);
this.#syncTaskCount();
}
disconnectedCallback() {
this.#taskSlot.removeEventListener('slotchange', this.#handleSlotChange);
}
#syncTaskCount() {
const tasks = this.#taskSlot
.assignedElements({ flatten: true })
.filter((element) => element.matches('kb-task-card'));
const label = `${tasks.length} task`;
if (this.#count.textContent !== label) this.#count.textContent = label;
this.#empty.hidden = tasks.length > 0;
}
}
customElements.define('kb-task-column', KbTaskColumn);
Markup consumer:
<kb-task-column>
<span slot="heading">Đang làm</span>
<button slot="actions" type="button">Thêm task</button>
<kb-task-card task-id="T-17">Viết contract test</kb-task-card>
<kb-task-card task-id="T-18">Kiểm tra keyboard</kb-task-card>
</kb-task-column>
<script type="module" src="./kb-task-column.js"></script>
<section> và <h2> giữ semantics ngay trong cấu trúc nội bộ. Tên cột được chiếu vào heading nên không cần giả heading bằng div role="heading". Empty state nằm ngoài slot và được đồng bộ bằng assignedElements(), nên khoảng trắng trong light DOM không vô tình tắt nó. Vùng đếm dùng aria-live="polite", nhưng chỉ cập nhật khi số task thật sự đổi; tránh phát thông báo liên tục cho mọi render.
Component chỉ đếm element đúng contract. Một comment, khoảng trắng hay nút actions không làm sai kết quả. Listener có setup/teardown đối xứng vì host có thể disconnect rồi reconnect.
5. Composed tree thay đổi cách ta truy vấn
Sau khi slot phân phối, task trông như nằm trong .tasks, nhưng các truy vấn sau cho kết quả khác nhau:
const column = document.querySelector('kb-task-column');
column.querySelectorAll('kb-task-card');
// Hai card: truy vấn light DOM do host sở hữu.
column.shadowRoot.querySelectorAll('kb-task-card');
// Rỗng: card không trở thành con vật lý của shadow root.
column.shadowRoot.querySelector('.task-slot').assignedElements();
// Các element được gán trực tiếp vào slot.
Ranh giới này là feature, không phải lỗi. Code ngoài dùng public API và light DOM; code trong dùng shadow DOM và slot API. Phần 7 sẽ tiếp tục mental model composed tree với event retargeting và composedPath().
6. Failure modes thường gặp
Gán slot cho node không phải con trực tiếp
<kb-task-column>
<div><span slot="heading">Đang làm</span></div>
</kb-task-column>
span không được gán vì nó không phải con trực tiếp của host. Đưa slot="heading" lên div, hoặc bỏ wrapper.
Tìm slotted node bằng shadowRoot.querySelector()
Node vẫn thuộc light DOM. Dùng assignedElements() từ slot, hoặc query trên host nếu component thực sự cần làm việc với nó.
Dùng children.length để đo slot
Host còn có heading và action nên con số sai. Hãy hỏi đúng slot về các element được phân phối và lọc theo contract.
Render lại toàn bộ shadow root
Gán lại shadowRoot.innerHTML phá tham chiếu tới slot, listener và trạng thái focus. Với layout tĩnh, clone template một lần rồi chỉ cập nhật node cần đổi.
Dùng closed root để “bảo vệ dữ liệu”
Shadow DOM cô lập cấu trúc và CSS, không cô lập quyền thực thi. Dữ liệu nhạy cảm không nên được gửi tới client chỉ vì nó nằm trong closed root.
7. Bài tập
- Cơ bản: thêm named slot
footercó fallback “Kéo task vào đây”, nhưng bảo đảm fallback không được tính là task. - Mở rộng: thêm property chỉ đọc
taskCount; cập nhật nó trongslotchangevà viết test chứng minh giá trị chỉ đổi khi tập card đổi. - Thử thách: cho phép một component trung gian forward task qua slot lồng nhau. So sánh kết quả của
assignedElements()vớiassignedElements({ flatten: true })bằng một test trong browser.
Cốt lõi cần nhớ
- Host mang shadow root; light DOM và shadow tree vẫn có chủ sở hữu riêng.
- Composed tree là hình ảnh đã được slot ghép để render, không phải một DOM mới để query.
- Slot mặc định nhận node chưa có tên; named slot chỉ nhận con trực tiếp có tên khớp.
- Fallback nằm trong
<slot>và chỉ xuất hiện khi không có node được gán. - Dùng
slotchangecùngassignedElements()để quan sát phép phân phối, không để slot quản lý state của child. - Ưu tiên
mode: 'open'; closed root không phải security boundary.
Phần 6 sẽ trả lời câu hỏi tiếp theo: khi DOM đã có ranh giới, CSS nào bị chặn, CSS nào vẫn đi qua, và component nên công khai một theming contract bằng custom properties, ::part() cùng exportparts như thế nào.