カスタムディレクティブ

ディレクティブはテンプレートエクスプレッションをレンダリングする方法を変更することによってLitを拡張する関数です。 ディレクティブはステートを持つことができて、DOMにアクセスすることができて、テンプレートがDOMツリーに接続/切断されたことを検知することができて、レンダリング関数外から独立してエクスプレッションを更新することができます。だから、便利で応用範囲が広いです。

下記のように、テンプレートのエクスプレッションでディレクティブを使うことは関数を実行することと同じくらいシンプルです。

html`<div>
       ${fancyDirective('some text')}
     </div>`

Litはrepeat()cache()のようなビルトインディレクティブを用意しています。 カスタムディレクティブを作成することもできます。

ディレクティブは下記の2種類あります。

関数ディレクティブは下記のようにレンダリングする値を返します。 関数ディレクティブは任意の引数を受け取ることができます。

export noVowels = (str) => str.replaceAll(/[aeiou]/ig,'x');

クラスディレクティブを使うと関数ディレクティブではできないことができるようになります。 クラスディレクティブは下記の用途で使用します。

ここからはクラスディレクティブについて解説します。

クラスディレクティブを生成する

下記の手順でクラスディレクティブを実装します。

import {Directive, directive} from 'lit/directive.js';

// ディレクティブを定義します。
class HelloDirective extends Directive {
  render() {
    return `Hello!`;
  }
}
// ディレクティブ関数を生成します。
const hello = directive(HelloDirective);

// ディレクティブを使いします。
const template = html`<div>${hello()}</div>`;

上記のテンプレートが評価される時、 ディレクティブ関数(hello())はDirectiveResultオブジェクトを返します。 DirectiveResultオブジェクトはLitにクラスディレクティブ(HelloDirective)を生成もしくは更新するように命令します。 それから、Litはクラスディレクティブインスタンスのメソッドでその更新ロジックを実行します。

ディレクティブで通常の更新サイクル外でDOMを非同期に更新したい時があります。 非同期ディレクティブを生成するには、ベースクラスをDirectiveの代わりにAsyncDirectiveにします。 詳しくは非同期ディレクティブを見てください。

クラスディレクティブのライフサイクル

クラスディレクティブは下記のビルトインライフサイクルメソッドを持ちます。

クラスディレクティブではrender()を実装することは必須です。 update()はオプションです。 デフォルトのupdate()の実装はrender()を実行してその値を返します。

非同期ディレクティブを使うと、通常の更新サイクル外でDOMを更新することができます。 非同期ディレクティブには上記以外のライフサイクルメソッドが存在します。 詳しくは非同期ディレクティブを見てください。

1回だけ設定する: constructor()

Litが最初にエクスプレッション内のDirectiveResultを評価する時、 対応するクラスディレクティブのインスタンスを生成します。 (クラスディレクティブのコンストラクタを実行して、クラスフィールドを初期化します。)

class MyDirective extends Directive {
  // クラスフィールドは1回だけ初期化されます。これはレンダリング間で保持されます。
  value = 0;
  // コンストラクタはエクスプレッション内で使われるディレクティブで初回のみ実行されます。
  constructor(partInfo: PartInfo) {
    super(partInfo);
    console.log('MyDirective created');
  }
  ...
}

レンダー毎に同じエクスプレッションに同じディレクティブ関数を配置する限り、 1つ前のクラスディレクティブのインスタンスを再び使います。 そして、レンダリング間でクラスディレクティブのインスタンスのステートは保持されます。

コンストラクタはPartInfoオブジェクトを引数に取ります。 そのPartInfoオブジェクトはディレクティブが使われているエクスプレッションに関するメタデータを含んでいます。 これは使用されるエクスプレッションの種類を限定しているディレクティブがそれをチェックする際に利用することができます。 詳しくはディレクティブを使用することができるエクスプレッションの種類を1つに制限するを見てください。

ディレクティブのレンダリング: render()

render()メソッドはDOMにレンダリングする値を返す必要があります。 render()メソッドはDirectiveResultのようなレンダリング可能な値を返すこともできます。

下記のようにrender()はディレクティブインスタンスのステートを参照できるだけではなく、 render()メソッドはディレクティブ関数に渡された引数を引数として受け取ります。

const template = html`<div>${myDirective(name, rank)}</div>`

