ASP.NET Core 中的单页应用 (SPA) 概述

注意

此版本不是本文的最新版本。 对于当前版本,请参阅此文的 ASP.NET Core 8.0 版本

Visual Studio 提供用于创建基于 JavaScript 框架(例如具有 ASP.NET Core 后端的 AngularReactVue)的单页应用 (SPA) 的项目模板。 这些模板:

  • 创建一个包含前端项目和后端项目的 Visual Studio 解决方案。
  • 将 Visual Studio 项目类型用于 JavaScript,将 TypeScript (.esproj) 用于前端。
  • 将 ASP.NET Core 项目用于后端。

在 Windows、Linux 和 macOS 上,可从命令行运行使用 Visual Studio 模板创建的项目。 若要运行应用,请使用 dotnet run --launch-profile https 运行服务器项目。 如果运行服务器项目,会自动启动前端 JavaScript 开发服务器。 当前需要 https 启动配置文件。

Visual Studio 教程

若要开始,请按照 Visual Studio 文档中的教程之一进行操作:

有关详细信息,请参阅 Visual Studio 中的 JavaScript 和 TypeScript

ASP.NET Core SPA 模板

Visual Studio 包含的模板可用于生成具有 JavaScript 或 TypeScript 前端的 ASP.NET Core 应用。 这些模板在安装了“ASP.NET 和 Web 开发”工作负载的 Visual Studio 2022 17.8 或更高版本中提供

Visual Studio 模板用于生成具有 JavaScript 或 TypeScript 前端的 ASP.NET Core 应用,具有以下优点:

  • 前端和后端的项目完全分离。
  • 随时了解最新的前端框架版本。
  • 与最新的前端框架命令行工具(如 Vite)集成。
  • JavaScript 和 TypeScript(仅限 Angular 的 TypeScript)的模板。
  • 丰富的 JavaScript 和 TypeScript 代码编辑经验。
  • 将 JavaScript 生成工具与 .NET 生成集成。
  • npm 依赖项管理 UI。
  • 与 Visual Studio Code 调试和启动配置兼容。
  • 使用 JavaScript 测试框架在测试资源管理器中运行前端单元测试。

旧 ASP.NET Core SPA 模板

.NET SDK 的早期版本包含现在用于通过 ASP.NET Core 生成 SPA 应用的旧模板。 有关这些较旧模板的文档,请参阅 ASP.NET Core 7.0 版本的 SPA 概述一文以及 Angular 一文和 React 一文。

单页应用程序模板体系结构

适用于 AngularReact 的单页应用程序 (SPA) 模板提供开发托管在 .NET 后端服务器内的 Angular 和 React 应用的功能。

在发布时,Angular 和 React 应用文件将复制到 wwwroot 文件夹,并通过静态文件中间件提供。

回退路由不会返回 HTTP 404(“找不到”),而是处理对后端的未知请求,并为 SPA 提供 index.html

在开发期间,应用配置为使用前端代理。 React 和 Angular 使用相同的前端代理。

启动应用时,将在浏览器中打开 index.html 页面。 仅在开发中启用的特殊中间件:

  • 截获传入请求。
  • 检查代理是否正在运行。
  • 如果代理正在运行或启动新的代理实例,则重定向到该代理的 URL。
  • 向浏览器返回一个页面,该页面每隔几秒自动刷新一次,直到代理启动并重定向浏览器。

Browser Proxy Server diagram

ASP.NET Core SPA 模板提供的主要优势:

  • 启动代理(如果代理尚未运行)。
  • 设置 HTTPS。
  • 将某些请求配置为代理到后端 ASP.NET Core 服务器。

浏览器发送后端终结点请求时,例如模板中的 /weatherforecast。 SPA 代理接收请求,并将其透明地发送回服务器。 服务器响应,而 SPA 代理将请求发送回浏览器:

Proxy Server diagram

已发布的单页应用

发布应用后,SPA 将成为 wwwroot 文件夹中的文件集合。

无需运行时组件即可为应用提供服务:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();


app.MapControllerRoute(
    name: "default",
    pattern: "{controller}/{action=Index}/{id?}");

app.MapFallbackToFile("index.html");

app.Run();

在上述模板生成的 Program.cs 文件中:

