これからのフロントエンドコンポーネント開発 Lit -その(4)-


はじめに

Web components ベースのフロントエンドUIフレームワーク Lit の入門記事です。

前回は Lit コンポーネントの作り方について簡単に説明しました。

blog1.mammb.com

今回は Lit による ToDo コンポーネントを Vite を使って構築していきます(と言っても、内容は本家のチュートリアルのまんまです)。

Vite については以下を参照してください。

blog1.mammb.com


Vite によるプロジェクト作成

Vite でプロジェクト作成します。

プロジェクト名は todo-list とし、TypeScript を選択します。

$ npm create vite@latest
✔ Project name: … todo-list
✔ Select a framework: › Lit
✔ Select a variant: › TypeScript

Scaffolding project in /...todo-list...

Done. Now run:

  cd todo-list
  npm install
  npm run dev


作成されたひな形のままでも良いですが、todo-list.ts として実装することにするので、プロジェクトルートの index.html を以下のように変更します。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>ToDo List</title>
    <script type="module" src="/src/todo-list.ts"></script>
  </head>
  <body>
    <todo-list></todo-list>
  </body>
</html>

Vite の設定ファイルである vite.config.ts を以下のように変更します。

import { defineConfig } from 'vite'

// https://vitejs.dev/config/
export default defineConfig({
  build: {
    lib: {
      entry: 'src/todo-list.ts',
      formats: ['es']
    },
    rollupOptions: {
      external: /^lit/
    }
  }
})

Vite の defineConfig ヘルパを使用しています。

build.lib によりライブラリモードの設定とし、エントリポイントとして todo-list.ts を指定しています。

rollupOptions.external では、lit の依存関係をライブラリにバンドルしない設定になります。


todo-list コンポーネントの作成

todo-list コンポーネント src/todo-list.ts を追加して以下のように編集します。

import {LitElement, html} from 'lit';
import {customElement} from 'lit/decorators.js';

@customElement('todo-list')
export class ToDoList extends LitElement {

  override render() {
    return html`
      <h2>To Do</h2>
      <ul></ul>
      <input id="newitem" aria-label="New item">
      <button @click=${this.addToDo}>Add</button>
    `;
  }

  addToDo() {
  }

}

declare global {
  interface HTMLElementTagNameMap {
    'todo-list': ToDoList
  }
}

ToDoList クラスを作成し、カスタム要素として todo-list という名前を付けました。

render() メソッドでは、テキスト入力と、ToDo追加用のボタンを html リテラルで定義しています。 ボタンクリック時のイベントリスナを addToDo() とし、現在は空実装としています。


ここまでの内容で、プロジェクトを実行してみましょう。

$ cd todo-list
$ npm install
$ npm run dev

http://localhost:5173/ にアクセスすれば、以下のような画面が表示されます。


ToDo アイテムの追加

ToDo アイテムを 内部リアクティブステート として定義し、ボタンのクリックでアイテムが追加できるようにします。

最初に完了後のソースを示します。

import {LitElement, html} from 'lit';
import {customElement, state, query} from 'lit/decorators.js';

@customElement('todo-list')
export class ToDoList extends LitElement {

  @state()
  private _listItems = [
    { text: 'Start Lit tutorial', completed: true },
    { text: 'Make to-do list', completed: false }
  ];

  @query('#newitem')
  input!: HTMLInputElement;

  override render() {
    return html`
      <h2>To Do</h2>
      <ul>
        ${this._listItems.map((item) =>
          html`<li>${item.text}</li>`
        )}
      </ul>
      <input id="newitem" aria-label="New item">
      <button @click=${this.addToDo}>Add</button>
    `;
  }

  addToDo() {
      this._listItems = [...this._listItems,
            {text: this.input.value, completed: false}];
      this.input.value = '';
  }

}

declare global {
  interface HTMLElementTagNameMap {
    'todo-list': ToDoList
  }
}


内部リアクティブステートには @state ディレクティブを使うので、インポートに追加します。

import {customElement, state} from 'lit/decorators.js';

続いてアイテムリストを配列として定義します。

@customElement('todo-list')
export class ToDoList extends LitElement {

