今さらWeb Components

名前だけ知ってたんだけど、なんとなく深入りせずにきていた Web Components について改めて調べてみた。

なお、前提条件として自分には以下の経験があるので、その辺の知識がベースになって差分で理解しようとしている。したがって以下はその辺の知識が説明なしに出てくる。

  • Vue.js ( v2 / v3 ) / Mithril など Virtual DOM 系フレームワーク
  • Angular.js ( v1 )
  • Backbone.js ( jQuery / Underscore )
  • Stimulus ( v2 )

Web Componentsは総称

MDN の解説によると

Web Components | MDN

以下の3つから成る。

  • Custom Elements
  • Shadow DOM
  • Template and Slot Element

Google の Web Fundamentals によると

Building Components  |  Web Fundamentals  |  Google Developers

  • Custom Elements (v1)
  • Shadow DOM (v1)

のようだ。

以下は基本的に MDN の解説をもとに実際に手を動かしながら確認していく。

Custom Elements

基本ルール

  1. HTML tag として文法に違反していなければどんな tag を書いてもよい
    • とりあえずスルーで Inner HTML が出力される
    • 挙動を追加したい場合は Custom Elements の定義を扱う JavaScript を追加する必要がある
  2. 既存の tag とぶつからないように hyphen が必要
  3. Shadow DOM が有効になると Element ではなく DOM Tree の親 ( Shadow Host ) に変身する
    • 与えた Inner HTML も含めてそのままでは何も表示されない
  4. built-in のものを継承した要素も作ることができる。その場合はデザインなどもそのまま継承される。
  5. life cycle callback が用意されており、要素のレンダリングや削除に応じた処理を追加できる

Autonomous 自律的な Element

HTML は定義済みの要素以外にも文法的にエラーにならなければ自由な要素を作って書くことができる。

<custom-element>
  JavaScript による定義があろうがなかろうがここはそのまま出力されます
</custom-element>

ただし、ほんとに素通しでただ中身が「見える」というだけ。

この勝手に作った要素に対して独自の定義を行うには ES2015 class を書き、customElements.define() で CustomElementRegistry に定義を追加する必要がある。

// class を作り
class <CustomElement> extends HTMLElement {
  constructor () {
    super() // need it
  }
}

// registry に登録する
customElements.define('custom-element', CustomElement)

このとき気をつけなければいけないのは constructor を書く場合は super() を呼ばなければいけないこと。これは custom element 云々ではなく JavaScript の class の制限。constructor は不要なら書かなくてもよい。

define() の引数の1つめが名前で、2つめが class になっている。ここで 名前には hyphen が必要 。この制限があるおかげで既存の要素とぶつかる心配がない。

この定義は HTML 上で実際に要素が書かれる前であっても後であってもよい。どちらでも動作する。既存の DOM ツリーに足して何らかの操作を加える jQuery 以前の世界観ではやや驚くものだが、JavaScript 側から DOM 側に対して何も干渉しておらず、ブラウザが自分で紐付けを行うのでこれで問題ない。

※ もちろん class 定義と define() を別ファイルに分けるのはよいアイディアなので、その場合は export / import を利用するが、ここでは割愛する。

この Custom Elements には life cycle callback が用意されている。これは Virtual DOM を利用する ViewFramework や Stimulus や Svelte など、多くのライブラリに採用されているアイディアであり、これを利用したことがある人には馴染みのあるものと言える。1

コンテンツを中心に見ている場合、確かに Custom Elements ではあるが、このままでは面白くもなんともない。div に class を当てたり style を当てたりするよりも明確な名前空間を持ってはいるが、それだけ。

恐らくこれは Shadow DOM を利用した独自のツリーを構築するためのものなので、その点を踏まえて後述する。

余談だが、hyphen を必要とするルールは他のフレームワークにも似たようなものが採用されている。

Vue のスタイルガイドにはコンポーネント名は複数単語で命名することが Essential Rule として定義されており、これによって必然的に単語の区切りが発生し、hyphen などを利用するインセンティブになる。(Vue のガイドでは kebab-case よりも PascalCase を推奨しているが、いずれにせよ絶対に衝突はしない。)

Customized built-in Element

ES2015 class を利用して定義を行い、customElements.define() を理由して定義を追加するのは同じだが、一部書き方が異なる。

class <CustomElement> extends HTMLDListElement {
  constructor () {
    super() // need it
  }
}

customElements.define('custom-element', CustomElement, { extends: 'dl' })
<dl is="custom-element">
</dl>
  1. class を書く際に継承したい要素を明示する
  2. customElements.define() の際に extends オプションを与える
  3. HTML 側は既存の要素をそのまま使いつつ is 属性に定義した名前を与える