使用 dotnet publish 发布应用时,csproj 文件中的以下任务可确保 npm restore 运行并运行相应的 npm 脚本以生成生产项目:

  <Target Name="DebugEnsureNodeEnv" BeforeTargets="Build" Condition=" '$(Configuration)' == 'Debug' And !Exists('$(SpaRoot)node_modules') ">
    <!-- Ensure Node.js is installed -->
    <Exec Command="node --version" ContinueOnError="true">
      <Output TaskParameter="ExitCode" PropertyName="ErrorCode" />
    </Exec>
    <Error Condition="'$(ErrorCode)' != '0'" Text="Node.js is required to build and run this project. To continue, please install Node.js from https://nodejs.org/, and then restart your command prompt or IDE." />
    <Message Importance="high" Text="Restoring dependencies using 'npm'. This may take several minutes..." />
    <Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
  </Target>

  <Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
    <!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
    <Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
    <Exec WorkingDirectory="$(SpaRoot)" Command="npm run build" />

    <!-- Include the newly-built files in the publish output -->
    <ItemGroup>
      <DistFiles Include="$(SpaRoot)build\**" />
      <ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
        <RelativePath>wwwroot\%(RecursiveDir)%(FileName)%(Extension)</RelativePath>
        <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
        <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
      </ResolvedFileToPublish>
    </ItemGroup>
  </Target>
</Project>

开发单页应用

项目文件定义了一些属性,用于控制开发过程中应用的行为:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net7.0</TargetFramework>
    <Nullable>enable</Nullable>
    <TypeScriptCompileBlocked>true</TypeScriptCompileBlocked>
    <TypeScriptToolsVersion>Latest</TypeScriptToolsVersion>
    <IsPackable>false</IsPackable>
    <SpaRoot>ClientApp\</SpaRoot>
    <DefaultItemExcludes>$(DefaultItemExcludes);$(SpaRoot)node_modules\**</DefaultItemExcludes>
    <SpaProxyServerUrl>https://localhost:44414</SpaProxyServerUrl>
    <SpaProxyLaunchCommand>npm start</SpaProxyLaunchCommand>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.SpaProxy" Version="7.0.1" />
  </ItemGroup>

  <ItemGroup>
    <!-- Don't publish the SPA source files, but do show them in the project files list -->
    <Content Remove="$(SpaRoot)**" />
    <None Remove="$(SpaRoot)**" />
    <None Include="$(SpaRoot)**" Exclude="$(SpaRoot)node_modules\**" />
  </ItemGroup>

  <Target Name="DebugEnsureNodeEnv" BeforeTargets="Build" Condition=" '$(Configuration)' == 'Debug' And !Exists('$(SpaRoot)node_modules') ">
    <!-- Ensure Node.js is installed -->
    <Exec Command="node --version" ContinueOnError="true">
      <Output TaskParameter="ExitCode" PropertyName="ErrorCode" />
    </Exec>
    <Error Condition="'$(ErrorCode)' != '0'" Text="Node.js is required to build and run this project. To continue, please install Node.js from https://nodejs.org/, and then restart your command prompt or IDE." />
    <Message Importance="high" Text="Restoring dependencies using 'npm'. This may take several minutes..." />
    <Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
  </Target>

  <Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
    <!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
    <Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
    <Exec WorkingDirectory="$(SpaRoot)" Command="npm run build" />

    <!-- Include the newly-built files in the publish output -->
    <ItemGroup>
      <DistFiles Include="$(SpaRoot)build\**" />
      <ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
        <RelativePath>wwwroot\%(RecursiveDir)%(FileName)%(Extension)</RelativePath>
        <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
        <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
      </ResolvedFileToPublish>
    </ItemGroup>
  </Target>
</Project>
  • SpaProxyServerUrl:控制服务器希望运行 SPA 代理的 URL。 这是 URL:
    • 服务器在启动代理后执行 ping 操作,以了解代理是否已准备就绪。
    • 成功响应后重定向浏览器的位置。
  • SpaProxyLaunchCommand:服务器在检测到代理未运行时用于启动 SPA 代理的命令。

Microsoft.AspNetCore.SpaProxy 包负责上述检测代理并重定向浏览器的逻辑。

Properties/launchSettings.json 中定义的托管启动程序集用于在开发期间自动添加所需的组件,以检测代理是否正在运行并以其他方式启动:

