> makibishi throw|

React+RecoilでHTML,CSS,JavaScriptのプレイグラウンドを作ってみる

はじめに

Codepenというサービスがあります。オンラインで HTML,CSS,JavaScript を編集でき、その結果がリアルタイムで確認できるというサービスですが、書いたコードが即座に反映されるのってすごく不思議じゃないですか?私は Codepen の存在を知った時、サービスの便利さよりもむしろその仕組みが気になりました。

実は難しい実装など必要なく、標準 API のみで Codepen と同じようなことを実現できるのですが、通常のサービス開発では滅多に使わない属性を使ったりします。 この記事では React コンポーネントとしてプレイグラウンド環境を実際に作っていきながら、裏で何が起こっているのかを紐解いていきます。

最終的な成果物は以下のようなものです。

playground

上記を以下のような構成の元で構築していきます。

  • AltJS: typescript
  • UI フレームワーク: React
  • ステート管理: Recoil
  • バンドラ: Parcel

STEP.1 環境の用意

Parcelが未導入の場合、インストールしておきます。

npm install -g parcel-bundler

依存パッケージをインストールします。

yarn add -D @types/react @types/react-dom @types/recoil
yarn add react react-dom react-ace recoil

以下のような html,css,ts ファイルを同一ディレクトリに配置します。

index.html

<html>
  <head>
    <link rel="stylesheet" href="playground.css" />
  </head>
  <body>
    <div id="playground"></div>
    <script src="app.tsx" type="text/javascript"></script>
  </body>
</html>

playground.css

※今回の主旨から外れるため、css ファイルの詳細については詳しく触れません。

.playground-render {
  display: inline-flex;
  border: solid #a8a8a8 1px;
}

.playground-render iframe {
  background-color: #ffffff;
  border-style: none;
}

.playground-render a {
  text-decoration: none;
}

.playground-result {
  width: 400px;
}

.playground-edit {
  border-left: solid #a8a8a8 1px;
}

.playground-nav {
  display: flex;
  border-bottom: solid #a8a8a8 1px;
  width: 400px;
}

.playground-nav__link {
  flex: 1;
  padding: 12px 0px 12px 0px;
  text-align: center;
  color: #ffffff;
  background-color: #b3b3b3;
  font-weight: bold;
}

.playground-nav__link:last-child {
  margin-left: 0;
}

.playground-nav__link:hover {
  color: #6d6d6d;
  cursor: pointer;
}

.playground-nav__link.is-active {
  color: #dc446e;
  background-color: #e7e7e7;
  text-decoration: none;
}

.playground-editor {
  display: none;
  background-color: #e7e7e7;
  width: 400px;
  height: 400px;
}

.playground-editor {
  display: block;
}

.playground__content {
  width: 100%;
  height: 100%;
}

app.tsx

import React from "react"
import { render } from "react-dom"
import "./playground.css"

render(<div>test</div>, document.querySelector("#playground"))

playground.tsx

※最初は空です。このファイルにコンポーネントを作っていきます。

また、tsx 記法のためにtsconfig.jsonも配置します。

tsconfig.json

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "strict": true,
    "jsx": "react",
    "lib": ["dom", "es2015"],
    "allowSyntheticDefaultImports": true
  }
}

この時点で、以下のようなファイル構成になっているはずです。

dir

parcel index.htmlで開発用サーバーが立ち上がるので、ブラウザからアクセスしてみます。無事に「test」の文字が出ていれば成功です。

hello

STEP.2 UI の実装

続いて、プレイグラウンドを構成する UI を実装していきます。

プレイグラウンドでは、以下のようなパーツが必要です。これらを子コンポーネントとして持つコンポーネントとして作るのが良さそうです。

  • html,css,js の実行結果が表示されるエリア
  • ソースを編集するためのエディタ
  • html,css,js から現在の編集対象を選択するタブ

まずは、playground.tsx にコンポーネントを用意しておきましょう。

playground.tsx

import React from "react"

export const PlayGroundEditor: React.FC<{}> = () => {
  return <div>test</div>
}

さらに、app.tsx を変更し、上記のコンポーネントをレンダー対象にしておきます。

app.tsx

...

render(
  <PlayGroundEditor></PlayGroundEditor>, // PlayGroundEditorコンポーネントをレンダリングする
  document.querySelector("#playground")
)

