jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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.

19 MIN READ Updated JUL 12, 2026

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
typeBộ chuyển attribute (string) ↔ property: String, Number, Boolean, Array, Object
attributeTên attribute (false = không map; 'my-attr' = đổi tên)
reflecttrue = property đổi thì ghi ngược ra attribute (cho CSS/DOM thấy)
useDefaulttrue = 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
converterBộ chuyển tùy chỉnh { fromAttribute, toAttribute }
hasChangedHàm quyết định “có thực sự đổi không” → bỏ qua re-render thừa
noAccessorKhô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 useDefineForClassFieldstrue (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ùng accessor; static properties thì 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ápLoạiTương đương DOMKhi nào dùng
${value}Text/childnode.textContent / chèn nodeHiển thị nội dung
attr=${value}AttributesetAttribute('attr', value)Giá trị là chuỗi (class, src, id)
.prop=${value}Propertyel.prop = valueTruyền object/array/number giữ kiểu
?attr=${value}Boolean attrtoggleAttribute('attr', !!value)disabled, hidden, checked
@event=${handler}EventaddEventListener('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ùng my-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ứngNguyên nhân thường gặpSửa ở contract nào
property đổi nhưng UI đứng yênclass field che reactive accessorsử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/focuslist không có key theo identitydùng repeat với key ổn định
update chạy mãiset reactive state vô điều kiện trong updated()derived value ở getter/willUpdate
request cũ ghi đè request mớiasync work không abort/kiểm tra identityAbortController hoặc @lit/task
XSS lọt qua templatedùng unsafeHTML với dữ liệu không tin cậyrender text binding hoặc sanitize ở boundary
reconnect bị nhân listeneroverride lifecycle nhưng thiếu cleanup/supergiữ 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

  1. Đổi kb-task-search sang static properties và JavaScript thuần nhưng giữ nguyên public contract.
  2. Tạo regression test reorder ba task, giữ focus trên task có cùng id; so sánh map với repeat dùng key.
  3. Thêm trạng thái lỗi có role="status", nút retry và không làm mất query.
  4. Viết một AsyncDirective nhận AbortSignal; cleanup trong disconnected() và khởi động lại trong reconnected().
  5. Cố tình set state trong updated() để tạo loop, rồi chuyển derived value sang getter hoặc willUpdate().

Đ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.

Nguồn chính thức