{
  "iisSettings": {
    "windowsAuthentication": false,
    "anonymousAuthentication": true,
    "iisExpress": {
      "applicationUrl": "http://localhost:51783",
      "sslPort": 44329
    }
  },
  "profiles": {
    "MyReact": {
      "commandName": "Project",
      "launchBrowser": true,
      "applicationUrl": "https://localhost:7145;http://localhost:5273",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development",
        "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy"
      }
    },
    "IIS Express": {
      "commandName": "IISExpress",
      "launchBrowser": true,
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development",
        "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy"
      }
    }
  }
}

客户端应用设置

此设置特定于应用使用的前端框架,但配置的许多方面是相似的。

Angular 设置

模板生成的 ClientApp/package.json 文件:

{
  "name": "myangular",
  "version": "0.0.0",
  "scripts": {
    "ng": "ng",
    "prestart": "node aspnetcore-https",
    "start": "run-script-os",
    "start:windows": "ng serve --port 44483 --ssl --ssl-cert \"%APPDATA%\\ASP.NET\\https\\%npm_package_name%.pem\" --ssl-key \"%APPDATA%\\ASP.NET\\https\\%npm_package_name%.key\"",
    "start:default": "ng serve --port 44483 --ssl --ssl-cert \"$HOME/.aspnet/https/${npm_package_name}.pem\" --ssl-key \"$HOME/.aspnet/https/${npm_package_name}.key\"",
    "build": "ng build",
    "build:ssr": "ng run MyAngular:server:dev",
    "watch": "ng build --watch --configuration development",
    "test": "ng test"
  },
  "private": true,
  "dependencies": {
    "@angular/animations": "^14.1.3",
    "@angular/common": "^14.1.3",
    "@angular/compiler": "^14.1.3",
    "@angular/core": "^14.1.3",
    "@angular/forms": "^14.1.3",
    "@angular/platform-browser": "^14.1.3",
    "@angular/platform-browser-dynamic": "^14.1.3",
    "@angular/platform-server": "^14.1.3",
    "@angular/router": "^14.1.3",
    "bootstrap": "^5.2.0",
    "jquery": "^3.6.0",
    "oidc-client": "^1.11.5",
    "popper.js": "^1.16.0",
    "run-script-os": "^1.1.6",
    "rxjs": "~7.5.6",
    "tslib": "^2.4.0",
    "zone.js": "~0.11.8"
  },
  "devDependencies": {
    "@angular-devkit/build-angular": "^14.1.3",
    "@angular/cli": "^14.1.3",
    "@angular/compiler-cli": "^14.1.3",
    "@types/jasmine": "~4.3.0",
    "@types/jasminewd2": "~2.0.10",
    "@types/node": "^18.7.11",
    "jasmine-core": "~4.3.0",
    "karma": "~6.4.0",
    "karma-chrome-launcher": "~3.1.1",
    "karma-coverage": "~2.2.0",
    "karma-jasmine": "~5.1.0",
    "karma-jasmine-html-reporter": "^2.0.0",
    "typescript": "~4.7.4"
  },
  "overrides": {
    "autoprefixer": "10.4.5"
  },
  "optionalDependencies": {}
}
  • 包含启动 angular 开发服务器的脚本:

  • prestart 脚本调用 ClientApp/aspnetcore-https.js,它负责确保开发服务器 HTTPS 证书可供 SPA 代理服务器使用。

  • start:windowsstart:default

    • 通过 ng serve 启动 Angular 开发服务器。
    • 提供端口、使用 HTTPS 的选项,以及证书路径和关联密钥。 提供的端口号与 .csproj 文件中指定的端口号匹配。

