製品のフロントエンドで利用する「UI システム」を漸進的に移行していく際に役立ちそうな方法を思いついて組んでみた。残念ながら自分はこの方法は当面使わないことにしたので、ある種の小ネタ集として公開し、供養しておく。
きっかけ
やりたいこと
既存の、すでに大きいフロントエンドプロジェクトの「UI システム」を別のものに漸進的に移行したい。自分のプロジェクトでは SSR や ISR は必要がない領域なので考慮せず、今回はフル CSR を前提とする。
「UI システム」の例は以下の内容が挙げられる。
- リセット CSS の類
- コンポーネントフレームワーク・ライブラリ(Vuetify や MUI 等)
- Utility-First CSS(preflight)
例えば、MUI から TailwindCSS に移行したいという状況だ。MUI と TailwindCSS を同じ HTML 要素や Organism レベルコンポーネントで併用するのは避けたい。
こうしたとき、ページ丸ごと(ビッグバン)リプレイスする方法では共存の手間は不要だが、並行開発にするか・移行中の機能実装を止めるかの判断が難しい、切り替え時に問題があったときの影響範囲が大きい、という問題があり、リスクが大きい。移行と機能実装を完全にフェーズを分けてロックを設けるようにして移行していく場合には、そのフェーズ中に外部環境の変化により方針変更が必要になることは多々あり、その際に方針がロックによる制約を受け問題になる。
そうならないように、アジャイル開発と同じように、タスク・フェーズを細かく分け、UI 上の領域や部品の開発ロック期間を最小化したい。こうしたときの流れは以下の図のようになる。緑のアイテムが移行後の部品、緑の枠線が(今回の本題となる)境界を示す。
例えば、Step1 の完了から Step2 の完了まで、“Organism” と上の “Molecule”(移行後)は自由にコードを修正できる。つまり、移行 Step の対象が自分自身でない部分はロックがかからない。また、移行後の UI 要素に問題があったとしても、その影響は一部分にしか存在しないので、問題は必要以上には大きくならない。
UI の無いコードであればリファクタリングのプラクティスにより段階移行は容易に実現可能[1]だが、UI が絡むコードでは副作用も多いため、領域を分けるための技術的な境界が必要となる。
また、このための領域・境界を半永久的な物とすれば Modular Monolith ならぬ “Modular Front-End” も可能だろう。
技術的要求
- ある子ツリーが CSS 的に独立していること
- 境界を跨いでイベント等がやり取り可能なこと
- 設置が簡単なこと
- 複数箇所・複数種類の領域が作成可能なこと
実現
方法
上記の領域を分けるための技術的な境界として、今回はタイトルにある通り Shadow DOM を用いる。その他の Custom Element の構成要素は使わない。
今回やることに似たものとして、すでに ReactShadow というものがある。これを参考に、今回の要求を軽量に満たすものを実装する。
具体的には、Shadow root を attachShadow
で作成し、そこに領域内の要素に React Portal を使ってマウントする。Shadow root にはその領域内で利用する「UI システム」のグローバル CSS を加える。
今回は Emotion という CSS in JS ライブラリを利用するが、他のライブラリも何かしらの設定を行えば同じことが可能だろう。なお、Shadow DOM グローバルに CSS を置く上で、CSS の設置をバンドラに一任する現時点での CSS Modules は厳しそうだった。
使い方
今回は、実装として <Barrier />
と囲うだけでその内外に境界を設置できるものを作成した。サンプルプロジェクトはこちらから。なお、「複数種類の領域が作成可能なこと」は例として実装していないが、‘infrastructures’ 内のコード一式をそれぞれ作成したりファクトリ化したりすれば実現可能だ。
以下のように利用する。
1 | <div className="layout-widget-area"> |
これだけで div.layout-widget-area
と div.widget-content
はスタイル的に分離されている。
コードを書き換える際の移行ステップも、コンポーネントの一部を取り急ぎ移行してしまう方法と移行先のコンポーネントを <Barrier />
でラップしたものを移行元のコードでコンポーネントとして定義・置換する方法とがあるだろう。できるところからガンガン進めるという意味でどちらも段階的な移行に役立つ。例えば、前者はページのような大きなコンポーネントをとりあえず一部分だけ先に移行させること、後者は子コンポーネントや Atoms レベルコンポーネントを丸ごと先に移行してしまうことなどだ。
1 | <Barrier> |
1 | const Button = (props: ComponentProps<typeof ButtonNewGen>) => ( |
1 | <Button onClick={() => setCount((count) => count + 1)}> |
実装
Barrier
コンポーネントの中身は以下のようになる。Shadow root を作成する層と領域内に「UI システム」のグローバル CSS を加える層の 2 つに分かれる。
16 | export const ShadowLayer = ({ id, children }: Props) => { |
display: contents
で Light DOM 部分の存在を隠しつつ、Shadow DOM を作成して createPortal
でそれに内容(子ツリー)をマウントする。
10 | export const StyleLayer = ({ container, children }: Props) => { |
Emotion の cache という仕組みを用いて、子ツリーで指定しているスタイルの挿入場所を Shadow DOM にする。
(繰り返しになるが)今回は、領域は移行前後の 2 つしかないため固定でグローバル CSS を指定しているが、領域がたくさん考えられる場所では Barrier
のようなコンポーネントを各領域の種類毎に用意する。
Portal を使う場合の工夫
この方法では、Dialog や Tooltip など、React Portal を使って実現するところは面倒になっている。こうした要素をページの要素ツリーのルート近くに Portal で render するとして、そのコンポーネントがどの領域に属するべきかを考える必要がある。ページと同じ「UI システム」に属するものであれば Portal の作成元・作成先でグローバル CSS は同じであるため問題は無い。そうでない場合には、移行先領域を考慮して指定するように createPortal
を使わなければ正しいスタイルが当たらない。
このために、ページの要素ツリーには各「UI システム」毎にページルート Portal 用の領域を作っておき、createPortal
を使うような場所でその領域に入るように呼び出す。以下のようなヘルパーコンポーネント[2]と関数を用意すると良いかもしれない。
7 | const ID = "portal-head"; |
(なお、25 行目で <CacheProvider />
を使わない場合とスタイルは Portal の設置元に置いて行かれてスタイルが当たらない。)
以下のように使う。
23 | ReactDOM.render( |
1 | const DialogBase = ({dialogContent}: Props) => ( |
懸念
- 移行中は UI にちぐはぐ感が出る
- 色合いは同じであっても「UI システム」でそれぞれ挙動が違うことを許容できるか
- 自分のプロジェクトで今回の方法をとらなかった理由の一つ
Barrier
内で React props のonMouseEnter
/onMouseLeave
が効かない- Consider removing mouseenter/mouseleave polyfill · Issue #11972 · facebook/react の実現で解決されるかも
- 面倒だが ref と
addEventListener
で"mouseenter"
"mouseleave"
を登録すれば動作上は問題ない
- Shadow DOM のスタイルは Light DOM からも継承[3]される
- Shadow DOM の仕様をよく叩き込み
Barrier
グローバル CSS を当てる必要がある
- Shadow DOM の仕様をよく叩き込み
- パフォーマンス上の不安
- 境界が大量に散らばっている場合やリッチなインタラクションのある場合で計測していないため
まとめ
複数の「UI システム」を共存させたい状況に対して、Shadow DOM による境界を簡単に設置できるコンポーネントの実装と利用法を紹介した。
エンジニアリングができていれば技術的には容易という意味。組織的、ビジネス戦略的には難しいチームはあるかもしれない。 ↩︎
PortalHead
というのは橋頭堡(bridgehead)の感覚で適当に名付けた ↩︎javascript - Light DOM style leaking into Shadow DOM - Stack Overflow および CSS Scoping Module Level 1 3.3.2 Inheritance ↩︎