WebAssembly 和 Blazor:解决了一个存在十年的老问题

作者:Jeremy Likness

阅读数:3574 2019 年 6 月 11 日

本文要点

  • WebAssembly 是一种新的客户端技术,可以在所有现代浏览器(包括移动浏览器)中实现近乎原生的性能,而且不需要插件。
  • 许多语言,包括 C、C#、Go 和 Rust,都可以编译成面向基于栈的 WebAssembly 虚拟机的代码。
  • .NET 代码可以在任何地方运行,包括浏览器内部。
  • Blazor 是一个客户端库,它在 WebAssembly 上使用.NET 来支持借助 Razor 模板使用 C# 编写的单页应用程序。
  • Blazor 支持代码重用和将遗留代码移植到现代 Web 应用程序的能力。

在 2019 年 4 月中旬,微软悄悄地推出了一个年轻的框架,从“一切皆有可能”的实验阶段过渡到“我们致力于实现这一目标”的预览版。这个框架名为Blazor,因为它在浏览器中运行,并利用了一个名为 Razor 的模板系统或“视图引擎”,促成了这个.NET 开发人员几乎放弃了的场景。它不仅允许开发人员使用 C# 构建客户端代码(不需要 JavaScript),还允许开发人员在没有插件的情况下在浏览器中运行现有的.NET 标准 DLL。

Blazor 有两种托管模式。本文主要关注客户端版本。你可以阅读“Blazor 服务器端托管模型”了解更多关于服务器端版本的信息。

Silverlight 的希望

在任何地方运行.NET 的梦想始于 2006 年,当时有一个名为“Windows Presentation Foundation/Everywhere(WPF/E)”的应用程序框架以 Silverlight 的形式向公众发布。第一个版本支持通过 WPF 引入的声明性用户界面,即可扩展应用程序标记语言(Extensible Application Markup Language,简称 XAML)。该平台提供了对 UI 元素的细粒度控制,并提供了自己的文档对象模型(DOM),可以通过 JavaScript 访问。

当 Silverlight 2 在 2008 年发布时,它通过一个作为浏览器插件运行的公共语言运行时(CLR)实现.NET 的完全支持,从而加快了采用速度。开发人员可以使用任何.NET 语言来构建 Web 应用程序,利用成熟的数据绑定模式,如 Model-View-ViewMode(MVVM),并使用 REST 或 Windows Communication Foundation(WCF)客户端与 Web API 通信。看起来,.NET 开发人员可以摆脱 JavaScript 的束缚,不用再担心跨浏览器测试,而是专注于一个具有公共代码库的平台来交付他们的应用程序。

Silverlight 开发者不知道的是,2007 年对于这个平台来说是艰难的一年。两个看似不相干的事件发生了,最终导致了它的灭亡。首先,Web 超文本应用技术工作组(WHATWG)和万维网联盟(W3C)之间开始着手合作编写将于 2008 年发布的 HTML5 规范初稿。

第二,2007 年 6 月 29 日,苹果发布了 iPhone

每隔一段时间,我们就会有一件革命性的产品横空出世,并彻底改变一切。
——史蒂夫•乔布斯

比赛开始了。手机几乎是在一夜之间从带有联系人列表的翻盖手机发展到带有游戏和内置网络浏览器的便携式电脑。在很短的一段时间内,Silverlight 的未来似乎充满了希望。微软对 iPhone 的回应是 Windows Phone 7,支持以 Silverlight 作为开发平台。Chrome 支持即将到来。如果微软能够找到一种将 Silverlight 应用到 iPhone 和 Android 手机上的方法,那么“一次编写,到处运行”的圣杯将最终被发现。

只是,它没有找到。

出于许多原因,包括运行“浏览器中的虚拟机”的安全性考虑,以及潜在的电池消耗,通向浏览器插件的大门砰地关上了,特别是在移动设备上。业界开始期待 HTML5 在打造移动体验方面的前景。微软改变了自己的关注点,到 2011 年 Silverlight 5 发布时,大多数开发人员已经看到了不祥之兆:不会再有新版本了。

HTML5 和 JavaScript 继续赢得 Web 开发人员的青睐。jQuery 等工具对 DOM 进行了标准化,使构建多浏览器应用程序变得更加容易,同时,浏览器引擎开始采用通用的 DOM 标准,使“一次构建,到处运行”变得更加容易。Angular、React 和 Vue.js 等前端框架的爆炸式增长使单页应用程序(SPA)成为主流,并巩固了 JavaScript 作为浏览器操作系统首选语言的地位。

JavaScript 即平台

2013 年 3 月,asm.js正式推出。其文档把它描述为JavaScript 的一个严格子集,可以用作编译器的一种低级、高效的目标语言。该规范本质上定义了一组 JavaScript 约定,这些约定使通过提前编译优化代码成为可能,并提供了强类型(JavaScript 本身是一种动态语言)和基于堆的内存模型。

Asm.js 的推出使将 C/C++ 代码编译成 JavaScript 成为可能,从而开启了一个新的可能性领域。对约定的限制使得“支持 asm.js”的引擎可以有效地将 JavaScript 编译成高性能的本地代码。为了更好地理解这是如何实现的,请考虑下面的 C 代码片段:

复制代码
int find(char *buf, char test) {
char *cur = buf;
while (*cur != 0 && *cur != test) {
cur++;
}
if (*cur == 0) {
return -1;
}
return (cur - buf);
}

该代码有效地扫描一个字符串,寻找标记其结束的测试字符或零字节,并计算偏移量。C++ 已经可以使用一个名为Clang的工具编译成字节码,该工具与LLVM 工具链兼容。LLVM 是一组支持快速跨平台编译代码的技术。一个名为Emscripten的项目利用该工具链来生成 asm.js。

使用 Emscripten 编译 C++ 代码可以生成几十行高度优化的 JavaScript。以下代码经过简化,用于说明生成的内容:

复制代码
function find(buf, test) {
buf = buf|0;
var cur = buf|0;
var result = -1|0;
while (1) {
var check = HEAP8[cur>>0]|0;
var foundZero = (check) === (0);
if (foundZero) {
break;
}
var foundTest = (check) === (test|0);
if (foundTest) {
result = (cur - buf)|0;
break;
}
}
return result|0;
}

生成的 JavaScript 与所有浏览器兼容并且运行良好。带零操作的异或(|0)可以很容易地将任何数字转换为带符号整数。在较老的浏览器中,这可以确保数字没有小数部分。在现代浏览器中,该约定可以提前通知编译器使用 32 位整数(使数学运算更快),而不是默认的 64 位浮点值。0 右移(>>0)可以防止溢出,它还声明了一个“索引”整数类型,该类型在 HEAP8 上迭代,而 HEAP8 是一个可以在 asm.js 中使用的类型化的字节缓冲区。

在 asm.js 中没有定义 for 循环。所有内容都被转换为 while(1) 循环。这使得应用编译器优化变得更容易。这些优化非常有效,以至于一个团队能够将 Unreal 4 引擎移植到 Web 浏览器中,以近乎原生的性能直接运行 3D 第一人称视角游戏。

WebAssembly:新希望

时间很快来到 2017 年,WebAssembly发布了,这是一种基于栈的虚拟机的二进制指令格式。WebAssembly 提供了一个可移植的编译目标(简称 Wasm),与 asm.js 相比,它有几个优点:

  • 作为字节码格式,不需要解析脚本和预编译来进行优化。代码可以直接翻译成本机指令。与 asm.js 相比,加载和开始执行代码的启动时间要快几个数量级。
  • 字节码格式是一种更紧凑的代码交付方式。
  • Wasm 实现了自己的指令集,因此不受 JavaScript 语言的限制。

