Counts the Clouds

Reactでscriptタグを含むHTMLを挿入し、scriptも動作させる
2021.08.12

React

danielle-macinnes-ZyiT0dUPKy0-unsplash.jpg

環境構築

% npx create-react-app react-insert-html-with-script
% cd react-insert-html-with-script
% yarn start

本記事は、こちらをかなり参考にしている。

dangerouslySetInnerHTMLは使えない

まず、innerHTMLの代替であるdangerouslySetInnerHTMLを使用する方法を思いつくが、HTMLとしては挿入できてもscriptは動作しない。

これはクロスサイトスクリプティング攻撃のように見えますが、結果的には無害です。 HTML5 では innerHTML で挿入された <script> タグは実行するべきではないと定義しているからです。

appendChildでHTMLを挿入する

scriptタグを含むHTMLとしてBootstrap 5のアコーディオンを含むHTML文字列を考える。

<!-- Bootstrap 5 -->
<div class="accordion" id="accordionExample">
  <div class="accordion-item">
    <h2 class="accordion-header" id="headingOne">
      <button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapseOne" aria-expanded="true" aria-controls="collapseOne">
        Accordion Item #1
      </button>
    </h2>
    <div id="collapseOne" class="accordion-collapse collapse show" aria-labelledby="headingOne" data-bs-parent="#accordionExample">
      <div class="accordion-body">
        <strong>This is the first item's accordion body.</strong> It is shown by default, until the collapse plugin adds the appropriate classes that we use to style each element. These classes control the overall appearance, as well as the showing and hiding via CSS transitions. You can modify any of this with custom CSS or overriding our default variables. It's also worth noting that just about any HTML can go within the <code>.accordion-body</code>, though the transition does limit overflow.
      </div>
    </div>
  </div>
  <!-- ... -->
</div>

まずは簡単に、publicのHTMLでBootstrapを読み込んでおいて、HTMLコンテンツだけをappendChildで挿入してうまくいくか見てみる。

<!-- public/index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <!-- ... -->
    <title>React App</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
  </head>
  <body>
    <!-- ... -->
  </body>
</html>

appendChildでDOMを挿入するにはNodeを渡す必要がある。

createElementでもNodeは構成できるが要素単位で構成するのは面倒なので、参考サイトにあるようにcreateContextualFragmentを使用して文字列からNodeを生成する方法が便利。

createContextualFragmentはRangeのメソッドなので、document.createRangeでRangeを生成する必要がある。

以下のようにApp.jsを変更するとアコーディオンが動作する。refが張られるのを待って、useEffect内でappendChildを実行する。

// App.js
import { useEffect, useRef } from 'react'
import './App.css'

const html = `...`

function App() {
  const element = useRef(null)
  useEffect(() => {
    if (!element.current) return
    element.current.appendChild(document.createRange().createContextualFragment(html))
  }, [element])

  return (
    <div className="App">
      <div ref={element} />
    </div>
  )
}

export default App

Bootstrap自体も読み込む

こちらが本題であるが、先にBootstrapを読み込んでglobal(window)に提供しておかないと、インラインスクリプトも動作しない。

そこで、事前にHTMLからsrc属性を持つscriptタグを抽出してheadに配置し、その後、抽出されたscriptタグを除去したHTMLを挿入する。

// App.js
// ...
const html = `...`
const REG_DETECT_SCRIPT_TAGS = /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gmi

function extractLocalContents(html) {
  return html.replace(REG_DETECT_SCRIPT_TAGS, (match) => {
    const scriptFragment = document.createRange().createContextualFragment(match);
    const scriptElement = scriptFragment.querySelector('script')
    if (scriptElement.src === '') return match
    return ''
  })
}

function extractExternalScriptElements(html) {
  const scriptElements = html.match(REG_DETECT_SCRIPT_TAGS)
  if (!scriptElements) return []
  return scriptElements.map((match) => {
    const scriptFragment = document.createRange().createContextualFragment(match);
    return scriptFragment.querySelector('script')
  }).filter(scriptElement => scriptElement.src !== '')
}

function isScriptAlreadyInserted(scriptElement) {
  return Array.from(document.querySelectorAll('script')).some(s => s.src === scriptElement.src)
}

function App() {
  const element = useRef(null)
  useEffect(() => {
    if (!element.current) return
    (async () => {
      const localContents = await extractLocalContents(html)
      const externalScriptElements = await extractExternalScriptElements(html)
      if (externalScriptElements) {
        await Promise.all(externalScriptElements.map((scriptElement) => {
          if (isScriptAlreadyInserted(scriptElement)) {
            return Promise.resolve()
          }

          return new Promise((resolve) => {
            scriptElement.addEventListener('load', resolve)
            document.head.appendChild(scriptElement)
          })
        }))
      }
      element.current.appendChild(document.createRange().createContextualFragment(localContents))
    })()
  }, [element])

  return (
    <div className="App">
      <div ref={element} />
    </div>
  );
}

export default App

依存がある場合〜Bootstrap 4を読み込む

Bootstrap 4はjQueryに依存しているので、上記の処理だと読み込み順によっては動かないことがある。

<!-- public/index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <!-- ... -->
    <title>React App</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/css/bootstrap.min.css" integrity="sha384-B0vP5xmATw1+K9KRQjQERJvTumQW0nPEzvF6L/Z6nronJ3oUOFUFpCjEUQouq2+l" crossorigin="anonymous">
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <!-- ... -->
  </body>
</html>
<!-- Bootstrap 4 -->
<div class="accordion" id="accordionExample">
  <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-Piv4xVNRyMGpqkS2by6br4gNJ7DXjqk09RmUpJ8jgGtD7zP9yug3goQfGII0yAns" crossorigin="anonymous"></script>
  <div class="card">
    <div class="card-header" id="headingOne">
      <h2 class="mb-0">
        <button class="btn btn-link btn-block text-left" type="button" data-toggle="collapse" data-target="#collapseOne" aria-expanded="true" aria-controls="collapseOne">
          Collapsible Group Item #1
        </button>
      </h2>
    </div>

    <div id="collapseOne" class="collapse show" aria-labelledby="headingOne" data-parent="#accordionExample">
      <div class="card-body">
        Some placeholder content for the first accordion panel. This panel is shown by default, thanks to the <code>.show</code> class.
      </div>
    </div>
  </div>
  <!-- ... -->
</div>

Promise.allで並列に読み込むのではなくreduceで直列に読み込む。

// App.js
// ...
      if (externalScriptElements) {
        await externalScriptElements.reduce(async (acc, scriptElement) => {
          await acc;
          if (isScriptAlreadyInserted(scriptElement)) {
            return Promise.resolve()
          }

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