こんにちは、Inflabのフロントエンド開発者のHongsiです。
最近私たちのチームは、既存の技術スタックでInflearnのサービスを維持・発展させつつ、将来を見据えて1ページずつモダンな技術スタックで改善していくことに力を入れています。
モダンな技術スタックで新しく作るWebアプリは、最終的に既存のInflearnサービスの上に載ることになるため、新しいWebアプリと既存サービスのあいだでスタイルの衝突が避けられませんでした。
これをshadow DOMを使って解決した方法を共有します。
1. 問題
初期のInflearnサービスはCSSフレームワークのBulmaを使って構築されており、現在もBulmaのスタイルシートがグローバルに適用されています。
それだけでなく、Bulmaのスタイルシートから派生したさまざまなグローバルスタイルもあります。
これらのグローバルスタイルは汎用的なセレクター、特にタグセレクターを多用していたため、新しいアプリで使うデザインシステムがどんな影響を受け、どんな混乱を招くのか予測しにくい状態でした。
4年前に書かれたreset.scss内のaタグに対するスタイルコード
aタグに対するグローバルスタイルの影響を受けて、本来のスタイルが崩れたボタンコンポーネント
2. 解決方法の検討
モダンスタックへ移行中のページのHTMLに、既存のスタイルシートをまったく適用させないという方法は、最初から除外しました。
GNBとFooterを除いた領域だけを新しいReactアプリで描画しており、同じHTMLの中でもGNBとFooterには既存のスタイルシートが必要だからです。

