[WIP]CSSの命名について
下書き供養 Advent Calendar 2018の9日目の記事です。
CSSの命名規則じゃない命名についての体系的な何かができないかを考えていた。どういう要素に命名するためにどのように言葉を選定するのか、命名という切り口で具体的に説明する文書みたいなものを見たことない気がする。
きっかけはクラス名に使える単語リストみたいなのが流れてきたのを見て感じたなんか違う感。それはそれでわかるんだけど、そもそもどういった場合にどのような種類の言葉を探すのか、要素をどの視点から捉えて説明するのかみたいな話が先にあった方が良さそうに思う。
一旦コンポーネント名の命名に基準を合わせて、UI的な命名とドメイン的な命名に分解できるのではと考えた。有名なところではMCSSのベースとプロジェクト、及びそれに影響を受けたであろうFLOCSSのComponentとProjectのレイヤー分けに近いと思う。ただしこれらはレイヤーとして分離した先にある個々のコンポーネント名についての詳細な説明がない。どちらのレイヤーに属するのかわからないと言われるのもその辺が鍵になってきそう。
- CardとかModalとかはUI的、NewsとかUserとかはドメイン的
- 特定のドメイン知識に依存しないコンポーネント、そのサイトの中では汎用的に使えるルールのコンポーネントはUI的な命名が妥当
- デザインの共通化が進むとUI的な命名が増えてくる
- コンポーネントを小さく砕いて共通化していくとUI的な命名に向かいやすい
- 規模によってはドメイン的である方がネームスペース的には安全である可能性が高い
- 同じ機能が出現する場所はだいたい決まっているし、だいたい同じUIで表現される
- カンプよりワイヤーフレームの方が一貫性があることが多い、表現することの密度の問題
- ドメイン的な命名はスケールしない、反面捨てやすい
- UI的な命名とドメイン的な命名を掛け合わせることでより端的な命名に向かうこともある
- Articleとかどっち側? 記事というUIで記事というデータ型。そのデータ型が特定のドメイン特有のものでないのであればUI的と言っても良い?
- CardというのかTeaserというのかとかはUI側だけどそれぞれ別の側面から見た視点っぽい。Cardはパターンっぽい、Teaserは用法っぽい。対応する両方はイコールじゃなくて全然違いものを指す可能性があるけどイコールなこともある
- Primary buttonとBlue buttonとかも違うものだけど、ドメイン的ではないという意味では同じものとして説明できる、Blueっていう名前の商品を買わせるための専用ボタンなのであればドメイン的かもしれない
- ややこしい用語があったりするとなにがUI的な言葉でなにがドメイン的な言葉なのかプロジェクトごとにはっきりさせとく必要があることもありそう
しばらくはまとまる気配がない。いつか更新するかもしれないし永遠にしないかもしれない。
たまごまぜごはん
TKG Advent Calendar 2018の2日目の記事です。
人が卵かけご飯と言ったとき、僕の頭の中で連想されるのは「卵まぜご飯」と呼んだ方が適切であろうと思われるものだ。幼少期に母親の紹介を経ていわゆる卵かけご飯との出会いを果たしたとき、それは当たり前のように混ぜ合わされた後の状態になっていて、さも混ぜ合わされるという工程を経て初めて卵かけご飯という名を授かるのだと思わされた。
ご飯とおかずは均等な味の比率を保っていなければならない。当時の僕は暗黙的にこのようなルールを遵守していた。特定のおかずにご飯と合う種類の味がどの程度含まれているかによって同時に口に含むべきご飯の量は決まる。これは単なる目安というより、可能な限り守られなければならない規定だった。特定のおかずに対して必要な量のご飯が用意されていないとき、味の総量としてご飯を超えるだけのおかずは食べることができない。そしてそのルールを犯してしまうことに強い罪悪感があった。
チキンライスやチャーハンなどの色付きご飯は例外として扱われた。ルールに則るならば、その味付けの量が対応するご飯の量を超えている場合は均等になるだけの「白いご飯」を増やして中和するというのが筋だ。しかし僕の家庭では色付きご飯の日には炊飯器の釜は「洗い待ち」状態で食事が開始された。これは色付きご飯の日には白いご飯を提供する意思がないということを示す。僕はこの色付きご飯の場合にはルールに当てはまらないとして自分を納得させた。従って色付きご飯の日に白いご飯を食べることができる状態になっていても味を中和させることは必要ではない。
他に少し複雑な対応として、ちょっと茶色いキノコ混ぜ炊き込みご飯のような、色付きご飯ではありながら味の比率としてご飯の方が多いという薄い色付きご飯パターンもあった。これまでのルールに則れば、薄い色付きご飯にはおかずの味の量が不足しているためその分の別のおかずが入り込む余地があるはずだ。つまり特定のおかずを食べるときに、白いご飯の場合に比べれば必要なご飯の量は増えるが、薄い色付きご飯でも味の比率を均等にするルールを守ることができる。しかし当時の僕には別のルールとして、別の種類のおかずの味を混ぜてはならないという決まりがあった。そのため薄い色付きご飯を別のおかずと同時に食べると規定違反になる。
ただそうはいってもこれではその日の食事を食べることができない。これに対する対策として、僕は薄い色付きご飯は白いご飯と同一であるというように解釈した。このご飯には一切の色などなくて、白いご飯とまったく変わりがないのだと。もちろんこの解釈には無理があった。強く念じようとも薄い色付きご飯はやはりおかずとしての佇まいを残しているのだ。僕はこれに対する打開策を講じることができず、薄い味付けご飯の日は「捨て」であるということにした。
こうした背景を振り返って自分はなぜ卵かけご飯は卵混ぜご飯であると捉えているのかが分析できてきた。まず卵の味がご飯全体に均等に浸透していなければ食べることができない。それに塩や醤油が混ざると色付きご飯のパターンとして取り扱われる。そして色付きご飯であるということはこれまでのあらゆるルールの免罪符となり無限の進化の可能性を獲得するのだ。
CSS in JSはCSSの書き方をどのように変えるのか
CSSの難しさの根源はセレクタにある。CSS設計のための方法論ではどのようにしてセレクタと関わるべきかについて語られる。
その関わり方がCSSのみで実現できなければならないという制約を捨てたのがいわゆるCSS in JSの類(定義的に微妙なやつも全部ひっくるめて)だ。可能性は一気に広がり無数のライブラリが生み出された。
ある程度の期間を経ていくつかの着目すべきアプローチが見えてきた。これから僕はどのようにセレクタと関わっていくべきかという視点で記してみたい。
擬似スコープ
通常CSSのセレクタにはスコープはないが、HTMLやCSSにハッシュ値を付与して特定のコンテキストを擬似的に閉じてしまおうというアイデア。実装としては、Vue.jsの単一ファイルコンポーネント、Angularのコンポーネントスタイル、styled-jsxなど。関連するウェブ標準技術としてShadow DOMがある。
例えば、次のように書かれたスタイル宣言は同一コンポーネント内にしか適用されない。
<template> <p>Hello World!</p> </template> <style scoped> p { font-size: 2em; text-align: center; } </style>
このコンポーネントの外にp
要素があったとしても適用範囲外になる。
書き方としては、CSS in JSの類のアプローチの中では最もこれが普通のCSSに近い。スコープがあるということ以外、実質的に変わりがない。セレクタがグローバルであるという難しさを解消するためにスコープを作るということは、発想としてごく普通で受け入れられやすい。
BEMで規約として作っていたブロックというルールを仕組みとして取り入れたとも言える。BEMらしき姿は見えなくなっていても、これはより安全で楽になったBEMだ。
安パイだと思う。
スタイル宣言が付属したHTML要素を作る
styled-componentsを使うと、最初にスタイル宣言が付属したReactコンポーネントを作成し、それを配置していくという形でスタイリングを行うことになる。
import React from 'react' import styled from 'styled-components' const Wrapper = styled.button` background-color: lightgray; border: 1px solid gray; ` const Icon = styled.img` width: 1.25em; height: 1.25em; ` const Text = styled.span` margin-left: 1em; ` const Button = () => { return <Wrapper type="button"> <Icon src="/icon.svg" alt="" /> <Text>click me</Text> </Wrapper> }
従って、個々の宣言ブロックの中身を実装した後に作成した要素を配置して画面を組み上げていくという流れになるのが自然だ。通常、スタイリングはHTMLを書いた後に行う。対して、styled-componentsは宣言の記述のために個々の要素のReactコンポーネント化を要求するため、全体のHTMLが完成する前に個々の宣言ブロックの中身を実装することに意識を向けなければならない。
しかし実際にそのような流れで実装することには無理がある。そのためstyled-componentsを利用したスタイリングには不自然さが伴う。
宣言ブロックの中身は適用される対象の要素だけを見ても完成させられない。継承させるプロパティの兼ね合いや、兄弟要素とのレイアウト上の関係性など、対象となっている要素以外の要素も意識しなければどのような宣言をするかは決められない。
またこうして作成したReactコンポーネントを配置していく際にも難がある。どこにどの要素を配置するかは、それぞれのHTML要素の種類に依存して決まる。にも関わらずこれらを配置していく際には要素の種類が一目ではわからなくなる。さらには単なるスタイルが付属するコンポーネントなのか別の場所から読み込んだ真っ当なコンポーネントなのかも不明瞭になる。
分解したReactコンポーネントを利用するとき、その中の実装が見えないというゆえの扱いにくさがある。先述したようなHTMLやCSSとしての構造の問題がその一部だが、大抵はそれを上回るだけのコンポーネント化による利点がある。しかしstyled-componentsは全てのHTML要素をReactコンポーネント化することを要求してくる。剥き出しになっていて欲しい部分も覆い隠されてしまう。
最初にHTMLだけを組み上げた後に個々の要素をstyled-componentsに置き換えることもできるが、明らかに非効率的だ。結局は読みにくくもなってしまう。
ごく小さなコンポーネントであればともかく、HTMLとしてそれなりの大きさのコンポーネントを実装しているとまるでもう無理になってしまった。
HTML要素にスタイル宣言をリンクする
宣言ブロックを基にしてそれに対応するユニークなクラス名を生成するというアイデアもある。そのクラス名はHTML要素のclass
属性を通してリンクされる。
/* style.css */ .wrapper { background-color: lightgray; border: 1px solid gray; } .icon { width: 1.25em; height: 1.25em; } .text { margin-left: 1em; }
// button.js import styles from "./style.css" const Button = () => { return <button className={styles.wrapper} type="button"> <img className={styles.icon} src="/icon.svg" alt="" /> <span className={styles.text}>click me</span> </button> }
これはCSS Modulesの例。
生成されるクラス名をCSSファイルから読み込み、対象の要素と宣言ブロックを直接繋ぎ合わせる。CSSファイル内のセレクタのようなものはクラス名がマッピングされるオブジェクトのキーに過ぎない。
セレクタをユーザーから隠して宣言ブロックと直接リンクさせるようにしたという意味ではstyled-componentsに近い。このアイデアが優れているのは、単にリンクさせるようにしただけであるというところ。
セレクタというのは書きやすくかつ読みにくいものだ。付属する宣言ブロックがどの要素に適用されるものなのか、実際に実行してみるまで本当に信頼はできない。このアプローチでは、スタイルを適用するためにはクラス名となる文字列を対象の要素に直接繋ぎ合わせるという前提ができることで、コンパイル前の段階でその関係性を確実に保証できる。
ただしCSS Modulesはビルドを複雑にしてしまう。styled-componentsが流行った理由は採用の手軽さにもあるのだろう。
幸い、このようにスタイル宣言をリンクさせるというアプローチが可能なライブラリとしてemotionがある。emotionはstyled-componentsと同等の機能の他に、The css Propという宣言ブロックを基にユニークなクラス名を生成できる機能を備えている。
import React from 'react' import { css } from 'emotion' const wrapperClass = css` background-color: lightgray; border: 1px solid gray; ` const iconClass = css` width: 1.25em; height: 1.25em; ` const textClass = css` margin-left: 1em; ` const Button = () => { return <button className={wrapperClass} type="button"> <img className={iconClass} src="/icon.svg" alt="" /> <span className={textClass}>click me</span> </button> }
さらにこのようにJavaScriptでスタイル宣言を管理することで、未使用の宣言ブロックを検出できるようになるという利点がある。ESLintやTypeScriptを使用すれば、デザインの変更時などに不要になったスタイルを確実に取り除けるようになる。コードを整理するという観点から、これまでのCSSではとても実現できなかったことだ。
感想
CSS in JSとかそんなものやめてしまえけしからんという気持ちもわかる。けど、これまでのCSSで事足りるというのは大抵、道具として完璧で最高にフィットしてるという感じでなくて、まあまあ不満もあるけど妥協してやっていけるよくらいの温度感のはずだ。
僕自身これ系のものを割と食わず嫌いしていたけどそれなりに学ぶこともあった。ので、とりあえずこれでなにか書いてみればよいのではという感想。
帰ってきた彼
姿を消したのは一夜の夢だったかのように、僕はあのころと何も変わらない彼と話をしていた。慣れというのは恐ろしいものだ。彼は再び当たり前の存在に舞い戻った。
「今でもそれを転がしているし、それなりに好きだよ」という話を聞いた。昔の僕は少し気負い過ぎていたせいか、反射的にそれを否定してしまっていた。とはいえ僕も少しは大人になれたのかもしれない。「君が好きならいいと思うよ」。投げやりになったのではなくて、本心からそんな言葉が出た。
たぶん人は自分の姿を消してしまいたいと思うことがたまにあるのだろう。事情はいろいろあるかもしれないけど、そうしたければそうするしかない。これまでそこにいた人が翌日すっかり影も形もなくなってしまっても、僕たちはそれを受け入れる他ないのだ。
彼はそのことを悪びれる様子もなく、その必要もないと思わされた。僕は日本人の平均以上には自由に生きているという自負があったが、それでも自分にまとわりついている鎖の重さを再確認することになった。
なにはともあれ彼には姿を消すという実績ができた。以降はもうないのかもしれないけど、一度あったことなので二度目もあるかもしれない。きっとそのときも僕にはなにもできないのだろう。僕が彼の失踪から学んだのは、諦めるのは諦めないのと同じくらい重要であるということだ。
それでも彼は今ここにいる。僕は楽しくやれてるし、彼の気持ちはわからないが、再び姿を消すようなことはしばらくしないはずだ。しばらく経ってからのことはわからないが。けれど今が楽しくやれてるということはおそらく素晴らしいことなのだ。今を感じることができるのは今だけなのだから。
より良いリンクの下線の実装
前回、リンクには下線を付けようという記事を書いた。が、実際のところ、デフォルトのリンクのスタイルはあまりイケてないと思ってる。リンク色と同色の下線は視覚的な主張として強すぎるし、下線の位置が文字の下端に隣接し過ぎていて見づらい。
幸いにも、CSS Text Decoration Moduleにはあまり知られていない便利なプロパティがあり、この野暮ったさを解消できそうに見える。text-decoration-color
では下線の色を指定できる。IE以外では実装されているので、プログレッシブエンハンスメントということにすれば問題無さそうだ。
a { color: hsl(240, 100%, 47%); text-decoration: underline; text-decoration-color: hsla(240, 100%, 47%, 0.5); }
text-underline-position
を使えば下線の位置を変更できる。次の宣言によってテキストの下に下線を配置できる。このプロパティの値は継承されるので、html
要素に指定しておくと良いだろう。
html { text-underline-position: under; }
ただし、こちらはChromeにしか実装されていない。手軽にはできるので、一部のユーザーだけでもいい感じにしたいのであればこれを指定しておくと良いかもしれない。
これらはCSS Text Decoration Module Level 3の範囲だが、CSS Text Decoration Module Level 4を眺めてみるともう少し可能性が広がりそうだ。前述の内容に関連したところだと、text-underline-offset
は下線の位置を<length>
型で指定できる。また、text-decoration-width
では下線の幅を指定できる。下線を引くためにborder
とかでがんばるのはあまりきれいじゃないと思っているので、この辺には期待したいところ。
現段階において独自の下線を実装するためには、やはりborder
を使うのが一番簡単だ。もし、文字のディセンダーを下線が横切らないようにしたいということなら、text-decoration-skip-ink
とか、text-shadow
とlinear-gradient()
を使ったハックがある。が、単に文字とぶつからないだけ下線の位置を下げるのが一番単純である。border
といっしょにpadding
を指定してやればいいだけだ。
a { padding-top: 2px; padding-bottom: 2px; color: hsl(240, 100%, 47%); text-decoration: none; border-bottom: 1px solid hsla(240, 100%, 47%, 0.5); }
ターゲットサイズが下にだけ伸びしてまうのが気持ち悪いので、padding-bottom
と同じサイズをpadding-top
にも指定している。
見た目を調整してあげれば下線は取り除いてしまわなくても済みそうだ。
で、どうですか?
:hoverの誤用について
ある要素がマウスオーバーされたことを伝える。それ以上の役割を:hover
に紐付くスタイルに持たせるべきではない。マウスオーバーするまでその要素のインタラクションがわからないデザインにしてはいけないのだ。
マウスオーバーするまでその要素がリンクであるという確信が持てないデザインによく遭遇する。それが本当にリンクであるか確かめるためには、わざわざマウスカーソルを移動させて試してみるしかない。スマホではタップだ。もし、ただのテキストだと期待して誤操作すれば、ページを戻る操作も必要になるかもしれない。
:hover
のスタイルを指定できることを拠り所にしてしまい、通常状態ではその振る舞いを期待させられる見た目になっていないことがよくある。しかし、マウスオーバーするということはインタラクションコストが掛かるということだ。また、:hover
によって何かしらの手掛かりが得られるかもしれないということにすら気づかれないことも当然ある。全ての要素にマウスオーバーしてくれるユーザーはほぼ存在しない。スマホにおいては無意味だ。
:hover
は、マウスオーバーされたということのみを伝えるために利用すべきだろう。それを意識することによって、マウスカーソルを見失いやすいユーザーやそういった状況を補助することができたり、要素のクリッカブルなエリアの境界をよりわかりやすくしたりできる。背景色など、変化が広い面で即座に見えるスタイルが好ましそうだ。
とは言え、:hover
のルール作りは思いの外問題を抱えがちだ。:hover
状態と通常状態の差をつけるために、いずれかを可視性の悪いスタイルにしてしまうこと。状態のバリエーションを増やすために見た目の一貫性を失ってしまうこと。そしてやはり、:hover
のスタイルで補うことに甘えてしまうことだ。:hover
のスタイルを実装することはもはや当たり前のように思われていることもあるが、あえて無視してしまった方が良い理由もある。:hover
のスタイルに振り回されることでそれ以外の部分に悪影響が及ぶのであれば、むしろ僕は積極的に:hover
をやめてしまうことを提案したい。
僕がこれについて気になったきっかけは、テキストリンクにマウスオーバーすると下線が付くという良くあるパターンには問題があると感じたからだ。それらの多くは、本文中のリンク以外の部分と色のみで区別されている。マウスオーバーするまでは、色以外の方法でリンクを区別する術が無い。
色は情報を区別するための手段として脆い。ディスプレイの品質や設定、部屋の照明や日の当たり具合によって容易に意味を失ってしまう。また、色覚異常やロービジョンなどの理由で色の区別が難しいユーザーも非常に多い。そのため、WCAGでは色が要素を判別するための唯一の手段になっていないことが達成基準となっている。前述したパターンでは、:hover
状態になるまでリンクであることを伝えていないことと同義となる。
ある要素がリンクであるということを伝えるとき、下線を引くことはこれ以上にないくらいに当たり前の表現だ。例えば、下線のないテキストリンクをリンクであると判断することはかなり文脈依存である。ボタンっぽい見た目のリンクも、それが本当にボタンなのかどうかの判断が難しいシーンがある。下線があればそれがリンクなのか迷うことはほとんど無い。ウェブにおいてかなり強い慣習だからだ。リンクから安易に下線を取ってしまわず、慣習に基づいてうまくやる方法を考えたいところだ。
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)の問題は依存として存在する。GoogleのSEOと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なんてものは知らない。
早く未来を見たい。