iframeを使用した親子プロジェクト間の通信

## はじめに

仕事上の要件において、1つのReactプロジェクトを別のReactプロジェクト内でiframeを使用して表示する必要が生じました。この過程で、プロジェクト間でのデータの送受信が必要でした。近年、セキュリティの観点からiframeの使用は減少していますが、今回かなり有益な経験だったため、記録として残したいと思います。

## 前提条件

- 本記事ではReactをベースに解説しますが、基本的な考え方は他のWEB技術にも応用可能です。
- TypeScriptを使用します。
- Reactはバージョン18を使っています。

## 基本的な考え方

基本的な考え方はとても簡単です。データを送信したい側は対象のwindowオブジェクトの`postMessage`を呼び出し、データを受信したい側は`window`の`addEventListener`でmessageイベントを傍受できるようにします。

```tsx
import { useEffect } from "react"

type Props = Record<string, unknown>

const Component: React.FC<Props> = ({...props}) => {
  const onSendToIFrame = (): void => {
    const data: any = {}
    // NOTE: データを送信する場合
    window.postMessage(data)
  }

  const handleMessageEvent = (event: MessageEvent<any>): void => {
    // NOTE: メッセージを受信した後の処理
    // NOTE: event.dataを使用して受信したデータにアクセスできる
  }

  useEffect(() => {
    // NOTE: データを受信する場合
    window.addEventListener("message", handleMessageEvent)

    return () => {
      window.removeEventListener("message", handleMessageEvent)
    }
  }, [])

  return <button onClick={onSendToIFrame}>Send</button>
}
```

## 親プロジェクト → 子プロジェクト

ここからより詳しく説明します。
まず、親プロジェクトでは`iframe`とそれに対して`ref`の設定を行う必要があります。

```tsx
import { useRef } from "react"

type Props = Record<string, unknown>

const ParentProject: React.FC<Props> = () => {
  const ref = useRef<HTMLIFrameElement | null>(null)

  return <React.Fragment>
    <iframe ref={ref} />
  </React.Fragment>
}
```

iframeに向けてデータを送信すると仮定します。
すると、以下のように`postMessage`を使うことでiframeに対してデータを送信することができます。

```tsx
import { useRef } from "react"

type UserData = {
  username: string
}

type PostData = {
  request: string,
  message: UserData
}

type Props = Record<string, unknown>

const ParentProject: React.FC<Props> = () => {
  const ref = useRef<HTMLIFrameElement | null>(null)

  const onSendDataToIFrame = (): void => {
    // NOTE: refを使ってるので、存在確認をしないとエラーになる可能性がある
    if (!ref || !ref.current || !ref.current.contentWindow) {
      return
    }

	// NOTE: 送信するデータは本当になんでもOK
	const data: PostData = {
	  request: "userUpdate",
	  message: {
	    username: "John"
	  }
	}
	ref.current.contentWindow.postMessage(data, { targetOrigin: "*" })
  }

  return <React.Fragment>
    <button onClick={onSendDataToIFrame}>iframeにデータを送信</button>
    <iframe ref={ref} />
  </React.Fragment>
}
```

これを受け取る子プロジェクト側は、iframeで表示されている画面のコンポーネントでmessageイベントを受け取れるようにする。

```tsx
import { useEffect, useState } from "react"

type UserData = {
  username: string
}

type PostData = {
  request: string,
  message: UserData
}

type Props = Record<string, unknown>

const ChildProject: React.FC<Props> = () => {
  const [username, setUsername] = useState("")

  // NOTE: MessageEventはReactが用意してる型です
  const handleMessageEvent = (event: MessageEvent<PostData>): void => {
    const request = event.data.request
    const _username = event.data.message.username

	if (request === "userUpdate" && !!_username) {
	  setUsername(_username)
	}
  }

  // NOTE: 送信されているpostMessageを受け取る
  useEffect(() => {
    window.addEventListener("message", handleMessageEvent)

	return () => {
	  window.removeEventListener("message", handleMessageEvent)
	}
  }, [])

  return <React.Fragment>
    {username ? <p>Hello, {username}</p> : null}
  </React.Fragment>
}
```

## 子プロジェクト → 親プロジェクト

子プロジェクトから親プロジェクトへデータを送信する場合も似たようなアプローチになります。
ただし、親プロジェクトから子プロジェクトへデータを送信する場合は`ref`に紐づいたwindowオブジェクトの`postMessage`を呼び出していたのに対して、子プロジェクトから親プロジェクトへデータを送信する場合は`window`オブジェクトにある`parent`プロパティを使うことで親プロジェクトのwindowにアクセスすることができます。

```tsx
import { useEffect, useState } from "react"

type UserData = {
  username: string
}

type PostData = {
  request: string,
  message: UserData
}

type Props = Record<string, unknown>

const ChildProject: React.FC<Props> = () => {
  const [username, setUsername] = useState("")

  const onSendDataToParent = (): void => {
    const data: PostData = {
      request: "requestPermission",
	  message: "camera",
    }
    window.parent.postMessage(data, { targetOrigin: "*" })
  }

  // NOTE: MessageEventはReactが用意してる型です
  const handleMessageEvent = (event: MessageEvent<PostData>): void => {
    const request = event.data.request
    const _username = event.data.message.username

	if (request === "userUpdate" && !!_username) {
	  setUsername(_username)
	}
  }

  // NOTE: 送信されているpostMessageを受け取る
  useEffect(() => {
    window.addEventListener("message", handleMessageEvent)

	return () => {
	  window.removeEventListener("message", handleMessageEvent)
	}
  }, [])

  return <React.Fragment>
    {username ? <p>Hello, {username}</p> : null}
  </React.Fragment>
}
```
※`{ targetOrigin: "*" }`は`window`のオリジンを指定しています。`postMessage`を発行するときに`window`のオリジンが一致しない場合は`postMessage`はメッセージを発行しません。つまり、今回の例のようにどんな親プロジェクトでもデータを送信したい場合は必ず`*`を設定する必要があります。特定のオリジンにする場合はURLを指定できます。

この`postMessage`を受け取る親プロジェクトは、子プロジェクトでの受け取り方と全く同じです。

```tsx
import { useEffect, useRef } from "react"

type UserData = {
  username: string
}

type PostData = {
  request: string,
  message: UserData
}

type Props = Record<string, unknown>

const ParentProject: React.FC<Props> = () => {
  const ref = useRef<HTMLIFrameElement | null>(null)

  const onSendDataToIFrame = (): void => {
    // NOTE: refを使ってるので、そhんざいかくにんをしないとエラーになる可能性がある
    if (!ref || !ref.current || !ref.current.contentWindow) {
      return
    }

	// NOTE: 送信するデータは本当になんでもOK
	const data: PostData = {
	  request: "userUpdate",
	  message: {
	    username: "John"
	  }
	}
	ref.current.contentWindow.postMessage(data, { targetOrigin: "*" })
  }

  const handleMessageEvent = async (event: MessageEvent<PostData>): Promise<void> => {
    const request = event.data.request

	if (request === "requestPermission") {
	  const queryName = event.data.message: as PermissionName
	  await navigator.permissions.query({ name: queryName })
	}
  }

  useEffect(() => {
    window.addEventListener("message", handleMessageEvent)

	return () => {
	  window.removeEventListener("message", handleMessageEvent)
	}
  }, [])

  return <React.Fragment>
    <button onClick={onSendDataToIFrame}>iframeにデータを送信</button>
    <iframe ref={ref} />
  </React.Fragment>
}
```


## サマリー

基本的な考え方としては、

- データの送信 → `postMessage`
- データの受信 → `window.addEventListener("message", (event: MessageEvent<any>) => {})`

となります。