👨‍💻

ブラウザー上でReactやTypeScriptのコードをコンパイルして動かすツールを作った

2023/05/25に公開

通常、ReactやTypeScriptを使って開発する場合は、ローカル環境で開発して、ビルドして、ブラウザーで表示するという流れになります。
ただ、昨今のブラウザーの性能はかなり高くなっており、ES Modulesをうまく使うことで、ノーバンドルでReactやTypeScriptをリアルタイムにブラウザー上で反映させることができるのではないかと考えました。

この案をもとに、何番煎じかわかりませんが、ブラウザー上でリアルタイムにReactやTypeScriptをバンドルするライブラリを作成しました。
以下のようにコードを書くだけで、ブラウザーで実行可能なJavaScriptコードが生成されます。

import { browserBundle } from "browser-bundler";

const code = `
import React from "react";
import ReactDOM from "react-dom";
import { Hello } from "./hello.tsx";

const App = () => {
  return (<div><Hello /></div>)
}

ReactDOM.render(<App />, document.getElementById("root"));
`

const result = await browserBundle(code, {
  files: {
    "./hello.tsx": `import React from "react";
    export const Hello = () => {
      return (<div>Hello World</div>)
    }`,
  }
})

コードをみていただいたらお分かりになる通り、filesというオプションを指定することで、相対パスを使ってファイル名に一致するモジュールをimportすることもできます。

これをES Modulesを使ってブラウザー上で実行することができます。

生成されたコードをそのつど、iframeのsrcdocに埋め込むことで、リアルタイムに書いたコードをブラウザー上で実行することができます。

const { code: bundleCode } = await browserBundle(code)
iframe.srcdoc = `
  <html>
    <head>
      <meta charset="utf-8">
    </head>
    <body>
      <div id="root"></div>
      <script type="module">
      ${bundleCode}
      </script>
    </body>
  </html>
`

なぜ作ったのか

先日個人開発でリリースした模写して学ぶコーディング学習サービス、mosyaというサービスがあります。

https://mosya.dev/

こちらのサービスは現在、HTML,CSS,JavaScriptの学習を対象としていますが、今後ReactやTypeScriptなどのフレームワークやライブラリを対象にしたコンテンツを作成していく予定です。

そこで、mosyaでエディターにコードを書いている時に、ブラウザー上でリアルタイムでReactやTypeScriptをバンドルして表示するライブラリを作ってみました。

https://github.com/steelydylan/browser-bundler

また、実際にこのライブラリを試していただけるPlaygroundも用意しました。

https://mosya.dev/tools/react-ts

どのように動いているか

このライブラリでは、コードのビルドにTypeScriptのライブラリとES Modules、esm.shというES ModulesをCDNで配信してくれるサービスを利用しています。

https://esm.sh/

順を追って、どのように動いているかを説明していきます。

1. コードをTypeScriptでJavaScriptに変換

まず初めに入力されたコードをts.transpileModuleというAPIを利用してES Modulesとして実行可能なJavaScriptに変換します。

CompilerOptionsは以下の通りです。

import { CompilerOptions, JsxEmit, ModuleKind, ScriptTarget, transpileModule } from 'typescript';

const defaultCompilerOptions: CompilerOptions = {
  jsx: JsxEmit.React,
  target: ScriptTarget.ESNext,
  module: ModuleKind.ESNext,
};

const jsCode = transpileModule(code, {
  compilerOptions: opt,
});

2. 相対パスなしでimportされたモジュールはesm.shを利用してCDNから取得

生成されたコードに相対パスなしのimportが含まれている場合は、esm.shを利用してCDNからモジュールを取得するように正規表現で書き換えます。

const importRegex = /import\s+.*\s+from\s+['"][^'.].*['"];?/g
const importStatements = code.match(importRegex)
let importCode = ""
importStatements?.forEach((importStatement) => {
  const convertedCode = importStatement.replace(/from\s*['"]([^'"]*)['"]/g, function(_match, p1) {
    return `from 'https://esm.sh/${p1}'`;
  });
  importCode += convertedCode + "\n"
})

これで、reactやreact-domなど多くのライブラリがES Modulesとして実行可能となります。

3. 相対パスとして読み込まれたモジュールはBlob URLに変換

ここが肝となっています。実はES Modulesimport文ですが、スクリプトのURLにBlobdataスキームを指定することができます。

import { Hello } from "blob:https://example.com/0b3e2b5e-5b7e-4b1e-8b9e-9e0e2b5e7e4b"

この特性を利用して、相対パスで読み込まれたモジュールはBlob URLに変換して、import文を書き換えます。
大体、以下のようなコードになりますが、詳細はソースコードを参照してください。
importされているファイルを再起的にBlob URLに変換していく処理を書いています。

const relativeImportRegex = /import\s+.*\s+from\s+['"][.].*['"];?/g
const relativeImportStatements = code.match(relativeImportRegex)
if (relativeImportStatements) {
  await Promise.all(relativeImportStatements.map(async (relativeImportStatement) => {
    const convertedCode = await replaceAsync(relativeImportStatement, /from\s*['"]([^'"]*)['"]/g, async (_match, p1) => {
      const file = files[p1]
      const { code: converted } = await transformCode(file, options)
      if (file) {
        const blob = new Blob([converted], { type: 'text/javascript' })
        const blobURL = URL.createObjectURL(blob)
        return `from '${blobURL}'`;
      } else {
        return `from '${p1}'`;
      }
    });
    importCode += convertedCode + "\n"
  }))
}

4. 出来上がったコードをscript type="module"の中に埋め込む

最終的に出来上がったソースコードをscript type="module"の中に埋め込めばこれらのコードを実行することができます。
iframesrcDocに出来上がったコードを入れ込む使い方をおすすめしています。

const { code: bundleCode } = await browserBundle(code)
iframe.srcdoc = `
  <html>
    <head>
      <meta charset="utf-8">
    </head>
    <body>
      <div id="root"></div>
      <script type="module">
      ${bundleCode}
      </script>
    </body>
  </html>
`

まとめ

今回は、ブラウザー上でReactやTypeScriptをリアルタイムにバンドルして実行するライブラリを作ってみました。
作ってみて気づいたことは最近のブラウザーの機能は本当にすごいということです。
昔はWebpackなどを使って、ビルドして、ブラウザーで実行するというのが当たり前でしたが、今はブラウザー上だけでも工夫すれば、バンドルなしでコードを実行できますね!

今後の課題

  • コードのビルドをWorker上で行う
  • 生成されたBlob URLをいらなくなったタイミングで破棄する

Discussion