模板生成的 ClientApp/angular.json 文件包含:

  • serve 命令。

  • development 配置中的 proxyconfig 元素,用于指示 proxy.conf.js 应用于配置前端代理,如以下突出显示的 JSON 所示:

    {
      "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
      "version": 1,
      "newProjectRoot": "projects",
      "projects": {
        "MyAngular": {
          "projectType": "application",
          "schematics": {
            "@schematics/angular:application": {
              "strict": true
            }
          },
          "root": "",
          "sourceRoot": "src",
          "prefix": "app",
          "architect": {
            "build": {
              "builder": "@angular-devkit/build-angular:browser",
              "options": {
                "progress": false,
                "outputPath": "dist",
                "index": "src/index.html",
                "main": "src/main.ts",
                "polyfills": "src/polyfills.ts",
                "tsConfig": "tsconfig.app.json",
                "allowedCommonJsDependencies": [
                  "oidc-client"
                ],
                "assets": [
                  "src/assets"
                ],
                "styles": [
                  "node_modules/bootstrap/dist/css/bootstrap.min.css",
                  "src/styles.css"
                ],
                "scripts": []
              },
              "configurations": {
                "production": {
                  "budgets": [
                    {
                      "type": "initial",
                      "maximumWarning": "500kb",
                      "maximumError": "1mb"
                    },
                    {
                      "type": "anyComponentStyle",
                      "maximumWarning": "2kb",
                      "maximumError": "4kb"
                    }
                  ],
                  "fileReplacements": [
                    {
                      "replace": "src/environments/environment.ts",
                      "with": "src/environments/environment.prod.ts"
                    }
                  ],
                  "outputHashing": "all"
                },
                "development": {
                  "buildOptimizer": false,
                  "optimization": false,
                  "vendorChunk": true,
                  "extractLicenses": false,
                  "sourceMap": true,
                  "namedChunks": true
                }
              },
              "defaultConfiguration": "production"
            },
            "serve": {
              "builder": "@angular-devkit/build-angular:dev-server",
              "configurations": {
                "production": {
                  "browserTarget": "MyAngular:build:production"
                },
                "development": {
                  "browserTarget": "MyAngular:build:development",
                  "proxyConfig": "proxy.conf.js"
                }
              },
              "defaultConfiguration": "development"
            },
            "extract-i18n": {
              "builder": "@angular-devkit/build-angular:extract-i18n",
              "options": {
                "browserTarget": "MyAngular:build"
              }
            },
            "test": {
              "builder": "@angular-devkit/build-angular:karma",
              "options": {
                "main": "src/test.ts",
                "polyfills": "src/polyfills.ts",
                "tsConfig": "tsconfig.spec.json",
                "karmaConfig": "karma.conf.js",
                "assets": [
                  "src/assets"
                ],
                "styles": [
                  "src/styles.css"
                ],
                "scripts": []
              }
            },
            "server": {
              "builder": "@angular-devkit/build-angular:server",
              "options": {
                "outputPath": "dist-server",
                "main": "src/main.ts",
                "tsConfig": "tsconfig.server.json"
              },
              "configurations": {
                "dev": {
                  "optimization": true,
                  "outputHashing": "all",
                  "sourceMap": false,
                  "namedChunks": false,
                  "extractLicenses": true,
                  "vendorChunk": true
                },
                "production": {
                  "optimization": true,
                  "outputHashing": "all",
                  "sourceMap": false,
                  "namedChunks": false,
                  "extractLicenses": true,
                  "vendorChunk": false
                }
              }
            }
          }
        }
      },
      "defaultProject": "MyAngular"
    }
    

ClientApp/proxy.conf.js 定义需要代理回服务器后端的路由。 http-proxy-middleware 中定义了用于 react 和 angular 的常规选项集,因为它们都使用同一代理。

下面 ClientApp/proxy.conf.js 中突出显示的代码使用基于开发期间设置的环境变量的逻辑来确定后端运行的端口:

const { env } = require('process');

const target = env.ASPNETCORE_HTTPS_PORTS ? `https://localhost:${env.ASPNETCORE_HTTPS_PORTS}` :
  env.ASPNETCORE_URLS ? env.ASPNETCORE_URLS.split(';')[0] : 'http://localhost:51951';

const PROXY_CONFIG = [
  {
    context: [
      "/weatherforecast",
   ],
    target: target,
    secure: false,
    headers: {
      Connection: 'Keep-Alive'
    }
  }
]

module.exports = PROXY_CONFIG;

