React + Viteで作ったアプリをローカルファイルで開きたい

# はじめに

React + Viteで作ったアプリケーションを、**いちいちサーバーを立ち上げずに**ローカルファイルとして開きたい...
**Finderから直で開けたら**最高なのに...

と思ったReact初心者による記事です。

:::note warn
作者は初心者なので、内容に間違いがある可能性があります。
この情報は鵜呑みにせず、一度自分で調べることをお勧めします。
:::

:::note info
急いでいる方は[まとめ](#まとめ)まで飛ばしてください。
:::


## 環境

ランタイムとパッケージマネージャにはBunを使用しています。
Node.jsやNPMを使っている方は、適宜読み替えてください。

* MacOS Sonoma
* React 18.3.1
* Vite 5.4.0
* Bun 1.1.17
* ファイルの観覧: Vivaldi 6.8.3381.55 


## やりたいこと

ビルドコマンドを打つと、`dist/`にコンパイルされたHTML/CSS/JavaScriptファイルが生成されます。
このコンパイルされたファイルですが、**そのまま開くと動きません**。

この動かない原因を突き止め、どうにかして正常に動かすのが目標です。

:::note info
この際、ビルドしたあと手動でファイルを変更するのを想定しています。
:::


# エラー内容と対処法

ここからは、発生している各エラーとその対処法を書いていきます。

...とその前に、一度HTMLファイルの中身をおさらいします。
ここではHTML/CSS/JavaScriptのみの構成で、CSSをHTML側で読み込むという構成の場合の例を示します。

```dist/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" crossorigin href="/assets/index-[文字列].css">
  <script type="module" crossorigin src="/assets/index-[文字列].js"></script>
</head>
<body>
  <div id="root"></div>
</body>
</html>
```

ここで注目すべきは以下の3つです。

* `link`タグでバンドルされたCSSを読み込む
* `script`タグでバンドルされたJavaScriptファイルを読み込む
* それぞれのファイルは`/assets`にあることを前提としている


## CORS

```:エラー1
Access to script at 'file:///assets/index-[文字列].js' from origin 'null' has been blocked by CORS policy: 
Cross origin requests are only supported for protocol schemes: http, data, isolated-app, chrome-extension, chrome, https, chrome-untrusted.
```

```:エラー2
Access to CSS stylesheet at 'file:///assets/index-CT_B-64d.css' from origin 'null' has been blocked by CORS policy: 
Cross origin requests are only supported for protocol schemes: http, data, isolated-app, chrome-extension, chrome, https, chrome-untrusted.
```

これは、ファイルを開いたら真っ白な画面が表示され、違和感を持ってコンソールを開いたら一番上に表示されているエラーです。
エラー内容を見る感じ、どうやらJavaScriptファイルとCSSファイルの読み込み時にエラーが発生しているようです。


### 原因

このエラーは`file://`プロトコルが**クロスオリジンリクエストに対応していない**というものみたいです。
通常ならばHTMLからJavaScriptやCSSを読み込むのにクロスオリジンリクエストなんてしないはずですが、いったい何があったのでしょうか？

```dist/index.html
<link rel="stylesheet" crossorigin href="/assets/index-[文字列].css">
<script type="module" crossorigin src="/assets/index-[文字列].js"></script>
```

なにやら`crossorigin`というそれっぽい属性がついています。

この属性はCORSポリシーを適切に設定している外部リソースを読み取るときに使うらしいです。
詳しいことは私もよくわからなかったので、MDNのページを見てください。

https://developer.mozilla.org/ja/docs/Web/HTML/Attributes/crossorigin

とりあえず一つだけ言えるのは、`file`プロトコルではCORSは使えず、使おうとするとエラーになるということです。


### 解決策

ということで、`crossorigin`属性は消します。
なくてもアクセスはできますし、そんなに問題はないでしょう。多分。

```dist/index.html
<link rel="stylesheet" href="/assets/index-[文字列].css">
<script type="module" src="/assets/index-[文字列].js"></script>
```

これでCORSエラーは解決できました。


## type="module"

さて、上でCORSエラーは解決したはずなので、もう一度コンソールを覗いてみます。

```:エラー
Access to script at 'file:///assets/index-[文字列].js' from origin 'null' has been blocked by CORS policy: 
Cross origin requests are only supported for protocol schemes: http, data, isolated-app, chrome-extension, chrome, https, chrome-untrusted.
```

CSSのほうのエラーは消えているのですが、JavaScriptのほうな残ってしまっています。
`crossorigin`属性は消したのに...いったいなぜでしょうか？


### 原因

`file`プロトコルでは`script`タグの`type="module"`が使えないのが原因です。

どうやらJavaScriptのセキュリティ要件を満たさないらしく、CORSエラーが発生するようになっています。
詳細はこちらをご覧ください。

https://stackoverflow.com/questions/67230845/how-to-use-javascript-type-module-locally-in-browser

https://qiita.com/azukiazusa/items/98845e79807fff0dd3cb

:::note info
ちなみに、この現象はローカルファイルで`import/export`を試そうとしたときにも起こります。
:::


### 解決策

ということで、`tyep="module"`は消します。

```dist/index.html
<link rel="stylesheet" href="/assets/index-[文字列].css">
<script src="/assets/index-[文字列].js"></script>
```

これで今度こそCORSエラーが解決できました。

* * *

ちなみに、`type="module`属性を消したことによってJavaScriptのコード内で`import/export`が使えなくなりました。
といっても、適切にNPMパッケージを使用していればパッケージの内容ごとバンドルされるはずなので、そこまで問題ではないでしょう。

ただし、コード内でURLを使用した`import`を行っている場合は注意が必要かもしれません。（未検証）
CDNを利用しているなど、あり得ない話ではないと思います。
その場合は以下の方法を取ることができます。

* NPMパッケージに置き換える（パッケージがある場合）
* HTMLファイル内に`script`タグとして書く
* 諦めてローカルサーバーを使う

`script`タグとして書く場合、モジュールになっているものではなくグローバル変数として定義するものがあるなら、そのURLの`script`タグを書くだけで大丈夫です。
それもない場合、`script`タグで`import`してグローバル変数にするという手法を取ることができると思われます。（未検証）

```html
<script type="module">
    import * as Module from 'ここにURL'
    window.Module = Module
</script>
```

これは、CORSエラーが発生するのは`type="module"`と`src`を併用した時だけだという仕様？を使ったものです。

また、この際JavaScript内にある`import`宣言を削除する必要があります。
`import`はファイルの先頭にあるはずです。


## パス

CORSのエラーが解決できたので、もう一度コンソールを開いてみます。

```:エラー1
GET file:///assets/index-[文字列].js net::ERR_FILE_NOT_FOUND
```

```:エラー2
GET file:///assets/index-[文字列].css net::ERR_FILE_NOT_FOUND
```

だそうです。


### 原因

どうやらアセットを`file://assets`から読み込もうとして、ファイルが見つからず404エラーになっているようです。
パスの設定が間違っているみたいですね。

:::note info
パスが適切に設定されている場合、こちらのエラーは起こりません。
:::


この問題に関しては、複数のアプローチを取ることができます。


### 解決策1: 手動で直す

特に何も考えず、パスを手動で変更すると直ります。
具体的には、パスの始まりを`/`ではなく`./`にすればOKです。

```dist/index.html
<link rel="stylesheet" href="./assets/index-[文字列].css">
<script src="./assets/index-[文字列].js"></script>
```


### 解決策2: ベースパスの設定を変更する

:::note warn
こちらの内容は実際に試していません。
おそらくこの方法でも大丈夫たと思いますが、もし無理だったらすみません。
:::

こちらの問題ですが、ベースパスの設定を`./`に変更しても直すことができます。
ベースパスの設定を`./`にした場合、アセットファイルのパスが`./`から始まるようになるみたいです。

```vite.config.js
import { defineConfig } from 'vite'
// プラグインなどの読み込み

export default defineConfig({
    // 他の設定
    base: './'
})
```

また、設定ファイルを変更しなくても、ビルドコマンドに`base`オプションをつけてもいけます。
Bunの場合、`bun run build --base=./`でビルドする他、`package.json`の`build`スクリプトの箇所を変更することもできます。

:::note warn
ビルドをやり直す場合、こちらの手順は[CORS](#cors)からやり直しになります。
といっても`crossorigin`と`type="module"`を消すだけです。
:::

こちらを参考にしました。

https://zenn.dev/pr_tan_10/scraps/8b6dd25e150a17

https://ja.vitejs.dev/config/shared-options.html#base


### 実行タイミング

これでパスの問題が解決できたはずなので、再びブラウザをのぞいてみます。
今度は**CSSがうまく読み込めている**はずです。

ですが、JavaScriptの実行時にエラーが出てしまっています。

```:エラー
Uncaught Error: Minified React error #299; visit https://reactjs.org/docs/error-decoder.html?invariant=299 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.
    at be.createRoot (index-[文字列].js:40:55426)
    at index-[文字列].js:336:4915
```

メッセージを見る感じ、最小化されたReactエラー#299とやらが発生しているみたいです。
開発環境を使用するとエラーの解決に役立つかもと書かれていますが、そもそも開発環境でそれらしきエラーは発生していませんでした。

とりあえず[リンク先](https://reactjs.org/docs/error-decoder.html?invariant=299)のページを見ています。

どうやら、Reactではファイルのバイト数を減らすため、エラーメッセージの全文は表示されないみたいです。
そして、実際に発生しているエラーはこちらになります。

```:実際のエラー
Target container is not a DOM element.
```


### 原因

こちらのエラーは、`createRoot`で指定された値がDOM要素でないことを示しています。
ひとまず`createRoot`を呼び出している`main.tsx`の中身を見てみますが、問題はないように思えます。

```tsx
import { StrictMode } from "react";
import { createRoot } from 'react-dom/client'
import { App } from "./App";

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)
```

いや待てよ..なんだこの`!`は...
`getElementById('root')!`の最後にある`!`は、TypeScriptで使えるアノテーションです。
これは前の値(`document.getElementById('root')`)の値が`null`ではないということを示しています。

そして、**HTMLが読み込まれる前に要素を取得しようとしても`null`になります**。

ここでもう一度HTMLを見てみましょう。

```html:dist/index.html（一部略）
<head>
  <script src="assets/index-[文字列].js"></script>
</head>
<body>
  <div id="root"></div>
</body>
</html>
```

`div#root`の前に`script`タグがありますね。
原因はこれだと思われます。

:::note info
ちなみに、ローカルサーバーを使った状態で、ビルドした時の状態でこのエラーが発生しないのには理由があります。
それは`type="module"`の実行タイミングです。

何も属性をつけていない時の`script`タグは、**そのタグの位置までHTMLがパースされたら**同期的に読み込まれて実行されます。
上のようなHTMLの場合、`div#root`が読み込まれる前にJavaScriptが実行されたため、エラーが発生してしまいました。

そして、`type="module"`をつけたスクリプトは、非同期に読み込まれて**HTMLのパースが終わった後に実行**されます。
だからエラーが出なかったんですね。

[こちら](https://zenn.dev/oreo2990/articles/a93d3b1088c2fc)が非常にわかりやすかったので、詳しくはリンク先をご覧ください。
:::


### 解決方法

これも解決方法は２通りあります。

一つ目は、`script`タグの位置を下にずらす方法です。
HTMLのパースが最後まで終わってからJavaScriptを読み込み/実行するようにします。

```diff_html:dist/index.html（一部略）
<head>
- <script src="assets/index-[文字列].js"></script>
</head>
<body>
  <div id="root"></div>
+ <script src="assets/index-[文字列].js"></script>
</body>
</html>
```

もう一つは`defer`属性をつける方法です。
この属性をつけると、スクリプトの実行をHTMLのパース後まで遅らせることができます。

```html:dist/index.html（一部略）
<head>
　 <script defer src="assets/index-[文字列].js"></script>
</head>
<body>
  <div id="root"></div>
</body>
</html>
```

`defer`属性についても[先ほどの記事](https://zenn.dev/oreo2990/articles/a93d3b1088c2fc)がわかりやすかったので、詳しくはリンク先をご覧ください。

* * *

これでローカルファイルでもReactアプリが動くようになったと思います。


# まとめ

React + Viteで作ったアプリをローカルファイルで開くには、以下の手順を踏みます。

1. （パスを設定する）
1. ビルドする
1. `index.html`を編集する
    1. パスを修正する（1で行っていない場合）
    1. `crossorigin`属性と`type="modules"`属性を消す

パスを修正するには以下の方法があります。

* `vite.config.js`に`base: './'`を指定する（ビルド前）
* コマンドの`base`オプションに`./`を渡す
* `index.html`のパスを手動で編集する（ビルド後）

* * *

`vite.config.js`のパスの設定例はこちらです。

```vite.config.js
import react from "@vitejs/plugin-react";

export default {
  plugins: [react()],
  base: './',
}
```

これにより、パスが`./`で始まるようになります。
ちなみに、設定なしだと`/`から始まるので、ビルドのたびに手動で変更する必要があります。

```html
<link rel="stylesheet" crossorigin href="./assets/index-[文字列].css">
<script type="module" crossorigin src="./assets/index-[文字列].js"></script>
```

あとは、ここから`crossorigin`と`type="module"`を消し、`script`に`defer`属性をつけたら、ローカルファイルでもアプリが動くようになります。

```html
<link rel="stylesheet" href="./assets/index-[文字列].css">
<script defer src="./assets/index-[文字列].js"></script>
```

`defer`をつける代わりに、`script`タグを`</body>`の前に移動することもできます。