Web Componentsを待ち望んでいる話

某所でWeb Componentsについて少し話す機会があったんですが、下調べが不十分で誤った意見を述べてしまってました。代わりにこの記事を出すことで訂正とさせてください、という意味で書きます。


コンポーネントを実装するための基盤となる仕組み

Web Componentsは、Custom ElementsやShadow DOMなどのいくつかの技術から構成される仕様だ。この記事ではそれぞれの詳しい仕組みについては説明しない。必要に応じてこの辺の記事を参照いただきたい。

これらは一言でいうと、コンポーネントを実装するための基盤になる仕組みである。Reactなどのコンポーネントライブラリのようなことを標準のAPIを用いて実装できる。だからと言って「あーSPAとか作るときに使うやつね」で終わる話でなくて、普通のウェブサイトを実装するときにこそ求められる機能だと思っている。特にSPAじゃないけどそこそこJavaScript書くようなページで。

ライブラリを利用せずにコンポーネントを実装する基盤となる仕組みを持ち込める

コンポーネントライブラリのような仕組みを用いずに、コンポーネントのようなものを実装しようとするとなかなか難しい。テンプレートの管理、初期化のタイミング、状態の反映など、それぞれのコンポーネントがよしなに処理してくれれば済む処理をプログラマーが自分で制御しなければならないからだ。

例として、次のような要件を含むページを実装するとする。

  • サーバーで描画されるHTMLと、非同期的に追加されるHTMLに同じコンポーネントが含まれている
  • 非同期的に追加されるコンポーネントのイベント発火時に状態を出力する
  • それらのコンポーネントに影響を及ぼすページ固有の処理がある

単体で見るとそこまで難しくないが、組み合わさるとそこそこ見通しの悪いコードになる。コンポーネント固有の処理は簡易的に分離しつつ、ページ固有の処理はmain.jsに書くという設計で次のように実装してみた。

index.html:

<header>
  <h1>Awesome Web Components</h1>
  <button id="open-all">Open all of sections</button>
</header>

<section class="Disclosure">
  <h2 class="Disclosure__heading">
    <button class="Disclosure__trigger" type="button">About</button>
  </h2>
  <div class="Disclosure__content" hidden>
    <p>Lorem ipsum dolor sit amet consectetur adipisicing elit.</p>
    <p>Lorem ipsum dolor sit amet consectetur adipisicing elit.</p>
  </div>
</section>

<div id="products"></div>

components/Disclosure.js:

import EventEmitter from 'events'

const initializedEls = new WeakSet()

export const create = (rootEl) => {
  // prevent double initialization
  if (initializedEls.has(rootEl)) {
    throw new Error('Disclosure has already been initialized')
  }
  initializedEls.add(rootEl)

  const triggerEl = rootEl.querySelector('.Disclosure__trigger')
  const contentEl = rootEl.querySelector('.Disclosure__content')
  let isExpanded = false
  const emitter = new EventEmitter()

  const open = () => {
    if (isExpanded) {
      return
    }

    contentEl.hidden = true
    isExpanded = true
    emitter.emit('toggle', isExpanded)
  }

  const close = () => {
    if (!isExpanded) {
      return
    }

    contentEl.hidden = false
    isExpanded = false
    emitter.emit('toggle', isExpanded)
  }

  const toggle = () => {
    if (isExpanded) {
      close()
    } else {
      open()
    }
  }

  triggerEl.addEventListener('click', toggle)

  return {
    open,
    close,
    toggle,
    on: emitter.on.bind(emitter),
  }
}

export const template = ({ trigger, content, initialExpanded }) => `
  <section class="Disclosure">
    <h2 class="Disclosure__heading">
      <button class="Disclosure__trigger" type="button">${trigger}</button>
    </h2>

    <div class="Disclosure__content" ${initialExpanded ? '' : 'hidden'}>
      ${content}
    </div>
  </section>
`

main.js:

import * as Disclosure from './components/Disclosure'

const disclosures = []
let shouldOpenAll = false

// initialize to server side rendered markup
document.querySelectorAll('.Disclosure').forEach((rootEl) => {
  const disclosure = Disclosure.create(rootEl)
  disclosures.append(disclosure)
})

document.querySelector('#open-all').addEventListener('click', () => {
  if (shouldOpenAll) {
    return
  }

  disclosures.forEach((disclosure) => {
    disclosure.open()
  })
  shouldOpenAll = true
})

