関数
◆ ただの関数だとデータをもとに画面状態を更新するだけ
    ◆ インスタンスはないので独自のデータを保存する場所はない
    ◆ 渡されるデータのどこかに含める必要あり
◆ 各関数が状態持たないので処理やデータは1箇所にまとまる
◆ 外部からタブ切り替えたい場合など メソッド呼び出しで操作できない
    ◆ その UI 状態を表すデータを直接変える

クラス
◆ 状態を持てるので コンポーネント内のデータを内部で管理できる
◆ インスタンスのメソッドを使って操作できる
    ◆ インスタンスが自身の管理するデータを変更できる
◆ データの場所や処理が散らばる

lit-html は WebComponents と使うと便利ですが (公式に lit-element がある) いろいろな事情で WebComponents を使わず作らないといけないこともあります
主に ShadowDOM とライブラリの相性であって CustomElements が使えればそこまで困らないのですけど CustomElements も使わない場合は迷うところがあります

さすがに全部の HTML をひとつの html タグ関数で書いてしまうのは難しいのでコンポーネント風に分割します
分割しても CustomElements のようにコンポーネントになる HTML の要素があるわけではないので その辺りをどうするかが問題です

関数とクラス

lit-html である以上 親であるテンプレートの中で

html`
<div>${something()}</div>
`

のように 埋め込むことになります
something() の部分に何を書くか です
なにかと言っても コンポーネントは関数かクラスのどちらかになると思います

関数の場合は something() のように関数を実行して 関数が返すテンプレートを埋め込むことになります
関数なので状態を持たず 作られるプレートは引数によって決まります
動的に表示するためのデータは外部で管理して関数にわたすことになります

クラスの場合は instance.render() のようにインスタンスのメソッドを実行した結果を埋め込むことになります
インスタンス自身がさまざまなデータを持てるので WebComponents の一般的な作りに似たものになります
ただし CustomElement のインスタンスとは異なり インスタンス自体が HTML の要素とはならないので自分でインスタンスを管理する必要がありますし DOM にアタッチされたとかの判断は難しいです

とりあえず 例として簡単なものを関数とクラスの両方で作ってみました

内容は h1 のタイトル表示の下にタブコンテナを作って 3 つのタブを用意します
1 つめの名前は tab1
名前と別のタブで設定したテキストを表示します
2 つめの名前は tab2
名前と現在時刻を表示します
毎秒更新します
3 つめの名前は option
ここでタイトルとテキストとカラーを設定できます
タイトルはタブの外側の h1 でテキストとカラーは tab1 に表示するものです

共通部分

エントリポイントの HTML はどっちも一緒です

[index.html]
<!doctype html>
<link rel="stylesheet" href="index.css">
<script type="module" src="main.js"></script>

index.css と main.js をロードします

css は見た目だけなので影響しませんが一応貼っておきます

[index.css]
* {
box-sizing: border-box;
}

.tab-container {
border: 1px solid #aaa;
}

.tab-head-container {
display: flex;
border-bottom: 1px solid #ccc;
}

.tab-head {
padding: 3px 10px;
border-right: 1px solid #ccc;
cursor: pointer;
}

.tab-head:hover {
background: #f5f5f5;
}

.tab-head.active {
background: aquamarine;
}

.tab-body-container {
height: 500px;
padding: 10px;
}

関数

関数の場合の main.js はこうなります

main.js

import { html, render } from "https://unpkg.com/lit-html?module"
import tabContainer from "./tab-container.js"

window.onload = eve => {
const data = {
active_tab: null,
title: "test",
color: "#333",
text: "message",
}

const update = () =>
render(
html`
<h1>${data.title}</h1>
${tabContainer(data, update)}
`,
document.body
)

update()
}

data にすべてのデータを入れます
update が lit-html の render を実行して画面を更新する関数です
タブの部分はモジュールの tabContainer 関数に data と update を渡して返ってきたテンプレートを埋め込みます
各コンポーネント内でのイベントで data を更新した場合などに再描画を行うために 再描画を行う update は全部のコンポーネントに渡します

data のプロパティを setter にしておいて自動で行うこともできますが 事前に全プロパティ定義はめんどうです
変えるたびに定義も変えないといけないのであまりしたくないです
TypeScript とか型を定義してるならそういうのもありだと思います

他には dispatchEvent でイベントを起こして最上位でキャプチャという方法もあります
今回は body 直下にこれしか置かない予定ですが 大きな DOM ツリーのごく一部という場合もあって 他も同じ仕組みで作っていたら競合する可能性もあるのでやめておきます
main.js で作る部分の最上位の要素にリスナをつけることもできますが 今回の例のように複数ある場合もあります
とても多くの要素がある場合に全部につけないといけないのは大変です
また tabContainer の結果のようにコンポーネントの結果を埋め込むとなっていると最上位コンポーネントだけで済ませられません
最上位は 1 つの div でまとめるというルールにすれば可能ですけど なくてもできるなら余計なルールはつけたくないので やっぱりこの方法は使わないことにしました