  @state()
  private _listItems = [
    { text: 'Start Lit tutorial', completed: true },
    { text: 'Make to-do list', completed: false }
  ];
  ...

アイテムリストは内部リアクティブステートとして定義したため、属性として公開されず、コンポーネントの外部からアクセスすることはできません。

続いて、シャドウDOM内の要素を参照するために、@query デコレータを使用します。 こちらもインポートに追加しておきます。

import {customElement, state, query} from 'lit/decorators.js';

@query ディレクティブと、空実装としていた addToDo() の中身を以下のように実装します。

  @query('#newitem')
  input!: HTMLInputElement;

  ...
  addToDo() {
      this._listItems = [...this._listItems,
            {text: this.input.value, completed: false}];
      this.input.value = '';
  }

@query デコレータにより、クラスプロパティが、レンダールートからノードを返すゲッターに変換されます。 入力用のテキストボックスに this.input としてアクセス可能となります。

addToDo() イベントリスナで、入力内容を既存のリストに追加した新しいリストを設定しています。 リストアイテムはリアクティブプロパティなので、これによりレンダリングがトリガされます。

最後に、html リテラルで、リストアイテムを表示するように変更します。

  override render() {
    return html`
      <h2>To Do</h2>
      <ul>
        ${this._listItems.map((item) =>
          html`<li>${item.text}</li>`
        )}
      </ul>
      ...
    `;
  }

以下のように、ToDo アイテムの追加が可能になりました。


ToDo アイテムのトグル

追加可能となった ToDo アイテムを、クリックにて完了/未完了をトグルできるようにしていきます。

最初に、完了後のソースを示します。

import {LitElement, html, css} from 'lit';
import {customElement, state, query} from 'lit/decorators.js';

type ToDoItem = {
  text: string,
  completed: boolean
}

@customElement('todo-list')
export class ToDoList extends LitElement {

  static override styles = css`
    .completed {
      text-decoration-line: line-through;
      color: #777;
    }
  `;

  @state()
  private _listItems = [
    { text: 'Start Lit tutorial', completed: true },
    { text: 'Make to-do list', completed: false }
  ];

  @query('#newitem')
  input!: HTMLInputElement;

  override render() {
    const items = this._listItems;
    const todos = html`
      <ul>
        ${items.map((item) =>
          html`
            <li style='cursor: pointer'
                class=${item.completed ? 'completed' : ''}
                @click=${() => this.toggleCompleted(item)}>
              ${item.text}
            </li>`
        )}
      </ul>
    `;
   
    return html`
      <h2>To Do</h2>
      ${todos}
      <input id="newitem" aria-label="New item">
      <button @click=${this.addToDo}>Add</button>
    `;
  }

  addToDo() {
      this._listItems = [...this._listItems,
            {text: this.input.value, completed: false}];
      this.input.value = '';
  }

  toggleCompleted(item: ToDoItem) {
    item.completed = !item.completed;
    this.requestUpdate();
  }

}

declare global {
  interface HTMLElementTagNameMap {
    'todo-list': ToDoList
  }
}

最初に、アイテムクリック時のリスナメソッドを以下のように定義します。

  toggleCompleted(item: ToDoItem) {
    item.completed = !item.completed;
    this.requestUpdate();
  }

引数の ToDoItem は import 文の下部に以下のように型定義します。

type ToDoItem = {
  text: string,
  completed: boolean
}

render() メソッドを以下のように変更します。