render()メソッドのパラメータの定義はディレクティブ関数のパラメータの定義になります。

class MaxDirective extends Directive {
  maxValue = Number.MIN_VALUE;
  // 下記のような引数を持つrender関数を定義します。
  render(value: number, minValue = Number.MIN_VALUE) {
    this.maxValue = Math.max(value, this.maxValue, minValue);
    return this.maxValue;
  }
}
const max = directive(MaxDirective);

// `render()`で定義されている`value`および`minValue`引数をディレクティブ関数に渡して実行します。
const template = html`<div>${max(someNumber, 0)}</div>`;

命令的DOMアクセス: update()

ディレクティブでディレクティブが配置されているDOMにアクセスして命令的にそれを読んだり変更したりする必要がある場合があるかもしれません。 update()メソッドをオーバーライドすればそれができます。

update()メソッドは下記の2つの引数を受け取ります。

update()メソッドはLitがレンダリング可能な値を返す必要があります。もしくは、再レンダリングの必要がない場合はnoChangeを返します。 通常、update()メソッドは次の処理を行います。

Part

Partオブジェクトはエクスプレッションの位置に対応するPartオブジェクトになります。

コンストラクタの引数であるPartInfoに格納されているエクスプレッションが配置されている位置のメタデータに加えて、 update()メソッドでは、すべてのPartオブジェクトはエクスプレッションに関連したDOM(elementもしくはparentElement)にアクセスすることができます。

// 属性名のリストを親要素のtextContentにレンダリングします。
class AttributeLogger extends Directive {
  attributeNames = '';
  update(part: ChildPart) {
    this.attributeNames = (part.parentNode as Element).getAttributeNames?.().join(' ');
    return this.render();
  }
  render() {
    return this.attributeNames;
  }
}
const attributeLogger = directive(AttributeLogger);

const template = html`<div a b>${attributeLogger()}</div>`;
// `<div a b>a b</div>`

In addition, the directive-helpers.js module includes a number of helper functions which act on Part objects, and can be used to dynamically create, insert, and move parts within a directive's ChildPart.

update()内でrender()を実行する

デフォルトのupdate()の実装は単にrender()の戻り値を返すだけです。 update()をオーバーライドしても値の生成にrender()を使って値を生成したい場合、update()内でrender()を実行する必要があります。

render()の引数は配列でupdate()に引数として渡されます。 そのrender()の引数は下記の様に定義します。

class MyDirective extends Directive {
  update(part: Part, [fish, bananas]: DirectiveParameters<this>) {
    // ...
    return this.render(fish, bananas);
  }
  render(fish: number, bananas: number) { ... }
}

update()とrender()の違い

update()メソッドはrender()メソッドよりできることが多いですが、 注意すべき点があります。 それは@lit-labs/ssrパッケージをサーバーサイドレンダリング(SSR)に使う際、サーバではrender()メソッドのみが実行される点です。 SSRとの互換性のために、 ディレクティブはrender()で値を返して、update()はDOMにアクセスする必要がある場合のみ使用してください。

変更がないことを伝える

ディレクティブのレンダリングをスキップして欲しい場合があるでしょう。 その場合はupdate()メソッドもしくはrender()メソッドでnoChangeを返します。 undefinedを返すとディレクティブに関連したPartをクリアされます。 noChangeを返すとレンダリング結果の変更をスキップされます。

下記はnoChangeを使う動機です。

下記の例では、 ディレクティブは前に渡された値を保持して、 それを使って変更を検知してディレクティブのレンダリング結果を更新する必要があるか判断しています。 update()もしくはrender()noChangeを返すことでディレクティブの再レンダリングが必要ないことを示すことができます。

import {Directive} from 'lit/directive.js';
import {noChange} from 'lit';
class CalculateDiff extends Directive {
  a?: string;
  b?: string;
  render(a: string, b: string) {
    if (this.a !== a || this.b !== b) {
      this.a = a;
      this.b = b;
      // 高コストのテキスト差分アルゴリズム
      return calculateDiff(a, b);
    }
    return noChange;
  }
}

ディレクティブを使用することができるエクスプレッションの種類を1つに制限する

ディレクティブの中には特定のコンテキスト(attribute expressionやchild expression等)でのみ使える物があります。 そのディレクティブが不適切な位置に配置された場合は適切なエラーを発生させるべきです。

