JavaScript-Interoperabilität mit [JSImport]
/[JSExport]
in .NET WebAssembly
Hinweis
Dies ist nicht die neueste Version dieses Artikels. Die aktuelle Version finden Sie in der .NET 9-Version dieses Artikels.
Warnung
Diese Version von ASP.NET Core wird nicht mehr unterstützt. Weitere Informationen finden Sie in der .NET- und .NET Core-Supportrichtlinie. Die aktuelle Version finden Sie in der .NET 9-Version dieses Artikels.
Wichtig
Diese Informationen beziehen sich auf ein Vorabversionsprodukt, das vor der kommerziellen Freigabe möglicherweise noch wesentlichen Änderungen unterliegt. Microsoft gibt keine Garantie, weder ausdrücklich noch impliziert, hinsichtlich der hier bereitgestellten Informationen.
Die aktuelle Version finden Sie in der .NET 9-Version dieses Artikels.
Von Aaron Shumaker
In diesem Artikel wird erklärt, wie Sie mit JavaScript (JS) in clientseitigem WebAssembly unter Verwendung der JS-Interoperabilität mit [JSImport]
/[JSExport]
(System.Runtime.InteropServices.JavaScript-API) interagieren können.
Die Interoperabilität mit [JSImport]
/[JSExport]
ist anwendbar, wenn ein .NET-WebAssembly-Modul in einem JS-Host in den folgenden Szenarien ausgeführt wird:
- JavaScript-Interoperabilität mit `[JSImport]`/`[JSExport]` mit einem WebAssembly-Browser-App-Projekt.
- JavaScript-Interoperabilität mit JSImport/JSExport mit ASP.NET Core-Blazor.
- Andere .NET WebAssembly-Plattformen, die die Interoperabilität mit
[JSImport]
/[JSExport]
unterstützen.
Voraussetzungen
Jeder der folgenden Projekttypen:
- Ein WebAssembly-Browser-App-Projekt, das gemäß der JavaScript-Interoperabilität mit `[JSImport]`/`[JSExport]` mit einem WebAssembly-Browser-App-Projekt erstellt wurde.
- Ein clientseitiges Blazor-Projekt, das gemäß der JavaScript-Interoperabilität mit JSImport/JSExport mit ASP.NET Core-Blazor erstellt wurde.
- Ein Projekt, das für eine kommerzielle oder Open-Source-Plattform erstellt wurde und Interoperabilität mit
[JSImport]
/[JSExport]
(System.Runtime.InteropServices.JavaScript API) unterstützt.
Beispiel-App
Anzeigen oder Herunterladen von Beispielcode (Downloadanleitung): Wählen Sie einen Ordner für Version 8.0 oder höher aus, der der Version von .NET entspricht, die Sie übernehmen. Greifen Sie im Ordner „version“ auf das Beispiel mit dem Namen WASMBrowserAppImportExportInterop
zu.
JS-Interoperabilität mit [JSImport]
/[JSExport]
-Attributen
Das [JSImport]
-Attribut wird auf eine .NET-Methode angewendet, um anzugeben, dass eine entsprechende JS-Methode aufgerufen werden soll, wenn die .NET-Methode aufgerufen wird. Dies ermöglicht es .NET-Entwickelnden, „Importe“ zu definieren, die es .NET-Code ermöglichen, JS aufzurufen. Zusätzlich kann ein Action als Parameter übergeben werden und JS kann die Aktion aufrufen, um ein Rückruf- oder Ereignisabonnementmuster zu unterstützen.
Das [JSExport]
-Attribut wird auf eine .NET-Methode angewendet, um sie für JS-Code verfügbar zu machen. Dadurch kann JS-Code Aufrufe der .NET-Methode initiieren.
Importieren von JS-Methoden
Das folgende Beispiel importiert eine standardmäßig integrierte JS/Methode (console.log
) in C#. [JSImport]
ist auf das Importieren von Methoden von global zugänglichen Objekten beschränkt. log
ist beispielsweise eine Methode, die für das console
/Objekt definiert ist, das für das global zugängliche Objekt globalThis
definiert ist. Die console.log
-Methode wird einer C#-Proxy-Methode zugeordnet, ConsoleLog
, die eine Zeichenfolge für die Protokollnachricht akzeptiert:
public partial class GlobalInterop
{
[JSImport("globalThis.console.log")]
public static partial void ConsoleLog(string text);
}
In Program.Main
wird ConsoleLog
mit der Meldung zum Anmelden aufgerufen:
GlobalInterop.ConsoleLog("Hello World!");
Die Ausgabe erscheint in der Konsole des Browsers.
Im Folgenden wird der Import einer in JS deklarierten Methode veranschaulicht.
Die folgende benutzerdefinierte JS-Methode (globalThis.callAlert
) erzeugt ein Dialogfeld für Benachrichtigungen (window.alert
) mit der in text
übergebenen Nachricht:
globalThis.callAlert = function (text) {
globalThis.window.alert(text);
}
Die globalThis.callAlert
-Methode wird einer C#-Proxy-Methode (CallAlert
) zugeordnet, die eine Zeichenfolge für die Nachricht akzeptiert:
using System.Runtime.InteropServices.JavaScript;
public partial class GlobalInterop
{
[JSImport("globalThis.callAlert")]
public static partial void CallAlert(string text);
}
In Program.Main
wird CallAlert
aufgerufen, wobei der Text für die Warnhinweis-Dialogmeldung übergeben wird:
GlobalInterop.CallAlert("Hello World");
Die C#-Klasse, die die [JSImport]
-Methode deklariert, verfügt über keine Implementierung. Zum Zeitpunkt der Kompilierung enthält eine quellgenerierte Teilklasse den .NET-Code, der das Marshalling des Aufrufs und der Typen implementiert, um die entsprechende JS-Methode aufzurufen. In Visual Studio navigieren Sie mit den Optionen Zur Definition gehen bzw. Zur Implementierung gehen entweder zu der aus dem Quellcode generierten Teilklasse oder zu der von den Entwickelnden definierten Teilklasse.
Im vorangegangenen Beispiel wird die Zwischen-Deklaration globalThis.callAlert
JS verwendet, um vorhandenen JS-Code zu umhüllen. In diesem Artikel wird die Zwischendeklaration JS informell als JS-Shim bezeichnet. JS-Shims füllen die Lücke zwischen der .NET-Implementierung und den vorhandenen JS-Fähigkeiten/Bibliotheken. In vielen Fällen, wie dem vorangegangenen trivialen Beispiel, ist das JS-Shim nicht notwendig und die Methoden können direkt importiert werden, wie in dem früheren ConsoleLog
-Beispiel gezeigt. Wie in den folgenden Abschnitten dieses Artikels gezeigt wird, hat ein JS-Shim folgende Eigenschaften:
- Kapselt zusätzliche Logik
- Manuelles Zuordnen von Typen
- Reduzieren der Anzahl von Objekten oder Aufrufen, die die Interoperabilitäts-Grenze überschreiten
- Manuelles Zuordnen statischer Aufrufe zu Instanzmethoden
Laden von JavaScript-Deklarationen
JS-Deklarationen, die mit [JSImport]
importiert werden sollen, werden normalerweise im Kontext derselben Seite oder desselben JS-Hosts geladen, der die .NET WebAssembly geladen hat. Dies kann wie folgt erreicht werden:
- Ein
<script>...</script>
-Block, der JS-Inline deklariert. - Eine Skript-Quellendeklaration (
src
) (<script src="./some.js"></script>
), die eine externe JS-Datei (.js
) lädt. - Ein JS-ES6-Modul (
<script type='module' src="./moduleName.js"></script>
). - Ein JS-ES6-Modul, das mit JSHost.ImportAsync .NET WebAssembly geladen wird.
Die Beispiele in diesem Artikel verwenden JSHost.ImportAsync. Beim Aufruf von ImportAsync fordert die clientseitige .NET WebAssembly die Datei mit dem Parameter moduleUrl
an und erwartet daher, dass die Datei als statisches Web-Asset zugänglich ist, ähnlich wie ein <script>
-Tag eine Datei mit einer src
-URL abruft. Der folgende C#-Code in einem WebAssembly-Browser-App-Projekt verwaltet beispielsweise die Datei JS (.js
) unter dem Pfad /wwwroot/scripts/ExampleShim.js
:
await JSHost.ImportAsync("ExampleShim", "/scripts/ExampleShim.js");
Abhängig von der Plattform, die WebAssembly lädt, kann eine URL mit Punktpräfix, z.B. ./scripts/
, auf ein falsches Unterverzeichnis verweisen, z.B. /_framework/scripts/
, da das WebAssembly-Paket von Framework-Skripten unter /_framework/
initialisiert wird. In diesem Fall verweist das Präfix ../scripts/
in der URL auf den richtigen Pfad. Die Präfixierung mit /scripts/
funktioniert, wenn die Website im Stammverzeichnis der Domäne gehostet wird. Ein typischer Ansatz besteht darin, den richtigen Basispfad für die gegebene Umgebung mit einem HTML <base>
-Tag zu konfigurieren und das Präfix /scripts/
zu verwenden, um auf den Pfad relativ zum Basispfad zu verweisen. Tilde-Notationspräfixe ~/
werden von JSHost.ImportAsync nicht unterstützt.
Wichtig
Wenn JS von einem JavaScript-Modul geladen wird, müssen [JSImport]
-Attribute den Modulnamen als zweiten Parameter enthalten. Zum Beispiel zeigt [JSImport("globalThis.callAlert", "ExampleShim")]
an, dass die importierte Methode in einem JavaScript-Modul namens „ExampleShim
“ deklariert wurde.
Typzuordnungen
Parameter und Rückgabetypen in der .NET-Methodensignatur werden zur Laufzeit automatisch in oder aus den entsprechenden JS-Typen konvertiert, wenn eine eindeutige Zuordnung unterstützt wird. Dies kann zu Werten führen, die nach Wert oder Verweisen konvertiert werden, die in einen Proxytyp eingeschlossen sind. Dieser Prozess wird als Marshallen von Typen bezeichnet. Verwenden Sie JSMarshalAsAttribute<T>, um zu steuern, wie die importierten Methodenparameter und Rückgabetypen aufbereitet werden.
Einige Typen verfügen nicht über eine Standardtypzuordnung. Ein long
kann z.B. als System.Runtime.InteropServices.JavaScript.JSType.Number oder System.Runtime.InteropServices.JavaScript.JSType.BigInt gemarshallt werden, sodass das JSMarshalAsAttribute<T> erforderlich ist, um einen Kompilierzeitfehler zu vermeiden.
Die folgenden Typenzuordnungsszenarien werden unterstützt:
- Übergabe von Action oder Func<TResult> als Parameter, die als aufrufbare JS-Methoden bereitgestellt werden. Dies ermöglicht es .NET-Code, Listener als Reaktion auf JS-Rückrufe oder Ereignisse aufzurufen.
- Übergabe von JS-Referenzen und Referenzen auf verwaltete .NET-Objekte in beide Richtungen, die als Proxy-Objekte bereitgestellt und über die Interoperabilitäts-Grenze hinweg bis zur Löschung des Proxys aufrechterhalten werden.
- Das Marshalling asynchroner JS-Methoden oder eines JS
Promise
mit einem Task-Ergebnis und umgekehrt.
Die meisten der gemarshallten Typen funktionieren in beide Richtungen, als Parameter und als Rückgabewerte, und sowohl bei importierten als auch bei exportierten Methoden.
In der folgenden Tabelle werden die unterstützten Typzuordnungen gezeigt.
.NET | JavaScript | Nullable |
Task zu Promise |
JSMarshalAs optional |
Array of |
---|---|---|---|---|---|
Boolean |
Boolean |
Unterstützt | Unterstützt | Unterstützt | Nicht unterstützt |
Byte |
Number |
Unterstützt | Unterstützt | Unterstützt | Unterstützt |
Char |
String |
Unterstützt | Unterstützt | Unterstützt | Nicht unterstützt |
Int16 |
Number |
Unterstützt | Unterstützt | Unterstützt | Nicht unterstützt |
Int32 |
Number |
Unterstützt | Unterstützt | Unterstützt | Unterstützt |
Int64 |
Number |
Unterstützt | Unterstützt | Nicht unterstützt | Nicht unterstützt |
Int64 |
BigInt |
Unterstützt | Unterstützt | Nicht unterstützt | Nicht unterstützt |
Single |
Number |
Unterstützt | Unterstützt | Unterstützt | Nicht unterstützt |
Double |
Number |
Unterstützt | Unterstützt | Unterstützt | Unterstützt |
IntPtr |
Number |
Unterstützt | Unterstützt | Unterstützt | Nicht unterstützt |
DateTime |
Date |
Unterstützt | Unterstützt | Nicht unterstützt | Nicht unterstützt |
DateTimeOffset |
Date |
Unterstützt | Unterstützt | Nicht unterstützt | Nicht unterstützt |
Exception |
Error |
Nicht unterstützt | Unterstützt | Unterstützt | Nicht unterstützt |
JSObject |
Object |
Nicht unterstützt | Unterstützt | Unterstützt | Unterstützt |
String |
String |
Nicht unterstützt | Unterstützt | Unterstützt | Unterstützt |
Object |
Any |
Nicht unterstützt | Unterstützt | Nicht unterstützt | Unterstützt |
Span<Byte> |
MemoryView |
Nicht unterstützt | Nicht unterstützt | Nicht unterstützt | Nicht unterstützt |
Span<Int32> |
MemoryView |
Nicht unterstützt | Nicht unterstützt | Nicht unterstützt | Nicht unterstützt |
Span<Double> |
MemoryView |
Nicht unterstützt | Nicht unterstützt | Nicht unterstützt | Nicht unterstützt |
ArraySegment<Byte> |
MemoryView |
Nicht unterstützt | Nicht unterstützt | Nicht unterstützt | Nicht unterstützt |
ArraySegment<Int32> |
MemoryView |
Nicht unterstützt | Nicht unterstützt | Nicht unterstützt | Nicht unterstützt |
ArraySegment<Double> |
MemoryView |
Nicht unterstützt | Nicht unterstützt | Nicht unterstützt | Nicht unterstützt |
Task |
Promise |
Nicht unterstützt | Nicht unterstützt | Unterstützt | Nicht unterstützt |
Action |
Function |
Nicht unterstützt | Nicht unterstützt | Nicht unterstützt | Nicht unterstützt |
Action<T1> |
Function |
Nicht unterstützt | Nicht unterstützt | Nicht unterstützt | Nicht unterstützt |
Action<T1, T2> |
Function |
Nicht unterstützt | Nicht unterstützt | Nicht unterstützt | Nicht unterstützt |
Action<T1, T2, T3> |
Function |
Nicht unterstützt | Nicht unterstützt | Nicht unterstützt | Nicht unterstützt |
Func<TResult> |
Function |
Nicht unterstützt | Nicht unterstützt | Nicht unterstützt | Nicht unterstützt |
Func<T1, TResult> |
Function |
Nicht unterstützt | Nicht unterstützt | Nicht unterstützt | Nicht unterstützt |
Func<T1, T2, TResult> |
Function |
Nicht unterstützt | Nicht unterstützt | Nicht unterstützt | Nicht unterstützt |
Func<T1, T2, T3, TResult> |
Function |
Nicht unterstützt | Nicht unterstützt | Nicht unterstützt | Nicht unterstützt |
Die folgenden Bedingungen gelten für Typzuordnungen und gemarshallte Werte:
- Die Spalte
Array of
gibt an, ob der .NET-Typ als JSArray
gemarshallt werden kann. Beispiel: C#int[]
(Int32
) zugeordnet zu JSArray
vonNumber
. - Wenn ein JS-Wert vom falschen Typ an C# übergeben wird, löst das Framework in den meisten Fällen eine Ausnahme aus. Das Framework führt zur Kompilierzeit keine Typüberprüfung in JS durch.
JSObject
,Exception
,Task
undArraySegment
erstellenGCHandle
und einen Proxy. Sie können die Entsorgung im Entwicklercode auslösen oder es der .NET-Garbage Collection (GC) überlassen, die Objekte später zu entfernen. Diese Typen bedeuten einen erheblichen Leistungsaufwand.Array
: Beim Marshallen eines Arrays wird eine Kopie des Arrays in JS oder .NET erstellt.MemoryView
MemoryView
ist eine JS-Klasse für die .NET-WebAssembly-Runtime zum Marshallen vonSpan
undArraySegment
.- Im Gegensatz zum Marshallen eines Arrays wird beim Marshallen einer
Span
oder einesArraySegment
keine Kopie des zugrunde liegenden Speichers erstellt. MemoryView
kann nur von der .NET-WebAssembly-Runtime ordnungsgemäß instanziiert werden. Daher ist es nicht möglich, eine JS-Methode als eine .NET-Methode zu importieren, die über einen Parameter vonSpan
oderArraySegment
verfügt.C- Wenn
MemoryView
für einenSpan
erstellt wird, gilt sie nur für die Dauer des Interoperabilitätsaufrufs. DaSpan
der Aufrufliste zugewiesen wird, die nach dem Interoperabilitätsaufruf nicht beibehalten wird, ist es nicht möglich, eine .NET-Methode zu exportieren, die einenSpan
zurückgibt. MemoryView
wird für einArraySegment
erstellt und auch nach dem Interoperabilitätsaufruf beibehalten und ist damit für die gemeinsame Verwendung eines Puffers nützlich. Durch Aufrufen vondispose()
in einerMemoryView
, die für einArraySegment
erstellt wurde, wird der Proxy verworfen und das zugrunde liegende .NET-Array getrennt. Es wird empfohlen,dispose()
in einemtry-finally
-Block fürMemoryView
aufzurufen.
Einige Kombinationen von Typenzuordnungen, die verschachtelte generische Typen in JSMarshalAs
erfordern, werden derzeit nicht unterstützt. Wenn Sie beispielsweise versuchen, ein Array aus einem Promise
wie [return: JSMarshalAs<JSType.Promise<JSType.Array<JSType.Number>>>()]
zu materialisieren, wird ein Kompilierzeitfehler erzeugt. Eine geeignete Abhilfe hängt vom jeweiligen Szenario ab, aber dieses spezielle Szenario wird im Abschnitt Einschränkungen bei der Typenzuordnung näher erläutert.
JS-Grundtypen
Das folgende Beispiel demonstriert [JSImport]
die Nutzung von Typzuordnungen mehrerer JS-Grundtypen und die Verwendung von JSMarshalAs
, wo explizite Zuordnungen zur Kompilierzeit erforderlich sind.
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");
}
}
In Program.Main
:
await PrimitivesUsage.Run();
Das vorangehende Beispiel zeigt die folgende Ausgabe in der Debug-Konsole des Browsers an:
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
-Objekte
Das Beispiel in diesem Abschnitt zeigt Importmethoden, die über ein JS Date
-Objekt als Rückgabe oder Parameter verfügen. Die Daten werden über Interop-By-Value angeordnet, d. h. sie werden auf ähnliche Weise wie JS-Grundtypen kopiert.
Ein Date
-Objekt ist zeitzonenunabhängig. Ein .NET DateTime wird relativ zu seinem DateTimeKind angepasst, wenn es zu einem Date
zusammengestellt wird, aber die Zeitzoneninformationen bleiben nicht erhalten. Erwägen Sie, ein DateTime mit einem DateTimeKind.Utc oder DateTimeKind.Local zu initialisieren, das mit dem Wert übereinstimmt, den es darstellt.
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);
}
}
In Program.Main
:
await DateUsage.Run();
Das vorangehende Beispiel zeigt die folgende Ausgabe in der Debug-Konsole des Browsers an:
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)
Die vorherigen Zeitzoneninformationen (GMT-0500 (Eastern Standard Time)
) hängen von der lokalen Zeitzone Ihres Computers/Browsers ab.
JS-Objektverweise
Wenn eine JS-Methode eine Objektreferenz zurückgibt, wird diese in .NET als JSObject dargestellt. Das ursprüngliche JS-Objekt setzt seine Lebensdauer innerhalb der JS-Grenze fort, während .NET-Code über die JSObject darauf zugreifen und es per Referenz ändern kann. Während der Typ selbst eine begrenzte API bereitstellt, ermöglicht die Fähigkeit, eine JS Objektreferenz zu halten und sie über die Interoperabilitäts-Grenze hinweg zurückzugeben oder weiterzugeben, die Unterstützung mehrerer Interoperabilitäts-Szenarien.
JSObject bietet Methoden für den Zugriff auf Eigenschaften, aber keinen direkten Zugriff auf Instanzmethoden. Wie die folgende Summarize
-Methode zeigt, kann auf Instanzmethoden indirekt zugegriffen werden, indem eine statische Methode implementiert wird, die die Instanz als Parameter verwendet.
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);
}
}
In Program.Main
:
await JSObjectUsage.Run();
Das vorangehende Beispiel zeigt die folgende Ausgabe in der Debug-Konsole des Browsers an:
{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
Asynchrone Interoperabilität
Viele JS-APIs sind asynchron und signalisieren die Fertigstellung entweder durch einen Rückruf, ein Promise
oder eine asynchrone Methode. Das Ignorieren asynchroner Funktionen ist oft keine Option, da nachfolgender Code möglicherweise von der Beendigung des asynchronen Vorgangs abhängt und abgewartet werden muss.
JS-Methoden, die das Schlüsselwort async
verwenden oder einen Promise
zurückgeben, können in C# von einer Methode erwartet werden, die einen Task zurückgibt. Wie unten gezeigt, wird das async
-Schlüsselwort nicht in der C#-Methode mit dem [JSImport]
-Attribut verwendet, da es das await
-Schlüsselwort nicht darin verwendet. Die Verwendung von Code, der die Methode aufruft, würde jedoch in der Regel das await
-Schlüsselwort verwenden und wie im PromisesUsage
-Beispiel gezeigt als async
gekennzeichnet werden.
JS mit einem Rückruf, wie z. B. einem setTimeout
, kann in einen Promise
eingeschlossen werden, bevor es von JS zurückgegeben wird. Einen Rückruf in eine Promise
zu umschließen, wie in der Funktion, die Wait2Seconds
zugewiesen wurde, gezeigt, ist nur dann angebracht, wenn der Rückruf genau einmal aufgerufen wird. Andernfalls kann ein C# Action übergeben werden, um auf einen Rückruf zu warten, der null oder viele Male aufgerufen werden kann, wie im Abschnitt Abonnieren von JS Ereignissen veranschaulicht wird.
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);
});
}
Verwenden Sie das async
-Schlüsselwort nicht in der C#-Methodensignatur. Die Rückgabe von Task oder Task<TResult> ist ausreichend.
Beim Aufruf asynchroner JS-Methoden möchten wir oft warten, bis die JS-Methode die Ausführung abgeschlossen hat. Wenn wir eine Ressource laden oder eine Anfrage stellen, möchten wir wahrscheinlich, dass der folgende Code davon ausgeht, dass die Aktion abgeschlossen ist.
Wenn die JS-Shim eine Promise
zurückgibt, kann C# sie als await-fähiges Task/Task<TResult> behandeln.
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}'");
}
}
}
In Program.Main
:
await PromisesUsage.Run();
Das vorangehende Beispiel zeigt die folgende Ausgabe in der Debug-Konsole des Browsers an:
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'
Einschränkungen der Typzuordnung
Einige Typzuordnungen, die verschachtelte generische Typen in der JSMarshalAs
-Definition erfordern, werden derzeit nicht unterstützt. Wenn Sie beispielsweise Promise
für ein Array wie [return: JSMarshalAs<JSType.Promise<JSType.Array<JSType.Number>>>()]
zurückgeben, wird ein Fehler beim Kompilieren generiert. Eine geeignete Umgehung hängt vom jeweiligen Szenario ab, aber eine Möglichkeit besteht darin, das Array als JSObject-Referenz darzustellen. Dies kann ausreichend sein, wenn der Zugriff auf einzelne Elemente innerhalb von .NET nicht erforderlich ist und die Referenz an andere JS-Methoden übergeben werden kann, die auf das Array einwirken. Alternativ kann eine dedizierte Methode die JSObject-Referenz als Parameter verwenden und das materialisierte Array zurückgeben, wie im folgenden UnwrapJSObjectAsIntArray
-Beispiel gezeigt. In diesem Fall verfügt die JS-Methode über keine Typüberprüfung und die Entwickelnden sind dafür verantwortlich, sicherzustellen, dass eine JSObject-Umschließung des entsprechenden Array-Typs übergeben wird.
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);
//...
In Program.Main
:
JSObject arrayAsJSObject = await PromisesInterop.WaitGetIntArrayAsObject();
int[] intArray = PromisesInterop.UnwrapJSObjectAsIntArray(arrayAsJSObject);
Überlegungen zur Leistung
Das Marshallen von Aufrufen und der Aufwand für das Nachverfolgen von Objekten über die Interoperabilitätsgrenze hinweg ist teurer als systemeigene .NET-Vorgänge, sollte aber dennoch eine akzeptable Leistung für eine typische Web-App mit moderater Nachfrage demonstrieren.
Objektproxys wie JSObject, die Referenzen über die Interoperabilitätsgrenze hinweg verwalten, haben einen zusätzlichen Arbeitsspeicheraufwand und beeinflussen, wie sich die automatische Speicherbereinigung auf diese Objekte auswirkt. Darüber hinaus kann der verfügbare Arbeitsspeicher in einigen Szenarien erschöpft sein, ohne dass eine automatische Speicherbereinigung ausgelöst wird, da der Speicherdruck von JS und .NET nicht geteilt wird. Dieses Risiko ist erheblich, wenn eine übermäßige Anzahl großer Objekte über die Interoperabilitäts-Grenze hinweg von relativ kleinen JS-Objekten referenziert wird oder umgekehrt, wenn große .NET-Objekte von JS-Proxys referenziert werden. In solchen Fällen empfehlen wir, deterministische Bereinigungsmuster mit using
-Geltungsbereichen zu befolgen, die die IDisposable-Schnittstelle auf JS-Objekten nutzen.
Die folgenden Benchmarks, die auf früheren Beispielcodes basieren, zeigen, dass Interoperabilitätsvorgänge etwa eine Größenordnung langsamer sind als Vorgänge, die innerhalb der .NET-Grenze bleiben, aber die Interoperabilitätsvorgänge bleiben relativ schnell. Beachten Sie außerdem, dass die Leistungsfähigkeit eines Geräts von den Fähigkeiten der Benutzenden abhängt.
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;
}
}
In Program.Main
:
JSObjectBenchmark.Run();
Das vorangehende Beispiel zeigt die folgende Ausgabe in der Debug-Konsole des Browsers an:
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
Abonnieren von JS-Ereignissen
.NET-Code kann JS-Ereignisse abonnieren und JS-Ereignisse verarbeiten, indem er eine C# Action an eine JS-Funktion übergibt, die als Handler fungiert. Der JS-Shim-Code dient zum Abonnieren des Ereignisses.
Warnung
Die Interaktion mit einzelnen Eigenschaften des DOM über JS-Interoperabilität ist, wie die Anleitung in diesem Abschnitt zeigt, relativ langsam und kann zur Erstellung vieler Proxys führen, die einen hohen Druck auf die automatische Speicherbereinigung ausüben. Das folgende Muster wird im Allgemeinen nicht empfohlen. Verwenden Sie das folgende Muster für nicht mehr als ein paar Elemente. Weitere Informationen finden Sie im Abschnitt Leistungsüberlegungen.
Eine Nuance von removeEventListener
ist, dass es einen Verweis auf die zuvor an addEventListener
übergebene Funktion erfordert. Wenn ein C# Action über die Interoperabilitäts-Grenze übergeben wird, wird es in ein JS-Proxy-Objekt umschlossen. Wenn also dasselbe C# Action sowohl an addEventListener
als auch an removeEventListener
übergeben wird, werden zwei verschiedene JS-Proxy-Objekte generiert, die das Action umschließen. Diese Verweise sind unterschiedlich, daher kann removeEventListener
den Ereignislistener nicht finden, um ihn zu entfernen. Um dieses Problem zu beheben, wird in den folgenden Beispielen C# Action in eine JS-Funktion eingeschlossen und die Referenz als JSObject vom Subscribe-Aufruf zurückgegeben, um sie später an den Unsubscribe-Aufruf zu übergeben. Da das C# Action zurückgegeben und als JSObject übergeben wird, wird für beide Aufrufe dieselbe Referenz verwendet und der Ereignislistener kann entfernt werden.
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");
}
}
In Program.Main
:
await EventsUsage.Run();
Das vorangehende Beispiel zeigt die folgende Ausgabe in der Debug-Konsole des Browsers an:
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]
-Interoperabilitäts-Szenarien
Die folgenden Artikel konzentrieren sich auf das Ausführen eines .NET WebAssembly-Moduls in einem JS-Host, z. B. einem Browser: