JavaScript
ShadowDOM
WebComponents
Polymer
lit-html

lit-htmlとバニラWeb Componentsでコンポーネントを実装する

Web Components は現在、Chrome と Safari でポリフィルなしで使うことができます。( 参考: Can I use: Custom Elements, Can I use: Shadow DOM )

Web Components ではコンポーネント開発に適した HTML 標準の API を使って、UI ライブラリに頼らずにコンポーネントベースのアプリケーションを構築できます。ポリフィルが必要な場合には Polymer から分離した webcomponents.js があるので、今から、標準の Web Components で開発ができる環境が整っていると言えます。

今回、標準の Web Components を使ったコンポーネント開発と、それを便利にする lit-html との組み合わせを比べてみます。最後に Polymer によって拡張された Extended Web Components を使う場面についても触れています。

*このポストのなかでは Web Components と Custom Elements を区別していませんが、Web Components とは Custom Elements や Shadow DOM などの仕様の総称であることに留意してください。

TL;DR

  • Vanilla Web Components ではテンプレートがつらいのでは
  • lit-html を使ってテンプレートを書くと効率的
  • 必要があれば Polymer を Web Components の mixin として使う

lit-html は Google の Polymer チームが開発したライブラリで、HTML in JS を Tagged template literal によって記述します。

hyperHTML などに似ていますが、lit-html が提供する API は必要最小限であり、網羅的なエコシステムも作られていません。

Web Components などと組み合わせて使うことを想定していると思っていいと思います。

Vanilla Web Components

まずは標準だけで作る Web Components を考えます。

MDN のサンプル を元に少し削ってシンプルにしたものがこちらです。

js
class XProduct extends HTMLElement {
    constructor() {
        super()
        const shadow = this.attachShadow({mode: 'open'})
        const img = document.createElement('img')
        img.alt = this.getAttribute('data-name')
        img.src = this.getAttribute('data-img')
        img.width = '150'
        img.height = '150'
        img.className = 'product-img'
        shadow.appendChild(img)
        img.addEventListener('click', () => {
            window.location = this.getAttribute('data-url')
        })
    }
}
customElements.define('x-product', XProduct)
html
<x-product data-name="JavaScript" data-img="https://s3-us-west-2.amazonaws.com/s.cdpn.io/4621/javascript.png" data-url="http://example.com/2"></x-product>

Playground: https://jsfiddle.net/aggre/qL6epsd8/

このコンポーネントは this.attachShadow({mode: 'open'}) により Shadow DOM を使っているので、<style> タグを innerHTML で挿入すれば Scoped CSS を使うこともできます。

とはいえ、テンプレートの生成は createElement などを書く必要があり、一般的な規模のアプリケーションをこれだけで作るのはつらそうです。

HTML Templates を使えば宣言的に表現できるものの、<li> を使うようなダイナミックなリスト状の UI には使えません。

効率的な更新も難しくなります。実際にはより効率的な差分更新が求められているはずです。

Vanilla Web Components + lit-html

先と同じコンポーネントを lit-html を使うとこのように書けます。

js
import {html, render} from 'lit-html/lib/lit-extended'

class XProduct extends HTMLElement {
    constructor() {
        super()
        const name = this.getAttribute('data-name')
        const img = this.getAttribute('data-img')
        render(this.html(name, img), this.attachShadow({mode: 'open'}))
    }

    html(name, img) {
        return html`<img alt="${name}"
            src="${img}"
            width="150"
            height="150"
            class="product-img"
            on-click="${() => this.onClick()}" />`
    }

    onClick() {
        window.location = this.getAttribute('data-url')
    }
}
customElements.define('x-product', XProduct)

テンプレートがだいぶ自然に書けるようになりました。

メソッドもすっきりしています。

html にテンプレートリテラルを渡して、返ってきたテンプレートオブジェクトを render に渡すことで任意の DOM に HTML を生成します。DOM を this.attachShadow({mode: 'open'}) とすれば Shadow DOM にマウントできます。

この例では 'lit-html/lib/lit-extended' から html を import していますが、これはイベントハンドラを使うときに必要な API が含まれている(拡張された) html を利用するためです。

イベントを使うことがなければ、より軽量な html を使うこともできます。

ライフサイクル

コンポーネントのライフサイクルは Web Components にもあり、一般的な UI ライブラリのように扱うことができます。

js
class XProduct extends HTMLElement {
    static get observedAttributes() {
        return ['data-name']
    }
    constructor() {
        super()
        console.log('constructor')
    }
    connectedCallback() {
        console.log('connectedCallback')
    }
    disconnectedCallback() {
        console.log('disconnectedCallback')
    }
    attributeChangedCallback(attributeName, oldValue, newValue, namespace) {
        console.log('attributeChangedCallback', attributeName, oldValue, newValue, namespace)
    }
}
customElements.define('x-product', XProduct)

このようなコンポーネントを書いたうえで、次のようにコンポーネントに対して変更を加えてみます。

js
setTimeout(() => {
    document.querySelector('x-product').setAttribute('data-name', 'NewJavaScript')
}, 100)

setTimeout(() => {
    const element = document.querySelector('x-product')
    element.parentNode.removeChild(element)
}, 200)

このときコンソールには次の順番で出力されます。

- constructor
- attributeChangedCallback data-name null JavaScript null
- connectedCallback
- attributeChangedCallback data-name JavaScript NewJavaScript null
- disconnectedCallback

監視対象の属性をマウント時にも持っている場合は attributeChangedCallbackconstructor の後にも呼ばれます。

監視対象の属性がマウント時点で存在しない場合は、その属性が出現するまで attributeChangedCallback が呼ばれることはありません。

アップデートに対応する

先ほど作ったコンポーネントを属性のアップデートによる再レンダリングに対応させてみます。

アップデートのタイミングでは render に対して既存の Shadow DOM を渡す必要があるため、次のような関数を作って render を制御しておくと便利です。

lib/render.js
import {render} from 'lit-html/lib/lit-extended'

export default (html, component) => {
    return render(html, component.shadowRoot || component.attachShadow({mode: 'open'}))
}

コンポーネントはこうなります。

js
import {html} from 'lit-html'
import render from '../lib/render'

class XProduct extends HTMLElement {
    static get observedAttributes() {
        return ['data-name']
    }

    constructor() {
        super()
        this.state = {}
        this.state.img = this.getAttribute('data-img')
    }

    attributeChangedCallback(_, __, name) {
        render(this.html(name, this.state.img), this)
    }

    html(name, img) {
        return html`<img alt="${name}"
        src="${img}"
        width="150"
        height="150"
        class="product-img"
        on-click="${() => this.onClick()}" />`
    }

    onClick() {
        window.location = this.getAttribute('data-url')
    }
}
customElements.define('x-product', XProduct)

変更の都度テンプレートを更新するコンポーネントが標準の API だけで作れました。

コンポーネント内で使用したい変数を this.state に入れていますが、もちろんこれは自由です。getter, setter を定義してもよいと思います。

Polymer

lit-html はあくまでも HTML を扱うライブラリです。

Polymer 含め一般的な UI ライブラリでは可能な、配列オブジェクト を属性から渡すことはできません。この制約を受け止めてストイックに設計するのもアリですが、どうしても辛いときは素直に UI ライブラリを使うのが早いでしょう。

これらを使いたい場合には Polymer が有用です。

Polymer は HTMLElement を拡張しているので、先ほどのコンポーネントの extends を HTMLElement から Polymer に変更してもそのまま動作します。

この使い方ができるのは Polymer 3.0 からです。現在( 2017/10/18 )の安定版 v2.1.1 では依存解決に HTML Imports が使われるため js からは import できません。

import {Element as PolymerElement} from '@polymer/polymer/polymer-element'
import {html} from 'lit-html'
import render from '../lib/render'

class XProduct extends PolymerElement {
    static get observedAttributes() {
        return ['data-name']
    }

    constructor() {
        super()
        this.state = {}
        this.state.img = this.getAttribute('data-img')
    }

    attributeChangedCallback(_, __, name) {
        render(this.html(name, this.state.img), this)
    }

    html(name, img) {
        return html`<img alt="${name}"
        src="${img}"
        width="150"
        height="150"
        class="product-img"
        on-click="${() => this.onClick()}" />`
    }

    onClick() {
        window.location = this.getAttribute('data-url')
    }
}
customElements.define('x-product', XProduct)

HTMLElement から Polymer に変更してもそのまま動作することが分かりましたが、これではただの Web Components のままです。テンプレートを Polymer の文法に合わせることで Polymer のデータバインディングを活用することができます。

import {Element as PolymerElement} from '@polymer/polymer/polymer-element'

class XProduct extends PolymerElement {
    static get template() {
        return `<img alt="[[dataName]]"
        src="[[dataImg]]"
        width="150"
        height="150"
        class="product-img"
        on-click="onClick" />`
    }

    static get properties() {
        return {
            dataName: {
                type: String
            },
            dataImg: {
                type: String
            }
        }
    }

    onClick() {
        window.location = this.getAttribute('data-url')
    }
}
customElements.define('x-product', XProduct)

この例では文字列しか受け渡していませんが、配列やオブジェクトも渡すことができます。

また、双方向 or 単方向のデータバインディングも可能です。

UI ライブラリを使う以上はまったく同じ文法というわけにはいきませんが、それでも標準の API を共有しているので、Polymer であれば Vanilla/Non-Vanilla の共存がしやすくなります。

Web Components のベネフィット

  • ライブラリのオーバーヘッドがない
  • バンドルサイズを削減できる
  • テンプレートシステムを交換可能

標準の API であることに起因したベネフィットがまずあります。

サポートも心配ないでしょう。

Web Components は標準化されたライフサイクルを使えるクラスにすぎないため、UI ライブラリに縛られることなくテンプレートを開発できることの意義は大きいと思っています。

私は社内のプロジェクトで lit-html + Web Components を採用しましたが、標準 API だけで構築することができました。ちなみに Flowtype で型も利用しています。

Appendix

  1. lit-html の API は https://github.com/PolymerLabs/lit-html にまとまっています。
  2. Polymer の日本語コミュニティ Polymer Japan が 11 月 4 日に Google で勉強会をやります。10 月 23 日から参加者募集をはじめるので、ぜひ先にメンバー登録をしてください✨