Udostępnij za pośrednictwem


Interop języka JavaScript [JSImport]/[JSExport] na platformie .NET WebAssembly

Uwaga

Nie jest to najnowsza wersja tego artykułu. Aby zapoznać się z bieżącą wersją, zobacz wersję tego artykułu platformy .NET 9.

Ostrzeżenie

Ta wersja ASP.NET Core nie jest już obsługiwana. Aby uzyskać więcej informacji, zobacz zasady pomocy technicznej platformy .NET i platformy .NET Core. Aby zapoznać się z bieżącą wersją, zobacz wersję tego artykułu platformy .NET 9.

Ważne

Te informacje odnoszą się do produktu w wersji wstępnej, który może zostać znacząco zmodyfikowany, zanim zostanie wydany komercyjnie. Firma Microsoft nie udziela żadnych gwarancji, jawnych lub domniemanych, w odniesieniu do informacji podanych w tym miejscu.

Aby zapoznać się z bieżącą wersją, zobacz wersję tego artykułu platformy .NET 9.

Autor: Aaron Shumaker

W tym artykule wyjaśniono, jak wchodzić w interakcje z językiem JavaScript (JS) w zestawie WebAssembly po stronie klienta przy użyciu JS/[JSImport][JSExport] międzyoperacyjnego (System.Runtime.InteropServices.JavaScriptINTERFEJS API).

[JSImport]/[JSExport] Interop ma zastosowanie w przypadku uruchamiania modułu WebAssembly platformy .NET na JS hoście w następujących scenariuszach:

Wymagania wstępne

Zestaw .NET SDK (najnowsza wersja)

Dowolny z następujących typów projektów:

Przykładowa aplikacja

Wyświetl lub pobierz przykładowy kod (jak pobrać): wybierz folder wersji 8.0 lub nowszej zgodny z wdrażaną wersją platformy .NET. W folderze wersji uzyskaj dostęp do przykładu o nazwie WASMBrowserAppImportExportInterop.

JS międzyoperajności przy użyciu [JSImport]/[JSExport] atrybutów

Atrybut [JSImport] jest stosowany do metody .NET, aby wskazać, że odpowiednią JS metodę należy wywołać po wywołaniu metody .NET. Dzięki temu deweloperzy platformy .NET mogą definiować "importy", które umożliwiają wywołanie kodu platformy .NET do JSelementu . Action Ponadto element może zostać przekazany jako parametr i JS może wywołać akcję w celu obsługi wzorca subskrypcji wywołania zwrotnego lub zdarzenia.

Atrybut [JSExport] jest stosowany do metody .NET w celu uwidocznienia go w JS kodzie. Dzięki temu kod może JS inicjować wywołania metody .NET.

Importowanie JS metod

Poniższy przykład importuje standardową wbudowaną JS metodę (console.log) do języka C#. [JSImport] program jest ograniczony do importowania metod obiektów dostępnych globalnie. Na przykład log jest metodą zdefiniowaną na console obiekcie, która jest zdefiniowana w obiekcie dostępnym globalnie globalThis. Metoda console.log jest mapowana na metodę serwera proxy języka C#, ConsoleLogktóra akceptuje ciąg komunikatu dziennika:

public partial class GlobalInterop
{
    [JSImport("globalThis.console.log")]
    public static partial void ConsoleLog(string text);
}

W Program.Mainpliku ConsoleLog jest wywoływana z komunikatem w celu zalogowania:

GlobalInterop.ConsoleLog("Hello World!");

Dane wyjściowe są wyświetlane w konsoli przeglądarki.

Poniżej przedstawiono importowanie metody zadeklarowanej w JSpliku .

Następująca metoda niestandardowa JS () generuje okno dialogowe alertu (globalThis.callAlertwindow.alert) z komunikatem przekazanym w textpliku :

globalThis.callAlert = function (text) {
  globalThis.window.alert(text);
}

Metoda globalThis.callAlert jest mapowana na metodę serwera proxy języka C# (CallAlert), która akceptuje ciąg komunikatu:

using System.Runtime.InteropServices.JavaScript;

public partial class GlobalInterop
{
	[JSImport("globalThis.callAlert")]
	public static partial void CallAlert(string text);
}

W Program.Mainpliku CallAlert jest wywoływana, przekazując tekst komunikatu okna dialogowego alertu:

GlobalInterop.CallAlert("Hello World");

