2
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Posted at

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

今回作った構成のデモはaspnetcore-razor-react-boilerplateに置いています。

目標

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

構成のポイント

  • クライアントサイドスクリプトはwebpackでトランスコンパイル&バンドルしてwwwroot/以下に出力し、Razorのレイアウトと各ページでそれぞれロードする
  • webpackのエントリポイントはページごとに追加し、ソースマップ内のファイルパスは「ソースマップファイルから出力元ファイルへの相対パス」を設定する
  • MSBuildのターゲットを利用してビルド時にパッケージ復元や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

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

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

tree1.png

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

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

mkdir RazorReactBoilerplate/Scripts
cd RazorReactBoilerplate/Scripts

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

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の設定ファイルも追加します。

npx tsc --init

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

tsconfig.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
長いので折りたたみ
// @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: ["**/*"],
    }),
  ],
};

なお、webpackの設定は開発時と本番で別々に分けておきたいのでwebpack-mergeを使って設定を分割しています。

webpack.dev.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
// @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/以下に出力するようにします。

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なのでスクリプトファイルは一つにバンドルせず、ページごとにバンドルするのでファイルごとにエントリポイントを追加します。

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

ただ、ページを追加するたびに毎回エントリポイントを追加するのは非常に手間なのでnode-globsrc/以下のスクリプトファイルを全てエントリポイントとして追加するようにします。

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でソースマップを出力するようにします。

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でカスタマイズします。

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を実行するスクリプトを追加します。

{
  "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プロジェクトのビルド構成名称と一致させておきます。

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

バンドルの確認

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

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

src/page-scripts/page1.tsx
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を実行します。

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.jswwwroot/js/vendor.jsが生成されていればOKです。

bundlecheck.png

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

Pages/Shared/_Layout.cshtml
<!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
@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

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

debugonvs.png

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

npm run watch

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

MSBuildのターゲットを利用してビルド時にクライアントサイドのパッケージ復元とwebpackを実行するようにします。

RazorReactBoilerplate.csproj
<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 cinpm run build:$(Configuration)コマンドを実行してパッケージ復元とwebpackを実行しています。

なお、毎回ビルド時にこれが走るとかなり時間がかかるので、InputsOutputsを指定してインクリメンタルビルドを有効にしています。
これを行うと、Scripts/package.jsonScripts/package-lock.jsonScripts/node_modules/.package-lock.jsonよりも新しい場合のみターゲットが実行され、ビルド時間の短縮に繋がります。

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

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

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

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

code RazorReactBoilerplate/Scripts

webpackを実行する

まずはvscode上でwebpackが実行できるように.vscode/tasks.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.kindbuildに設定してgroup.isDefaultをtrueにする)に指定しているので、Ctrl+Shift+Bwebpack watchが走ります。

また、ProblemMatcherを使うとビルド状態がアイコンで表示されて少し便利なので、TypeScript + Webpack Problem Matchersを使用しています。

デバッグする

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

{
  "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で確認できます。

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

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

debugonvscode.png

2
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?