使用 C# 代码和 Blazor 开发单页应用程序






4.99/5 (33投票s)
到目前为止,我看到的 Blazor 的大多数示例都包含一些简单的页面、按钮和表单。因此,我决定研究一下它是否能与我旧的 Bricks 游戏一起使用,以帮助探索 WebAssembly。
引言
您是否曾经想过使用 JavaScript 以外的编程语言来开发和运行客户端代码?
本文使用一个(几乎)完全用 C# 编写的 Bricks 游戏,并在 Blazor 项目 的帮助下,我们将探索这项名为 WebAssembly 的技术这一崭新的世界,讨论它的功能和局限性,它如何与 JavaScript 交互,以及它如何从我们熟悉的 C# 语言生成。
背景
近年来,由于一些有趣的技术和工具的发布,C# 语言获得了新的应用领域,例如:
- Xamarin,一个面向 Windows、Android 和 iOS 跨平台开发的工具
- .NET Core,一个可在 Windows、Linux 或 Mac 上运行的跨平台框架
- ASP.NET Core,一个跨平台 Web 开发框架
- Visual Studio Code,一个轻量级的开发环境,支持包括 C# 在内的多种语言
所有这些工具和框架最终都扩大了对 C# 语言的兴趣。
现在(2018 年 4 月),微软正在赞助一项新技术的实验,该技术将进一步拓展 C# 语言应用程序的极限:Blazor 项目。
多年前,我用 C# 开发了一个简单的类似 Tetris 的游戏引擎,我曾将其用于 Code Project 的一些项目和文章中。
几个月前,当我第一次听说 Blazor 项目时,我非常兴奋地想了解它是如何工作的。据说 Blazor 是一个单页应用程序(SPA)框架,并且使用 WebAssembly 产生的程序在浏览器中的速度比 JavaScript 快 30 倍。
到目前为止,我看到的 Blazor 的大多数示例都包含一些简单的页面、按钮和表单。因此,我决定研究一下它是否能与我旧的 Bricks 游戏一起使用。
WebAssembly 作为客户端代码
如果您已经为 Web 编写过客户端代码,您就知道有大量的框架和库,例如 jQuery、ReactJS、Angular、Vue.js 等等。但最终您总是要编写 JavaScript 代码,这是一种非常灵活的语言,尽管它也有自己的问题。如果您想绕过 JavaScript 语言的一些问题,您可以编写 TypeScript 代码,但最终,这一切都会变成 JavaScript 代码。
将 C# 带入客户端开发并将其编译为 WebAssembly
具有明显的优势:
- C# 是一种强大、功能丰富且健壮的语言,拥有庞大的开发者社区。
- 开发人员可以在客户端重用现有的 C# 代码。不仅是他们自己的代码,还有他人的代码。(事实上,我之所以创建这个 Blazor 项目并撰写这篇文章,动机之一就是我成功地重用了现有代码。)
- ASP.NET Core 是一个强大的 Web 开发框架,并且由于它在服务器端使用 C# 代码,因此如果在客户端也使用 C#,那将非常有意义,因为这意味着我们拥有一个通用的堆栈来进行开发。
什么是 Blazor?
Blazor 是由 Steve Sanderson(来自微软)创建的一个实验性项目,它是一个单页应用程序(SPA)框架,旨在将 C# 代码编译为 WebAssembly
。
WebAssembly
是 W3C 关于在 Web 浏览器上运行的二进制格式的规范,所有流行的浏览器都支持并实现了它。自从该规范发布以来,人们一直在忙于创建将多种语言编译成 WebAssembly 二进制文件的编译器。
据说 WebAssembly
的运行速度比 JavaScript 代码快 30 倍,因为它具有接近原生的性能。但 Blazor 背后的理念不是要 *完全* 取代 JavaScript,而是作为其补充。
我们必须记住,Blazor 无法执行某些 JavaScript 功能。例如,Blazor 无法直接访问 HTML DOM 元素。相反,Blazor 被创建为一个以组件为中心(component-centered)的框架。这里的组件比它们将生成的 HTML 元素更具概念性。因此,它必须创建和管理自己的组件,这些组件又会生成 HTML 片段/元素。
Blazor 的灵感来自于客户端 SPA(单页应用程序)框架,例如 Angular、React.JS 和 Vue.js,它们也共享组件的概念。
使用 Blazor 编译的项目在浏览器上运行,运行在名为 Mono 的 .NET Framework 的 WebAssembly 实现之上,而 Mono 又运行在 WebAssembly 上。一些人想知道为什么 Blazor 团队选择 Mono 项目而不是 .NET Core,答案是,与 Mono 不同,.NET Core 不包含处理不同设备和用户界面所需的表示逻辑。
Razor
Razor 最初是一个在服务器上运行的引擎,它将 C# 和 HTML 模板结合起来,动态生成最终部署到浏览器的 HTML 代码。
另一方面,Blazor 在编译时使用 Razor 作为一种机制,将 C# 和 HTML 模板结合起来,并生成 C# 代码。
您可以在下图看到我们的 Index.cshtml Blazor 模板文件是如何被编译成 Index.g.cs 文件的。
我们得到了从 HTML 生成的 C# 代码。现在怎么办?
但是浏览器如何识别 C# 代码呢?实际上,它不识别,因为浏览器只知道如何运行 WebAssembly(.wasm 文件),这是 W3C(万维网联盟)发布的一项规范。
那么,浏览器如何运行我们的 C# 代码呢?首先,我们的代码被编译成一个.dll文件,这是一个托管代码。Blazor 不仅会将您的应用程序的.dll文件部署到浏览器,还必须由浏览器下载 Mono。
Mono 是 .NET Framework 的一个开源版本,用于 Xamarin 等跨平台开发工具。Mono 团队成功地将 Mono 移植到了 WebAssembly
,形式为 Mono.wasm 文件,该文件被部署到浏览器。然后,Mono IL(中间语言)用于运行我们应用程序的托管代码。
您可以从下图看到 Web 浏览器启动过程是如何加载所有这些包的:
- 首先,加载 blazor.js
- Blazor.js 使用 Mono 的 JavaScript 库(mono.js)
- Mono.js 加载 Mono WebAssembly 运行时(mono.wasm)
- Mono.wasm 反过来加载我们的应用程序 DLL(
BlazorBricks
和BlazorBricks.Core
)以及 .NET Framework DLL。
HTML 生成
多年前,在 HTML5 和 CSS3 出现之前,Web 应用程序的功能要有限得多,因此人们使用 Flash 和 Silverlight 等浏览器插件来提供更具交互性的用户体验。Flash 使用一种名为 ActionScript 的语言,而 Silverlight 在客户端使用 C#/VB.NET。得益于 HTML5、CSS3 和 ES6,这两种技术如今几乎都已过时。
由于 Blazor 也在客户端使用 C# 代码,所以 Blazor 似乎是 Silverlight 的一种复兴。但幸运的是,情况并非如此。Silverlight 有自己的内部渲染机制,它使用浏览器窗口作为画布来绘制自己的界面组件。
另一方面,Blazor 依赖浏览器文档对象模型(DOM)来呈现网页。但是 C# 本身无法直接访问 DOM。相反,它必须依赖 JavaScript 代码来操作 div、input、span 和其他 DOM 元素。您可以查看下面的图表来了解它是如何工作的:
- Blazor 的 C# 代码创建一个包含要显示的 UI 组件的分层结构(即渲染树),然后将该树传递给 Blazor 的 JavaScript 代码部分。
- Blazor 的 JavaScript 代码根据渲染树的结构和内容执行 DOM 中的更改。
- Blazor 的 JavaScript 代码监听所有用户事件,例如鼠标点击、按键等,并响应应用程序中 C# 代码实现的事件。
- C# 反过来可以通过修改 Model(或 ViewModel)的某些部分来做出响应。
- 模型中的这些更改必须反映在视图中,因此 Blazor 的 JavaScript 部分会再次分析渲染树,然后只对 DOM 应用检测到的差异性更改。
Blazor 演示
您可以从默认的 Blazor 项目模板创建标准的Blazor 演示。
目前,如果您想开发 Blazor 项目,您需要:
- 下载并安装 Visual Studio(最低版本:15.7)
- 搜索并安装 Blazor 组件 Visual Studio 扩展:ASP.NET Core Blazor Language Services
要创建新的 Blazor 项目,请首先选择 File > New Project,然后选择 ASP.NET Core Web App:
接下来,您将看到各种可能的项目类型。选择 Blazor
请注意,上图还显示了 Blazor - ASP.NET Core hosted 类型,但那种项目对我们的需求来说相当复杂。因此,让我们创建一个新的 Blazor 项目。
一旦我创建了全新的 Blazor 项目,我发现它不支持调试……至少目前还不行。根据 Blazor 团队的路线图,调试功能将稍后出现。因此,您应该按 CTRL+F5(“不调试运行”)并等待服务器启动以及 Blazor 应用程序运行。
下一步是创建一个新的类库项目来与 Blazor 一起工作。我刚刚创建了一个新的 .NET Standard 项目,我称之为“BlazorBricks.Core
”,并在第一个项目中添加了对它的引用。
这很有趣。正如您在下图中所看到的,当您编译项目时,您会看到 Blazor 如何将新的 .NET Standard .dll 文件包含在 Blazor 项目的/bin文件夹中。
Blazor Bricks 游戏
现在让我们逐个解释游戏如何实现 Blazor 的概念。
布局
如果您使用多个页面,当用户在页面之间导航时,您应该保持网站布局的一致性。Blazor 项目中的布局由实现 ILayoutComponent
的组件定义。默认情况下,此角色由 MainLayout.cshtml 文件承担。
@implements ILayoutComponent
@Body
@functions {
public RenderFragment Body { get; set; }
}
请注意,Body
属性作为片段传递给 MainLayout
,后者又会在适当的位置渲染 HTML 主体。在这里,我从 MainLayout.cshtml 文件中删除了任何 HTML 代码,但默认和原始的 MainLayout 文件代码包含在多个页面之间共享的 HTML。
生命周期
当您创建一个 Blazor 页面(在 .cshtml 文件中)并对其进行编译时,它会生成该页面的 C# 类表示(在./obj/Debug/netstandard2.0/Pages文件夹内)。例如,我们的游戏实现了 Index.cshtml 文件,该文件在 Index.g.cs 文件中被转换为一个类。该类继承自 Microsoft.AspNetCore.Blazor.Components.BlazorComponent
。
需要注意的是,只有页面的二进制表示形式会被部署到浏览器。.cshtml 文件不包含在部署中。
每个 Blazor 页面都是一个 BlazorComponent
,因此它遵循组件生命周期:首先,它从其父组件在渲染树中接收参数。然后,调用Index.cshtml页面的 OnInitAsync
方法。但是我们如何在 Blazor .cshtml 文件中的 C# 中实现这个方法呢?我们必须在@functions
指令中包含该页面的 C# 代码。
@functions {
protected override async Task OnInitAsync()
{
}
}
现在我们可以实现游戏 C# 代码了。第一步是创建一个字段,它将为页面提供模型。我们称之为 boardViewModel
。
@functions {
BlazorBricks.Core.BoardViewModel boardViewModel;
protected override async Task OnInitAsync()
{
boardViewModel = BlazorBricks.Core.GameManager.Instance.CurrentBoard;
}
}
请注意,我们为该字段分配了一个 BlazorBricks.Core.BoardViewModel
的实例,该实例由 BlazorBricks.Core.GameManager.Instance
的单例属性提供,该属性来自 BlazorBricks.Core
项目。
数据绑定
现在您已经有了数据源,是时候将其绑定到视图了。
对于已经了解 Razor 的人来说,这将非常直接,因为 Blazor 中的 Razor 与 ASP.NET 中的非常相似。例如,假设您想在 HTML 代码中渲染 boardViewModel
的游戏得分值。该值由 boardViewModel
对象的 Score
属性提供。您只需将 C# 表达式 @boardViewModel.Score
放在应该渲染它的位置即可。
<div class="statsLine">
<div>SCORE</div>
<div>@boardViewModel.Score</div>
<hr />
</div>
现在您可以遵循相同的逻辑来显示其余的游戏统计数据:最高分、行数和关卡:
<div class="statsLine">
<div>SCORE</div>
<div>@boardViewModel.HiScore</div>
<hr />
</div>
<div class="statsLine">
<div>SCORE</div>
<div>@boardViewModel.Lines</div>
<hr />
</div>
<div class="statsLine">
<div>SCORE</div>
<div>@boardViewModel.Level</div>
<hr />
</div>
但是等等……请注意上面的 HTML 代码是如何重复的。您可能想知道是否有什么我们可以做的。幸运的是,Blazor 提供了组件的概念。
Components
组件允许我们封装 HTML 片段——比如上面的那些——并对其进行参数化。Blazor 组件实现起来非常简单。首先,在/Shared文件夹中创建一个带有您想要的组件名称的新.cshtml文件。在本例中,组件将命名为 StatsInfo
。然后复制代码片段并粘贴您希望组件渲染的 HTML。
<div class="statsLine">
<div>SCORE</div>
<div>@boardViewModel.Score</div>
<hr />
</div>
然后我们必须参数化组件。请注意,这里我们有两个变量数据:状态标签及其值。让我们用这些参数替换这些部分:
<div class="statsLine">
<div>@Label</div>
<div>@Value</div>
<hr />
</div>
现在这些参数必须来自某个地方。同样,我们在文档底部创建了一个 @functions
部分,就像我们在 Index
页面中所做的那样,然后我们为 label
和 value
参数提供了两个属性:
<div class="statsLine">
<div>@Label</div>
<div>@Value</div>
<hr />
</div>
@functions
{
public string Label { get; set; }
public int Value { get; set; }
}
然后,我们在/Shared/StatsInfo.cshtml文件中得到了一个新组件。让我们像使用常规 HTML 元素一样在 Index.cshtml 文件中使用它。请注意 Blazor 如何允许我们以与在 HTML 元素中分配值相同的方式传递参数:
<StatsInfo Label="SCORE"
Value="@boardViewModel.Score" />
<StatsInfo Label="HI SCORE"
Value="@boardViewModel.HiScore" />
<StatsInfo Label="LINES"
Value="@boardViewModel.Lines" />
<StatsInfo Label="LEVEL"
Value="@boardViewModel.Level" />
幸运的是,我们还可以创建层次结构,其中可以嵌套不同类型的组件。让我们看看主 Bricks 板是如何通过组件嵌套显示的:
<div class="board">
<BricksBoard Bricks="@boardViewModel.Bricks" />
</div>
在上面的代码中,@boardViewModel.Bricks
提供了要显示为游戏 Bricks 板的 bricks 数组。但是我们在 BricksBoard.cshtml 文件中找到了什么?
@foreach (var brick in Bricks)
{
<Brick Color="@brick.Color" />
}
@functions
{
public BlazorBricks.Core.BrickViewModel[] Bricks { get; set; }
}
foreach
循环依次为数组中的每个实例显示一个 Brick
组件。
<span class="colorChip shapecolor-@(Color)"></span>
@functions
{
public string Color { get; set; }
}
事件绑定
在我写这篇文章的时候,Blazor 只支持两种事件:onclick
和 onchange
。在游戏中,onclick
事件用于在玩家按下“开始新游戏”按钮时触发页面 C# 函数中的 StartTickLoop
方法。
<button @onclick(StartTickLoop)>START NEW GAME</button>
StartTickLoop
反过来会调用 BlazorBricks.Core
项目中 BricksPresenter
的同名方法。
public void StartTickLoop()
{
BlazorBricks.Core.GameManager.Instance.Presenter.StartTickLoop();
}
调用 StartTickLoop
方法以启动游戏和主游戏循环,移动棋子并等待玩家操作。
回到事件:还记得我说只支持 onclick
和 onchange
事件吗?在开发游戏时,我迫切需要处理箭头键来控制游戏中下落的棋子。但由于 Blazor 团队尚未发布一套完整的事件,因此有一个解决方法可以处理 Blazor 的按键事件:我们可以互操作(interop)JavaScript 来填补这一空白。
互操作:从 JavaScript 调用 C#/.NET 方法
下面的 JavaScript 代码显示了如何在 JavaScript 中绑定 onkeyup
文档,以便我们可以调用一个由 Blazor 编译到 WebAssembly
的 C# 方法。
<script>
const assemblyName = 'BlazorBricks';
const namespace = 'BlazorBricks';
const typeName = 'OnKeyUp';
const methodName = 'Handler';
const onkeyupMethod = Blazor.platform.findMethod(
assemblyName,
namespace,
typeName,
methodName
);
document.onkeyup = function (evt) {
evt = evt || window.event;
const keyCode = Blazor.platform.toDotNetString(evt.keyCode.toString());
Blazor.platform.callMethod(onkeyupMethod, null, [keyCode]);
};
function onKeyUp(element, evt) {
const char = Blazor.platform.toDotNetString(evt.key)
Blazor.platform.callMethod(onkeyupMethod, null, [char]);
}
</script>
请注意,上面的代码定义了要在 OnKeyUp
类上调用的 Handler
方法。这个 C# 类非常简单:
public static class OnKeyUp
{
public static Action<string> Action { get; set; }
public static void Handler(string value)
{
Action?.Invoke(value);
}
}
我们在页面的 C# 代码的 OnInitAsync
重写方法中定义了 Action
代码:
OnKeyUp.Action = async value =>
{
ConsoleKey consoleKey = (ConsoleKey)Enum.Parse(typeof(ConsoleKey), value);
var presenter = BlazorBricks.Core.GameManager.Instance.Presenter;
switch (consoleKey)
{
case ConsoleKey.LeftArrow:
presenter.MoveLeft();
break;
case ConsoleKey.RightArrow:
presenter.MoveRight();
break;
case ConsoleKey.UpArrow:
presenter.Rotate90();
break;
case ConsoleKey.DownArrow:
presenter.MoveDown();
break;
default:
break;
}
this.StateHasChanged();
};
移植旧的 MVC Bricks 游戏
尽管我能够重用我旧的游戏代码,但将整个 Bricks 引擎与 Blazor 视图集成显然并不那么容易。首先,原始的 MVC Bricks 游戏包含一些用 JavaScript 编写的 AJAX 代码,用于每秒的几分之一时间内从服务器端请求棋盘状态和棋子快照。这是一个疯狂的想法——我知道——但它奏效了。
当时,客户端 JavaScript 负责每 1/4 秒请求一次棋盘状态,从而跟上游戏节奏。但对于 Blazor 项目,我修改了 Bricks 引擎代码,使 Razor 视图尽可能被动,将渲染循环保留在 Bricks 引擎端。现在,应用程序的视图端只需要启动和停止 Bricks GameManager
,从而改变游戏的内部状态。
游戏规则
您应该非常熟悉这类游戏,但我还是得解释一下游戏规则。
这里有一个空的 10x20 的棋盘,即包含 200 个空位置。游戏开始后,游戏引擎会一次随机生成一个新的棋子,它会从棋盘顶部落下,速度为每秒 1 格。当下落的棋子遇到障碍物(即已固定在棋盘底部的另一个棋子的部分)时,它就不能再下落了,因此下落的棋子会卡住。然后游戏引擎会生成新的随机棋子,并将它们堆叠起来,直到棋子堆到达棋盘顶部,此时游戏结束。用户需要控制每个下落的棋子,通过向左、向右移动或旋转它,将新棋子放置在棋盘上最低的空位处,并且新棋子能够在此处放置,以避免堆叠的棋子到达棋盘顶部。此外,当用户填满任何棋盘行时,这些行会被清除,从而提供额外的空间并延长游戏时间。
![]() | ![]() | ![]() | ![]() |
“I” 形 | “L” 形 | “J” 形 | “O” 形 |
![]() | ![]() | ![]() | |
“T” 形 | “S” 形 | “Z” 形 |
游戏引擎可以随机生成以上任何一种形状。正如我们所见,每种形状都关联着一个与其相似的字母。
每清除一行,用户将获得总共 10 乘以游戏关卡的得分。也就是说,在第一关清除的每一行将得到 10 分。第二关每清除一行将得到 20 分,依此类推。
每当用户清除 10 行时,该关卡就完成。也就是说,要达到第 5 关,用户必须清除 40 行。
游戏结束后,游戏得分将与之前的高分进行比较,如果出现新纪录,则会替换旧纪录。
下一个棋子让用户有机会将当前棋子放置在更容易容纳下一个下落棋子的位置。
模型
模型由 BoardViewModel
和 BrickViewModel
类定义,并包含视图渲染游戏棋盘、得分板以及知道游戏是否结束所需的所有信息。正如我们下面看到的,BoardViewModel
类的大多数属性都是原生类型,除了 Bricks
和 Next
属性,它们是 BrickViewModel
的二维数组,保存构成当前游戏棋盘快照的棋子和空位的以及将从游戏棋盘顶部下落的下一个棋子的数据。
低级别的 BrickViewModel
类包含关于每个单独棋子的信息:行、列和颜色名称。这些值将由视图用于查找相应的 div 并相应地更新其背景颜色。
public class BrickViewModel
{
public int Row { get; set; }
public int Col { get; set; }
public string Color { get; set; }
}
public class BoardViewModel
{
public BoardViewModel()
{
IsGameOver = false;
}
public BrickViewModel[] Bricks { get; set; }
public int Score { get; set; }
public int HiScore { get; set; }
public int Lines { get; set; }
public int Level { get; set; }
public BrickViewModel[] Next { get; set; }
public bool IsGameOver { get; set; }
}
}
游戏管理器
GameManager
类包含 BricksController
所需的所有方法,以便 BricksView
的请求可以传达给游戏引擎,并且响应可以发送回 BricksView
。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace MVCBricks.Core
{
public class GameManager : MVCBricks.Core.IView
{
private static GameManager instance = null;
private static BricksPresenter presenter = null;
private static BoardViewModel currentBoard = null;
private GameManager()
{
currentBoard = new BoardViewModel();
currentBoard.Bricks = new BrickViewModel[] { };
presenter = new BricksPresenter(this);
presenter.InitializeBoard();
presenter.Tick();
}
当调用 DisplayScore
时,所有记分板数据都会被收集起来,以便控制器通过一次调用就可以供视图使用。
public void DisplayScore(int score, int hiScore, int lines,
int level, MVCBricks.Core.Shapes.IShape next)
{
currentBoard.Score = score;
currentBoard.HiScore = hiScore;
currentBoard.Lines = lines;
currentBoard.Level = level;
currentBoard.Next = GetBricksArray(next.ShapeArray.GetUpperBound(1) + 1,
next.ShapeArray.GetUpperBound(0) + 1, next.ShapeArray);
}
GetBricksArray
方法将游戏棋盘棋子数组和下一个形状数组都转换为视图可以理解的颜色系统。
private BrickViewModel[] GetBricksArray(int rowCount, int colCount, IBrick[,] array)
{
var bricksList = new List<BrickViewModel>();
for (var row = 0; row < rowCount; row++)
{
for (var col = 0; col < colCount; col++)
{
var b = array[col, row];
if (b != null)
{
bricksList.Add(new BrickViewModel()
{
Row = row,
Col = col,
Color = b.Color.ToString().Replace("Color [", "").Replace("]", "")
});
}
else
{
bricksList.Add(new BrickViewModel()
{
Row = row,
Col = col,
Color = "rgba(0, 0, 0, 1.0)"
});
}
}
}
return bricksList.ToArray();
}
延伸阅读
结论
我希望您喜欢阅读这篇文章,即使只是为了对 Blazor 有一点了解。显然,由于 Blazor 目前仍处于预 Alpha 阶段,它还有很多问题需要解决和改进,但我认为 Blazor 在吸引众多 C# 开发人员进行 WebAssembly
开发方面具有巨大的潜力。
请从文章顶部的链接下载源代码。您喜欢吗?有什么建议、技巧或投诉吗?请在下面的评论区留言,我非常期待阅读您的反馈!:-)
历史
- 2018 年 5 月 11 日:初始版本