Lit とは
Lit は、任意のフレームワークで動作する高速で軽量なコンポーネントの開発に役立つ Google のオープンソース ライブラリで、共有可能なコンポーネントやアプリケーション、設計システムなどの構築に使用できます。
演習内容
以下について、React のコンセプトを Lit に変換する方法を学びます。
- JSX とテンプレート
- コンポーネントとプロパティ
- 状態とライフサイクル
- フック
- 子
- 参照
- 状態の仲介
達成目標
この Codelab を完了すると、React コンポーネントのコンセプトを Lit の対応するコンポーネントに変換できるようになります。
必要なもの
- Chrome、Safari、Firefox、または Edge の最新バージョン。
- HTML、CSS、JavaScript、Chrome DevTools に関する知識。
- React に関する知識。
- (上級者向け)最適な開発環境が必要な場合は、VS Code をダウンロードしてください。VS Code 用の lit-plugin と NPM も必要です。
Lit の中心となるコンセプトと機能は多くの点で React と似ていますが、次のような違いがあります。
サイズが小さい
Lit はサイズが小さく、約 5 KB に gzip 圧縮され軽量化されています。これに対し、React と ReactDOM は 40 KB を超えます。
処理が速い
Lit のテンプレート システム「lit-html」を React の VDOM と比較した公開ベンチマークでは、lit-html は React と比べ最低でも 8~10% 速く、一般的なユースケースでは 50% 以上速いという結果が出ています。
LitElement(Lit コンポーネントのベースクラス)により lit-html に最小限のオーバーヘッドが追加されますが、メモリ使用量、インタラクション、起動時間などのコンポーネントの機能を比較すると React のパフォーマンスを 16~30% 上回ります。
ビルドが不要
ES モジュールのような新しいブラウザ機能とタグ付きのテンプレート リテラルにより、Lit はコンパイルなしで実行できます。つまり、スクリプトタグ、ブラウザ、サーバーがあれば、開発環境を設定して運用できます。
ES モジュールと最新の CDN(Skypack、UNPKG など)があれば、NPM なしでも始められます。
必要な場合は、Lit コードを作成、最適化することも可能です。ネイティブ ES モジュールは、近年統合が進んでおり、標準の JavaScript であり、フレームワーク固有の CLI やビルド処理の必要がない Lit を使用するメリットは大きくなっています。
フレームワークに依存しない
Lit コンポーネントは、「Web Components」と呼ばれる一連のウェブ標準仕様に沿って構築されています。つまり、Lit で作成したコンポーネントは、現在だけでなく今後リリースされるフレームワークでも動作します。HTML 要素をサポートしているということは、Web Components もサポートしているということです。
フレームワークの相互運用において唯一問題が生じるのは、フレームワークで DOM のサポートが制限されている場合です。React はこうしたフレームワークの 1 つで、参照によるエスケープ ハッチが許可されていますが、こうした手法は効率的ではありません。
Lit チームでは、@lit-labs/react
という実験的なプロジェクトに取り組んでおり、Lit コンポーネントを自動解析して React ラッパーを生成することで、参照の使用を回避できるよう目指しています。
また、Custom Elements Everywhere では、カスタム要素が適切に動作するフレームワークやライブラリを確認できます。
TypeScript の高度なサポート
Lit コードはすべて JavaScript で記述できますが、Lit 自体は TypeScript で記述されているため、デベロッパーも TypeScript を使用することが推奨されています。
Lit チームでは Lit コミュニティと協力し、lit-analyzer
や lit-plugin
を使用した開発およびビルド時に Lit テンプレートでの TypeScript の型チェックや入力支援を行うプロジェクトをサポートしています。
ブラウザに組み込みの DevTools
Lit コンポーネントは DOM の HTML 要素です。つまり、コンポーネントを検証するために、ブラウザにツールや拡張機能をインストールする必要はありません。
DevTools を開いて要素を選択すれば、そのプロパティや状態を確認できます。
を、$0.value は「hello world」を、$0.outlined は true をそれぞれ返し、{$0} にはプロパティが展開表示されています" class="l10n-relative-url-src" l10n-attrs-original-order="alt,src,class" src="https://codelabs.developers.google.com/codelabs/lit-2-for-react-devs/./img/browser-tools.png" />
サーバーサイド レンダリング(SSR)を考慮した設計
Lit 2 は SSR に対応できるように構築されています。この Codelab を作成した時点では、SSR ツールの安定版はまだリリースされていませんが、サーバーサイドでレンダリングされるコンポーネントは Google サービス全体ですでに実装されています。これらのツールは GitHub で近日中に一般リリースされる予定です。
それまでの間、進捗状況はこちらでご確認いただけます。
導入しやすい
Lit は大きな負担なく使用できます。Lit で作成したコンポーネントを既存のプロジェクトに追加することも可能です。Web Components は他のフレームワークでも動作するため、必要なければ、アプリ全体を一度に変換しなくても構いません。
アプリ全体を Lit で作成していて、フレームワークを別のものに変更したい場合は、現在の Lit アプリケーションを新しいフレームワーク内に置いて、必要な要素だけを新しいフレームワークのコンポーネントに移行できます。
また、Web Components への出力は、最新の多くのフレームワークでサポートされているので、通常は Lit 要素内に含めることができます。
この Codelab の演習は、次の 2 つの方法で行うことができます。
- ブラウザを使用してすべてオンラインで行う
- (上級者向け)VS Code を使用してローカルのパソコンで行う
コードへのアクセス
この Codelab では、次のような Lit 環境へのリンクが設けられています。
この環境は、ブラウザで完全に動作するコード サンドボックスです。TypeScript や JavaScript のファイルをコンパイルして実行できるだけでなく、次のようなノード モジュールへの読み込みを自動的に解決することも可能です。
// before
import './my-file.js';
import 'lit';
// after
import './my-file.js';
import 'https://cdn.skypack.dev/lit';
これらのチェックポイントを出発点に、Lit 環境でチュートリアル全体を実施できます。VS Code を使用する場合は、これらのチェックポイントから任意のステップの開始用コードをダウンロードしたり、そのコードを使って製品をチェックしたりできます。
Lit 環境の UI
Lit 環境の UI のスクリーンショットで、この Codelab で使用するセクションがハイライト表示されています。
- ファイル選択ツール。プラスボタンをご確認ください。
- ファイル エディタ。
- コード プレビュー。
- 再読み込みボタン。
- ダウンロード ボタン。
VS Code の設定(上級者向け)
この VS Code の設定を使用すると、次の機能を使用できます。
- テンプレートの型チェック
- テンプレートの入力支援とオートコンプリート
NPM と VS Code(および lit-plugin プラグイン)がインストール済みで、これらの環境の使用方法をご存知の場合は、次の手順でプロジェクトをダウンロードして開始できます。
- ダウンロード ボタンを押す
- tar ファイルの中身をディレクトリに展開する
- (TS の場合)ES モジュールを出力する quick tsconfig と ES2015 以降を設定する
- ベアモジュール指定子(Lit チームの推奨は @web/dev-server)を解決できる開発サーバーをインストールする
- 開発サーバーを実行し、ブラウザを開く(@web/dev-serve の場合は
web-dev-server --node-resolve --watch --open
を使用できます)
このセクションでは、Lit のテンプレートの基本を学びます。
JSX と Lit テンプレート
React では、JavaScript の構文を拡張した JSX を使用して、JavaScript コードでテンプレートを簡単に記述できます。Lit テンプレートも同じような用途で使用し、コンポーネントの UI をその状態の関数として表すことができます。
基本的な構文
React では、JSX の「hello world」を次のようにレンダリングします。
import 'react';
import ReactDOM from 'react-dom';
const name = 'Josh Perez';
const element = (
<>
<h1>Hello, {name}</h1>
<div>How are you?</div>
</>
);
ReactDOM.render(
element,
mountNode
);
上の例では 2 つの要素と「name」変数を使用しています。Lit では、次のように記述します。
import {html, render} from 'lit';
const name = 'Josh Perez';
const element = html`
<h1>Hello, ${name}</h1>
<div>How are you?</div>`;
render(
element,
mountNode
);
Lit テンプレートでは、テンプレートの複数の要素をグループ化する際に React Fragment は必要ありません。
Lit では、テンプレートは html
タグ付きのテンプレート リテラル(Literal)でラップされます。ちなみにこれが Lit の名称の由来です。
テンプレートの値
Lit テンプレートには、TemplateResult
という形で、他の Lit テンプレートを組み込むことができます。たとえば、name
を斜体(<i>
)タグでラップし、それをさらにタグ付きのテンプレート リテラル N.B. でラップします。その際は、一重引用('
)ではなく逆引用符(`
)を必ず使用してください。
import {html, render} from 'lit';
const name = html`<i>Josh Perez</i>`;
const element = html`
<h1>Hello, ${name}</h1>
<div>How are you?</div>`;
render(
element,
mountNode
);
Lit の TemplateResult
では、配列、文字列、他の TemplateResult
、およびディレクティブを受け取ることができます。
演習として、次の React コードを Lit に変換してみましょう。
const itemsToBuy = [
<li>Bananas</li>,
<li>oranges</li>,
<li>apples</li>,
<li>grapes</li>
];
const element = (
<>
<h1>Things to buy:</h1>
<ol>
{itemsToBuy}
</ol>
</>);
ReactDOM.render(
element,
mountNode
);
解答:
import {html, render} from 'lit';
const itemsToBuy = [
html`<li>Bananas</li>`,
html`<li>oranges</li>`,
html`<li>apples</li>`,
html`<li>grapes</li>`
];
const element = html`
<h1>Things to buy:</h1>
<ol>
${itemsToBuy}
</ol>`;
render(
element,
mountNode
);
プロパティを渡して設定する
JSX と Lit の構文の主な違いの 1 つは、データ バインディング構文です。バインディングを使用した次の React の input を例に説明します。
const disabled = false;
const label = 'my label';
const myClass = 'my-class';
const value = 'my value';
const element =
<input
disabled={disabled}
className={`static-class ${myClass}`}
defaultValue={value}/>;
ReactDOM.render(
element,
mountNode
);
上の例では、input に対して以下の処理を行うよう定義しています。
- disabled を定義済みの変数(この例では false)に設定する
- クラスを
static-class
と変数(この例では"static-class my-class"
)に設定する - デフォルト値を設定する
Lit では、次のように記述します。
import {html, render} from 'lit';
const disabled = false;
const label = 'my label';
const myClass = 'my-class';
const value = 'my value';
const element = html`
<input
?disabled=${disabled}
class="static-class ${myClass}"
.value=${value}>`;
render(
element,
mountNode
);
Lit では、まず disabled
属性を切り替えるため、ブール値のバインディングを追加します。
次に、className
ではなく class
属性に直接バインドします。class
属性には複数のバインディングを追加できます。ただし、クラスの切り替えに宣言型ヘルパーの classMap
ディレクティブを使用している場合は除きます。
最後に、input に対して value
プロパティを設定します。React とは異なり、input 要素は読み取り専用には設定されず、input のネイティブの実装と動作に従います。
Lit のプロパティ バインディング構文
html`<my-element ?attribute-name=${booleanVar}>`;
?
プレフィックスは要素の属性を切り替えるバインディング構文inputRef.toggleAttribute('attribute-name', booleanVar)
に相当disabled
を使用する要素に有効(inputElement.hasAttribute('disabled') === true
により、disabled="false"
は引き続き DOM で true と読み取られるため)
html`<my-element .property-name=${anyVar}>`;
.
プレフィックスは要素のプロパティを設定するバインディング構文inputRef.propertyName = anyVar
に相当- オブジェクト、配列、クラスなどの複雑なデータを渡すのに最適
html`<my-element attribute-name=${stringVar}>`;
- 要素の属性にバインド
inputRef.setAttribute('attribute-name', stringVar)
に相当- 基本的な値、スタイルルール セレクタ、クエリセレクタに最適
ハンドラを渡す
const disabled = false;
const label = 'my label';
const myClass = 'my-class';
const value = 'my value';
const element =
<input
onClick={() => console.log('click')}
onChange={e => console.log(e.target.value)} />;
ReactDOM.render(
element,
mountNode
);
上の例では、input に対して以下の処理を行うよう定義しています。
- input のクリック時に「click」と記録する
- ユーザーが文字を入力したときに input の値を記録する
Lit では、次のように記述します。
import {html, render} from 'lit';
const disabled = false;
const label = 'my label';
const myClass = 'my-class';
const value = 'my value';
const element = html`
<input
@click=${() => console.log('click')}
@input=${e => console.log(e.target.value)}>`;
render(
element,
mountNode
);
Lit では、まず @click
を使用して click
イベントにリスナーを追加します。
次に、onChange
を使用する代わりに、<input>
のネイティブの input
イベントにバインドします。これは、ネイティブの change
イベントは blur
の発生時にのみ呼び出されるためです(React ではこれらのイベントは抽象化されます)。
Lit のイベント ハンドラ構文
html`<my-element @event-name=${() => {...}}></my-element>`;
@
プレフィックスはイベント リスナーのバインディング構文inputRef.addEventListener('event-name', ...)
に相当- ネイティブの DOM イベント名を使用
このセクションでは、Lit のクラス コンポーネントと関数について学びます。状態とフックについては、後述のセクションで詳しく確認します。
クラス コンポーネントと LitElement
React のクラス コンポーネントに相当する Lit の要素は LitElement です。Lit の「リアクティブ プロパティ」のコンセプトは、React のプロパティと状態を組み合わせたものになります。たとえば次のコードがあるとします。
import React from 'react';
import ReactDOM from 'react-dom';
class Welcome extends React.Component {
constructor(props) {
super(props);
this.state = {name: ''};
}
render() {
return <h1>Hello, {this.props.name}</h1>;
}
}
const element = <Welcome name="Elliott"/>
ReactDOM.render(
element,
mountNode
);
上の例の React コンポーネントでは、以下の処理を行っています。
name
をレンダリングするname
のデフォルト値を空の文字列(""
)に設定するname
を"Elliott"
に割り当て直す
LitElement では、次のように記述します。
TypeScript の場合:
import {LitElement, html} from 'lit';
import {customElement, property} from 'lit/decorators.js';
@customElement('welcome-banner')
class WelcomeBanner extends LitElement {
@property({type: String})
name = '';
render() {
return html`<h1>Hello, ${this.name}</h1>`
}
}
JavaScript の場合:
import {LitElement, html} from 'lit';
class WelcomeBanner extends LitElement {
static get properties() {
return {
name: {type: String}
}
}
constructor() {
super();
this.name = '';
}
render() {
return html`<h1>Hello, ${this.name}</h1>`
}
}
customElements.define('welcome-banner', WelcomeBanner);
HTML ファイルの場合:
<!-- index.html -->
<head>
<script type="module" src="./index.js"></script>
</head>
<body>
<welcome-banner name="Elliott"></welcome-banner>
</body>
上の例で行っている処理の再確認:
@property({type: String})
name = '';
- パブリックなリアクティブ プロパティ(コンポーネントのパブリック API の一部)を定義する
- コンポーネントの属性(デフォルト)とプロパティをエクスポーズする
- コンポーネントの属性(文字列)を値に変換する方法を定義する
static get properties() {
return {
name: {type: String}
}
}
@property
TS デコレータと同じ働きをするが、JavaScript でネイティブに実行される
render() {
return html`<h1>Hello, ${this.name}</h1>`
}
- リアクティブ プロパティが変更されるたびに呼び出される
@customElement('welcome-banner')
class WelcomeBanner extends LitElement {
...
}
- HTML 要素タグ名をクラスの定義に関連付ける
- カスタム要素の標準仕様に沿って、タグ名にハイフン(-)を含める必要がある
- LitElement の
this
は、カスタム要素のインスタンス(この例では<welcome-banner>
)を参照する
customElements.define('welcome-banner', WelcomeBanner);
@customElement
TS デコレータに相当する JavaScript
<head>
<script type="module" src="./index.js"></script>
</head>
- カスタム要素の定義を読み込む
<body>
<welcome-banner name="Elliott"></welcome-banner>
</body>
- カスタム要素をページに追加する
name
プロパティを'Elliott'
に設定する
関数コンポーネント
Lit では、JSX やプリプロセッサを使用しないため、関数コンポーネントに相当するものはありません。しかしながら、プロパティを取り込み、そのプロパティに基づいて DOM をレンダリングする関数は簡単に作成できます。たとえば、次のコードがあるとします。
function Welcome(props) {
return <h1>Hello, {props.name}</h1>;
}
const element = <Welcome name="Elliott"/>
ReactDOM.render(
element,
mountNode
);
Lit では、次のように記述します。
import {html, render} from 'lit';
function Welcome(props) {
return html`<h1>Hello, ${props.name}</h1>`;
}
render(
Welcome({name: 'Elliott'}),
document.body.querySelector('#root')
);
このセクションでは、Lit の状態とライフサイクルについて学びます。
状態
Lit の「リアクティブ プロパティ」のコンセプトは、React の状態とプロパティを組み合わせたものになります。リアクティブ プロパティでは、変更時にコンポーネントのライフサイクルをトリガーできます。リアクティブ プロパティには 2 つの種類があります。
パブリックなリアクティブ プロパティ
// React
import React from 'react';
class MyEl extends React.Component {
constructor(props) {
super(props)
this.state = {name: 'there'}
}
componentWillReceiveProps(nextProps) {
if (this.props.name !== nextProps.name) {
this.setState({name: nextProps.name})
}
}
}
// Lit (TS)
import {LitElement} from 'lit';
import {property} from 'lit/decorators.js';
class MyEl extends LitElement {
@property() name = 'there';
}
@property
で定義する- React のプロパティと状態に似ているが変更可能
- コンポーネントのユーザーによってアクセスおよび設定されるパブリック API
内部のリアクティブの状態
// React
import React from 'react';
class MyEl extends React.Component {
constructor(props) {
super(props)
this.state = {name: 'there'}
}
}
// Lit (TS)
import {LitElement} from 'lit';
import {state} from 'lit/decorators.js';
class MyEl extends LitElement {
@state() name = 'there';
}
@state
で定義する- React の状態に似ているが変更可能
- 通常はコンポーネントまたはサブクラス内からアクセスされるプライベートな内部の状態
ライフサイクル
Lit のライフサイクルは React のライフサイクルとよく似ていますが、重要な違いがいくつかあります。
constructor
// React (js)
import React from 'react';
class MyEl extends React.Component {
constructor(props) {
super(props);
this.state = { counter: 0 };
this._privateProp = 'private';
}
}
// Lit (ts)
class MyEl extends LitElement {
@property({type: Number}) counter = 0;
private _privateProp = 'private';
}
// Lit (js)
class MyEl extends LitElement {
static get properties() {
return { counter: {type: Number} }
}
constructor() {
this.counter = 0;
this._privateProp = 'private';
}
}
- 相当する Lit の要素も
constructor
- super の呼び出しには何も渡さなくてよい
- 呼び出し元(これらに限りません):
document.createElement
document.innerHTML
new ComponentClass()
- アップグレードされていないタグ名がページに存在する場合は、定義が読み込まれて
@customElement
またはcustomElements.define
で登録される
- React の
constructor
と同様の働き
render
// React
render() {
return <div>Hello World</div>
}
// Lit
render() {
return html`<div>Hello World</div>`;
}
- 相当する Lit の要素も
render
TemplateResult
やstring
など、レンダリング可能な任意の結果を返すことができる- React と同様、
render()
は純粋な関数である必要がある createRenderRoot()
から返されるノードにレンダリングされる(デフォルトはShadowRoot
)
componentDidMount
Lit で componentDidMount
と同様の働きをするのは、firstUpdated
と connectedCallback
のライフサイクル コールバックの組み合わせです。
firstUpdated
import Chart from 'chart.js';
// React
componentDidMount() {
this._chart = new Chart(this.chartElRef.current, {...});
}
// Lit
firstUpdated() {
this._chart = new Chart(this.chartEl, {...});
}
- コンポーネントのテンプレートがコンポーネントのルートに初めてレンダリングされたときに呼び出される
- 要素が接続されている場合にのみ呼び出される(たとえば、そのノードが DOM ツリーに追加されるまで
document.createElement('my-component')
を介した呼び出しは行われない) - レンダリングする DOM が必要なコンポーネントを設定するのに最適
- React の
componentDidMount
と異なり、firstUpdated
のリアクティブ プロパティが変更されるとレンダリングが再度トリガーされるが、ブラウザでは通常、変更が同じフレームにバッチ処理される。ルートの DOM へのアクセスを必要としない変更の場合、通常はwillUpdate
に渡される
connectedCallback
// React
componentDidMount() {
this.window.addEventListener('resize', this.boundOnResize);
}
// Lit
connectedCallback() {
super.connectedCallback();
this.window.addEventListener('resize', this.boundOnResize);
}
- カスタム要素が DOM ツリーに挿入されるたびに呼び出される
- React コンポーネントと異なり、カスタム要素は DOM からデタッチされても破棄されないため、複数回「接続」できる
- DOM の再初期化や、切断時に削除されたイベント リスナーの再アタッチに有効
- 注:
connectedCallback
はfirstUpdated
の前に呼び出されることがあるため、初回の呼び出し時には DOM を使用できない可能性がある
componentDidUpdate
// React
componentDidUpdate(prevProps) {
if (this.props.title !== prevProps.title) {
this._chart.setTitle(this.props.title);
}
}
// Lit (ts)
updated(prevProps: PropertyValues<this>) {
if (prevProps.has('title')) {
this._chart.setTitle(this.title);
}
}
- 相当する Lit の要素は
updated
(英語の「update」の過去形を使用) - React と異なり、
updated
は初回レンダリング時にも呼び出される - React の
componentDidUpdate
と同様の働き
componentWillUnmount
// React
componentWillUnmount() {
this.window.removeEventListener('resize', this.boundOnResize);
}
// Lit
disconnectedCallback() {
super.disconnectedCallback();
this.window.removeEventListener('resize', this.boundOnResize);
}
- 相当する Lit の同様の要素は
disconnectedCallback
- React コンポーネントと異なり、カスタム要素を DOM からデタッチしてもコンポーネントは破棄されない
componentWillUnmount
と異なり、disconnectedCallback
は要素がツリーから削除された後に呼び出される- ルート内の DOM は引き続きルートのサブツリーにアタッチされる
- イベント リスナーやメモリリークを起こしやすい参照を削除して、ブラウザでコンポーネントのガベージ コレクションを行えるようにするのに有効
演習
import React from 'react';
import ReactDOM from 'react-dom';
class Clock extends React.Component {
constructor(props) {
super(props);
this.state = {date: new Date()};
}
componentDidMount() {
this.timerID = setInterval(
() => this.tick(),
1000
);
}
componentWillUnmount() {
clearInterval(this.timerID);
}
tick() {
this.setState({
date: new Date()
});
}
render() {
return (
<div>
<h1>Hello, world!</h1>
<h2>It is {this.state.date.toLocaleTimeString()}.</h2>
</div>
);
}
}
ReactDOM.render(
<Clock />,
document.getElementById('root')
);
上の例では、シンプルな時計を表示して、次の処理を行っています。
- 「Hello World! It is」をレンダリングした後、時刻を表示する
- 時計を毎秒更新する
- マウントが解除されたら、tick を呼び出してタイマーを停止する
まず、コンポーネントのクラスを宣言します。
// Lit (TS)
// some imports here are imported in advance
import {LitElement, html} from 'lit';
import {customElement, state} from 'lit/decorators.js';
@customElement('lit-clock')
class LitClock extends LitElement {
}
// Lit (JS)
// `html` is imported in advance
import {LitElement, html} from 'lit';
class LitClock extends LitElement {
}
customElements.define('lit-clock', LitClock);
次に、date
を初期化して、@state
で内部のリアクティブ プロパティであることを宣言します。これは、コンポーネントのユーザーが date
を直接設定することはないためです。
// Lit (TS)
import {LitElement, html} from 'lit';
import {customElement, state} from 'lit/decorators.js';
@customElement('lit-clock')
class LitClock extends LitElement {
@state() // declares internal reactive prop
private date = new Date(); // initialization
}
// Lit (JS)
import {LitElement, html} from 'lit';
class LitClock extends LitElement {
static get properties() {
return {
// declares internal reactive prop
date: {state: true}
}
}
constructor() {
super();
// initialization
this.date = new Date();
}
}
customElements.define('lit-clock', LitClock);
次に、テンプレートをレンダリングします。
// Lit (JS & TS)
render() {
return html`
<div>
<h1>Hello, World!</h1>
<h2>It is ${this.date.toLocaleTimeString()}.</h2>
</div>
`;
}
次に、tick メソッドを実装します。
tick() {
this.date = new Date();
}
続けて、componentDidMount
を実装します。繰り返しになりますが、Lit でこれに相当するのは firstUpdated
と connectedCallback
の組み合わせです。このコンポーネントの場合、setInterval
で tick
を呼び出す際にルート内の DOM にアクセスする必要はありません。また、要素がドキュメント ツリーから削除されるときはタイマーが停止されるので、再アタッチする場合はタイマーをもう一度開始する必要があります。したがって、ここでは connectedCallback
の方が適しています。
// Lit (TS)
@customElement('lit-clock')
class LitClock extends LitElement {
@state()
private date = new Date();
private timerId = -1; // initialize timerId for TS
connectedCallback() {
super.connectedCallback();
this.timerId = setInterval(
() => this.tick(),
1000
);
}
...
}
// Lit (JS)
constructor() {
super();
// initialization
this.date = new Date();
this.timerId = -1; // initialize timerId for JS
}
connectedCallback() {
super.connectedCallback();
this.timerId = setInterval(
() => this.tick(),
1000
);
}
最後に、タイマーを停止して、要素がドキュメント ツリーから切断された後に tick が実行されないようにします。
// Lit (TS & JS)
disconnectedCallback() {
super.disconnectedCallback();
clearInterval(this.timerId);
}
ここまでのコードをまとめると、次のようになります。
// Lit (TS)
import {LitElement, html} from 'lit';
import {customElement, state} from 'lit/decorators.js';
@customElement('lit-clock')
class LitClock extends LitElement {
@state()
private date = new Date();
private timerId = -1;
connectedCallback() {
super.connectedCallback();
this.timerId = setInterval(
() => this.tick(),
1000
);
}
tick() {
this.date = new Date();
}
render() {
return html`
<div>
<h1>Hello, World!</h1>
<h2>It is ${this.date.toLocaleTimeString()}.</h2>
</div>
`;
}
disconnectedCallback() {
super.disconnectedCallback();
clearInterval(this.timerId);
}
}
// Lit (JS)
import {LitElement, html} from 'lit';
class LitClock extends LitElement {
static get properties() {
return {
date: {state: true}
}
}
constructor() {
super();
this.date = new Date();
}
connectedCallback() {
super.connectedCallback();
this.timerId = setInterval(
() => this.tick(),
1000
);
}
tick() {
this.date = new Date();
}
render() {
return html`
<div>
<h1>Hello, World!</h1>
<h2>It is ${this.date.toLocaleTimeString()}.</h2>
</div>
`;
}
disconnectedCallback() {
super.disconnectedCallback();
clearInterval(this.timerId);
}
}
customElements.define('lit-clock', LitClock);
このセクションでは、React のフックのコンセプトを Lit に変換する方法について学びます。
React のフックのコンセプト
React のフックは、関数コンポーネントで状態を「フック」できるようにする機能です。フックを使用すると、さまざまなメリットがあります。
- ステートフルなロジックを簡単に再利用できる
- より小さな関数にコンポーネントを分割できる
さらに、関数ベースのコンポーネントを主に使用することで、React のクラスベースの構文で生じる次のような問題を解決できます。
props
をconstructor
からsuper
に渡す必要があるconstructor
でのプロパティの初期化が整理されていない- 当時 React チームがフック導入の理由として挙げたものの、ES2019 で解決済み
this
がコンポーネントを参照しなくなったことにより生じる問題
Lit における React のフックのコンセプト
「コンポーネントとプロパティ」のセクションで学んだように、Lit では関数からカスタム要素を作成することはできませんが、LitElement を使用することで、React のクラス コンポーネントに関する主な問題のほとんどに対処できます。たとえば、次のコードがあるとします。
// React (at the time of making hooks)
import React from 'react';
import ReactDOM from 'react-dom';
class MyEl extends React.Component {
constructor(props) {
super(props); // Leaky implementation
this.state = {count: 0};
this._chart = null; // Deemed messy
}
render() {
return (
<>
<div>Num times clicked {count}</div>
<button onClick={this.clickCallback}>click me</button>
</>
);
}
clickCallback() {
// Errors because `this` no longer refers to the component
this.setState({count: this.count + 1});
}
}
// Lit (ts)
class MyEl extends LitElement {
@property({type: Number}) count = 0; // No need for constructor to set state
private _chart = null; // Public class fields introduced to JS in 2019
render() {
return html`
<div>Num times clicked ${count}</div>
<button @click=${this.clickCallback}>click me</button>`;
}
private clickCallback() {
// No error because `this` refers to component
this.count++;
}
}
Lit では、こうした問題に次のように対処しています。
constructor
は引数を取らない@event
バインディングはすべてthis
に自動バインドする- ほとんどのケースで
this
はカスタム要素の参照を指す - クラスのプロパティをクラスの構成要素としてインスタンス化できる(これに伴い、コンストラクタベースの実装を削除)
リアクティブ コントローラ
Lit では、フックの主要なコンセプトは、リアクティブ コントローラとして実現されています。リアクティブ コントローラ パターンを使用すると、ステートフルなロジックを共有したり、コンポーネントをより多くの小さなモジュールに分割したり、要素の更新ライフサイクルにフックしたりできます。
リアクティブ コントローラは、LitElement などのコントローラ ホストの更新ライフサイクルにフックできるオブジェクト インターフェースです。
ReactiveController
と reactiveControllerHost
のライフサイクルは次のとおりです。
interface ReactiveController {
hostConnected(): void;
hostUpdate(): void;
hostUpdated(): void;
hostDisconnected(): void;
}
interface ReactiveControllerHost {
addController(controller: ReactiveController): void;
removeController(controller: ReactiveController): void;
requestUpdate(): void;
readonly updateComplete: Promise<boolean>;
}
リアクティブ コントローラを作成し、addController
を使ってホストにアタッチすることで、コントローラのライフサイクルがホストのライフサイクルとともに呼び出されます。「状態とライフサイクル」のセクションの時計を例に説明します。
import React from 'react';
import ReactDOM from 'react-dom';
class Clock extends React.Component {
constructor(props) {
super(props);
this.state = {date: new Date()};
}
componentDidMount() {
this.timerID = setInterval(
() => this.tick(),
1000
);
}
componentWillUnmount() {
clearInterval(this.timerID);
}
tick() {
this.setState({
date: new Date()
});
}
render() {
return (
<div>
<h1>Hello, world!</h1>
<h2>It is {this.state.date.toLocaleTimeString()}.</h2>
</div>
);
}
}
ReactDOM.render(
<Clock />,
document.getElementById('root')
);
上の例では、シンプルな時計を表示して、次の処理を行っています。
- 「Hello World! It is」をレンダリングした後、時刻を表示する
- 時計を毎秒更新する
- マウントが解除されたら、tick を呼び出してタイマーを停止する
コンポーネントの基盤の作成
まず、コンポーネントのクラスを宣言してから、render
関数を追加します。
// Lit (TS) - index.ts
import {LitElement, html} from 'lit';
import {customElement} from 'lit/decorators.js';
@customElement('my-element')
class MyElement extends LitElement {
render() {
return html`
<div>
<h1>Hello, world!</h1>
<h2>It is ${'time to get Lit'}.</h2>
</div>
`;
}
}
// Lit (JS) - index.js
import {LitElement, html} from 'lit';
class MyElement extends LitElement {
render() {
return html`
<div>
<h1>Hello, world!</h1>
<h2>It is ${'time to get Lit'}.</h2>
</div>
`;
}
}
customElements.define('my-element', MyElement);
コントローラの作成
次に、clock.ts
に切り替えて、ClockController
のクラスを作成し、constructor
を設定します。
// Lit (TS) - clock.ts
import {ReactiveController, ReactiveControllerHost} from 'lit';
export class ClockController implements ReactiveController {
private readonly host: ReactiveControllerHost;
constructor(host: ReactiveControllerHost) {
this.host = host;
host.addController(this);
}
hostConnected() {
}
private tick() {
}
hostDisconnected() {
}
// Will not be used but needed for TS compilation
hostUpdate() {};
hostUpdated() {};
}
// Lit (JS) - clock.js
export class ClockController {
constructor(host) {
this.host = host;
host.addController(this);
}
hostConnected() {
}
tick() {
}
hostDisconnected() {
}
}
リアクティブ コントローラは、ReactiveController
インターフェースを共有している限り、どのような方法でも作成できます。しかしながら、多くの基本的なケースでは、クラスで constructor
を使用して、ReactiveControllerHost
インターフェースだけでなく、コントローラの初期化に必要な他のプロパティを取り込めるようにすることが推奨されます。
ここで、React のライフサイクル コールバックをコントローラ コールバックに変換する必要があります。つまり、以下の変換を行います。
componentDidMount
- LitElement の
connectedCallback
に変換 - コントローラの
hostConnected
に変換
- LitElement の
ComponentWillUnmount
- LitElement の
disconnectedCallback
に変換 - コントローラの
hostDisconnected
に変換
- LitElement の
React のライフサイクルを Lit のライフサイクルに変換する方法について詳しくは、「状態とライフサイクル」のセクションをご覧ください。
次に、「状態とライフサイクル」のセクションの例で行ったとおり、hostConnected
コールバックと tick
メソッドを実装して、hostDisconnected
でタイマーを停止します。
// Lit (TS) - clock.ts
export class ClockController implements ReactiveController {
private readonly host: ReactiveControllerHost;
private interval = 0;
date = new Date();
constructor(host: ReactiveControllerHost) {
this.host = host;
host.addController(this);
}
hostConnected() {
this.interval = setInterval(() => this.tick(), 1000);
}
private tick() {
this.date = new Date();
}
hostDisconnected() {
clearInterval(this.interval);
}
hostUpdate() {};
hostUpdated() {};
}
// Lit (JS) - clock.js
export class ClockController {
interval = 0;
host;
date = new Date();
constructor(host) {
this.host = host;
host.addController(this);
}
hostConnected() {
this.interval = setInterval(() => this.tick(), 1000);
}
tick() {
this.date = new Date();
}
hostDisconnected() {
clearInterval(this.interval);
}
}
コントローラの使用
作成した時計コントローラを使用するには、コントローラを読み込み、index.ts
または index.js
でコンポーネントを更新します。
// Lit (TS) - index.ts
import {LitElement, html, ReactiveController, ReactiveControllerHost} from 'lit';
import {customElement} from 'lit/decorators.js';
import {ClockController} from './clock.js';
@customElement('my-element')
class MyElement extends LitElement {
private readonly clock = new ClockController(this); // Instantiate
render() {
// Use controller
return html`
<div>
<h1>Hello, world!</h1>
<h2>It is ${this.clock.date.toLocaleTimeString()}.</h2>
</div>
`;
}
}
// Lit (JS) - index.js
import {LitElement, html} from 'lit';
import {ClockController} from './clock.js';
class MyElement extends LitElement {
clock = new ClockController(this); // Instantiate
render() {
// Use controller
return html`
<div>
<h1>Hello, world!</h1>
<h2>It is ${this.clock.date.toLocaleTimeString()}.</h2>
</div>
`;
}
}
customElements.define('my-element', MyElement);
コントローラを使用するには、コントローラ ホストへの参照(<my-element>
コンポーネント)を渡してコントローラをインスタンス化してから、render
メソッドでコントローラを使用する必要があります。
コントローラでの再レンダリングのトリガー
以下のコードでは、時刻は表示されますが、更新されません。これは、コントローラで日付が毎秒設定されているものの、ホストで更新が行われていないためです。date
が ClockController
クラスで変更されていて、コンポーネントでなくなっていることがその原因です。このため、date
をコントローラに設定したら、host.requestUpdate()
を使用して、ホストに更新ライフサイクルを実行するよう指示する必要があります。
// Lit (TS & JS) - clock.ts / clock.js
private tick() {
this.date = new Date();
this.host.requestUpdate();
}
これで、時計の時刻が更新されるようになります。
一般的な用途とフックの詳細な比較については、「上級者向けトピック - フック」のセクションをご覧ください。
このセクションでは、Lit でスロットを使用して子を管理する方法について学びます。
スロットと子
スロットを使用すると、コンポーネントをネストして、コンポジションを作成できます。
React では、プロパティを介して子が継承されます。デフォルトのスロットは props.children
で、render
関数を使ってその場所を定義します。たとえば、次のコードがあるとします。
const MyArticle = (props) => {
return <article>{props.children}</article>;
};
props.children
は HTML 要素ではなく React コンポーネントであることに注意してください。
Lit では、子はレンダリング関数内でスロット要素を使って作成します。子の継承は React と同じ方法では行われません。Lit では、子はスロットにアタッチされる HTML 要素であり、このアタッチのことを投影と呼びます。
@customElement("my-article")
export class MyArticle extends LitElement {
render() {
return html`
<article>
<slot></slot>
</article>
`;
}
}
複数のスロット
React では、複数のスロットを追加することは、継承されるプロパティが増えることと本質的に同じです。
const MyArticle = (props) => {
return (
<article>
<header>
{props.headerChildren}
</header>
<section>
{props.sectionChildren}
</section>
</article>
);
};
Lit でも同様に、<slot>
要素を追加すると、作成されるスロットが増えます。複数のスロットを定義する場合は、name
属性を使用します(<slot name="slot-name">
)。これにより、子は割り当て先のスロットを宣言できるようになります。
@customElement("my-article")
export class MyArticle extends LitElement {
render() {
return html`
<article>
<header>
<slot name="headerChildren"></slot>
</header>
<section>
<slot name="sectionChildren"></slot>
</section>
</article>
`;
}
}
デフォルトのスロットのコンテンツ
スロットにノードが投影されていない場合、スロットにはサブツリーが表示されます。スロットにノードが投影されている場合は、サブツリーの代わりに、投影されているノードが表示されます。
@customElement("my-element")
export class MyElement extends LitElement {
render() {
return html`
<section>
<div>
<slot name="slotWithDefault">
<p>
This message will not be rendered when children are attached to this slot!
<p>
</slot>
</div>
</section>
`;
}
}
スロットへの子の割り当て
React では、コンポーネントのプロパティを介して、スロットに子を割り当てます。次の例では、React の要素を headerChildren
と sectionChildren
のプロパティに渡しています。
const MyNewsArticle = () => {
return (
<MyArticle
headerChildren={<h3>Extry, Extry! Read all about it!</h3>}
sectionChildren={<p>Children are props in React!</p>}
/>
);
};
Lit では、slot
属性を使用して、スロットに子を割り当てます。
@customElement("my-news-article")
export class MyNewsArticle extends LitElement {
render() {
return html`
<my-article>
<h3 slot="headerChildren">
Extry, Extry! Read all about it!
</h3>
<p slot="sectionChildren">
Children are composed with slots in Lit!
</p>
</my-article>
`;
}
}
デフォルトのスロット(例: <slot>
)がなく、カスタム要素の子(例: <div slot="foo">
)の slot
属性に一致する name
属性が指定されたスロット(例: <slot name="foo">
)もない場合、そのノードは投影されず、表示されません。
場合によっては、HTML 要素の API にアクセスする必要があります。
このセクションでは、Lit で要素の参照を取得する方法を学びます。
React の参照
React コンポーネントは、一連の関数呼び出しにトランスパイルされ、呼び出されると仮想 DOM を作成します。この仮想 DOM は ReactDOM によって解釈、実行され、HTML 要素をレンダリングします。
React における参照は、生成された HTML 要素を格納するメモリ内のスペースを指します。
const RefsExample = (props) => {
const inputRef = React.useRef(null);
const onButtonClick = React.useCallback(() => {
inputRef.current?.focus();
}, [inputRef]);
return (
<div>
<input type={"text"} ref={inputRef} />
<br />
<button onClick={onButtonClick}>
Click to focus on the input above!
</button>
</div>
);
};
上の例の React コンポーネントでは、以下の処理を行っています。
- 空のテキスト入力欄と、テキスト付きのボタンをレンダリングする
- ボタンのクリック時に入力欄にフォーカスする
初回レンダリング後、React では ref
属性を介して、生成された HTMLInputElement
に inputRef.current
を設定します。
Lit の @query
を使用した「参照」
Lit はブラウザに近く、ネイティブのブラウザ機能に対して非常に薄い抽象化層を作成します。
React の refs
に相当する Lit の要素は、@query
と @queryAll
デコレータによって返される HTML 要素です。
@customElement("my-element")
export class MyElement extends LitElement {
@query('input') // Define the query
inputEl!: HTMLInputElement; // Declare the prop
// Declare the click event listener
onButtonClick() {
// Use the query to focus
this.inputEl.focus();
}
render() {
return html`
<input type="text"></input>
<br />
<!-- Bind the click listener -->
<button @click=${this.onButtonClick}>
Click to focus on the input above!
</button>
`;
}
}
上の例では、Lit コンポーネントで以下の処理を行っています。
MyElement
で@query
デコレータを使用してプロパティを定義する(HTMLInputElement
のゲッターを作成する)onButtonClick
というクリック イベント コールバックを宣言してアタッチする- ボタンのクリック時に入力欄にフォーカスする
JavaScript では、@query
と @queryAll
のデコレータはそれぞれ querySelector
と querySelectorAll
を実行します。これは JavaScript の @query('input') inputEl!: HTMLInputElement;
に相当します。
get inputEl() {
return this.renderRoot.querySelector('input');
}
Lit コンポーネントで render
メソッドのテンプレートを my-element
のルートにコミットすると、@query
デコレータによって、レンダリング ルートで最初に検出された input
要素を inputEl
から返せるようになります。@query
で、指定された要素を検出できない場合は null
が返されます。
レンダリング ルートに複数の input
要素がある場合、@queryAll
はノードのリストを返します。
このセクションでは、Lit コンポーネント間で状態を仲介する方法を学びます。
再利用可能なコンポーネント
React は、トップダウンのデータフローによる関数レンダリング パイプラインを模しており、親はプロパティを介して子に状態を渡し、子はプロパティで渡されたコールバックを介して親と通信します。
const CounterButton = (props) => {
const label = props.step < 0
? `- ${-1 * props.step}`
: `+ ${props.step}`;
return (
<button
onClick={() =>
props.addToCounter(props.step)}>{label}</button>
);
};
上の例の React コンポーネントでは、以下の処理を行っています。
- 値
props.step
に基づいてラベルを作成する - ボタンに + ステップまたは - ステップのラベルをレンダリングする
- クリック時に
props.step
を引数としてprops.addToCounter
を呼び出し、親コンポーネントを更新する
Lit でコールバックを渡すことは可能ですが、その使用パターンは異なります。上の例の React コンポーネントは、以下の例のような Lit コンポーネントとして記述することができます。
@customElement('counter-button')
export class CounterButton extends LitElement {
@property({type: Number}) step: number = 0;
onClick() {
const event = new CustomEvent('update-counter', {
bubbles: true,
detail: {
step: this.step,
}
});
this.dispatchEvent(event);
}
render() {
const label = this.step < 0
? `- ${-1 * this.step}` // "- 1"
: `+ ${this.step}`; // "+ 1"
return html`
<button @click=${this.onClick}>${label}</button>
`;
}
}
上の例の Lit コンポーネントでは、以下の処理を行っています。
- リアクティブ プロパティ
step
を作成する - クリック時に要素の
step
値を保持するupdate-counter
というカスタム イベントをディスパッチする
ブラウザのイベントは、子要素から親要素へとバブリングされます。これにより、子はインタラクション イベントや状態の変化をブロードキャストできます。React では基本的に状態が逆方向に渡されるため、React コンポーネントが Lit コンポーネントと同じようにイベントをディスパッチしてリッスンすることはまれです。
ステートフル コンポーネント
React では、フックを使用して状態を管理するのが一般的です。MyCounter
コンポーネントは、CounterButton
コンポーネントを再利用して作成できます。addToCounter
を CounterButton
の両方のインスタンスにどのように渡しているかに注意してください。
const MyCounter = (props) => {
const [counterSum, setCounterSum] = React.useState(0);
const addToCounter = useCallback(
(step) => {
setCounterSum(counterSum + step);
},
[counterSum, setCounterSum]
);
return (
<div>
<h3>Σ: {counterSum}</h3>
<CounterButton
step={-1}
addToCounter={addToCounter} />
<CounterButton
step={1}
addToCounter={addToCounter} />
</div>
);
};
上の例では、以下の処理を行っています。
count
状態を作成するcount
状態に数値を追加するコールバックを作成するCounterButton
でaddToCounter
を使用して、クリックのたびにstep
ずつcount
を更新する
MyCounter
と同様の実装を Lit でも実現できます。addToCounter
を counter-button
に渡していないことに注意してください。代わりに、コールバックを親要素の @update-counter
イベントに対するイベント リスナーとしてバインドします。
@customElement("my-counter")
export class MyCounter extends LitElement {
@property({type: Number}) count = 0;
addToCounter(e: CustomEvent<{step: number}>) {
// Get step from detail of event or via @query
this.count += e.detail.step;
}
render() {
return html`
<div @update-counter="${this.addToCounter}">
<h3>Σ ${this.count}</h3>
<counter-button step="-1"></counter-button>
<counter-button step="1"></counter-button>
</div>
`;
}
}
上の例では、以下の処理を行っています。
count
というリアクティブ プロパティを作成し、値が変更されたときにコンポーネントを更新するaddToCounter
コールバックを@update-counter
イベント リスナーにバインドするupdate-counter
イベントのdetail.step
の値を追加してcount
を更新するstep
属性を介して、counter-button
のstep
値を設定する
Lit では、リアクティブ プロパティを使用して親から子に変更をブロードキャストする手法が一般的です。同様に、ブラウザのイベント システムを使用して、ボトムアップで詳細をバブリングする手法も推奨されます。
この手法はベスト プラクティスに沿っているだけでなく、Web Components のクロス プラットフォーム サポートを実現するという Lit の目標にも即しています。
このセクションでは、Lit のスタイル設定について学びます。
スタイル設定
Lit では、複数の方法で要素のスタイルを設定できるほか、組み込みのソリューションも用意されています。
インライン スタイル
Lit では、インライン スタイルとそのバインディングがサポートされています。
import {LitElement, html, css} from 'lit';
import {customElement} from 'lit/decorators.js';
@customElement('my-element')
class MyElement extends LitElement {
render() {
return html`
<div>
<h1 style="color:orange;">This text is orange</h1>
<h1 style="color:rebeccapurple;">This text is rebeccapurple</h1>
</div>
`;
}
}
上の例では、インライン スタイルを使って、2 つの見出しをそれぞれ指定しています。
次に、オレンジ色のテキストに border: 1px solid black
をバインドします。
<h1 style="color:orange;${'border: 1px solid black;'}">This text is orange</h1>
スタイル文字列を毎回計算しなければならないのは手間がかかるため、Lit ではそれをサポートするディレクティブが提供されています。
styleMap
styleMap
ディレクティブを使用すると、JavaScript で簡単にインライン スタイルを設定できます。たとえば、次のコードがあるとします。
import {LitElement, html, css} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {styleMap} from 'lit/directives/style-map';
@customElement('my-element')
class MyElement extends LitElement {
@property({type: String})
color = '#000'
render() {
// Define the styleMap
const headerStyle = styleMap({
'border-color': this.color,
});
return html`
<div>
<h1
style="border-style:solid;
<!-- Use the styleMap -->
border-width:2px;${headerStyle}">
This div has a border color of ${this.color}
</h1>
<input
type="color"
@input=${e => (this.color = e.target.value)}
value="#000">
</div>
`;
}
}
上の例では、以下の処理を行っています。
- 枠線とカラー選択ツールを使用して
h1
を表示する border-color
をカラー選択ツールの値に変更する
さらに、styleMap
を使用して h1
のスタイルを設定しています。styleMap
で使用する構文は、React の style
属性のバインディング構文と同様です。
CSSResult
コンポーネントのスタイル設定では、css
タグ付きのテンプレート リテラルを使用することをおすすめします。
import {LitElement, html, css} from 'lit';
import {customElement} from 'lit/decorators.js';
const ORANGE = css`orange`;
@customElement('my-element')
class MyElement extends LitElement {
static styles = [
css`
#orange {
color: ${ORANGE};
}
#purple {
color: rebeccapurple;
}
`
];
render() {
return html`
<div>
<h1 id="orange">This text is orange</h1>
<h1 id="purple">This text is rebeccapurple</h1>
</div>
`;
}
}
上の例では、以下の処理を行っています。
- バインディングを使って CSS タグ付きのテンプレート リテラルを宣言する
- ID を使って 2 つの
h1
の色を設定する
css
テンプレート タグを使用すると、次のようなメリットがあります。
- インスタンスごとではなく、クラスごとに 1 回解析される
- モジュールの再利用を考慮して実装できる
- スタイルを専用のファイルに簡単に切り離すことができる
- CSS カスタム プロパティのポリフィルと互換性がある
また、index.html
の <style>
タグにも注意してください。
<!-- index.html -->
<style>
h1 {
color: red !important;
}
</style>
Lit では、コンポーネントのスタイルのスコープはそのルートに設定されます。このため、スタイルがスコープ外に適用されることはありません。コンポーネントにスタイルを渡す場合は、Lit のスタイルのスコープに適用できる CSS カスタム プロパティを使用することが推奨されます。
スタイルタグ
テンプレートで <style>
タグをインライン編集することもできます。ブラウザではこれらのスタイルタグの重複は除去されますが、テンプレートに配置すると、コンポーネント インスタンスごとに解析されるようになります。これは、クラスごとに解析される css
タグ付きのテンプレートとは対照的です。また、ブラウザでの CSSResult
の重複除去が高速化されます。
リンクタグ
テンプレートで <link rel="stylesheet">
を使用してスタイルを設定することも可能ですが、コンテンツのスタイル設定の遅れによるちらつき(FOUC)を引き起こす可能性があるため推奨されません。
JSX とテンプレート
Lit と仮想 DOM
lit-html には、個々のノードの差分を抽出する従来の仮想 DOM は含まれません。代わりに、ES2015 のタグ付きのテンプレート リテラルの仕様に固有のパフォーマンス機能が使用されます。タグ付きのテンプレート リテラルは、タグ関数がアタッチされたテンプレート リテラル文字列です。
次にテンプレート リテラルの例を示します。
const str = 'string';
console.log(`This is a template literal ${str}`);
次にタグ付きのテンプレート リテラルの例を示します。
const tag = (strings, ...values) => ({strings, values});
const f = (x) => tag`hello ${x} how are you`;
console.log(f('world')); // {strings: ["hello ", " how are you"], values: ["world"]}
console.log(f('world').strings === f(1 + 2).strings); // true
上の例では、タグは tag
関数となっており、f
関数はタグ付きのテンプレート リテラルの呼び出しを返します。
Lit のパフォーマンスが優れている主な理由は、タグ関数に渡される文字列配列で同じポインタが使用されている(2 つ目の console.log
を参照)という点です。ブラウザでは同じテンプレート リテラル(AST の同じ場所にあるもの)が使用されるので、タグ関数の呼び出しのたびに新しい strings
配列が再作成されることはありません。Lit ではバインド、解析、テンプレートのキャッシュ時にこうした機能を活用できるため、実行時に差分オーバーヘッドはほとんど生じません。
Lit の優れたパフォーマンスは、タグ付きのテンプレート リテラルがブラウザでこのように動作することによって、もたらされています。従来の多くの仮想 DOM では JavaScript で大部分の処理が行われますが、タグ付きのテンプレート リテラルでは、ブラウザの C++ で大部分の差分処理が行われます。
React または Preact で HTML タグ付きのテンプレート リテラルを初めて使用する場合は、htm
ライブラリの使用が推奨されます。
Google Codelab のサイトやオンラインのコードエディタなどでは、タグ付きのテンプレート リテラル構文のハイライト表示は、基本的にサポートされませんが、一部の IDE やテキスト エディタではデフォルトでサポートされています(Atom や GitHub のコードブロック ハイライト表示ツールなど)。Lit チームでは、コミュニティと緊密に連携し、lit-plugin
(Lit のプロジェクトに構文のハイライト表示、型チェック、入力支援を追加する VS Code のプラグイン)などのプロジェクトに取り組んでいます。
Lit と JSX、React DOM
JSX はブラウザでは実行されません。代わりに、プリプロセッサを使用して JSX を JavaScript 関数の呼び出しに変換します(通常は Babel 経由)。
たとえば、次のコードがあるとします。
const element = <div className="title">Hello World!</div>;
ReactDOM.render(element, mountNode);
このコードを Babel で変換すると次のようになります。
const element = React.createElement('div', {className: 'title'}, 'Hello World!');
ReactDOM.render(element, mountNode);
その後、React DOM で React の出力を受け取って実際の DOM(プロパティ、属性、イベント リスナーなど)に変換します。
lit-html では、トランスパイルやプリプロセッサなしにブラウザで実行できるタグ付きのテンプレート リテラルを使用します。つまり、Lit の使用にあたって必要となるのは HTML ファイル、ES モジュール スクリプト、サーバーのみです。たとえば以下のスクリプトのように、ブラウザですべて実行できるようになっています。
<!DOCTYPE html>
<html>
<head>
<script type="module">
import {html, render} from 'https://cdn.skypack.dev/lit';
render(
html`<div>Hello World!</div>`,
document.querySelector('.root')
)
</script>
</head>
<body>
<div class="root"></div>
</body>
</html>
また、Lit のテンプレート システム(lit-html)では、従来の仮想 DOM ではなく DOM API を直接使用します。このため、React(2.8 KB)と react-dom(39.4 KB)のサイズが 40 KB となるのに対し、Lit 2 は 5 KB 未満で済みます(いずれも gzip 圧縮で軽量化した状態)。
イベント
React は合成イベント システムを採用しています。つまり、react-dom ではすべてのコンポーネントで使用するすべてのイベントを定義して、ノードの各タイプに相当する camelCase イベント リスナーを設定する必要があります。結果として、JSX にはカスタム イベント用のイベント リスナーを定義するメソッドがなく、デベロッパーは ref
を使用して、強制的にリスナーを適用する必要があります。このように、React が考慮されていないライブラリを統合する場合は、React 用のラッパーを記述する必要が生じるため、効率的とは言えません。
一方、lit-html は DOM に直接アクセスしてネイティブ イベントを使用するため、イベント リスナーの追加も @event-name=${eventNameListener}
のように簡単です。そのため、イベント リスナーの追加やイベントの発生に伴う実行時の解析も少なくて済みます。
コンポーネントとプロパティ
React コンポーネントとカスタム要素
LitElement では、カスタム要素を使ってコンポーネントをパッケージ化しています。コンポーネント化に関しては、カスタム要素と React コンポーネントとの間に一定の交換関係があります(状態とライフサイクルについて詳しくは、「状態とライフサイクル」のセクションをご覧ください)。
コンポーネント システムとしてのカスタム要素には、次のようなメリットがあります。
- ブラウザ ネイティブなため、ツールが不要
innerHTML
、document.createElement
からquerySelector
まで、すべてのブラウザ API に適合- 通常はフレームワークをまたいで使用できる
customElements.define
で登録を遅らせて DOM を「ハイドレート」できる
React コンポーネントとの比較では、カスタム要素には次のようなデメリットがあります。
- クラスを定義せずにカスタム要素を作成できない(JSX のような関数コンポーネントがない)
- 終了タグを含める必要がある
- 注: 自己終了タグは、デベロッパーにとって便利な一方、多くのブラウザ ベンダーは否定的なため、新しい仕様では自己終了タグが採用されない傾向にあります。
- DOM ツリーにノードが追加されると、レイアウトの問題が生じる可能性がある
- JavaScript を介して登録する必要がある
Lit では、独自の要素システム上でカスタム要素を運用しています。これは、カスタム要素がブラウザに組み込まれていることと、クロスフレームワークのメリットがコンポーネントの抽象化レイヤーのメリットに勝ると考えられるためです。実際、lit-ssr 空間における Lit チームの取り組みにより、JavaScript の登録に関する主要な問題は解消されています。また、GitHub などの一部の企業では、カスタム要素の遅延登録を活用して、任意のスタイルでページのプログレッシブ エンハンスメントを行っています。
カスタム要素にデータを渡す
カスタム要素に対する最も多い誤解は、データを文字列でしか渡せない、という点です。このような誤解が生じるのは、要素の属性が文字列でしか記述できないことが理由と考えられます。確かに Lit では文字列属性が定義済みの型にキャストされますが、カスタム要素で複雑なデータをプロパティとして受け入れることは可能です。
たとえば、次のような LitElement 定義があるとします。
// data-test.ts
import {LitElement, html} from 'lit';
import {customElement, property} from 'lit/decorators.js';
@customElement('data-test')
class DataTest extends LitElement {
@property({type: Number})
num = 0;
@property({attribute: false})
data = {a: 0, b: null, c: [html`<div>hello</div>`, html`<div>world</div>`]}
render() {
return html`
<div>num + 1 = ${this.num + 1}</div>
<div>data.a = ${this.data.a}</div>
<div>data.b = ${this.data.b}</div>
<div>data.c = ${this.data.c}</div>`;
}
}
このコードでは、プリミティブ型のリアクティブ プロパティ num
を定義して、属性の文字列値を number
に変換しています。そして複雑なデータ構造の場合は attribute:false
を指定することで、Lit の属性処理を無効化します。
次に、このカスタム要素にデータを渡す方法を示します。
<head>
<script type="module">
import './data-test.js'; // loads element definition
import {html} from './data-test.js';
const el = document.querySelector('data-test');
el.data = {
a: 5,
b: null,
c: [html`<div>foo</div>`,html`<div>bar</div>`]
};
</script>
</head>
<body>
<data-test num="5"></data-test>
</body>
状態とライフサイクル
その他の React ライフサイクル コールバック
static getDerivedStateFromProps
Lit では、プロパティと状態はどちらも同じクラスのプロパティのため、これに相当するものはありません
shouldComponentUpdate
- 相当する Lit の要素は
shouldUpdate
- React と異なり、初回レンダリング時に呼び出される
- React の
shouldComponentUpdate
と同様の働き
getSnapshotBeforeUpdate
getSnapshotBeforeUpdate
と類似する Lit の要素は update
と willUpdate
です
willUpdate
update
の前に呼び出されるgetSnapshotBeforeUpdate
と異なり、willUpdate
はrender
の前に呼び出されるwillUpdate
でリアクティブ プロパティを変更しても、更新サイクルは再度トリガーされない- 他のプロパティによって変化し、残りの更新プロセスで使用されるプロパティ値を計算するのに適している
- このメソッドは SSR によりサーバーで呼び出されるため、DOM にアクセスすることは推奨されない
update
willUpdate
の後に呼び出されるgetSnapshotBeforeUpdate
と異なり、update
はrender
の前に呼び出されるupdate
でリアクティブ プロパティを変更しても、その変更がsuper.update
を呼び出す前であれば、更新サイクルは再度トリガーされない- レンダリングした出力を DOM にコミットする前に、コンポーネント周辺の DOM から情報を取得するのに適している
- このメソッドは SSR によりサーバーで呼び出されることはない
その他の Lit ライフサイクル コールバック
対応するものが React にないため、前のセクションで取り上げていないライフサイクル コールバックを以下に紹介します。
attributeChangedCallback
要素の observedAttributes
のいずれかが変更されたときに呼び出されます。observedAttributes
と attributeChangedCallback
はどちらもカスタム要素の仕様の一部で、Lit の内部で実装され、Lit 要素向けの属性 API を提供します。
adoptedCallback
コンポーネントが新しいドキュメントに移動(たとえば HTMLTemplateElement
の documentFragment
からメインの document
に移動)したときに呼び出されます。このコールバックもカスタム要素の仕様の一部ですが、コンポーネントによってドキュメントが変更されるような高度なユースケースでのみ使用してください。
その他のライフサイクルのメソッドとプロパティ
以下のメソッドとプロパティは、ライフサイクル プロセスの操作に役立つ、呼び出し、オーバーライド、待機が可能なクラスの構成要素です。
updateComplete
要素の更新が完了したときに、更新とレンダリングのライフサイクルの非同期を解決する Promise
です。次に例を示します。
async nextButtonClicked() {
this.step++;
// Wait for the next "step" state to render
await this.updateComplete;
this.dispatchEvent(new Event('step-rendered'));
}
getUpdateComplete
updateComplete
による解決時にオーバーライドしてカスタマイズする必要があるメソッドです。コンポーネントが子コンポーネントをレンダリングしていて、これらのレンダリング サイクルを同期する必要がある場合などに使用するのが一般的です。次に例を示します。
class MyElement extends LitElement {
...
async getUpdateComplete() {
await super.getUpdateComplete();
await this.myChild.updateComplete;
}
}
performUpdate
更新ライフサイクル コールバックを呼び出すメソッドです。通常は必要ありませんが、更新の同期やカスタムのスケジュール設定が必要なごく一部のユースケースで使用します。
hasUpdated
コンポーネントが 1 回以上更新されている場合、このプロパティは true
になります。
isConnected
このプロパティはカスタム要素の仕様の一部で、要素がメインのドキュメント ツリーにアタッチされている場合は true
になります。
Lit の更新ライフサイクルの可視化
更新ライフサイクルは次の 3 つに分けられます。
- 更新前
- 更新
- 更新後
更新前
requestUpdate
の後は、スケジュール設定された更新が待機しています。
更新
更新後
フック
フックを使用するメリット
React のフックは、状態を必要とするシンプルな関数コンポーネントのユースケース向けに導入されたものです。多くの場合、関数コンポーネントとフックを使用すると、同等のクラス コンポーネントを使用するより、はるかにシンプルで読みやすいコードになります。しかしながら、状態の更新を非同期で行って、フックまたはエフェクト間でデータをやり取りする場合、一般的にフックパターンでは不十分で、リアクティブ コントローラなどのクラスベースのソリューションの方が適しています。
API リクエスト フックとコントローラ
API からデータをリクエストする場合、フックを記述するのが一般的です。たとえば、この React 関数コンポーネントでは次のような処理を行います。
index.tsx
- テキストをレンダリングする
useAPI
のレスポンスをレンダリングする- ユーザー ID とユーザー名
- エラー メッセージ
- (設計上)11 人目のユーザーに達したときの 404
- API 取得を中止した場合の中止エラー
- メッセージを読み込む
- 操作ボタンをレンダリングする
- 次のユーザー: 次のユーザーの API を取得する
- キャンセル: API の取得を中止してエラーを表示する
useApi.tsx
useApi
カスタムフックを定義する- API からユーザー オブジェクトを非同期で取得する
- 次の情報を発行する
- ユーザー名
- 取得が進行中かどうか
- エラー メッセージ
- 取得を中止するためのコールバック
- マウントが解除された場合に進行中の取得を中止する
詳しくは、「Lit とリアクティブ コントローラの実装」をご覧ください。
要点:
- リアクティブ コントローラはカスタムフックとほぼ同じ働きをする
- レンダリングできないデータをコールバックやエフェクト間でやり取りする
- React では
useRef
を使用してuseEffect
とuseCallback
の間でデータをやり取りする - Lit ではプライベートなクラスのプロパティを使用する
- React は基本的にプライベートなクラスのプロパティを模している
- React では
子
デフォルトのスロット
slot
属性が指定されていない HTML 要素は、デフォルトの無名スロットに割り当てられます。次の例では、MyApp
で 1 つの段落を名前付きスロットに割り当てています。それ以外の段落は、デフォルトの無名スロットに割り当てられます。
@customElement("my-element")
export class MyElement extends LitElement {
render() {
return html`
<section>
<div>
<slot></slot>
</div>
<div>
<slot name="custom-slot"></slot>
</div>
</section>
`;
}
}
@customElement("my-app")
export class MyApp extends LitElement {
render() {
return html`
<my-element>
<p slot="custom-slot">
This paragraph will be placed in the custom-slot!
</p>
<p>
This paragraph will be placed in the unnamed default slot!
</p>
</my-element>
`;
}
}
スロットの更新
スロットの子孫の構造が変更されると、slotchange
イベントが発生します。Lit コンポーネントでは、イベント リスナーを slotchange
イベントにバインドできます。次の例では、slotchange
イベントの発生時に shadowRoot
で検出された最初のスロットで assignedNodes をコンソールに記録しています。
@customElement("my-element")
export class MyElement extends LitElement {
onSlotChange(e: Event) {
const slot = this.shadowRoot.querySelector('slot');
console.log(slot.assignedNodes({flatten: true}));
}
render() {
return html`
<section>
<div>
<slot @slotchange="{this.onSlotChange}"></slot>
</div>
</section>
`;
}
}
参照
参照の作成
Lit と React のいずれの場合も、render
関数が呼び出された後に HTML 要素への参照をエクスポーズします。なお、React と Lit でどのように DOM が作成されるのかを確認しておくことをおすすめします。DOM は、後で Lit の @query
デコレータまたは React の参照を介して返されます。
React は、HTML 要素ではなく React コンポーネントを作成する関数パイプラインです。参照は HTML 要素をレンダリングする前に宣言されるため、メモリ内のスペースが割り当てられます。このため、参照の初期値は null
となります(例: useRef(null)
)。これは、実際の DOM 要素はまだ作成(レンダリング)されていないためです。
ReactDOM では React コンポーネントを HTML 要素に変換した後、React コンポーネント内で ref
という属性を探します。この属性が見つかった場合は、HTMLE 要素の参照を ref.current
に設定します。
LitElement では、lit-html の html
テンプレート タグ関数を使用して、内部で Template 要素を作成します。そして、レンダリング後にテンプレートのコンテンツをカスタム要素の Shadow DOM にスタンプします。Shadow DOM は、シャドウルートによってカプセル化されたスコープ付き DOM ツリーです。@query
デコレータでは次にプロパティのゲッターを作成します。基本的にこのゲッターは、スコープされたルート上で this.shadowRoot.querySelector
を実行します。
複数の要素のクエリ
次の例では、@queryAll
デコレータを使ってシャドウルート内の 2 つの段落を NodeList
として返しています。
@customElement("my-element")
export class MyElement extends LitElement {
@queryAll('p')
paragraphs!: NodeList;
render() {
return html`
<p>Hello, world!</p>
<p>How are you?</p>
`;
}
}
基本的に、@queryAll
は this.shadowRoot.querySelectorAll()
の結果を返す paragraphs
のゲッターを作成します。JavaScript では、ゲッターで同じ目的を達成するよう宣言できます。
get paragraphs() {
return this.renderRoot.querySelectorAll('p');
}
要素の変更のクエリ
@queryAsync
デコレータは、別の要素のプロパティの状態に応じて変更可能なノードの処理に適しています。
次の例では、@queryAsync
で最初の段落要素を検索しています。ただし、段落要素は renderParagraph
によりランダムに奇数が生成された場合にのみレンダリングされます。@queryAsync
ディレクティブは、最初の段落が使用可能な場合に解決する Promise を返します。
@customElement("my-dissappearing-paragraph")
export class MyDisapppearingParagraph extends LitElement {
@queryAsync('p')
paragraph!: Promise<HTMLElement>;
renderParagraph() {
const randomNumber = Math.floor(Math.random() * 10)
if (randomNumber % 2 === 0) {
return "";
}
return html`<p>This checkbox is checked!`
}
render() {
return html`
${this.renderParagraph()}
`;
}
}
状態の仲介
React では、状態の仲介は React 自体により行われるため、コールバックを使用するのが一般的です。React では、要素で示される状態に依存しないようにします。DOM はレンダリング処理の結果にすぎません。
外部での状態管理
Lit と併せて Redux や MobX などの状態管理ライブラリを使用することができます。
Lit コンポーネントは、ブラウザのスコープ内で作成されます。このため、ブラウザのスコープ内に存在するライブラリはすべて Lit で使用できます。Lit の既存の状態管理システムを活用できるライブラリは数多くあります。
Lit コンポーネントで Redux を使用する方法については、Vaadin のこちらのシリーズをご覧ください。
大規模なサイトにおいて Lit で MobX を使用する方法については、Adobe の lit-mobx をご覧ください。
また、Web Components に GraphQL を組み込む方法については、Apollo Elements をご覧ください。
Lit はネイティブのブラウザ機能に対応しており、Lit コンポーネントではブラウザのスコープ内のほとんどの状態管理ソリューションを使用できます。
スタイル設定
Shadow DOM
Lit では、Shadow DOM を使用して、カスタム要素内でスタイルと DOM をネイティブにカプセル化します。シャドウルートは、メインのドキュメント ツリーとは別にシャドウツリーを生成します。つまり、ほとんどのスタイルはこのドキュメントにスコープされます。なお、色や他のフォント関連のスタイルなど、一部のスタイルはこのスコープから外れます。
Shadow DOM を使用すると、CSS の仕様に新しいコンセプトとセレクタを導入することもできます。
:host, :host(:hover), :host([hover]) {
/* Styles the element in which the shadow root is attached to */
}
slot[name="title"]::slotted(*), slot::slotted(:hover), slot::slotted([hover]) {
/*
* Styles the elements projected into a slot element. NOTE: the spec only allows
* styling the direcly slotted elements. Children of those elements are not stylable.
*/
}
スタイルの共有
Lit では、css
テンプレート タグを介して CSSTemplateResults
形式のコンポーネント間でスタイルを簡単に共有できます。次に例を示します。
// typography.ts
export const body1 = css`
.body1 {
...
}
`;
// my-el.ts
import {body1} from './typography.ts';
@customElement('my-el')
class MyEl Extends {
static get styles = [
body1,
css`/* local styles come after so they will override bod1 */`
]
render() {
return html`<div class="body1">...</div>`
}
}
テーマ設定
シャドウルートを使用する場合、トップダウンのスタイルタグの手法が一般的な従来のテーマ設定では少し問題が生じます。Shadow DOM を使用した Web Components のテーマ設定を従来の方法で行うには、CSS カスタム プロパティを介してスタイル API をエクスポーズします。たとえば、マテリアル デザインで次のパターンを使用しているとします。
.mdc-textfield-outline {
border-color: var(--mdc-theme-primary, /* default value */ #...);
}
.mdc-textfield--input {
caret-color: var(--mdc-theme-primary, #...);
}
この場合、次のカスタム プロパティ値を適用してサイトのテーマを変更できます。
html {
--mdc-theme-primary: #F00;
}
html[dark] {
--mdc-theme-primary: #F88;
}
トップダウンのテーマ設定が必要不可欠で、スタイルをエクスポーズできない場合は、createRenderRoot
をオーバーライドして this
を返すようにすることで、いつでも Shadow DOM を無効にできます。こうして、カスタム要素にアタッチされたシャドウルートではなく、カスタム要素自体にコンポーネントのテンプレートをレンダリングします。この場合、スタイルのカプセル化、DOM のカプセル化、スロットは失われます。
製品化
IE 11
IE 11 などの古いブラウザに対応させる場合は、ポリフィル(約 33 KB)の読み込みが必要となります。詳しくはこちらをご覧ください。
条件付きバンドル
Lit チームでは、IE 11 用と最新のブラウザ用の 2 種類のバンドルを提供することを推奨しています。これには、次のようなメリットがあります。
- ES 6 をより速く、ほとんどのクライアントに配信できる
- トランスパイル済みの ES 5 はバンドルサイズが大幅に増える
- 条件付きバンドルによって両方の環境を最大限に活用できる
- IE 11 に対応できる
- 最新のブラウザで読み込みが遅くならない
条件付きで配信するバンドルの作成方法について詳しくは、こちらのドキュメント サイトをご覧ください。