Shadow DOM による境界で複数 UI システムを共存させる

製品のフロントエンドで利用する「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
2
3
4
5
<div className="layout-widget-area">
<Barrier>
<div className="widget-content">Foo</div>
</Barrier>
</div>

これだけで div.layout-widget-areadiv.widget-content はスタイル的に分離されている。

コードを書き換える際の移行ステップも、コンポーネントの一部を取り急ぎ移行してしまう方法と移行先のコンポーネントを <Barrier /> でラップしたものを移行元のコードでコンポーネントとして定義・置換する方法とがあるだろう。できるところからガンガン進めるという意味でどちらも段階的な移行に役立つ。例えば、前者はページのような大きなコンポーネントをとりあえず一部分だけ先に移行させること、後者は子コンポーネントや Atoms レベルコンポーネントを丸ごと先に移行してしまうことなどだ。

Area Level Coexistence
1
2
3
4
5
<Barrier>
<button type="button" css={css`...`} onClick={() => setCount((count) => count + 1)}>
count is: {count}
</button>
</Barrier>
Component Level Coexistence
1
2
3
4
5
const Button = (props: ComponentProps<typeof ButtonNewGen>) => (
<Barrier>
<ButtonNewGen {...props} />
</Barrier>
);
1
2
3
<Button onClick={() => setCount((count) => count + 1)}>
count is: {count}
</Button>

実装

Barrier コンポーネントの中身は以下のようになる。Shadow root を作成する層と領域内に「UI システム」のグローバル CSS を加える層の 2 つに分かれる。

src/infrastructures/Barrier/ShadowLayer.tsx
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
export const ShadowLayer = ({ id, children }: Props) => {
const baseRef = useRef<HTMLElement>(null);

const [shadowRoot, setShadowRoot] = useState<HTMLElement>();

useLayoutEffect(() => {
setShadowRoot(
baseRef.current!.attachShadow({ mode: "open" }) as unknown as HTMLElement
);
}, []);

return (
<span
id={id}
ref={baseRef}
css={css`
display: contents;
`}
>
{shadowRoot
? createPortal(
<StyleLayer container={shadowRoot}>{children}</StyleLayer>,
shadowRoot
)
: null}
</span>
);
};

display: contents で Light DOM 部分の存在を隠しつつ、Shadow DOM を作成して createPortal でそれに内容(子ツリー)をマウントする。

src/infrastructures/Barrier/StyleLayer.tsx
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
export const StyleLayer = ({ container, children }: Props) => {
let emotionCache = cache.get(container);
if (!emotionCache) {
emotionCache = createCache({
key: "barrier",
container,
});
cache.set(container, emotionCache);
}

return (
<CacheProvider value={emotionCache}>
<Global styles={theNewCssReset} />
{children}
</CacheProvider>
);
};

const cache = new WeakMap<HTMLElement, EmotionCache>();

Emotion の cache という仕組みを用いて、子ツリーで指定しているスタイルの挿入場所を Shadow DOM にする。

(繰り返しになるが)今回は、領域は移行前後の 2 つしかないため固定でグローバル CSS を指定しているが、領域がたくさん考えられる場所では Barrier のようなコンポーネントを各領域の種類毎に用意する。

Portal を使う場合の工夫

この方法では、Dialog や Tooltip など、React Portal を使って実現するところは面倒になっている。こうした要素をページの要素ツリーのルート近くに Portal で render するとして、そのコンポーネントがどの領域に属するべきかを考える必要がある。ページと同じ「UI システム」に属するものであれば Portal の作成元・作成先でグローバル CSS は同じであるため問題は無い。そうでない場合には、移行先領域を考慮して指定するように createPortal を使わなければ正しいスタイルが当たらない。

Portal を使う場合に考慮すべき構造 Portal を使う場合に考慮すべき構造

このために、ページの要素ツリーには各「UI システム」毎にページルート Portal 用の領域を作っておき、createPortal を使うような場所でその領域に入るように呼び出す。以下のようなヘルパーコンポーネント[2]と関数を用意すると良いかもしれない。

src/infrastructures/PortalHead.tsx
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const ID = "portal-head";

export const PortalHead = () => <Barrier id={ID} />;

function getElement() {
return document.getElementById(ID)!.shadowRoot as unknown as HTMLElement;
}

export function createPagePortal(children: ReactNode) {
const element = getElement();

let emotionCache = cacheRef?.deref();
if (!emotionCache) {
emotionCache = createCache({ key: "portal-head", container: element });
cacheRef = new WeakRef(emotionCache);
}

return createPortal(
<CacheProvider value={emotionCache}>{children}</CacheProvider>,
element
);
}

let cacheRef: WeakRef<EmotionCache> | undefined;

(なお、25 行目で <CacheProvider /> を使わない場合とスタイルは Portal の設置元に置いて行かれてスタイルが当たらない。)

以下のように使う。

23
24
25
26
27
28
29
30
ReactDOM.render(
<React.StrictMode>
<Global styles={appGlobalStyle} />
<DefaultPage />
<PortalHead />
</React.StrictMode>,
document.getElementById("root")
);
1
2
3
const DialogBase = ({dialogContent}: Props) => (
createPagePortal(<div className="dialog">{dialogContent}</div>);
);

懸念

  • 移行中は UI にちぐはぐ感が出る
    • 色合いは同じであっても「UI システム」でそれぞれ挙動が違うことを許容できるか
    • 自分のプロジェクトで今回の方法をとらなかった理由の一つ
  • Barrier 内で React props の onMouseEnter/onMouseLeave が効かない
  • Shadow DOM のスタイルは Light DOM からも継承[3]される
    • Shadow DOM の仕様をよく叩き込み Barrier グローバル CSS を当てる必要がある
  • パフォーマンス上の不安
    • 境界が大量に散らばっている場合やリッチなインタラクションのある場合で計測していないため

まとめ

複数の「UI システム」を共存させたい状況に対して、Shadow DOM による境界を簡単に設置できるコンポーネントの実装と利用法を紹介した。


  1. エンジニアリングができていれば技術的には容易という意味。組織的、ビジネス戦略的には難しいチームはあるかもしれない。 ↩︎

  2. PortalHead というのは橋頭堡(bridgehead)の感覚で適当に名付けた ↩︎

  3. javascript - Light DOM style leaking into Shadow DOM - Stack Overflow および CSS Scoping Module Level 1 3.3.2 Inheritance ↩︎