◆ コンポーネントにルート要素をもたせるようにした
◆ lit-html テンプレートを render するホスト要素

前の記事の続きです
WebComponents が使えない状況でコンポーネントぽいことしたいけどどういう風に作るかって話です

前回のは関数もクラスも CustomElement にあたる要素はなしで ${} で埋め込むところに直接コンポーネントのルートが入るようになってました
ですが よくよく考えてみると CustomElement ならそこに要素があります
なので 要素を自分で作って良い気がしました

そうするとそれに対して lit-html の render を実行できます
トップレベルの 1 つの render で全部を行わないので this を個々に設定できたり document のツリーにアタッチされたりデタッチされたことを検出してそのタイミングで何かの処理をすることもできます

関数版にコンポーネントのルート要素を作る

コンポーネントのルート要素を作ると言っても 関数の返り値で同じコンポーネントならば毎回同じ要素が返ってくる必要があります
普通にやると 関数なので実行ごとに全部新規に作られるか 前の要素を使い回すなら全部同じになります
毎回別の要素になると DOM の入れ替え操作が多くなりますし 毎回作るコストも掛かります
全部同じだと同じコンポーネントを複数箇所で使えません

これは関数である以上仕方ないので ユーザが渡す引数を使って同じコンポーネントであることを判別することで対処します
hyperHTML の wire に近い感じです
コンポーネント関数に参照を渡して その参照に対して作ったルート要素を保存します
すでに参照に対する要素があったらそれに対して render を行いその要素を返します

component.js

こういうモジュールを作ります

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

const store = {}
const elemstore = (component_name, ref) => {
const wmap = (store[component_name] = store[component_name] || new WeakMap())
if (!wmap.has(ref)) {
const element = document.createElement("div")
element.className = "component-host"
element.dataset.component = component_name
wmap.set(ref, element)
}
const element = wmap.get(ref)
return element
}

export default (component_name, template) => ref => (...args) => {
const host = elemstore(component_name, ref)
render(template(host, ref, ...args), host)
return host
}

store はオブジェクトで キーがコンポーネント名 バリューが WeakMap です
WeakMap にはユーザが渡したオブジェクトをキーとして バリューにルート要素を保存します

elemstore ではそれを管理します
コンポーネント名と参照オブジェクトを渡すとルート要素を取得し まだなければ作ります

最後のエクスポートしてる関数はコンポーネントとして使いやすいようにする関数です
lit-html の render 処理などはここでやるので 各コンポーネントのファイルはテンプレートを返す関数を作るだけで済みます
元々の関数のやり方で作ってたエクスポートする関数と近い関数を作るだけで済みます

コンポーネントを作る側はこういうフォーマットになります

export default component("component-name", (host, ref, data, update) => { /* return template */ })

コンポーネントを使う側はこういうフォーマットになります

html`
${tab1(ref)(data, update)}
`

ref を設定して得られる関数に data と update を入れて呼び出します

作られるコンポーネントのルート要素はこういう div になります

<div class="component-host" data-component="option">

class に component-host がついて data-component にコンポーネント名が入ります

tab1.js

シンプルな tab1.js で比較してみます
これが新しいやり方のファイルです

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

export default component("tab1", (host, ref, data, update) => {
return html`
<div data-tab-name="tab1">
<h2>tab1</h2>
<p style=${`color: ${data.color}`}>${data.text}</p>
</div>
`
})

以前のはこれです

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>
`
}

option.js

option.js も比べてみます
これが今回のファイルです

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

export default component("option", (host, ref, data, update) => {
const editing = (ref.editing = ref.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>
`
})

以前のはこれです

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>
`
}

tab_data をなくして ref を使ってます
ref はそのコンポーネントを一意に判別するオブジェクトなので そのコンポーネント独自のデータを入れるオブジェクトとしても使えます
ref として active_tab.data を渡せばコンポーネントの判別兼データ保持場所としても使えて無駄がありません

tab-container.js

コンポーネントを埋め込む側の処理として 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"
import component from "./component.js"

const tabs = { tab1, tab2, option }

export default component("tab-container", (host, ref, 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.active_tab.data)(data, update)}
</div>
</div>
`
})

前のはこれです

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>
`
}

関数内は最後のタブ内のコンポーネント表示部分が変わったくらいです

コンポーネントぽくなった

ルート要素が存在することで少しコンポーネント感が増しました
host として得られる div も操作できますし コンポーネント用のデータを保持したいときに div に対して WeakMap を作ったりしなくても最初から ref として対応するオブジェクトが渡されてます
コンポーネントがデータを持つことで コンポーネント感は増したものの クラスのやり方と同じようになって data にすべてのデータがあるわけじゃないので全体の状態を見るのはできなくなります
今回の例だと ref てして渡してるのが data の一部なので可能ですが関係ないものを渡すとできなくなります

ここまでクラスのやり方ぽくなるならクラスでやったほうが良い気もします

クラス版にコンポーネントのルート要素を作る

やってみるとこっちはすごく簡単でした
すでにインスタンスが存在するので一意にするための ref とかは不要です
ルート要素も WeakMap を使って保存したりしなくても プロパティに保存すればよいです

なのでほとんど base-class.js を変更するだけでした

base-class.js

base-class.js の変更後はこうなりました

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

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

createRoot() {
const element = document.createElement("div")
element.className = "component-host"
element.dataset.component = this.component_name
return element
}

get component_name() {
return ""
}

get template() {
return html``
}

render() {
render(this.template, this.root)
return this.root
}
}

参考用に元はこうでした

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``
}
}

ルート要素を作る機能を追加してコンストラクタで作成します
また render メソッドは lit-html の render をルート要素に実行してその要素を返すようにします
元々はテンプレートを返していたのですが それは template getter に移動しました
あとルート要素に設定するためにコンポーネント名が必要になったので component_name getter も作りました

各コンポーネントは component_name getter をつくって render を get template に置き換えるだけで済みます

tab1.js

変更後

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

export default class extends BaseClass {
get component_name() {
return "tab1"
}

get template() {
return html`
<div data-tab-name="tab1">
<h2>tab1</h2>
<p style=${`color: ${this.data.color}`}>${this.data.text}</p>
</div>
`
}
}

変更前

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>
`
}
}

残りも同じように変更するだけです

と クラスの方は簡単に変更できました
ここまで来るとクラスのほうがシンプルなので良いかなと思えます
関数の方でもデータの持ち方がコンポーネントごとに持つようになってますし

コード

全体のコードは前のリポジトリに追加しています