tab-container.js

import { html } from "https://unpkg.com/lit-html?module"
import tab1 from "./tab1.js"
import tab2 from "./tab2.js"
import option from "./option.js"

const tabs = { tab1, tab2, option }

export default (data, update) => {
if (!data.active_tab) {
data.active_tab = { name: "tab1", data: {} }
}

const h = {
switchTab(eve) {
const elem = eve.target.closest("[data-tab]")
data.active_tab = { name: elem.dataset.tab, data: {} }
update()
},
}

const tab_list = ["tab1", "tab2", "option"]

return html`
<div class="tab-container">
<div class="tab-head-container" @click=${h.switchTab}>
${tab_list.map(e => {
return html`
<div class=${`tab-head ${data.active_tab.name === e ? "active" : ""}`} data-tab=${e}>
${e}
</div>
`
})}
</div>
<div class="tab-body-container">
${tabs[data.active_tab.name](data, update, data.active_tab.data)}
</div>
</div>
`
}

tab-container.js では関数 1 つだけエクスポートしてます
これが lit-html 中のテンプレート中で呼び出されます

最初は data.active_tab が null なので active_tab の情報を追加してます
関数なので内部に持てないので data のプロパティにもたせます
このデータはこのコンポーネント専用なものなので グローバルになる data 自体で初期化するのもどうかと思ったので初期化もここでしてます

h はイベントリスナのハンドラ関数を書くところです
リスナのハンドラだけでまとまってたほうがいいのでここにまとめるようにしました
このコンポーネントだと 1 つしかないですけど

tab-header-container のところは繰り返し処理を直接書きたくないので 直前で変数に入れてる tab1 や option を表示する部分を map で作ってます

表示するタブ部分は active_tab.name から関数を選択して呼び出す形式になってます
ここは data と update に加えて active_tab.data も渡してます
タブ内を表示するコンポーネントでは渡されたオブジェクト内にコンポーネント用データを保存できるようにしてます
コンポーネント側が data のどこのパスって指定すると コンポーネントなのに外部の data に依存しますし このコンポーネントを複数使ったりすることも考えると どこに保存するというのはコンポーネント側が決めるより与えられるべきかなと思った結果です

とは言っても data 自体は第一引数に受け取ってますし このコンポーネント自体は data.active_tab にデータ入れると自分で指定してたりもします
この辺はどっちがいいのか迷ってたりする部分もあって自分の中でもはっきりしてません
data 全部を渡さずコンポーネント用のデータ領域だけを渡すとしたら 全体に影響する部分のデータを受け取れませんし やっぱり data 全部を受け取って必要なところを参照することになると思います
しかし その場合は data に依存するコンポーネントになって再利用性がイマイチです

コンポーネントをそのアプリケーション固有なものと 汎用的なものに分けて 固有なものはアプリケーション固有である data に依存し放題で そこから汎用的なものにデータを渡すという作りにして 汎用なものは一切外部に依存しない というのが理想なんでしょうか
変にこだわると作りたいものを単純に作るより何倍も複雑で面倒になっていくので難しいところです

とりあえず タブに関しては タブ外でも使いそうということで変数でデータ領域を受け取ります

tab1.js

import { html } from "https://unpkg.com/lit-html?module"
export default (data, update, tab_data) => {
return html`
<div data-tab-name="tab1">
<h2>tab1</h2>
<p style=${`color: ${data.color}`}>${data.text}</p>
</div>
`
}

ここは単純
コンポーネント独自のデータなく data から color と text を使って表示します

tab2.js

import { html } from "https://unpkg.com/lit-html?module"
export default (data, update, tab_data) => {
setTimeout(update, 1000)

return html`
<div data-tab-name="tab2">
<h2>tab2</h2>
<p>${new Date().toLocaleString()}</p>
</div>
`
}

こっちも単純です
ただ setTimeout で update を 1 秒ごとに実行するようにしています
setInterval にしないのは 毎回実行されるので 再帰風にしたほうがよさそうだったのと 解除タイミングがないからです

CustomElement だと disconnectedCallback で解除できますが ただの関数だとそのテンプレートの Node が document ツリー上に存在するかわからないです
hyperHTML のように Node を取り出せれば MutationObserver という手もありますが lit-html の html タグ関数は TemplateResult のインスタンスを作るだけです
render されるまで Node を作らないですし 作ってもプロパティ的に取り出すことは容易ではなさそうです

