无需Blazor:在浏览器中直接运行.NET的完整指南
背景介绍
WebAssembly(WASM)在2017年首次出现在.NET领域,当时Steve Sanderson展示了最终成为Blazor的技术演示。Blazor是一个完整的基于组件的Web框架,使用HTML和C#构建Web应用程序。它可以在多种渲染模式下运行,其中交互式WebAssembly模式完全在浏览器中使用WASM的能力运行。
当谈到.NET和WASM时,大多数人会立即想到Blazor,但实际上还有其他几种将.NET与WASM结合的方式:
- 不使用Blazor在浏览器中通过WASM运行.NET
- 在服务器的Node.js进程中通过WASM运行.NET
- 编写与WebAssembly系统接口(WASI)兼容的.NET组件(并调用用其他语言编写的其他WASI组件)
此外,您还可以将Blazor组件集成到其他JavaScript框架(如Vue或React)中,.NET 10将改进此过程。
安装实验性WASM模板
用于构建可从JavaScript运行的.NET应用程序的模板不随默认SDK一起提供。它们是实验性的,因此您需要显式安装它们。安装哪个NuGet包取决于您需要的模板版本:
- .NET 8: Microsoft.NET.Runtime.WebAssembly.Templates
- .NET 9: Microsoft.NET.Runtime.WebAssembly.Templates.net9
- .NET 10: Microsoft.NET.Runtime.WebAssembly.Templates.net10
我们需要最新的模板,因此安装.NET 10模板(撰写时为预览版6):
1
|
dotnet new install Microsoft.NET.Runtime.WebAssembly.Templates.net10
|
这将安装三个模板:
1
2
3
4
5
6
|
Success: Microsoft.NET.Runtime.WebAssembly.Templates.net10::10.0.0-preview.6.25358.103 installed the following templates:
Template Name Short Name Language Tags
----------------------- ----------- -------- -----------------------
Wasi Console App wasiconsole [C#] Wasi/WasiConsole
WebAssembly Browser App wasmbrowser [C#] Web/WebAssembly/Browser
WebAssembly Console App wasmconsole [C#] Web/WebAssembly/Console
|
或者,您可以安装wasm-experimental工作负载,其中包括模板以及…一堆东西😅不过,我不确定这些额外的东西实际上是用来做什么的,因为据我所知,这些都不需要🤷♂️
1
|
dotnet workload install wasm-experimental
|
请注意,如果您想要AOT编译生成的应用程序,则还需要安装wasm-tools工作负载。这将提供更好的性能,但会大大增加文件大小(从而增加启动时间),因此您需要决定在那里进行哪些权衡。
创建.NET WASM应用程序
安装模板后,我们可以创建一个新应用程序:
模板创建以下文件:
我们将很快查看这些文件中的大部分,但首先我们将运行该应用程序。您可以使用简单的dotnet run
运行它:
1
2
3
4
5
6
|
WasmAppHost --use-staticwebassets --runtime-config D:\repos\temp\bin\Debug\net10.0\temp.runtimeconfig.json
App url: http://localhost:5156/
App url: https://localhost:7048/
Debug at url: http://localhost:5156/_framework/debug
Debug at url: https://localhost:7048/_framework/debug
|
如果您在浏览器中打开该应用程序,您将看到模板是一个简单的秒表应用程序。它会在您打开页面时立即启动,然后您可以暂停、重置和启动计时器。
探索模板
我们将从查看Program.cs开始,它是一个顶级程序,包含一个名为StopwatchSample的辅助类型。“程序"本身非常简单,如下所示。首先它写入控制台(将出现在浏览器的控制台窗口中),然后如果正确的参数传递给程序,则可选地调用静态方法StopwatchSample.Start()。然后它进入一个无限循环,每秒调用Render()。
1
2
3
4
5
6
7
8
9
10
|
Console.WriteLine("Hello, Browser!");
if (args.Length == 1 && args[0] == "start")
StopwatchSample.Start();
while (true)
{
StopwatchSample.Render();
await Task.Delay(1000);
}
|
大部分实现在StopwatchSample类型中定义,如下所示。通常,此类型是静态System.Diagnostics.Stopwatch实例的简单包装器。有趣的部分是调用SetInnerText(用[JSImport]属性装饰)的Render()方法,以及用[JSExport]属性装饰的其他方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
partial class StopwatchSample
{
private static Stopwatch stopwatch = new();
public static void Start() => stopwatch.Start();
public static void Render() => SetInnerText("#time", stopwatch.Elapsed.ToString(@"mm\:ss"));
[JSImport("dom.setInnerText", "main.js")]
internal static partial void SetInnerText(string selector, string content);
[JSExport]
internal static bool Toggle()
{
if (stopwatch.IsRunning)
{
stopwatch.Stop();
return false;
}
else
{
stopwatch.Start();
return true;
}
}
[JSExport]
internal static void Reset()
{
if (stopwatch.IsRunning)
stopwatch.Restart();
else
stopwatch.Reset();
Render();
}
[JSExport]
internal static bool IsRunning() => stopwatch.IsRunning;
}
|
正如您可能已经猜到的,[JSImport]和[JSExport]提供了从.NET代码与浏览器中的JavaScript交互的方法。这些属性用于驱动两个源生成器,分别是Microsoft.Interop.JavaScript中的JSImportGenerator和JSExportGenerator。因此,您可以F12在IDE中查看生成的源代码,并确切了解它在做什么。最终,这是有些棘手的代码,因此我不打算在这里详细介绍,但它本质上只是在.NET(WASM)世界和JavaScript世界之间进行封送处理,绑定现有的JavaScript函数(在[JSImport]的情况下),或描述要公开的方法的形状以供JavaScript调用。
为了理解这个生成的代码与什么交互,我们接下来看一下HTML和JavaScript代码。HTML非常基础:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
<!DOCTYPE html>
<html>
<head>
<title>temp78</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 👇 These are updated during dotnet run and dotnet publish -->
<link rel="preload" id="webassembly" />
<script type="importmap"></script>
<script type='module' src="main#[.{fingerprint}].js"></script>
</head>
<body>
<h1>Stopwatch</h1>
<p>
Time elapsed in .NET is <span id="time"><i>loading...</i></span>
</p>
<p>
<button id="pause">Pause</button>
<button id="reset">Reset</button>
</p>
</body>
</html>
|
这里的HTML显示了我们之前看到的应用程序的大致轮廓。它包括一些链接和脚本元素,这些是连接.NET WASM组件所必需的,并且它包括应用程序的基本元素结构,包括一堆具有显式id的元素。
接下来我们查看main.js文件,它是应用程序的入口点,因为它直接链接在上面的index.html文件中。我为此添加了注释以解释每个步骤在做什么:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
// Import the .NET runtime support
import { dotnet } from './_framework/dotnet.js'
const { setModuleImports, getAssemblyExports, getConfig, runMain } = await dotnet
.withApplicationArguments("start") // these are the args passed to the program
.create(); // Set up the .NET WASM runtime
// setModuleImports associates a set of imports (dom.setInnerText) with an
// associated module (main.js). This pair must match the values provided in the
// [JSImport] attribute to connect everything up correctly
setModuleImports('main.js', {
dom: {
setInnerText: (selector, time) => document.querySelector(selector).innerText = time
}
});
// Return information about the environment and app. e.g. Environment variables (very few)
// runtimeConfig, assembly name, referenced assemblies etc
const config = getConfig();
// get all the functions exposed in the main assembly by [JSExport], so that they
// can be invoked from JavaScript
const exports = await getAssemblyExports(config.mainAssemblyName);
// attach a click handler to the reset button and invoke the exported
// StopwatchSample.Reset() function
document.getElementById('reset').addEventListener('click', e => {
exports.StopwatchSample.Reset();
e.preventDefault();
});
// attach a click handler to the pause button and invoke the exported
// StopwatchSample.Toggle() function
const pauseButton = document.getElementById('pause');
pauseButton.addEventListener('click', e => {
const isRunning = exports.StopwatchSample.Toggle();
pauseButton.innerText = isRunning ? 'Pause' : 'Start';
e.preventDefault();
});
// run the C# Main() method and keep the runtime process running and executing further API calls
await runMain();
|
这几乎涵盖了所有内容。总结一下:
- [JSExport]和[JSImport]生成处理与JavaScript类型之间封送的C#代码。
- Index.html引用捆绑的WASM .NET运行时和您编译的应用程序。
- main.js处理启动.NET运行时,为您的应用程序需要调用JavaScript的地方提供所需的导入,并运行.NET应用程序。
这些功能周围的工具的一个不错的部分是,您可以只是dotnet run
或F5您的应用程序并在浏览器中运行它们,但最终在生产中运行时,您将希望发布您的项目。
发布您的WASM应用程序
您可以使用简单的dotnet publish -c Release
发布您的应用程序,默认情况下,工具将编译您的应用程序,发布并修剪框架引用,并对输出进行gzip和brotli压缩。
另一个有趣的点是这些资产的客户端指纹识别。.NET 9引入了服务器端静态资产指纹识别(使用MapStaticAssets()),在.NET 10中,您可以选择为Blazor WebAssembly应用程序以及无Blazor的WASM应用程序(正如我们正在讨论的)启用类似的资产指纹识别。
要启用此行为,您需要做几件事:
- 在index.html中添加
<script type="importmap"></script>
- 在index.html中的脚本引用中添加
#[.{fingerprint}]
- 设置
OverrideHtmlAssetPlaceholders=true
- 使用
<StaticWebAssetFingerprintPattern>
选择加入您的资产
这些在模板中默认完成,但最后一点有一个错误,应该在更新中修复。请继续阅读以获取更多详细信息。
前两点由.NET 10中模板的新增内容覆盖,该模板将importmap和指纹添加到main:
1
2
3
|
<link rel="preload" id="webassembly" />
<script type="importmap"></script>
<script type='module' src="main#[.{fingerprint}].js"></script>
|
模板还添加了<link rel="preload" id="webassembly" />
,它启用了webassembly文件的预加载,旨在改善冷启动时间。
当您运行应用程序时,这些元素被重写为类似于以下内容,所有文件都有指纹:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
<link href="_framework/dotnet.y5zm2li12l.js" rel="preload" as="script" fetchpriority="high" crossorigin="anonymous" integrity="sha256-eo4p7mQEfnCQ6TQ0N72uJX+t0QX8QmikrPGJjyy3QLQ=" />
<script type="importmap">{
"imports": {
"./_framework/dotnet.native.js": "./_framework/dotnet.native.hwglpvp32y.js",
"./_framework/dotnet.runtime.js": "./_framework/dotnet.runtime.0t78nptbqi.js",
"./_framework/dotnet.js": "./_framework/dotnet.y5zm2li12l.js",
"./main.js": "./main.ofkecrt505.js"
},
"scopes": {},
"integrity": {
"./_framework/dotnet.js": "sha256-eo4p7mQEfnCQ6TQ0N72uJX+t0QX8QmikrPGJjyy3QLQ=",
"./_framework/dotnet.native.hwglpvp32y.js": "sha256-0S3nkr+7+aZ+9tFRQOEYkmozryFXfrzgl+nv+qz71QM=",
"./_framework/dotnet.native.js": "sha256-0S3nkr+7+aZ+9tFRQOEYkmozryFXfrzgl+nv+qz71QM=",
"./_framework/dotnet.runtime.0t78nptbqi.js": "sha256-fBs/I1SdlqDOQOqxGF+LdElB3o5/FirA8fyIHRUy9cE=",
"./_framework/dotnet.runtime.js": "sha256-fBs/I1SdlqDOQOqxGF+LdElB3o5/FirA8fyIHRUy9cE=",
"./_framework/dotnet.y5zm2li12l.js": "sha256-eo4p7mQEfnCQ6TQ0N72uJX+t0QX8QmikrPGJjyy3QLQ=",
"./main.js": "sha256-9EZteoeGyecFbFTmMweXxx9ItCAClDZ8n+6lXEEulSU=",
"./main.ofkecrt505.js": "sha256-9EZteoeGyecFbFTmMweXxx9ItCAClDZ8n+6lXEEulSU="
}
}</script>
<script type='module' src="main.ofkecrt505.js"></script>
</head>
|
在.csproj文件中,模板还添加了所需的OverrideHtmlAssetPlaceholders和StaticWebAssetFingerprintPattern条目,这启用了上述行为:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
<Project Sdk="Microsoft.NET.Sdk.WebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<!-- 👇 Required for fingerprinting -->
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
</PropertyGroup>
<ItemGroup>
<!-- 👇 Required for fingerprinting -->
<StaticWebAssetFingerprintPattern Include="JS" Pattern="*.js" />
</ItemGroup>
</Project>
|
但是。如果您发布应用程序并检查index.html文件,您将看到main.js指纹明显缺失:
1
2
|
<!-- No fingerprint 👇 -->
<script type='module' src="main.js"></script>
|
那么这里发生了什么🤔原来这是模板中的一个错误,但您可以通过更改模板来解决它:
1
2
|
- <StaticWebAssetFingerprintPattern Include="JS" Pattern="*.js" />
+ <StaticWebAssetFingerprintPattern Include="JS" Pattern="*.js" Expression="#[.{fingerprint}]!" />
|
添加Expression=”#[.{fingerprint}]!“属性(在文档中引用)解决了发布时的问题,并确保指纹被添加到脚本文件中。
减小发布的应用程序大小
出于兴趣,我检查了这个示例应用程序的发布大小(在发布模式下),它大致如下:
- 6.8MB 未压缩
- 2.5MB 压缩(gzip)
- 2.0MB 压缩(brotli)
这包括所有文件,包括.NET运行时,所以还不错。运行时显然被大量修剪以达到这些大小,但我们可以更小吗?一个明显的突出点是icu程序集,所以我想知道启用全球化不变模式是否可以进一步减少事情。我将以下内容添加到项目文件中:
1
|
<InvariantGlobalization>true</InvariantGlobalization>
|
并再次运行dotnet publish -c Release
。果然,有一些不错的收益:
- 4.3MB 未压缩 — 减少2.5MB
- 1.7MB 压缩(gzip)— 减少0.8MB
- 1.4MB 压缩(brotli)— 减少0.6MB
这使总应用程序大小减少了30-37%,这是一个相当不错的减少!显然,这取决于您的应用程序是否可行全球化不变模式,但如果是的话,这是一个方便的工具。
这就是全部内容。这种在JavaScript中运行.NET代码的方法比使用Blazor或与其他Web框架交互要低级得多,因此您在此层插入时看到巨大价值的可能性要小得多。但是,如果您不需要Blazor,那么这可能正是您需要的!
总结
在本文中,我描述了使用WebAssembly(WASM)运行.NET代码的各种方式,重点是在不使用Blazor Web组件框架的情况下在浏览器中运行.NET代码。我逐步介绍了使用WASM在浏览器中运行.NET的基本模板,检查了.NET和JavaScript代码以了解它们如何协同工作。最后,我查看了.NET 10中客户端指纹识别的一些更改,这些更改使您能够为发布的资产启用缓存清除指纹识别。