以前作成したこちらの記事Bootstrap.cssを特定の領域内部のみに適用し、その中にReactのコンポーネントを描画する方法(ShadowDOMを利用)
で、Shadow DOM内にReactを描画することはできましたが、いくつか問題がありました
- CSSの継承プロパティが外部のDOMからshadow DOMへ継承されてしまい正しく表示されない場合がある
- モーダルダイアログを表示すると、Bootstrapのスタイルが適用されない
1.については、shadow DOMでもCSSのスコープを分離できない場合があるに原因と対策を書きました
2.については、モーダルダイアログを表示する際、コンポーネントライブラリ側でcreatePortalを呼び出します。その結果document.bodyにDOMが追加される(shadow DOMの外に出てしまう)ため、スタイルが適用されなくなっていました
上記を考慮したコンポーネント<IsolatedScope>を作成しました
概要
- Shadow DOMを利用して、スタイルの干渉を防ぎながらReactコンポーネントをレンダリングする
- 外部CSSを適用可能 (styleSheetURLs プロパティ)
- Portalコンテナを提供し、ModalなどのUIコンポーネントの配置を容易にする (onPortalContainer)
DOMの様子
- shadow DOM内に
bootstrap
が読み込まれており、モーダルダイアログもshadow-root
配下に作成されているため、スタイルが正しく適用されるようになっています
利用方法
- <IsolatedScope>で囲んだ箇所が、shadow DOMでラップされます
- スタイルが外部と隔離されます。shadow DOM内部で適用するCSSのURLを渡します(オプション)
- モーダルダイアログを利用する場合、shadow DOM内部に作成したportalをコンテナとして利用する必要があります。コールバック関数でportalを受け取り、必要な個所で利用します
main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import IsolatedScope from './IsolatedScope.tsx';
import './index.css';
import App from './App.tsx';
declare global {
interface Window {
portal_container: HTMLElement;
}
}
// bootstrapのURL(shadow DOM内部に適用する)
const styleSheetURLs = [
'https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css',
];
const root = document.getElementById('root');
createRoot(root!).render(
<StrictMode>
<IsolatedScope
styleSheetURLs={styleSheetURLs}
onPortalContainer={(container) => {
// モーダルダイアログ表示用portalを保存
window['portal_container'] = container;
}}
>
<App />
</IsolatedScope>
</StrictMode>
);
- ダイアログ(<Modal>)の
container
に、portalを渡すことでダイアログが正しく表示されるようになります
app.tsx
import { useState } from 'react';
import Button from 'react-bootstrap/Button';
import Modal from 'react-bootstrap/Modal';
function App() {
const [show, setShow] = useState(false);
const handleClose = () => setShow(false);
const handleShow = () => setShow(true);
return (
<>
<Button variant="primary" onClick={handleShow}>
ダイアログを表示する
</Button>
<Modal
container={window['portal_container']}
show={show}
onHide={handleClose}
>
<Modal.Header>
<Modal.Title>ダイアログタイトル</Modal.Title>
</Modal.Header>
<Modal.Body>ダイアログコンテンツ</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={handleClose}>
閉じる
</Button>
</Modal.Footer>
</Modal>
</>
);
}
export default App;
※container
未指定の場合、document.body直下にモーダルダイアログが追加されるため正しく表示されません
<IsolatedScope>コンポーネントソース
Shadow DOM内にReactを描画するためのコンポーネント
/**
* Shadow DOM内にReactを描画するためのコンポーネント
*/
import React, { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom/client';
type IsolatedScopeProps = {
children: React.ReactNode;
styleSheetURLs?: string[];
onPortalContainer?: (container: HTMLElement) => void;
};
/**
* Shadow DOM内にReactコンポーネントをレンダリングするコンポーネント
* ・外部のスタイルシートの影響を受けない独立したスコープ (Shadow DOM) を提供します
* ・`:host { all: initial; }` を適用することで、継承されたスタイルをリセットし、スタイルの衝突を防ぎます
* ・`styleSheets` プロパティを通じて、Shadow DOM 内部に適用するスタイルシートを制御できます
* ・Portalを利用するコンポーネント(ダイアログなど)をShadow DOM内に配置するためのコンテナ `portal-container`)を提供します
* これにより、ダイアログがShadow DOMのスタイルスコープ内で適切にスタイルされます
*
* 使用例:
* ```tsx
* <IsolatedScope styleSheets={['/bundle-react.css', '/other.css']}>
* <Button>Button Component</Button>
* </IsolatedScope>
* ```
* @param children レンダリングするReact子要素
* @param styleSheets Shadow DOM内部に適用するスタイルシートURLの配列 (オプション)
* @param onPortalContainer Portalを取得するためのコールバック関数 (オプション)
* このコールバック関数が`portal-container`要素を引数として呼び出されます
* ダイアログを表示する際のcontainerとしてコールバック引数を利用してください
*/
const IsolatedScope: React.FC<IsolatedScopeProps> = ({
children,
styleSheetURLs,
onPortalContainer,
}) => {
const shadowHostRef = useRef<HTMLDivElement | null>(null);
const rootRef = useRef<ReactDOM.Root | null>(null);
useEffect(() => {
if (shadowHostRef.current) {
// Shadow DOMが存在しない場合に作成 (初回レンダー時のみ実行)
const shadowRoot =
shadowHostRef.current.shadowRoot ||
shadowHostRef.current.attachShadow({ mode: 'open' });
// Shadow DOM 内のスタイルを初期化 (継承スタイルを打ち消し、スコープを分離)
// :host はCSSの擬似クラスで、shadow DOMのルートを表す
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
:host {
all: initial;
}`);
shadowRoot.adoptedStyleSheets = [sheet];
if (!rootRef.current) {
// 初回のみ実行(エラー回避)
rootRef.current = ReactDOM.createRoot(shadowRoot);
}
// Shadow DOM 内に React コンポーネントをレンダリング
rootRef.current.render(
<div id="shadow-root">
{styleSheetURLs?.map((url) => (
<link rel="stylesheet" key={url} href={url}></link>
))}
<div id="shadow-component-container">{children}</div>
<div
id="shadow-portal-container"
ref={(portal) => {
// マウントされたタイミングでコールバックを行う
if (portal && onPortalContainer) {
onPortalContainer(portal);
}
}}
></div>
</div>
);
}
}, [children, styleSheetURLs, onPortalContainer]);
return <div id="isolated-scope-host" ref={shadowHostRef} />;
};
export default IsolatedScope;
各部分の解説
1. Props の定義
type IsolatedScopeProps = {
children: React.ReactNode;
styleSheetURLs?: string[];
onPortalContainer?: (container: HTMLElement) => void;
};
- children: Shadow DOM 内にレンダリングする React 要素
- styleSheetURLs:Shadow DOM内部に適用するスタイルシートURLの配列 (オプション)
- onPortalContainer: Portalを取得するためのコールバック関数 (オプション)
2. Shadow DOM の作成
useEffect(() => {
if (shadowHostRef.current) {
const shadowRoot =
shadowHostRef.current.shadowRoot ||
shadowHostRef.current.attachShadow({ mode: 'open' });
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
:host {
all: initial;
}`);
shadowRoot.adoptedStyleSheets = [sheet];
- shadowHostRef.current.attachShadow({ mode: 'open' })
- shadowRoot を作成(すでに存在すればそれを再利用)
- CSSStyleSheet を適用
- :host { all: initial; } によって、外部のスタイルをリセット
- shadowRoot.adoptedStyleSheets = [sheet] で Shadow DOM に適用
3. React のレンダリング
if (!rootRef.current) {
rootRef.current = ReactDOM.createRoot(shadowRoot);
}
- rootRef に ReactDOM.createRoot() を設定(初回のみ実行)
rootRef.current.render(
<div id="shadow-root">
{styleSheetURLs?.map((url) => (
<link rel="stylesheet" key={url} href={url}></link>
))}
<div id="shadow-component-container">{children}</div>
<div
id="shadow-portal-container"
ref={(portal) => {
if (portal && onPortalContainer) {
onPortalContainer(portal);
}
}}
></div>
</div>
);
-
styleSheetURLs
で指定されたCSSのURLをlinkタグで適用(#shadow-component-container
,#shadow-portal-container
両方に適用される) - childrenを
#shadow-component-container
内にレンダリング -
#shadow-portal-container
作成時、refコールバックを利用することで(該当DOM要素がマウントされたタイミングで)portal
を呼び出し元へ渡す