任何编译成 asm.js 的代码都可以把 WebAssembly 作为目标。对于前面的示例,编译器标志的一个简单修改就会生成一个扩展名为.wasm 的文件。该文件只有 116 字节长。尽管该文件包含字节码,但也存在代码的标准化文本表示形式,名为WebAssembly 文本格式。这是 WebAssembly 中 find 模块的文本表示:

复制代码
(module
(type $t0 (func (param i32 i32) (result i32)))
(import "env" "memory" (memory $env.memory 256 256))
(func $a (type $t0) (param $p0 i32) (param $p1 i32) (result i32)
(local $l0 i32) (local $l1 i32) (local $l2 i32) (local $l3 i32)
get_local $p0
set_local $l0
loop $L0
get_local $l0
i32.load8_s
tee_local $l2
i32.eqz
set_local $l1
get_local $l0
i32.const 1
i32.add
set_local $l3
get_local $l1
i32.const 1
i32.xor
get_local $p1
i32.const 24
i32.shl
i32.const 24
i32.shr_s
get_local $l2
i32.ne
i32.and
if $I1
get_local $l3
set_local $l0
br $L0
end
end
i32.const -1
get_local $l0
get_local $p0
i32.sub
get_local $l1
select)

代码大小进行了优化,因此函数被重命名为了 a。

WebAssembly 现在是 1.x 稳定版,支持所有的现代浏览器,包括手机。若干语言都以 Wasm 作为有效的编译目标。你可以使用 C、C++、Go、Rust、TypeScript 和许多其他语言来构建 WebAssembly 程序。它已经应用于计算机视觉、音频混合、视频编解码器支持、数字信号处理、医学成像、物理模拟、加密、压缩等解决方案中。

但是 C# 呢?

在 WebAssembly 推出之后,将. NET 框架的工作版本(包括它的公共语言运行时)移植到 WebAssembly 上运行的工作就立即开始了。

这一努力取得了成功。

浏览器和 Razor 视图引擎

2017 年底,微软软件工程师 Steve Sanderson 在他的个人博客上宣布了 Blazor 的消息。当时,它“只是一个实验”,并不是正式产品。它始于这样一个问题:“我们如何让.NET 在 WebAssembly 中运行?”第一个答案是比较老的简化版.NET 运行时,他能够在几个小时内将其编译成 Wasm 二进制文件。.NET 本身在浏览器中并不是非常有用:你需要一个 UI 和某种与用户交互的方式。Razor 文件将标记和 C# 结合起来创建 Web 模板,基于这项扎实的工作,Blazor 添加了大量的服务,从数据绑定和依赖注入到可重用组件、布局,以及调用 JavaScript 和从 JavaScript 调用。所有这些服务结合使得使用.NET 和 C# 构建单页面应用程序(SPA)成为可能。

图 1 默认 Blazor 应用程序

为什么会有人在意呢?开发人员最初对 Blazor 的反应非常积极,这主要是因为:

  • 它允许开发人员使用他们已经熟悉的语言(C#)和框架(.NET)来构建以前深深扎根在 JavaScript 中的客户端应用程序。
  • 它可以在所有现代浏览器中运行,包括移动浏览器,而且不需要插件。
  • 它使开发人员能够进入.NET 生态系统并“按原样”使用现有的库。例如,如果你正在构建一个使用 Markdown 的博客引擎,那么你可以为现有的 Markdown 引擎安装 NuGet 包,并将 Markdown 直接转换为 HTML,以便在浏览器中预览。
  • .NET 的性能会随着时间的推移不断提高,因此在浏览器的 Wasm 上运行已经足够了。
  • Blazor 是一个真正的单页应用程序,它从一组静态资产运行,可以使用Azure Storage 静态网站等服务以非常低的成本托管这些静态资产。

现在,你已经了解了 Blazor 背后的历史和动机,让我们来研究一些技术细节。

本文中的所有代码示例都可以在Blazor WebAssembly GitHub 存储库中找到。

安装 Blazor 并开始使用所需要了解的所有内容都可以在Blazor 入门这篇文章中找到。在安装了 Blazor 之后,你可以选择只创建客户端或带有ASP.NET Core后端的客户端。对于现有的基于 MVC 的服务器端项目来说,该项目看起来非常熟悉。但是,生成的 DLL 直接加载到浏览器中,并由.NET 的 WebAssembly 版本运行。

图 2 Blazor 应用中的网络活动

Mono.js JavaScript 动态加载 mono.wasm 并开始在浏览器中运行.NET。其余的加载是组成应用程序的实际 DLL 文件。

浏览器中的 C#(带依赖注入)

默认模板包含一个获取模拟天气信息的页面。这是 Razor 视图,完全由 Wasm 在客户端上渲染。

复制代码
<h1>Weather forecast</h1>
<p>This component demonstrates fetching data from the server.</p>
@if (forecasts == null)
{
<p><em>Loading...</em></p>
}
else
{
<tableclass="table">
<thead>
<tr>
<th>Date</th>
<th>Temp. (C)</th>
<th>Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}

在模板的上部,一组指令决定了页面的路径,声明了一个 using 指令,并使用依赖注入来获取.NET Framework 的 HttpClient 副本,该副本可以在浏览器中使用。

复制代码
@page "/fetchdata"
@using GetStarted.Shared
@inject HttpClient Http

最后,页面上的一小段代码嵌入到 @functions 块中。这里需要注意的是,代码完全是 C# 的。你可以使用熟悉的 HttpClient 执行网络操作,并且支持 async/wait。

复制代码
WeatherForecast[] forecasts;
protected override async Task OnInitAsync()
{
forecasts = await Http.GetJsonAsync<WeatherForecast[]>("api/SampleData/WeatherForecasts");
}

视图模板渲染一个页面,但是控件呢?

可重用组件

Blazor 基于分层组件的可组合 UI。天气预报组件与其他组件的唯一区别是提供路由的页面指令。下面是一个名为 LabelSlider.razor 的组件的模板,使用一个显示当前值的 span 扩展内置的 HTML Input Range。

复制代码
<inputtype="range"min="@Min"max="@Max"bind-value-oninput="@CurrentValue"/>
<span>@CurrentValue</span>

绑定语法的格式为 bind-{property}-{event}。事件是可选的,触发事件时绑定会更新。如果没有这个,滑块只会在用户停止移动滚动条时更新 span。通过连接到 oninput,该值将随着滑块的移动而刷新。

关联的代码暴露了参数,这些参数允许父组件设置最小和最大范围值,并将数据绑定到当前值。Action 属性暴露一个与 CurrentValue 关联的事件,并按照约定将其命名为 CurrentValueChanged,以方便双向数据绑定(父组件可以“侦听”更改事件并相应更新绑定值)。

复制代码
[Parameter]
int Min { get; set; }
[Parameter]
int Max { get; set; }
private int _currentValue;
[Parameter]
int CurrentValue
{
get => _currentValue;
set
{
if (value != _currentValue)
{
_currentValue = value;
CurrentValueChanged?.Invoke(value);
}
}
}
[Parameter]
Action<int> CurrentValueChanged { get; set; }

注意,没有使用 Parameter 属性标记的属性只对组件可见。组件重用非常简单,只需把组件名置入标签并提供必要的参数,用法如下:

复制代码
<LabelSlider Min="0" Max="99" bind-CurrentValue="@currentCount"/>

在本例中,一个与父组件上的 currentCount 属性之间的双向绑定建立起来了。

使用现有的库

Blazor 的一个非常强大的优点是能够“按原样”集成现有的类库。例如,考虑一个使用Markdown的博客引擎,它能够在浏览器中预览生成的 HTML。在 Blazor 中构建它就像安装一个 NuGet 包一样简单,在本例中是开源的MarkDig处理器。然后,可以直接调用库:

复制代码
var html = Markdig.Markdown.ToHtml(SourceText);

NuGet DLL 像其他项目引用一样被导入浏览器,并且可以从客户端应用程序调用。

图 3 浏览器中的 Markdown 转换

调用 JavaScript/ 从 JavaScript 调用

Blazor 提供的一个重要服务是能够从.NET 调用 JavaScript,反之亦然。你希望从 Blazor 调用的任何 JavaScript 方法都必须能够从全局 window 对象访问,调用方法如下:

复制代码
window.jsAlert = msg => alert(msg);

该互操作功能的使用方式如下:

复制代码
await JsRuntime.InvokeAsync<object>("jsAlert", "Wow!");

InvokeAsync 方法支持传递和返回值,这些值将自动在 JavaScript 与.NET 之间转换并由 Blazor 运行时编组。使用 JsInvokable 属性来暴露 C# 方法,以便可以从 JavaScript 调用它。下面是一个例子,它封装了 Markdown 转换调用:

复制代码
public static class Markdown
{
[JSInvokable]
public static string Convert(string src)
{
return Markdig.Markdown.ToHtml(src);
}
}

从 JavaScript 调用 DotNet.invoke 方法并传递程序集名称、暴露的方法名称和任何参数。

图 4 从 JavaScript 调用.NET

这使得扩展遗留应用程序和使用现有的 JavaScript 库成为可能。你甚至可以从 Blazor 应用程序调用其他 WebAssembly 模块。

Blazor 在发展

微软将 Blazor 移出了实验阶段,进入了官方预览版。使用组件模型进行服务器端渲染的 Blazor 版本将与.NET Core 3 的最终版本一起发布(请参阅.NET Core 路线图),客户端版本将在随后不久发布。还有工作要完成:调试体验极其有限,必须改进;有机会通过提前编译生成本机 Wasm 来优化代码性能;在将未使用的代码库发送到浏览器之前,需要从库中删除未使用的代码,从而降低总体大小(这个过程称为树抖动)。对 WebAsssembly 的兴趣和采用与日俱增,借助 Blazor,编写可以在任何地方运行的 C# 和.NET 代码的梦想终于实现了。

关于作者

Jeremy Likness是微软 Azure 的云推广专员。Jeremy 在 1982 年编写了他的第一个程序,并且已经开发企业应用程序 25 年了。他是四本科技书籍的作者,曾做过 8 年微软 MVP,是国际性的主题演讲家。Jeremy 的饮食以植物为主,在大部分空闲时间里,他都在太平洋西北部他的家附近跑步、徒步旅行和露营。关注Jeremy 的博客

查看英文原文::WebAssembly and Blazor: A Decades Old Problem Solved

收藏

评论

微博

发表评论

注册/登录 InfoQ 发表评论

最新评论

张家华 2019 年 06 月 12 日 08:53 1 回复
用c#来写前端?前后端分离的大趋势。在h5越来越方便,各种前端框架大行其道的时候,用c#来写前端么? webassembly没啥问题,前途光明。但blazor还搞前后端混合开发,c#代码嵌入页面中开发前端,就是拍脑袋想出来的。 mvc的Razor视图模式已经沦为鸡肋,淘汰是必然。前后端混合开发注定了要被抛弃。
是不是你的思维有点固化了,依托Blazor用C#来做独立前端开发又怎么不行了? 其本质不也就是一个前端框架吗?不是JS就不许做前端了?你立的行业标准? 而且Blazor允许前后端使用相同的技术栈和类库,来保障逻辑一致性,这是其一大利好。 0 回复
小欣 2019 年 06 月 11 日 11:22 0 回复
Silverlight 没有起来的原因是flash的风生水起,当时直面flash 的市场占有率,Silverlight 根本无力反击。 后来网页插件动画的时代随着h5的到来而逐渐凋零,flash 好歹还和时代抗争了一下,残喘了一阵子,Silverlight 根本就是无人问津啊。
当场就吓脲了 2019 年 06 月 11 日 10:48 0 回复
能使用js的已有生态吗?如果不行,那就难了
没有更多了