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


はじめに

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

前回は Lit のプロジェクト作成について説明しました。

blog1.mammb.com

今回は Lit コンポーネントの作り方についての簡単な説明です。


Lit コンポーネント

Lit コンポーネントは、LitElement を継承したクラスにて定義します。

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

@customElement('simple-greeting')
export class SimpleGreeting extends LitElement {
    // ...
}

LitElement は、HTMLElement のサブクラスとなっており、これによりカスタムHTML要素を定義します。

@customElement デコレータでは、カスタム要素に対して simple-greeting という名前を定義しています。 この名前は、- を使ったケバブケースとします。

コンポーネントは、HTML中で以下のように利用できます。

<simple-greeting name="Markup"></simple-greeting>


HTMLElementTagNameMap

TypeScript では、タグ名とコンポーネントの紐づけを HTMLElementTagNameMap に定義することで、型推論が利用できるようになります。

例えば以下のようなコンポーネントを定義した場合、

@customElement('my-element')
export class MyElement extends LitElement {

  @property({type: Number})
  aNumber: number = 5;

}

HTMLElementTagNameMap にタグ名とコンポーネントのマッピングを登録します。

declare global {
  interface HTMLElementTagNameMap {
    "my-element": MyElement;
  }
}

これにより、プロパティへのアクセスがタイプセーフに行えるようになります。

const myElement = document.createElement('my-element');
myElement.aNumber = 10;


リアクティブプロパティ

リアクティブプロパティは、値の変更によりコンポーネントの再レンダリングがトリガーされるプロパティです。

リアクティブプロパティは、@property デコレーターを使用して宣言します。

class MyElement extends LitElement {

  @property({type: String})
  mode: string;

  @property({attribute: false})
  data = {};

}

リアクティブプロパティごとにゲッター/セッターが生成され、リアクティブプロパティが変更されると、コンポーネントは更新をスケジュールします。

Lit はプロパティに対応する監視対象の属性を設定し、属性が変更されるとプロパティを更新します(プロパティ値を属性に反映させることもできます)。


@property デコレータの引数にオプションを指定できます。引数を省略した場合はデフォルト値が利用されます。オプションには以下が存在します。

  • attribute:デフォルトtrue. プロパティが属性と関連付けられているかどうか、または関連付けられた属性のカスタム名を指定
  • converter:プロパティと属性を変換するためのカスタムコンバーター。指定しない場合はデフォルトのコンバータが適用される
  • hasChanged:プロパティが変更されたかどうかを判断するためにプロパティが設定されるたびに呼び出され、更新をトリガーする必要がある関数
  • noAccessor:デフォルト false. デフォルトのプロパティアクセサーを生成しないようにするには、true に設定
  • reflect:デフォルト false. プロパティ値を関連付けられた属性に反映させるかどうか
  • state:デフォルト false. プロパティを内部リアクティブ状態として宣言するには、true に設定(@stateデコレータを使用するのと同じ)
  • type:属性をプロパティに変換する場合のためのコンバータ定義


プロパティオブジェクトの更新

リアクティブプロパティを変更することで、再レンダリングがトリガされますが、配列やオブジェクトの中身を変更した場合には変更がトリガされません。

このようなケースでは、プロパティをイミュータブルデータとして扱い、変更後の内容を再設定することができます。

例えば、配列から要素を削除するケースでは以下のようになります。

this.myArray = this.myArray.filter((_, i) => i !== indexToRemove);

また、手動で更新をトリガーすることもできます。

this.myArray.splice(indexToRemove, 1);
this.requestUpdate();

手動により更新のトリガでは、コンポーネントがサブコンポーネントに渡された場合にサブコンポーネント側では変更が検知されないため、サブコンポーネントの更新は行われないことに注意する必要があります。


内部リアクティブステート(Internal reactive state)

コンポーネントの属性ではなく、パブリックAPIではないリアクティブプロパティは、内部リアクティブステートとして以下のように @stateデコレーターを使用して宣言できます。

