古き良きDOM操作
かつて関数型っぽいjsがバズり、集約されてツリー構造でイミュータブルなステート、宣言的に仮想DOMを吐くviewの時代になった。
でも末端の状態は末端のコンポーネントに持たせて、上流から必要になったとき必要なのを引き出せばいいよね、というのを感じたので命令的にWeb Componentsで書いてみようの会。
mastodonのタイムラインを想定しつつ削りまくった例でざっくり雰囲気を掴む。
トゥートコンポーネント
まず一つのトゥートから。
Web Componentsはclass構文でHTMLElementを継承するところから始まる。
class StatusItem extends HTMLElement {
constructor() {
super()
this.attachShadow({mode: "open"})
}
コンストラクタで必ずsuperを呼び出す。ShadowRoot等の準備も行う。renderはまだ呼ばない。
そして、トゥートの持つ状態として、トゥートのidと、トゥートをお気に入りしているかという状態を持たせる。
これらは属性として持たせる。トゥート本文は子要素として挿入してもらう。
get statusId() {
return this.getAttribute("status-id")
}
set statusId(id) {
this.setAttribute("status-id", id)
}
get favorite() {
return this.hasAttribute("favorite")
}
set favorite(boolean) {
this.toggleAttribute("favorite", boolean)
}
レンダリングはconnectedCallbackで行う。
Shadow DOMの内容を変えたい場合は後でレンダリングしなおす。子要素の変更はLight DOMの話なので気にすることはない。
ちょっと色を変えるくらいだったらCSSだけでできるため、今回はconnectedCallbackのみ実装。
生DOMで全部書くとか正気の沙汰じゃないのでlit-htmlでサクッと書く。
connectedCallback() {
render(html`
<style>
:host { --button-color: white; }
:host([favorite]) { --button-color: pink; }
button { background-color: var(--button-color) }
</style>
<div>
[${this.statusId}]
<slot></slot>
<button type="button" @click=${this.favor.bind(this)}>
Fav.
</button>
</div>
`, this.shadowRoot)
}
トゥートをお気に入りにする処理を書けるようにする。
favor(e) {
e.preventDefault()
this.favorite = true;
const event = new CustomEvent("favorite", {
bubbles: true,
cancelable: true,
composed: true
})
this.dispatchEvent(event)
}
トゥートコンポーネントから直接APIを叩くのではなく、あくまでお気に入りにしたというイベントを発火する。
イベントとして放流することで、バブリングしたイベントを好きな場所で拾えるようになる。イベントを使わずonfavoriteプロパティだけ定義するのはハンドラのバケツリレーが始まるだけなのでやってはいけない。
イベントのtargetは越境時点でShadow Rootのホストつまりカスタム要素自身に再設定される。そしてカスタム要素はイベントを処理するのに必要な情報(お気に入りにするトゥートのid)を保持している。これが「必要になったとき必要なのを引き出す」仕組み。
注意点として、生のHTMLにonfavoriteとか書いてもハンドラは登録されない。jsxやlit-htmlのような方法でレンダリングしたときはaddEventListenerに変換されるため問題ないはず。また、Shadow Rootを越境するためcomposed: true
も必須。
これをこう登録すると、HTMLにこう書いておいた要素がカスタム要素に昇格する。
customElements.define("status-item", StatusItem)
<status-item status-id="1000000000000">hello!</status-item>
タイムラインコンポーネント
トゥートをカスタム要素として作ったので、当然トゥートを束ねるコンポーネントでは子要素としてトゥートを持つ。
ところで、トゥートは消えることがある。
見られたくないから消すのであって、なるはや、O(1)で消してあげるのが親切心だろう。
つまり、多数あるトゥートの中からidで引く仕組みがいる。
その仕組みはコンポーネント内に隠蔽できる。
まずはconstructorで準備。
辞書として使うMapと、MutationObserverを用意している。
class StatusCollection extends HTMLElement {
constructor() {
super()
this.observer = new MutationObserver(this.mutationCallback.bind(this))
this.statusMap = new Map()
this.attachShadow({mode: "open"})
}
connectedCallback内でobserverに自分自身を登録し、更に生成時点での子要素を辞書に載せていく。
disconnectedでdisconnect。
connectedCallback() {
this.observer.observe(this, {childList: true});
[...this.children]
.filter(x => x.localName == "status-item")
.forEach(x => {
// status-itemがアップグレードされていない可能性がある
this.statusMap.set(x.getAttribute("status-id"), x)
})
render(html`
<slot></slot>
`, this.shadowRoot)
}
disconnectedCallback() {
this.observer.disconnect()
}
せっかくstatusIdというgetterを定義したのにgetAttributeを呼んでいるのは、この時点でstatus-itemがアップグレードされていない場合があるため。undefindにしばらく悩んだ。
そして、子要素の変更時に辞書を更新する。
mutationCallback(mutations) {
mutations.forEach(mutation => {
[...mutation.addedNodes]
.filter(x => x.localName == "status-item")
.forEach(status => {
this.statusMap.set(status.statusId, status)
});
[...mutation.removedNodes]
.filter(x => x.localName == "status-item")
.forEach(status => {
this.statusMap.delete(status.statusId)
});
})
}
これで子要素内からトゥートをelement.statusMap.get(id)
で引けるようになる。引いたらremoveChildで消すだけ。
あとはトゥートコンポーネント同様、登録して使う。
customElements.define("status-collection", StatusCollection)
<status-collection>
<status-item status-id="1000000000000">hello!</status-item>
</status-collection>
カスタム要素の命名を利用者に委ねる宗派の人は、子要素に期待する名前を属性で与えられるようにしておくといいかもしれない。
get statusElementName() {
return this.getAttribute("status-element-name") || "status-item"
}
set statusElementName(localName) {
this.setAttribute("status-element-name", localName)
}
入力をappendChildするだけのフォームも並べてみた。appendしたあとは忘れられるのが伝わってほしい。
<form onsubmit="return false">
<input name="note" />
<button type="button"
onclick="
const element = document.createElement('status-item')
element.statusId = Date.now()
element.innerText = this.form.note.value
document.querySelector('status-collection').appendChild(element)
"
>
toot!
</button>
</form>