从 JavaScript 运行 .NET

本文介绍如何使用 JS[JSImport]/[JSExport] 互操作从 JavaScript 运行 .NET (JS)。

有关其他指导,请参阅 .NET 运行时 (dotnet/runtime) GitHub 存储库中的配置和托管 .NET WebAssembly 应用程序指南。

现有 JS 应用可以使用扩展的客户端 WebAssembly 支持来重复使用 JS 中的 .NET 库,或生成基于 .NET 的新式应用和框架。

注意

本问重点介绍在不依赖 Blazor 的情况下如何从 JS 应用运行 .NET。 有关在 Blazor WebAssembly 应用中使用 [JSImport]/[JSExport] 互操作的指导,请参阅 JavaScript JSImport/JSExport 与 ASP.NET Core Blazor 互操作

这些方法仅在预计在 WebAssembly (WASM) 上运行时才适用。 库可以通过调用 OperatingSystem.IsBrowser 来进行运行时检查以确定应用是否在 WASM 上运行。

先决条件

安装最新版本的 .NET SDK

在管理命令 shell 中安装 wasm-tools 工作负载,这将引入相关的 MSBuild 目标:

dotnet workload install wasm-tools

这些工具也可通过 Visual Studio 安装程序安装在 Visual Studio 安装程序中的“ASP.NET 和 web 开发”工作负载下。 从可选组件列表中选择“.NET WebAssembly 生成工具”选项。

(可选)安装 wasm-experimental 工作负载,其中包含用于在浏览器应用(WebAssembly 浏览器应用)或在基于 Node.js 的控制台应用(WebAssembly 控制台应用)中开始使用 WebAssembly 上的 .NET 的实验性项目模板。 如果计划将 JS[JSImport]/[JSExport] 互操作集成到现有 JS 应用中,则不需要此工作负载。

dotnet workload install wasm-experimental

还可以通过以下命令从 Microsoft.NET.Runtime.WebAssembly.Templates NuGet 包安装模板:

dotnet new install Microsoft.NET.Runtime.WebAssembly.Templates

有关详细信息,请参阅实验性工作负载和项目模板部分。

命名空间

本文中描述的 JS 互操作 API 由 System.Runtime.InteropServices.JavaScript 命名空间中的属性控制。

项目配置

配置项目 (.csproj) 以启用 JS 互操作:

  • 设置目标框架名字对象{TARGET FRAMEWORK} 占位符):

    <TargetFramework>{TARGET FRAMEWORK}</TargetFramework>
    

    支持 .NET 7 (net7.0) 或更高版本。

  • 启用 AllowUnsafeBlocks 属性,这允许 Roslyn 编译器中的代码生成器将指针用于 JS 互操作:

    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    

    警告

    JS 互操作 API 需要启用 AllowUnsafeBlocks。 在 .NET 应用中实现自己的不安全代码时要小心,这可能会带来安全和稳定性风险。 有关详细信息,请参阅不安全代码、指针类型和函数指针

下面是配置后的示例项目文件 (.csproj)。 {TARGET FRAMEWORK} 占位符是目标框架:

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

  <PropertyGroup>
    <TargetFramework>{TARGET FRAMEWORK}</TargetFramework>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>

</Project>
  • 设置目标框架名字对象

    <TargetFramework>net7.0</TargetFramework>
    

    支持 .NET 7 (net7.0) 或更高版本。

  • 为运行时标识符指定 browser-wasm

    <RuntimeIdentifier>browser-wasm</RuntimeIdentifier>
    
  • 指定可执行输出类型:

    <OutputType>Exe</OutputType>
    
  • 启用 AllowUnsafeBlocks 属性,这允许 Roslyn 编译器中的代码生成器将指针用于 JS 互操作:

    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    

    警告

    JS 互操作 API 需要启用 AllowUnsafeBlocks。 在 .NET 应用中实现自己的不安全代码时要小心,这可能会带来安全和稳定性风险。 有关详细信息,请参阅不安全代码、指针类型和函数指针

  • 指定 WasmMainJSPath 以指向磁盘上的文件。 此文件随应用一起发布,但如果要将 .NET 集成到现有 JS 应用中,则不需要使用此文件。

    在下面的示例中,磁盘上的 JS 文件为 main.js,但任何 JS 文件名都是允许的:

    <WasmMainJSPath>main.js</WasmMainJSPath>
    

配置后的示例项目文件 (.csproj):

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

  <PropertyGroup>
    <TargetFramework>net7.0</TargetFramework>
    <RuntimeIdentifier>browser-wasm</RuntimeIdentifier>
    <OutputType>Exe</OutputType>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    <WasmMainJSPath>main.js</WasmMainJSPath>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

