ASP.NET Core Razor PagesでMPAをベースにしつつ部分的にReact + TypeScriptを導入する

ASP.NET Core Razor Pagesを利用して基本はMPAをベースにするが、一部クライアントサイドでインタラクティブな機能も必要、といったケースでの構成に悩んだのでメモです。

今回作った構成のデモは[aspnetcore-razor-react-boilerplate](https://github.com/aoisupersix/aspnetcore-razor-react-boilerplate)に置いています。

# 目標

- サーバサイドのアプリビルド時にクライアントサイドもビルドが走るようにする
    - サーバサイドのみに変更を加えたい場合に、クライアントサイドを意識せずにアプリが実行できる状態にしておきたい
    - CI/CDでクライアントサイドをビルドするステップを増やして複雑にしたくない
- クライアントサイドに.NET関係のツール（VisualStudioの拡張機能、[Microsoft.Typescript.MSBuild](https://www.nuget.org/packages/Microsoft.TypeScript.MSBuild)など）は使用しない
    - ツールをインストール＆チームに共有する手間を省きたい
    - .NET関係のツールを使った特殊な構成だと参考情報が一気に少なくなるので
- クライアントサイドもホットリロードとデバッグを可能とする

# 構成のポイント

- クライアントサイドスクリプトはwebpackでトランスコンパイル＆バンドルして`wwwroot/`以下に出力し、Razorのレイアウトと各ページでそれぞれロードする
- webpackのエントリポイントはページごとに追加し、ソースマップ内のファイルパスは「ソースマップファイルから出力元ファイルへの相対パス」を設定する
- [MSBuildのターゲット](https://learn.microsoft.com/ja-jp/visualstudio/msbuild/target-element-msbuild?view=vs-2022)を利用してビルド時にパッケージ復元やwebpackのバンドルを実行する

# 環境

- Windows 11
- VisualStudio 2022
- VisualStudioCode 1.73
- ASP.NET Core 6.0
- node.js 18

# 構築手順

## プロジェクト作成

VisualStudioからプロジェクトを作成する際にReactやTypeScriptが構成されたテンプレートがありますが、これらはSPAな構成となってしまう上、様々なパッケージがプレインストールされて後々困りそうなので最もシンプルそうな「ASP.NET Core Webアプリ」をベースにします。

![templateselection.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197871/80f0d295-2e5c-23af-75e7-0628ad1257b0.png)

設定はデフォルトのままで、プロジェクト名は`RazorReactBoilerplate`としました。

初期のディレクトリ構成は以下の通りです。

![tree1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197871/aed6e2cd-df76-4ff0-cf4d-8650a470665e.png)

## クライアントサイドの環境構築

プロジェクト直下に`Scripts/`ディレクトリを追加して、その中でクライアントサイドの環境を整備します。

```sh
mkdir RazorReactBoilerplate/Scripts
cd RazorReactBoilerplate/Scripts
```

npmを初期化してTypeScript、React、webpack関係のパッケージを追加します。
一応NuGetにもJavaScript/TypeScriptのパッケージがあったりしますが、バージョンが古かったりするのでnpm、もしくはLibManを利用するのが無難です。

```sh
npm init -y
npm i -S react react-dom
npm i -D @types/react @types/react-dom
npm i -D typescript
npm i -D webpack webpack-cli webpack-merge ts-loader glob clean-webpack-plugin
```

TypeScriptの設定ファイルも追加します。

```sh
npx tsc --init
```

Reactを使うため、生成された`tsconfig.json`に`compilerOptions.jsx: "react"`を追加します。

##### tsconfig.json

```json
{
  "compilerOptions": {
    "jsx": "react", // 追加
    "target": "es2016",
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  },
}
```

その他eslint、prettier、editorconfig等を追加していますが本筋ではないので飛ばします。

## webpackの設定

webpackの設定を`webpack.common.js`に記述します。

##### webpack.common.js

<details><summary>長いので折りたたみ</summary><div>

```js
// @ts-check

const path = require("path");
const glob = require("glob");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");

const wwwrootPath = path.resolve(__dirname, "..", "wwwroot");
const jsPath = path.resolve(wwwrootPath, "js");
const getEntryName = (entryPath) =>
  entryPath.replace(/\.[^/.]+$/, "").replace(/\.\/src\//, "");

const entries = {};
glob.sync("./src/**/*.{ts,tsx}").map((file) => {
  entries[getEntryName(file)] = file;
});

/** @type {import('webpack').Configuration} */
module.exports = {
  entry: entries,
  output: {
    path: jsPath,
    filename: "[name].js",
    publicPath: "/",
    devtoolModuleFilenameTemplate: (info) => {
      const destPath = path.resolve(
        jsPath,
        getEntryName(info.resourcePath) + ".js"
      );
      const destDirPath = path.dirname(destPath);
      const sourceRelativePath = path.relative(
        destDirPath,
        info.absoluteResourcePath
      );
      return sourceRelativePath.split(path.sep).join(path.posix.sep);
    },
  },
  optimization: {
    splitChunks: {
      name: "vendor",
      chunks: "initial",
    },
  },
  resolve: {
    extensions: [".js", ".ts", ".tsx"],
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: "ts-loader",
      },
    ],
  },
  plugins: [
    new CleanWebpackPlugin({
      cleanOnceBeforeBuildPatterns: ["**/*"],
    }),
  ],
};
```

</div></details>

なお、webpackの設定は開発時と本番で別々に分けておきたいので[webpack-merge](https://github.com/survivejs/webpack-merge)を使って設定を分割しています。

##### webpack.dev.js

```js
// @ts-check

const { merge } = require("webpack-merge");
const common = require("./webpack.common.js");

module.exports = merge(common, {
  mode: "development",
  devtool: "inline-source-map",
});
```

##### webpack.prod.js

```js
// @ts-check

const { merge } = require("webpack-merge");
const common = require("./webpack.common.js");

module.exports = merge(common, {
  mode: "production",
  devtool: false,
});
```

webpackのポイントとしては以下です。

### 出力先を`wwwroot/js/`に設定

ASP.NET Coreでは`wwwroot/`以下のディレクトリにファイルを設定すると静的ファイルとしてホスティングしてくれるので、バンドルしたJavaScriptファイルは`webpack.common.js`からの相対パスで`../wwwroot/js/`以下に出力するようにします。

```js
const path = require("path");

const wwwrootPath = path.resolve(__dirname, "..", "wwwroot");
const jsPath = path.resolve(wwwrootPath, "js");

module.exports = {
  output: {
    path: jsPath,
    filename: "[name].js",
    ...
  },
  ...
};
```

### スクリプトファイルごとにエントリポイントを追加する

今回はMPAなのでスクリプトファイルは一つにバンドルせず、ページごとにバンドルするのでファイルごとにエントリポイントを追加します。

```js
module.exports = {
  entry: {
    page1: 'src/page1.ts',
    page2: 'src/page2.ts',
    ...
  },
};
```

ただ、ページを追加するたびに毎回エントリポイントを追加するのは非常に手間なので[node-glob](https://github.com/isaacs/node-glob)で`src/`以下のスクリプトファイルを全てエントリポイントとして追加するようにします。

```js
const glob = require("glob");

const getEntryName = (entryPath) =>
  // エントリポイントのパスから拡張子と"./src/"を削除
  entryPath.replace(/\.[^/.]+$/, "").replace(/\.\/src\//, "");

const entries = {};
glob.sync("./src/**/*.{ts,tsx}").map((file) => {
  entries[getEntryName(file)] = file;
});

module.exports = {
  entry: entries,
};
```

エントリポイント名がそのまま出力ファイル名に設定されるので、余計な拡張子やパスは`getEntryName()`で除外するようにしています。
サブディレクトリに置いたスクリプトファイルはディレクトリ構造を維持したまま出力先ディレクトリにバンドルされます。

例えば、バンドル前のスクリプトファイルが以下のような構造だった場合、

```
`-- Scripts
    `-- src
        |-- page1.ts
        `-- subdir
            `-- page2.ts
```

`wwwroot/`には以下のファイルが出力されます。

```
`-- wwwroot
    `-- js
        |-- page1.js
        `-- subdir
            `-- page2.js
```

### ソースマップ内のファイル名を調整する

TypeScriptファイルをデバッグするためにはソースマップ（`*.js.map`）の生成が必要になるので、webpackでソースマップを出力するようにします。

```js
module.exports = {
  devtool: "inline-source-map",
};
```

ソースマップ内に出力されるファイル名はデフォルトで`webpack:///[resource-path]`となりますが、VisualStudioのデバッガでデバッグするためには、「ソースマップファイルから出力元ファイルへの相対パス」が設定されている必要があります。

例えば、

- 出力元（バンドル前）のファイルが`Scripts/src/page1.ts`
- ソースマップファイルが`wwwroot/js/page1.js.map`

だった場合、ソースマップ内のファイル名は`../../Scripts/src/page1.ts`が設定されるように調整します。

webpackでは`output.devtoolModuleFilenameTemplate`でカスタマイズします。

```js
const path = require("path");

const wwwrootPath = path.resolve(__dirname, "..", "wwwroot");
const jsPath = path.resolve(wwwrootPath, "js");
const getEntryName = (entryPath) =>
  entryPath.replace(/\.[^/.]+$/, "").replace(/\.\/src\//, "");

module.exports = {
  output: {
    ...
    devtoolModuleFilenameTemplate: (info) => {
      const destPath = path.resolve(
        jsPath,
        getEntryName(info.resourcePath) + ".js"
      );
      const destDirPath = path.dirname(destPath);
      const sourceRelativePath = path.relative(
        destDirPath,
        info.absoluteResourcePath
      );
      return sourceRelativePath.split(path.sep).join(path.posix.sep);
    },
  },
};
```

`path.relative()`を使って相対パスを取得しています。

## npm-scriptsの追加

`package.json`にwebpackを実行するスクリプトを追加します。

```json
{
  "scripts": {
    "build:Debug": "webpack --config webpack.dev.js",
    "build:Release": "webpack --config webpack.prod.js",
    "watch": "webpack --config webpack.dev.js --watch",
  },
  ...
}
```

この時、スクリプト名：`build:XXX`の「XXX」の部分はASP.NET Coreプロジェクトのビルド構成名称と一致させておきます。

デフォルトは`Debug`と`Release`になっていると思います。

## バンドルの確認

適当なスクリプトを追加してバンドルが機能しているかを確認します。

今回は`src/page-scripts/page1.tsx`を追加しました。

##### src/page-scripts/page1.tsx

```ts
import React, { ReactElement } from "react";
import { createRoot } from "react-dom/client";

const App = (): ReactElement => {
  return <div>Hello React TypeScript!</div>;
};

const container = document.querySelector("#app");
if (container === null) {
  throw new Error("React root container(#app) not found.");
}
const root = createRoot(container);
root.render(<App />);
```

webpackを実行します。

```sh
npm run build:Debug

> razor-react-boilerplate@1.0.0 build:Debug
> webpack --config webpack.dev.js

asset vendor.js 2.77 MiB [emitted] (name: vendor) (id hint: vendors)
asset page-scripts/page1.js 14.8 KiB [emitted] (name: page-scripts/page1)
Entrypoint page-scripts/page1 2.79 MiB = vendor.js 2.77 MiB page-scripts/page1.js 14.8 KiB
runtime modules 2.63 KiB 4 modules
modules by path ./node_modules/ 1.08 MiB
  modules by path ./node_modules/react-dom/ 1000 KiB
    ./node_modules/react-dom/client.js 619 bytes [built] [code generated]
    ./node_modules/react-dom/index.js 1.33 KiB [built] [code generated]
    ./node_modules/react-dom/cjs/react-dom.development.js 1000 KiB [built] [code generated]
  modules by path ./node_modules/react/ 85.7 KiB
    ./node_modules/react/index.js 190 bytes [built] [code generated]
    ./node_modules/react/cjs/react.development.js 85.5 KiB [built] [code generated]
  modules by path ./node_modules/scheduler/ 17.3 KiB
    ./node_modules/scheduler/index.js 198 bytes [built] [code generated]
    ./node_modules/scheduler/cjs/scheduler.development.js 17.1 KiB [built] [code generated]
./src/page-scripts/page1.tsx 679 bytes [built] [code generated]
webpack 5.75.0 compiled successfully in 1748 ms
```

実行後、`wwwroot/js/page-scripts/page1.js`と`wwwroot/js/vendor.js`が生成されていればOKです。

![bundlecheck.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197871/a771d0ee-869a-62de-b2d3-e9a250962145.png)

ついでにRazor Pagesからスクリプトをロードして機能しているかも確認します。

##### Pages/Shared/_Layout.cshtml

```html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>@ViewData["Title"] - RazorReactBoilerplate</title>
    <link rel="stylesheet" href="~/RazorReactBoilerplate.styles.css" asp-append-version="true" />
</head>
<body>
    @RenderBody()

    <script src="~/js/vendor.js" asp-append-version="true"></script>
    @await RenderSectionAsync("Scripts", required: false)
</body>
</html>
```

##### Pages/Page1.cshtml

```html
@page
@model Page1Model
@section Scripts {
    <script src="~/js/page-scripts/page1.js" asp-append-version="true"></script>
}

<div id="app"></div>
```

VisualStudioからデバッグを実行し`/Page1`にアクセスすると「Hello React TypeScript!」と表示されReactが機能していることが確認できます。

![page1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197871/efe9529d-0792-b047-0274-136b16716cca.png)

また、VisualStudio上で`Scripts/src/page-scripts/page1.tsx`にブレークポイントを置いてリロードするとデバッグが可能です。

![debugonvs.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197871/18d97c17-a6be-6395-1fd0-fba122802c8c.png)

`wwwroot/`以下の静的ファイルについては再ビルドを行わなくても即時に変更が反映されるので`webpack watch`を走らせておけばクライアントサイドスクリプトのホットリロードが可能です。
（反映させるためにはブラウザ上でのリロード操作が必要になりますが。）

```sh
npm run watch
```

## ASP.NET Coreプロジェクトのビルド時にパッケージ復元とwebpackを実行する

[MSBuildのターゲット](https://learn.microsoft.com/ja-jp/visualstudio/msbuild/msbuild-targets?view=vs-2022)を利用してビルド時にクライアントサイドのパッケージ復元とwebpackを実行するようにします。

##### RazorReactBoilerplate.csproj

```xml
<Project Sdk="Microsoft.NET.Sdk.Web">
  ...
  <Target Name="NpmCI" Inputs="Scripts\package-lock.json;Scripts\package.json" Outputs="Scripts\node_modules\.package-lock.json">
    <Exec Command="npm ci" WorkingDirectory="Scripts" />
  </Target>
  <Target Name="NpmRunBuild" DependsOnTargets="NpmCI" AfterTargets="Build">
    <Exec Command="npm run build:$(Configuration)" WorkingDirectory="Scripts" />
  </Target>

</Project>
```

`AfterTarget="Build"`を指定することで、ビルド後に`npm ci`と`npm run build:$(Configuration)`コマンドを実行してパッケージ復元とwebpackを実行しています。

なお、毎回ビルド時にこれが走るとかなり時間がかかるので、`Inputs`と`Outputs`を指定して[インクリメンタルビルド](https://learn.microsoft.com/ja-jp/visualstudio/msbuild/incremental-builds?view=vs-2022)を有効にしています。
これを行うと、`Scripts/package.json`と`Scripts/package-lock.json`が`Scripts/node_modules/.package-lock.json`よりも新しい場合のみターゲットが実行され、ビルド時間の短縮に繋がります。

webpackについてはインクリメンタルビルドを行う良い方法が分からなかったので常に実行するようにしています。

## vscodeでクライアントサイドを開発する

TypeScriptやJSXはVisualStudioよりもvscodeの方が開発しやすいので、vscodeで開発できる環境を整備します。

クライアントサイドスクリプトは`RazorReactBoilerplate/Scripts/`に置いているので、このディレクトリをワークスペースとすることを前提とします。

```sh
code RazorReactBoilerplate/Scripts
```

### webpackを実行する

まずはvscode上でwebpackが実行できるように`.vscode/tasks.json`にタスクを定義します。

```json
{
  "version": "2.0.0",
  "tasks": [
    {
      "type": "npm",
      "script": "watch",
      "label": "npm: watch",
      "group": {
        "kind": "build",
        "isDefault": true
      },
      "isBackground": true,
      "problemMatcher": ["$ts-webpack-watch", "$eslint-compact"]
    },
    {
      "type": "npm",
      "script": "build:Debug",
      "label": "npm: build:Debug",
      "group": "build",
      "problemMatcher": ["$ts-webpack", "$eslint-compact"]
    },
    {
      "type": "npm",
      "script": "build:Release",
      "label": "npm: build:Release",
      "group": "build",
      "problemMatcher": ["$ts-webpack", "$eslint-compact"]
    }
  ]
}
```

`npm run watch`をデフォルトビルドタスク（`group.kind`を`build`に設定して`group.isDefault`をtrueにする）に指定しているので、`Ctrl+Shift+B`で`webpack watch`が走ります。

また、ProblemMatcherを使うとビルド状態がアイコンで表示されて少し便利なので、[TypeScript + Webpack Problem Matchers](https://marketplace.visualstudio.com/items?itemName=amodio.tsl-problem-matcher)を使用しています。

### デバッグする

`.vscode/launch.json`を追加してMSEdge上でデバッグが可能なようにします。
（Chromeでも可）

```json
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "msedge",
      "request": "launch",
      "name": "Launch MSEdge",
      "url": "https://localhost:7257",
      "webRoot": "${workspaceFolder}"
    }
  ]
}
```

この時、`url`にはサーバサイドのアプリケーションURLを設定します。
アプリケーションURLは`RazorReactBoilerplate/Properties/launchSettings.json`で確認できます。

```json
{
  ...
  "profiles": {
    "RazorReactBoilerplate": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
      "applicationUrl": "https://localhost:7257", // これ
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    },
    ...
  }
}
```

VisualStudioで実行した際に立ち上がるブラウザとは別にウインドウが作られるのがちょっと面倒ですが、これでvscodeでもデバッグが可能です。

![debugonvscode.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197871/a2519b78-c429-c900-6270-4c91a406b762.png)