@state()
protected _active = false;


レンダリング

Lit コンポーネントでは、render() メソッドでレンダリング内容を定義します。

レンダリング内容は、タグ付きテンプレートリテラル(tagged template literal) html を使います。

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

@customElement('my-element')
class MyElement extends LitElement {

  render(){
    return html`<p>Hello from my template.</p>`;
  }
}

render() メソッドの内部では、コンポーネントの状態を変更しないようにします。

render() メソッドでは、入力としてコンポーネントのプロパティのみを使用し、同じプロパティ値が与えられると、同じ結果を返すような副作用のない関数として定義すべきです。

render() メソッドでは JavaScript フロー制御を使用して条件付きコンテンツをレンダリングできます。

render() {
  let message;
  if (this.userName) {
    message = html`Welcome ${this.userName}`;
  } else {
    message = html`Please log in <button>Login</button>`;
  }
  return html`<p class="message">${message}</p>`;
}

以下のようにすることもできます。

getUserMessage() {
  if (this.userName) {
    return html`Welcome ${this.userName}`;
  } else {
    return html`Please log in <button>Login</button>`;
  }
}
render() {
  return html`<p>${this.getUserMessage()}</p>`;
}


Lit テンプレート

Lit テンプレートでは、さまざまな方法で動的コンテンツをレンダリングすることができます。

テンプレートには、式と呼ばれる動的な値を含めることができます。

html`<h1>Hello ${name}</h1>`

html`<ul>${listItems}</ul>`

HTML要素の属性を以下のようにレンダリングすることもできます。

html`<div class=${highlightClass}></div>`

ブール属性

属性名に?プレフィックス付けたブール属性を定義できます。

html`<div ?hidden=${!show}></div>`

式が真の値に評価される場合は属性が追加され、偽の値に評価される場合は削除されます。

プロパティ式

. プレフィックス付けることで要素に JavaScript プロパティを設定できます。

html`<input .value=${value}>`

イベントリスナ

@ プレフィックスの後にイベント名を付けることで、宣言型のイベントリスナを定義できます。

html`<button @click=${this._clickHandler}>Go</button>`
clickHandler() {
  this.clickCount++;
}

これは、以下のようにイベントリスナを登録するのと同じような働きとなります。

addEventListener('click', this.clickHandler)


テンプレートの合成

テンプレートは以下のように部分要素を合成して構築できます。

  headerTemplate() {
    return html`<header>${this.article.title}</header>`;
  }

  articleTemplate() {
    return html`<article>${this.article.text}</article>`;
  }

  render() {
    return html`
      ${this.headerTemplate()}
      ${this.articleTemplate()}
    `;
  }

インポートした他のタグを使うこともできます。

import './my-header.js';

@customElement('my-page')
class MyPage extends LitElement {
  render() {
    return html`
      <my-header></my-header>
    `;
  }
}


テンプレートのループ処理

以下のように繰り返し項目を出力することができます。

@property()
colors = ['red', 'green', 'blue'];

render() {
  return html`
    <ul>
      ${this.colors.map((color) =>
        html`<li style="color: ${color}">${color}</li>`
      )}
    </ul>
  `;
}

以下のようにすることもできます。

render() {
  const itemTemplates = [];
  for (const i of this.items) {
    itemTemplates.push(html`<li>${i}</li>`);
  }

  return html`
    <ul>
      ${itemTemplates}
    </ul>
  `;
}


ディレクティブ

ディレクティブは、Lit のテンプレート機能を拡張できる関数です。

いくつもの便利なディレクティブが組み込みで提供されていますし、自身でディレクティブを作成することもできます。

各組み込みのディレクティブについてはリファレンスdirectivesを参照してください。


ディレクティブの例として repeat を見てみます。

import {repeat} from 'lit/directives/repeat.js';

repeat(items, keyFunction, itemTemplate)
  • items 配列またはイテラブル
  • keyFunction 単一のアイテムを引数として取り、キーを返す関数
  • itemTemplate アイテムとその現在のインデックスを引数として取り、 TemplateResultを返すテンプレート関数

repeat ディレクティブを使うことで、 employees リストを以下のようにレンダリングできます。

render() {
  return html`
    <ul>
      ${repeat(this.employees,
        (employee) => employee.id,
        (employee, index) => html`
          <li>${index}: ${employee.familyName}, ${employee.givenName}</li>
        `)
      }
    </ul>
    <button @click=${this.toggleSort}>Toggle sort</button>
  `;
}


Styles

コンポーネントにスタイルを定義すると、スタイルはコンポーネントのシャドウルート内の要素にのみ影響します。

スタイルは、以下のようにタグ付きテンプレートリテラル(tagged template literal) css を使い、静的クラスフィールド styles として定義します。

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

@customElement('my-element')
export class MyElement extends LitElement {
  static styles = css`
    p {
      color: green;
    }
  `;
  protected render() {
    return html`<p>I am green!</p>`;
  }
}

スタイル中で式を使うこともできます。

const mainColor = css`red`;
...
static styles = css`
  div { color: ${mainColor} }
`;

式には、css テンプレートリテラルを受け付けます。

文字列を埋め込みたいケースでは、セキュリティのため、unsafeCSS を使って以下のようにする必要があります。

const mainColor = 'red';
...
static styles = css`
  div { color: ${unsafeCSS(mainColor)} }
`;


スタイルの共有

タグ付きスタイルをエクスポートするモジュールを作成することで、コンポーネント間でスタイルを共有できます。

export const buttonStyles = css`
  .blue-button {
    color: white;
    background-color: blue;
  }
  .blue-button:disabled {
    background-color: grey;
  }`;

要素はスタイルをインポートして、静的stylesクラスフィールドに追加できます。

import { buttonStyles } from './button-styles.js';

class MyElement extends LitElement {
  static styles = [
    buttonStyles,
    css`
      :host { display: block;
        border: 1px solid black;
      }`
  ];
}

コンポーネントはスーパークラスからスタイルを継承し、独自のスタイルを追加することができます。

import { css } from 'lit';
import { customElement } from 'lit/decorators.js';
import { SuperElement } from './super-element.js';

@customElement('my-element')
export class MyElement extends SuperElement {
  static styles = [
    SuperElement.styles,
    css`div {
      color: red;
    }`
  ];
}


セレクター

コンポーネント自体のスタイルを設定には、特別なセレクターである :host を使います。

  • :host ホスト要素を選択
  • :host(selector) selectorに一致するホスト要素を選択
import {LitElement, html, css} from 'lit';
import {customElement} from 'lit/decorators/custom-element.js';

@customElement('my-element')
export class MyElement extends LitElement {
  static styles = css`
    :host {
      display: block;
      background-color: lightgray;
      padding: 8px;
    }
    :host(.blue) {
      background-color: aliceblue;
      color: darkgreen;
    }
  `;
  protected render() {
    return html`Hello World`;
  }
}


子コンポーネントのスタイリングには、::slotted を使います。

  • ::slotted(*) すべてのスロット付き要素に一致
  • ::slotted(p) スロット段落に一致
  • p ::slotted(*) が段落要素の子孫であるスロット付き要素に一致
import {LitElement, html, css} from 'lit';
import {customElement} from 'lit/decorators.js';

@customElement('my-element')
export class MyElement extends LitElement {
  static styles = css`
    ::slotted(*) { font-family: Roboto; }
    ::slotted(p) { color: blue; }
    div ::slotted(*) { color: red; }
  `;
  protected render() {
    return html`
      <slot></slot>
      <div><slot name="hi"></slot></div>
    `;
  }
}


まとめ

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

次回は ToDo リスト作成をチュートリアル形式で説明します。