WASM 上的 JavaScript 互操作

以下示例中的 API 是从 dotnet.js 导入的。 这些 API 使你可以设置可导入到 C# 代码中的命名模块,并调用 .NET 代码公开的方法,包括 Program.Main

重要

本文中的“导入”和“导出”是从 .NET 的角度定义的:

  • 应用导入 JS 方法,以便可以从 .NET 调用它们。
  • 应用导出 .NET 方法,以便可以从 JS 调用它们。

如下示例中:

  • dotnet.js 文件用于创建和启动 .NET WebAssembly 运行时。 dotnet.js 作为应用的生成输出的一部分生成。

    重要

    要与现有应用集成,可将发布输出文件夹†的内容复制到现有应用的部署资产中,以便与应用的其他部分一起提供。 对于生产部署,可在命令 shell 中使用 dotnet publish -c Release 命令发布应用,并使用应用部署输出文件夹的内容。

    †发布输出文件夹是发布配置文件的目标位置。 在 .NET 8 或更高版本中,Release 配置文件的默认值是 bin/Release/{TARGET FRAMEWORK}/publish,其中 {TARGET FRAMEWORK} 占位符是目标框架(例如 net8.0)。

  • dotnet.create() 设置 .NET WebAssembly 运行时。

  • setModuleImports 将名称与要导入到 .NET 中的 JS 函数模块相关联。 JS 模块包含一个 dom.setInnerText 函数,该函数接受元素选择器和时间,以便在 UI 中显示当前秒表时间。 模块的名称可以是任意字符串(它不需要是文件名),但它必须与用于 JSImportAttribute(将在本文的后面进行介绍)的名称相匹配。 dom.setInnerText 函数将导入 C# 中,并由 C# 方法 SetInnerText 调用。 本部分稍后将展示 SetInnerText 方法。

  • exports.StopwatchSample.Reset() 从 JS 调用 .NET (StopwatchSample.Reset)。 Reset C# 方法会在秒表正在运行时,重启秒表,在秒表没有运行时,重置秒表。 本部分稍后将展示 Reset 方法。

  • exports.StopwatchSample.Toggle() 从 JS 调用 .NET (StopwatchSample.Toggle)。 Toggle C# 方法根据秒表当前是否正在运行来启动或停止秒表。 本部分稍后将展示 Toggle 方法。

  • runMain() 运行 Program.Main

  • setModuleImports 将名称与要导入到 .NET 中的 JS 函数模块相关联。 JS 模块包含一个 window.location.href 函数,该函数返回当前页面地址 (URL)。 模块的名称可以是任意字符串(它不需要是文件名),但它必须与用于 JSImportAttribute(将在本文的后面进行介绍)的名称相匹配。 window.location.href 函数将导入 C# 中,并由 C# 方法 GetHRef 调用。 本部分稍后将展示 GetHRef 方法。

  • exports.MyClass.Greeting() 从 JS 调用 .NET (MyClass.Greeting)。 Greeting C# 方法返回一个字符串,其中包含调用 window.location.href 函数的结果。 本部分稍后将展示 Greeting 方法。

  • dotnet.run() 运行 Program.Main

JS模块:

import { dotnet } from './_framework/dotnet.js'

const { setModuleImports, getAssemblyExports, getConfig, runMain } = await dotnet
  .withApplicationArguments("start")
  .create();

setModuleImports('main.js', {
  dom: {
    setInnerText: (selector, time) => 
      document.querySelector(selector).innerText = time
  }
});

const config = getConfig();
const exports = await getAssemblyExports(config.mainAssemblyName);

document.getElementById('reset').addEventListener('click', e => {
  exports.StopwatchSample.Reset();
  e.preventDefault();
});

const pauseButton = document.getElementById('pause');
pauseButton.addEventListener('click', e => {
  const isRunning = exports.StopwatchSample.Toggle();
  pauseButton.innerText = isRunning ? 'Pause' : 'Start';
  e.preventDefault();
});

await runMain();
import { dotnet } from './_framework/dotnet.js'

const { setModuleImports, getAssemblyExports, getConfig } = await dotnet
  .withDiagnosticTracing(false)
  .withApplicationArgumentsFromQuery()
  .create();

setModuleImports('main.js', {
  window: {
    location: {
      href: () => globalThis.window.location.href
    }
  }
});

const config = getConfig();
const exports = await getAssemblyExports(config.mainAssemblyName);
const text = exports.MyClass.Greeting();
console.log(text);

document.getElementById('out').innerHTML = text;
await dotnet.run();
import { dotnet } from './dotnet.js'