Klasa języka C# deklarując metodę [JSImport] nie ma implementacji. W czasie kompilacji klasa częściowa wygenerowana przez źródło zawiera kod platformy .NET, który implementuje marshalling wywołania i typów w celu wywołania odpowiedniej JS metody. W programie Visual Studio, używając odpowiednio opcji Przejdź do definicji lub Przejdź do implementacji , przejdź do klasy częściowej wygenerowanej przez źródło lub klasy częściowej zdefiniowanej przez dewelopera.

W poprzednim przykładzie deklaracja pośrednia globalThis.callAlertJS jest używana do zawijania istniejącego JS kodu. Ten artykuł nieformalnie odnosi się do deklaracji pośredniej JS jako podkładkiJS. JS podkładki wypełniają lukę między implementacją platformy .NET i istniejącymi JS możliwościami/bibliotekami. W wielu przypadkach, takich jak poprzedni przykład trywialny, JS podkładka nie jest konieczna, a metody można zaimportować bezpośrednio, jak pokazano w poprzednim ConsoleLog przykładzie. Jak pokazano w kolejnych sekcjach w poniższych sekcjach, JS podkładka może:

  • Hermetyzowanie dodatkowej logiki.
  • Ręczne typy map.
  • Zmniejsz liczbę obiektów lub wywołań przekraczających granicę międzyoperacyjną.
  • Ręczne mapowanie wywołań statycznych na metody wystąpień.

Ładowanie deklaracji języka JavaScript

JS deklaracje przeznaczone do zaimportowania [JSImport] są zwykle ładowane w kontekście tej samej strony lub JS hosta, który załadował zestaw WebAssembly platformy .NET. Można to zrobić za pomocą:

  • Blok <script>...</script> deklarujący wbudowany JSelement .
  • Deklaracja źródła skryptu () (<script src="./some.js"></script>src), która ładuje plik zewnętrzny JS (.js).
  • Moduł JS ES6 (<script type='module' src="./moduleName.js"></script>).
  • Moduł JS ES6 ładowany z JSHost.ImportAsync zestawu WebAssembly platformy .NET.

Przykłady w tym artykule używają polecenia JSHost.ImportAsync. Podczas wywoływania wywołania ImportAsyncpolecenia zestaw webAssembly platformy .NET po stronie klienta żąda pliku przy użyciu parametru moduleUrl i dlatego oczekuje, że plik będzie dostępny jako statyczny zasób internetowy, podobnie jak <script> tag pobiera plik z src adresem URL. Na przykład następujący kod języka C# w projekcie aplikacji webAssembly Browser przechowuje JS plik (.js) w ścieżce /wwwroot/scripts/ExampleShim.js:

await JSHost.ImportAsync("ExampleShim", "/scripts/ExampleShim.js");

W zależności od platformy, która ładuje zestaw WebAssembly, adres URL z prefiksem kropkowym, taki jak , może odwoływać się do niepoprawnego podkatalogu, takiego jak ./scripts//_framework/scripts/, ponieważ pakiet WebAssembly jest inicjowany przez skrypty platformy w obszarze /_framework/. W takim przypadku prefiks adresu URL odwołuje ../scripts/ się do właściwej ścieżki. Prefiksowanie z /scripts/ programem działa, jeśli witryna jest hostowana w katalogu głównym domeny. Typowe podejście polega na skonfigurowaniu prawidłowej ścieżki podstawowej dla danego środowiska za pomocą tagu HTML <base> i przy użyciu prefiksu /scripts/ w celu odwoływania się do ścieżki względem ścieżki podstawowej. Prefiksy notacji ~/ tilde nie są obsługiwane przez program JSHost.ImportAsync.

Ważne

Jeśli JS element jest ładowany z modułu JavaScript, [JSImport] atrybuty muszą zawierać nazwę modułu jako drugi parametr. Na przykład wskazuje, [JSImport("globalThis.callAlert", "ExampleShim")] że zaimportowana metoda została zadeklarowana w module JavaScript o nazwie "ExampleShim".

Mapowania typów

Parametry i typy zwracane w podpisie metody .NET są automatycznie konwertowane na lub z odpowiednich JS typów w czasie wykonywania, jeśli jest obsługiwane unikatowe mapowanie. Może to spowodować przekonwertowanie wartości według wartości lub odwołań opakowanych w typ serwera proxy. Ten proces jest znany jako typ marshalling. Służy JSMarshalAsAttribute<T> do kontrolowania sposobu, w jaki importowane parametry metody i typy zwracane są marshalled.

Niektóre typy nie mają mapowania typów domyślnych. Na przykład long element może być marshalled jako System.Runtime.InteropServices.JavaScript.JSType.Number lub System.Runtime.InteropServices.JavaScript.JSType.BigInt, więc JSMarshalAsAttribute<T> jest wymagany, aby uniknąć błędu czasu kompilacji.

Obsługiwane są następujące scenariusze mapowania typów:

  • Przekazywanie Action lub Func<TResult> jako parametrów, które są marshalled jako metody wywoływane JS . Dzięki temu kod platformy .NET może wywoływać odbiorniki w odpowiedzi na JS wywołania zwrotne lub zdarzenia.
  • Przekazywanie JS odwołań i odwołań do obiektów zarządzanych platformy .NET w obu kierunkach, które są marshalowane jako obiekty serwera proxy i przechowywane w granicach międzyoperacyjności, dopóki serwer proxy nie zostanie odśmiecany.
  • Marshalling asynchroniczne JS metody lub JS Promise z Task wynikiem i na odwrót.

Większość typów marshalled działa w obu kierunkach, jako parametry i jako wartości zwracane, zarówno w metodach importowanych, jak i eksportowanych.

W poniższej tabeli przedstawiono obsługiwane mapowania typów.

.NET JavaScript Nullable Task➔do Promise JSMarshalAs fakultatywny Array of
Boolean Boolean Obsługiwane Obsługiwane Obsługiwane Nieobsługiwane
Byte Number Obsługiwane Obsługiwane Obsługiwane Obsługiwane
Char String Obsługiwane Obsługiwane Obsługiwane Nieobsługiwane
Int16 Number Obsługiwane Obsługiwane Obsługiwane Nieobsługiwane
Int32 Number Obsługiwane Obsługiwane Obsługiwane Obsługiwane
Int64 Number Obsługiwane Obsługiwane Nieobsługiwane Nieobsługiwane
Int64 BigInt Obsługiwane Obsługiwane Nieobsługiwane Nieobsługiwane
Single Number Obsługiwane Obsługiwane Obsługiwane Nieobsługiwane
Double Number Obsługiwane Obsługiwane Obsługiwane Obsługiwane
IntPtr Number Obsługiwane Obsługiwane Obsługiwane Nieobsługiwane
DateTime Date Obsługiwane Obsługiwane Nieobsługiwane Nieobsługiwane
DateTimeOffset Date Obsługiwane Obsługiwane Nieobsługiwane Nieobsługiwane
Exception Error Nieobsługiwane Obsługiwane Obsługiwane Nieobsługiwane
JSObject Object Nieobsługiwane Obsługiwane Obsługiwane Obsługiwane
String String Nieobsługiwane Obsługiwane Obsługiwane Obsługiwane
Object Any Nieobsługiwane Obsługiwane Nieobsługiwane Obsługiwane
Span<Byte> MemoryView Nieobsługiwane Nieobsługiwane Nieobsługiwane Nieobsługiwane
Span<Int32> MemoryView Nieobsługiwane Nieobsługiwane Nieobsługiwane Nieobsługiwane
Span<Double> MemoryView Nieobsługiwane Nieobsługiwane Nieobsługiwane Nieobsługiwane
ArraySegment<Byte> MemoryView Nieobsługiwane Nieobsługiwane Nieobsługiwane Nieobsługiwane
ArraySegment<Int32> MemoryView Nieobsługiwane Nieobsługiwane Nieobsługiwane Nieobsługiwane
ArraySegment<Double> MemoryView Nieobsługiwane Nieobsługiwane Nieobsługiwane Nieobsługiwane
Task Promise Nieobsługiwane Nieobsługiwane Obsługiwane Nieobsługiwane
Action Function Nieobsługiwane Nieobsługiwane Nieobsługiwane Nieobsługiwane
Action<T1> Function Nieobsługiwane Nieobsługiwane Nieobsługiwane Nieobsługiwane
Action<T1, T2> Function Nieobsługiwane Nieobsługiwane Nieobsługiwane Nieobsługiwane
Action<T1, T2, T3> Function Nieobsługiwane Nieobsługiwane Nieobsługiwane Nieobsługiwane
Func<TResult> Function Nieobsługiwane Nieobsługiwane Nieobsługiwane Nieobsługiwane
Func<T1, TResult> Function Nieobsługiwane Nieobsługiwane Nieobsługiwane Nieobsługiwane
Func<T1, T2, TResult> Function Nieobsługiwane Nieobsługiwane Nieobsługiwane Nieobsługiwane
Func<T1, T2, T3, TResult> Function Nieobsługiwane Nieobsługiwane Nieobsługiwane Nieobsługiwane

Następujące warunki dotyczą mapowania typów i wartości marshalled:

  • Kolumna Array of wskazuje, czy typ platformy .NET można rozmieścić jako JSArray. Przykład: C# int[] (Int32) zamapowany na JSArray s Number.
  • W przypadku przekazywania JS wartości do języka C# z wartością nieprawidłowego typu struktura zgłasza wyjątek w większości przypadków. Struktura nie wykonuje ewidencjonowania typów w czasie kompilacji.JS
  • JSObjectTask, Exceptioni ArraySegment utwórz GCHandle i serwer proxy. Usunięcie można wyzwolić w kodzie dewelopera lub zezwolić na późniejsze usunięcie obiektów przez program .NET. Te typy mają znaczne obciążenie związane z wydajnością.
  • Array: Przeprowadzanie marshalingu tablicy tworzy kopię tablicy na JS platformie lub .NET.
  • MemoryView
    • MemoryView jest klasą JS środowiska uruchomieniowego .NET WebAssembly do marshalingu Span i ArraySegment.
    • W przeciwieństwie do marshalingu tablicy, marshaling a Span lub ArraySegment nie tworzy kopii pamięci bazowej.
    • MemoryView Można poprawnie utworzyć wystąpienie tylko przez środowisko uruchomieniowe zestawu WebAssembly platformy .NET. W związku z tym nie można zaimportować JS metody jako metody .NET, która ma parametr Span lub ArraySegment.
    • MemoryView element utworzony dla elementu Span jest ważny tylko przez czas trwania wywołania międzyoperacyjnego. Ponieważ Span jest przydzielany na stos wywołania wywołania, który nie jest utrwalany po wywołaniu międzyoperamentowym, nie można wyeksportować metody platformy .NET zwracającej Spanelement .
    • MemoryView utworzony dla ArraySegment elementu przetrwa po wywołaniu międzyoperacyjnym i jest przydatny do udostępniania buforu. Wywołanie dispose() elementu utworzonego MemoryView dla ArraySegment serwera proxy powoduje usuwanie serwera proxy i odpina podstawową tablicę .NET. Zalecamy wywołanie dispose() w try-finally bloku dla elementu MemoryView.

Niektóre kombinacje mapowań typów, które wymagają zagnieżdżonych typów ogólnych w programie JSMarshalAs , nie są obecnie obsługiwane. Na przykład próba zmaterializowania tablicy z elementu Promise , takiego jak [return: JSMarshalAs<JSType.Promise<JSType.Array<JSType.Number>>>()] generowanie błędu czasu kompilacji. Odpowiednie obejście różni się w zależności od scenariusza, ale ten konkretny scenariusz jest dokładniej omówiony w sekcji Ograniczenia mapowania typów.

JS Pierwotnych

W poniższym przykładzie pokazano [JSImport] wykorzystanie mapowań typów kilku typów pierwotnych JS i użycie metody JSMarshalAs, gdzie jawne mapowania są wymagane w czasie kompilacji.

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");
    }
}

W pliku Program.Main:

await PrimitivesUsage.Run();

W poprzednim przykładzie zostaną wyświetlone następujące dane wyjściowe w konsoli debugowania przeglądarki:

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 Obiektów

W przykładzie w tej sekcji przedstawiono metody importowania, które mają obiekt jako zwracany JS Date lub parametr. Daty są ukierunkowane międzyoperacyjnie według wartości, co oznacza, że są kopiowane w taki sam sposób, jak JS w przypadku elementów pierwotnych.

Date Obiekt jest niezależny od strefy czasowej. Platforma .NET DateTime jest dostosowywana w stosunku do Dateelementu DateTimeKind , ale informacje o strefie czasowej nie są zachowywane. Rozważ zainicjowanie elementu DateTime z wartością DateTimeKind.Utc lub DateTimeKind.Local zgodną z wartością, która reprezentuje.

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);
    }
}

W pliku Program.Main:

await DateUsage.Run();

W poprzednim przykładzie zostaną wyświetlone następujące dane wyjściowe w konsoli debugowania przeglądarki:

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)

Powyższe informacje o strefie czasowej (GMT-0500 (Eastern Standard Time)) zależą od lokalnej strefy czasowej komputera/przeglądarki.

JS odwołania do obiektów

JS Za każdym razem, gdy metoda zwraca odwołanie do obiektu, jest reprezentowana na platformie .NET jako JSObject. Oryginalny JS obiekt kontynuuje okres istnienia w granicach JS , podczas gdy kod platformy .NET może uzyskiwać dostęp do niego i modyfikować go przy użyciu odwołania za pośrednictwem JSObject. Chociaż sam typ uwidacznia ograniczony interfejs API, możliwość przechowywania JS odwołania do obiektu i zwracania lub przekazywania go przez granicę międzyoperacyjną umożliwia obsługę kilku scenariuszy międzyoperacyjności.

Udostępnia metody uzyskiwania JSObject dostępu do właściwości, ale nie zapewnia bezpośredniego dostępu do metod wystąpienia. Jak pokazano w poniższej Summarize metodzie, metody wystąpienia mogą być uzyskiwane pośrednio przez zaimplementowanie metody statycznej, która przyjmuje wystąpienie jako parametr.

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);
    }
}

W pliku Program.Main:

await JSObjectUsage.Run();

W poprzednim przykładzie zostaną wyświetlone następujące dane wyjściowe w konsoli debugowania przeglądarki:

{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

Asynchroniczne międzyoperacyjności

Wiele JS interfejsów API jest asynchronicznych i uzupełniania sygnałów za pośrednictwem wywołania zwrotnego, Promisemetody , lub asynchronicznej. Ignorowanie możliwości asynchronicznych często nie jest opcją, ponieważ kolejny kod może zależeć od ukończenia operacji asynchronicznej i musi być oczekiwany.

JS metody używające słowa kluczowego async lub zwracające Promise element można oczekiwać w języku C# przez metodę zwracającą Taskelement . Jak pokazano poniżej, słowo async kluczowe nie jest używane w metodzie języka C# z atrybutem [JSImport] , ponieważ nie używa w nim słowa kluczowego await . Jednak używanie kodu wywołującego metodę zwykle używa słowa kluczowego await i jest oznaczone jako async, jak pokazano w przykładzie PromisesUsage .

JS z wywołaniem zwrotnym setTimeout, takim jak , można opakować w obiekcie Promise przed powrotem z JS. Zawijanie wywołania zwrotnego w Promiseobiekcie , jak pokazano w funkcji przypisanej do Wait2Secondsmetody , jest odpowiednie tylko wtedy, gdy wywołanie zwrotne jest wywoływane dokładnie raz. W przeciwnym razie można przekazać kod C# Action , aby nasłuchiwać wywołania zwrotnego, które może być wywoływane zero lub wiele razy, co zostało pokazane w sekcji Subskrybowanie JS zdarzeń .

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);
  });
}

Nie używaj słowa kluczowego async w podpisie metody języka C#. Zwracanie Task lub Task<TResult> jest wystarczające.

Podczas wywoływania metod asynchronicznych JS często chcemy poczekać, aż JS metoda zakończy wykonywanie. Jeśli ładujemy zasób lub wysyłamy żądanie, prawdopodobnie chcemy, aby poniższy kod zakładał, że akcja została ukończona.

Jeśli podkładka JS zwraca element Promise, język C# może traktować go jako oczekiwany Task/Task<TResult>element .

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}'");
        }
    }
}

W pliku Program.Main:

await PromisesUsage.Run();

W poprzednim przykładzie zostaną wyświetlone następujące dane wyjściowe w konsoli debugowania przeglądarki:

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'

Ograniczenia mapowania typów

Niektóre mapowania typów wymagające zagnieżdżonych typów ogólnych w JSMarshalAs definicji nie są obecnie obsługiwane. Na przykład zwraca wartość Promise dla tablicy, na przykład [return: JSMarshalAs<JSType.Promise<JSType.Array<JSType.Number>>>()] generuje błąd czasu kompilacji. Odpowiednie obejście różni się w zależności od scenariusza, ale jedną z opcji jest reprezentowanie tablicy jako JSObject odwołania. Może to być wystarczające, jeśli uzyskanie dostępu do poszczególnych elementów na platformie .NET nie jest konieczne, a odwołanie można przekazać do innych JS metod, które działają na tablicy. Alternatywnie dedykowana metoda może przyjąć JSObject odwołanie jako parametr i zwrócić zmaterializowaną tablicę, jak pokazano w poniższym UnwrapJSObjectAsIntArray przykładzie. W takim przypadku JS metoda nie ma sprawdzania typów, a deweloper ponosi odpowiedzialność za zapewnienie JSObject przekazania odpowiedniego typu tablicy.

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);
//...