では、playground.tsx に子コンポーネントを作っていきましょう。

playground.tsx

まずは実行結果を表示するコンポーネントです。最終的には、ユーザーの編集した html,css,js の内容が反映されることになります。

...

// 実行結果を表示する
const PlayGroundResult: React.FC<{}> = () => {
  return <div className="playground-result"></div>
}

続いては編集対象を切り替えるタブのコンポーネントです。現在編集しているものにのみis-activeクラスをつけるようにします。

...

// html,css,jsの切替えタブ
const PlayGroundNav: React.FC<{}> = () => {
  return (
    <div className="playground-nav">
      <a className={`playground-nav__link is-active`}>HTML</a>
      <a className={`playground-nav__link`}>CSS</a>
      <a className={`playground-nav__link`}>JS</a>
    </div>
  );
};

さらに、エディタのコンポーネントを作っておきます。

...

// ソースコードエディタ
const SourceEditor: React.FC<{}> = () => {
  return <div className="playground-editor"></div>;
};

そして、実装した 3 つの子コンポーネントをPlayGroundEditorコンポーネントの中で利用します。

...

export const PlayGroundEditor: React.FC<{}> = () => {
  return (
    <div className="playground-render">
      <PlayGroundResult></PlayGroundResult>
      <div className="playground-edit">
        <PlayGroundNav></PlayGroundNav>
        <SourceEditor></SourceEditor>
      </div>
    </div>
  );
};

一応の UI が表示されました。もちろんまだ何も操作出来ません。以後、ユーザーの操作に反応するように振る舞いを実装していきます。

ui

STEP.3 タブの切り替えの実装

タブをクリックした時にアクティブなタブが切り替わるようにしていきます。また、現在のタブをステートとして持つようにします。

現在のタブを分かりやすくするため、enum のように使えるオブジェクトを作っておきます。なお、as constをつけると各フィールドが read-only になり、代入しようとするとエラーになります。

playground.tsx

const TAB = {
  HTML: 0,
  CSS: 1,
  JS: 2,
} as const;

...

次に、現在のアクティブなタブを表すステートを用意します。

import { RecoilRoot, useRecoilValue, useRecoilState, atom } from "recoil";

const TAB = {
  ...
}

const activeTab = atom<0 | 1 | 2>({ key: "active", default: TAB.HTML });

...

Recoil のステート管理を使うため、親コンポーネントをRecoilRootで包みます。

...

export const PlayGroundEditor: React.FC<{}> = () => {
  return (
    <RecoilRoot>
      <div className="playground-render">
        <PlayGroundResult></PlayGroundResult>
        <div className="playground-edit">
          <PlayGroundNav></PlayGroundNav>
          <SourceEditor></SourceEditor>
        </div>
      </div>
    </RecoilRoot>
  )
}

PlayGroundNav コンポーネントがタブを持っています。ユーザーはこのタブによって編集対象を切り替えます。

そこで、タブが押下された時、activeTab の値を書き換えるようにします。 また、activeTab の値に応じて、is-activeクラスが付け替えられるようにします。

...

// html,css,jsの切替えタブ
const PlayGroundNav: React.FC<{}> = () => {
  const [active, changeActive] = useRecoilState(activeTab);

  const activeClass = (tab: number) => {
    return active === tab ? "is-active" : "";
  };

  return (
    <div className="playground-nav">
      <a
        className={`playground-nav__link ${activeClass(TAB.HTML)}`}
        onClick={() => {
          changeActive(TAB.HTML);
        }}
      >
        HTML
      </a>
      <a
        className={`playground-nav__link ${activeClass(TAB.CSS)}`}
        onClick={() => {
          changeActive(TAB.CSS);
        }}
      >
        CSS
      </a>
      <a
        className={`playground-nav__link ${activeClass(TAB.JS)}`}
        onClick={() => {
          changeActive(TAB.JS);
        }}
      >
        JS
      </a>
    </div>
  );
};

これで、タブの切り替えができるようになりました。

tab

STEP.4 エディタの実装

タブの切り替えができるようになりましたが、肝心のソース編集が出来ません。次はエディタを用意します。

エディタの実装といっても、本格的なエディタを作ろうとすると膨大な時間が必要です。そこで 3 分クッキングのシステムを拝借して、すでにある素晴らしいエディタを使うことにしましょう。react-aceです。

