◆ DOM 管理系のライブラリは使いたくなくて WebComponents を使ってすべてコンポーネントに分割したいけど DOM イベント処理だとコンポーネント間のやりとりが辛い場合向け
◆ イベントはツリー外に送って処理して ルートコンポーネントから子コンポーネントへ伝播させる

WebComponents を使ってページを作るときにどういう風に作るかについてです
React や Vue を各コンポーネントにマウントしてもいいですし lit-element という WebComponents に向いているライブラリもあります
これらは DOM を管理してくれるものなので サーバサイドでテンプレートエンジンを使って HTML を書くような感じで 今の状態だとどういう DOM にするかを定義しておけば 後の差分更新は勝手にやってくれます
最近はもう全部こういうものに任せればいいやって気がしてきていますが この記事の内容はこれらを使わず直接 DOM 操作する場合の話です

2 年か 3 年くらい前に WebComponents で複雑なページを作ってみたときに試行錯誤したもので 最近でも DOM 管理ライブラリを使わずどう作るか考えてみるとやっぱり似たものになるので これでいいのかなと個人的に思っています
とはいえ あくまで個人的な好みであって推奨してるわけでもないですし 理由がないなら DOM 管理してくれるライブラリに任せればいいと思います

前提の作り方

WebComponents を使うといっても 主に 2 パターンあると思っています

ひとつは必要な箇所だけコンポーネント化するものです
select や video みたいな組み込みタグは内部で色々な機能があります
div などに比べていかにもコンポーネント感があると思います
それと同じように汎用的に使われるもののみをコンポーネントにします
それ以外の全体的な部分は昔ながらのグローバルな DOM 操作です

もうひとつは React や Vue で見るような感じで細かく全てをコンポーネントに分割するものです
一箇所でしか使われないことが確定しているものでもコンポーネントにします
その結果 ページの body 要素内にはひとつの my-app みたいなルートコンポーネントだけがあるようなイメージです

前者は基本的に昔ながらの作りのままです
select タグは検索できないのが不便だからと input を組み合わせて select の仕組みを自作するようなライブラリがあります
しかし select タグとは違い中の input が見えていて HTML の構造ではどこからがコンポーネントになっているかがわかりづらく扱いづらいものでした
それがコンポーネントにできたことで select タグのように使えるようになったというものです
自作やライブラリのコンポーネントを select タグなどの組み込みタグと同じように使うだけで 全体的な部分は以前のままなので 特に困ることも少ないと思います

それにくらべて後者の方は作り方が結構変わります
困るのはコンポーネント間のデータのやり取りです
前者側では完全に閉じた部分だけがコンポーネントであって データのやり取り部分はグローバルなのでセレクタで要素を取得して書き換えるというこれまでの方法でよかったです
しかし 細かくコンポーネントに分かれていると データのやり取りも一苦労です

というわけで 後者側のすべてをコンポーネントに分ける場合が前提になります

データの受け渡し

普通に DOM の仕組みを使えば なにかあるごとにイベントを送出して 親で受け取ってメソッドを呼び出したり setter を通してプロパティ代入したりです

---- A
-------- B
------------ C
---------------- D
------------ E
-------- F
------------ G

こういう構造のコンポーネントがある場合では
D → E にデータを反映させるなら D がイベントを送出し B で受け取り B が E に伝えます
D → G にデータを反映させるなら D がイベントを送出し A で受け取り A が F に伝え F が G に伝えます

面倒ですし コンポーネントがいっぱい挟まると辛いです
直接セレクタで id を指定して要素に値をセットする場合と違って コンポーネントの構造を変えるときの考えることも増えます
特に共通の親を探すというのが面倒で 構造を変えたら変わる可能性もあるのが辛いところです
コンポーネント間が近い場合なら 関連するデータであることが多いので特に問題ないのですが ただ共通の親であるというだけで そのデータがそのコンポーネントになんの関係もないというところでイベントをリッスンして処理するというのは気が進みません

DOM ツリーの外で処理

統一感があってシンプルで楽にしたいと考えた結果 ツリー構造の外で処理すればいいやということになりました

コンポーネント内で完結する ボタンを押したら span に表示される数値がカウントアップみたいなものであれば ボタンにリスナを設定して span の textContent を変更すれば良いです
しかし 全然違う場所にあるコンポーネントに表示する数値を変更するなど コンポーネント外に影響するものは DOM の機能でイベントを送出せず ツリー外にイベントを通知します

