カスタムディレクティブ
ディレクティブはテンプレートエクスプレッションをレンダリングする方法を変更することによってLitを拡張する関数です。 ディレクティブはステートを持つことができて、DOMにアクセスすることができて、テンプレートがDOMツリーに接続/切断されたことを検知することができて、レンダリング関数外から独立してエクスプレッションを更新することができます。だから、便利で応用範囲が広いです。
下記のように、テンプレートのエクスプレッションでディレクティブを使うことは関数を実行することと同じくらいシンプルです。
html`<div>
${fancyDirective('some text')}
</div>`
Litはrepeat()
とcache()
のようなビルトインディレクティブを用意しています。
カスタムディレクティブを作成することもできます。
ディレクティブは下記の2種類あります。
- 関数ディレクティブ
- クラスディレクティブ
関数ディレクティブは下記のようにレンダリングする値を返します。 関数ディレクティブは任意の引数を受け取ることができます。
export noVowels = (str) => str.replaceAll(/[aeiou]/ig,'x');
クラスディレクティブを使うと関数ディレクティブではできないことができるようになります。 クラスディレクティブは下記の用途で使用します。
- 直接レンダリングされたDOMにアクセスします。(例: レンダリングされたDOMを追加、削除、並べ替える)
- レンダリング間でステートを保持します。
- レンダリングの実行外でDOMを非同期で更新します。
- ディレクティブがDOMから切断される時にリソースをクリーンアップします。
ここからはクラスディレクティブについて解説します。
クラスディレクティブを生成する
下記の手順でクラスディレクティブを実装します。
- Directiveクラスを継承したクラスを実装します。
- そのクラスを
directive()
に渡してテンプレートのエクスプレッションで使うことができるディレクティブ関数を生成します。
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
にします。
詳しくは非同期ディレクティブを見てください。
クラスディレクティブのライフサイクル
クラスディレクティブは下記のビルトインライフサイクルメソッドを持ちます。
- コンストラクタで1回だけの初期化をします。
render()
で宣言的レンダリングをします。update()
で命令的DOMアクセスをします。
クラスディレクティブでは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つの引数を受け取ります。
- エクスプレッションに関連しているDOMを直接管理するためのAPIを持つ
Part
オブジェクト render()
の引数を含む配列
update()
メソッドはLitがレンダリング可能な値を返す必要があります。もしくは、再レンダリングの必要がない場合はnoChange
を返します。
通常、update()
メソッドは次の処理を行います。
- DOMからデータを取得して、それを使ってレンダリングする値を生成します。
Part
オブジェクトのelement
もしくはparentNode
プロパティを操作してDOMを命令的に更新します。 通常、この場合は、ディレクティブをレンダリングするために何もする必要がないことをLitに通知するためにupdate()
はnoChange
を返します。
Part
Part
オブジェクトはエクスプレッションの位置に対応するPart
オブジェクトになります。
- HTMLの子要素の位置にあるエクスプレッションではChildPartです。
- HTMLの属性の値の位置にあるエクスプレッションではAttributePartです。
- 真偽値(属性名の接頭辞が
?
)の値の位置にあるエクスプレッションではBooleanAttributePartです。 - イベントリスナ(属性名の接頭辞が
@
)の値の位置にあるエクスプレッションではEventPartです。 - プロパティ(属性名の接頭辞が
.
)の値の位置にあるエクスプレッションではPropertyPartです。 - HTMLタグ内にあるエクスプレッションではElementPartです。
コンストラクタの引数である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()
メソッド内でDOMを命令的に更新した。- 非同期ディレクティブで何もレンダリングしないので
update()
やrender()
で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を継承します。
AsyncDirective
はsetValue()
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を用意しています。
disconnected()
: ディレクティブが使われなくなった時に実行されます。ディレクティブインスタンスは下記の3つの場合にdisconnected()
を実行します。- ディレクティブを内包しているDOMツリーが上位のDOMツリーからdisconnectされた時
- ディレクティブのホストコンポーネントがdisconnectされた時
- ディレクティブを生成したエクスプレッションが引き続き同じディレクティブを適用しないかった時
メモリーリークを防ぐために、ディレクティブが
disconnected()
コールバックを実行した後、update()
やrender()
メソッドでsubscribeしたすべてのリソースを解放されている必要があります。reconnected()
: 以前にdisconnectされたディレクティブが再び使われた時に実行されます。 DOMのサブツリーは一時的に上位のDOMツリーからdisconnectされた後にreconnectすることがあります。 だから、disconnectされたディレクティブはreconnectされることに備える必要がある場合があります。 これの具体的な例としては削除されたDOMが後で使うとためにキャッシュされる場合や、ホスト要素が移動することでdisconnectとreconnectが起きる場合があります。 disconnectされたディレクティブが稼働状態になった時に対応するためにdisconnected()
とreconnected()
は常に両方とも実装されるべきです。isConnected
: ディレクティブのconnectの状態を表します。
Note that it is possible for an AsyncDirective
to continue receiving updates while it is disconnected if its containing tree is re-rendered.
メモリーリークを防ぐために、update
やrender
で長期間保持される資源を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:
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
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.
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.