結果的に、新旧スタイルの衝突問題を解決するために検討した方法は、全部で3つでした。
- 問題となる部分だけを
!importantで処理する。 - SCSSを使って、特定のクラス名を持つ要素にはInflearnサービスのグローバルスタイルシートが適用されないようにする。
- shadow DOMを使う。
2-1. 問題となる部分だけを!importantで処理する
短期的には最も早く問題を覆い隠せますが、根本的な解決策にはなりません。
後で本当に !important が必要になったときはどうするのか、ほかのスタイルとの優先順位をどう管理するのかなど、メンテナンスが非常に難しくなるからです。
また、その「問題となる部分」を正確に見つけ出すには、各要素のスタイルを開発者が一つひとつ確認しなければなりません。
スタイルを書くたびに目を皿のようにして違いを探すのは、本当に大変です。
gray 600 が gray 500 に変わるような微妙な違いには、なかなか気づけないからです。
2-2. SCSSを使って、特定のクラス名を持つ要素にはスタイルシートが適用されないようにする
sass:meta.load-css と not セレクターを使って、特定のクラスを除いた残りの要素にグローバルスタイルを適用しようと試みました。
meta.load-css で特定のスタイルシートを読み込むと、次のように特定のセレクター配下にある要素にだけスタイルを適用できます。
しかしこの方法では、それぞれのスタイルシートで宣言したスタイル変数にアクセスできない問題が発生するため、使えませんでした。
スタイル変数を読み込めずに発生するエラー
結局、最後の方法であるshadow DOMを試すことにしました。
3. shadow DOM
shadow DOMは、Webコンポーネントの中核の一つです。
Webコンポーネントについて詳しく知りたい方は、こちらを参照してください。
Webコンポーネントは、再利用可能なカスタムHTML要素を作成し、その要素をカプセル化する技術です。
カプセル化によってマークアップ、スタイル、動作を外部から隔離し、Webページのほかの構成要素からの干渉を防ぐことができます。
なかでもshadow DOM APIは、隠されて分離されたDOMを既存のHTML要素にアタッチする方法を提供します。
そのため、shadow DOMが今悩んでいる問題の解決に役立つと判断しました。
shadow DOMを適用したときのDOMツリー構造
shadow DOMで使われる用語は次のとおりです。後述する例と合わせて見ていきましょう。
- Shadow host:shadow DOMがアタッチされる通常のDOMノードで、ホスト要素です。
- Shadow tree:shadow DOM内部のDOMツリー。
- Shadow boundary:shadow DOMが終わり、通常のDOMが始まる場所。
- Shadow root:shadowツリーのルートノード。
また、shadow DOMは主要ブラウザーの直近2バージョンでサポートされているため、本番環境での利用に適しています。
4. shadow DOMを適用する
実際のプロダクションコードにshadow DOMを適用する手順を紹介します。
4-1. shadow DOMの作成
私が所属する学習者向けのチーム(学習者セル)では、accountというReactアプリを開発しています。
Inflearnのサービスのうち「学習者」にあたる領域を、モダンスタックへ移行する取り組みです。
このアプリはビルドされた後、既存のInflearnサービスからscriptタグで読み込まれ、InflearnのHTML内の <div id="App"> に描画されます。
そのHTMLを簡単に表すと、次のとおりです。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>InflearnのHTMLを簡略化したもの</title>
<style>
✨ グローバルスタイルを含む、Inflearnサービスの既存スタイル
</style>
</head>
<body>
<div id="root">
<nav>GNB</nav>
<main id="main">
<div id="App">✨ account Reactアプリが描画される場所</div>
</main>
<footer></footer>
</div>
</body>
</html>
accountアプリが配信されるフローの詳細は、次の記事で紹介する予定です 😇
私たちが目指しているのは、ReactアプリがInflearnサービスのグローバルスタイルの影響を受けないよう、<div id="App"> をshadow DOMで隔離することです。
shadow DOMを直接作成することもできますが、後述するemotionのスタイルシートの問題をより便利に扱うために、react-shadow ライブラリを使いました。
// main.tsx
ReactDOM.render(
<React.StrictMode>
<AppProvider>
<App />
</AppProvider>
</React.StrictMode>,
document.getElementById('main'),
);
// AppProvider.tsx
import root from 'react-shadow';
<BrowserRouter>
// ✨ root.divはshadow hostにあたります
<root.div id="shadow-root">{children}</root.div>
</BrowserRouter>;
すると、次のようなDOMツリーが作成されます。▶︎#shadow-root が、先ほど説明したShadow rootにあたります。
Shadow root配下に作成されたコードは、外部に影響を与えません。
また、外部から影響を受けることもありません。
そのため、Inflearnサービスのグローバルスタイルからも安全です。
通常、shadow DOM内部の要素を選択しようとすると、上のように null が返されます。
上のコードではライブラリを使いましたが、実は Element.attachShadow() を使えば、shadow DOMの登録はとても簡単です。
このメソッドは、引数としてオプションオブジェクト { mode: 'open' | 'closed' } を受け取ります。
modeがopenの場合、メインページ(DOM)からJavaScriptを使って(Element.shadowRoot など)shadow DOMにアクセスできます。
modeがopenであれば、このようにshadowRootにアクセスでき、shadowRootを通じて要素を選択できます。
4-2. styleタグの移動
ここまでで、新しいReactアプリをInflearnサービスのグローバルスタイルから隔離することには成功しました。
しかし、アプリをローカルで実行してみると……Reactアプリのスタイルがすべて崩れていることが分かります 😱
account Reactアプリで生成された <style/> タグが、shadow DOMではなくメインページのhead内にあるため、shadow DOMにスタイルが適用されないからです。
したがって、元のDOMのhead内にある <style> をshadow DOMの中に移す必要があります。
方法は簡単です。emotion cacheを使います。
emotion cacheについて詳しく知りたい方は、関連する公式ドキュメントを参照してください
emotion cacheは、emotionによって生成されたスタイルを挿入するために使われます。
createCache 関数と <CacheProvider/> コンポーネントを組み合わせると、好きな位置にスタイルを挿入できます。
createCache 関数でemotion cacheのインスタンスを作成し、そのインスタンスを <CacheProvider/> コンポーネントのvalueとして渡すだけです。
私たちの場合、<style> を生成すべき場所はshadow DOM内部の最上部なので、shadow hostをrefで選択します。
const AppProvider: FC = ({ children }) => {
const shadowRootRef = useRef<HTMLDivElement>(null);
return (
<BrowserRouter>
<QueryClientProvider client={queryClient}>
// ✨ shadow hostをrefで選択します。
<root.div id="shadow-root" ref={shadowRootRef}>
{children}
</root.div>
{MODE !== 'production' ? <ReactQueryDevtools /> : null}
</QueryClientProvider>
</BrowserRouter>
);
};
export default AppProvider;
createCache 関数(下のコードでは createEmotionCache)は、keyとcontainerを受け取れます。
keyはインスタンスを区別する識別子として使われ、containerはstyleタグが生成される位置、つまりターゲットを意味します。
containerにrefをそのまま渡すと、shadow rootと同じ階層にstyleタグが生成されてしまうため、shadow hostのshadow rootを指定する必要があります。
ここで問題になるのは、ref.shadowRoot が null の場合です。
containerが null だと、createCache 関数は正しく実行されません。
一度DOMが描画された後に setShadowRefs 関数が実行される必要があるので、shadowRootRef.current?.shadowRoot を依存配列に指定したuseEffectで囲み、shadowRootが null でないときに createCache 関数が実行されるようにします。
const AppProvider: FC = ({ children }) => {
const shadowRootRef = useRef<HTMLDivElement>(null);
const [inflabEmotionCache, setInflabEmotionCache] = useState<EmotionCache>();
useEffect(() => {
if (shadowRootRef.current?.shadowRoot) {
setShadowRefs(shadowRootRef.current);
}
}, [shadowRootRef.current?.shadowRoot]);
function setEmotionStyles(ref: Maybe<HTMLDivElement>) {
if (!ref?.shadowRoot) {
return;
}
if (ref && !inflabEmotionCache) {
const createdInflabEmotionWithRef = createEmotionCache({
key: 'inflab',
container: ref.shadowRoot, // ✨ shadow host(=ref)からshadow rootを選択してcontainerにします。
});
setInflabEmotionCache(createdInflabEmotionWithRef);
}
}
function setShadowRefs(ref: Maybe<HTMLDivElement>) {
setEmotionStyles(ref);
}
return (
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<root.div id="shadow-root" ref={shadowRootRef}>
{inflabEmotionCache && (
<EmotionCacheProvider value={inflabEmotionCache}>{children}</EmotionCacheProvider>
)}
</root.div>
{MODE !== 'production' ? <ReactQueryDevtools /> : null}
</QueryClientProvider>
</BrowserRouter>
);
};
export default AppProvider;
これで、containerに指定したshadow rootの中にemotionが生成したstyleタグが入り、Reactアプリにスタイルが正しく適用されていることを確認できます。
4-3. React Portalのターゲット修正
最後に、React Portalの設定が残っています。
私たちが使っているデザインシステムのmodal、tooltip、notificationなどのコンポーネントは、React Portalをベースに動作します。
これらのコンポーネントにはPortalを使わずに動作させるオプションもありますが、いろいろな面でPortalを通じて隔離されたrootに生成されるほうがすっきりします。
例えば position:fixed などのスタイルが適用されたとき、モーダルが特定の要素の中に閉じ込められてしまったり、z-indexの問題が起きたりする可能性があるからです。
何よりも、今の状態のままPortalベースのmodalコンポーネントを使うと、shadow DOMの外側に要素が生成されるため、スタイルも適用されず、レイアウトも崩れる問題が発生します。
そのため、デザインシステムで使うPortalのターゲットをshadow DOMの中に移す必要があります。
幸い、Theme ProviderでPortalのターゲットを設定できました。
<ThemeProvider
theme={{
components: {
Portal: {
defaultProps: {
// ✨ shadow rootをPortalのデフォルトのtargetに設定します。
target: document.querySelector('#shadow-root')?.shadowRoot,
},
},
},
}}
>
5. おわりに
このようにshadow DOMを使うことで、新旧のスタイルを独立して保ちながら、既存サービスと新しく開発するReactアプリをうまくつなぎ合わせることができました。
既存のコードと新しいコードを、互いに影響を与えないよう独立させつつ、それでいて一緒に維持していきたい方の参考になれば幸いです。
ありがとうございました 🤓
※この記事は2022年8月31日に執筆されたものです。