// load from Web API and append components
;(async () => {
  const res = await fetch('/api/products')
  const { products } = await res.json()
  const productsContainerEl = document.querySelector('#products')

  productsContainerEl.innerHTML = products
    .map(({ name, content }) =>
      Disclosure.template({
        trigger: name,
        content,
        initialExpanded: shouldOpenAll,
      }),
    )
    .join('')

  productsContainerEl.querySelectorAll('.Disclosure').forEach((rootEl, idx) => {
    const disclosure = Disclosure.create(rootEl)
    const product = products[idx]
    disclosure.on('toggle', (isExpanded) => {
      console.log(isExpanded)
    })
    disclosures.append(disclosure)
  })
})()

コンポーネント固有の処理は分離しつつも、main.jsで気にしなければいけないことが多過ぎる。いくつかある問題点としては次だ。

設計による問題もあるが、簡易的にやろうとする限りはどれも一長一短だろう。

これらの処理を上手いことやるためのライブラリはたくさんある。たくさんあり過ぎて、多くの人が混乱して嫌悪感さえ抱くほどに。

SPAを作らない人にとってそれらのライブラリは高機能過ぎる。機能はもっと最小限で必要十分だし学習量も減らせる。Web Componentsを利用すれば、ライブラリに振り回されることなく、簡単に堅牢なコンポーネントを実装できるようになるのだ。

先ほどの例をWeb Componentsを利用して書き直してみる。

index.html:

<header>
  <h1>Awesome Web Components</h1>
  <button id="open-all">Open all of sections</button>
</header>

<my-disclosure>
  <span slot="trigger">About</span>
  <p>Lorem ipsum dolor sit amet consectetur adipisicing elit.</p>
  <p>Lorem ipsum dolor sit amet consectetur adipisicing elit.</p>
</my-disclosure>

<div id="products"></div>

elements/my-disclosure.js:

export default class MyDisclosure extends HTMLElement {
  static get observedAttributes() {
    return ['open']
  }

  get open() {
    return this.hasAttribute('open')
  }

  set open(val) {
    if (val) {
      this.setAttribute('open', '')
    } else {
      this.removeAttribute('open')
    }
  }

