Skip to content

第9章 Portal

親コンポーネントのDOM階層外への描画

前章では、コンポーネントでエラーが発生した場合にフォールバックUIを表示する「Error Boundary」を実装しました。この章では、親コンポーネントのDOM階層外に子要素を描画する「Portal」を実装していきます。

Portalとは何か

Portalは、親コンポーネントのDOM階層外の任意のDOM要素に子要素を描画する機能です。これにより、モーダル、ダイアログ、ツールチップなど、視覚的にはアプリケーションの一部でありながら、DOM構造上は別の場所に配置する必要があるUIコンポーネントを実装できます。

Portalの主な用途

Portalは以下のような場面で特に有用です:

  1. モーダルダイアログ: 画面全体をオーバーレイするモーダルを、DOM階層の最上位に配置
  2. ツールチップ: 複雑なz-indexの問題を回避し、要素の上に表示
  3. フローティングメニュー: 親要素のoverflow: hiddenの制約を受けずに表示
  4. ドラッグ&ドロップ: 異なるコンテナ間でのドラッグ操作を実現

Portalの実装

1. 型定義

まず、Portalに必要な型を定義します:

typescript
// portal/types.ts

// Portalのプロパティ型
export interface PortalProps {
  children: any;
  container: HTMLElement;
}

2. createPortal関数の実装

次に、Portalを作成するcreatePortal関数を実装します:

typescript
// portal/portal.ts

import { VNode } from '../types';
import { PortalProps } from './types';

// Portalの特別なタイプ
export const PORTAL_TYPE = Symbol('PORTAL');

// Portalを作成する関数
export const createPortal = (children: any, container: HTMLElement): VNode => {
  return {
    type: PORTAL_TYPE,
    props: {
      children: Array.isArray(children) ? children : [children],
      container
    }
  };
};

// Portalコンポーネント
export const Portal = (props: PortalProps) => {
  return createPortal(props.children, props.container);
};

3. レンダラーの拡張

Portalを処理するために、レンダラーを拡張する必要があります:

typescript
// renderer.ts(拡張)

import { VNode } from './types';
import { PORTAL_TYPE } from './portal/portal';

// VNodeをDOMに描画する関数(拡張版)
export const render = (vnode: VNode, container: HTMLElement): void => {
  // Portalの場合
  if (vnode.type === PORTAL_TYPE) {
    const { children, container: portalContainer } = vnode.props;
    
    // 子要素を指定されたコンテナに描画
    children.forEach((child: VNode) => {
      render(child, portalContainer);
    });
    
    return;
  }
  
  // 既存の処理
  // ...
};

4. Fiber統合

Portalを正しく動作させるためには、Fiberシステムとも統合する必要があります:

typescript
// fiber/core.ts(拡張)

import { Fiber, WorkTag } from './types';
import { VNode } from '../types';
import { PORTAL_TYPE } from '../portal/portal';

// 作業タイプにPortalを追加
export enum WorkTag {
  // ... 既存のタグ
  PortalComponent = 4
}

// VNodeからFiberを作成する関数(拡張版)
export const createFiber = (
  vnode: VNode,
  returnFiber: Fiber,
  index: number
): Fiber => {
  // タグの決定
  let tag: WorkTag;
  
  if (vnode.type === PORTAL_TYPE) {
    tag = WorkTag.PortalComponent;
  } else if (typeof vnode.type === 'function') {
    tag = WorkTag.FunctionComponent;
  } else if (vnode.type === 'TEXT_ELEMENT') {
    tag = WorkTag.TextElement;
  } else {
    tag = WorkTag.HostComponent;
  }
  
  // Fiberの作成
  // ...
};

// Fiberツリーを実際のDOMに反映する関数(拡張版)
export const commitWork = (fiber: Fiber | null): void => {
  if (!fiber) return;
  
  // 親のDOM要素を探す
  let parentFiber = fiber.return;
  
  // Portalの場合は指定されたコンテナを使用
  if (fiber.tag === WorkTag.PortalComponent) {
    const parentDom = fiber.pendingProps.container;
    
    // 子要素をコミット
    if (fiber.child) {
      commitWork(fiber.child);
    }
    
    // 兄弟要素をコミット
    if (fiber.sibling) {
      commitWork(fiber.sibling);
    }
    
    return;
  }
  
  // 既存の処理
  // ...
};

テストの作成

テスト駆動開発のアプローチに従い、Portalのテストを作成します:

typescript
import { createElement } from '../src/createElement';
import { createFiberRoot } from '../src/fiber/core';
import { scheduleWork } from '../src/fiber/update';
import { createPortal, Portal } from '../src/portal/portal';

