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 出现//段,ServiceProxy404。约定结尾不带斜杠。- 纯 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 群