Lit toàn tập — Reactive Properties, Bindings, Directives & Events
Hướng dẫn dùng Lit cụ thể: LitElement, reactive properties, 5 kiểu binding (text/attribute/property/boolean/event), built-in directives, custom directive, vòng đời update, styling và Reactive Controllers.
Web Components are powerful but verbose {Web Components mạnh nhưng dài dòng}: manual attachShadow, manual innerHTML, manual re-rendering on every state change {tự attachShadow, tự innerHTML, tự re-render mỗi lần state đổi}. Lit is a ~5KB library that keeps the standards-based foundation but adds declarative templates and reactive rendering on top {Lit là thư viện ~5KB giữ nền tảng dựa trên chuẩn nhưng thêm template khai báo và render phản ứng lên trên}.
If you haven’t read the Web Components deep dive, do that first — Lit is just a thin, ergonomic layer over those exact APIs {Nếu bạn chưa đọc bài Web Components, hãy đọc trước — Lit chỉ là một lớp mỏng, tiện tay phủ lên đúng các API đó}.
Vì sao Lit {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)
Lit’s core ideas {Ý tưởng cốt lõi của Lit}: state lives in reactive properties, the UI is a pure function of state via the html tagged template, and Lit re-renders only the parts that changed {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. Cài đặt & component đầu tiên {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>
Decorators (
@customElement,@property) cần bật trongtsconfig.json("experimentalDecorators": truecho TS cũ, hoặc standard decorators với TS 5+ — khi đó nhớ dùng từ khoáaccessor, xem mục 2.2). Không dùng TS? Có API tương đương bằngstatic properties(xem mục 2). {Decorators cần cấu hình TS; với standard decorators nhớ dùngaccessor; nếu không dùng TS có APIstatic propertiestương đương.}
2. Reactive properties — trái tim của reactivity {Reactive properties — the heart of reactivity}
A reactive property triggers a re-render when it changes {Một reactive property kích hoạt re-render khi nó đổi}. Declare it two ways {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
}
Không dùng decorator (JS thuần) {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; }
}
2.1. Các option của @property {@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 phun 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 !==
})
accessor count = 0;
Best practices khi reflect {Best practices when reflecting} (theo docs): reflect dè dặt — attribute nên là input từ ngoài vào, không phải state element tự quản {reflect dè dặt — attribute nên là input từ ngoài}; không reflect Object/Array (serialize JSON nặng, tốn bộ nhớ) {không reflect Object/Array vì serialize nặng}; khi đã reflect thì thường nên kèm useDefault: true để element không tự sinh attribute mà user không set {khi đã reflect thì thường kèm useDefault: true}.
@state vs @property: use @property for public API consumers set from HTML/JS {dùng @property cho API công khai mà người dùng set từ HTML/JS}; use @state for internal state that should still trigger renders but isn’t an attribute {dùng @state cho state nội bộ vẫn cần trigger render nhưng không phải attribute}.
Reactivity là per-binding, không deep {Reactivity là theo từng binding, không sâu}: mutating an object/array in place (this.items.push(x)) won’t trigger an update because the reference didn’t change {thay đổi tại chỗ một object/array sẽ không trigger update vì tham chiếu không đổi}. Assign a new reference {Hãy gán tham chiếu mới}: this.items = [...this.items, x]. Need to mutate in place? Call this.requestUpdate() manually {Bắt buộc mutate tại chỗ? Gọi this.requestUpdate() thủ công} — nhưng chỉ component đó re-render, con nhận cùng tham chiếu sẽ không cập nhật {nhưng chỉ component đó re-render, component con nhận cùng tham chiếu sẽ không cập nhật}.
2.2. Cạm bẫy class fields — lỗi reactivity thầm lặng {The class fields footgun — silent loss of reactivity}
This is the #1 silent bug in Lit {Đây là bug thầm lặng số 1 trong Lit}: a reactive property is defined as an accessor on the prototype, but a plain class field is defined on the instance — and per JavaScript rules, the instance field shadows the accessor, so setting the property never triggers an update {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}.
This bites when useDefineForClassFields is true (the default with standard decorators / TS targets ES2022+) {Vấn đề xảy ra khi useDefineForClassFields là true (mặc định với standard decorators / TS target ES2022+)}. Three correct patterns {Ba cách viết đúng}:
// ✅ Cách 1 (khuyến nghị, Lit 3 + TS 5): 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 (TS, experimentalDecorators): tắt useDefineForClassFields
// trong tsconfig → class field giữ nguyên cú pháp mà vẫn reactive.
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 nhanh {Rule of thumb}: dùng standard decorators thì luôn kèm
accessor; dùngexperimentalDecoratorsthì đặt"useDefineForClassFields": false. Nếu thấy “đổi property mà UI không cập nhật” → 90% là dính cạm bẫy này. {Nếu property đổi mà UI không cập nhật, gần như chắc chắn là lỗi này.}
2.3. Custom accessor — khi cần validate lúc set {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 {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 {Put the decorator on the setter}:
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
const old = this._size;
this._size = next;
this.requestUpdate('size', old); // báo Lit để update
}
get size() { return this._size; }
Hầu hết trường hợp không cần custom accessor {Most of the time you don’t need a custom accessor}: tính giá trị dẫn xuất thì dùng willUpdate (mục 6), phản ứng sau render thì dùng updated — chỉ tự viết setter khi bắt buộc validate đồng bộ tại thời điểm gán {tính giá trị dẫn xuất dùng willUpdate, phản ứng sau render dùng updated; chỉ tự viết setter khi bắt buộc validate đồng bộ}.
3. Templates & 5 kiểu binding {Templates & the 5 binding types}
The html tagged template returns a TemplateResult — a description of DOM, not DOM itself {Tagged template html trả về một TemplateResult — một mô tả DOM, không phải DOM thật}. Lit parses the static parts once and only updates the dynamic ${...} holes on each render {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}.
Binding position quyết định loại binding — đây là điểm cốt lõi của Lit {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 |
The .prop vs attr= distinction is the #1 thing beginners get wrong {Phân biệt .prop với attr= là lỗi số 1 của người mới}: to pass an array/object to a child component, you must use .prop {để truyền array/object cho component con, bạn phải dùng .prop}; attr= would stringify it to "[object Object]" {attr= sẽ biến nó thành "[object Object]"}.
Event handlers keep this bound automatically {Event handler giữ this tự động} when written as arrow-function class fields or referenced as methods — Lit binds the handler’s this to the host element {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 — logic tái dùng trong template {Directives — reusable logic in templates}
A directive is a function that customizes how an expression renders {Directive là một hàm tùy biến cách một biểu thức được render}. Lit ships many; import each from its own path (tree-shakeable) {Lit có sẵn nhiều cái; import từng cái theo đường dẫn riêng (tree-shake được)}.
4.1. Conditionals & loops {Đ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, KHÔNG diff (nhanh, nhẹ) -->
<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 vs repeat — câu hỏi hay gặp {a common question}: map re-renders DOM in place (no diffing) — smaller and faster {render DOM tại chỗ, không diff — nhỏ và nhanh hơn}. Use repeat with a keyFn only when the list reorders and you need to preserve DOM state (focus, input, animation) per item {Dùng repeat kèm keyFn chỉ khi list đổi thứ tự và bạn cần giữ trạng thái DOM theo từng item}.
4.2. Attributes & styling directives {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/null (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} />
`;
}
4.3. Performance & async directives {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>`)}
`;
}
Other built-ins worth knowing {Các directive khác đáng biết}: ref (lấy tham chiếu element), join, range, unsafeHTML (render HTML thô — cẩn thận XSS), asyncAppend/asyncReplace (stream từ async iterator).
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'); }
}
4.4. Custom directive — tự viết {Writing a custom directive}
When built-ins aren’t enough, write your own {Khi directive có sẵn không đủ, hãy tự viết}. Extend Directive, implement render (and update if you need DOM access) {Kế thừa Directive, implement render (và update nếu cần truy cập DOM)}:
import { Directive, directive, type PartInfo, PartType } from 'lit/directive.js';
// Directive nối chuỗi với separator — ví dụ tối giản.
class JoinDirective extends Directive {
render(items: string[], sep = ', ') {
return items.join(sep);
}
}
export const join = directive(JoinDirective);
// Dùng: html`<p>${join(this.tags, ' · ')}</p>`
For directives that must persist state across renders or clean up (timers, observers), extend AsyncDirective and use this.setValue() / disconnected() {Cho directive cần giữ state qua nhiều lần render hoặc dọn dẹp, kế thừa AsyncDirective và dùng this.setValue() / disconnected()}.
5. Events — phát và lắng nghe {Events — dispatch and listen}
Listen with @event inside templates; dispatch a CustomEvent to talk to parents {Lắng nghe bằng @event trong template; phát CustomEvent để nói với cha}. Remember composed: true to cross the shadow boundary (see the Web Components post) {Nhớ composed: true để vượt ranh giới shadow}:
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`
${[1, 2, 3, 4, 5].map((n) => html`
<button @click=${() => this.#select(n)}>
${n <= this.value ? '★' : '☆'}
</button>
`)}
`;
}
}
// Cha lắng nghe như event thường:
html`<rating-stars @rating-change=${(e) => (this.score = e.detail.value)}></rating-stars>`
6. Vòng đời update — khi nào code của bạn chạy {The reactive update lifecycle}
When a reactive property changes, Lit schedules an async batched update (microtask) so multiple changes in one tick cause a single render {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
class Chart extends LitElement {
@property({ type: Array }) data: number[] = [];
// 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
}
}
Key distinctions {Phân biệt then chốt}: willUpdate for computing derived values (runs before render, no DOM yet) {willUpdate để tính giá trị dẫn xuất (chạy trước render, chưa có DOM)}; firstUpdated for one-time DOM setup {firstUpdated cho thiết lập DOM một lần}; updated for reacting to every change {updated để phản ứng mọi thay đổi}. Call this.requestUpdate() manually only when state lives outside reactive properties {Chỉ gọi this.requestUpdate() thủ công khi state nằm ngoài reactive properties}.
7. Styling — scoped & themeable {Styling — scoped & themeable}
static styles with the css tag is parsed once and shared via Constructable Stylesheets {static styles với tag css được parse một lần và chia sẻ qua Constructable Stylesheets}:
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 / .1); }
::slotted(h2) { margin: 0; } /* style nội dung được slot */
.body::part(x) { } /* expose part ra ngoài nếu cần */
`;
}
- Mảng styles {Style arrays}:
static styles = [reset, shared, local]để tái dùng {để tái dùng}. - Theming đi qua CSS custom properties vì chúng xuyên ranh giới shadow {vì chúng xuyên qua ranh giới shadow} — nguồn duy nhất nên dùng để cho phép user tùy biến {nguồn duy nhất nên dùng để cho phép user tùy biến}.
- Dynamic styles {Style động} per render dùng
classMap/styleMap(mục 4), không nhồi vàostatic styles{không nhồi vàostatic styles}.
8. Slots & composition {Slots & composition}
Lit uses the same native <slot> mechanism {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>
Read slotted nodes with @queryAssignedElements {Đọ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 — chia sẻ logic có vòng đời {Reactive Controllers — share lifecycle-aware logic}
A Reactive Controller is a reusable object that hooks into a host’s update lifecycle — Lit’s answer to “composition over inheritance” (similar role to React hooks) {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 = window.innerWidth;
#host: ReactiveControllerHost;
constructor(host: ReactiveControllerHost) {
this.#host = host;
host.addController(this); // đăng ký với host
}
hostConnected() { 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>`; }
}
Lit ecosystem packages build on this pattern {Các package trong hệ sinh thái Lit xây trên mẫu này}: @lit/task (Task controller cho async data + trạng thái pending/complete/error) {cho dữ liệu async + trạng thái}, and @lit/context (@provide/@consume để truyền data xuống cây không cần prop-drilling) {để truyền data xuống cây không cần prop-drilling}.
10. Ví dụ hoàn chỉnh — ô tìm kiếm có debounce {Full example — a debounced search box}
Tying it all together {Ghép tất cả lại}: reactive properties, all binding types, directives, events, lifecycle, and styling {reactive properties, mọi kiểu binding, directive, event, vòng đời, và styling}.
// search-box.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';
interface Result { id: string; title: string; }
@customElement('search-box')
export class SearchBox extends LitElement {
static styles = css`
:host { display: block; max-width: 420px; font: 14px system-ui; }
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 kiếm…';
@state() private _query = '';
@state() private _results: Result[] = [];
@state() private _loading = false;
@state() private _active = -1;
#debounce = 0;
render() {
return html`
<input
type="search"
.value=${this._query}
placeholder=${this.placeholder}
?disabled=${this._loading}
@input=${this.#onInput}
@keydown=${this.#onKey}
/>
${when(this._loading,
() => html`<div class="empty">Đang tải…</div>`,
() => this.#renderResults())}
`;
}
#renderResults() {
if (!this._query) return html``;
if (this._results.length === 0)
return html`<div class="empty">Không có kết quả.</div>`;
return html`
<ul>
${repeat(this._results, (r) => r.id, (r, i) => html`
<li
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);
// 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') this._active = Math.min(this._active + 1, this._results.length - 1);
else if (e.key === 'ArrowUp') this._active = Math.max(this._active - 1, 0);
else if (e.key === 'Enter' && this._results[this._active]) this.#choose(this._results[this._active]);
};
async #fetch() {
if (!this._query) { this._results = []; return; }
this._loading = true;
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(this._query)}`);
this._results = await res.json(); // gán tham chiếu mới → re-render
this._active = -1;
} finally {
this._loading = false; // state đổi → tự render lại
}
}
#choose(r: Result) {
this.dispatchEvent(new CustomEvent('select', {
detail: r, bubbles: true, composed: true,
}));
this._query = r.title;
this._results = [];
}
}
<search-box placeholder="Tìm sản phẩm…"></search-box>
<script type="module" src="/search-box.js"></script>
<script type="module">
document.querySelector('search-box')
.addEventListener('select', (e) => console.log('Đã chọn:', e.detail));
</script>
Kết luận {Conclusion}
Lit keeps the standards-based, framework-agnostic, long-lived nature of Web Components, and removes the boilerplate {Lit giữ bản chất dựa trên chuẩn, độc lập framework, sống lâu của Web Components, và bỏ đi phần lặp}. The mental model is small {Mô hình tư duy nhỏ gọn}: reactive properties hold state, html describes UI, the five binding types wire data in, directives add reusable logic, and the update lifecycle gives you precise hooks {reactive properties giữ state, html mô tả UI, năm kiểu binding nối dữ liệu vào, directive thêm logic tái dùng, và vòng đời update cho bạn các hook chính xác}.
Tham khảo {References}: