无需Blazor:在浏览器中直接运行.NET的完整指南

本文详细介绍了如何在浏览器中不使用Blazor框架直接运行.NET代码,通过WebAssembly技术实现.NET与JavaScript的互操作,包括模板安装、项目创建、代码交互机制以及发布优化等完整流程。

无需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应用程序

安装模板后,我们可以创建一个新应用程序:

1
dotnet new wasmbrowser

模板创建以下文件:

我们将很快查看这些文件中的大部分,但首先我们将运行该应用程序。您可以使用简单的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应用程序(正如我们正在讨论的)启用类似的资产指纹识别。

要启用此行为,您需要做几件事:

  1. 在index.html中添加<script type="importmap"></script>
  2. 在index.html中的脚本引用中添加#[.{fingerprint}]
  3. 设置OverrideHtmlAssetPlaceholders=true
  4. 使用<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中客户端指纹识别的一些更改,这些更改使您能够为发布的资产启用缓存清除指纹识别。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计