  override render() {

    const items = this._listItems;
    const todos = html`
      <ul>
        ${items.map((item) =>
          html`
            <li style='cursor: pointer'
                @click=${() => this.toggleCompleted(item)}>
              ${item.text}
            </li>`
        )}
      </ul>
    `;

    return html`
      <h2>To Do</h2>
      <!-- TODO: Update expression. -->
      ${todos}
      <input id="newitem" aria-label="New item">
      <button @click=${this.addToDo}>Add</button>
    `;

html リテラルを分割して定義するように変更しています。 li 要素には、クリックに対するリスナーとして () => this.toggleCompleted(item) のように登録しています。

クリックによるトグルで表示を変化させるため、スタイルを適用します。

css をインポートします。

import {LitElement, html, css} from 'lit';

スタイルを以下のように定義します。

static override styles = css`
  .completed {
    text-decoration-line: line-through;
    color: #777;
  }
`;

li 要素の class 属性に、状態に応じてスタイルを適用するようにします。

    const todos = html`
      <ul>
        ${items.map((item) =>
          html`
            <li style='cursor: pointer'
                class=${item.completed ? 'completed' : ''}
                @click=${() => this.toggleCompleted(item)}>
              ${item.text}
            </li>`
        )}
      </ul>
    `;

これにより、以下のようにクリックに応じて打消し線がトグルするようになりました。


ToDo アイテムの消込

完了したToDoアイテムは、表示から消しこめるように変更しましょう。

ソース全体は以下のようになります。

import {LitElement, html, css} from 'lit';
import {customElement, state, property, query} from 'lit/decorators.js';

type ToDoItem = {
  text: string,
  completed: boolean
}

@customElement('todo-list')
export class ToDoList extends LitElement {

  static override styles = css`
    .completed {
      text-decoration-line: line-through;
      color: #777;
    }
  `;

  @state()
  private _listItems = [
    { text: 'Start Lit tutorial', completed: true },
    { text: 'Make to-do list', completed: false }
  ];
  @property()
  hideCompleted = false;

  @query('#newitem')
  input!: HTMLInputElement;

  override render() {
    const items = this.hideCompleted
      ? this._listItems.filter((item) => !item.completed)
      : this._listItems;

    const todos = html`
      <ul>
        ${items.map((item) =>
          html`
            <li style='cursor: pointer'
                class=${item.completed ? 'completed' : ''}
                @click=${() => this.toggleCompleted(item)}>
              ${item.text}
            </li>`
        )}
      </ul>
    `;

    const caughtUpMessage = html`<p>You're all caught up!</p>`;
    const todosOrMessage = items.length > 0
      ? todos
      : caughtUpMessage;

   
    return html`
      <h2>To Do</h2>
      ${todosOrMessage}
      <input id="newitem" aria-label="New item">
      <button @click=${this.addToDo}>Add</button>
      <br>
      <label>
        <input type="checkbox"
          @change=${this.setHideCompleted}
          ?checked=${this.hideCompleted}>
        Hide completed
      </label>
    `;
  }

  addToDo() {
      this._listItems = [...this._listItems,
            {text: this.input.value, completed: false}];
      this.input.value = '';
  }

  toggleCompleted(item: ToDoItem) {
    item.completed = !item.completed;
    this.requestUpdate();
  }

  setHideCompleted(e: Event) {
    this.hideCompleted = (e.target as HTMLInputElement).checked;
  }

}

declare global {
  interface HTMLElementTagNameMap {
    'todo-list': ToDoList
  }
}

画面から消しこむかどうかはチェックボックスで指定でき、hideCompleted というプロパティで扱うこととします。

最初に @property ディレクティブをインポートに追加しておきます。

import {customElement, state, property, query} from 'lit/decorators.js';

クラスフィールドに hideCompleted プロパティを定義します。

  @property()
  hideCompleted = false;

このプロパティは、以下のメソッドでチェック状態を反映します。

  setHideCompleted(e: Event) {
    this.hideCompleted = (e.target as HTMLInputElement).checked;
  }

render() メソッドに以下の input 要素を追加し、イベントリスナと属性を追加します。

  override render() {
    ...
    return html`
      ...
      <br>
      <label>
        <input type="checkbox"
          @change=${this.setHideCompleted}
          ?checked=${this.hideCompleted}>
        Hide completed
      </label>
    `;

render() メソッドの先頭に記載していた items を完了状態によりフィルタリングするようにします。

const items = this.hideCompleted
  ? this._listItems.filter((item) => !item.completed)
  : this._listItems;

これで消込処理は完了です。

最後に、ToDo アイテムが無くなった場合にメッセージを表示するようにします。

const caughtUpMessage = html`<p>You're all caught up!</p>`;
const todosOrMessage = items.length > 0
  ? todos
  : caughtUpMessage;
    return html`
      <h2>To Do</h2>
      ${todosOrMessage}
      <input id="newitem" aria-label="New item">
      <button @click=${this.addToDo}>Add</button>
      <br>
      <label>
        <input type="checkbox"
          @change=${this.setHideCompleted}
          ?checked=${this.hideCompleted}>
        Hide completed
      </label>
    `;

これにより、以下のような ToDo 操作が完了しました。


まとめ

Lit コンポーネントの構築をチュートリアル形式で紹介しました。

本家にはインタラクティブに操作できるチュートリアルがいくつもあるので、試してみてはいかがでしょうか。

lit.dev