Web Componentsというと、自己完結した部品を公開するのが主な用途だとか、SPAを置き換えるものじゃないとか、そういう話になる。
ただ、私はWeb Componentsこそ未来だと楽観的に期待している。どんな感じに期待しているかつらつら書きたい。
長いよ!
- MPA需要にWeb Components
- divの強い版を作れる
- つらみは多そう
まえがき
伝え話に聞くWebの過去は混沌としていて、現実にあったこととして受け止めがたい。今も同じだって?その通りだよ。
ただ、Webの歴史を今と昔とに切り分けるなら、その境界線の一つは間違いなくReact、ひいてはVDOMだと思う。
このへんの登場によってフロントエンド界隈は加速し、ぶっ壊れた。得られたものはVDOMだけじゃない。
- コンポーネント指向
- コンポーネント単位でのスタイル適用
- 宣言的かつ速度を犠牲にしない記法
- 一元的な状態管理のアーキテクチャ
Reactやvueといった、VDOMに基づく先進的でエルゴノミックなフレームワークが今のフロントエンド界隈を牽引している。
いずれも活発なコミュニティがあり、関連パッケージが非常に充実している。そしてSPAを志向している。
これら強力なフレームワークがあるがために、今どきSPAでフロントエンドを構築しないのは縛りプレイとか言われることすらある。ついでにjQueryがディスられる。
デザインのためのマークアップ
昔からあった話だけれど、HTMLはセマンティクスよりデザインが優先されがちだ。
デザインを当てるためにゴリゴリdivで囲い、document.querySelectorAll("div")
したら100を超えるのはザラな話。Qiitaトップで試したら476個のdivが取れた。
HTML標準の要素はショボすぎる。見た目を整えようと思ったらすぐdivが増える。
大量のdivにスタイルを与えるためにclassを使う。すると大量のclassでCSSが崩壊する。
大量のdivを掻い潜って操作したりスタイルを当てたりするのはどうしたって辛い。より抽象化された次元からの管理が必要になった。
VDOMが出てきた背景にはWebページに求められるデザインの高度化と、それに伴う大量のdivの出現があったといっても過言ではないだろう。
jQueryは時代遅れか?
思うよ。でも、不思議なことに、未だにjQueryの利用率は高いらしい。
ソースは知らない。執筆時点(2019/02/15)でnpm上のjqueryのweekly downloadsは300万超だけど、bootstrapは250万で、ほぼCSSフレームワークと一緒にダウンロードされていそう。シェアで見るとjQuery率が異常に高いけれど、過去の遺物の影響がどれくらい大きいのかちょっとわからなかった。
ちゃんとしたソースが咄嗟に出せなかったのでQiitaで説明すると、tag:jquery
で新着順で検索した100ページ目、記事数でいうと1000/3500の記事が投稿されたのは2017/10/02だった。1年半足らずで1000個の記事が増える程度には使われているようだ。速でいうと0.6reactくらい?
もちろんjQueryを勧めたいわけではないし、なんでまだ使われてるのかよくわかっていない。
バベらずともIEで動くとかそんな理由?
ただ、なぜReact等がjQueryを駆逐できなかったかはなんとなくわかる。
多くの場合、SPAにするのは過剰だ。
かつてAjaxはページ移動という概念を超越してWebのパラダイムを一つ進めた。
一方でだいたいのコンテンツはページ移動で辿っていけば十分だ。
宣言的にビューを記述できると、状態の変化を反映させるのが容易になる。
一方で一度配信されたら更新されないコンテンツが多数だ。
でもコンポーネントは欲しかったんだよ
そんなわけでSPAにはしたくないなーとか、SPAにしちゃったけどやりすぎだったなーとか、そんなアレがアレしてきたのがここ数年。
でもそれはそれとしてコンポーネントは欲しい。
アプリケーションである以上何らかの状態は存在するし、リアルタイムに更新したいときも当然ある。
通知バッジの数字を増やしたり、コメントを流したり、色々あるよね。
そんなとき生DOM操作に手を出すのは、JSXで舌の肥えた我々には抵抗がある。そこだけ宣言的に書きたい。
デザインが高度化したのも問題で、カルーセル一つ書くにもCSSフレームワークだけでは手間が掛かる。
[bootstrap4のドキュメントのサンプル][bootstrap4-carousel]を引用すると;
[bootstrap4-carousel]:https://getbootstrap.com/docs/4.3/components/carousel/#with-indicators
<div id="carouselExampleIndicators" class="carousel slide" data-ride="carousel">
<ol class="carousel-indicators">
<li data-target="#carouselExampleIndicators" data-slide-to="0" class="active"></li>
<li data-target="#carouselExampleIndicators" data-slide-to="1"></li>
<li data-target="#carouselExampleIndicators" data-slide-to="2"></li>
</ol>
<div class="carousel-inner">
<div class="carousel-item active">
<img src="..." class="d-block w-100" alt="...">
</div>
<div class="carousel-item">
<img src="..." class="d-block w-100" alt="...">
</div>
<div class="carousel-item">
<img src="..." class="d-block w-100" alt="...">
</div>
</div>
<a class="carousel-control-prev" href="#carouselExampleIndicators" role="button" data-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="sr-only">Previous</span>
</a>
<a class="carousel-control-next" href="#carouselExampleIndicators" role="button" data-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="sr-only">Next</span>
</a>
</div>
わぁっ...
ネストが深いだけではなく、コントロールやインディケータを明示的に書いていて冗長なのも辛い。
これくらいシンプルに書きたい。
<simple-carousel control indicator>
<img src="..." alt="..." />
<img src="..." alt="..." />
<img src="..." alt="..." />
</simple-carousel>
これくらいシンプルに書くために期待されているのがWeb Components。
Web Componentsの導入は、部分的にVDOMをマウントするよりいくらかアドバンテージがある。
ReactとかVDOMがバズったのが2014年頃っぽい。Web Components v1がアレしてきたのが2016年頃、そろそろ使えそうな雰囲気が醸されてきたのが2018年末くらいのようなので、タイミングがいいといえばいい。
Web Components
Googleの解説記事がめちゃくちゃわかりやすくまとまっていて素敵。
わからない単語はMDNの解説からリンクを辿って見ていけばなんとなくわかってくる。
Light DOMを圧縮
問題の一端は大量のdivだ。
カスタム要素はよくCSSのスコープのみが取り沙汰されるけれど、こういったデザインのためのdivをShadow DOMに隠蔽する能力も持つ。
Light DOMから余計な要素を隔離することで、マークアップを清潔に保つことができる。
目に見えるタグが少なければそれだけで全体の構造を掴みやすいし、もし生DOMを操作することになっても動作を追いやすくなる。
有益なthis
divのthisはゴミだ。
div要素はただのブロック要素で、追加のプロパティやメソッドを何一つ提供していない。
divの子孫を辿れば何かしら有益なデータが取り出せるかもしれないけれど、ツリーの中身を把握している必要がある。
カスタム要素は複雑な構造を代表して一つのthisを提供する。
追加のプロパティやメソッドを定義して、外から適切にコンポーネント内の状態を取り出せるようにできる。
しかもカスタム要素の子要素も何かしら有益なthisを提供している可能性が高い。少なくともdivではない。ネストした一連のdivはカスタム要素で置き換えられる。
突き詰めると、状態を一貫して保持する一つのアプローチにもなる。唯一つのStoreとツリー構造のStateを、documentとDOMツリーで代えてしまえるわけだ。
画面の状態はDOMに持たせ、必要なときにだけDOMから状態を引き出して利用する。SPAにする程でもないアプリケーションにはちょうどいい。
Shadow DOMの構築にVDOM
カスタム要素にはLight DOMに現れる1要素としての側面と、内部にShadow DOMを抱えるツリーとしての側面がある。
全体として状態を持ち、Shadow DOM内に反映させる。
これってつまりSPAをミニマムにしたようなもので、実際Shadow DOMを構築するのにSPA用のフレームワークやエコシステムを転用できる。
Vue CLI 3がWeb Componentsの出力に対応したし、ReactのドキュメントにもWeb Componentsの中で使う例がある。
私にはlit-htmlで十分だけどね。VDOMではなくtemplateを使っているらしいけどよくわかんないや。
サーバーとクライアントで一貫性
SSR一式を建てなくてもテンプレートエンジンで同じコンポーネントを利用できる。
テンプレートエンジンが出力したHTMLにクライアント側で付け足すことも簡単にできるようになる。
Elementとしての振る舞い
カスタム要素は生のDOMに組み込まれるため、Elementとして振る舞える/振る舞う必要がある。
Elementに期待されるインターフェースはだいたいこんな感じ。
描いていないけれど属性はプロパティと同期していると期待される。
描いてしまったけどイベントはJavaScriptの世界からだけ触るのが推奨されているし、onなんとか属性が存在するのは特別なイベントだけだ。
これに追加で要素に固有のプロパティ・メソッドがJavaScriptの世界へ向けて公開されている。
カスタム要素もできるだけこれを踏襲する。
つまり、属性や子要素を使って何かして、イベントによって通知を行う。
ベストプラクティスも読もう。
何事にも例外はあって、JavaScriptで操作することを前提にしたほうがいい場合もある。
例えば、チャートを表示するカスタム要素に対して、大量の系列データをserializeして渡したりするのは筋が悪い。JavaScript上でオブジェクトを渡すのが素直だろう。
そういう場合でもsrc属性でURLを与えられるようになってたりすると便利。
カスタムイベントをバブリングさせるかどうかも場合による。
video要素のplayのように、コンポーネントで閉じた操作で発生するイベントはバブリングしないことが一般的だと思う。カスタム要素の場合、コンポーネント内で発生した出来事をカスタムイベントで通知するのだから、基本的にバブリングは不要ということになる。
一方でイベントの移譲という概念があり、イベントをバブリングさせることによってハンドラを集約できる。
カスタム要素のthisは有益だから、ハンドラを集約することは単にハンドラを書く位置が変わる以上の意味がある。
場合によってばかりだけど、それだけカスタム要素のユースケースが広いんだよ。
やばそうなところ
要素名の衝突と命名権
カスタム要素の名前はグローバルかつ変更が利かないため、名前が衝突すると致命傷になる。
一番に思いつく回避策はクラスの定義と登録を分けてしまうこと。名前は利用者に決めさせる。
しかし、customElementsにdefineする例はdefineの第2引数に与えながらclass構文で定義するのを奨励しているようにも見える。これに従ったカスタム要素は問答無用で登録される。読み込むだけで使えるようになるから便利といえば便利。
CustomElementRegistryのメソッドを見ても、名前を知っていることを前提としている節がある。名前からコンストラクタは取得できても逆はできないし、カスタム要素が定義されるのを待つときは名前を与える必要がある。
ネームスペースを分けるのも難しい。
GitHubでもScoped Custom Element Registryが欲しいという議論があるけれど、newしたときの扱いとか結局どうしたいのかよくわからなかった。
結局「外部に公開するカスタム要素には依存関係を持たせない」「絶対に被らない名前をひねり出す」しかないのかなぁという気持ち。
継承した要素のアップグレードを待機できない
ネイティブなHTMLの要素は組み合わせて使うものも多い。labelとinput、videoとsourceなど。
カスタム要素でも同様に組み合わせて効果を発揮するコンポーネントを作りたくなるけれど、継承が絡むとややこしくなる。
カスタム要素は継承して拡張できる。
オブジェクト指向の常識では、Aを継承したA*は、Aを要求するBから使えてしかるべきだろう。
ここで問題になるのが、DOMに存在するa-star要素がアップグレードされるタイミング。
下の例だと、whenDefined("a-base")が解決されても、a-baseの拡張のつもりで書いたa-starは登録されていない。
a-star要素は「カスタム要素っぽい名前の未知の要素」であってa-baseとは何の関係性もない。a-baseのつもりで触ろうとすると死ぬ。
class A extends HTMLElement { ... }
class B extends HTMLElement {
connectedCallback() {
customElements.whenDefined("a-base")
.then(() => {
[...this.children].filter(x => x instanceof A) // => []
})
}
}
customElements.define("a-base", A)
customElements.define("b-base", B)
class AStar extends A { ... }
<b-base>
<a-star></a-star>
</b-base>
どうしても相互作用させたいなら、ポリモーフィズムを捨てて継承されたら別物としてみなすか、whenDefinedで待つのを諦めて未昇格でも壊れないように書く、の2択なのかな。