WebComponents を使い始めた頃は React や Vue は使ってなかったので Redux や Vuex と同じような感じではありますが 近いようで遠いものになってます

WebSocket でサーバと通信のイメージ

コンポーネント内の処理とツリー外の処理のイメージはクライアントとサーバの通信です
どちらも実際にはページ内の JavaScript ですが WebSocket で通信しているイメージで ツリー外がサーバにあたります

client <---> server
コンポーネント <---> ツリー外

呼びやすさの点から view と dataserver と呼ぶことにします

view <---> dataserver

作り方的には dataserver を実際に WebSocket サーバにおいてリモートにすれば複数 PC やタブ間で状態の同期ができそうですが それが目的ではないので試していません
dataserver を Worker に配置であれば dataserver での処理が重くなる場合にはありかもしれません

state ではなく event

今回は生の DOM 操作をするものであって DOM 管理ライブラリは使いません
仮想 DOM で差分を更新してくれないのに state だけを知っても 自力で state 間の差分を見つけて DOM の更新を行うのは大変なだけです
なので dataserver から view へ伝えるものは event のようなものです
Redux のアクション自体をコンポーネントに伝えるというとイメージが伝わりやすいかもです

clear 処理を行いたい時に clear 済みの state を受け取ってその値をセットするよりも clear というイベントを受け取り Custom Element の clear メソッドを呼び出すほうがスッキリしていて好きです

しかし完全にイベント通知のみになると不便なところもあります
イベントのみだと 昔ながらの作りと同じで 要素の value や class みたいなところでしか現在の状態がわかりません
ダイアログが開いているかを知りたいときに セレクタで対象の要素を取得して class に open があるかで判断はデメリットも大きいです
dataserver 側で知りたいときに知ることができません

せっかく dataserver というものを通すようにしているので 全体としての状態を持つことも可能にしています

view のイベント伝播

dataserver から view へメッセージを伝える方法についてです

各コンポーネントを client とみなして コンポーネントが自分が必要なイベントをリッスンするのが一番無駄が少なくて良さそうです
Custom Element には connectedCallback と disconnectedCallback があるのでここでリスナの登録解除しようと考えたのですが connectedCallback の中で dataserver へのアクセス手段がないです

dataserver をグローバル変数として配置してコンポーネントがグローバル変数にアクセスというのは避けたいです
また dataserver が複数ある場合に対応できません

<body>
<app-root id="foo"></app-root>
<app-root id="bar"></app-root>
</body>

こんな感じで app-root が複数あってそれぞれ別の dataserver としたいケースもありえます

connectedCallback であれば connect されたわけなので親をたどることが可能です
ルートとなるコンポーネントに id をもたせたりプロパティとして dataserver をもたせておいて connectedCallback のタイミングで dataserver を探すことはできます
ただ なんか良い方法って気がそれほどしないので この方法はやめました

後から気づきましたが 親とその子孫が同じイベントをリッスンしていた場合 親がイベントを処理したことで子孫に影響があり その実行順で意図しない結果になることもありえます

その代わりにルートコンポーネントが一括でイベントを受け取り 必要であれば下流方向へ伝播させます
DOM のイベントが親方向へ伝わるのとは逆向きで ルートコンポーネントから子方向に伝えます

伝播の仕組み

Custom Element 間でどういう仕組みで子コンポーネントに伝えるかについてです
これは親が子のメソッドを呼び出す形にしました
親が初期化時に子コンポーネントを含む DOM を作るわけなので 親がそれらの子コンポーネントへ責任を持ってイベントを伝播させるようにします
ちょっと面倒ではありますが これは仕方ない部分かなと思います
lit-element でも子コンポーネントにプロパティを渡したりしますし それの代わりです

また初期化についても この仕組みを使って初期化用の connected イベントを受け取ったときにするようにしました
最初は connectedCallback が良いかなと思っていましたが Custom Element のアップグレードの問題がありました
親子関係がすでに定義されてる状態でアップグレードが発生する場合に問題が起きます
親コンポーネント側の connectedCallback の処理では まだ子コンポーネント側のアップグレードは終わっていません
つまり 子コンポーネントにアクセスしても初期化処理がされておらずプロパティやメソッドもない状態です
setTimeout や Promise を使って非同期処理にして connectedCallback の処理を後回しにすれば 子コンポーネントのアップグレード後に処理できますが 親側の処理も後回しになりますし どのタイミングでどこが初期化済みなのかがわかりづらく扱いづらくなります
これは親コンポーネントが ShadowDOM を作ってそこに子要素を作る場合には発生せず slot などに使うために Custom Element の子要素を書いている場合のみ発生します