const is_browser = typeof window != "undefined";
if (!is_browser) throw new Error(`Expected to be running in a browser`);

const { setModuleImports, getAssemblyExports, getConfig } = 
  await dotnet.create();

setModuleImports("main.js", {
  window: {
    location: {
      href: () => globalThis.window.location.href
    }
  }
});

const config = getConfig();
const exports = await getAssemblyExports(config.mainAssemblyName);
const text = exports.MyClass.Greeting();
console.log(text);

document.getElementById("out").innerHTML = text;
await dotnet.run();

若要导入 JS 函数,以便可以从 C# 调用它,请对匹配的方法签名使用新的 JSImportAttributeJSImportAttribute 的第一个参数是要导入的 JS 函数的名称,第二个参数是模块的名称。

在以下示例中,当调用 SetInnerText 方法时,会从 main.js 模块调用 dom.setInnerText 函数:

[JSImport("dom.setInnerText", "main.js")]
internal static partial void SetInnerText(string selector, string content);

在以下示例中,当调用 GetHRef 方法时,会从 main.js 模块调用 window.location.href 函数:

[JSImport("window.location.href", "main.js")]
internal static partial string GetHRef();

在导入的方法签名中,可以将 .NET 类型用于参数和返回值,它们由运行时自动封送。 使用 JSMarshalAsAttribute<T> 控制导入的方法参数的封送方式。 例如,可以选择将 long 封送为 System.Runtime.InteropServices.JavaScript.JSType.NumberSystem.Runtime.InteropServices.JavaScript.JSType.BigInt。 可以将 Action/Func<TResult> 回调作为参数传递,这些回调被封送为可调用的 JS 函数。 可以同时传递 JS 和托管对象引用,它们被封送为代理对象,使对象在边界上保持活动状态,直到代理被垃圾回收。 还可以导入和导出具有 Task 结果的异步方法,这些结果被封送为 JS 承诺。 大多数封送类型在导入和导出的方法上作为参数和返回值双向工作。

下表指示受支持的类型映射。

.NET JavaScript Nullable TaskPromise JSMarshalAs 可选 Array of
Boolean Boolean 支持 支持 支持 不支持
Byte Number 支持 支持 支持 支持
Char String 支持 支持 支持 不支持
Int16 Number 支持 支持 支持 不支持
Int32 Number 支持 支持 支持 支持
Int64 Number 支持 支持 不支持 不支持
Int64 BigInt 支持 支持 不支持 不支持
Single Number 支持 支持 支持 不支持
Double Number 支持 支持 支持 支持
IntPtr Number 支持 支持 支持 不支持
DateTime Date 支持 支持 不支持 不支持
DateTimeOffset Date 支持 支持 不支持 不支持
Exception Error 不支持 支持 支持 不支持
JSObject Object 不支持 支持 支持 支持
String String 不支持 支持 支持 支持
Object Any 不支持 支持 不支持 支持
Span<Byte> MemoryView 不支持 不支持 不支持 不支持
Span<Int32> MemoryView 不支持 不支持 不支持 不支持
Span<Double> MemoryView 不支持 不支持 不支持 不支持
ArraySegment<Byte> MemoryView 不支持 不支持 不支持 不支持
ArraySegment<Int32> MemoryView 不支持 不支持 不支持 不支持
ArraySegment<Double> MemoryView 不支持 不支持 不支持 不支持
Task Promise 不支持 不支持 支持 不支持
Action Function 不支持 不支持 不支持 不支持
Action<T1> Function 不支持 不支持 不支持 不支持
Action<T1, T2> Function 不支持 不支持 不支持 不支持
Action<T1, T2, T3> Function 不支持 不支持 不支持 不支持
Func<TResult> Function 不支持 不支持 不支持 不支持
Func<T1, TResult> Function 不支持 不支持 不支持 不支持
Func<T1, T2, TResult> Function 不支持 不支持 不支持 不支持
Func<T1, T2, T3, TResult> Function 不支持 不支持 不支持 不支持

