三种运行形态

同一份 razor 跑成 WebAssembly 纯前端、Server 一体托管、Hybrid WinForms 桌面三种宿主——怎么选、怎么装、坑在哪。

最后更新

Blazor 有三种把同一份 razor 跑起来的宿主形态,Known 对每种都给了同一套组件、同一个 App.razor,只换引导代码。这一页一次讲完:先看决策表选形态,再逐种给装配骨架与常见坑。

如果你只想"五分钟把 Known 装进任意 Blazor 项目",先读五分钟跑起来;这一页补的是"三种形态分别怎么选、怎么装、为什么一份 razor 到处能跑"。

一、怎么选(决策表)

维度 WebAssembly 纯前端 Server 一体托管 Hybrid WinForms 桌面
仓库工程 KnownWasm KnownWeb(内含 KnownWasm) KnownWin
csproj SDK ...Sdk.BlazorWebAssembly ...Sdk.Web ...Sdk.Razor + WinExe
TFM net10.0 net10.0 net10.0-windows
渲染进程 浏览器 浏览器(客户端)+ ASP.NET Core(接口) 桌面进程内 WebView
引导脚本 blazor.webassembly.js blazor.webassembly.js blazor.webview.js
应用声明 AddKnownApp AddKnownApp AddKnownApp
形态入口 AddKnownClient AddKnownWeb AddKnownDesktop
AppType Web Web Desktop
接口走 HTTP? 是(打外部 API) 是(同源调用) 否,进程内直调
数据库 无(本进程不含) SQLite(同进程服务端) SQLite 本地 .db 文件
交付形态 浏览器 / PWA 站点(一进程前后端一体) 原生 exe

一句话决策:要不要独立后端(不独立 → Server) / 要不要离线(离线 → WASM+PWA 或 Hybrid) / 要不要桌面壳或本地硬件(要 → Hybrid)。三种模式共享同一份 razor、同一个 App.razor、同一套组件,只有引导代码不同。

二、为什么同一份 razor 到处能跑

