css-modulesを止めようとしている話(具体的な解決編)

BEMでいいじゃん話の続きその2にして, 具体的な解決編.

多分ここが気になる人が多いと思うのでなるべく箇条書きで済ませることにする.

背景

前回書いてた内容をまとめると以下のようになる. 無駄話が多いので前回は読まなくてもいいです.

追記: 前回読んでもらった方がいい気がしてきた. 時間が無い方はいったん飛ばしてくれて構わないのは変わらず.

状況

  1. サービスの初期からwebpackのcss-loader(を用いたcss-modules)を使用していた
    • これは(個人的には好きではないが)そこまでの問題ではなかった
    • stylelintなどの各種のLint機構がそのまま使える上, CSSとUIコンポーネントのコードは分離できていた
  2. サービス開始時の鉄火場の中で, cascadingに基づく暗黙的なスタイルの継承を用いて, UI component treeにおいて祖先側が子孫側のスタイルを上書きしている箇所が多々あった
    • 実際に表示されているstyle systemは最終的に生成されたCSSのcascading順序に強く依存していた
    • 意図しないものまで暗黙的に上書きされているケースもあった
      • WebComponents一門のCSS Scopingによる解決待たないと, そもそも完全なisolation無理なので仕方ないんだけど.

起きた問題

  1. リファクタリングの一環でディレクトリ構造を転置し, 併せてimportの順序も変更・整列した結果, css-loaderによるモジュール間依存グラフの構築順序が変わり, 最終成果物たるCSSのcascading順序が変わり, 先述の暗黙的な上書きと相まって, ES Module間の依存構造の先の先に位置するような, ほぼ全く予期せぬ箇所のスタイルがいきなり崩れるような事故が発生し始めた
    • この時点でreftest(visual regression test)は無い
    • reftestがあったとしてもいきなり全く関係ない箇所のスタイルが壊れるコードはメンテがしんどい
    • リファクタリングを妨げるような道具立ては長期運用するプロダクトにおいて選択する理由がない
  2. webpackの上にCSSのビルドまで依存させた結果, ビルドパイプラインが複雑化の一途を辿っていた.

解決方法

要求事項

  1. 最終的に生成されるCSSが予測可能(透過的)なアプローチであること
  2. 他のツール群(Lintなど)との相互運用性が取れていること
  3. できればwebpackに依存しないこと
  4. css-loaderベースの既存コードからのmigrationが容易であること
  5. css-loaderベースの既存コードのメリットである「UIコンポーネントのコードとそれに関連するCSSファイルの物理的距離が近い(siblingである)」ことが保たれること

具体的な解決法

importの方法

元々, 以下のようなファイル構成になっていた

/
├ featA/
│ ├ ComponentA.css
│ └ ComponentA.jsx
└ featB/
  ├ ComponentB.css
  └ ComponentB.jsx

ComponentA.jsxのスタイル指定は原則的にはComponentA.cssに記述され, css-loaderを用いることでwebpackでのbuild時にimport styles form './ComponentA.css';のように解決される.

これを以下のようにする

/
root.css
├ featA/
│ ├ featA.css
│ ├ ComponentA.css
│ └ ComponentA.jsx
└ featB/
  ├ featB.css
  ├ ComponentB.css
  └ ComponentB.jsx

/root.cssは, featA.cssfeatB.cssを任意の順序で@import経由でimportし, featA.cssComponentA.cssを同様にimportする. featB.css も同じ.

これにより, 既存の記述方式からの緩やかな移行が可能になりつつ, ディレクトリ単位での順序関係の定義がCSS wayで明確に可能になった.

また, 現実的にはUIコンポーネントは粒度の細かいものから大きなものに沿って, 祖先方向(caller)なUIコンポーネントが子孫方向(callee)となり, 従属関係があるように設計されるため, セレクタ名さえユニークであればconflictすることはないし, ディレクトリ構造を少しいじった程度で規則が変わるという問題もない.

これは元々css-loaderを採用していた時から「理想的・本来的にはそうなっているはず」だったのだけど, 現実にはcascading順序が先述の理由から偶発的に変わることがあり, 厳密な保証を取りにくい状況にあった.

