Reactでスクリプトタグを含んだHTMLを挿入する(SSG/SSR対応)

いわゆるJAMstackを用いたWebメディアなどでは、コンテンツの構成が記事ごとに少しずつ違うけれどもマークダウンでは表現力が制限されるため、本文などコンテンツの一部をHTMLで記述して表示したいという状況はそれなりにあると思います。 そのような状況で、外部サービスから提供されるスクリプトタグを含んだコンテンツを表示したい、という要件に対応したものです。

dangerouslySetInnerHTMLの制限

ReactにはdangerouslySetInnerHTMLを用いることで、任意のDOM要素の子要素としてHTMLを挿入することができます。 ただし、この機能は内部的にinnerHTML用いています。innerHTMLXSS攻撃に対する対策として<script>タグを実行しません。(ただし、インベントハンドラとしてスクリプトを実行することは可能です) そのため、スクリプトタグを含むHTMLはdangerouslySetInnerHTMLで設定することはできますが、そのスクリプトは実行されないということになります。

appendChildcreateContextualFragmentの利用

WebAPIのappendChildメソッドは特定のノードに対して子ノードを追加するメソッドです。 このメソッドによって追加されたノードは通常のノードと同様に実行されるため、追加されたスクリプトタグも実行されます。 文字列として渡されたHTMLから要素を生成する際は、createContextualFragmentメソッドを用います。これはHTMLタグを含む文字列から、指定された範囲の開始点を起点とするドキュメントフラグメントを生成するメソッドです。

Reactでの擬似的なコードは以下となります。

const HTMLComponent = ({ htmlString }) => {
  const divRef = useRef();
  
  useLayoutEffect(() => {
    if (!divRef.current) {
      return;
    }
    
    const fragment = document
      .createRange()
      .createContextualFragment(htmlString);
    
    divRef.current.appendChild(fragment);
  }, [htmlString]);
  
  return <div ref={divRef} />;
};

ただし、上記のコードではいくつか対応できないパターンがあります。

HubSpotの埋め込みコードのような外部ファイルの読み込みとそれに依存するインラインスクリプトがある場合

<script charset="utf-8" type="text/javascript" src="//js.hsforms.net/forms/shell.js"></script>
<script>
  hbspt.forms.create({
    portalId: "xxxxxx",
    formId: "xxxxxx"
  });
</script>

appendChildによって追加すると、2つのスクリプトタグが同時に実行されるため、2番目のインラインスクリプトhbsptグローバル変数として存在しない状態で実行されることとなります。 この問題は、外部ファイルを読み込むスクリプトタグを先に追加・実行し、それが完了したのちに残りの要素を追加することで解決することが可能です。

const HTMLComponent = ({ htmlString }) => {
  const divRef = useRef();
  
  useLayoutEffect(() => {
    if (!divRef.current) {
      return;
    }
    
    (async () => {
      const scriptStrings = htmlString.match(
        /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi
      );
      
      let updatedHtmlString = htmlString;
      await scriptStrings.reduce(async (acc, current) => {
        await acc;

        const scriptFragment = document
          .createRange()
          .createContextualFragment(current);
        const scriptElement = scriptFragment.querySelector('script');

        if (scriptElement.src === '') {
          return Promise.resolve();
        }
  
        updatedHtmlString = updatedHtmlString.replace(current, '');
  
        if (
          Array.from(document.querySelectorAll('script')).some(
            se => se.src === scriptElement.src
          )
        ) {
          return Promise.resolve();
        }
    
          return new Promise(resolve => {
          scriptElement.addEventListener('load', () => {
            resolve();
          });

          document.head.appendChild(scriptElement);
        });
      }, Promise.resolve());
      
      const fragment = document
        .createRange()
        .createContextualFragment(updatedHtmlString);
      
      divRef.current.appendChild(fragment);
    })();
  }, [htmlString]);
  
  return <div ref={divRef} />;
};

外部ファイルを読み込むスクリプトタグの場合、ヘッダに追加して元のHTMLタグから取り除きます。 もし、すでに同じソースを読み込むスクリプトタグが存在する場合、多重の読み込みを防止するために処理をスキップします。 スクリプトタグのロードが完了した後に残りのHTMLを挿入することで、外部ファイルに依存するインラインスクリプトを問題なく実行することができます。

SSG/SSRでコンテンツがレンダリングされない

useLayoutEffectはサーバサイドでは実行されないため、SSR/SSGを行った際にレンダリングされていない状態となります。 この問題に対しては、挿入する要素に対して初期値を設定することで対応します。

const HTMLComponent = ({ htmlString }) => {
  const divRef = useRef();
  
  const initialHTMLString = useMemo(() => {
    const scriptStrings = htmlString.match(
      /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi
    );
    
    return scriptStrings.reduce((acc, current) => {
      return acc.replace(current, '');
    }, htmlString);
  }, [htmlString]);
  
  useLayoutEffect(() => {
    if (!divRef.current) {
      return;
    }
    
    (async () => {
      const scriptStrings = htmlString.match(
        /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi
      );
      
      let updatedHtmlString = htmlString;
      await scriptStrings.reduce(async (acc, current) => {
        await acc;
  
        const scriptFragment = document
          .createRange()
          .createContextualFragment(current);
        const scriptElement = scriptFragment.querySelector('script');
  
        if (scriptElement.src === '') {
          return Promise.resolve();
        }

        updatedHtmlString = updatedHtmlString.replace(current, '');
        
        if (
          Array.from(document.querySelectorAll('script')).some(
            se => se.src === scriptElement.src
          )
        ) {
          return Promise.resolve();
        }

        return new Promise(resolve => {
          scriptElement.addEventListener('load', () => {
            resolve();
          });

          document.head.appendChild(scriptElement);
        });
      }, Promise.resolve());
      
      const fragment = document
        .createRange()
        .createContextualFragment(updatedHtmlString);
      
      divRef.current.innerHTML = '';
      divRef.current.appendChild(fragment);
    })();
  }, [htmlString]);
  
  return (
    <div
      ref={divRef}
      dangerouslySetInnerHTML={{
        __html: initialHTMLString,
      }}
    />
  );
};

htmlStringからスクリプトタグを除いたinitialHTMLStringを設定しているのは、プリレンダリングされたHTMLの読み込み時にインラインスクリプトが実行されることを防ぐためです。 これを行わないと、例えばHubSpotのインラインスクリプトが含まれている場合、プリレンダリングされたHTML読み込み時にスクリプトが実行されフォームが追加、アプリの起動とハイドレーションのあとのuseLayoutEffectの処理によって、一度フォームが消えた後に再び追加されるという挙動となります。

まとめ

  • dangerouslySetInnerHTMLではスクリプトタグを含んだHTMLを挿入することはできるが、innerHTMLの仕様によりそのスクリプトタグは実行されない。
  • appendChildcreateContextualFragmentを利用することで、スクリプトタグを含んだHTMLを挿入することができる。
  • 外部ファイルを読み込むスクリプトタグとインラインスクリプトの両方が存在する場合、外部ファイルの読み込みが行われた後にHTMLを挿入する必要がある。
  • SSG/SSRを利用する場合は、スクリプトタグを除いたHTMLを初期値として設定する。

この実装では任意のスクリプトの実行が可能となるため、基本的に信頼できる入力に対してのみ適用するようにしましょう。 今回はデータの入力が、特定のコンテンツ管理者だけがログインできるContentfulからのみであるという状況であったためにこの実装を採用しています。 不特定多数のユーザが入力、閲覧するような要件の場合は適切なサニタイズを確実に。