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アプリ」をベースにします。
設定はデフォルトのままで、プロジェクト名はRazorReactBoilerplate
としました。
初期のディレクトリ構成は以下の通りです。
クライアントサイドの環境構築
プロジェクト直下に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.json
にcompilerOptions.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-globでsrc/
以下のスクリプトファイルを全てエントリポイントとして追加するようにします。
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プロジェクトのビルド構成名称と一致させておきます。
デフォルトはDebug
とRelease
になっていると思います。
バンドルの確認
適当なスクリプトを追加してバンドルが機能しているかを確認します。
今回は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.js
とwwwroot/js/vendor.js
が生成されていればOKです。
ついでに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が機能していることが確認できます。
また、VisualStudio上でScripts/src/page-scripts/page1.tsx
にブレークポイントを置いてリロードするとデバッグが可能です。
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 ci
とnpm run build:$(Configuration)
コマンドを実行してパッケージ復元とwebpackを実行しています。
なお、毎回ビルド時にこれが走るとかなり時間がかかるので、Inputs
とOutputs
を指定してインクリメンタルビルドを有効にしています。
これを行うと、Scripts/package.json
とScripts/package-lock.json
がScripts/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.kind
をbuild
に設定してgroup.isDefault
をtrueにする)に指定しているので、Ctrl+Shift+B
でwebpack 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でもデバッグが可能です。