connected イベントは DOM の connected とは別で dataserver との接続時に発生するようにしました
全てのイベントは dataserver から受け取れますし dataserver が state を使う場合に初期の state を connected イベント時に参照できます

他の WebComponents との連携

独自の仕組みを用いすぎると他の仕組みのコンポーネントと連携しづらくなるのが欠点です
今回のだと view のイベント伝播の仕組みがないコンポーネントは下流にイベントを伝えられなくなります

コンポーネントが select タグのような末端のものであれば その子孫へイベントを伝える必要はありません
select タグを使うときと同じような感じで 使う側のコンポーネントが DOM 操作をすれば問題ないです

末端以外の中間のコンポーネントもありますが これも slot を使うのであれば slot 内へイベントを伝播させれば問題ないです

<custom-dialog>
<foo-bar></foo-bar>
</custom-dialog>

<custom-tab>
<tab-item>
<custom-element1></custom-element1>
</tab-item>
<tab-item>
<custom-element2></custom-element2>
</tab-item>
</custom-tab>

この custom-dialog や custom-tab はライブラリで別の作りになっていたとしても その中の foo-bar や custom-element1 などは直接メソッドを呼び出せます

困るのは

<tab-item component="custom-element1"></tab-item>

みたいな書き方になっていて 自作の子コンポーネントへアクセスできないときです
tab-item を継承してイベント伝播の仕組みに対応させるか custom-element1 をラップして親の親からアクセスできる仕組みをもたせるかです

私がこの作り方で作ったときは tab とか dialog とかの UI 系も含めて全部自作だったので困りませんでしたが ライブラリを使えないのは不便なので 改善したいところではあります

ここまでは実際のコードがなくて分かりづらい部分も多かったと思うので実際の動く例です
全部のコードを書くと長くなるので 全体はリポジトリに置いてます
共通処理をまとめた lib がライブラリ部分で example が使った例です

https://nexpr.gitlab.io/wcf/example1/
https://nexpr.gitlab.io/wcf/example2/
https://nexpr.gitlab.io/wcf/example3/
https://nexpr.gitlab.io/wcf/example4/
https://nexpr.gitlab.io/wcf/example5/

lib には base-element.js と data-server.js があります

base-element.js は Custom Element のベースクラスです
各コンポーネントはこれを継承して Custom Element を作ります
直接の DOM 操作が前提なので querySelector や DOM イベントのリスナや id を使ったキャッシュなど便利そうな機能とイベント伝播用の機能が入っています

data-server.js は dataserver を作るのに使います

import { createDataServer } from "./data-server.js"
const dataserver = createDataServer(def, initial)
dataserver.listen(ctx => view.propagate(ctx))
dataserver.request("add", 1)

def が dataserver 側の処理の定義で initial が state の初期値です
listen メソッドで dataserver からのイベントをリッスンできます
ここでの view がルートコンポーネントになり propagate メソッドに引数を渡します
view (Custom Element) を直接 listen に入れても動作します

dataserver へイベントを送るときは request というメソッドです
サーバ風にしてるだけで HTTP のリクエストはもちろん起きません
1 つ目の引数に type を入れて そのあとにパラメータの値を入れます

WebSocket サーバのイメージと書いたように request に対して response があるわけではありません
dataserver 側の処理で必要がなければ view 側にイベントは送られませんし dataserver 側から複数のイベントが送られることもあります
タイマーなどで時間差で dataserver 側から一方的に送られるイベントもあり WebSocket 通信に近い感じです
それなら request じゃなくて send で良かった気もしましたがとりあえず request のままです

example1

1 つ目の例では clock-element という要素で現在時刻を表示します
コンポーネント内で setInterval してもいいのですが dataserver との通信イメージなのでコンポーネント内ではイベントで受け取った時刻を表示するだけです
一応 複数の時刻表示コンポーネントがある場合は独自にタイマーをもつと画面上の更新タイミングが揃わなくて気持ち悪いデメリットがあるので タイマーは外部において同時に更新のほうが嬉しいこともあります

index.html はシンプルで index.js をロードするだけです
body でもいいのですが 一応ルートコンポーネント用に root の div を準備しました

[index.html]
<!doctype html>
<meta charset="utf-8"/>

<script type="module" src="index.js"></script>

<div id="root"></div>

index.js ではルートコンポーネントを作って root の div に追加します
また ds.js からインポートした dataserver の listen にルートコンポーネントを設定してイベントを受け取るようにします
setInterval は index.js で行って 毎秒 ds.request を呼び出して time イベントで現在時刻を送ります

[index.js]
import "./clock-element.js"
import { ds } from "./ds.js"

const clockelem = document.createElement("clock-element")
document.getElementById("root").append(clockelem)

ds.listen(clockelem)

setInterval(() => {
ds.request("time", new Date())
}, 1000)

ds.js は createDataServer で作った dataserver オブジェクトをエクスポートします
今回は state は要らず time イベントをそのまま view 側に送るだけなので単純です
time イベントが来たら time プロパティの関数が呼び出されます
関数が値を return すれば view 側に送られます

[ds.js]
import { createDataServer } from "../lib/data-server.js"

export const ds = createDataServer({
time({ type }, time) {
return { type, time }
},
})

clock-element.js は base-element.js がエクスポートする BaseElement を継承した Custom Element を定義します
伝播したイベントを受け取ると update メソッドが呼び出されます
引数は dataserver の関数で return した値です

[clock-element.js]
import { BaseElement } from "../lib/base-element.js"

customElements.define(
"clock-element",
class extends BaseElement {
get template() {
return this.fragment`
<div id="now"></div>
`
}

initialize() {
this.$.now.textContent = new Date().toLocaleTimeString()
}

update({ type, time }) {
if (type === "time") {
this.$.now.textContent = new Date(time).toLocaleTimeString()
}
}
}
)

Custom Element の Shadow DOM 内は template という getter で DocumentFragment を返すようにしておくと自動で作られます

initialize メソッドは初期化に使うもので connectedCallback メソッドの代わりです
connectedCallback にすると super.connectedCallback を呼び出さないといけなくなり忘れると正しく動かないので別名で書きやすいものにしました
リスナの登録などに使います
state を使う場合などは initialize ではなく update で type が connected のときに実行します

今回は type が time のときに受け取った時刻で div を更新します
「this.$.now」 というものがありますが id を持つものは唯一でその参照は変更されないものという前提で template から DOM 構築後にキャッシュしています
id を持った要素ごと置き換える場合は 削除された要素を更新することになって意味がないので $ は使ってはいけません
this.qs で querySelector を使うか $ のプロパティを自分で更新する必要があります

example2

2 つ目はカウンターです
こっちもコンポーネント内で完結するので dataserver がなくてもいいのですが 使った場合の例です

index.html は 1 つ目と一緒で index.js は setInterval が無いだけでほぼ一緒です

ds.js では カウントアップのイベントを view に送るのではなく state を使ってみます
2 つめの引数に初期値を入れて request 処理では getData メソッドの呼び出しで state を取得できるので それを更新します

[ds.js]
import { createDataServer } from "../lib/data-server.js"

export const ds = createDataServer(
{
up({ getData, type }, value) {
const data = getData()
data.count += value
return { type }
},
},
{
count: 0,
}
)

count-element.js ではボタンを押したら request を行い up イベントが来たら現在のカウントに更新します

[count-element.js]
import { BaseElement } from "../lib/base-element.js"

customElements.define(
"count-element",
class extends BaseElement {
get template() {
return this.fragment`
<span id="count"></span>
<input id="plus" type="button" value="+">
`
}

initialize() {
this.on(this.$.plus, "click", (eve) => {
this.request("up", 1)
})
}

update({ type }) {
if (type === "connected" || type === "up") {
this.$.count.textContent = this.getData().count
}
}
}
)

this.on で addEventListener を少し書きやすくしています
リスナ系のヘルパ機能って高機能にしても使うのはほぼ同じパターンなので 一時期変に高機能にしてたこともありましたが 今ではシンプルに target, event, handler, option をいれるだけです
また リスナはつけても外すことがほぼないので off はなくしました

Custom Element から state の取得は this.getData で dataserver への request は this.request で行えます

example3

3 つ目は 1 つ目と同じ時刻表示です
時分秒をそれぞれ別のコンポーネントとして表示します
例のごとく意味があるわけじゃないですがイベント伝播の方法の例です

ds.js は 1 つ目と一緒でも良いのですが時刻はイベントパラメータではなく state に入れてみました

[ds.js]
import { createDataServer } from "../lib/data-server.js"

export const ds = createDataServer(
{
time({ getData, type }, time) {
const data = getData()
data.time = time
return { type }
},
},
{
time: new Date(),
}
)

見ての通り少し長くなります
state としての保持が不要ならイベントパラメータで渡すだけにするのもありですね

clock-element.js では hour-element と minute-element と second-element を使います
これらにイベントを伝播させるために getProgationTargets というメソッドで対象の要素の配列を返します

[clock-element.js]
import { BaseElement } from "../lib/base-element.js"
import "./hour-element.js"
import "./minute-element.js"
import "./second-element.js"

customElements.define(
"clock-element",
class extends BaseElement {
get template() {
return this.fragment`
<hour-element id="hour"></hour-element>
:
<minute-element id="minute"></minute-element>
:
<second-element id="second"></second-element>
`
}

getProgationTargets(event) {
return [this.$.hour, this.$.minute, this.$.second]
}
}
)

id をつけておくと $ が使えて便利です
今回は clock-element では特に更新するものがなくて子コンポーネントへ伝播させるだけなので update メソッドは不要です

hour-element では state から時刻部分を取り出して表示するだけです

[hour-element.js]
import { BaseElement } from "../lib/base-element.js"

customElements.define(
"hour-element",
class extends BaseElement {
get template() {
return this.fragment`
<span id="hour"></span>
`
}

update({ type }) {
if (type === "connected" || type === "time") {
const time = this.getData().time
this.$.hour.textContent = time.getHours().toString().padStart(2, "0")
}
}
}
)

minute-element や second-element も同じ感じです

example4

4 つ目は 3 つ目の改良版です
hour-element や minute-element をそれぞれ作って各自が時や分を取り出して表示ってわざわざコンポーネントにする必要があるのかと思いますよね
数値を表示する部分を分けるにしても 数値を表示するコンポーネントにして時や分の値を親から渡せば良いと思います

しかし イベント伝播の仕組みだと同じコンポーネントに同じイベントを渡すと全部同じ結果になるはずです
どうにかする方法の 1 つは数値を表示するコンポーネントではイベント伝播を受け取らず 数値をセットするメソッドを用意して clock-element が update の処理でそれぞれのコンポーネントのメソッドを呼び出す方法です
今回のイベント伝播の仕組みの影響を受けず汎用的なコンポーネントになるのでそれはそれで良いと思います
ですが ここではイベント伝播の仕組みを使う方法をとります

getProgationTargets では配列以外に Map を返すこともできます
その場合 key が要素で value にはイベントを指定します
別のイベントに置き換えて子コンポーネントに伝えることができます
マージはされませんが getProgationTargets の引数で今のイベントを受け取れるので必要なら手動でマージできます

[clock-element.js]
import { BaseElement } from "../lib/base-element.js"
import "./number-element.js"

customElements.define(
"clock-element",
class extends BaseElement {
get template() {
return this.fragment`
<number-element id="hour"></number-element>
:
<number-element id="minute"></number-element>
:
<number-element id="second"></number-element>
`
}

getProgationTargets(event) {
const d = this.getData().time
return new Map([
[this.$.hour, { type: "change", number: d.getHours(), digit: 2 }],
[this.$.minute, { type: "change", number: d.getMinutes(), digit: 2 }],
[this.$.second, { type: "change", number: d.getSeconds(), digit: 2 }],
])
}
}
)

number-element.js では change イベントを受け取るとイベントパラメータの number を digit の桁数で表示する作りにします

[number-element.js]
import { BaseElement } from "../lib/base-element.js"

customElements.define(
"number-element",
class extends BaseElement {
get template() {
return this.fragment`
<span id="num"></span>
`
}

update({ type, number, digit }) {
if (type === "change") {
const num = +number || 0
const di = +digit || 0
this.$.num.textContent = String(+num).padStart(di, "0")
}
}
}
)

example5

5 つ目はカウンターのちょっと複雑な例です
セレクトボックスでカウンターの数を指定して その数だけカウンターを表示します
カウンターは + ボタンと - ボタンがあります
また 全カウントの合計値も表示します

こういう遠く離れた場所のコンポーネントとデータを共有しないものなら単純にカウンター要素がカウントを保持してその要素を指定数作るのがシンプルなのですが例のごとく dataserver を経由します

[ds.js]
import { createDataServer } from "../lib/data-server.js"

export const ds = createDataServer(
{
"num-length-change"({getData}, num) {
const data = getData()
if (data.numbers.length > num) {
data.numbers.length = num
} else {
while (data.numbers.length < num) {
data.numbers.push(0)
}
}
return { type: "num-length-change" }
},
up({ getData, type }, index) {
const data = getData()
data.numbers[index] += 1
return { type: "num-value-change", index }
},
down({ getData, type }, index) {
const data = getData()
data.numbers[index] -= 1
return { type: "num-value-change", index }
},
},
{
numbers: [0, 0],
}
)

こういう要素が増えたり減ったりは手間が多くなり state で持つ場合は state の更新と DOM の更新で同じような処理が 2 回必要です

[app-element.js]
import { BaseElement } from "../lib/base-element.js"
import "./number-input.js"

customElements.define(
"app-element",
class extends BaseElement {
elems = []

get template() {
return this.fragment`
<div>
<select id="num">
<option>1</option>
<option>2</option>
<option>3</option>
<option>4</option>
</select>
<div id="numinputs"></div>
<div>
Total:
<span id="total"></span>
</div>
</div>
`
}

initialize() {
this.on(this.$.num, "change", (eve) => {
this.request("num-length-change", +eve.target.value)
})
}

update({ type }) {
const data = this.getData()
if (type === "connected" || type === "num-length-change") {
const len = data.numbers.length
this.$.num.value = len
while (len > this.elems.length) {
const n = document.createElement("number-input")
const index = this.elems.length
this.elems.push(n)
this.$.numinputs.append(n)
this.propagateChild(n, {
type: "connected",
onUp: () => this.request("up", index),
onDown: () => this.request("down", index),
value: data.numbers[index],
})
}
while (len < this.elems.length) {
const elem = this.elems.pop()
elem.remove()
}
this.$.total.textContent = data.numbers.reduce((a, b) => a + b)
}
}

getProgationTargets(event) {
if (event.type === "num-value-change") {
const elem = this.elems[event.index]
const num = this.getData().numbers[event.index]
return new Map([[elem, { type: "updated", value: num }]])
}
}
}
)

view へのイベントの内容が単に num-length-change だけの通知だと state の数に合わせるのが大変なので ds.js での処理で「◯番目以降を消す」とか「最後に◯個追加」みたいな情報を丁寧に持たせて DOM 処理側を少し楽にすることはできなくもないです

今回のように後から Custom Element を作る場合 append 後に connected を手動で通知する必要があります
getProgationTargets とは別に append 直後に必要です
update は getProgationTargets より先に実行されるので同期的に処理していれば connected の後に getProgationTargets による通知を受け取れます


今回の作りの場合 number-input.js はカウントアップやダウン時の this.request の処理を行うときに自分が何番目かの情報を送る必要があります
connected 時に index を渡せばいい話ではあるのですが そうするとここ以外で number-input を使う場合に一つしかない場合でも index を必要とすることになってしまいます
未指定で undefined にしていても dataserver に 「index: undefined」 が届くことになってしまい コンポーネントとしては汎用性に欠けます
その対処のために this.request を index 付きで行う関数を親から受け取っておいて イベントが起きたときにその関数を実行します

[number-input.js]
import { BaseElement } from "../lib/base-element.js"

customElements.define(
"number-input",
class extends BaseElement {
get template() {
return this.fragment`
<div>
<input id="text" type="text" value="0" readonly>
<input id="up" type="button" value="+">
<input id="down" type="button" value="-">
</div>
`
}

initialize() {
this.on(this.$.up, "click", (eve) => {
this.__onUp()
})
this.on(this.$.down, "click", (eve) => {
this.__onDown()
})
}

update(event) {
if (event.type === "connected") {
this.__onUp = event.onUp
this.__onDown = event.onDown
this.$.text.value = event.value
}
if (event.type === "updated") {
this.$.text.value = event.value
}
}
}
)

コードが長くなる

見ての通り長いですよね
規模が大きく複雑になってきて離れたコンポーネントでもイベントを受け取りたいときには良いのですが そういうことがほとんどない小さめのものだと苦労のほうが大きいです
Todo アプリだとかそういう小さめなものだと変なことせず単純にコンポーネントを作ったほうが良いと思います

複雑になってくれば嬉しいところもでてきますが DOM 管理ライブラリに任せたほうが短く書けていいと思います
lit-element などを使ったとしても このコンポーネントだけは直接 DOM 操作したいとなればそこだけ使わなければ済みますし

使う理由

メリットがあまりないですが それでもこれを使う利点はパフォーマンス面です
DOM 管理系のライブラリでは中で仮想 DOM を作って差分検出など複雑な処理をしています
こっちの場合は書くコードは長いものの ライブラリはほとんど何もしません
base-element.js は少し長めですが Custom Element を DOM 操作で使う上でよく使う便利機能を含んでいるからです
lit-element は Custom Element には色々機能がありますが DOM 更新でいうと 仮想 DOM は使わず template literal を使い可変部分のみを最小限のチェックと更新で済ませるので高速です
しかし こっちは生の DOM 処理がメインでほぼライブラリ以外に書いた処理になるので DOM 操作で変なことしなければ速度面で負けることはないでしょう

あと消費メモリの点でも DOM 管理ライブラリは差分更新のためにいろいろな情報を保持しているので 通常はこっちのほうが消費メモリは少なくなるはずです

これらは WebComponents に限らず生の DOM 操作をすれば DOM 管理ライブラリを使うより優れているのは当たり前のことです
ですが 基本的には気にしなくて良い程度のものです
目に見えて重くなるときは 差分検出や DOM 更新の何倍や何十倍もブラウザのレンダリング処理に時間が掛かっているので 差分更新を高速化してもたいした改善になりません

とは言え ページャもなしで大量のデータを全部表示するみたいなことをやれば lit-element などは結構遅いです
メモリ使用量も明らかに多くなり 無限スクロールでは生 DOM だと表示できる量でクラッシュもあります
そもそもそういう作りをしなければ良いのですが 色々とそうせざるを得ないこともあったり これまで動いてたのに動かなくなるのでは困るということもあり 極稀に役立つときもあります
極稀に

無限スクロール自体はけっこうあるものなので React などでは仮想スクロールにして DOM 自体は軽めになるよう工夫されているのをよく見ます
ul の中に li が 1 万件ある場合 全部を表示すると重いので 画面表示される li のみ ul の子要素にします
それ以外の部分は空白を入れてスクロールバーの位置を調整します
重いページって基本繰り返しが多いので そういうのを使えばいいかなと思ったものの完全に置き換えれるものでもありませんでした

まず 仮想とはいえ 高さがわからないと空白部分の高さを決められません
li に表示するものが一行のテキストで 長い場合は 「...」 で省略などして高さは常に固定というものでは向いています
しかし 中身のテキストの長さで折り返し数が変わったり 高さが固定でない画像やブラウザのウィンドウ幅でそれぞれの li で高さが変わるというのだと難しいです
また DOM の実体がないので Ctrl-F で見えていないところの検索やジャンプが使えないなど これまでと全く同じ動きにできないなどの問題点がありました


もう一つの大きな理由はライブラリに依存しないことです
今回のはライブラリと呼びつつも大したことはしていません

  • コンポーネント外に影響を与える場合はツリー外(dataserver)で処理
  • view と dataserver 間で双方向にイベントを送る
  • view はルートコンポーネントから子コンポーネントにイベントを伝える

というルールがあるだけでコンポーネント自体は生の DOM 操作ですし DOM 管理ライブラリを使うもののように大きく作りが変わるわけでもないです

React や Vue は長めに続いてはいますが 移り変わりのペースの速いフロントエンド界隈です
5 年や 10 年後に今の形であるかはわかりませんし あっても jQuery みたいな立ち位置かもしれません
Web 標準の WebComponents を使っていて ライブラリ部分の内部処理がすごくシンプルで見たままというのはメリットと言える気もします

最後に

何度か書いてますが 特別な理由でもないと使わなくていいと思います
DOM 管理ライブラリをなにかの理由で使いたくなくて WebComponents をできるだけ生で使いたくて さらに通常の DOM イベントでのコンポーネント間のやり取りだと辛いというときのやり方のひとつ程度です
このまま使うことがなく忘れ去られる前に一応記事にまとめとこうと思ってまとめたようなものです