0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Shadow DOM内にReactを描画するためのコンポーネント

Posted at

以前作成したこちらの記事Bootstrap.cssを特定の領域内部のみに適用し、その中にReactのコンポーネントを描画する方法(ShadowDOMを利用)
で、Shadow DOM内にReactを描画することはできましたが、いくつか問題がありました

  1. CSSの継承プロパティが外部のDOMからshadow DOMへ継承されてしまい正しく表示されない場合がある
  2. モーダルダイアログを表示すると、Bootstrapのスタイルが適用されない

1.については、shadow DOMでもCSSのスコープを分離できない場合があるに原因と対策を書きました

2.については、モーダルダイアログを表示する際、コンポーネントライブラリ側でcreatePortalを呼び出します。その結果document.bodyにDOMが追加される(shadow DOMの外に出てしまう)ため、スタイルが適用されなくなっていました

上記を考慮したコンポーネント<IsolatedScope>を作成しました

概要

  • Shadow DOMを利用して、スタイルの干渉を防ぎながらReactコンポーネントをレンダリングする
  • 外部CSSを適用可能 (styleSheetURLs プロパティ)
  • Portalコンテナを提供し、ModalなどのUIコンポーネントの配置を容易にする (onPortalContainer)

DOMの様子

image.png

  • 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;

image-1.png

container未指定の場合、document.body直下にモーダルダイアログが追加されるため正しく表示されません

image-2.png

<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を呼び出し元へ渡す
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?