Web Components · Phần 12 — Lit templates, bindings và directives
Đào sâu Lit 3: reactive properties, năm kiểu binding, keyed list, built-in và custom directives, events, update lifecycle, styling, slots và controllers.
Phần 11 đã viết lại <kb-task-card> bằng Lit mà vẫn giữ nguyên thẻ HTML,
property và event contract. Bài này đi xuống lớp máy của Lit: một binding được
chọn theo vị trí nào, property đổi ra sao, directive được dùng lúc nào và update
cycle cho phép đặt side effect ở đâu.
Các ví dụ giả định Lit 3.3+ vì dùng property option useDefault. Lit không
tạo một component model khác. LitElement vẫn là HTMLElement, vẫn
được đăng ký trong customElements, vẫn dùng Shadow DOM, slot và DOM events.
Thư viện thêm template khai báo cùng reactive update để ta không phải tự giữ
đồng bộ giữa state và từng node DOM.
Why Lit
Vanilla Web Component Lit
───────────────────── ───
attachShadow thủ công → tự động (shadow root)
innerHTML + querySelector → html`` template + binding
tự gọi render mỗi lần đổi → reactive: đổi property → re-render
getAttribute/setAttribute → @property tự map attribute ↔ property
tự diff DOM → lit-html diff hiệu quả (chỉ đổi phần đổi)
Ý tưởng cốt lõi của Lit: state nằm trong reactive properties, UI là hàm thuần của state qua tagged template html, và Lit chỉ re-render đúng phần thay đổi.
1. Setup & first component
npm install lit
// hello-lit.ts
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('hello-lit') // = customElements.define('hello-lit', HelloLit)
export class HelloLit extends LitElement {
// Scoped styles — parse 1 lần, share qua mọi instance (Constructable Stylesheet).
static styles = css`
p {
color: var(--accent, rebeccapurple);
font: 600 14px system-ui;
}
`;
// Reactive property: đổi giá trị → tự động re-render.
@property() name = 'thế giới';
// render() trả về template. Chạy lại mỗi khi reactive state đổi.
render() {
return html`<p>Xin chào, ${this.name}!</p>`;
}
}
<hello-lit name="vinxi"></hello-lit>
<script type="module" src="/hello-lit.js"></script>
Các ví dụ TypeScript trong bài dùng experimental decorators, cấu hình mà tài liệu Lit hiện khuyến nghị cho production vì output gọn hơn:
{
"compilerOptions": {
"experimentalDecorators": true,
"useDefineForClassFields": false
}
}
Không bật emitDecoratorMetadata. Nếu dự án dùng standard decorators, thêm
accessor vào các field được decorate, ví dụ
@property() accessor name = 'thế giới'. Với JavaScript thuần, dùng
static properties và khởi tạo giá trị trong constructor như mục tiếp theo.
2. Reactive properties — the heart of reactivity
Một reactive property kích hoạt re-render khi nó đổi. Khai báo hai cách:
import { LitElement, html } from 'lit';
import { property, state } from 'lit/decorators.js';
class MyEl extends LitElement {
@property({ type: String }) label = ''; // public API, map ↔ attribute
@property({ type: Number }) count = 0;
@property({ type: Boolean }) open = false;
@state() private _internal = 0; // state nội bộ, KHÔNG là attribute
}
Without decorators (plain JS):
class MyEl extends LitElement {
static properties = {
label: { type: String },
count: { type: Number },
open: { type: Boolean },
};
constructor() {
super();
this.label = '';
this.count = 0;
this.open = false;
}
}
@property options
| Option | Ý nghĩa |
|---|---|
type | Bộ chuyển attribute (string) ↔ property: String, Number, Boolean, Array, Object |
attribute | Tên attribute (false = không map; 'my-attr' = đổi tên) |
reflect | true = property đổi thì ghi ngược ra attribute (cho CSS/DOM thấy) |
useDefault | true = không reflect giá trị mặc định ra attribute lúc đầu; xoá attribute thì property quay về default (giống id của native element). Dùng kèm reflect |
converter | Bộ chuyển tùy chỉnh { fromAttribute, toAttribute } |
hasChanged | Hàm quyết định “có thực sự đổi không” → bỏ qua re-render thừa |
noAccessor | Không tạo getter/setter (hiếm dùng) |
@property({
type: Number,
reflect: true, // count đổi → <my-el count="3"> để style :host([count]) được
useDefault: true, // không tạo count="0" lúc mount; xoá attr → count về 0
attribute: 'item-count',
hasChanged: (next, prev) => next !== prev, // mặc định là so sánh !==
})
count = 0;
Reflection nên dùng dè dặt: xem attribute là input từ owner, không reflect
object/array vì serialize tốn chi phí; khi đã reflect: true, thường nên cân
nhắc thêm useDefault: true để component không tự sinh attribute mặc định.
Dùng @property cho API công khai mà consumer set từ HTML/JS; dùng @state
cho state nội bộ vẫn cần trigger render nhưng không phải attribute.
Reactivity là theo từng binding, không sâu: thay đổi tại chỗ một object/array sẽ không trigger update vì tham chiếu không đổi. Hãy gán tham chiếu mới: this.items = [...this.items, x]. Bắt buộc mutate tại chỗ? Gọi this.requestUpdate() thủ công — nhưng chỉ component đó re-render, component con nhận cùng tham chiếu sẽ không cập nhật.
The class fields footgun — silent loss of reactivity
Đây là bug thầm lặng số 1 trong Lit: một reactive property được định nghĩa là accessor trên prototype, còn class field thường nằm trên instance — theo luật JavaScript, field instance che mất accessor, nên gán property không bao giờ trigger update.
Vấn đề xảy ra khi useDefineForClassFields là true (mặc định với standard decorators / TS target ES2022+). Ba cách viết đúng:
// ✅ Cách 1: standard decorators + `accessor`.
// `accessor` biến field thành getter/setter → Lit hook vào được.
class A extends LitElement {
@property() accessor name = 'world';
@state() accessor _count = 0;
}
// ✅ Cách 2 (JS thuần / static properties): KHÔNG dùng class field,
// khởi tạo trong constructor.
class B extends LitElement {
static properties = { name: { type: String } };
constructor() {
super();
this.name = 'world';
}
}
// ✅ Cách 3 (khuyến nghị hiện tại của Lit cho TS):
// experimentalDecorators=true và useDefineForClassFields=false.
class C extends LitElement {
@property() name = 'world'; // OK khi useDefineForClassFields=false
}
// ❌ SAI: useDefineForClassFields=true + class field thường (không `accessor`)
// → set this.value KHÔNG re-render. Im lặng, rất khó debug.
class Broken extends LitElement {
@property() value = 0; // field này che mất accessor Lit sinh ra
}
Quy tắc kiểm tra: experimental decorators đi cùng
useDefineForClassFields: false; standard decorators đi cùngaccessor;static propertiesthì khởi tạo reactive value trong constructor. Khi property đổi mà UI không cập nhật, hãy kiểm tra cấu hình này trước.
Custom accessor — validate on set
Lit tự sinh getter/setter, nhưng bạn có thể tự viết để validate/normalize đồng
bộ ngay khi set. Đặt decorator trên setter; trong Lit 3, decorator tự gọi
requestUpdate(), nên setter không gọi lại thủ công:
private _size = 0;
@property({ type: Number })
set size(v: number) {
const next = Math.max(0, Math.floor(v)); // chặn số âm + làm tròn
this._size = next;
}
get size() { return this._size; }
Phần lớn trường hợp không cần custom accessor: tính giá trị dẫn xuất bằng
willUpdate, phản ứng sau render bằng updated; chỉ tự viết setter khi cần
validate đồng bộ tại public boundary.
3. Templates & the 5 binding types
Tagged template html trả về một TemplateResult — một mô tả DOM, không phải DOM thật. Lit parse phần tĩnh một lần và chỉ cập nhật các “lỗ” động ${...} mỗi lần render.
Vị trí của binding quyết định loại binding — đây là điểm cốt lõi của Lit:
render() {
return html`
<!-- 1. Text/child binding: nội dung element -->
<h1>${this.title}</h1>
<ul>${this.items.map((i) => html`<li>${i}</li>`)}</ul>
<!-- 2. Attribute binding: attr=${...} → set ATTRIBUTE (string) -->
<img src=${this.url} alt=${this.alt} />
<div class="card ${this.variant}"></div>
<!-- 3. Property binding: .prop=${...} → set PROPERTY (giữ nguyên kiểu) -->
<input .value=${this.text} />
<my-list .items=${this.items}></my-list> <!-- truyền array, KHÔNG stringify -->
<!-- 4. Boolean attribute: ?attr=${...} → thêm/xóa attribute theo truthy -->
<button ?disabled=${this.loading}>Lưu</button>
<!-- 5. Event binding: @event=${...} → addEventListener -->
<button @click=${this.onSave}>Lưu</button>
<input @input=${(e) => (this.text = e.target.value)} />
`;
}
| Cú pháp | Loại | Tương đương DOM | Khi nào dùng |
|---|---|---|---|
${value} | Text/child | node.textContent / chèn node | Hiển thị nội dung |
attr=${value} | Attribute | setAttribute('attr', value) | Giá trị là chuỗi (class, src, id) |
.prop=${value} | Property | el.prop = value | Truyền object/array/number giữ kiểu |
?attr=${value} | Boolean attr | toggleAttribute('attr', !!value) | disabled, hidden, checked |
@event=${handler} | Event | addEventListener('event', h) | Bắt sự kiện |
Phân biệt .prop với attr= là lỗi số 1 của người mới: để truyền array/object cho component con, bạn phải dùng .prop; attr= sẽ biến nó thành "[object Object]".
Event handler giữ this tự động khi viết dạng class field arrow hoặc tham chiếu method — Lit gắn this của handler vào host element.
4. Directives — reusable logic in templates
Directive là một hàm tùy biến cách một biểu thức được render. Lit có sẵn nhiều cái; import từng cái theo đường dẫn riêng (tree-shake được).
Điều kiện & vòng lặp
import { when } from 'lit/directives/when.js';
import { choose } from 'lit/directives/choose.js';
import { map } from 'lit/directives/map.js';
import { repeat } from 'lit/directives/repeat.js';
render() {
return html`
<!-- when: if/else khai báo -->
${when(this.loggedIn,
() => html`<user-menu></user-menu>`,
() => html`<login-button></login-button>`)}
<!-- choose: switch/case -->
${choose(this.status, [
['loading', () => html`<spinner></spinner>`],
['error', () => html`<p>Lỗi rồi.</p>`],
], () => html`<p>Sẵn sàng.</p>`)}
<!-- map: vòng lặp đơn giản, reconcile theo vị trí thay vì domain key -->
<ul>${map(this.tags, (t) => html`<li>${t}</li>`)}</ul>
<!-- repeat: vòng lặp CÓ KEY → giữ ổn định DOM khi list đổi thứ tự -->
<ul>${repeat(this.items, (item) => item.id,
(item) => html`<li>${item.name}</li>`)}</ul>
`;
}
map reconcile theo index/vị trí, không di chuyển node theo identity; đây là
lựa chọn đơn giản khi list không reorder. Dùng repeat kèm keyFn ổn định và
duy nhất khi list thêm, xoá hoặc
đổi thứ tự và bạn cần giữ focus/state DOM đi cùng đúng item.
Directive cho attribute & styling
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
render() {
return html`
<!-- classMap: bật/tắt class theo object -->
<div class=${classMap({ active: this.active, disabled: this.loading })}></div>
<!-- styleMap: inline style theo object (camelCase) -->
<div style=${styleMap({ color: this.color, marginTop: '8px' })}></div>
<!-- ifDefined: BỎ attribute nếu giá trị undefined (tránh src="undefined") -->
<img src=${ifDefined(this.maybeUrl)} />
<!-- live: so với GIÁ TRỊ THẬT trong DOM, không phải lần render trước
→ cần cho <input> khi user gõ làm DOM value lệch khỏi state -->
<input .value=${live(this.text)} @input=${this.onInput} />
`;
}
Directive hiệu năng & bất đồng bộ
import { guard } from 'lit/directives/guard.js';
import { cache } from 'lit/directives/cache.js';
import { until } from 'lit/directives/until.js';
import { keyed } from 'lit/directives/keyed.js';
render() {
return html`
<!-- guard: chỉ tính lại template khi deps đổi (memo cho phần đắt) -->
${guard([this.items], () => this.items.map((i) => heavyRender(i)))}
<!-- cache: giữ DOM của template không hiển thị để swap qua lại nhanh -->
${cache(this.tab === 'a'
? html`<panel-a></panel-a>`
: html`<panel-b></panel-b>`)}
<!-- until: hiện placeholder cho tới khi promise resolve -->
${until(this.dataPromise, html`<spinner></spinner>`)}
<!-- keyed: ép tạo DOM mới khi key đổi (reset state element con) -->
${keyed(this.userId, html`<user-profile .id=${this.userId}></user-profile>`)}
`;
}
Đừng rải guard/cache như tối ưu mặc định. cache giữ DOM không hiển thị
trong bộ nhớ; guard chỉ đáng dùng khi profiling chứng minh phần tính toán đủ
đắt và dependency được mô hình hoá đúng.
Các directive khác đáng biết: ref
import { ref, createRef } from 'lit/directives/ref.js';
class MyEl extends LitElement {
#canvas = createRef<HTMLCanvasElement>();
render() {
return html`<canvas ${ref(this.#canvas)}></canvas>`;
}
firstUpdated() {
const ctx = this.#canvas.value!.getContext('2d');
}
}
Writing a custom directive
Khi directive có sẵn không đủ, hãy tự viết. Kế thừa Directive, implement render (và update nếu cần truy cập DOM):
import { Directive, directive } from 'lit/directive.js';
// Ví dụ tối giản để nhìn cơ chế factory → instance → render.
class TruncateDirective extends Directive {
render(value: string, max = 40) {
if (value.length <= max) return value;
return `${value.slice(0, Math.max(0, max - 1))}…`;
}
}
export const truncate = directive(TruncateDirective);
// Dùng: html`<p>${truncate(this.description, 80)}</p>`
Với logic thuần như truncate, một function thường đã đủ; đoạn trên chỉ minh
hoạ API. Lit đã có sẵn join cho iterable. Chỉ viết directive riêng khi cần
điều khiển Part, giữ state qua nhiều render hoặc cập nhật bất đồng bộ. Trường
hợp cần cleanup, kế thừa AsyncDirective và triển khai disconnected() cùng
reconnected().
5. Events — dispatch and listen
Lắng nghe bằng @event trong template; phát CustomEvent từ host để nói
với consumer. Event phát trực tiếp từ this đã bắt đầu ở host, nên
bubbles: true là cờ quyết định nó có đi lên ancestor hay không. Nếu event bắt
đầu từ một node bên trong shadow root, nó còn cần composed: true để vượt
boundary. Public event thường đặt cả hai cờ để contract không phụ thuộc nơi
implementation phát event.
import { LitElement, html } from 'lit';
import { customElement, property, eventOptions } from 'lit/decorators.js';
@customElement('rating-stars')
export class RatingStars extends LitElement {
@property({ type: Number }) value = 0;
#select(n: number) {
this.value = n;
// Event công khai → bubbles + composed.
this.dispatchEvent(
new CustomEvent('rating-change', {
detail: { value: n },
bubbles: true,
composed: true,
})
);
}
// @eventOptions: truyền addEventListener options (passive, capture, once).
@eventOptions({ passive: true })
private _onScroll() {
/* ... */
}
render() {
return html`
<div @scroll=${this._onScroll}>
${[1, 2, 3, 4, 5].map(
(n) => html`
<button @click=${() => this.#select(n)}>
${n <= this.value ? '★' : '☆'}
</button>
`
)}
</div>
`;
}
}
// Cha lắng nghe như event thường:
html`<rating-stars
@rating-change=${(e) => (this.score = e.detail.value)}
></rating-stars>`;
6. The reactive update lifecycle
Khi một reactive property đổi, Lit lên lịch một update gộp bất đồng bộ (microtask) để nhiều thay đổi trong một tick chỉ gây một lần render.
property đổi
│ requestUpdate() (tự gọi)
▼
shouldUpdate(changed) → return false để HỦY update
│
willUpdate(changed) → tính derived state TRƯỚC render (không đụng DOM)
│
update(changed) → phản chiếu attribute, rồi gọi render()
│
render() → trả TemplateResult (hàm thuần, không side effect)
│
firstUpdated(changed) → CHẠY 1 LẦN sau render đầu (DOM đã có → query/đo)
│
updated(changed) → sau MỖI render (reaction tới DOM mới)
│
updateComplete (Promise)→ await để biết DOM đã cập nhật xong
Đoạn sau dùng một adapter chart giả lập để tập trung vào vị trí lifecycle hook:
import { LitElement, html } from 'lit';
declare class SomeChartLib {
constructor(canvas: HTMLCanvasElement);
setData(data: readonly number[]): void;
}
class Chart extends LitElement {
@property({ attribute: false }) data: number[] = [];
private _max = 0;
private _chart?: SomeChartLib;
render() {
return html`<canvas
aria-label=${`Giá trị lớn nhất: ${this._max}`}
></canvas>`;
}
// Tính derived state trước render — KHÔNG gây re-render vòng lặp.
willUpdate(changed: Map<string, unknown>) {
if (changed.has('data')) this._max = Math.max(...this.data);
}
// DOM đã tồn tại — nơi đúng để khởi tạo thư viện cần element thật.
firstUpdated() {
this._chart = new SomeChartLib(this.renderRoot.querySelector('canvas')!);
}
// Phản ứng mỗi lần đổi — vd vẽ lại chart.
updated(changed: Map<string, unknown>) {
if (changed.has('data')) this._chart?.setData(this.data);
}
async _afterRender() {
await this.updateComplete; // đảm bảo DOM mới đã apply
}
}
Phân biệt then chốt: willUpdate để tính giá trị dẫn xuất (chạy trước render, chưa có DOM); firstUpdated cho thiết lập DOM một lần; updated để phản ứng mọi thay đổi. Chỉ gọi this.requestUpdate() thủ công khi state nằm ngoài reactive properties.
7. Styling — scoped & themeable
static styles với tag css được chuẩn bị một lần cho class và tái sử dụng
giữa các instance. Lit dùng adopted stylesheets khi môi trường hỗ trợ và có
fallback tương ứng:
import { css, LitElement } from 'lit';
class Card extends LitElement {
static styles = css`
:host {
display: block;
padding: 16px;
border-radius: 12px;
background: var(--card-bg, #fff);
} /* token theme từ ngoài */
:host([elevated]) {
box-shadow: 0 4px 20px rgb(0 0 0 / 0.1);
}
::slotted(h2) {
margin: 0;
} /* style nội dung được slot */
.body {
min-width: 0;
}
`;
}
- Style arrays:
static styles = [reset, shared, local]để tái dùng. - CSS custom properties kế thừa qua shadow boundary, phù hợp cho design token.
- Muốn mở một điểm style có chủ đích, render
<div part="body">bên trong; consumer dùngmy-card::part(body)từ bên ngoài. - Style động không nhồi vào
static styles.
8. Slots & composition
Lit dùng đúng cơ chế <slot> gốc:
render() {
return html`
<div class="dialog">
<header><slot name="title">Không tiêu đề</slot></header>
<div class="content"><slot></slot></div> <!-- slot mặc định -->
<footer><slot name="actions"></slot></footer>
</div>
`;
}
<my-dialog>
<h2 slot="title">Xác nhận</h2>
<p>Bạn chắc chứ?</p>
<button slot="actions">OK</button>
</my-dialog>
Đọc các node được slot bằng @queryAssignedElements:
import { queryAssignedElements } from 'lit/decorators.js';
class MyTabs extends LitElement {
@queryAssignedElements({ slot: '', selector: 'my-tab' })
private _tabs!: HTMLElement[];
}
9. Reactive Controllers — share lifecycle-aware logic
Reactive Controller là một object tái dùng móc vào vòng đời update của host — câu trả lời của Lit cho “composition hơn inheritance” (vai trò giống React hooks):
import { ReactiveController, ReactiveControllerHost } from 'lit';
// Controller theo dõi kích thước cửa sổ, tự cleanup theo vòng đời host.
export class WindowSizeController implements ReactiveController {
width = 0;
#host: ReactiveControllerHost;
constructor(host: ReactiveControllerHost) {
this.#host = host;
host.addController(this); // đăng ký với host
}
hostConnected() {
this.#onResize();
window.addEventListener('resize', this.#onResize);
}
hostDisconnected() {
window.removeEventListener('resize', this.#onResize);
}
#onResize = () => {
this.width = window.innerWidth;
this.#host.requestUpdate(); // báo host re-render
};
}
class Responsive extends LitElement {
// Một dòng — mọi logic resize + cleanup gói gọn, tái dùng ở component khác.
#size = new WindowSizeController(this);
render() {
return html`<p>Rộng: ${this.#size.width}px</p>`;
}
}
Các package trong hệ sinh thái Lit xây trên mẫu này: @lit/task đóng gói công
việc async cùng trạng thái pending/error/complete; @lit/context truyền dữ
liệu qua cây mà không buộc mọi tầng trung gian nhận property. Phần 13 sẽ dùng
cả hai trong kiến trúc Mini Kanban.
10. Checkpoint — <kb-task-search> có debounce và chống race
Ghép các primitive vừa học vào ô tìm task của Mini Kanban. Ví dụ giữ DOM
identity bằng key, không khoá input lúc request chạy, hủy request cũ và expose
một event có tên theo domain thay vì event chung chung như select.
// kb-task-search.ts
import { LitElement, html, css } from 'lit';
import { customElement, state, property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { when } from 'lit/directives/when.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
interface Task {
id: string;
title: string;
}
function isTask(value: unknown): value is Task {
if (!value || typeof value !== 'object') return false;
const task = value as Record<string, unknown>;
return typeof task.id === 'string' && typeof task.title === 'string';
}
@customElement('kb-task-search')
export class KbTaskSearch extends LitElement {
static styles = css`
:host {
display: block;
max-width: 420px;
font: 14px system-ui;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
input {
width: 100%;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--border, #ccc);
}
ul {
list-style: none;
margin: 4px 0 0;
padding: 0;
}
li {
padding: 8px 12px;
cursor: pointer;
border-radius: 6px;
}
li.active {
background: var(--accent, #eef);
}
.empty {
color: #888;
padding: 8px 12px;
}
`;
@property() placeholder = 'Tìm task…';
@state() private _query = '';
@state() private _results: Task[] = [];
@state() private _loading = false;
@state() private _active = -1;
#debounce = 0;
#request?: AbortController;
disconnectedCallback() {
super.disconnectedCallback();
clearTimeout(this.#debounce);
this.#request?.abort();
}
focus(options?: FocusOptions) {
this.renderRoot.querySelector<HTMLInputElement>('input')?.focus(options);
}
render() {
const activeId = this._results[this._active]
? `task-option-${this._active}`
: undefined;
return html`
<label class="sr-only" for="query">Tìm task theo tiêu đề</label>
<input
id="query"
type="search"
role="combobox"
autocomplete="off"
aria-autocomplete="list"
aria-controls="results"
aria-expanded=${String(this._results.length > 0)}
aria-activedescendant=${ifDefined(activeId)}
.value=${live(this._query)}
placeholder=${this.placeholder}
@input=${this.#onInput}
@keydown=${this.#onKey}
/>
${when(
this._loading,
() => html`<div class="empty" role="status">Đang tải…</div>`,
() => this.#renderResults()
)}
`;
}
#renderResults() {
if (!this._query) return html``;
if (this._results.length === 0)
return html`<div class="empty" role="status">Không có kết quả.</div>`;
return html`
<ul id="results" role="listbox">
${repeat(
this._results,
(r) => r.id,
(r, i) => html`
<li
id=${`task-option-${i}`}
role="option"
aria-selected=${String(i === this._active)}
class=${classMap({ active: i === this._active })}
@click=${() => this.#choose(r)}
>
${r.title}
</li>
`
)}
</ul>
`;
}
#onInput = (e: Event) => {
this._query = (e.target as HTMLInputElement).value;
clearTimeout(this.#debounce);
this.#request?.abort();
this._active = -1;
this._results = [];
if (!this._query.trim()) {
this.#request?.abort();
this._results = [];
this._loading = false;
return;
}
// Debounce 250ms để không bắn request mỗi phím.
this.#debounce = window.setTimeout(() => this.#fetch(), 250);
};
#onKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (this._results.length === 0) return;
this._active = Math.min(this._active + 1, this._results.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (this._results.length === 0) return;
this._active = Math.max(this._active - 1, 0);
} else if (e.key === 'Enter' && this._results[this._active]) {
e.preventDefault();
this.#choose(this._results[this._active]);
} else if (e.key === 'Escape') {
clearTimeout(this.#debounce);
this.#request?.abort();
this._query = '';
this._results = [];
this._active = -1;
this._loading = false;
}
};
async #fetch() {
const query = this._query.trim();
if (!query) return;
this.#request?.abort();
const request = new AbortController();
this.#request = request;
this._loading = true;
try {
const response = await fetch(
`/api/tasks?q=${encodeURIComponent(query)}`,
{
signal: request.signal,
}
);
if (!response.ok) throw new Error(`Search failed: ${response.status}`);
const data: unknown = await response.json();
if (request.signal.aborted || query !== this._query.trim()) return;
const seen = new Set<string>();
this._results = Array.isArray(data)
? data.filter(isTask).filter((task) => {
if (seen.has(task.id)) return false;
seen.add(task.id);
return true;
})
: [];
this._active = -1;
} catch (error) {
if (!request.signal.aborted) {
console.error(error);
this._results = [];
}
} finally {
if (this.#request === request) {
this.#request = undefined;
this._loading = false;
}
}
}
#choose(task: Task) {
const selectedTask = Object.freeze({ ...task });
this._query = task.title;
this._results = [];
this._active = -1;
this.dispatchEvent(
new CustomEvent('kb-task-select', {
detail: Object.freeze({ task: selectedTask }),
bubbles: true,
composed: true,
})
);
}
}
<kb-task-search placeholder="Tìm task…"></kb-task-search>
<script type="module" src="/kb-task-search.js"></script>
<script type="module">
document
.querySelector('kb-task-search')
.addEventListener('kb-task-select', (event) => {
console.log('Đã chọn:', event.detail.task);
});
</script>
Failure modes cần bắt được trước review
| Triệu chứng | Nguyên nhân thường gặp | Sửa ở contract nào |
|---|---|---|
| property đổi nhưng UI đứng yên | class field che reactive accessor | sửa decorator/useDefineForClassFields |
component con nhận "[object Object]" | dùng attribute binding cho object | đổi sang .property=${value} |
| reorder làm input giữ sai value/focus | list không có key theo identity | dùng repeat với key ổn định |
| update chạy mãi | set reactive state vô điều kiện trong updated() | derived value ở getter/willUpdate |
| request cũ ghi đè request mới | async work không abort/kiểm tra identity | AbortController hoặc @lit/task |
| XSS lọt qua template | dùng unsafeHTML với dữ liệu không tin cậy | render text binding hoặc sanitize ở boundary |
| reconnect bị nhân listener | override lifecycle nhưng thiếu cleanup/super | giữ setup/teardown đối xứng |
updateComplete mặc định chỉ xác nhận update của component hiện tại. Nó không
cam kết mọi descendant đã render xong; integration test nên chờ điều kiện mà
user thực sự quan sát thay vì thêm một setTimeout ngẫu nhiên.
Bài tập thực hành
- Đổi
kb-task-searchsang staticpropertiesvà JavaScript thuần nhưng giữ nguyên public contract. - Tạo regression test reorder ba task, giữ focus trên task có cùng
id; so sánhmapvớirepeatdùng key. - Thêm trạng thái lỗi có
role="status", nút retry và không làm mất query. - Viết một
AsyncDirectivenhậnAbortSignal; cleanup trongdisconnected()và khởi động lại trongreconnected(). - Cố tình set state trong
updated()để tạo loop, rồi chuyển derived value sang getter hoặcwillUpdate().
Điều cốt lõi
Lit giữ component ở trên web platform và bỏ đi phần đồng bộ DOM lặp lại. Mô
hình tư duy cần giữ nhỏ: reactive properties là input/state, render() mô tả
UI, vị trí expression quyết định loại binding, directive chỉ được thêm khi
template thông thường chưa diễn đạt đủ, và lifecycle hook giữ side effect ra
khỏi render().
Phần 13 sẽ rời một component đơn lẻ để thiết kế cả cây Mini Kanban: state nằm ở đâu, dữ liệu đi xuống thế nào, event đi lên ra sao, controller tái dùng lifecycle, context tránh prop drilling và task async xử lý race condition.