宿主无关不是魔法,是三层约定叠出来的:

  • 根组件靠反射装配,不认宿主。App.razor 只有 <Router AppAssembly="Config.App.Assembly" AdditionalAssemblies="Config.Assemblies"> 和 <Container />——它扫的是"程序集里带 @page 的组件",不关心这个程序集被浏览器、被 WebView、还是被 Kestrel 加载。换宿主只是把 App 挂到不同的挂载点(#app / head::after / WinForms 的 #app)。
  • 接口是契约,传输可换。业务代码只 @inject 一个 [WebApi] 接口,具体实现由装配入口决定:WASM 用 AddKnownClient 生成 HTTP 版 ServiceProxy<T>;Server 用 AddKnownWeb 把同一批接口注册成端点;桌面用 AddKnownDesktop 直接把接口绑到本地 [Service] 实现(进程内方法调用)。同一句 _userService.GetById(id),一个发请求、一个调函数,razor 不改一行。详见 前后端契约。
  • 静态资源走 _content/Known。RCL 把 css/web.css、js/web.js、js/libs/echarts.js 自动暴露成 _content/Known/... 虚拟路径,三种宿主的 index.html 引的是同一串 URL。

AddKnown / AddKnownClient / AddKnownWeb / AddKnownDesktop 的职责边界见应用装配;Config.AddModule 如何把整个程序集的 [WebApi] / [Service] / @page 扫进路由见模块与插件式装配。


三、形态一:WebAssembly 纯前端宿主

对应仓库里的 Known/KnownWasm。

适用场景

  • 前端完全跑在浏览器里,后端只提供 REST API(前后端分离)。
  • 静态站 / CDN 直出、离线 PWA(见 PWA / 离线)。
  • 纯前端进程:[Service] 服务端实现不在这个进程里,所有数据访问都经 HttpClient 打到外部 API。

想一键跑通"前端 + 接口 + 数据库"直接用下一节的 Server 一体宿主——它就是本篇的 WASM 客户端挂进一个 ASP.NET Core 进程。

工程文件 KnownWasm.csproj

SDK 是 Microsoft.NET.Sdk.BlazorWebAssembly,只引一个共享的业务程序集 KnownApp(它才是引用 Known RCL、并暴露 AddKnownApp 的地方):

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

	<PropertyGroup>
		<TargetFramework>net10.0</TargetFramework>
		<ImplicitUsings>enable</ImplicitUsings>
	</PropertyGroup>

	<ItemGroup>
		<SupportedPlatform Include="browser" />
		<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.*" />
		<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.*" PrivateAssets="all" />
		<ProjectReference Include="..\KnownApp\KnownApp.csproj" />
	</ItemGroup>

</Project>

入口 Program.cs

WebAssemblyHostBuilder 起手,App 挂到 #app,装配只两步——AddKnownApp(声明"我这个应用":元信息 + 模块扫描 + 菜单分组)和 AddKnownClient(把 [WebApi] 接口编译成走 HTTP 的 ServiceProxy<T>):

using Known;
using KnownApp;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;

var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");

builder.Services.AddKnownApp(AppType.Web);
builder.Services.AddKnownClient(option =>
{
    option.BaseAddress = builder.HostEnvironment.BaseAddress;
});

await builder.Build().RunAsync();

关键差异就在 AddKnownClient:它注册一个 BaseAddress 结尾不带斜杠的 HttpClient,并为每个 [WebApi] 接口生成 ServiceProxy<T>——调用接口方法 = 发一个 HTTP 请求。这里用 builder.HostEnvironment.BaseAddress 取当前站点根作为基地址(前后端同源部署时最省心);若接口挂在独立域,就把它换成那个站点根,仍约定结尾不带斜杠。

宿主页 wwwroot/index.html

WASM 宿主由这段 HTML 里的 _framework/blazor.webassembly.js 引导。Known 的样式 / 脚本全部来自 RCL 自动输出的 _content/Known/...,引一次即得:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Known信息管理系统</title>
    <base href="/" />
    <link rel="stylesheet" href="_content/Known/css/web.css" />
</head>
<body>
    <div id="app">
        <svg class="loading-progress">
            <circle r="40%" cx="50%" cy="50%" />
            <circle r="40%" cx="50%" cy="50%" />
        </svg>
        <div class="loading-progress-text"></div>
    </div>
    <script src="_content/Known/js/libs/echarts.js"></script>
    <script src="_content/Known/js/web.js"></script>
    <script src="_framework/blazor.webassembly.js"></script>
</body>
</html>

常见坑

  • BaseAddress 结尾多写了 /:拼 URL 出现 // 段,ServiceProxy 404。约定结尾不带斜杠。
  • 纯 WASM 里没有后端:KnownWasm 单跑时无人应答接口调用,全 404。要么连一个独立的 API 服务(就是下一节 Server 形态的接口面),要么直接用 Server 一体托管。
  • 别在 WASM 里调 AddKnownWeb:那是服务端进程入口,客户端进程调用会抛异常。

四、形态二:全栈 Server 一体宿主

对应仓库里的 Known/KnownWeb。这是一个 ASP.NET Core(Microsoft.NET.Sdk.Web)全栈进程:它同时做两件事——把上一节的 WASM 客户端当静态资源 serve 出去,又在同一个进程里用 AddKnownWeb 把 [WebApi] 接口注册成 Minimal API 端点、绑定 [Service] 实现、连 SQLite。

术语澄清:这不是 Blazor Server(没有 AddRazorComponents / MapRazorComponents / SignalR circuit)。razor 仍然编译进 WASM、仍在浏览器里渲染,只是后端接口和数据库被搬进了同一个 ASP.NET Core 进程——即经典 "Blazor WebAssembly hosted(一体托管)" 模式。Known 目前落地的 Server 形态就是这种;组件宿主无关(不依赖 HttpContext),理论上也能挂 Blazor Server 的 InteractiveServer,但仓库里没有该工程。

适用场景

  • 一个进程、一条 dotnet run、一个端口就把"前端 + 接口 + 库"跑起来(单体起步、内网系统、工业现场单机服务)。
  • 需要 Cookie 会话、同源部署,省掉跨域配置。
  • 客户端仍要拿到 WASM 的离线 / CDN 分发能力,但服务端逻辑不想单拆一个 API 工程。

工程文件 KnownWeb.csproj

Sdk.Web + WebAssembly.Server(提供 UseBlazorFrameworkFiles)+ Microsoft.Data.Sqlite,并直接引用 KnownWasm 工程——WASM 客户端就是被它托管的:

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

	<PropertyGroup>
		<TargetFramework>net10.0</TargetFramework>
		<ImplicitUsings>enable</ImplicitUsings>
		<ApplicationIcon>favicon.ico</ApplicationIcon>
	</PropertyGroup>

	<ItemGroup>
		<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.*" />
		<PackageReference Include="Microsoft.Data.Sqlite" Version="10.*" />
		<ProjectReference Include="..\KnownWasm\KnownWasm.csproj" />
	</ItemGroup>

</Project>

入口 Program.cs

using KnownWeb;

var builder = WebApplication.CreateBuilder(args);
builder.AddApplication();

var app = builder.Build();

if (!app.Environment.IsDevelopment())
    app.UseHsts();
else
    app.UseWebAssemblyDebugging();

app.UseHttpsRedirection();
app.UseBlazorFrameworkFiles();
app.UseApplication();
app.UseRouting();

app.MapFallbackToFile("index.html");
app.Run();

UseBlazorFrameworkFiles() 让被引用的 KnownWasm 客户端以 _framework/... 形式对外可见,MapFallbackToFile("index.html") 把 SPA 路由兜底到 WASM 的 index.html——浏览器加载的就是上一节那套 WASM 客户端。

服务端装配 AppServer.cs(AddApplication)

一次调用把 AddKnownWeb(接口 + 实现 + 数据库)和 Cookie 鉴权都装上:

public static void AddApplication(this WebApplicationBuilder builder)
{
    builder.Services.AddKnownApp(AppType.Web);
    builder.Services.AddKnownWeb(option =>
    {
        option.WebRoot = builder.Environment.WebRootPath;
        option.ContentRoot = builder.Environment.ContentRootPath;
        option.Assembly = typeof(AppServer).Assembly;
        option.Database = db =>
        {
            var connString = builder.Configuration.GetSection("ConnString").Get<string>();
            db.AddSQLite<Microsoft.Data.Sqlite.SqliteFactory>(connString);
        };
        // 文件日志:按天 / 单文件大小滚动写入 ContentRoot/Logs,多实例共享同一文件也安全
        option.UseFileLogger(Path.Combine(builder.Environment.ContentRootPath, "Logs"));
    });
    // 添加Cookie身份验证和授权服务
    builder.Services.AddHttpContextAccessor();
    builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
        .AddCookie(options =>
        {
            options.ExpireTimeSpan = TimeSpan.FromDays(7);
            options.SlidingExpiration = true;
            options.Cookie.HttpOnly = true;
            options.Cookie.SameSite = SameSiteMode.Lax;
            options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
        });
    builder.Services.AddAuthorization();
    builder.Services.AddScoped<IAuthProvider, CookieAuthProvider>();
}

AddKnownWeb 与 WASM 侧 AddKnownClient 是镜像的一对:客户端把 [WebApi] 编译成发 HTTP 的代理,服务端把同一批 [WebApi] 注册成端点、把 [Service] 实现绑进 DI、再挂上 Database。AppType 两侧都填 Web(Desktop 只属于 Hybrid)。

管道 AppServer.cs(UseApplication)与 MapWebApis

public static void UseApplication(this WebApplication app)
{
    var provider = new FileExtensionContentTypeProvider();
    app.UseStaticFiles(new StaticFileOptions { ContentTypeProvider = provider });
    app.UseAuthentication();
    app.UseAuthorization();
    app.MapWebApis();
    // 若有实时通道(示例的物联网日志中心):app.MapHub<IotLogHub>("/hubs/iot-log");
    // 统一初始化数据库:表结构同步(非破坏性建表/增列)+ 幂等种子,与 KnownWin 共用同一套
    app.Services.UseDatabase(AppConfig.SeedAsync);
}

private static void MapWebApis(this IEndpointRouteBuilder endpoints)
{
    var routes = endpoints.ServiceProvider.GetService<IEnumerable<ApiRouteInfo>>();
    if (routes == null)
        return;

    foreach (var route in routes)
        foreach (var method in route.Methods)
        {
            var pattern = route.InterfaceType.GetWebApiUrl(method);
            var httpMethod = method.GetHttpMethod();
            endpoints.MapMethods(pattern, [httpMethod],
                context => WebApiInvoker.InvokeAsync(context, route.InterfaceType, method));
        }
}

MapWebApis 不写死任何控制器:它遍历 AddKnownWeb 扫描阶段产出的 ApiRouteInfo 列表,用反射把每个 [WebApi] 接口方法映射成一个 Minimal API 端点,统一交给 WebApiInvoker 反射调用实现、按方法名约定判定 GET/POST 并序列化返回。这正是模块与插件式装配里 Config.AddModule 扫描能力的服务端兑现。

请求闭环(一图流)

浏览器
  │  加载 KnownWasm 客户端(razor→wasm) + _framework/blazor.webassembly.js
  │  页面 @inject IOrderService  ← AddKnownClient 生成的 ServiceProxy<T>
  ▼
GET/POST 同源接口路径  (Cookie 随 SameSite=Lax 自动带上)
  ▼
KnownWeb 进程
  MapWebApis 注册的端点 → WebApiInvoker → [Service] 实现 → Database(SQLite)

同一个进程里"客户端被 serve、服务端在跑接口",但渲染仍在浏览器——这也是为什么上一节 WASM 的 index.html / Program.cs 在这里一字不改地复用。

常见坑

  • 忘了 AddKnownWeb:[Service] 类不会进 DI,客户端调用一律 500。
  • MapWebApis 早于扫描:ApiRouteInfo 由 AddKnownWeb 在装配阶段产出,务必在 AddKnownWeb 之后再映射端点。
  • 鉴权口径:本模式走 Cookie(SameSite=Lax、同源),与对外机器面(Bearer / /api/v1)是两套受众,别混用——详见鉴权接入。
  • 连接串:示例从 Configuration["ConnString"] 读,落地要配 appsettings.json,别把库文件写死在生产路径。

五、形态三:Hybrid WinForms 桌面宿主

对应仓库里的 Known/KnownWin。这是 Blazor Hybrid:一个 WinForms 桌面程序,窗体里塞一个 BlazorWebView,把和 WASM 完全相同的那份 razor 渲染进内嵌浏览器控件。区别只有一个——接口调用不发 HTTP,而是用 AddKnownDesktop 把 [WebApi] 接口直接绑到本地 [Service] 实现,数据落在进程内的 SQLite 文件。AppType 这里填 Desktop。

适用场景

  • 工业现场 / 车间终端要离线单机跑,或需要调用本地硬件(串口、打印机、USB、本地文件)。
  • 想要原生 exe 分发、开机即用,但业务 UI 复用已有的 Known razor,不重写一套。
  • 网络不可靠或不希望暴露 HTTP 端口:前后端在同一进程里"方法级"直调。

工程文件 KnownWin.csproj

用 Microsoft.NET.Sdk.Razor + WinExe + UseWindowsForms,TFM 是 net10.0-windows(平台限定,这是三种宿主里唯一带 -windows 的)。Microsoft.AspNetCore.Components.WebView.WindowsForms 提供 BlazorWebView,Costura.Fody 把依赖 IL 合并进单个 exe:

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

	<PropertyGroup>
		<OutputType>WinExe</OutputType>
		<TargetFramework>net10.0-windows</TargetFramework>
		<UseWindowsForms>true</UseWindowsForms>
		<ImplicitUsings>enable</ImplicitUsings>
		<ApplicationIcon>favicon.ico</ApplicationIcon>
		<Version>1.0.0</Version>
	</PropertyGroup>

	<ItemGroup>
		<PackageReference Include="Costura.Fody" Version="6.2.0">
		  <PrivateAssets>all</PrivateAssets>
		  <IncludeAssets>compile; runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
		</PackageReference>
		<PackageReference Include="Microsoft.AspNetCore.Components.WebView.WindowsForms" Version="8.*" />
		<PackageReference Include="Microsoft.Data.Sqlite" Version="10.*" />
		<ProjectReference Include="..\KnownApp\KnownApp.csproj" />
	</ItemGroup>

</Project>

入口 Program.cs

没有 WebApplication,手动 new ServiceCollection();AddKnownApp(AppType.Desktop) 声明应用、AddKnownDesktop 指定本地 SQLite 与文件日志,最后 provider.UseDatabase(AppConfig.SeedAsync) 建库再 Application.Run(new MainForm(provider)):

using Known;
using KnownApp;
using Microsoft.Extensions.DependencyInjection;

namespace KnownWin;

internal static class Program
{
    [STAThread]
    static void Main()
    {
        ApplicationConfiguration.Initialize();

        var services = new ServiceCollection();
        services.AddKnownApp(AppType.Desktop);
        services.AddKnownDesktop(option =>
        {
            option.WebRoot = AppContext.BaseDirectory;
            option.ContentRoot = AppContext.BaseDirectory;
            option.Database = db =>
            {
                var dbFile = RepoFile("KnownApp.db");   // 相对仓库根定位本地库文件
                db.AddSQLite<Microsoft.Data.Sqlite.SqliteFactory>($"Data Source={dbFile}");
            };
            // 文件日志:写 exe 目录下 Logs,按天 / 大小滚动;多实例共享同一文件也安全
            option.UseFileLogger(Path.Combine(AppContext.BaseDirectory, "Logs"));
        });
        services.AddWindowsFormsBlazorWebView();
#if DEBUG
        services.AddBlazorWebViewDeveloperTools();
#endif

        var provider = services.BuildServiceProvider();
        provider.UseDatabase(AppConfig.SeedAsync);      // 表结构同步 + 幂等种子,与 KnownWeb 共用一套
        Application.Run(new MainForm(provider));
    }

    /// <summary>相对仓库根(以 Known.slnx 为标记)定位文件;脱离仓库时兜底为 exe 同目录。</summary>
    static string RepoFile(string name)
    {
        for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
            if (File.Exists(Path.Combine(dir.FullName, "Known.slnx")))
                return Path.Combine(dir.FullName, name);
        return Path.Combine(AppContext.BaseDirectory, name);
    }
}

AddKnownDesktop 与 AddKnownWeb 走的是同一条内部装配(AddKnownServer):都注册 [WebApi]→[Service] 实现映射与 Database。区别在兑现方式——Web 侧另外把接口摊成 HTTP 端点给远端 WASM 客户端调,Desktop 侧不摊 HTTP,接口在 DI 里直接解析成本地实现,注入即函数调用。

主窗体 MainForm.cs

BlazorWebView.Dock = Fill 铺满窗体,HostPage 指向 wwwroot/index.html,把根组件 App 挂到 WebView 的 #app(和 WASM 里 RootComponents.Add<App>("#app") 一模一样):

public partial class MainForm : Form
{
    private readonly ServiceProvider _provider;
    private readonly BlazorWebView _webView;

    public MainForm(ServiceProvider provider)
    {
        _provider = provider;
        Text = AppConfig.AppName;
        Width = 1280;
        Height = 800;
        StartPosition = FormStartPosition.CenterScreen;
        WindowState = FormWindowState.Maximized;

        _webView = new BlazorWebView
        {
            Dock = DockStyle.Fill,
            HostPage = "wwwroot/index.html",
            Services = _provider
        };
        _webView.RootComponents.Add<App>("#app");
        Controls.Add(_webView);

        FormClosing += MainForm_FormClosing;
    }

    private void MainForm_FormClosing(object sender, FormClosingEventArgs e)
    {
        _provider?.DisposeAsync();
    }
}

宿主页 wwwroot/index.html

与 WASM 版几乎逐字相同,唯一差别是引导脚本:blazor.webassembly.js 换成 blazor.webview.js:

    <link rel="stylesheet" href="_content/Known/css/web.css" />
  </head>
  <body>
    <div id="app"><!-- 加载占位 svg --></div>
    <script src="_content/Known/js/libs/echarts.js"></script>
    <script src="_content/Known/js/web.js"></script>
    <script src="_framework/blazor.webview.js"></script>
  </body>

为什么桌面端不用改一行 razor

Hybrid 是最能证明"组件宿主无关"的形态——浏览器和桌面共享同一套 KnownApp razor、同一个 App.razor:

  • 同一挂载契约:App.razor 的 <Router> 扫程序集里的 @page 组件,BlazorWebView.RootComponents.Add<App>("#app") 和 WASM 的 builder.RootComponents.Add<App>("#app") 是同一句话,只是宿主不同。
  • 同一接口、不同传输:页面 @inject IOrderService——WASM 拿到的 ServiceProxy<T> 会发请求,Desktop 里 AddKnownDesktop 已把 IOrderService 绑到本地实现,注入即调用。契约不变、razor 不变。见前后端契约。
  • 同一 _content/Known:RCL 静态资源路径不变,index.html 只有引导脚本那一行不同。

常见坑

  • TFM 必须是 net10.0-windows:漏了 -windows 或 UseWindowsForms=false,BlazorWebView / WinForms 类型找不到。
  • 别调 AddKnownClient:桌面端没有 HttpClient / BaseAddress,接口靠 AddKnownDesktop 的本地实现解析;误加会引入无意义的 HTTP 代理。
  • 本地库文件路径:示例用 RepoFile("KnownApp.db") 相对仓库根定位,脱离仓库兜底到 exe 同目录;落地请放 AppContext.BaseDirectory 或用户数据目录,避免装到 Program Files 写不进去。
  • WebView.WindowsForms 版本:仓库里锁的是 8.*(WebView 包独立于 net10 主框架节奏),升级时留意与 blazor.webview.js 的兼容。

六、下一步

  • 数据层与 [Table] 实体、Database 契约:读数据层与 Database 契约。
  • 想把接口和前端彻底分家、独立部署:让形态一的 WASM 客户端连一个独立后端(形态二的接口面单拆出去),上线编排见 Linux 部署指南。
  • 四个装配入口的职责边界与 AddMyApp(AppType) 自封装范式:读应用装配。
微信公众号二维码 扫码关注公众号
QQ 交流群二维码 扫码加入 QQ 群