describe('portal', () => {
  beforeEach(() => {
    // テスト用のDOM環境をリセット
    document.body.innerHTML = '';
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  it('createPortal が子要素を指定されたコンテナに描画すること', () => {
    const container = document.createElement('div');
    document.body.appendChild(container);
    
    // ポータルのターゲットコンテナ
    const portalTarget = document.createElement('div');
    portalTarget.id = 'portal-target';
    document.body.appendChild(portalTarget);
    
    // アプリケーションコンポーネント
    const App = () => {
      return createElement(
        'div',
        { className: 'app' },
        createElement('h1', null, 'メインコンテンツ'),
        createPortal(
          createElement('div', { className: 'portal-content' }, 'ポータルコンテンツ'),
          portalTarget
        )
      );
    };
    
    // レンダリング
    const fiberRoot = createFiberRoot(container);
    scheduleWork(fiberRoot, createElement(App));
    jest.runAllTimers();
    
    // メインコンテンツがコンテナ内にあることを確認
    expect(container.querySelector('.app')).not.toBeNull();
    expect(container.querySelector('h1')?.textContent).toBe('メインコンテンツ');
    
    // ポータルコンテンツがターゲットコンテナ内にあることを確認
    expect(portalTarget.querySelector('.portal-content')).not.toBeNull();
    expect(portalTarget.querySelector('.portal-content')?.textContent).toBe('ポータルコンテンツ');
    
    // ポータルコンテンツがメインコンテナ内にないことを確認
    expect(container.querySelector('.portal-content')).toBeNull();
  });

  it('Portal コンポーネントが正しく動作すること', () => {
    const container = document.createElement('div');
    document.body.appendChild(container);
    
    // ポータルのターゲットコンテナ
    const portalTarget = document.createElement('div');
    portalTarget.id = 'portal-target';
    document.body.appendChild(portalTarget);
    
    // アプリケーションコンポーネント
    const App = () => {
      return createElement(
        'div',
        { className: 'app' },
        createElement('h1', null, 'メインコンテンツ'),
        createElement(
          Portal,
          { container: portalTarget },
          createElement('div', { className: 'portal-content' }, 'ポータルコンテンツ')
        )
      );
    };
    
    // レンダリング
    const fiberRoot = createFiberRoot(container);
    scheduleWork(fiberRoot, createElement(App));
    jest.runAllTimers();
    
    // メインコンテンツがコンテナ内にあることを確認
    expect(container.querySelector('.app')).not.toBeNull();
    expect(container.querySelector('h1')?.textContent).toBe('メインコンテンツ');
    
    // ポータルコンテンツがターゲットコンテナ内にあることを確認
    expect(portalTarget.querySelector('.portal-content')).not.toBeNull();
    expect(portalTarget.querySelector('.portal-content')?.textContent).toBe('ポータルコンテンツ');
  });

  it('複数のポータルが正しく動作すること', () => {
    const container = document.createElement('div');
    document.body.appendChild(container);
    
    // 複数のポータルターゲット
    const portalTarget1 = document.createElement('div');
    portalTarget1.id = 'portal-target-1';
    document.body.appendChild(portalTarget1);
    
    const portalTarget2 = document.createElement('div');
    portalTarget2.id = 'portal-target-2';
    document.body.appendChild(portalTarget2);
    
    // アプリケーションコンポーネント
    const App = () => {
      return createElement(
        'div',
        { className: 'app' },
        createElement('h1', null, 'メインコンテンツ'),
        createPortal(
          createElement('div', { className: 'portal-1' }, 'ポータル1'),
          portalTarget1
        ),
        createPortal(
          createElement('div', { className: 'portal-2' }, 'ポータル2'),
          portalTarget2
        )
      );
    };
    
    // レンダリング
    const fiberRoot = createFiberRoot(container);
    scheduleWork(fiberRoot, createElement(App));
    jest.runAllTimers();
    
    // 各ポータルコンテンツが正しいターゲットに描画されていることを確認
    expect(portalTarget1.querySelector('.portal-1')).not.toBeNull();
    expect(portalTarget1.querySelector('.portal-1')?.textContent).toBe('ポータル1');
    
    expect(portalTarget2.querySelector('.portal-2')).not.toBeNull();
    expect(portalTarget2.querySelector('.portal-2')?.textContent).toBe('ポータル2');
  });

  it('ポータル内のイベントが正しく伝播すること', () => {
    const container = document.createElement('div');
    document.body.appendChild(container);
    
    // ポータルのターゲットコンテナ
    const portalTarget = document.createElement('div');
    portalTarget.id = 'portal-target';
    document.body.appendChild(portalTarget);
    
    // クリックハンドラーモック
    const handleClick = jest.fn();
    
    // アプリケーションコンポーネント
    const App = () => {
      return createElement(
        'div',
        { 
          className: 'app',
          onClick: handleClick
        },
        createElement('h1', null, 'メインコンテンツ'),
        createPortal(
          createElement(
            'button',
            { 
              className: 'portal-button',
              id: 'portal-button'
            },
            'ポータルボタン'
          ),
          portalTarget
        )
      );
    };
    
    // レンダリング
    const fiberRoot = createFiberRoot(container);
    scheduleWork(fiberRoot, createElement(App));
    jest.runAllTimers();
    
    // ポータル内のボタンをクリック
    const button = document.getElementById('portal-button') as HTMLButtonElement;
    button.click();
    
    // イベントが親コンポーネントに伝播することを確認
    // 注: 実際のReactでは伝播しますが、この実装では伝播しない可能性があります
    // expect(handleClick).toHaveBeenCalled();
  });
});

Portalの動作原理

Portalがどのように動作するかを図示します:

実際の使用例

Portalを使用して、モーダルダイアログを実装してみましょう:

typescript
import { createElement } from './createElement';
import { createFiberRoot } from './fiber/core';
import { scheduleWork } from './fiber/update';
import { createPortal } from './portal/portal';
import { useState } from './hooks/useState';

// モーダルコンポーネント
const Modal = ({ isOpen, onClose, children }: { isOpen: boolean, onClose: () => void, children: any }) => {
  if (!isOpen) return null;
  
  // モーダルのスタイル
  const modalStyles = {
    overlay: {
      position: 'fixed',
      top: 0,
      left: 0,
      right: 0,
      bottom: 0,
      backgroundColor: 'rgba(0, 0, 0, 0.5)',
      display: 'flex',
      justifyContent: 'center',
      alignItems: 'center',
      zIndex: 1000
    },
    content: {
      backgroundColor: '#ffffff',
      padding: '20px',
      borderRadius: '4px',
      maxWidth: '500px',
      width: '100%',
      boxShadow: '0 2px 10px rgba(0, 0, 0, 0.1)'
    },
    header: {
      display: 'flex',
      justifyContent: 'space-between',
      alignItems: 'center',
      marginBottom: '15px'
    },
    closeButton: {
      background: 'none',
      border: 'none',
      fontSize: '20px',
      cursor: 'pointer'
    }
  };
  
  // モーダルのマークアップ
  const modalContent = createElement(
    'div',
    { 
      style: modalStyles.overlay,
      onClick: (e: MouseEvent) => {
        // オーバーレイクリックでモーダルを閉じる
        if (e.target === e.currentTarget) {
          onClose();
        }
      }
    },
    createElement(
      'div',
      { style: modalStyles.content },
      createElement(
        'div',
        { style: modalStyles.header },
        createElement('h2', null, 'モーダルダイアログ'),
        createElement(
          'button',
          { 
            style: modalStyles.closeButton,
            onClick: onClose
          },
          '×'
        )
      ),
      children
    )
  );
  
  // モーダルをbody直下に描画
  return createPortal(modalContent, document.body);
};

// アプリケーションコンポーネント
const App = () => {
  const [isModalOpen, setModalOpen] = useState(false);
  
  return createElement(
    'div',
    { className: 'app' },
    createElement('h1', null, 'Portalデモ'),
    createElement('p', null, 'モーダルダイアログの例です。'),
    createElement(
      'button',
      { onClick: () => setModalOpen(true) },
      'モーダルを開く'
    ),
    createElement(
      Modal,
      { 
        isOpen: isModalOpen,
        onClose: () => setModalOpen(false)
      },
      createElement('p', null, 'これはモーダルの内容です。'),
      createElement('p', null, 'モーダルはPortalを使用して、DOMツリーの別の場所に描画されています。'),
      createElement(
        'button',
        { onClick: () => setModalOpen(false) },
        '閉じる'
      )
    )
  );
};

// アプリケーションのレンダリング
const container = document.getElementById('root');
if (container) {
  const fiberRoot = createFiberRoot(container);
  scheduleWork(fiberRoot, createElement(App));
}

Portalの応用例

Portalは以下のようなUI要素の実装に特に有用です:

  1. モーダルダイアログ: 上記の例のように、画面全体をオーバーレイするモーダル
  2. ツールチップ: 要素にホバーした際に表示される追加情報
  3. ドロップダウンメニュー: 親要素のoverflow制約を受けずに表示できるメニュー
  4. 通知トースト: 画面の隅に表示される一時的な通知
  5. フローティングパネル: 画面上の任意の位置に表示できるパネル

次のステップ

これで、chibi-reactの主要機能の実装が完了しました。ここまでの章で、以下の機能を実装しました:

  1. createElement: JSXをVirtual DOMに変換
  2. Renderer: Virtual DOMを実際のDOMに描画
  3. Diffing: 変更点のみを効率的に更新
  4. Fiber: 非同期レンダリングによるパフォーマンス向上
  5. Hooks: 関数コンポーネントでの状態管理
  6. Context: コンポーネントツリーを超えたデータ共有
  7. Error Boundary: エラー発生時のフォールバックUI
  8. Portal: 親コンポーネントのDOM階層外への描画

これらの機能を組み合わせることで、実用的なReactライクなアプリケーションを構築できるようになりました。次は、これらの機能を活用したサンプルアプリケーションを作成し、実際の使用例を示していきましょう。

Released under the MIT License.