.NET WebAssembly 中的 JavaScript
注意
此版本不是本文的最新版本。 有关当前版本,请参阅本文的 .NET 9 版本。
警告
此版本的 ASP.NET Core 不再受支持。 有关详细信息,请参阅 .NET 和 .NET Core 支持策略。 有关当前版本,请参阅本文的 .NET 9 版本。
本文介绍如何使用 JSJS[JSImport]
/ 互操作 ([JSExport]
API) 在客户端 WebAssembly 中与 JavaScript (System.Runtime.InteropServices.JavaScript) 交互。
在以下方案中,[JSImport]
/[JSExport]
在 JS 主机中运行 .NET WebAssembly 模块时,互操作适用:
- JavaScript `[JSImport]`/`[JSExport]` 与 WebAssembly 浏览器应用项目的互操作。
- JavaScript JSImport/JSExport 与 ASP.NET Core Blazor 的互操作。
- 支持
[JSImport]
/[JSExport]
互操作的其他 .NET WebAssembly 平台。
先决条件
以下任一项目类型:
- 根据 JavaScript `[JSImport]`/`[JSExport]` 与 WebAssembly 浏览器应用项目互操作创建的 WebAssembly 浏览器应用项目。
- 根据 Blazor创建的 客户端项目。
- 为支持
[JSImport]
/[JSExport]
互操作 (System.Runtime.InteropServices.JavaScript API) 的商业或开源代码平台创建的项目。
示例应用
查看或下载示例代码(下载方法):选择与所采用的 .NET 版本匹配的 8.0 或更新版本文件夹。 在版本文件夹中,访问名为 WASMBrowserAppImportExportInterop
的示例。
使用 JS[JSImport]
/ 特性的 [JSExport]
互操作
[JSImport]
特性应用于 .NET 方法,以指示在调用 .NET 方法时应调用相应的 JS 方法。 这样,.NET 开发人员就可以定义允许 .NET 代码调用 JS 的“导入”。 此外,Action 可以作为参数传递,JS 可以调用该操作来支持回调或事件订阅模式。
[JSExport]
特性应用于 .NET 方法,以将其公开给 JS 代码。 这样,JS 代码就可以启动对 .NET 方法的调用。
导入 JS 方法
以下示例将标准内置 JS 方法 (console.log
) 导入 C# 中。 [JSImport]
仅限于导入可全局访问对象的方法。 例如,log
是在 console
对象上定义的方法,该方法在全局可访问的对象 globalThis
上定义。 console.log
方法映射到 C# 代理方法 (ConsoleLog
),该方法接受日志消息的字符串:
public partial class GlobalInterop
{
[JSImport("globalThis.console.log")]
public static partial void ConsoleLog(string text);
}
在 Program.Main
中,使用消息调用 ConsoleLog
以记录:
GlobalInterop.ConsoleLog("Hello World!");
输出将显示在浏览器控制台中。
下面演示如何导入在 JS.中声明的方法。
以下自定义 JS 方法 (globalThis.callAlert
) 生成一个警报对话框 (window.alert
),其中包含 text
中传入的消息:
globalThis.callAlert = function (text) {
globalThis.window.alert(text);
}
globalThis.callAlert
方法映射到 C# 代理方法 (CallAlert
),该方法接受消息的字符串:
using System.Runtime.InteropServices.JavaScript;
public partial class GlobalInterop
{
[JSImport("globalThis.callAlert")]
public static partial void CallAlert(string text);
}
在Program.Main
中,调用CallAlert
,传递警报对话框消息的文本:
GlobalInterop.CallAlert("Hello World");
声明 [JSImport]
方法的 C# 类没有实现。 在编译时,源生成的分部类包含实现调用封送的 .NET 代码和调用相应 JS 方法的类型。 在 Visual Studio 中,使用“转到定义”或“转到实现”选项分别导航到源生成的分部类或开发人员定义的分部类。
在前面的示例中,中间 globalThis.callAlert
JS 声明用于包装现有 JS 代码。 本文非正式地将中间 JS 声明称为“JS 填充码”。 JS填充码填补了 .NET 实现和现有 JS 功能/库之间的空白。 在许多情况下,例如前面的简单示例,不需要 JS 填充码,并且可以直接导入方法,如前面的 ConsoleLog
示例所示。 如本文在后续部分中所示,JS 填充码可以:
- 封装其他逻辑。
- 手动映射类型。
- 减少跨越互操作边界的对象数或调用数。
- 手动将静态调用映射到实例方法。
加载 JavaScript 声明
旨在与 JS 一起导入的 [JSImport]
声明,通常在加载 .NET WebAssembly 的同一页或 JS 主机的上下文中加载。 可执行如下操作来实现:
- 声明内联
<script>...</script>
的 JS 块。 - 加载外部
src
文件 (<script src="./some.js"></script>
) 的脚本源 (JS) 声明 (.js
)。 - JS ES6 模块 (
<script type='module' src="./moduleName.js"></script>
)。 - 使用 JS 从 .NET WebAssembly 加载的 JSHost.ImportAsync ES6 模块。
本文中的示例使用 JSHost.ImportAsync。 调用 ImportAsync 时,客户端 .NET WebAssembly 使用 moduleUrl
参数请求文件,因此它期望文件可以作为静态 Web 资产来供访问,这与 <script>
标记使用 src
URL 检索文件的方式大致相同。 例如,WebAssembly 浏览器应用项目中的以下 C# 代码维护路径 JS 处的 .js
文件 (/wwwroot/scripts/ExampleShim.js
):
await JSHost.ImportAsync("ExampleShim", "/scripts/ExampleShim.js");
根据加载 WebAssembly 的平台,以点为前缀的 URL(例如 ./scripts/
)可能引用不正确的子目录(例如 /_framework/scripts/
),因为 WebAssembly 包是由 /_framework/
下的框架脚本初始化的。 在这种情况下,为 URL 添加 ../scripts/
前缀,引用正确的路径。 如果站点托管在域的根目录中,则添加 /scripts/
前缀有效。 典型方法涉及使用 HTML <base>
标记为给定环境配置正确的基路径,并使用 /scripts/
前缀来引用相对于基路径的路径。 ~/
不支持波形符表示法 JSHost.ImportAsync 前缀。
重要
如果 JS 从 JavaScript 模块加载,则 [JSImport]
特性必须包含模块名称作为第二个参数。 例如,[JSImport("globalThis.callAlert", "ExampleShim")]
指示导入的方法是在名为“ExampleShim
”的 JavaScript 模块中声明的。
类型映射
如果支持唯一映射,则 .NET 方法签名中的参数和返回类型会在运行时自动转换为适当的 JS 类型,或从中转换。 这可能会导致由值转换的值或代理类型中包装的引用。 此过程称为“类型封送”。 使用 JSMarshalAsAttribute<T> 控制导入的方法参数和退回类型的封送方式。
某些类型没有默认类型映射。 例如,可以将 long
封送为 System.Runtime.InteropServices.JavaScript.JSType.Number 或 System.Runtime.InteropServices.JavaScript.JSType.BigInt,因此 JSMarshalAsAttribute<T> 需要避免编译时错误。
支持以下类型映射方案:
- 将 Action 或 Func<TResult> 作为参数进行传递,并将其封装为可调用的 JS 方法。 这样,.NET 代码就可以调用侦听器来响应 JS 回调或事件。
- JS向任一方向传递引用和 .NET 托管对象引用,该引用作为代理对象封送,并在互操作边界内保持活动状态,直到代理被垃圾回收。
- 封送异步 JS 方法或 具有 JS
Promise
结果的 Task,反之亦然。
大多数封送类型在导入和导出的方法上作为参数和返回值双向工作。
下表指示受支持的类型映射。
.NET | JavaScript | Nullable |
Task 到Promise |
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
) 映射为 JS 的Array
Number
。- 将 JS 值传递给具有错误类型的值的 C# 时,在大多数情况下,框架将引发异常。 框架不会在 JS 中执行编译时类型检查。
JSObject
、Exception
、Task
和ArraySegment
将创建GCHandle
和代理。 你可以在开发人员代码中触发处置,或允许 .NET 垃圾回收 (GC) 来稍后处置对象。 这些类型会产生显著的性能开销。Array
:封送数组会在 JS 或 .NET 中创建数组的副本。MemoryView
MemoryView
是 .NET WebAssembly 运行时用于封送 JS 和Span
的ArraySegment
类。- 与封送数组不同,封送
Span
或ArraySegment
不会创建基础内存的副本。 MemoryView
只能由 .NET WebAssembly 运行时正确实例化。 因此,不可能将 JS 方法导入为具有Span
或ArraySegment
参数的 .NET 方法。- 为
MemoryView
创建的Span
仅在互操作调用期间有效。 由于在调用堆栈上分配了Span
,这在互操作调用之后不会保留,因此不能导出返回Span
的 .NET 方法。 - 为
MemoryView
创建的ArraySegment
会在互操作调用后存活,并且可用于共享缓冲区。 对为dispose()
创建的MemoryView
调用ArraySegment
将处置代理并将取消固定基础 .NET 数组。 建议在dispose()
块中为try-finally
调用MemoryView
。
当前不支持需要在 JSMarshalAs
中嵌套泛型类型的类型映射的一些组合。 例如,尝试从 Promise
(例如 [return: JSMarshalAs<JSType.Promise<JSType.Array<JSType.Number>>>()]
)中具体化数组会生成编译时错误。 适当的解决方法因方案而异,但在“类型映射限制”部分中会进一步探讨此特定方案。
JS 基元
以下示例演示 [JSImport]
利用多种基元 JS 类型的类型映射和 JSMarshalAs
的使用,其中在编译时需要显式映射。
PrimitivesShim.js
:
globalThis.counter = 0;
// Takes no parameters and returns nothing.
export function incrementCounter() {
globalThis.counter += 1;
};
// Returns an int.
export function getCounter() { return globalThis.counter; };
// Takes a parameter and returns nothing. JS doesn't restrict the parameter type,
// but we can restrict it in the .NET proxy, if desired.
export function logValue(value) { console.log(value); };
// Called for various .NET types to demonstrate mapping to JS primitive types.
export function logValueAndType(value) { console.log(typeof value, value); };
PrimitivesInterop.cs
:
using System;
using System.Runtime.InteropServices.JavaScript;
using System.Threading.Tasks;
public partial class PrimitivesInterop
{
// Importing an existing JS method.
[JSImport("globalThis.console.log")]
public static partial void ConsoleLog([JSMarshalAs<JSType.Any>] object value);
// Importing static methods from a JS module.
[JSImport("incrementCounter", "PrimitivesShim")]
public static partial void IncrementCounter();
[JSImport("getCounter", "PrimitivesShim")]
public static partial int GetCounter();
// The JS shim method name isn't required to match the C# method name.
[JSImport("logValue", "PrimitivesShim")]
public static partial void LogInt(int value);
// A second mapping to the same JS method with compatible type.
[JSImport("logValue", "PrimitivesShim")]
public static partial void LogString(string value);
// Accept any type as parameter. .NET types are mapped to JS types where
// possible. Otherwise, they're marshalled as an untyped object reference
// to the .NET object proxy. The JS implementation logs to browser console
// the JS type and value to demonstrate results of marshalling.
[JSImport("logValueAndType", "PrimitivesShim")]
public static partial void LogValueAndType(
[JSMarshalAs<JSType.Any>] object value);
// Some types have multiple mappings and require explicit marshalling to the
// desired JS type. A long/Int64 can be mapped as either a Number or BigInt.
// Passing a long value to the above method generates an error at runtime:
// "ToJS for System.Int64 is not implemented." ("ToJS" means "to JavaScript")
// If the parameter declaration `Method(JSMarshalAs<JSType.Any>] long value)`
// is used, a compile-time error is generated:
// "Type long is not supported by source-generated JS interop...."
// Instead, explicitly map the long parameter to either a JSType.Number or
// JSType.BigInt. Note that runtime overflow errors are possible in JS if the
// C# value is too large.
[JSImport("logValueAndType", "PrimitivesShim")]
public static partial void LogValueAndTypeForNumber(
[JSMarshalAs<JSType.Number>] long value);
[JSImport("logValueAndType", "PrimitivesShim")]
public static partial void LogValueAndTypeForBigInt(
[JSMarshalAs<JSType.BigInt>] long value);
}
public static class PrimitivesUsage
{
public static async Task Run()
{
// Ensure JS module loaded.
await JSHost.ImportAsync("PrimitivesShim", "/PrimitivesShim.js");
// Call a proxy to a static JS method, console.log().
PrimitivesInterop.ConsoleLog("Printed from JSImport of console.log()");
// Basic examples of JS interop with an integer.
PrimitivesInterop.IncrementCounter();
int counterValue = PrimitivesInterop.GetCounter();
PrimitivesInterop.LogInt(counterValue);
PrimitivesInterop.LogString("I'm a string from .NET in your browser!");
// Mapping some other .NET types to JS primitives.
PrimitivesInterop.LogValueAndType(true);
PrimitivesInterop.LogValueAndType(0x3A); // Byte literal
PrimitivesInterop.LogValueAndType('C');
PrimitivesInterop.LogValueAndType((Int16)12);
// JS Number has a lower max value and can generate overflow errors.
PrimitivesInterop.LogValueAndTypeForNumber(9007199254740990L); // Int64/Long
// Next line: Int64/Long, JS BigInt supports larger numbers.
PrimitivesInterop.LogValueAndTypeForBigInt(1234567890123456789L);//
PrimitivesInterop.LogValueAndType(3.14f); // Single floating point literal
PrimitivesInterop.LogValueAndType(3.14d); // Double floating point literal
PrimitivesInterop.LogValueAndType("A string");
}
}
在 Program.Main
中:
await PrimitivesUsage.Run();
前面的示例显示以下浏览器调试控制台输出:
Printed from JSImport of console.log()
1
I'm a string from .NET in your browser!
boolean true
number 58
number 67
number 12
number 9007199254740990
bigint 1234567890123456789n
number 3.140000104904175
number 3.14
string A string
JSDate
对象
本节中的示例演示将 JS Date
对象作为其返回或参数的导入方法。 日期按值对互操作进行封送,这意味着它们与 JS 基元的复制方式大致相同。
Date
对象与时区无关。 .NET DateTime 相对于其 DateTimeKind 封送到某个 Date
时间进行调整,但不会保留时区信息。 考虑使用与其表示的值一致的 DateTime 或 DateTimeKind.Utc 初始化DateTimeKind.Local。
DateShim.js
:
export function incrementDay(date) {
date.setDate(date.getDate() + 1);
return date;
}
export function logValueAndType(value) {
console.log("Date:", value)
}
DateInterop.cs
:
using System;
using System.Runtime.InteropServices.JavaScript;
using System.Threading.Tasks;
public partial class DateInterop
{
[JSImport("incrementDay", "DateShim")]
[return: JSMarshalAs<JSType.Date>] // Explicit JSMarshalAs for a return type
public static partial DateTime IncrementDay(
[JSMarshalAs<JSType.Date>] DateTime date);
[JSImport("logValueAndType", "DateShim")]
public static partial void LogValueAndType(
[JSMarshalAs<JSType.Date>] DateTime value);
}
public static class DateUsage
{
public static async Task Run()
{
// Ensure JS module loaded.
await JSHost.ImportAsync("DateShim", "/DateShim.js");
// Basic examples of interop with a C# DateTime and JS Date.
DateTime date = new(1968, 12, 21, 12, 51, 0, DateTimeKind.Utc);
DateInterop.LogValueAndType(date);
date = DateInterop.IncrementDay(date);
DateInterop.LogValueAndType(date);
}
}
在 Program.Main
中:
await DateUsage.Run();
前面的示例显示以下浏览器调试控制台输出:
Date: Sat Dec 21 1968 07:51:00 GMT-0500 (Eastern Standard Time)
Date: Sun Dec 22 1968 07:51:00 GMT-0500 (Eastern Standard Time)
前面的时区信息 (GMT-0500 (Eastern Standard Time)
) 取决于计算机/浏览器的本地时区。
JS 对象引用
每当 JS 方法返回对象引用时,它在 .NET 中就表示为 JSObject。 原始 JS 对象在 JS 边界内继续其生存期,而 .NET 代码可以通过 JSObject 按引用来访问和修改它。 虽然类型本身公开了有限的 API,但能够保留 JS 对象引用并返回或跨互操作边界传递它,从而支持多个互操作方案。
JSObject 提供访问属性的方法,但它不提供对实例方法的直接访问。 如以下 Summarize
方法所示,可以通过实现将实例作为参数的静态方法间接访问实例方法。
JSObjectShim.js
:
export function createObject() {
return {
name: "Example JS Object",
answer: 41,
question: null,
summarize: function () {
return `Question: "${this.question}" Answer: ${this.answer}`;
}
};
}
export function incrementAnswer(object) {
object.answer += 1;
// Don't return the modified object, since the reference is modified.
}
// Proxy an instance method call.
export function summarize(object) {
return object.summarize();
}
JSObjectInterop.cs
:
using System;
using System.Runtime.InteropServices.JavaScript;
using System.Threading.Tasks;
public partial class JSObjectInterop
{
[JSImport("createObject", "JSObjectShim")]
public static partial JSObject CreateObject();
[JSImport("incrementAnswer", "JSObjectShim")]
public static partial void IncrementAnswer(JSObject jsObject);
[JSImport("summarize", "JSObjectShim")]
public static partial string Summarize(JSObject jsObject);
[JSImport("globalThis.console.log")]
public static partial void ConsoleLog([JSMarshalAs<JSType.Any>] object value);
}
public static class JSObjectUsage
{
public static async Task Run()
{
await JSHost.ImportAsync("JSObjectShim", "/JSObjectShim.js");
JSObject jsObject = JSObjectInterop.CreateObject();
JSObjectInterop.ConsoleLog(jsObject);
JSObjectInterop.IncrementAnswer(jsObject);
// An updated object isn't retrieved. The change is reflected in the
// existing instance.
JSObjectInterop.ConsoleLog(jsObject);
// JSObject exposes several methods for interacting with properties.
jsObject.SetProperty("question", "What is the answer?");
JSObjectInterop.ConsoleLog(jsObject);
// We can't directly JSImport an instance method on the jsObject, but we
// can pass the object reference and have the JS shim call the instance
// method.
string summary = JSObjectInterop.Summarize(jsObject);
Console.WriteLine("Summary: " + summary);
}
}
在 Program.Main
中:
await JSObjectUsage.Run();
前面的示例显示以下浏览器调试控制台输出:
{name: 'Example JS Object', answer: 41, question: null, Symbol(wasm cs_owned_js_handle): 5, summarize: ƒ}
{name: 'Example JS Object', answer: 42, question: null, Symbol(wasm cs_owned_js_handle): 5, summarize: ƒ}
{name: 'Example JS Object', answer: 42, question: 'What is the answer?', Symbol(wasm cs_owned_js_handle): 5, summarize: ƒ}
Summary: Question: "What is the answer?" Answer: 42
异步互操作
许多 JS API 都是异步的,通过回调、Promise
或异步方法发出完成信号。 忽略异步功能通常不是一个选项,因为后续代码可能取决于异步操作的完成,必须等待。
使用 JS 关键字或返回 async
的 Promise
方法可以通过返回 Task的方法在 C# 中等待 。 如下面所示,async
关键字在 C# 方法上未与 [JSImport]
特性一起使用,因为它不使用其中的 await
关键字。 但是,使用调用该方法的代码通常使用 await
关键字并标记为 async
,如 PromisesUsage
示例中所示。
使用回调(例如 JS)的 setTimeout
,可以在从 Promise
中返回之前包装在一个JS中。 将回调包装在 Promise
中(正如分配给 Wait2Seconds
的函数中所示),仅在调用回调恰好一次时才适用。 否则,可以传递 C# Action 来侦听可调用零次或多次的回调,该回调操作在“订阅 JS 事件”部分中有演示。
PromisesShim.js
:
export function wait2Seconds() {
// This also demonstrates wrapping a callback-based API in a promise to
// make it awaitable.
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(); // Resolve promise after 2 seconds
}, 2000);
});
}
// Return a value via resolve in a promise.
export function waitGetString() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve("String From Resolve"); // Return a string via promise
}, 500);
});
}
export function waitGetDate() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(new Date('1988-11-24')); // Return a date via promise
}, 500);
});
}
// Demonstrates an awaitable fetch.
export function fetchCurrentUrl() {
// This method returns the promise returned by .then(*.text())
// and .NET awaits the returned promise.
return fetch(globalThis.window.___location, { method: 'GET' })
.then(response => response.text());
}
// .NET can await JS methods using the async/await JS syntax.
export async function asyncFunction() {
await wait2Seconds();
}
// A Promise.reject can be used to signal failure and is bubbled to .NET code
// as a JSException.
export function conditionalSuccess(shouldSucceed) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldSucceed)
resolve(); // Success
else
reject("Reject: ShouldSucceed == false"); // Failure
}, 500);
});
}
请勿在 C# 方法签名中使用 async
关键字。 返回 Task 或 Task<TResult> 已足够。
调用异步 JS 方法时,我们通常需要等待 JS 方法完成执行。 如果加载资源或发出请求,我们可能希望以下代码假定操作已完成。
如果 JS 填充码返回 Promise
,则 C# 可以将其视为可等待的 Task/Task<TResult>。
PromisesInterop.cs
:
using System;
using System.Diagnostics;
using System.Runtime.InteropServices.JavaScript;
using System.Threading.Tasks;
public partial class PromisesInterop
{
// For a promise with void return type, declare a Task return type:
[JSImport("wait2Seconds", "PromisesShim")]
public static partial Task Wait2Seconds();
[JSImport("waitGetString", "PromisesShim")]
public static partial Task<string> WaitGetString();
// Some return types require a [return: JSMarshalAs...] declaring the
// Promise's return type corresponding to Task<T>.
[JSImport("waitGetDate", "PromisesShim")]
[return: JSMarshalAs<JSType.Promise<JSType.Date>>()]
public static partial Task<DateTime> WaitGetDate();
[JSImport("fetchCurrentUrl", "PromisesShim")]
public static partial Task<string> FetchCurrentUrl();
[JSImport("asyncFunction", "PromisesShim")]
public static partial Task AsyncFunction();
[JSImport("conditionalSuccess", "PromisesShim")]
public static partial Task ConditionalSuccess(bool shouldSucceed);
}
public static class PromisesUsage
{
public static async Task Run()
{
await JSHost.ImportAsync("PromisesShim", "/PromisesShim.js");
Stopwatch sw = new();
sw.Start();
await PromisesInterop.Wait2Seconds(); // Await Promise
Console.WriteLine($"Waited {sw.Elapsed.TotalSeconds:#.0}s.");
sw.Restart();
string str =
await PromisesInterop.WaitGetString(); // Await promise (string return)
Console.WriteLine(
$"Waited {sw.Elapsed.TotalSeconds:#.0}s for WaitGetString: '{str}'");
sw.Restart();
// Await promise with string return.
DateTime date = await PromisesInterop.WaitGetDate();
Console.WriteLine(
$"Waited {sw.Elapsed.TotalSeconds:#.0}s for WaitGetDate: '{date}'");
// Await a JS fetch.
string responseText = await PromisesInterop.FetchCurrentUrl();
Console.WriteLine($"responseText.Length: {responseText.Length}");
sw.Restart();
await PromisesInterop.AsyncFunction(); // Await an async JS method
Console.WriteLine(
$"Waited {sw.Elapsed.TotalSeconds:#.0}s for AsyncFunction.");
try
{
// Handle a promise rejection. Await an async JS method.
await PromisesInterop.ConditionalSuccess(shouldSucceed: false);
}
catch (JSException ex) // Catch JS exception
{
Console.WriteLine($"JS Exception Caught: '{ex.Message}'");
}
}
}
在 Program.Main
中:
await PromisesUsage.Run();
前面的示例显示以下浏览器调试控制台输出:
Waited 2.0s.
Waited .5s for WaitGetString: 'String From Resolve'
Waited .5s for WaitGetDate: '11/24/1988 12:00:00 AM'
responseText.Length: 582
Waited 2.0s for AsyncFunction.
JS Exception Caught: 'Reject: ShouldSucceed == false'
类型映射限制
目前不支持某些需要在 JSMarshalAs
定义中嵌套泛型类型的类型映射。 例如,返回数组的一个 Promise
(例如 [return: JSMarshalAs<JSType.Promise<JSType.Array<JSType.Number>>>()]
)生成编译时错误。 适当的解决方法因方案而异,但一个选项是将数组表示为 JSObject 引用。 如果不需要访问 .NET 中的单个元素,并且可以将引用传递给对数组执行操作的其他 JS 方法,这可能就足够了。 或者,专用方法可以将 JSObject 引用作为参数并返回具体化数组,如以下 UnwrapJSObjectAsIntArray
示例所示。 在这种情况下,JS 方法没有类型检查,开发人员有责任确保 JSObject 传递包装适当的数组类型。
export function waitGetIntArrayAsObject() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve([1, 2, 3, 4, 5]); // Return an array from the Promise
}, 500);
});
}
export function unwrapJSObjectAsIntArray(jsObject) {
return jsObject;
}
// Not supported, generates compile-time error.
// [JSImport("waitGetArray", "PromisesShim")]
// [return: JSMarshalAs<JSType.Promise<JSType.Array<JSType.Number>>>()]
// public static partial Task<int[]> WaitGetIntArray();
// Workaround, take the return the call and pass it to UnwrapJSObjectAsIntArray.
// Return a JSObject reference to a JS number array.
[JSImport("waitGetIntArrayAsObject", "PromisesShim")]
[return: JSMarshalAs<JSType.Promise<JSType.Object>>()]
public static partial Task<JSObject> WaitGetIntArrayAsObject();
// Takes a JSObject reference to a JS number array, and returns the array as a C#
// int array.
[JSImport("unwrapJSObjectAsIntArray", "PromisesShim")]
[return: JSMarshalAs<JSType.Array<JSType.Number>>()]
public static partial int[] UnwrapJSObjectAsIntArray(JSObject intArray);
//...
在 Program.Main
中:
JSObject arrayAsJSObject = await PromisesInterop.WaitGetIntArrayAsObject();
int[] intArray = PromisesInterop.UnwrapJSObjectAsIntArray(arrayAsJSObject);
性能注意事项
调用封送和跨互操作边界的对象跟踪的开销比本机 .NET 操作更昂贵,但对于具有中等需求的典型 Web 应用仍应能表现出可接受的性能。
对象代理(例如 JSObject,维护跨互操作边界的引用)具有额外的内存开销,并影响垃圾回收对这些对象的影响。 此外,在某些情况下,可用内存可能会耗尽,而不会触发垃圾回收,因为不会共享来自 JS .NET 的内存压力。 当相对较小的 JS 对象跨互操作边界引用过多大型对象时,或者 JS 代理引用大型 .NET 对象时,这种风险非常重要。 在这种情况下,我们建议遵循具有利用 using
对象上的 IDisposable 接口的 JS 范围的确定性处置模式。
以下基准(利用前面的示例代码)表明,互操作操作的速度大致比 .NET 边界内的操作慢一个数量级,但互操作操作保持相对快。 此外,请考虑用户的设备功能会影响性能。
JSObjectBenchmark.cs
:
using System;
using System.Diagnostics;
public static class JSObjectBenchmark
{
public static void Run()
{
Stopwatch sw = new();
var jsObject = JSObjectInterop.CreateObject();
sw.Start();
for (int i = 0; i < 1000000; i++)
{
JSObjectInterop.IncrementAnswer(jsObject);
}
sw.Stop();
Console.WriteLine(
$"JS interop elapsed time: {sw.Elapsed.TotalSeconds:#.0000} seconds " +
$"at {sw.Elapsed.TotalMilliseconds / 1000000d:#.000000} ms per " +
"operation");
var pocoObject =
new PocoObject { Question = "What is the answer?", Answer = 41 };
sw.Restart();
for (int i = 0; i < 1000000; i++)
{
pocoObject.IncrementAnswer();
}
sw.Stop();
Console.WriteLine($".NET elapsed time: {sw.Elapsed.TotalSeconds:#.0000} " +
$"seconds at {sw.Elapsed.TotalMilliseconds / 1000000d:#.000000} ms " +
"per operation");
Console.WriteLine($"Begin Object Creation");
sw.Restart();
for (int i = 0; i < 1000000; i++)
{
var jsObject2 = JSObjectInterop.CreateObject();
JSObjectInterop.IncrementAnswer(jsObject2);
}
sw.Stop();
Console.WriteLine(
$"JS interop elapsed time: {sw.Elapsed.TotalSeconds:#.0000} seconds " +
$"at {sw.Elapsed.TotalMilliseconds / 1000000d:#.000000} ms per " +
"operation");
sw.Restart();
for (int i = 0; i < 1000000; i++)
{
var pocoObject2 =
new PocoObject { Question = "What is the answer?", Answer = 0 };
pocoObject2.IncrementAnswer();
}
sw.Stop();
Console.WriteLine(
$".NET elapsed time: {sw.Elapsed.TotalSeconds:#.0000} seconds at " +
$"{sw.Elapsed.TotalMilliseconds / 1000000d:#.000000} ms per operation");
}
public class PocoObject // Plain old CLR object
{
public string Question { get; set; }
public int Answer { get; set; }
public void IncrementAnswer() => Answer += 1;
}
}
在 Program.Main
中:
JSObjectBenchmark.Run();
前面的示例显示以下浏览器调试控制台输出:
JS interop elapsed time: .2536 seconds at .000254 ms per operation
.NET elapsed time: .0210 seconds at .000021 ms per operation
Begin Object Creation
JS interop elapsed time: 2.1686 seconds at .002169 ms per operation
.NET elapsed time: .1089 seconds at .000109 ms per operation
订阅 JS 事件
.NET 代码可以通过将 C# JS 传递给 JS 函数以充当处理程序来订阅 Action 事件并处理 JS 事件。 JS填充码代码处理订阅事件。
警告
如本部分中的指南所示,通过 JS 互操作与 DOM 的各个属性交互相对缓慢,并可能导致创建许多会产生高垃圾回收压力的代理。 通常不建议以下模式。 将以下模式用于不超过几个元素。 有关详细信息,请参阅“性能注意事项”部分。
removeEventListener
的细微差别在于,它需要对以前传递给 addEventListener
的函数的引用。 当 C# Action 跨互操作边界传递时,它将包装在 JS 代理对象中。 因此,将相同的 C# Action 传递给 addEventListener
和 removeEventListener
会导致生成包装 JS 的两个不同的 Action 代理对象。 这些引用不同,因此 removeEventListener
找不到要删除的事件侦听器。 若要解决此问题,以下示例将 C# Action 包装在 JS 函数中,并将引用作为 JSObject 从订阅调用返回,以便稍后传递给取消订阅调用。 由于 C# Action 作为一个 JSObject 返回和传递,因此对两个调用使用相同的引用,并且可以删除事件侦听器。
EventsShim.js
:
export function subscribeEventById(elementId, eventName, listenerFunc) {
const elementObj = document.getElementById(elementId);
// Need to wrap the Managed C# action in JS func (only because it is being
// returned).
let handler = function (event) {
listenerFunc(event.type, event.target.id); // Decompose object to primitives
}.bind(elementObj);
elementObj.addEventListener(eventName, handler, false);
// Return JSObject reference so it can be used for removeEventListener later.
return handler;
}
// Param listenerHandler must be the JSObject reference returned from the prior
// SubscribeEvent call.
export function unsubscribeEventById(elementId, eventName, listenerHandler) {
const elementObj = document.getElementById(elementId);
elementObj.removeEventListener(eventName, listenerHandler, false);
}
export function triggerClick(elementId) {
const elementObj = document.getElementById(elementId);
elementObj.click();
}
export function getElementById(elementId) {
return document.getElementById(elementId);
}
export function subscribeEvent(elementObj, eventName, listenerFunc) {
let handler = function (e) {
listenerFunc(e);
}.bind(elementObj);
elementObj.addEventListener(eventName, handler, false);
return handler;
}
export function unsubscribeEvent(elementObj, eventName, listenerHandler) {
return elementObj.removeEventListener(eventName, listenerHandler, false);
}
export function subscribeEventFailure(elementObj, eventName, listenerFunc) {
// It's not strictly required to wrap the C# action listenerFunc in a JS
// function.
elementObj.addEventListener(eventName, listenerFunc, false);
// If you need to return the wrapped proxy object, you will receive an error
// when it tries to wrap the existing proxy in an additional proxy:
// Error: "JSObject proxy of ManagedObject proxy is not supported."
return listenerFunc;
}
EventsInterop.cs
:
using System;
using System.Runtime.InteropServices.JavaScript;
using System.Threading.Tasks;
public partial class EventsInterop
{
[JSImport("subscribeEventById", "EventsShim")]
public static partial JSObject SubscribeEventById(string elementId,
string eventName,
[JSMarshalAs<JSType.Function<JSType.String, JSType.String>>]
Action<string, string> listenerFunc);
[JSImport("unsubscribeEventById", "EventsShim")]
public static partial void UnsubscribeEventById(string elementId,
string eventName, JSObject listenerHandler);
[JSImport("triggerClick", "EventsShim")]
public static partial void TriggerClick(string elementId);
[JSImport("getElementById", "EventsShim")]
public static partial JSObject GetElementById(string elementId);
[JSImport("subscribeEvent", "EventsShim")]
public static partial JSObject SubscribeEvent(JSObject htmlElement,
string eventName,
[JSMarshalAs<JSType.Function<JSType.Object>>]
Action<JSObject> listenerFunc);
[JSImport("unsubscribeEvent", "EventsShim")]
public static partial void UnsubscribeEvent(JSObject htmlElement,
string eventName, JSObject listenerHandler);
}
public static class EventsUsage
{
public static async Task Run()
{
await JSHost.ImportAsync("EventsShim", "/EventsShim.js");
Action<string, string> listenerFunc = (eventName, elementId) =>
Console.WriteLine(
$"In C# event listener: Event {eventName} from ID {elementId}");
// Assumes two buttons exist on the page with ids of "btn1" and "btn2"
JSObject listenerHandler1 =
EventsInterop.SubscribeEventById("btn1", "click", listenerFunc);
JSObject listenerHandler2 =
EventsInterop.SubscribeEventById("btn2", "click", listenerFunc);
Console.WriteLine("Subscribed to btn1 & 2.");
EventsInterop.TriggerClick("btn1");
EventsInterop.TriggerClick("btn2");
EventsInterop.UnsubscribeEventById("btn2", "click", listenerHandler2);
Console.WriteLine("Unsubscribed btn2.");
EventsInterop.TriggerClick("btn1");
EventsInterop.TriggerClick("btn2"); // Doesn't trigger because unsubscribed
EventsInterop.UnsubscribeEventById("btn1", "click", listenerHandler1);
// Pitfall: Using a different handler for unsubscribe silently fails.
// EventsInterop.UnsubscribeEventById("btn1", "click", listenerHandler2);
// With JSObject as event target and event object.
Action<JSObject> listenerFuncForElement = (eventObj) =>
{
string eventType = eventObj.GetPropertyAsString("type");
JSObject target = eventObj.GetPropertyAsJSObject("target");
Console.WriteLine(
$"In C# event listener: Event {eventType} from " +
$"ID {target.GetPropertyAsString("id")}");
};
JSObject htmlElement = EventsInterop.GetElementById("btn1");
JSObject listenerHandler3 = EventsInterop.SubscribeEvent(
htmlElement, "click", listenerFuncForElement);
Console.WriteLine("Subscribed to btn1.");
EventsInterop.TriggerClick("btn1");
EventsInterop.UnsubscribeEvent(htmlElement, "click", listenerHandler3);
Console.WriteLine("Unsubscribed btn1.");
EventsInterop.TriggerClick("btn1");
}
}
在 Program.Main
中:
await EventsUsage.Run();
前面的示例显示以下浏览器调试控制台输出:
Subscribed to btn1 & 2.
In C# event listener: Event click from ID btn1
In C# event listener: Event click from ID btn2
Unsubscribed btn2.
In C# event listener: Event click from ID btn1
Subscribed to btn1.
In C# event listener: Event click from ID btn1
Unsubscribed btn1.
JS[JSImport]
/[JSExport]
互操作方案
以下文章重点介绍如何在 JS 主机(如浏览器)中运行 .NET WebAssembly 模块: