Freigeben über


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:

Voraussetzungen

.NET SDK (neueste Version)

Jeder der folgenden Projekttypen:

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.callAlertJS 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 von Number.
  • 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 und ArraySegment erstellen GCHandle 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 von Span und ArraySegment.
    • Im Gegensatz zum Marshallen eines Arrays wird beim Marshallen einer Span oder eines ArraySegment 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 von Span oder ArraySegment verfügt.C
    • Wenn MemoryView für einen Span erstellt wird, gilt sie nur für die Dauer des Interoperabilitätsaufrufs. Da Span der Aufrufliste zugewiesen wird, die nach dem Interoperabilitätsaufruf nicht beibehalten wird, ist es nicht möglich, eine .NET-Methode zu exportieren, die einen Span zurückgibt.
    • MemoryView wird für ein ArraySegment erstellt und auch nach dem Interoperabilitätsaufruf beibehalten und ist damit für die gemeinsame Verwendung eines Puffers nützlich. Durch Aufrufen von dispose() in einer MemoryView, die für ein ArraySegment erstellt wurde, wird der Proxy verworfen und das zugrunde liegende .NET-Array getrennt. Es wird empfohlen, dispose() in einem try-finally-Block für MemoryView 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: