65.9K
CodeProject 正在变化。 阅读更多。
Home

构建 Blazor WASM 和服务器一体化解决方案

starIconstarIconstarIconstarIconstarIcon

5.00/5 (6投票s)

2021 年 4 月 4 日

CPOL

5分钟阅读

viewsIcon

16647

如何构建一个可在 WASM 和服务器模式下运行的单一 Blazor 应用程序

代码仓库

本文的代码库位于 https://github.com/ShaunCurtis/AllinOne

解决方案和项目

使用 Blazor WebAssembly 模板创建一个名为 Blazor 的新解决方案。不要选择将其托管在 Aspnetcore 上。您将获得一个名为 Blazor 的单项目。

现在,使用 ASP.NET Core Web App 模板向解决方案添加第二个项目。将其命名为 Blazor.Web。将其设置为启动项目。

现在解决方案应如下所示

Blazor 项目更改

该解决方案在网站的子目录中运行 WASM 上下文。要实现此功能,需要对 Blazor 项目进行一些修改。

  1. 将 wwwroot 的内容移至 Blazor.Web 并删除 wwwroot 中的所有内容。
  2. 在项目文件中添加一个 StaticWebAssetBasePath 条目,并设置为 wasm。在使用的上下文中,此设置区分大小写,因此请使用小写字母。
  3. 添加必要的包。

项目文件应如下所示

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

  <PropertyGroup>
    <StaticWebAssetBasePath>wasm</StaticWebAssetBasePath>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" 
        Version="5.0.4" />
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" 
        Version="5.0.4" PrivateAssets="all" />
    <PackageReference Include="System.Net.Http.Json" Version="5.0.0" />
  </ItemGroup>

  <ItemGroup>
    <Folder Include="wwwroot\" />
  </ItemGroup>

</Project>

MainLayout

MainLayout 需要修改才能同时处理两种上下文。该解决方案为每种上下文更改颜色方案。WASM 为 Teal,Server 为 Steel

@inherits LayoutComponentBase
<div class="page">
    @*change class*@
    <div class="@_sidebarCss">
        <NavMenu />
    </div>
    <div class="main">
        <div class="top-row px-4">
            <a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
        </div>
        <div class="content px-4">
            @Body
        </div>
    </div>
</div>

@code {
    [Inject] NavigationManager NavManager { get; set; }
    private bool _isWasm => NavManager?.Uri.Contains
            ("wasm", StringComparison.CurrentCultureIgnoreCase) ?? false;
    private string _sidebarCss => _isWasm ? "sidebar sidebar-teal" : "sidebar sidebar-steel";
}

将以下 CSS 样式添加到组件 CSS 文件中 .sidebar 下方。

.sidebar {
    background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}

/* Added Styles*/
.sidebar-teal {
    background-image: linear-gradient(180deg, rgb(0, 64, 128) 0%, rgb(0,96,192) 70%);
}

.sidebar-steel {
    background-image: linear-gradient(180deg, #2a3f4f 0%, #446680 70%);
}
/* End Added Styles*/

NavMenu

添加代码和标记 - 它会添加一个链接以在上下文之间切换。

<div class="top-row pl-4 navbar navbar-dark">
    @*Change title*@
    <a class="navbar-brand" href="">Blazor</a>
    <button class="navbar-toggler" @onclick="ToggleNavMenu">
        <span class="navbar-toggler-icon">
    </button>
</div>

<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
    <ul class="nav flex-column">
        @*Add links between contexts*@
        <li class="nav-item px-3">
                <NavLink class="nav-link" 
                href="@_otherContextUrl" Match="NavLinkMatch.All">
                    <span class="oi oi-home" aria-hidden="true"> @_otherContextLinkName
                </NavLink>
        </li>
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
                <span class="oi oi-home" aria-hidden="true"> Home
            </NavLink>
        </li>
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="counter">
                <span class="oi oi-plus" aria-hidden="true"> Counter
            </NavLink>
        </li>
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="fetchdata">
                <span class="oi oi-list-rich" aria-hidden="true"> Fetch data
            </NavLink>
        </li>
    </ul>
</div>

@code {
    [Inject] NavigationManager NavManager { get; set; }
    private bool _isWasm => NavManager?.Uri.Contains
    ("wasm", StringComparison.CurrentCultureIgnoreCase) ?? false;
    private string _otherContextUrl => _isWasm ? "/" : "/wasm";
    private string _otherContextLinkName => _isWasm ? "Server Home" : "WASM Home";
    private string _title => _isWasm ? "AllinOne WASM" : "AllinOne Server";
    private bool collapseNavMenu = true;
    private string NavMenuCssClass => collapseNavMenu ? "collapse" : null;

    private void ToggleNavMenu()
    {
        collapseNavMenu = !collapseNavMenu;
    }
}

FetchData.razor

通过在开头添加 / 来更新获取预测的 URL,该文件现在位于根目录下,而不是 wasm 目录中。

protected override async Task OnInitializedAsync()
{
    forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("/sample-data/weather.json");
}

Blazor.Web

更新项目文件

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference 
     Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="5.0.3" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\Blazor\Blazor.csproj" />
  </ItemGroup>
</Project>

Pages 文件夹中添加一个名为 WASM.cshtml 的 Razor 页面 - WASM SPA 的启动页面。

@page "/wasm"
@{
    Layout = null;
}

<!DOCTYPE html<span class="pl-kos">>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, 
    initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
    <title>Blazor</title>
    @*Change base*@
    <base href="/wasm/" />
    @*Update Link hrefs*@
    <link href="/css/bootstrap/bootstrap.min.css" rel="stylesheet" />
    <link href="/css/app.css" rel="stylesheet" />
    <link href="/wasm/Blazor.styles.css" rel="stylesheet" />
</head>
<body>
    <div id="app">Loading...</div>
    <div id="blazor-error-ui">
        An unhandled error has occurred.
        <a href="" class="reload">Reload</a>
        <a class="dismiss">🗙</a>
    </div>
    @*Update js sources *@
    <script src="/wasm/_framework/blazor.webassembly.js"></script>
</body>
</html>

Pages 文件夹中添加一个名为 Server.cshtml 的第二个 Razor 页面 - Server SPA 的启动页面。

@page "/"
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
    Layout = null;
}

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, 
    initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
    <title>Blazor</title>
    <base href="/" />
    <link href="/css/bootstrap/bootstrap.min.css" rel="stylesheet" />
    <link href="/css/site.css" rel="stylesheet" />
    <link href="/wasm/Blazor.styles.css" rel="stylesheet" />
</head>

<body>
    <component type="typeof(Blazor.App)" render-mode="ServerPrerendered" />

    <div id="blazor-error-ui">
        <environment include="Staging,Production">
            An error has occurred. This application may no longer respond until reloaded.
        </environment>
        <environment include="Development">
            An unhandled exception has occurred. See browser dev tools for details.
        </environment>
        <a href="" class="reload">Reload</a>
        <a class="dismiss">🗙</a>
    </div>

    <script src="_framework/blazor.server.js"></script>
</body>
</html>

Index.cshtml

@page 指令更新为 @page "/index"

Startup.cs

更新 Startup 以处理 WASM 和 Server 中间件路径。

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    // This method gets called by the runtime. 
    // Use this method to add services to the container.
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddRazorPages();
        services.AddServerSideBlazor();

        // Server Side Blazor doesn't register HttpClient by default
        // Thanks to Robin Sue - Suchiman https://github.com/Suchiman/BlazorDualMode
        if (!services.Any(x => x.ServiceType == typeof(HttpClient)))
        {
            // Setup HttpClient for server side in a client side compatible fashion
            services.AddScoped<HttpClient>(s =>
            {
                // Creating the URI helper needs to wait until the 
                // JS Runtime is initialized, so defer it.
                var uriHelper = s.GetRequiredService<NavigationManager>();
                return new HttpClient
                {
                    BaseAddress = new Uri(uriHelper.BaseUri)
                };
            });
        }
    }

    // This method gets called by the runtime. 
    // Use this method to configure the HTTP request pipeline.
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        else
        {
            app.UseExceptionHandler("/Error");
            // The default HSTS value is 30 days. 
            // You may want to change this for production scenarios, 
            // see https://aka.ms/aspnetcore-hsts.
            app.UseHsts();
        }

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

        app.MapWhen(ctx => ctx.Request.Path.StartsWithSegments("/wasm"), app1 =>
        {
            app1.UseBlazorFrameworkFiles("/wasm");
            app1.UseRouting();
            app1.UseEndpoints(endpoints =>
            {
                endpoints.MapFallbackToPage("/wasm/{*path:nonfile}", "/wasm");
            });
        });

        app.UseRouting();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
            endpoints.MapBlazorHub();
            endpoints.MapRazorPages();
            endpoints.MapFallbackToPage("/Server");
        });
    }
}

运行应用程序

应用程序现在应该可以运行了。它将从 Server 上下文开始。通过左侧菜单中的链接切换到 WASM 上下文。您应该会看到切换上下文时颜色发生变化。

添加 DataService

虽然上述配置有效,但它需要一些演示代码来展示它如何处理更传统的数据服务。我们将修改解决方案以使用一个非常基本的数据服务来展示应使用的 DI 和接口概念。

DataServices 文件夹添加到 Blazor 项目。

WeatherForecast.cs

WeatherForecast 类添加到 Data 文件夹。

public class WeatherForecast
{
    public DateTime Date { get; set; }
    public int TemperatureC { get; set; }
    public string Summary { get; set; }
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

IWeatherForecastService.cs

IWeatherForecastService 接口添加到 Services 文件夹。

public interface IWeatherForecastService
{
    public Task<List<WeatherForecast>> GetRecordsAsync();
}

WeatherForecastServerService.cs

WeatherForecastServerService 类添加到 Services 文件夹。通常,这将接口化到一个数据库,但在这里我们只是创建了一组虚拟记录。

public class WeatherForecastServerService : IWeatherForecastService
{
    private static readonly string[] Summaries = new[]
    {
        "Freezing", "Bracing", "Chilly", "Cool", 
        "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
    };

    private List<WeatherForecast> records = new List<WeatherForecast>();

    public WeatherForecastServerService()
        => this.GetForecasts();

    public void GetForecasts()
    {
        var rng = new Random();
        records = Enumerable.Range(1, 10).Select(index => new WeatherForecast
        {
            Date = DateTime.Now.AddDays(index),
            TemperatureC = rng.Next(-20, 55),
            Summary = Summaries[rng.Next(Summaries.Length)]
        }).ToList();
    }

    public Task<List<WeatherForecast>> GetRecordsAsync()
        => Task.FromResult(this.records);
}

WeatherForecastAPIService.cs

WeatherForecastAPIService 类添加到 Services 文件夹。

public class WeatherForecastAPIService : IWeatherForecastService
{
    protected HttpClient HttpClient { get; set; }

    public WeatherForecastAPIService(HttpClient httpClient)
        => this.HttpClient = httpClient;

    public async Task<List<WeatherForecast>> GetRecordsAsync()
        => await this.HttpClient.GetFromJsonAsync<List<WeatherForecast>>
           ($"/api/weatherforecast/list");
}

WeatherForecastController.cs

最后,在 Blazor.Web 项目的 Controller 文件夹中添加一个 WeatherForecastController 类。

using System.Collections.Generic;
using System.Threading.Tasks;
using Blazor.Data;
using Microsoft.AspNetCore.Mvc;
using <span class="pl-en">MVC = Microsoft.AspNetCore.Mvc;
using Blazor.Services;

namespace Blazor.Web.APIControllers
{
    [ApiController]
    public class WeatherForecastController : ControllerBase
    {
        protected IWeatherForecastService DataService { get; set; }

        public WeatherForecastController(IWeatherForecastService dataService)
            => this.DataService = dataService;

        [MVC.Route("/api/weatherforecast/list")]
        [HttpGet]
        public async Task<List<WeatherForecast>> GetList() => 
                               await DataService.GetRecordsAsync();
    }
}

Blazor 项目 Program.cs

将 API 服务添加到 Blazor 项目的 Program.cs 文件中,通过其 IWeatherForecastService 声明。

public class Program
{
    public static async Task Main(string[] args)
    {
        var builder = WebAssemblyHostBuilder.CreateDefault(args);
        builder.RootComponents.Add<App>("#app");

        builder.Services.AddScoped(sp => new HttpClient 
        { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
        builder.Services.AddScoped<IWeatherForecastService, WeatherForecastAPIService>();

        await builder.Build().RunAsync();
    }
}

Blazor.Web Startup.cs

将服务器服务添加到 Blazor.Web 项目的 Startup.cs 文件中,同样通过其 IWeatherForecastService

public void ConfigureServices(IServiceCollection services)
{
    services.AddRazorPages();
    services.AddServerSideBlazor();
    services.AddScoped<IWeatherForecastService, WeatherForecastServerService>();
    .....
}

构建和运行项目

解决方案现在应该可以构建和运行了。

它是如何工作的?

从根本上说,Blazor Server 和 Blazor WASM 应用程序之间的区别在于它们运行的上下文。在此解决方案中,所有 SPA 代码都构建在 Web Assembly 项目中,并由 WASM 和 Server 上下文共享。没有“共享”代码库代码,因为它们是完全相同的前端代码,具有相同的入口点 - App.razor。两种上下文之间的区别在于后端服务的提供者。

Web assembly 项目被声明为 <Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">。它同时构建了一个标准的 Blazor.dll 文件以及 WASM 特定的代码,包括 Web Assembly“引导配置文件”blazor.boot.json

在 web assembly 上下文中,初始页面加载 blazor.webassembly.js。它加载 blazor.boot.json,该文件告诉 blazor.webassembly.js 如何“引导”浏览器中的 Web assembly 代码。它运行 Program,该程序构建 WebAssemblyHost,加载已定义的 خدمة,并启动 Renderer,该渲染器将 app html 元素替换为 Program 中指定的根组件。这会加载路由器,该路由器读取 URL,获取相应的组件,将其加载到指定的布局中,并开始渲染过程。SPA 启动并运行。

在 Server 上下文中,服务器端代码在初始加载页面中拾取组件引用并对其进行静态渲染。它将渲染的页面传递给客户端。这会加载并运行 blazor.server.js,该脚本会回调用服务器 SignalR Hub 并获取动态渲染的应用程序根组件。SPA 启动并运行。服务容器和渲染器位于 Blazor Hub 中 - 当 Web 服务器启动时,通过在 Startup 中调用 services.AddServerSideBlazor() 来启动。

我们实现的数据服务演示了依赖注入和接口。UI 组件 - 在本例中是 FetchData - 使用 Services 中注册的 IWeatherForcastService 服务。在 WASM 上下文中,服务容器启动 WeatherForecastAPIService,而在 Server 上下文中,服务容器启动 WeatherForecastServerService。两种不同的服务,符合同一个接口,并由 UI 组件通过接口进行消费。UI 组件不关心它们消费的是哪个服务,只需要它实现 IWeatherForcastService 即可。

总结

希望本文能让您深入了解 Blazor SPA 的工作原理以及 Server Blazor SPA 和 WASM Blazor SPA 之间的真正区别。

如果您在未来很长一段时间后阅读本文,本文的最新版本将在此处:https://shauncurtis.github.io/articles/Blazor-AllinOne.html

历史

  • 2021 年 4 月 4 日:初始版本
© . All rights reserved.