3 は Element と言いつつ Attribute になってしまっているし、class にも define() にも extends を与えなければいけないし、使いにくい感じがする。

それでも既存の要素にそのまま life cycle callback 付きの処理を加えられることがメリットなら採用してもよいかもしれない。

デザインは普通に style 属性に値を与えることで変更できる。

this.setAttribue('style', background: black; color: white')

CSS と JavaScript をワンセットで定義として閉じ込めて再利用できる。

Shadow DOM

window.document 以下の DOM Tree から独立した DOM を実現する API.

Custom Element の定義に this.attachShadow() を与えると、その要素は Shadow DOM Tree を現実の document DOM Tree 上に実現するための Shadow Host になる。

作り方の一例は以下のようなもの。

export class OriginalElement extends HTMLElement {
  constructor () {
    super()

    // shadowRoot を設定するといったんすべて出力されなくなる
    // open か closed かは関係ない
    const shadowRoot = this.attachShadow({ mode: 'open' })

    // shadowRoot に tree を再構築する必要がある

    // この要素は要素ではなく tree の親になっているので、style の設定も
    // style 要素を追加する必要がある
    const style = document.createElement('style')
    style.innerText = `
      background: black;
      color: white;
    `
    const text = document.createTextNode(this.innerHTML)

    // 子要素に追加しないと出力されない
    shadowRoot.appendChild(style)
    shadowRoot.appendChild(text)
  }
}

Shadow Root 以下に子要素を追加してブラウザ上に意図した DOM Tree を実現しているが、この際

  1. mode に関わらず Shadow DOM の要素は querySelector での検索の対象にならない
    • 上の場合、document.querySelector('custom-element') の結果は存在する2custom-element style の場合の結果は null になる
  2. Shadow DOM が有効になった Custom Element は実際にはそれ自身が意味のある Element なのではなく空の Tree の Root になる
  3. mode: ‘open’ の場合は <element>.shadowRoot で Shadow DOM の Tree を取得できる

という状態になる。見て分かる通り、外からも中からもお互いに干渉しにくい。(手間が掛かる)

※ 中の DOM Tree を DOM API だけで構築しようとするとかなり見通しが悪くなってしまうので、innerHTML に template literal を与えるか、Template と Slot 要素を使うのが Web Components としての回答っぽい。

まとめ

Shadow DOM は

  • 外からは document.querySelector() で内部構造に対して検索できない
  • 内からは style 指定が外に影響しない3

が、逆に

  • 中から document.querySelector() で外の世界にアクセスできる
  • 外から style を指定することはできる4

を実現してくれる。

以上から、Custom Element に対して Shadow DOM を有効にすることで、component としてのカプセル化、独立性を高め、再利用性の高いパーツを実現することができる。

Template と Slot

template と slot の使い方 - Web Components | MDN

  • component とは直接関係しない、DOM の表示に何も影響しない要素
  • JavaScript からは丸見えなので Custom Element の定義内から参照することができる

最も簡単な例は以下のようなものになる。

<template id="custom-template">
  <p><slot name="content">template default</slot></p>
</template>
const template = document.querySelector('#custom-template').content
..
shadowRoot.appendChild(template.cloneNode(true))

このとき、shadowRoot に appendChild する際に slot の置換は自動的に行われる。

このように Template と Slot は component の再利用性に寄与できる要素になっている。

が、明からさまに問題を抱えている。それは、

template 要素は component のようにはカプセル化できておらず、DOM の中の global な要素になってしまっている

点である。

これを解消する方法があるなら、生の Template 要素を使うよりそちらを採用した方がよさそう。

もう一つ、Slot が置換済みの DOM Tree をブラウザの DevTools で確認することができなかった(Chrome 92 + Show user agent shadow DOM の設定を ON にした状態で)。これでは複雑な構造に対して細かいデザインの調整を行う際にやや面倒になってしまうのではないか。

実際に開発するに当たって

Custom Elements, Shadow DOM について HTML, JavaScript ともエディタ側のボキャブラリに入っていない可能性がある。その場合いちいち lint がエラーになったり警告色で表示されたりして面倒なことになる。

※ これが JavaScript の import / require を前提にしていれば問題ないので、以下に示すライブラリを使っていればそういう心配はない。

あとブラウザネイティブなのでやはり互換性が問題になる。有名なところは IE 11 だが、2021-08 時点の日本では Safari のシェアも高く、Safari はいろいろ対応が中途半端になっている場合があるので注意が必要。

ライブラリ

ブラウザネイティブの Web Components を見てきたが、やはり考え方はよいと思うものの、実際の制作、開発を行うに当たっては課題もある。そこで Custom Elements とそれに近いライブラリがいくつかあるので見ておこうと思う。

いずれも、制作、開発時に利用したライブラリは Custom Elements や HTML 片利用時にも必要になる。つまり、pure Custom Elements にしたい場合には使えない。

なお、ここに挙げた以外にも他にもいろいろあるのでベンチマークを貼っておく。

Web Components Benchmark

これ見ると Custom Elements の方が圧倒的に バンドルサイズは小さい反面、多くの処理が絡むようになってくると Native DOM より Virtual DOM の方が速いのがよく分かる。機能を持つというよりむしろカプセル化こそがその真価と言ってよさそう。

Lit

Lit

2020-08 リリースの v 1.3.0 時点の話。

  • Google の Polymer Project の後継
  • React のような考え方を取り入れた Custom Element を作る
    • #properties で reactive な property を定義
    • render() が出力される
    • それでいて標準の customElements.define() で登録可能
  • 独自の LitElement を継承しており、constructor を含むすべての life cycle callback で super.xxxxxCallback() が必要
  • default で Shadow DOM が有効になっている
  • class の中の render() が出力になることで Shadow DOM を扱う際の分かり難さが軽減されている
  • TypeScript の decorator を利用して define() をスキップすることもできるが、使わなくても動く
  • template に対して No custom syntax to learn と言っているが、@ を利用した event listener をはじめ、directive など案外と追加の学習が必要。
    • slot も持っている

render() に template を持たせることで Template 要素が DOM global になってしまう問題を回避できている。

導入のハードルは低そうだが、dirctive など意外に複雑。これだけでそれなりに複雑なものが作れるとは思うが、それならもう少し抽象度が高くてメジャーなものを採用した方がよいかもしれない。複雑な部分を利用せずに Custom Elements の薄い wrappert とて使うくらいだとコストパフォーマンスよさそう。

実際、

Shoelace: A forward-thinking library of web components.

など LitElement 前提の component ライブラリもある。

Lit はとにかくドキュメントが参考になる。MDN や Google 本家のドキュメントよりも現実的なドキュメントになっている。実際に使うためのヒントになる。

Stimulus

Stimulus: A modest JavaScript framework for the HTML you already have.

2020-12 リリースの v2 時点の話。

  • Custom Element を作るのではなく、既存の HTML をそのまま生かしつつスコープの限定と life cycle callback を実現したもの
  • Element ではなく Controller という位置付け
  • event と紐づいた action の dispatch もあるので単にカプセル化された component というよりは UI として機能させやすい
  • Shadow DOM の考え方は導入されていないので、外から無作法に生の DOM API で干渉することはできる(Stimulus Controller からは親子関係になっていないと干渉できない)
  • 独自の template の記法は存在せず、controllr 配下の要素に data-*-target という属性をセットすることで controller からその中身の書き換えを許可できる
  • reactive な property は存在しないが、data-* の中に監視できる value と、それに対応する xxxxValueChanged() メソッドを定義しておき、そこから target を書き換える処理を実行することで半自動的に re-render させることができる

マジカルさがなく、素朴な JavaScript のコードになりやすいと思う。

GitHub Catalyst

Catalyst

2021年夏リリースの v1 時点の話。

  • Stimulus と Lit の中間のような Custom Element を構築する
  • 継承もとはネイティブの Element
  • HTML 側に <template data-shadowroot> で template を持たせることができる。これは Shadow DOM 前提
  • data-* に action や target を設定する辺りは Stimulus に似ている
  • TypeScript の decorator に依存しているが、decorator を function にして使うことも可能(MobXっぽい)
  • @github/jtml から htmlrender() で出力する書き方もできる。この辺りの書き味は Lit に似ているので、React っぽいのが好みの場合は寄せることもできなくはない
  • css で style を書ける機能は実現されていないので、その部分は Lit と異なる

どちらかというといろいろなものをカプセル化できることよりも Shadow DOM の中に property など「観測できないパラメータが閉じ込められない」ことの方を重要視して、それを避けるような設計になっているように見える。

特に Lit を使った場合に reactive な property の挙動などは DevTools から観測できないので、開発難易度は上がっているように見える。

GitHub Catalyst はこの辺りはすべて生の Custom Elements のメソッドにそのまま依存することになっており、

  • observedAttributes
  • attributeChangedCallback

を利用する。

したがって Stimulus のように data-* に値を保存するようにしておけば自ずと意図通りの動作を実現できるし、data-* に保存されているので DevTools でそのまま値の変化を目視できる。

具体的には読み書きする値は

set <attr> (val) {
  this.setAttribute(`data-${attr}`, val)
}

get <attr> () {
  return this.getAttribute(`data-${attr}`)
}

みたいなイメージで property を用意してやるとよい。そのうえで

static get observedAttributes () {
  return []
}

で監視したい data-* をセットしておく。実際の動作は

  1. property の setter を呼ぶ
  2. data-* attribute に適用される
  3. attributeChangedCallback() が呼ばれるので、変更のあった値を見て target の書き換えなどを行う

という流れになる。こうして見るとほんとに Stimulus にそっくりなんだけど、時系列としては逆で、Stimulus が Custom Elements そっくりな動作を既存の HTML 既存の DOM 構造の中に実現されたものなのだということが分かる。

またこのように素の Custom Elements に近いので @github/catalyst の方が Lit よりも小さい。

data-* に反映されるので DevTools を利用した目視開発の難易度は低いが、そのうえで以下の拡張を追加するとさらに分かりやすく開発できると思う。

Web Component DevTools - Chrome Web Store

Vue

なんと、Vue component を Custom Element として利用することができる。(Custom Elements を Vue と組み合わせるのではなく、Vue component を Custom Element として利用する。)

Vue の Single File Component は Custom Elements + Shadow DOM を実現するのに現状いちばん適した記法になっていると思うので、Vue に慣れている人にとってこれは嬉しい。

Polyfill

Requirements – Lit

Custom Elements は Legacy Browser には対応していない。ES 2015 形式の class 構文をサポートしていないといけない。

しかし Polyfill で解決することはできるようだ。

ユースケースを考えてみた

Custom Elements Everywhere

にあるように、より Higher-level の API を持つライブラリ、フレームワークと衝突しない5ので、例えば Shoelace のような UI フレームワークを導入して、

  • 機能としての View Framework ( React / Vue / Angular など )
  • デザインとしての UI Framework ( Shoelace など )

を分離しつつ同居させるといったことができそう。

デザインと機能を独立して開発できるのは分業体制がしっかりできているところには有用そう。

ただ実際にはレガシー対応が含まれる場合はフルにコンパイルする View Framework に寄せる方が簡単そうではある。また、ある程度強いコミュニティのある React / Vue / Angular などの方がエディタ、IDE の対応、ブラウザ拡張による開発支援が手厚くなる。ひとりの Custom Elements に詳しい人が扱っているのならともかく、組織で採用する場合はここがボトルネックになる可能性はある。

※ 2021-08 時点では Custom Elements 周りでは Lit がいちばん強そうなので、そこに寄せるのがよさそうではある。個人的には書き味的には @github/catalyst の方が扱いやすそうに思うんだけど。ほぼ素の Custom Elements なので拡張の対応も容易だし。

上に挙げたような課題を Polyfill や IDE の拡張などで解消できる目処が立つなら、少なくとも大規模なアプリ、ページ内の要素が多くそれぞれがカプセル化できているメリットが大きい場合にはフットプリントの小ささが有効に作用するのではないかと思う。また Custom Elements に寄せておくと例えば React のようにバージョンアップの早いライブラリを採用するよりも Element ( Component ) の寿命を伸ばせそうなので、やはり規模の大型化や、複数のアプリ、サービスでパーツを使い回すといったニーズが強い場合にはよくマッチしそう。

逆に UI がシンプルであったり、アプリの数も少ないうちはそこまで Custom Elements にこだわらなくてもよくあるコンポーネントをそのまま使うのでも十分通用するだろうし、そこにちょっとした効果やイベントをセットするに当たり、既存の HTML にそのまま乗せられる Stimulus のようなアプローチが合うだろう。6

最近、自分はことあるごとに Virtual DOM は設計のコスト、実装のコストが高いと言っているのだけれど、Custom Elements にも似た部分はあるように感じている。ただ、Slot と外部に影響しない CSS は本当によいと思うので、世間でよく言われているパーツ、コンポーネントに精通し、それを利用できるように Shoelace などで練習しておくことで設計の練度を上げていくのは、将来を考えるととても重要なんじゃないかと思う。

まぁ自分が流行のツールより「標準」が好きというのもあるかもしれないけど、けっきょく「標準」がありつつその「周辺」でよくできたツールを作れる人たちが頑張ってくれているわけで、「標準」を知らないまま「周辺」のツールだけつまみ食いするのはちょっとあぶないかもなと思ったのでした。

  1. というか恐らく逆で、Web Components にもともとあったアイディアを各ツールが実現しただけなのかもしれない。歴史に詳しくないので、この辺りの前後関係は今回は調べていない。 

  2. style 属性か Shadow Root 以下の style が適用されるので 

  3. Shadow Host になっている 

  4. 例えば Bulma のような pure CSS Framework と組み合わせるとオリジナルのデザインを持つ Custom Elements を比較的ローコストで実現できる 

  5. vue web component wrapper を通した Custom Elements は結局 Vue に依存するので衝突しないとはだいぶ言い難いが 

  6. 少なくともサーバサイドがあってクライアントサイドに集中しきれないような状況は初期にはよくある 

More