🗂

ブラウザで俺の書いたReactをほぼそのまま動かす

2022/03/18に公開

TC39が多くの新仕様を提案する一方で、同時にWebフロントエンドのビルドプロセスも複雑になってきています。

もはや以前のようにプログラマが書いたJavascriptがそのままブラウザで実行されることはありません。現代では私たちの書いたJavascriptはポリフィル・バンドラ・マングラ・トランスパイラなど多数のツールを用いて最適化を施すことが必須になっています。自分の命名した変数すら残っていないプログラムを納品することは誠実な態度と言えるのでしょうか?自分で読むことができないコードは自分が書いたと言えるのでしょうか。

もう少しわかりやすくなってほしい。ブラウザでは自分の読めるコードが動いてほしい。AT車が主流になって自動で複雑な制御をして最適なギア比に変わるのはいいことなんだけど、でもたまにはMT車に乗って自分の責任でガシャコンしたい。自分の制御下にある範囲が増えると気持ちいいよね。そういうことです。プロダクションではやっぱりビルドが必要ではあるけど、趣味のwebページくらいでは自分がわかる範囲の、自分で読めるコードを動かしたいとは思いませんか?

そういうわけでこの記事の目標は、出来るだけビルド手順を減らしつつ自分で読めるReactをブラウザで動かすことです。

ありがたいことに現代のJavaScriptはモジュールシステムがESMとして標準化されていますから、ESMさえあればモジュールバンドラは要らないはずです。ポリフィルは使いません。勿論マングラもです。ただしTypescriptは使います。TSがない開発はもはや考えられません。

よって使うプリプロセスツールTypescriptだけです。幸いなことにTypescriptはjsxのトランスパイルをサポートしています。なのでTypescriptのビルド時に型チェックして、型落として、jsxをプレーンなjavascriptに変換してもらいます。このTSビルド後のコードを読めるようにします。

まずはプロジェクトを初期化します。

npm add react react-dom
npm add -D @types/react @types/react-dom typescript

package.json"type": "module"を追加するのを忘れないでください。

次にTypescriptの初期化です。

npx tsc --init --target esnext --module esnext --rootDir ./src --outDir ./build --jsx react-jsxdev

Typescriptのnightlyビルドではmoduleにnode12(ESM)を使えますが、うまく動かなかったので止めました。

次にindex.htmlを作ります。

<!DOCTYPE html>

<head>
    <title>React As Is</title>
    <script type="importmap">
          {
            "imports": {
                "react": "https://esm.sh/react@17",
                "react-dom": "https://esm.sh/react-dom@17",
                "react/jsx-runtime": "https://esm.sh/react@17/jsx-runtime",
                "react/jsx-dev-runtime": "https://esm.sh/react@17/jsx-dev-runtime"
            }
          }
        </script>
    <script src="./build/main.js" type="module"></script>
</head>

<body>
    <div id="react-root"></div>
</body>

</html>

ポイントはimport mapと<script src="./build/main.js" type="module"></script>です。import mapでimport構文の参照先をブラウザに教え、<script type="module">でESMだということを伝えます。import mapもsrcアトリビュートで参照先を指定できるはずですが、chromeがまだ埋め込みしか対応してないので直で埋め込んでいます。なおfirefoxはそもそもimport mapに対応していません。

それではReactを書きます。

src/main.tsx
import { render } from "react-dom";
import { App } from "./mod.js";
const root = document.getElementById("react-root");
render(<App />, root);
src/mod.tsx
import { useState } from "react";

export const App = () => {
  const [name, setName] = useState("");
  return (
    <>
      <h2>Hello React</h2>
      <input onChange={(e) => setName(e.target.value)} />
      <p>Your name is...</p>
      <h3>{name.toUpperCase()}</h3>
    </>
  );
};

以上。普通のReactです。ESMなのでimport { App } from "./mod.js";.jsがポイントです。なくてもTSはエラーになりませんがランタイムでエラーになります。

ローカルでの開発にはサーバーが必要なので適当にサーバーを書きますが載せるのがめんどいので見たい人はリポジトリを見て下さい。

では準備ができたのでビルドします。

npx tsc --jsx react-jsx

--jsx react-jsxがあればproductionビルド、無ければdevelopmentビルドです。

ビルドしたファイルはこうなります。productionビルドです。

build/mod.js
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from "react";
export const App = () => {
    const [name, setName] = useState("");
    return (_jsxs(_Fragment, { children: [_jsx("h2", { children: "Hello React" }), _jsx("input", { onChange: (e) => setName(e.target.value) }), _jsx("p", { children: "Your name is..." }), _jsx("h3", { children: name.toUpperCase() })] }));
};

読めますね。あとはこれを静的ファイルとしてデプロイします。

デプロイしたのがこれです。EdgeかChromeの新しいやつじゃないと動きません。
https://hagihara-a.github.io/react-as-is/

ソースを見てもらえばわかるんですが、上で示したコードがそのままブラウザで動いています。さらにimport mapで指定した通りに、ブラウザがモジュールを読み込みに行っています。

これで自分で読めるReactをブラウザで動かすことができました。FirefoxやSafariでは動かないし、EdgeとChromeでも古いバージョンでは動かないです。あと新しいJavascriptの構文を使うごとに対応するブラウザが減っていきます。完全に自己満足です。

ただしこの構成には(対応ブラウザが限られるという点を除いて)3つ問題があります。

  1. ローカルのReactはnpmjs.comからダウンロードしているのに、ブラウザはesm.shにファイルを探しに行く
  2. もしnodeがesm.shを参照するようになっても、package.jsonimport mapでバージョンがずれる可能性がある
  3. TypescriptのモジュールシステムがまだCJSなので、ブラウザとnodeでモジュール解決の仕組みが違う

最後の点は結構深刻で、型上はエラーじゃないのにランタイムでエラーになる可能性があります。他の二つも結構めんどくさいです。

これらの問題点をすべて解消してくれるのがDenoです。最初Denoでやったら一瞬で終わったのでわざわざnodejsでやることにしました。みんなもDenoを使おう!最高!Denoはストレス無くて感動です!Denoの人ありがとう。

おしまい。

この記事のリポジトリ↓

https://github.com/Hagihara-A/react-as-is

余談

romeがフロントエンドのツールチェインを統一することを目標にしていますが、Denoもbundleとかfmtとかlintとかtestとか十分な機能を実装しているのでなんかもうDenoでいいかなと思っています。

romeはずっと開発中ですし。。。

Discussion