  constructor() {
    super()

    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>
        #trigger {}
        #content {}
      </style>

      <section>
        <h2>
          <button id="trigger" type="button">
            <slot name="trigger"></slot>
          </button>
        </h2>

        <div id="content" ${this.open ? '' : 'hidden'}>
          <slot></slot>
        </div>
      </section>
    `
  }

  connectedCallback() {
    const triggerEl = this.shadowRoot.querySelector('#trigger')

    triggerEl.addEventListener('click', () => {
      this.open = !this.open
    })
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (name === 'open' && oldVal !== newVal) {
      const contentEl = this.shadowRoot.querySelector('#content')
      contentEl.hidden = !this.open
      this.dispatchEvent(new CustomEvent('toggle'))
    }
  }
}

main.js:

import MyDisclosure from './elements/my-disclosure'

customElements.define('my-disclosure', MyDisclosure)

let shouldOpenAll = false

document.querySelector('#open-all').addEventListener('click', () => {
  if (shouldOpenAll) {
    return
  }

  document.querySelectorAll('my-disclosure').forEach((el) => {
    el.open = true
  })
  shouldOpenAll = true
})

// load from Web API and append components
;(async () => {
  const res = await fetch('/api/products')
  const { products } = await res.json()
  const productsContainerEl = document.querySelector('#products')

  products.forEach(({ id, name, content }) => {
    const disclosure = document.createElement('my-disclosure')
    disclosure.open = shouldOpenAll
    disclosure.innerHTML = `
      <span slot="trigger">${name}</span>
      ${content}
    `
    disclosure.addEventListener('toggle', () => {
      console.log(disclosure.open)
    })
    productsContainerEl.appendChild(disclosure)
  })
})()

このようにかなり簡単にコンポーネントを実装できるようになった。初期化はHTMLが追加されたタイミングで自動的に行われ、ドキュメント上に存在するインスタンスdocument.querySelector()で取得できる。DOMの構造は隠蔽され、利用方法だけ知っていれば良くなった。

ページ上に存在するコンポーネントの複雑性が増えるにつれて、こうして設計が単純化された恩恵がより大きくなっていく。この辺はReactと変わらないが、より少ないコストで実現できるようになるというところが大きい。

ライブラリをまたいで利用できるコンポーネントを実装できる

固有のライブラリに依存しない実装になることで、複数のライブラリで互換性があるように作れるようになる。React、Vue.js、Angularなど、それぞれのライブラリ固有に実装されたコンポーネントは、基本的には同じライブラリでしか利用できない。Custom Elementsという標準仕様に則って実装することで、ライブラリは仕様に追従してそれをサポートできるようになるというわけだ。

これによって、様々な場面で利用できるUIコンポーネントがnpmなどで公開されやすくなることが期待できる。現状では、同じような機能のコンポーネントが、ライブラリごとに実装し直されて公開されることがほとんどである。互換性が容易に実現できるようになれば、コンポーネント提供側のメンテナンスコストが下がり、利用側のユーザーは機能を主体として選択できるようになる。これによって、プロジェクトとしても息の長いものになり、機能や品質が洗練されていきつつ、多様性も生まれやすくなるはずだ。

現在の課題としては、主要ライブラリがCustom Elementsの利用を十分にサポートしていないことがある。具体的にはReactにおいて、プロパティ(属性ではなく)を渡すこと、関数を渡すことを宣言的に書けない。

プロパティを渡すというのは、setAttribute()するのではなくてelement.prop = dataのようにすることだ。属性を経由すると文字列しか渡すことができない。配列やオブジェクトなどを渡すためにはプロパティを介する必要がある。

Vue.jsでは既に次のように実現できるようになっている。

App.vue:

<template>
  <div id="app">
    <my-dropdown :options.prop="options" @toggle="toggle"></my-dropdown>

    <form @submit.prevent="submit">
      <label>
        <input type="text" v-model="text">
        <button type="submit">add</button>
      </label>
    </form>
  </div>
</template>

<script>
export default {
  name: 'app',
  data() {
    return {
      text: '',
      options: [
        { key: 0, text: 'foo' },
        { key: 1, text: 'bar' },
        { key: 2, text: 'baz' },
      ],
    }
  },
  methods: {
    toggle() {
      console.log('dropdown toggled')
    },
    submit() {
      const lastKey = this.options.slice(-1)[0].key
      this.options.push({ key: lastKey + 1, text: this.text })
      this.text = ''
    },
  },
}
</script>

v-bind.propを介することでプロパティとしてデータを渡せる。Angularにも同様の仕組みがある

関数については、Reactは独自のSyntheticEvent(合成イベント)によってイベントをラップしていて、カスタムイベントを受け取れるようになってない。ちなみにPreactの場合は合成イベントを実装してないので、カスタムイベントをハンドリングすることはできたりする。とは言え今のところは自分でaddEventListenerするしかない。この問題を解消するために、ReactDOM.createCustomElementType()というAPIを追加しようみたいな議論をしている最中のようだ。

で、プロパティを通してデータを渡す場合のカスタム要素の実装は次のようになる。

my-dropdown.js:

export default class MyDropdown extends HTMLElement {
  set open(value) {
    this._open = value
    this._render()
    this.dispatchEvent(new CustomEvent('toggle'))
  }

  get open() {
    return this._open
  }

  set options(value) {
    this._options = value
    this._render()
  }

  get options() {
    return this._options
  }

  set selected(value) {
    this._selected = value
    this._render()
  }

  get selected() {
    return this._selected
  }

  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
    this._open = false
    this._options = []
    this._selected = null
  }

  connectedCallback() {
    this._render()
  }

  _render() {
    this.shadowRoot.innerHTML = `
      <div id="root">
        <button id="toggle" type="button" aria-expanded="${this.open}">${
          (this.selected !== null
            ? this.options.find(({ key }) => key === this.selected)
            : this.options[0]
          ).text
        }</button>

        <ul id="list" ${this.open ? '' : 'hidden'}>
          ${this.options
            .map(
              ({ key, text }) => `
                <li>
                  <button name="${key}" type="button">${text}</button>
                </li>
              `,
            )
            .join('')}
        </ul>
      </div>
    `

    this.shadowRoot.querySelector('#toggle').addEventListener('click', () => {
      this.open = !this.open
    })

    this.shadowRoot.querySelectorAll('#list button').forEach((buttonEl) => {
      buttonEl.addEventListener('click', () => {
        this.selected = Number(buttonEl.name)
      })
    })
  }
}

便宜上ドロップダウンと命名しているけど、かくあるべきな実装にはなってないので、必要に応じて適当な資料を参照いただきたい。

プロパティ周りの実装がいまいちイケてない感じがするのはライブラリで吸収するしかなさそうだ。Polymerはこの辺を細かく制御できるようになっているMyDropdown#_render()の実装も雑だけど、これもいい感じに書きたければライブラリでという感じになる。今あるものなら他にSkateJSとかlit-htmlとか。

とは言えライブラリを使わなくても、jQueryプラグインのようになんの秩序もないところから思い思いの仕様で実装することに比べれば、まともなものをはるかに作りやすくなる。Reactのような大層なものを持ち込まなくても、標準のAPIだけでうまくやれるようになるというのは大きな希望だ。

サーバーサイドレンダリング

Web Componentsを利用しても、テンプレートのサーバーサイドレンダリング(以下SSR)の問題は依存として存在する。GoogleSEOとFirst Meaningful Paintまでの待ち時間のことだ。

Googlebotは現在、Chrome 41相当の性能でページの描画を行なっている。そのためページの初期描画はその環境で失敗しないようにしておく必要がある。また、JavaScriptの実行はできるが、SSRすることが推奨されている。実際その方がインデックスされるのが早い気もする(体感で)。しかしGooglebotのベースとなっているChromeは、年来あるいは来年の頭にはアップグレードされるらしい。この辺は待っていれば諸々解決されることを期待したい。

First Meaningful Paintまでの待ち時間に関しては、もちろんSSRした方が速いがやりたくない。Web ComponentsをSSRするためのライブラリもあるけど、結局この辺が複雑になってしまうのはちょっと……という感じ。クライアントサイドでできる範囲の努力をした上で、ブラウザがいろいろ最適化してくれて速くなればいいなーくらいの温度感でいる。。。

BEMの単純化と強制

今のCSSの難しさは、規約を守り続けるということだと思う。まともな規約を作って、それが守り続けられればおかしなことにはならない。しかしBEMのような単純な規約であっても、さまざまな事情によって破られてしまうのが世の常だ。Web Componentsによって、BEMは規約でなく仕組みとして強制できるようになる。

Shadow DOMの中ではセレクタは閉じている。例えば、BEMで言うところのブロックと同じ粒度でカスタム要素を設計すれば、全てのクラス名にブロックをつけるようなことをする必要は無くなる。エレメントとモディファイアだけ意識すればいい。

export default class SiteHeader extends HTMLElement {
  constructor() {
    super()

    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>
        #root { ... }
        #heading { ... }
        #lede { ... }
        #nav { ... }
        #navList { ... }
        .navItem { ... }
        .navLink { ... }
        .navLink.-current { ... }
      </style>

      <header id="root">
        <h1 id="heading">Awesome Web Components</h1>
        <p id="lede">Web Components is awesome!</p>
        <nav id="nav">
          <ul id="navList">
            <li class="navItem"><a class="navLink" href="/ce">Custom Elements</a></li>
            <li class="navItem"><a class="navLink" href="/sd">Shadow DOM</a></li>
            <li class="navItem"><a class="navLink" href="/hi">HTML Imports</a></li>
            <li class="navItem"><a class="navLink" href="/ht">HTML Template</a></li>
          </ul>
        </nav>
      </header>
    `

    const currentLink = [...this.shadowRoot.querySelectorAll('.navLink')].find(
      (el) => el.href === location.pathname,
    )
    if (currentLink) {
      currentLink.classList.add('-current')
      currentLink.setAttribute('aria-current', 'page')
    }
  }
}