option.js

import { html } from "https://unpkg.com/lit-html?module"
export default (data, update, tab_data) => {
const editing = (tab_data.editing = tab_data.editing || {
title: data.title,
color: data.color,
text: data.text,
})

const h = {
input(eve) {
const name = eve.target.name
if (!name) return
editing[name] = eve.target.value
update()
},
save(eve) {
data.title = editing.title
data.color = editing.color
data.text = editing.text
update()
},
}

return html`
<div data-tab-name="option">
<h2>option</h2>
<table @input=${h.input}>
<tr>
<th>Title</th>
<td><input name="title" .value=${editing.title}></td>
</tr>
<tr>
<th>Color</th>
<td><input name="color" .value=${editing.color}></td>
</tr>
<tr>
<th>Text</th>
<td><input name="text" .value=${editing.text}></td>
</tr>
</table>
<button @click=${h.save}>Save</button>
</div>
`
}

tab1 や tab2 では使ってなかったコンポーネント用のデータ領域の tab_data を使います
ここでは form を作るので 編集中のデータの実体を入れる場所を用意します

h.input は input イベントで editing のデータを更新します
h.save は save ボタンを押したら editing を実際の data 中のプロパティにコピーします

tab-container で data.active_tab はタブが切り替わるごとに新規作成で上書きなので タブを変えると編集中のデータは消えます

まとめ

関数版は以上です

data に各コンポーネント用のすべてのデータがあって 毎回引数を元にテンプレートを作っています
ハンドラ関数も毎回作るので 関数は render のたびに再設定されます
関数外に作る事も考えましたが 引数で渡される data や update にアクセスする必要があるので 外側につくるのは難しくてやめました
bind したりしても結局新しい関数が毎実行ごとに生成されてリスナに設定される関数は変わりますし 変に複雑な仕組みをつくるなら lit-html 内の関数を変更のほうがコストは少ないと思います
内部的には addEventListener/removeEventListener ではなく HTMLElement に設定されているリスナオブジェクト中の handleEvent 関数が呼び出す関数の参照だけを切り替えてるので実質 1 代入程度の処理です

あとは すべてが data にあるのでデバッグ中に確認しやすいメリットもあります
基本関数などは含めないので JSON にダンプしてリストアすれば同じ状況を再現できます

クラス

クラスの場合の main.js はこうなります

main.js

import { html, render } from "https://unpkg.com/lit-html?module"
import TabContainer from "./tab-container.js"

window.onload = eve => {
const data = {
active_tab: null,
title: "test",
color: "#333",
text: "message",
}

const update = () =>
render(
html`
<h1>${data.title}</h1>
${tab_container.render()}
`,
document.body
)

const tab_container = new TabContainer(data, update)
update()
}

ほぼ同じですが render の tabContainer() が tab_container.render() に変わっています
インスタンスを保持する必要があるので update の下でインスタンスを作っています

クラスでも data と update を渡すのは関数のときと一緒です
編集中のデータみたいな各コンポーネント内に完全に閉じてるデータならともかく タイトルとか現在アクティブなタブというデータは そのコンポーネントの外部でも使われることが多いので 全体からアクセスできる場所に保持するようにしています
update は関数のときと同じ理由です

base-class.js

import { html } from "https://unpkg.com/lit-html?module"

export default class BaseClass {
constructor(data, update) {
this.data = data
this.update = update
}

render() {
return html``
}
}

クラスの場合は毎回同じのを書くのは手間なので ベースとなるクラスを作ってこれを継承するようにします
今回は単純なものなので data と update を保持するのと render がなくても空のテンプレートを返すようにしてます

tab-container.js

import { html } from "https://unpkg.com/lit-html?module"
import BaseClass from "./base-class.js"
import tab1 from "./tab1.js"
import tab2 from "./tab2.js"
import option from "./option.js"

const tabs = { tab1, tab2, option }

export default class extends BaseClass {
constructor(...a) {
super(...a)
if (!this.data.active_tab) {
this.data.active_tab = { name: "tab1", instance: new tab1(this.data, this.update) }
}
}

get handlers() {
return {
switchTab: eve => {
const elem = eve.target.closest("[data-tab]")
const cls = tabs[elem.dataset.tab]
this.data.active_tab = { name: elem.dataset.tab, instance: new cls(this.data, this.update) }
this.update()
},
}
}

render() {
const data = this.data
const tab_list = ["tab1", "tab2", "option"]

return html`
<div class="tab-container">
<div class="tab-head-container" @click=${this.handlers.switchTab}>
${tab_list.map(e => {
return html`
<div class=${`tab-head ${data.active_tab.name === e ? "active" : ""}`} data-tab=${e}>
${e}
</div>
`
})}
</div>
<div class="tab-body-container">
${data.active_tab.instance.render()}
</div>
</div>
`
}
}