下記の例ではclassMapディレクティブはclass属性の値の位置にのみに配置を制限しています。

class ClassMap extends Directive {
  constructor(partInfo: PartInfo) {
    super(partInfo);
    if (
      partInfo.type !== PartType.ATTRIBUTE ||
      partInfo.name !== 'class'
    ) {
      throw new Error('The `classMap` directive must be used in the `class` attribute');
    }
  }
  ...
}

非同期ディレクティブ

これまでの例ではディレクティブは同期的に動作します。 それらのディレクティブはそれらのrender()/update()メソッドから同期的に値を返します。 その値はコンポーネントのupdate()メソッドでDOMに反映されます。

ディレクティブのDOMの更新がネットワークイベントのような非同期のイベントに依存している場合、DOMを非同期で更新したいでしょう。

ディレクティブを非同期で更新するには、 AsyncDirectiveを継承します。 AsyncDirectivesetValue() APIを提供します。 setValue()を使うと、通常のテンプレートのupdate/renderサイクル外で、テンプレートエクスプレッション内にあるディレクティブを新しい値に置き換えることができます。

下記はPromiseの結果をレンダリングする簡単な非同期ディレクティブの例です。

import { directive } from 'lit/directive.js';
import { AsyncDirective } from 'lit/async-directive.js';

class ResolvePromise extends AsyncDirective {
  render(promise: Promise<unknown>) {
    Promise.resolve(promise).then((resolvedValue) => {
      // 非同期でレンダリングされます。
      this.setValue(resolvedValue);
    });
    // 非同期で3秒後にFooが表示されます。
    setTimeout(() => this.setValue('Foo'), 3000)
    // 同期でレンダリングされます。
    return `Waiting for promise to resolve`;
  }
}
export const resolvePromise = directive(ResolvePromise);

上記の例では、レンダリングされたテンプレートにWaiting for promise to resolveが表示されます Promiseが解決されると解決された値がsetValue()に渡されます。 そして、その値が表示されます。 setValue()が実行される毎、それに渡された値が表示されます。

非同期ディレクティブは外部リソースをsubscribeする用途によく使われます。 メモリーリークを防ぐために、 非同期ディレクティブのインスタンスが不要になった時にリソースをunsubscribeするか破棄する必要があります。 この用途のために、AsyncDirectiveは下記のライフサイクルコールバックとAPIを用意しています。

Note that it is possible for an AsyncDirective to continue receiving updates while it is disconnected if its containing tree is re-rendered. メモリーリークを防ぐために、updaterenderで長期間保持される資源をsubscribeする前に常にthis.isConnectedを確認する必要があります。

下記はObservableをsubscribeして適切にdisconnectionとreconnectionを処理する例です。

class ObserveDirective extends AsyncDirective {
  observable: Observable<unknown> | undefined;
  unsubscribe: (() => void) | undefined;
  // observableが変わった時、古いobservableをunsubscribeして、新しいobservableをsbuscribeします。
  render(observable: Observable<unknown>) {
    if (this.observable !== observable) {
      this.unsubscribe?.();
      this.observable = observable
      if (this.isConnected)  {
        this.subscribe(observable);
      }
    }
    return noChange;
  }
  // observableをsubscribeします。値が変わるたび、ディレクティブのsetValue()を実行します。
  subscribe(observable: Observable<unknown>) {
    this.unsubscribe = observable.subscribe((v: unknown) => {
      this.setValue(v);
    });
  }
  // ディレクティブがDOMからdisconnectされる時、確実にディレクティブインスタンスがガベージコレクトされるようにunsubscribeします。
  disconnected() {
    this.unsubscribe!();
  }
  // ディレクティブを内包するサブツリーがdisconnectされた後に再びconnectされた際にディレクティブを再度操作可能にするために再subscribeします。
  reconnected() {
    this.subscribe(this.observable!);
  }
}
export const observe = directive(ObserveDirective);

License

Japanese part

Creative Commons Attribution-NonCommercial 4.0 International Public License

Copyright (c) 2022 38elements

Other

Creative Commons Attribution 3.0 Unported

Copyright (c) 2020 Google LLC. All rights reserved.

BSD 3-Clause License

Copyright (c) 2020 Google LLC. All rights reserved.

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

  1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.

  2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.

  3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.