以下条件适用于类型映射和封送的值:

  • Array of 列指示是否可以将 .NET 类型封送为 JSArray。 示例:C# int[] (Int32) 映射为 Number 的 JSArray
  • 将 JS 值传递给具有错误类型的值的 C# 时,在大多数情况下,框架将引发异常。 框架不会在 JS 中执行编译时类型检查。
  • JSObjectExceptionTaskArraySegment 将创建 GCHandle 和代理。 你可以在开发人员代码中触发处置,或允许 .NET 垃圾回收 (GC) 来稍后处置对象。 这些类型会产生显著的性能开销。
  • Array:封送数组会在 JS 或 .NET 中创建数组的副本。
  • MemoryView
    • MemoryView 是 .NET WebAssembly 运行时用于封送 SpanArraySegment 的 JS 类。
    • 与封送数组不同,封送 SpanArraySegment 不会创建基础内存的副本。
    • MemoryView 只能由 .NET WebAssembly 运行时正确实例化。 因此,不能将 JS 函数导入为具有 SpanArraySegment 参数的 .NET 方法。
    • Span 创建的 MemoryView 仅在互操作调用期间有效。 由于在调用堆栈上分配了 Span,这在互操作调用之后不会保留,因此不能导出返回 Span 的 .NET 方法。
    • ArraySegment 创建的 MemoryView 会在互操作调用后存活,并且可用于共享缓冲区。 对为 ArraySegment 创建的 MemoryView 调用 dispose() 将处置代理并将取消固定基础 .NET 数组。 建议在 try-finally 块中为 MemoryView 调用 dispose()

可以通过在函数名称中使用 globalThis 前缀并使用 [JSImport] 属性来导入全局命名空间上可访问的函数,而不提供模块名称。 在下面的示例中,console.logglobalThis 为前缀。 导入的函数由 C# Log 方法调用,该方法接受 C# 字符串消息 (message) 并将 C# 字符串封送到 console.log 的 JSString

[JSImport("globalThis.console.log")]
internal static partial void Log([JSMarshalAs<JSType.String>] string message);

若要导出 .NET 方法,以便可以从 JS 调用它,请使用 JSExportAttribute

在下面的示例中,每个方法都导出到 JS 中,可以从 JS 函数调用:

  • Toggle 方法根据秒表的正在运行状态启动或停止秒表。
  • Reset 方法会在秒表正在运行时,重启秒表,在秒表没有运行时,重置秒表。
  • IsRunning 方法指示秒表是否正在运行。
[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;

在下面的示例中,Greeting 方法将返回一个字符串,其中包含调用 GetHRef 方法的结果。 如前所示,GetHref C# 方法会从 main.js 模块调用 window.location.href 函数的 JS。 window.location.href 将返回当前页面地址 (URL):

[JSExport]
internal static string Greeting()
{
    var text = $"Hello, World! Greetings from {GetHRef()}";
    Console.WriteLine(text);
    return text;
}

实验性工作负载和项目模板

若要演示 JS 互操作功能并获取 JS 互操作项目模板,请安装 wasm-experimental 工作负载:

dotnet workload install wasm-experimental

wasm-experimental 工作负载包含两个项目模板:wasmbrowserwasmconsole。 这些模板目前属于实验性质,这意味着模板的开发人员工作流正在不断发展变化。 但是,模板中使用的 .NET 和 JS API 在 .NET 8 中受支持,并且为从 JS 使用 WASM 上的 .NET 提供了基础。

还可以通过以下命令从 Microsoft.NET.Runtime.WebAssembly.Templates NuGet 包安装模板:

dotnet new install Microsoft.NET.Runtime.WebAssembly.Templates

浏览器应用

可以通过命令行使用 wasmbrowser 模板创建浏览器应用,该模板将创建一个 Web 应用,用于演示在浏览器中一起使用 .NET 和 JS:

dotnet new wasmbrowser

或者,在 Visual Studio 中,可以使用 WebAssembly Browser App 项目模板创建应用。

从 Visual Studio 或通过使用 .NET CLI 生成应用:

dotnet build

从 Visual Studio 或通过使用 .NET CLI 生成并运行应用:

dotnet run

或者,安装和使用 dotnet serve 命令

dotnet serve -d:bin/$(Configuration)/{TARGET FRAMEWORK}/publish

在上面的示例中,{TARGET FRAMEWORK} 占位符是目标框架名字对象

Node.js 控制台应用

可以使用 wasmconsole 模板创建一个控制台应用,该模板将创建一个在 WASM 下作为 Node.jsV8 控制台应用来运行的应用:

dotnet new wasmconsole

或者,在 Visual Studio 中,可以使用 WebAssembly Console App 项目模板创建应用。

从 Visual Studio 或通过使用 .NET CLI 生成应用:

dotnet build

从 Visual Studio 或通过使用 .NET CLI 生成并运行应用:

dotnet run

或者,从包含 main.mjs 文件的发布输出目录中启动任何静态文件服务器:

node bin/$(Configuration)/{TARGET FRAMEWORK}/{PATH}/main.mjs

在上面的示例中,{TARGET FRAMEWORK} 占位符是目标框架名字对象{PATH} 占位符是 main.mjs 文件的路径。

其他资源