関数のときは毎回チェックしてましたがクラスの場合はコンストラクタで最初の 1 回だけ active_tab プロパティの初期化を行います
active_tab のデータにはタブ用のデータ領域は作りませんが 代わりにタブのインスタンス自体を入れます
インスタンスの中を見ればこっちでもアクティブタブのコンポーネントのデータを data から参照できることは変わりません
name だけで外部からアクティブなタブはわかるので data の一部となる active_tab に入れずにインスタンスのプロパティにしてもよかったのですが とりあえずこうしました

ハンドラ関数は getter の handlers にまとめました
この作りだと参照のたびに毎回ハンドラ一覧を作ってるので 関数のときと同じように render メソッドの中で作っても一緒です
見やすさのために分けてるだけです

パフォーマンスを優先するならコンストラクタで handlers をプロパティに入れてしまって毎回作らないようにすることもできます
それなら lit-html が毎回再設定する手間も省けます
ただ 関数のときにも書いたようにそこまでの処理にならないのと関数のときと同じでいいかということでそこまではしていません

また handlers の中でアロー関数を使ってるのは this の値をこのインスタンスにするためです
CustomElement を使ってそれぞれのコンポーネントで lit-html の render を行っているなら this を設定できますが CustomElement がなく一番親の render ひとつで成り立っている場合は 個別に this のコンテキストを設定できません
なので this がインスタンス自身となるようにちょっとした工夫が必要です

render の中は基本は関数のときと一緒です
名前から関数を取得しなくてもインスタンスを保持してるのでそのインスタンスの render メソッドを呼びだして返り値を埋め込みます

tab1.js

import { html } from "https://unpkg.com/lit-html?module"
import BaseClass from "./base-class.js"

export default class extends BaseClass {
render() {
return html`
<div data-tab-name="tab1">
<h2>tab1</h2>
<p style=${`color: ${this.data.color}`}>${this.data.text}</p>
</div>
`
}
}

これはクラスになってもほぼ変わりありません

tab2.js

import { html } from "https://unpkg.com/lit-html?module"
import BaseClass from "./base-class.js"

export default class extends BaseClass {
render() {
setTimeout(this.update, 1000)

return html`
<div data-tab-name="tab2">
<h2>tab2</h2>
<p>${new Date().toLocaleString()}</p>
</div>
`
}
}

こっちもほぼ一緒です

option.js

import { html } from "https://unpkg.com/lit-html?module"
import BaseClass from "./base-class.js"

export default class extends BaseClass {
constructor(...a) {
super(...a)
this.editing = {
title: this.data.title,
color: this.data.color,
text: this.data.text,
}
}

get handlers() {
return {
input: eve => {
const name = eve.target.name
if (!name) return
this.editing[name] = eve.target.value
this.update()
},
save: eve => {
this.data.title = this.editing.title
this.data.color = this.editing.color
this.data.text = this.editing.text
this.update()
},
}
}

render() {
return html`
<div data-tab-name="option">
<h2>option</h2>
<table @input=${this.handlers.input}>
<tr>
<th>Title</th>
<td><input name="title" .value=${this.editing.title}></td>
</tr>
<tr>
<th>Color</th>
<td><input name="color" .value=${this.editing.color}></td>
</tr>
<tr>
<th>Text</th>
<td><input name="text" .value=${this.editing.text}></td>
</tr>
</table>
<button @click=${this.handlers.save}>Save</button>
</div>
`
}
}

ここもほぼ同じです
ハンドラ関数などは tab-container.js のところで書いたとおりです
編集中データは自身のプロパティとしてもちます
外部から参照しないものは 保存場所やその渡し方に迷う心配はないです

まとめ

関数よりはちょっとコードが長くなりました
その分 1 回しか実行しない部分はコンストラクタに入れたり ハンドラ関数の定義を分けたり書く場所がちゃんとわかれて見やすいとも言えます
data と update は初回作成時にプロパティへ設定のみなので これらが変わることはないのが前提の作りです
変わるなら全インスタンスを破棄して作り直す必要があります

また コンポーネント内部でしか使わない一時的なフォームデータや開閉状態など画面的なものを保存したい場合はプロパティに入れるだけなので簡単です

良いことも多いですが 状態を持つ分引数を渡して呼び出すだけで良い関数より複雑ですし インスタンス自体をどこに保持するかなど考える部分も増えるデメリットもあります
両方混ぜて使い分けもできますが 統一したほうが良いような気もして迷うところです

コード

コードは Gitlab でも見れます
Gist だとフォルダ作れないのでリポジトリにしました



続き