それをroot.cssでのimport順序に用いてシンプルなcascading順序解決にすることで最終的なコードにおける記述順序が透過的な形式で記述できるようにできた. そのため, 万が一に順序の問題が起きたとしても(そしてそれはレガシーコードとの釣り合いの問題や, css-loaderからの移行, リファクタリングの中間過程においては頻繁に発生しうるが), 以前よりは順序問題として容易にコントロールが可能になる.

尚、コードはわかりやすくするとこんな感じになる.

/* desktop/mobileで切り替え可能になっている */
@import './variables.css';

@import './component/atom.css';
@import './component/mole.css';
@import './component/org.css';

/* template */
@import './featA/featA.css';
@import './featB/featB.css';

/* page */
@import './page1/page1.css';
@import './page2/page2.css';

これにより

  • css-loaderベースの既存コードからのスムーズなmigration
  • UIコンポーネントのコードとそれに関連するCSSファイルの物理的距離が近い(siblingである)

はなんとかなった.

セレクタ命名規則

前項の前提を達成するためにセレクタをユニークにするにはBEMを下敷きに以下のように設定した.

.アプリケーションドメイン-コンポーネント名__コンポーネント内の要素--修飾子

アプリケーションドメイン の箇所は, 当該cssが配置されているディレクトリまでのパスが-で繋がれる形式になることが多い. 一方で汎用コンポーネント.com-a.com-mのように簡略表記を許容していた.

同時に, あとでgrepで検索しやすくするため, アプリケーションドメイン-コンポーネント名の部分をJSコードなどで動的に構築するのは(事実上)禁止することにした.

これは将来的にセレクタ名をrenameするのを容易にするための方針. そもそもcssファイルのsiblingに位置するjsファイルの中でしか使われないので必ずしも重要ではないのだけれども, 原理上, 上位(粒度の粗い側)のコンポーネントからセレクタ経由でdirty hackとして上書きされる可能性を許容してしまっているため, それを「やらざるを得なくなった」場合への対処の余地も含んでいる(現実にはコードレビューで弾かれることの方が多いので存在はしないだろうと思っているが).

結果(中間報告)

一番最初にも述べたけど最終的な移行完了を自分は見届けたわけではないので中間報告になるけれども.

  • postcssベースで実現
    • node-sassを使わなかったのは, Node.jsのmajor version upへのnode-sassの対応が他に比べて遅れやすく, Node.jsの更新の妨げになるケースが過去多々あったために避けた
  • 要求事項は達成
    • コード上の使用箇所とスタイル定義が紐づくため, 不要コードの削除も比較的容易なcss-loaderのメリットを引き継げたのは非常に大きい
  • 何よりも生成コードの予測がつきやすくなった
    • easyではないがsimpleは達成できた

ので結果は上々(と言えると思っている).

Performance Impacts

  • minifyしないことで相対的なファイルサイズの増加は起こり得る
    • が, 概算で全体の半数近くを移行している現状でも, ファイルサイズが早速数倍に増加しているわけではない
      • 移行完了時に計測し直す必要はある
      • 現状, gzipすると数kbしか差がない
    • CSSのparsing速度に関するデータが足りないので迂闊なことは言えないが, 数倍に増加しなければいいだろう程度に考えている
      • それよりもアプリケーション起動時に必要なJSのサイズや起動フローの方がよほど問題になっている.
  • SpeedCurveでの計測を見ていると移行による大規模なperf regressionは起きていない
    • 長期的にはむしろ通常の機能追加に基づくコードの増加の影響の方が多い....

未解決または諦めた問題たち

  1. class属性のmangling
    • JSファイルとCSSファイルを相互に参照しながら一気通貫して処理する仕組みではなくなったので, class属性のmanglingは諦めた
    • 今の所(自分が主に関わっていた当時)はperformance上の問題になっていない.
    • 良くも悪くもユーザーからのhackabilityが向上してしまっている
      • 一方で, production環境でスタイル周りのバグを見つけた場合でも, その場で自分でデバッグしやすいというメリットもある
  2. critical rendering pathに必要なCSSだけの読み込み
    • css-loaderべったりの時から, 最終成果物は独立したCSSファイルにしていたため, これ自体はregressionではない
    • 将来的にこれが問題になった時に改めて検討する
      • plainなCSSにした結果, 必要なスタイル定義だけを切り出してhtml中に埋め込むなどの最適化の余地が広がっているんじゃないかと思っているものの, その調査と最適化を必要とする段階には至っていないので未確認.
    • 現代の多くのwebサイト・アプリケーションでは, この問題が現実の問題になるよりも先に, initialで読み込むJSのサイズの方がよっぽどperformance bugになると推測しているし, このとあるアプリケーションA(仮)では事実そうだった
  3. renameがめんどい
    • 変更する箇所が多い
    • これはある程度固まったプロダクトでは問題ない
    • 立ち上げ期の試行錯誤の続くコードベースではめんどくさいかもしれない
      • grep一発で解決可能ではあるが...

余談

  • 元々CSS-in-JS嫌いな自分が面白半分で実験していたものを, regression鎮火のために, UIコード周りのオーナーと煮詰め直してworkaroundとして急遽投入したものだけど, 案外うまくいった(と思っている).
  • easyではないがcascading style sheetの記述としてsimpleな所に落ち着いたと思うし, 自分としてはBEMで問題が解決していたという屁理屈が実証を伴ったという認識.
  • いくつかのunresolved question含め、そのうち同僚の誰かがもう少し詳細に話してくれる....はず. 多分.

教訓・まとめ

  • class属性を上書きしてどうにかするのは忙しくても止めた方が良い
  • reftestは早期からあると良い
    • 壊れたことを検知するには当然テストですよ
  • CSScascading style sheetsでありES Moduleとは諸々の解決規則が違うので, 道具としては別物として考えるべき

追記: ちょっとした返答・反論とか

CSS Moduleを止めようとしている話(具体的な解決編) - saneyuki_s log

“cascadingに基づく暗黙的なスタイルの継承を用いて, UI component treeにおいて祖先側が子孫側のスタイルを上書きしている” コレを食い止めなられなかったことが致命的であって、BEMかCSS Modulesか、という話はあんまり関係な

2019/03/15 03:32

根本的かつ理想的にはそうです. それで解決できていたならば何を採用しても苦労しません.

しかし残念ながら,

  • レビューの際の見逃しやby designや時間的な制約による場当たり的な対応という意味の事故も含め, そのような問題が容易に発生しうるのがCSS Scopingのない(WebComponentsに頼れない)世界のCascading Style Sheetsの限界である
    • そうした問題が発生していないのであれば, そもそもBEMだstyled-componentsだのという論争も存在しない.
  • 現実にそれが当該アプリケーションのコードベースでは発生していた
    • 今後の運用の過程で継承や上書きに頼る必要のある箇所が(偶発的であっても)現れるのが予見しうる状況にあったし, 仮にゼロから理想的な設計かつ負債の無いコードベースで構築したとしても, プロダクションにおけるコーナーケースも考慮すると, どこかの層で原理的に担保しない限り「発生しない」という前提に立つのは難しい.
  • CSS継承に頼らず, 関連するpropertyを個別にunsetまたはinitialで埋めつつ作業をするのも解法としてあり得るが現実的ではない.
  • そして本文の繰り返しになりますが, リファクタリングなどに際してそこの依存が思わぬ箇所に出てくる結果となっていた. また, css-modulesの枠内で対処を試みると, 取り扱っている対象と記法のセマンティクスのミスマッチから却ってmonkey patchを増やすばかりであった

ため, それらの課題に対して

  1. そもそも暗黙的な上書きをしうるケースを減らす方向にコーディングスタイルやミクロなUIツリーの設計を変えるようにした
  2. 最終的に生成されるCSSコードを透過的に扱えるようにすることで, 以下の課題への対処を容易にする
    • 1の結果発生しうるコーナーケースの緩和
    • 1を志向してもどうしてもうっかり発生しうる将来の問題へのworkaround
    • すでに負債と化していたコードベースで発生していた, css-module依存のcascadingに依存したコードからの移行