これをSourceEditorコンポーネントの中に入れてみます。

import AceEditor from "react-ace";
import "ace-builds/src-noconflict/mode-html";
import "ace-builds/src-noconflict/mode-css";
import "ace-builds/src-noconflict/mode-javascript";
import "ace-builds/src-noconflict/theme-monokai";

...

// ソースコードエディタ
const SourceEditor: React.FC<{}> = () => {
  return (
    <div className="playground-editor">
      <AceEditor
        setOptions={{
          useWorker: false,
          enableBasicAutocompletion: true,
        }}
        mode="html"
        theme="monokai"
        height="400px"
        width="400px"
        editorProps={{ $blockScrolling: true }}
      />
    </div>
  );
};

エディタが表示されました。

tab

ただ、最終的にはエディタで編集された html,css,js のソースコードをPlayGroundResultコンポーネント内で実行させたいです。そこで、エディタで編集された編集内容を参照できるよう、それらを管理するステートを作っておきます。

const activeTab = atom<0 | 1 | 2>({ key: "active", default: TAB.HTML })
const htmlSource = atom({ key: "html", default: "" }) // 追加
const cssSource = atom({ key: "css", default: "" }) // 追加
const jsSource = atom({ key: "js", default: "" }) // 追加

すでに active なタブの情報を持っているので、この値に応じてエディタのモードや表示されるソースコードが変更されるようにします。

const SourceEditor: React.FC<{}> = () => {
  const [html, changeHtml] = useRecoilState(htmlSource)
  const [css, changeCss] = useRecoilState(cssSource)
  const [js, changeJs] = useRecoilState(jsSource)
  const active = useRecoilValue(activeTab)

  const source = {
    [TAB.HTML]: {
      aceMode: "html",
      source: html,
      change: changeHtml,
    },
    [TAB.CSS]: {
      aceMode: "css",
      source: css,
      change: changeCss,
    },
    [TAB.JS]: {
      aceMode: "javascript",
      source: js,
      change: changeJs,
    },
  }

  const target = source[active]

  return (
    <div className="playground-editor">
      <AceEditor
        setOptions={{
          useWorker: false,
          enableBasicAutocompletion: true,
        }}
        mode={target.aceMode}
        theme="monokai"
        value={target.source}
        onChange={value => {
          target.change(value) // エディタ上でソースコードが編集されたらステートの値も同期させる
        }}
        height="400px"
        width="400px"
        editorProps={{ $blockScrolling: true }}
        key={target.aceMode}
      />
    </div>
  )
}

これで、html,css,js を個別に編集できるようになりました。また、エディタでの編集内容を他のコンポーネントから参照できるようになりました。

STEP.5 エディタでの編集内容を描画する

いよいよ、編集された内容を描画する部分を実装します。

現状、ユーザーが編集した html,css,js のコードを参照できる状態になりましたが、何をすればその実行結果が得られるでしょうか?

実は、あるページに他のページを埋め込むのに使う<iframe>要素にピッタリな属性があります。srcdoc属性です。

srcdoc属性は iframe に読み込まれるコンテンツを URL ではなくインライン html として指定できる属性で、指定した内容が iframe 内にレンダリングされます。つまり、html,css,js ソースコードを一つのインライン html にまとめることができれば、その内容をレンダリングすることができます。

また、sandbox属性を使うと iframe 内のコンテンツが実行できる内容に制約をかけることができます。

以下のようにインライン html を作れば、スタンダードな web ページの構成になりそうです。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>playground</title>
    <style>
      {cssのソースコード}
    </style>
  </head>
  <body>
    {htmlのソースコード}
    <script>
      {
        jsのソースコード
      }
    </script>
  </body>
</html>

早速、PlayGroundResult コンポーネントで iframe を利用してみます。

...

const createPlayGroundHtml = (html: string, css: string, js: string) =>
  `<!doctype html>
    <html>
      <head>
        <meta charset="utf-8">
        <title>playground</title>
        <style>${css}</style>
      </head>
      <body>
        ${html}
        <script>${js}</script>
      </body>
    </html>`

// 実行結果を表示する
const PlayGroundResult: React.FC<{}> = () => {
  const html = useRecoilValue(htmlSource)
  const css = useRecoilValue(cssSource)
  const js = useRecoilValue(jsSource)

  const playGroundHtml = createPlayGroundHtml(html, css, js)

  return (
    <div className="playground-result">
      <iframe
        sandbox="allow-scripts"
        className="playground__content"
        srcDoc={playGroundHtml}
      ></iframe>
    </div>
  )
}

