コンポーネントに期待していたことと分かったことについて考える - 2023秋(2)

まとめ

Aral Balkan — Sites vs. Apps defined: the Documents‐to‐Applications Continuum. における「アプリ」全振りでない場合に HTML と CSS を結びつけ、適度に再利用しやすい「コンポーネント」を考えるに当たり、Lit の使い勝手を確認した。

  • LitElement の書き味は html, css を利用した tagged template literal, property の変化に応じて render() が自動実行される仕様など、素の Custom Elements に比べてだいぶよい。しかも JS-centric 過ぎず、HTML, CSS を主戦場とする人にもめちゃくちゃ抵抗感があるわけではなさそう。
  • デフォルトで Shadow DOM を利用するため CSS の影響がリークしないのもマル
  • Lit で UI を、Lit の外で機能を実現するために Controller を追加するのがよさそう
  • Stimulus は生 DOM 上の Controller として悪くない。React で言う Container / Presentational Pattern のような組み合わせを Stimulus / Lit で実現できる

前回は

次回は

Litを試してみる

前回 Custom Elements を試した時 にはギリギリ Lit 1.x だったが、直後に 2.0 がリリースされていた

Litの書き味

Lit で作れるのは Custom Elements 独自要素である。基本の書き味はこんな感じ。

// import { LitElement, html, css } from 'lit'
import { LiElement, html, css } from https://cdn.jsdelivr.net/gh/lit/dist@2/core/lit-core.min.js

export class MyButton extends LitElement {
  static styles = css`
    :host::part(container) {
      display: inline-block;
      padding: 0;
      border-radius: 0.2rem;
      border: 1px solid;
    }

    :host::part(button) {
      margin: 0;
      padding: 0.25rem 0.4rem;
      border-radius: 0.2rem;
      border-color: transparent;
    }
  `

  render () {
    return html`
<span class="container" part="container">
<button type="button" part="button"><slot></slot></button>
</span>
    `
  }
}

customElements.define('my-button', MyButton)

使う側は

<script type="module" src="my-button.js"></script>

<my-button>ラベル</my-button>

こんな感じ。

class 構文を使うので例えば Vue の SFC に比べるといかにも JS, JS してるように見えなくもないが、HTML, CSS は tagged template literal で実現しているため、基本的にはそのまま書けばそのまま表示される、素の HTML, CSS と遜色ない書き味で、JSX や CSS-in-JS など特別なものを要求しない。この基本の部分だけなら HTML, CSS を主戦場とする人に馴染みやすく、特にデザインの手直しなどで手間取りにくそうで好感が持てる。

またデフォルトで Shadow DOM を構築するので、この中に書いた CSS が外に影響することはない。Vue の Scoped CSS のようなものが生 DOM でデフォルトで実現できている。仮に CSS が長くなって tagged template で書くのがしんどいということであれば別ファイルに逃してもよい。その場合でも CSS のスコープには影響しない。

template literal を利用している点については、JSX よりも新しい、ES 2015 以降の「標準」であり、単なる template を超えてなんでも書けてしまうという課題はあるものの、標準の JavaScript をそのまま利用できるのはよいと思う。

Litの特徴

Lit は基本的にはあまり多くの機能を積んで分厚くする方向でも JS の最適化を強力に行なって高速にするという方向でもなく、Custom Elements を実現する際の boilerplate を減らしつつ、イマドキこれくらいは実現しておいてほしいという最低ラインを達成、容量や動作速度へ多くの影響を与えないことを意識している。Lit Element を書く際に特別な構文は必要なく、上のコードを実現するのにトランスパイラ、バンドルシステムは必要なく、今後のバージョンアップでもガラッと書き方が変わってしまうとか、そういう心配はほぼないと言ってよいだろう。

機能としては、上では端折っているが、

  • そもそもの Custom Elements が持っている DOM との interaction に対応する Lifecycle callback がある(conneced, disconnected, attributeChanged など)ほか、updated() など独自の hook も用意されている
  • attributes と自動で連動する properties を作れる
  • properties の変化には自動で追随し render() が呼ばれるため、いわゆる reactive な動作をする

これだけでだいぶイマドキな感じである。公式のサンプルだと

Playground – Lit

この辺りを見てもらうと、属性を外から与えた場合と与えていない場合の初期値の違いを受け取りつつ、その後トグルするという動作は共通のものになっている。render() はあくまで値をハメた html を return しているだけで、値を書き換える処理は含まれていない。property の中身が togglePlanet() の中で書き換わり、それに応じて render() が自動で呼び直されているので、実際に DOM を書き換えるための処理の流れが省かれていて、本当に必要なことだけが書かれているので見通しがよい。

で、再利用性は?

ちょっと待った。Lit は標準的な記法だけで素直に読み書きしやすい、CDN からすぐに利用できる、いいだろう。標準的な Web Components なのでどのフレームワークとも競合しないという意味では再利用性は高いかもしれない。VDOM ではないので下の階層に波及しないか? slot を使えばある程度イケそうだが、Stimulus ほど確実にコンポーネントの数を削減できるかどうかはなんとも言えない。

また、機能も一緒に作り込めてしまうので結局再利用性に課題が残るのでは?という疑問もある。

Lit のサイトには

The first thing to know about Lit is that every Lit component is a standard web component. Web components have the superpower of interoperability: natively supported by browsers, web components can be used in any HTML environment, with any framework or none at all.

This makes Lit an ideal choice for developing shareable components or design systems. Lit components can be used across multiple apps and sites, even if those apps and sites are built on a variety of front-end stacks. Site developers using Lit components don’t need to write or even see any Lit code; they can just use the components the same way they do built-in HTML elements.

Lit is also perfect for progressively enhancing basic HTML sites. Browsers will recognize Lit components in your markup and initialize them automatically–whether your site is handcrafted, managed via a CMS, built with a server-side framework, or generated by a tool like Jekyll or eleventy.

と書かれている。

ん? 待てよ。機能を中に持たなければよいのでは?

確かに、前回見た Lit を利用したデザインシステム、見事に UI は実現できているが、何か特定の機能を実現しているわけではない。あくまで UI だけである。UI は中に UI を実現するための機能(状態)は持つが、何か特定の値を取得するような機能はコンポーネントの中にはない。

もともと自分はコンポーネントが Store を持って Store が様々な機能を持つことに疑問を持っているのだから、そうじゃない作りにすればよいのでは?

デザインと機能を分離して再利用性を高める

コンポーネントは状態を反映した値に対してのみ責務を持つ

上記の書き味で試したボタンの例をもう一度利用する。

<my-button>ラベル</my-button>

こういうやつ。

ボタンが果たす役割は様々で、その場でただ値が変わるだけの公式サンプルのようなボタンもあるかもしれないが、なんらかの情報を送信するボタンもある。この場合、送信処理が完了するまでそのボタンは利用不能にしたい

利用不能には少なくとも二種類あって、ここでは以下のように定義する。

disabled
前提条件が整っていないので利用できない
inprocess
現在処理中なので処理中であることを伝えつつ利用できない

伝統的な HTML 要素には disabled という属性はあるが inprocess という属性はない。しかし Custom Elements なんだから、inprocess という属性がないなら作ってしまえばよい。

  static properties = {
    inprocess: { type: Boolean }
  }

このコンポーネントを利用する側の HTML はこんな感じ。

<my-button inprocess>ラベル</my-button>

これが [ 処理中 ] みたいなラベルになってもなんらかの spinner みたいなアニメーション表現になってもよいが、状態の表現としてはこの属性の有無で表現可能だ。

あとはそのビジュアル表現だが、この特徴的な属性に引っ掛けて CSS を書けばよいので、

:host([inprocess]) {
}

だけでもできることは広がるし、コンポーネントの中身を書き換えて

  render () {
    return html`
${this.inprocess ? this.renderInprocess() : this.renderButton()}
`
  }

みたいにして HTML 構造そのものを差し替えてもよい。

このように、実際にはなんらかの処理をこのコンポーネント内部で行なっているわけではないが、処理中の状態を作り込むことはできる。

Controllerを別途導入する

コンポーネントには機能を持たないとなるとどうにかしてコンポーネント外に機能を持たせないと UI は実現できるが実用的ではないものになる。これはオリジナルで実装せずに公開されているデザインシステムを利用して UI を実現しても同じである。

ということで実際の機能の作り込みはコンポーネントの中ではなく外側で行いたい。ではどうやって実現するか。

LitのReactive Controllerを利用する

実は

  • class LitElement extends ReactiveElement
  • class ReactiveElement implements ReactiveControllerHost

の関係になっていて、LitElement は addController() メソッドから ReactiveController を受け取ることができる。1

これ、以前の日記

View FrameworkインスタンスをControllerに預けて管理してもらう現実的で雑なClean Architecture (2019-03-02) | あーありがち

で書いたものと似ている2。Controller に View Framework を預けるという発想はまったく同じだ。まぁそうなるよなぁという感想。

では ReactiveController が何をするものかというと、簡単に言うと Lifecycle Callback をコンポーネントの外に書けるというものらしい。

…これだけだと物足りないような…? callback が走っているということはすでに内部でなんらかの property などの変化が起きているということであり、その変化はどこで起きるのか? 中に値の変化を起こす機能が記述されているのでは?という問題があるような気がする。オフィシャルの API があるのはよいことだけど、うーん? という印象は拭えない。

Stimulus Controllerを利用する

Lit は環境を選ばないので、逆に言うと Controller で制御する範囲、Controller の実現方法はその環境ごとに変わってくる可能性がある。上記の ReactiveController は恐らく LitElement と密になっているおかげで生 DOM だろうが VDOM だろうが同じように機能するだろう。しかし、こと生 DOM に限ってしまえるのであれば生 DOM で Controller を実現する方法を採用してしまってもよい。この場合、我々にはすでに Stimulus というシンプルな Controller がある。

ただ、

始めに言っておくと Lit の ReactiveController とは考え方が逆

で、attribute のセットと、event listener, handler を Stimulus Controller 側に書くことになると思う。

  • Stimulus には value を attribute にマップする機能がある
  • LitElement には attribute を property にマップする機能がある
  • LitElement には property を監視し、自動的に render() を呼ぶ機能がある

これを組み合わせて、必要な値を Lit の外から attribute で渡してあげれば Lit がいい具合に render するという仕組みである。

では具体的に見ていく。Stimulus には前回も触れたが HTML 側の記述がややまどろっこしいという問題がある。もう少し詳しい内容と動作サンプルを以下に置いておく。

簡単に言うと

class ButtonController extends Controller {
  static targets = ['label']
}
<span data-controller="button">
  <button><span data-button-target="label"></span></button>
</div>

みたいな感じで、

value を ${} や `` といった形で埋め込むことができず、target element を用意してその中を書き換える必要がある

わけだけど、逆にこの部分は Custom Elements を利用するなら無視してよい。例えば今回の上の my-button を利用するなら

<my-button
  data-controller="button"
  data-button-inprocess-value="false"
>ラベル</my-button>

みたいに書けば、以下のような Stimulus Controller で制御可能になる。Custom Elements 内部の HTML 構造は LitElement 側の責務になるので target は不要だ。

export class ButtonController extends Controller {
  static values = {
    inprocess: Boolean
  }
}

ただ、Stimulus の value を Lit で受け取るには一工夫が必要で、以下のように

export class MyButton extends LitElement {
  static properties = {
    inprocess: {
      attribute: 'data-button-inprocess-value',
      converter: {
        fromAttribute: (value) => {
          return !(value === '0' || value === 'false')
        }
      }
    }
  }
}
  1. attribute を Stimulus が強制する名前に合わせる
  2. 本来 attribute の有無が Boolean として機能するのだが、Stimulus が attribute の有無ではなく値のセットを強制するので custom converter で Boolean にマップする

といった記述が必要になる。

2 の際のルールは以下

Stimulus Reference

のように '0''false' でなければ true という扱い。

converter は boolean に関してはすべて同じなので function を起こして使いまわせばよい。

これで Stimulus Controller 側で

this.inprocessValue = true

を実行すればボタンの無効化は実現できる。

いずれにせよEvent Listener, Handlerによって制御をControllerに持ってきたいかも

もう一度上の図を持ってくると、

この element から controller に伸びている value と event、特に event の部分である。

例えば Lit ベースのコンポーネント集として

  • Shoelace
  • Spectrum

を参考にすると、

みたいにイベントが割り当てられている。これを拾って「処理を Controller 側で書く」ことにすれば、機能そのものを Controller で実現することができる。逆に言うと、出来合いのコンポーネントは内部に手を出せないのでこの方法でないと機能を実現できない。3

オリジナルの Element x Stimulus の場合は valueChanged callback もあるので、そちらも活かせる。いずれにせよ、

DOM 経由で値やイベントを伝達できるので、生 DOM 環境に限れば DOM に依存してしまうことで Element の外に機能を持ち出すことができる

このコンポーネントの中と外に分けて作るやり方は、機能もビジュアルもゼロベースで作らなければいけない場合にはだいぶまどろっこしいことになってしまうが、UI 側の中身がすでにできている前提、例えば Shoelace や Spectrum などで UI を実現するのであれば、間違いなくコンポーネントの再利用は達成できており、課題に感じていたコンポーネントの再利用性については、それなりにいい具合に解決できるのではないかと思う。少なくとも

「コンポーネントを利用していない部分については自由にマークアップすればよいし、コンポーネントを利用する部分はこういう作法で書いておいてくれ、あとで機能を足すから」

といった感じで進めることは十分可能なのではないかと感じる。

  1. そして Controller には Element 自身を与えるので循環参照になってしまうんだけど、これはいいんだろうか… 

  2. この時点では Vue 2 を前提にしているので Vue が汎用的に提供してくれていた Vue VM 内外を event で繋ぐという方法になっているが、Vue 3 でこの API は廃止されたので、現在はこのままで同じ役割を実現することはできない。 

  3. 継承して独自 Element を作ってもいいっちゃいいが、Container/Presentational Pattern など考えても View, UI を担うコンポーネントの内部に機能を持つのは好ましくない。 

More