React 设置

  • package.json 脚本部分包含以下脚本,用于在开发过程中启动 react 应用,如以下突出显示的代码所示:

    {
      "name": "myreact",
      "version": "0.1.0",
      "private": true,
      "dependencies": {
        "bootstrap": "^5.2.0",
        "http-proxy-middleware": "^2.0.6",
        "jquery": "^3.6.0",
        "merge": "^2.1.1",
        "oidc-client": "^1.11.5",
        "react": "^18.2.0",
        "react-dom": "^18.2.0",
        "react-router-bootstrap": "^0.26.2",
        "react-router-dom": "^6.3.0",
        "react-scripts": "^5.0.1",
        "reactstrap": "^9.1.3",
        "rimraf": "^3.0.2",
        "web-vitals": "^2.1.4",
        "workbox-background-sync": "^6.5.4",
        "workbox-broadcast-update": "^6.5.4",
        "workbox-cacheable-response": "^6.5.4",
        "workbox-core": "^6.5.4",
        "workbox-expiration": "^6.5.4",
        "workbox-google-analytics": "^6.5.4",
        "workbox-navigation-preload": "^6.5.4",
        "workbox-precaching": "^6.5.4",
        "workbox-range-requests": "^6.5.4",
        "workbox-routing": "^6.5.4",
        "workbox-strategies": "^6.5.4",
        "workbox-streams": "^6.5.4"
      },
      "devDependencies": {
        "ajv": "^8.11.0",
        "cross-env": "^7.0.3",
        "eslint": "^8.22.0",
        "eslint-config-react-app": "^7.0.1",
        "eslint-plugin-flowtype": "^8.0.3",
        "eslint-plugin-import": "^2.26.0",
        "eslint-plugin-jsx-a11y": "^6.6.1",
        "eslint-plugin-react": "^7.30.1",
        "nan": "^2.16.0",
        "typescript": "^4.7.4"
      },
      "overrides": {
        "autoprefixer": "10.4.5"
      },
      "resolutions": {
        "css-what": "^5.0.1",
        "nth-check": "^3.0.1"
      },
      "scripts": {
        "prestart": "node aspnetcore-https && node aspnetcore-react",
        "start": "rimraf ./build && react-scripts start",
        "build": "react-scripts build",
        "test": "cross-env CI=true react-scripts test --env=jsdom",
        "eject": "react-scripts eject",
        "lint": "eslint ./src/"
      },
      "eslintConfig": {
        "extends": [
          "react-app"
        ]
      },
      "browserslist": {
        "production": [
          ">0.2%",
          "not dead",
          "not op_mini all"
        ],
        "development": [
          "last 1 chrome version",
          "last 1 firefox version",
          "last 1 safari version"
        ]
      }
    }
    
  • prestart 脚本调用:

    • aspnetcore-https.js,它负责确保开发服务器 HTTPS 证书可供 SPA 代理服务器使用。
    • 调用 aspnetcore-react.js 来设置相应的 .env.development.local 文件,以使用 HTTPS 本地开发证书。 aspnetcore-react.js 通过向文件添加 SSL_CRT_FILE=<certificate-path>SSL_KEY_FILE=<key-path> 来配置 HTTPS 本地开发证书。
  • .env.development 文件定义开发服务器端口并指定 HTTPS。

src/setupProxy.js 将 SPA 代理配置为将请求转发到后端。 常规选项集在 http-proxy-middleware 中定义。

下面 ClientApp/src/setupProxy.js 中突出显示的代码使用基于开发期间设置的环境变量的逻辑来确定后端运行的端口:

const { createProxyMiddleware } = require('http-proxy-middleware');
const { env } = require('process');

const target = env.ASPNETCORE_HTTPS_PORTS ? `https://localhost:${env.ASPNETCORE_HTTPS_PORTS}` :
  env.ASPNETCORE_URLS ? env.ASPNETCORE_URLS.split(';')[0] : 'http://localhost:51783';

const context = [
  "/weatherforecast",
];

const onError = (err, req, resp, target) => {
    console.error(`${err.message}`);
}

module.exports = function (app) {
  const appProxy = createProxyMiddleware(context, {
    target: target,
    // Handle errors to prevent the proxy middleware from crashing when
    // the ASP NET Core webserver is unavailable
    onError: onError,
    secure: false,
    // Uncomment this line to add support for proxying websockets
    //ws: true, 
    headers: {
      Connection: 'Keep-Alive'
    }
  });

  app.use(appProxy);
};

ASP.NET Core SPA 模板中支持的 SPA 框架版本

每个 ASP.NET Core 版本随附的 SPA 项目模板都引用相应 SPA 框架的最新版本。

SPA 框架的发布周期通常比 .NET 要短。 由于这两个发布周期不同,受支持版本的 SPA 框架和 .NET 可能会不同步:.NET 主版本所依赖的主 SPA 框架版本可能会停止支持,而 SPA 框架随附的 .NET 版本仍受支持。

ASP.NET Core SPA 模板可以在修补程序版本中更新为新的 SPA 框架版本,从而使模板保持受支持和安全的状态。

其他资源