エディタをいじってみると、遂にエディタでの編集内容が描画されるようになりました!

playground_hello

これでひとまず完成です!

EX1 実行結果描画の頻度を減らす

Chrome DevTools などを開きながら、完成したプレイグランド環境をいじってみましょう。 html や css をいじる時は問題なさそうですが、JavaScript を編集している最中、大量にエラーが出ているのがわかるはずです。

error

この大量のエラーは、ソースコードが 1 字変わるたびに毎回描画を試みているために発生しています。 一般的なプログラミングを考えると、変数名やメソッド名が 1 字で表されていることは稀なので、1 字変わるたびにソースコードを評価した場合、ほとんどがエラーの出るソースコードとなってしまいます。

そもそも web アプリケーションの実装時、今回使ったonchangeイベントをはじめonscrollonresizeのような高頻度で呼ばれるイベントでは、パフォーマンスの観点から重い処理を避けることが推奨されています。iframeへのレンダリング処理は、まさにその「重い処理」です。

そこでレンダリング処理が一定間隔に 1 度しか発生しないようにしてみましょう。codepen も、レンダリング処理を一定時間ごとにしか行わないように実装されています。

これは、React のuseEffectフックを使うことで実装できます。

import React, { useState, useEffect } from "react";
...

// 実行結果を表示する
const PlayGroundResult: React.FC<{}> = () => {
  ...

  const [playGroundHtml, setPlayGroundHtml] = useState(
    createPlayGroundHtml(html, css, js)
  )

  useEffect(() => {
    const timerId = setTimeout(() => {
      setPlayGroundHtml(createPlayGroundHtml(html, css, js))
    }, 2000)

    return () => {
      clearTimeout(timerId)
    }
  }, [html, css, js])

  ...
}

まず、useStateでレンダリングするソースを Functional Component のステートにしておきます。 useEffect内の関数は html,css,js のソースが変更されるたびに呼び出されますが、その関数は2秒後にレンダリングするソースを変更するタイマーを設定すると共に、そのタイマーを破棄する関数を返します。このように返却された「タイマーを破棄する関数」は次にuseEffect内の関数が実行される直前に実行されます。

よって、最後に変更があってから2秒後のタイマーのみが有効になり、ソースの更新が 2 秒間行われなくなって初めてレンダリング処理が走ります。なお、これとほぼ同じことを実現するフックが、react-useuseDebounceで利用できます。

EX2 HTML,CSS,Javascript のソースを初期値として渡せるようにする

コンポーネントとしては、各エディタの初期状態のコードを自由に指定できると便利です。そこで、それらの値をコンポーネントのプロパティとして渡せるようにしてみましょう。

PlayGroundEditor に任意の props フィールド を指定します。

...

export const PlayGroundEditor: React.FC<{
  html?: string;
  css?: string;
  js?: string;
}> = (props) => {
...

RecoilRoot のinitializeStateプロパティから、ステートの値の初期化ができます。ここで prop で与えられた値を使って初期化すれば OK です。

...

export const PlayGroundEditor: React.FC<{
  html?: string;
  css?: string;
  js?: string;
}> = (props) => {

  ...
    <RecoilRoot
      initializeState={({ set }) => {
        const defaultHtml = props.html ? props.html : "";
        set(htmlSource, defaultHtml);

        const defaultCss = props.css ? props.css : "";
        set(cssSource, defaultCss);

        const defaultJs = props.js ? props.js : "";
        set(jsSource, defaultJs);
      }}
    >

コンポーネント呼び出し部分で初期値を設定してみましょう。

app.tsx

<PlayGroundEditor
  html={`<div class="hello">Hello PlayGround!</div>`}
  css={`.hello {\n  background: #f48;\n}`}
  js={`const elem = document.querySelector(".hello");\nelem.style.color ="#fff";`}
></PlayGroundEditor>

props として渡した値がエディタに入り、その値で初期化されています。

init

これで、最低限の機能を持つプレイグラウンド環境が実装できました。これほどの機能が百行とちょっとで作れてしまうのは面白いです。

https://github.com/makibishi0212/react-playgroundに、今回実装してきたソースコードを置いておきます。