というのが, 当該アプリケーションで採用した解決案であり, 本エントリで主に言及していたのは2になります.

CSS Moduleを止めようとしている話(具体的な解決編) - saneyuki_s log

無秩序なCSS Modulesの使用から、秩序あるBEMにしたら、秩序を得た。CSS Modulesはなにも問題ないのでは?

2019/03/15 20:27

改めて説明すると,

  1. css-module時代のコードはそれはそれで秩序はあった.
  2. 付け加えていうのであれば
    1. リファクタリングを妨げるような道具立ては長期運用するプロダクトにおいて選択する理由がない
    2. 最終的にcascading style sheetsのセマンティクスで表現されるものを, ESModule(ないしwebpackのloaderセマンティクス)で構築するような, 異なるセマンティクスを道具で構築するのはやり方が悪い
    3. 最終的に規約や秩序や運用でカバーといった言葉で解決するしかないのであれば, よりsimpleな運用で済む手段で良い
  3. とりあえずこちら と, 本エントリを改めて読んでいただければと思います

色々書いてなかったこと

Twitterでやりとりした中で、やっぱり書いたほうがいいなと思った話。

込み入りすぎててうまく説明できる気がしなかった初稿で説明していなかったが, className地獄への門の一端として, 現状の課題たるコーナーケースも色々あった(全部覚えていない + 全部列挙する前にCSS Module路線を諦めた)。例えば「本来であれば:nth-child()擬似クラスで解決するのが綺麗なはずだが、実際には適用したい対象がReact Componentかつ孫よりも下位に位置するため, 結局何かしらの上書きが必要になっている」など。この辺りについては「そもそものUI ComponentのAPI設計・粒度がおかしい」と断じてしまえばそれまでなのだが, 目の前にあるコードをmigrationしようとするには避けて通れない問題であった。

また、CSS custom propertiesなどはレガシー環境を考慮するとコンパイル時に解決するものになってしまう。つまり, 実行時制約を転化には処理系でのネイティブサポートが必須になるもののIE11などのサポートを含めている以上はそちらを前提にした設計に切り替えることは難しい(丸々IE11向けとそれ以外のコードベースを用意するか、そこも含めてサポートする互換レイヤーを何かしらで用意する必要があった)。

また、WebComponents一式が使えるようになったとしても, ShadowDOM境界を跨ぐ前提に立つと諸々の設計パターンは変わる可能性があり, WebComponents周りが未だ発展途上気味であるため(form control周りなどは最たる例だし, 残念ながら「今年はWebComponents元年」と年初に叫ぶはもはや年中行事になりつつある), より「モダン」なapproachへ将来的に移行したいことや最も強固なエコシステムであるWeb標準のなるべく近くに位置しておきたいことを考慮すると, メンテナブルかつ保守的(Web標準の近くに寄る)手段を採用するほうが長期的には払うコストが少ないだろうと予想し. ゆえに, シンプルに振ることになった.

ゼロから全てを描き直せるのであれば, おそらく強固な規約を浸透させるなどの運用戦術を取りつつ, 別の手段を考慮する可能性もあるだろう. しかし, 我々にそのような富豪的な選択肢はありえないし, 仮に書き直せるとしても真っ先にレガシー環境を切り捨てる方向に体力を注ぐだろうし, 一連の記事で述べている過程は「書き直しやすくする」ための前段階としての措置であった. そもそも頻繁に書き直しできるのであれば, 長期的にメンテナブルにすべきかなどといった問題はない. 困ったら書き直せばいいのだから.

それとstyled-components/CSS-in-JSに関する話については, 「最終的にCSSというシステムに落ちるのだから」自分は否定的にすぎない(あれで本当に多くのcascadingの課題を難なく解決できるのであればそちらで全く問題ない). ゆえに, React Nativeのように最終形がAndroidiOSのスタイルシステムに落ちるフレームワークに関しては意見するつもりはない.そっちは今の所よく知らないし.