W pliku Program.Main:

JSObject arrayAsJSObject = await PromisesInterop.WaitGetIntArrayAsObject();
int[] intArray = PromisesInterop.UnwrapJSObjectAsIntArray(arrayAsJSObject);

Zagadnienia dotyczące wydajności

Marshalling wywołań i nakład pracy śledzenia obiektów w granicach międzyoperacyjności jest droższy niż natywne operacje platformy .NET, ale nadal powinien wykazać akceptowalną wydajność typowej aplikacji internetowej z umiarkowanym zapotrzebowaniem.

Serwery proxy obiektów, takie jak JSObject, które utrzymują odwołania w granicach międzyoperacyjnych, mają dodatkowe obciążenie pamięci i wpływają na sposób odzyskiwania pamięci na te obiekty. Ponadto dostępna pamięć może zostać wyczerpana bez wyzwalania odzyskiwania pamięci w niektórych scenariuszach, ponieważ wykorzystanie pamięci z JS platformy .NET nie jest współużytkowane. To ryzyko jest istotne, gdy nadmierna liczba dużych obiektów jest przywołyna przez granicę międzyoperacyjną przez stosunkowo małe JS obiekty lub odwrotnie, gdy duże obiekty platformy .NET odwołują się do JS serwerów proxy. W takich przypadkach zalecamy stosowanie deterministycznych wzorców usuwania z zakresami korzystającymi z using interfejsu IDisposable na JS obiektach.

Poniższe testy porównawcze, które wykorzystują wcześniej przykładowy kod, pokazują, że operacje międzyoperacyjności są mniej więcej o rzędu wielkości wolniejsze niż te, które pozostają w granicach platformy .NET, ale operacje międzyoperacyjności pozostają stosunkowo szybkie. Ponadto należy wziąć pod uwagę, że możliwości urządzenia użytkownika wpływają na wydajność.

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;
    }
}

W pliku Program.Main:

JSObjectBenchmark.Run();

W poprzednim przykładzie zostaną wyświetlone następujące dane wyjściowe w konsoli debugowania przeglądarki:

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

Subskrybowanie JS zdarzeń

Kod platformy .NET może subskrybować JS zdarzenia i obsługiwać JS zdarzenia, przekazując język C# Action do JS funkcji działającej jako procedura obsługi. Kod JS podkładki obsługuje subskrybowanie zdarzenia.

Ostrzeżenie

Interakcja z poszczególnymi właściwościami modelu DOM za pośrednictwem JS międzyoperacyjności, jak pokazano we wskazówkach w tej sekcji, jest stosunkowo niska i może prowadzić do powstania wielu serwerów proxy, które tworzą wysokie wykorzystanie pamięci. Poniższy wzorzec nie jest ogólnie zalecany. Użyj następującego wzorca dla nie więcej niż kilku elementów. Aby uzyskać więcej informacji, zobacz sekcję Zagadnienia dotyczące wydajności.

Niuansem removeEventListener jest to, że wymaga odwołania do funkcji wcześniej przekazanej do addEventListener. Gdy język C# Action jest przekazywany przez granicę JS międzyoperacyjną, jest owinięty w obiekt serwera proxy. W związku z tym przekazanie tego samego języka C# Action do obu addEventListener i removeEventListener powoduje wygenerowanie dwóch różnych JS obiektów serwera proxy opakowujących Actionobiekt . Te odwołania są różne, dlatego removeEventListener nie można odnaleźć odbiornika zdarzeń do usunięcia. Aby rozwiązać ten problem, poniższe przykłady opakowują język C# Action w JS funkcji i zwracają odwołanie jako JSObject odwołanie z wywołania subskrypcji, aby przekazać je później do wywołania anulowania subskrypcji. Ponieważ język C# Action jest zwracany i przekazywany jako JSObjectelement , to samo odwołanie jest używane dla obu wywołań, a odbiornik zdarzeń można usunąć.

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");
    }
}

W pliku Program.Main:

await EventsUsage.Run();

W poprzednim przykładzie zostaną wyświetlone następujące dane wyjściowe w konsoli debugowania przeglądarki:

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] scenariusze międzyoperacyjności

Następujące artykuły koncentrują się na uruchamianiu modułu WebAssembly platformy .NET na JS hoście, takim jak przeglądarka: