JavaScript
TypeScript
ShadowDOM
WebComponents
lit-html

関数だけで Web Components を表現する FP ビューライブラリ Ullr を書いた

Ullr というライブラリを書きました。

この記事の API は一部古いです。最新版は GitHub でご確認ください。


最近は個人的に、Web Components を書く機会が多いです。

バニラの Web Componentsは HTMLElement を継承していくのでオブジェクト指向なプログラミングスタイルをとることになります。

ただ、純粋な Web Components だと、コンポーネントの受け取れる値がすべて文字列になるという制約と、テンプレート中に書いた Web Components と実際に呼び出されるコンポーネントの連続性をコードで表現できないという問題があり、とくに後者はストイックでもなんでもなく単純に非効率です。

そこで、テンプレートとコンポーネントの連続性を表現できる Web Components を目指したところ、すべてを関数で表現する、という試みにたどり着きました。

TL;DR

関数型プログラミングによる Web Components の開発のために Ullr というライブラリを書きました。読み方は ウル です。1

テンプレートの表現には lit-html を使います。2

基本的な使い方は次のとおりです。

  • lit-html を使ったテンプレート関数を組み合わせてコンポーネントを表現する
  • component を使ってテンプレートをカプセル化する
  • customElements を使って Custom Elements を定義する
  • subscribe を使って RxJS の Observable を購読しビューを更新する

設計段階ではいろいろ考えたんですが、結果的にはシンプルな API に落ち着きました。

インストール

Ullr は lit-html に依存しているので、lit-html も一緒にインストールします。状態管理はなんでもよいですが、RxJS と合わせて使うことを想定します。

npm i ullr lit-html rxjs

Ullr と lit-html と RxJS を npm プロジェクトにインストールします。

APIs

Ullr はビューを担う API を 2 つ、振る舞いを担う API を 1 つエクスポートしています。TypeScript を推奨しているので、以下のコードはすべて TypeScript です。

component

lit-html の戻り値を Shadow DOM でカプセル化する関数です。Shadow DOM なので、CSS のスコープは気にせずテンプレート内にそのまま Style 要素を書きます。PostCSS とセットで使うと便利です。

component の戻り値も lit-html の戻り値と同じ型なので、他のテンプレートの一部として使うことができます。

import { component } from 'ullr'
import { html } from 'lit-html'

const template = (title: string) => html`
    <style>
        h1 {
            font-weight: 400;
        }
    </style>
    <h1>${title}</h1>
`

const app = (title: string) => component(template(title))

component には今のところライフサイクルコールバックとかはありません。

また、Atomic Design のようなコンポーネントの階層構造については、lit-html を返す関数と、その関数のカリー化などで完全に表現できるはずです。なので、Ullr ではテンプレートのカプセル化だけを行う API に留めました。

customElements

Custom Elements を定義するのに必要なクラスを返す関数です。この関数の戻り値を window.customElements.define に渡すことで Custom Elements を定義します。

第1引数には、lit-html のテンプレートを返す関数をとります。第2引数はオプショナルで、Custom Elements が変更を監視する属性名を配列で渡します。第2引数の値が Custom Elements の observedAttributes になるイメージです。

第1引数の関数の引数には、第2引数の配列の順番通りに属性値が渡ってきます。属性値が変更されたらその都度、自動的に再レンダリングします。

例えば 3 つの属性を監視して、変更時に再レンダリングする場合は次のような感じです。

const XApp = customElements(([attrA, attrB, attrC]) => app(attrA, attrB, attrC), [
    'attr-a',
    'attr-b',
    'attr-c'
])

先の component の項で作ったコンポーネントを登録するなら次のような感じです。

import { component, customElements } from 'ullr'
import { html } from 'lit-html'

// ... 中略 ...

const XApp = customElements(([title]) => app(title), ['title'])

window.customElements.define('x-app', XApp)

いつ customElements を使うか?

Custom Elements にする場合、テンプレートとクラスの連続性は失われます。Custom Elements に値をプロキシするだけのテンプレート関数を挟めば問題ありませんが、それだけの理由であれば component で十分でしょう。

customElements を使って Custom Elements にする必要性はその要素を外部に公開するかどうか、で変わってきます。それ以外の場合は component を使うか、DOM へのアタッチであれば lit-html の render 関数で十分です。

subscribe

RxJS の Observable を購読してテンプレートを更新するための、lit-html の directive です。lit-html のテンプレート内で使います。次のような感じです。

import { subscribe } from 'ullr/directive'
import { timer as _timer } from 'rxjs'
import { take, filter } from 'rxjs/operators'

const timer = _timer(10, 1).pipe(
    filter(x => x > 0),
    take(10)
)

const template = html`
    <main>
        ${subscribe(
            timer,
            x => html`<p>${x}</p>`,
            html`<p>Default content</p>`
        )}
    </main>
`

第1引数に Observable、第2引数に更新時のテンプレートを返す関数、第3引数はオプショナルで初期状態のテンプレートを渡します。

この関数の対象ノードが DOM から取り除かれたときには自動的に unsubscribe します。対象ノードが DOM から取り除かれたときというのは、このテンプレートを含んだ上位のテンプレートによって何らかの条件で、このテンプレートが使われなくなったときです。

開発スタイル

Ullr を使った場合、ほとんどのコードは lit-html の関数か、その関数のカリー化を処理するものになると思います。僕自身がまだプロダクションで使ってないので不都合が出てくるかもしれません。

関数型プログラミングによる恩恵を Web Components でも受けることができるので、これから積極的に使ってバグを見つけていきます。よろしければご協力ください。


Double O は Web Components だけで作ったサービスですが、Ullr を使ってリファクタリングしたいと思ってます。


  1. 分かる人は分かると思いますが厨二っぽいです。 

  2. lit-html は Tagged template literals を使ってテンプレートを表現できるので、関数型プログラミングに適しています。