前端托管
本页是 ClientApp 和多前端入口托管的主题页,聚合目录、路径、缓存和后端保护约定。正式规则请优先阅读 框架规范 / 前端项目规范 和 框架规范 / 前端对接规范。
适用场景
- 与后端一起部署一个或多个前端应用。
- 需要通过 ASP.NET Core 中间件统一托管静态资源与默认页。
- 需要在前端入口前后插入鉴权、缓存或网关逻辑。
必须遵守
- 所有前端构建产物都位于明确的
RootPath下。 - 每个应用显式声明
PublicPath、DistPath,且路径以/开头。 UseClientApp()放在明确的请求管道位置。- 敏感前端入口的保护由后端或网关负责,不依赖前端路由。
- 新前端项目的样式栈、图标库、主题模式和项目级包源默认值必须与 前端项目规范 保持一致。
- 前端对接的 JSON 字段和时间格式必须与正式前端契约规范保持一致。
- 缓存策略以当前框架实现为准,不把默认行为误写成长期缓存。
UseClientApp()前后涉及的认证、审计和反向代理规则必须在文档中写清楚。
推荐做法
- 每个前端应用使用独立
PublicPath。 - 发布目录按应用划分,例如
ClientApp/Admin、ClientApp/Portal。 - 需要登录保护的入口,在
UseClientApp()前后明确插入认证授权或网关保护。 - 统一通过反向代理暴露外部路径,不在构建产物里硬编码运行环境 URL。
- 本地联调用开发代理,生产环境只保留发布目录和路径映射。
- 前端构建工具的
base与ClientAppConfig.Create()的PublicPath保持一致。 Browser Route明确配置basename;HashRoute明确配置 hash 模式,不把两种路由混在一起。
典型目录结构
apps/WebSite
├─ ClientApp
│ ├─ Portal
│ │ ├─ index.html
│ │ └─ assets/...
│ ├─ Admin
│ │ ├─ index.html
│ │ └─ assets/...
│ └─ HelpDocs
│ ├─ index.html
│ └─ assets/...
└─ Program.cs
RootPath对应ClientApp。DistPath对应Portal、Admin、HelpDocs这类子目录。PublicPath对应浏览器访问路径,例如/portal、/admin、/help。
更完整的混合仓库结构通常会拆成“前端源码目录”和“宿主发布目录”两层:
repo-root
├─ apps
│ ├─ webapi
│ │ ├─ Program.cs
│ │ ├─ ClientApp
│ │ │ ├─ Portal
│ │ │ ├─ Admin
│ │ │ └─ HelpDocs
│ │ └─ Infrastructure
│ │ └─ Hosting
│ │ └─ Docker
│ ├─ portalweb
│ │ ├─ src
│ │ └─ dist
│ ├─ adminweb
│ │ ├─ src
│ │ └─ dist
│ └─ helpdocsweb
│ ├─ src
│ └─ dist
└─ scripts
└─ frontend
├─ sync-dist-to-clientapp.mjs
└─ sync-all-apps-to-publish.sh
apps/portalweb、apps/adminweb这类目录负责前端源码和本地构建。apps/webapi/ClientApp/...负责本地开发或直接托管场景下的前端发布目录。apps/webapi/Infrastructure/Hosting/Docker/publish/ClientApp/...负责 CI 中dotnet publish后的最终打包目录。- 多前端项目建议统一用脚本把
dist同步到ClientApp,不要在 CI 里手写多段重复cp。
示例代码
示例一:单前端入口,使用 Browser Route
后端注册:
// Program.cs / Startup.cs
services.AddClientApp(options =>
{
options.RootPath = "ClientApp";
options.ClientApps.Add(ClientAppConfig.Create("/portal", "Portal"));
});
app.UseStaticFiles();
app.UseClientApp();
前端构建配置:
// vite.config.ts
import { defineConfig } from "vite";
export default defineConfig({
base: "/portal/",
build: {
outDir: "dist"
}
});
// React Router 示例
import { BrowserRouter } from "react-router-dom";
export function App() {
return (
<BrowserRouter basename="/portal">
{/* routes */}
</BrowserRouter>
);
}
适用说明:
- 访问地址通常是
/portal/、/portal/orders/42。 Browser Route依赖后端在未命中静态资源时回退到index.html,因此更要保证UseClientApp()放在合适的位置。- 当前框架会把
/portal自动 301 到/portal/,可以减少相对资源路径出错。
示例二:同一宿主托管多个前端项目
后端注册多个 ClientApp:
services.AddClientApp(options =>
{
options.RootPath = "ClientApp";
options.ClientApps.Add(ClientAppConfig.Create("/portal", "Portal"));
options.ClientApps.Add(ClientAppConfig.Create("/admin", "Admin"));
options.ClientApps.Add(ClientAppConfig.Create("/help", "HelpDocs"));
});
app.UseStaticFiles();
app.UseClientApp();
前端发布产物落位:
ClientApp/
├─ Portal/ <- 对外路径 /portal
├─ Admin/ <- 对外路径 /admin
└─ HelpDocs/ <- 对外路径 /help
构建结果同步示例:
bash tutorials/samples/fullstack-starter/scripts/sync-clientapp.sh \
apps/portal/dist \
apps/api/ClientApp/Portal
bash tutorials/samples/fullstack-starter/scripts/sync-clientapp.sh \
apps/admin/dist \
apps/api/ClientApp/Admin
适用说明:
- 多个前端项目不要共用同一个
DistPath。 - 每个前端项目都应有自己独立的
base,例如/portal/、/admin/。 - 如果
/admin需要登录保护,保护逻辑仍然应该放在后端或网关,不要只依赖前端路由守卫。
如果你希望源码目录和宿主目录分开,比较推荐下面这种对应关系:
apps/portalweb/dist -> apps/webapi/ClientApp/Portal
apps/adminweb/dist -> apps/webapi/ClientApp/Admin
apps/helpdocsweb/dist -> apps/webapi/ClientApp/HelpDocs
在 CI 的发布阶段,再同步到最终发布目录:
apps/webapi/Infrastructure/Hosting/Docker/publish/ClientApp/Portal
apps/webapi/Infrastructure/Hosting/Docker/publish/ClientApp/Admin
apps/webapi/Infrastructure/Hosting/Docker/publish/ClientApp/HelpDocs
示例三:单前端入口,使用 HashRoute
后端注册方式与 Browser Route 一样,只是前端路由模式改成 hash:
services.AddClientApp(options =>
{
options.RootPath = "ClientApp";
options.ClientApps.Add(ClientAppConfig.Create("/help", "HelpDocs"));
});
// vite.config.ts
import { defineConfig } from "vite";
export default defineConfig({
base: "/help/",
build: {
outDir: "dist"
}
});
// React Router 示例
import { HashRouter } from "react-router-dom";
export function App() {
return (
<HashRouter>
{/* routes */}
</HashRouter>
);
}
适用说明:
- 访问地址通常是
/help/#/getting-started。 HashRoute的路由片段在#后面,后端主要处理/help/这一层入口。- 即便使用
HashRoute,静态资源仍然建议通过base: "/help/"固定到正确子路径下。
示例四:通过请求管道集中注册
如果项目使用 IRequestPiplineRegister 统一组织中间件,可以把前端托管作为一个明确的注册项:
public class AppConfigure : IRequestPiplineRegister
{
public RequestPiplineCollection Configure(
RequestPiplineCollection pipelines,
AppBuildContext context)
{
pipelines.Register("staticfiles-and-clientapp", RequestPipelineStage.BeforeRouting, app =>
{
app.UseStaticFiles();
app.UseClientApp();
}, stageOrder: 10);
return pipelines;
}
}
适用说明:
- 这个写法适合把
UseClientApp()的位置固定到可审查、可排序的请求阶段里。 - 如果项目里还有自定义静态资源、审计或网关逻辑,最好也一起写成命名明确的注册项。
CI 配置方式
多前端项目的 CI 推荐拆成四段:
- 前置环境准备。
- 多个前端项目并行构建。
- 后端执行
dotnet publish。 - 把所有前端
dist汇总到发布目录中的ClientApp,再做镜像打包和部署。
Drone 流水线示例
下面这个结构参考了真实项目里常见的 .drone.yml 写法,核心是“前端分开构建,最后统一汇总”:
kind: pipeline
name: build-pipeline
type: exec
steps:
- name: docker-config
commands:
- mkdir -p ~/.docker
- cp /root/.docker/config.json ~/.docker/config.json
- cp -R /root/.kube ~/.kube
- docker login hub.feinian.net
- name: build portalweb
depends_on:
- docker-config
commands:
- docker run -t --rm -v `pwd`:/workdir -v /data/build_share/pnpm_store:/pnpm_store \
--workdir /workdir/apps/portalweb hub.feinian.net/build/node:24-alpine \
sh -c "npm install -g pnpm && pnpm install --store=/pnpm_store && pnpm run build"
- name: build adminweb
depends_on:
- docker-config
commands:
- docker run -t --rm -v `pwd`:/workdir -v /data/build_share/pnpm_store:/pnpm_store \
--workdir /workdir/apps/adminweb hub.feinian.net/build/node:24-alpine \
sh -c "npm install -g pnpm && pnpm install --store=/pnpm_store && pnpm run typecheck && pnpm run build"
- name: build helpdocsweb
depends_on:
- docker-config
commands:
- docker run -t --rm -v `pwd`:/workdir -v /data/build_share/pnpm_store:/pnpm_store \
--workdir /workdir/apps/helpdocsweb hub.feinian.net/build/node:24-alpine \
sh -c "npm install -g pnpm && pnpm install --store=/pnpm_store && pnpm run build"
- name: publish backend
depends_on:
- docker-config
commands:
- docker run -t --rm -v /data/build_share/dotnet:/root/.dotnet -v /data/build_share/nuget:/root/.nuget -v `pwd`:/workdir \
--workdir /workdir hub.feinian.net/dotnet/sdk:10.0 \
bash -c "dotnet publish apps/webapi/WebApi.Host.csproj -c Release -o ./apps/webapi/Infrastructure/Hosting/Docker/publish"
- name: sync client apps
depends_on:
- publish backend
- build portalweb
- build adminweb
- build helpdocsweb
commands:
- bash ./scripts/frontend/sync-all-apps-to-publish.sh ./apps/webapi/Infrastructure/Hosting/Docker/publish
- name: package and deploy
depends_on:
- sync client apps
commands:
- docker build -t hub.feinian.net/demo/webapi:$IMAGE_VERSION \
--file `pwd`/apps/webapi/Infrastructure/Hosting/Dockerfile \
`pwd`/apps/webapi/Infrastructure/Hosting/Docker/publish
- docker push hub.feinian.net/demo/webapi:$IMAGE_VERSION
适用说明:
- 前端步骤只负责生成各自的
dist,不要在这些步骤里直接复制到发布目录。 publish backend先产出统一发布根目录,后续汇总步骤只对这个目录做增量补充。sync client apps是整条链路的关键收口点,最适合统一处理ClientApp目录结构。- 如果有多个前端项目,建议像上面这样拆成多个步骤并行执行,而不是串成一个长步骤。
汇总脚本示例
CI 里最好使用单独脚本把多个 dist 同步到发布目录,而不是在 Drone YAML 中重复写很多 cp 命令:
#!/usr/bin/env bash
set -euo pipefail
publish_root="${1:-./apps/webapi/Infrastructure/Hosting/Docker/publish}"
client_app_root="$publish_root/ClientApp"
sync_app() {
local source_dir="$1"
local target_dir="$2"
rm -rf "$target_dir"
mkdir -p "$target_dir"
cp -R "$source_dir"/. "$target_dir"/
}
sync_app "./apps/portalweb/dist" "$client_app_root/Portal"
sync_app "./apps/adminweb/dist" "$client_app_root/Admin"
sync_app "./apps/helpdocsweb/dist" "$client_app_root/HelpDocs"
适用说明:
- 这个脚本模式和真实项目里
sync-all-apps-to-publish.sh的职责一致。 - 如果项目还有额外静态资源汇总,例如门户公共素材、git 快照或诊断文件,也应该在这个脚本里集中处理。
- 文档页里只保留脚本骨架;具体镜像仓库地址、Kubernetes 命名空间和部署命令应写在项目自己的 CI 文档里。
常见坑
- 多个前端构建产物铺在同一目录。
PublicPath用相对路径。UseClientApp()放在会吞掉 API 请求的位置。- 认为“前端有登录页”就等于入口安全。
- 前端构建时忘记设置
base,导致/assets/...指向站点根路径。 Browser Route没有配置basename,导致子路径部署后路由跳转错误。- 以为
HashRoute不需要配置PublicPath,结果静态资源仍然从错误目录加载。 - 在 CI 的前端构建步骤里直接改发布目录,导致并行步骤互相覆盖。
dotnet publish和前端产物汇总顺序混乱,最后镜像里缺少ClientApp目录。- 在 Drone YAML 中手写大量重复复制逻辑,后续增加一个前端入口就要改很多地方。
示例与落地对照
gmandarin-backend:同一站点托管多个前端入口,并与自定义UseStaticFiles(...)缓存策略并存。