カスタム要素内にスタイルを書くことが強制されることにより、影響範囲が明確になる、宣言ブロックが正しい場所に書かれるようになる、ブロックの境界を意識しやすくなる、などさまざまな利点がある。マークアップが隠蔽されるという利点も見逃せない。

また<slot>要素に加えて、:host-context(<selector>)::slotted(<compound-selector>)は、コンポーネントの外から影響を受ける、外へ影響を及ぼすということをより明快に書けて素晴らしい。力尽きたので前述の記事とか読んでください。

あと、<style>要素内でSass書きたいって人はwebpackでそんなに難しくなくできるはず。to-string-loader ← css-loader ← postcss-loader ← sass-loaderという感じで動いた。

気持ち

僕がこれに期待する動機としてはJavaScript周りの理由が大きい。正直全部Reactで組める環境であれば別にいらないかなという気持ちもある。けど仕事としては、いわゆる普通のウェブサイトを堅牢に運用性高く作っていくというのを主軸にしているし、これからもやっていくつもりなので、そういったときにここまで欲しいものは他に見つかってない。

ただ、これが使えるようになるためにいつまで待ち続けるのかという問題がある。IE 11サポートを謳っているポリフィルはあるが、試しに使ってみると厳しすぎて絶対IE無理という感想になった。そこで頑張るのも不毛なので、趣味のアプリでも作って素振りしつつ消滅まで待つつもりだ。どうせそのころにはEdgeやFirefoxにも実装が済んでいるだろうとある意味楽観的に考えている。Shady CSSなんてものは知らない。

早く未来を見たい。