Partager via


Interopérabilité JavaScript [JSImport]/[JSExport] dans .NET WebAssembly

Remarque

Ceci n’est pas la dernière version de cet article. Pour la version actuelle, consultez la version .NET 8 de cet article.

Avertissement

Cette version d’ASP.NET Core n’est plus prise en charge. Pour plus d’informations, consultez la Stratégie de prise en charge de .NET et .NET Core. Pour la version actuelle, consultez la version .NET 8 de cet article.

Important

Ces informations portent sur la préversion du produit, qui est susceptible d’être en grande partie modifié avant sa commercialisation. Microsoft n’offre aucune garantie, expresse ou implicite, concernant les informations fournies ici.

Pour la version actuelle, consultez la version .NET 8 de cet article.

Par Aaron Shumaker

Cet article explique comment interagir avec JavaScript (JS) dans WebAssembly côté client à l’aide de l’interopérabilité JS[JSImport]/[JSExport] (API System.Runtime.InteropServices.JavaScript).

L’interopérabilité [JSImport]/[JSExport] s’applique lors de l’exécution d’un module WebAssembly .NET dans un hôte JS dans les scénarios suivants :

Prérequis

Kit de développement logiciel (SDK) .NET

Un des types de projet suivants :

Exemple d’application

Afficher ou télécharger un exemple de code (Comment le télécharger) : sélectionnez le dossier qui correspond à la version de .NET (version 8.0 ou ultérieure) que vous adoptez. Dans le dossier de la version, accédez à l’exemple nommé WASMBrowserAppImportExportInterop.

Interopérabilité JS à l’aide d’attributs [JSImport]/[JSExport]

L’attribut [JSImport] est appliqué à une méthode .NET pour indiquer qu’une méthode JS correspondante doit être appelée lorsque la méthode .NET est appelée. Cela permet aux développeurs .NET de définir des « importations » qui permettent au code .NET d’appeler JS. De plus, Action peut être transmis en tant que paramètre et JS peut appeler l’action pour prendre en charge un modèle d’inscription à un événement ou de rappel.

L’attribut [JSExport] est appliqué à une méthode .NET pour l’exposer au code JS. Cela permet au code JS de lancer des appels vers la méthode .NET.

Méthodes d’importation JS

L’exemple suivant importe une méthode JS intégrée standard (console.log) en C#. [JSImport] est limité à l’importation de méthodes d’objets accessibles à l’échelle globale. Par exemple, log est une méthode définie sur l’objet console, qui est définie sur l’objet globalThis accessible à l’échelle globale. La méthode console.log est mappée à une méthode de proxy C#, ConsoleLog, qui accepte une chaîne pour le message de journal :

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

Dans Program.Main, ConsoleLog est appelé avec le message à consigner dans le journal :

GlobalInterop.ConsoleLog("Hello World!");

La sortie s’affiche dans la console du navigateur.

L’exemple suivant illustre l’importation d’une méthode déclarée dans JS.

La méthode JS personnalisée suivante (globalThis.callAlert) génère une boîte de dialogue d’alerte (window.alert) avec le message transmis via text :

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

La méthode globalThis.callAlert est mappée à une méthode de proxy C# (CallAlert), qui accepte une chaîne pour le message :

using System.Runtime.InteropServices.JavaScript;

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

Dans Program.Main, CallAlert est appelé, et transmet le texte du message de boîte de dialogue d’alerte :

GlobalInterop.CallAlert("Hello World");

La classe C# qui déclare la méthode [JSImport] n’a pas d’implémentation. Au moment de la compilation, une classe partielle générée par la source contient le code .NET qui implémente la sérialisation de l’appel et des types pour appeler la méthode JS correspondante. Dans Visual Studio, utiliser les options Accéder à la définition ou Accéder à l’implémentation permet d’accéder respectivement à la classe partielle générée par la source ou à la classe partielle définie par le développeur.

Dans l’exemple précédent, la déclaration JS intermédiaire globalThis.callAlert est utilisée pour encapsuler le code JS existant. Cet article désigne de manière informelle la déclaration JS intermédiaire en tant que shim JS. Les shims JS permettent de combler l’écart entre l’implémentation .NET et les fonctionnalités/bibliothèques JS existantes. Dans de nombreux cas, tel que l’exemple trivial précédent, le shim JS n’est pas nécessaire et les méthodes peuvent être importées directement, comme illustré dans l’exemple ConsoleLog précédent. Comme presenté dans cet article dans les sections suivantes, un shim JS peut :

  • Encapsuler une logique supplémentaire.
  • Mapper manuellement des types.
  • Réduire le nombre d’objets ou d’appels qui dépassent les limites de l’interopérabilité.
  • Mapper manuellement des appels statiques à des méthodes d’instance.

Chargement des déclarations JavaScript

Les déclarations JS destinées à être importées avec [JSImport] sont généralement chargées dans le contexte de la même page ou de l’hôte JS qui a chargé .NET WebAssembly. Vous pouvez réaliser cela avec les éléments suivants :

  • Un bloc <script>...</script> qui déclare du JS intégré.
  • Une déclaration (<script src="./some.js"></script>) de source de script (src) qui charge un fichier JS externe (.js).
  • Un module JS ES6 (<script type='module' src="./moduleName.js"></script>).
  • Un module JS ES6 chargé à l’aide de JSHost.ImportAsync à partir de .NET WebAssembly.

Les exemples présentés dans cet article utilisent JSHost.ImportAsync. Lorsqu’il appelle la méthode ImportAsync, WebAssembly .NET côté client demande le fichier à l’aide du paramètre moduleUrl et s’attend donc à ce que le fichier soit accessible en tant que ressource web statique, de la même façon qu’une balise <script> récupère un fichier avec une URL src. Par exemple, le code C# suivant dans un projet d’application de navigateur WebAssembly gère le fichier JS (.js) au niveau du chemin d’accès /wwwroot/scripts/ExampleShim.js :

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

Selon la plateforme qui charge WebAssembly, une URL avec un point comme préfixe, telle que ./scripts/, peut faire référence à un sous-répertoire incorrect, tel que /_framework/scripts/, car le package WebAssembly est initialisé par des scripts de framework situés dans /_framework/. Dans ce cas, utiliser un préfixe d’URL tel que ../scripts/ fait référence au chemin d’accès correct. Utiliser /scripts/ comme préfixe fonctionne si le site est hébergé à la racine du domaine. Une approche classique consiste à configurer le chemin d’accès de base pour l’environnement donné à l’aide d’une balise HTML <base> et à utiliser le préfixe /scripts/ pour faire référence au chemin d’accès relatif au chemin de base. Les préfixes avec un tilde ~/ ne sont pas pris en charge par JSHost.ImportAsync.

Important

Si JS est chargé à partir d’un module JavaScript, alors les attributs [JSImport] doivent inclure le nom du module comme deuxième paramètre. Par exemple, [JSImport("globalThis.callAlert", "ExampleShim")] indique que la méthode importée a été déclarée dans un module JavaScript nommé «ExampleShim ».

Mappages de types

Les types des paramètres et des retours dans la signature de la méthode .NET sont automatiquement convertis en types JS appropriés au moment de l’exécution si un mappage unique est pris en charge. Cela peut entraîner la conversion de valeurs par des valeurs ou des références encapsulées dans un type proxy. Ce processus est appelé sérialisation de type. Utilisez JSMarshalAsAttribute<T> pour contrôler la façon dont les types des paramètres et des retours de la méthode importée sont sérialisés.

Certains types n’ont pas de mappage de type par défaut. Par exemple, long peut être sérialisé en tant que System.Runtime.InteropServices.JavaScript.JSType.Number ou System.Runtime.InteropServices.JavaScript.JSType.BigInt, JSMarshalAsAttribute<T> est ainsi nécessaire d’éviter une erreur de compilation.

Les scénarios de mappage suivants sont pris en charge :

  • La transmission de Action ou Func<TResult> en tant que paramètres, qui sont sérialisés en tant que méthodes JS pouvant être appelées. Cela permet au code .NET d’appeler des écouteurs en réponse à des rappels ou des événements JS.
  • La transmission de références JS et de références d’objets gérées par .NET dans les deux sens, qui sont sérialisées en tant qu’objets proxy et conservées au niveau de la délimitation de l’interopérabilité jusqu’à ce que le proxy soit récupéré par le processus de collecte des déchets.
  • La sérialisation de méthodes JS asynchrones ou d’un JS Promise avec un résultat Task, et inversement.

La plupart des types marshalés fonctionnent dans les deux sens, en tant que paramètres et en tant que valeurs de retour, sur les méthodes importées et exportées.

Le tableau suivant indique les mappages de types pris en charge.

.NET JavaScript Nullable Task vers Promise JSMarshalAs facultatif Array of
Boolean Boolean Pris en charge Pris en charge Pris en charge Non pris en charge
Byte Number Pris en charge Pris en charge Pris en charge Pris en charge
Char String Pris en charge Pris en charge Pris en charge Non pris en charge
Int16 Number Pris en charge Pris en charge Pris en charge Non pris en charge
Int32 Number Pris en charge Pris en charge Pris en charge Pris en charge
Int64 Number Pris en charge Pris en charge Non pris en charge Non pris en charge
Int64 BigInt Pris en charge Pris en charge Non pris en charge Non pris en charge
Single Number Pris en charge Pris en charge Pris en charge Non pris en charge
Double Number Pris en charge Pris en charge Pris en charge Pris en charge
IntPtr Number Pris en charge Pris en charge Pris en charge Non pris en charge
DateTime Date Pris en charge Pris en charge Non pris en charge Non pris en charge
DateTimeOffset Date Pris en charge Pris en charge Non pris en charge Non pris en charge
Exception Error Non pris en charge Pris en charge Pris en charge Non pris en charge
JSObject Object Non pris en charge Pris en charge Pris en charge Pris en charge
String String Non pris en charge Pris en charge Pris en charge Pris en charge
Object Any Non pris en charge Pris en charge Non pris en charge Pris en charge
Span<Byte> MemoryView Non pris en charge Non pris en charge Non pris en charge Non pris en charge
Span<Int32> MemoryView Non pris en charge Non pris en charge Non pris en charge Non pris en charge
Span<Double> MemoryView Non pris en charge Non pris en charge Non pris en charge Non pris en charge
ArraySegment<Byte> MemoryView Non pris en charge Non pris en charge Non pris en charge Non pris en charge
ArraySegment<Int32> MemoryView Non pris en charge Non pris en charge Non pris en charge Non pris en charge
ArraySegment<Double> MemoryView Non pris en charge Non pris en charge Non pris en charge Non pris en charge
Task Promise Non pris en charge Non pris en charge Pris en charge Non pris en charge
Action Function Non pris en charge Non pris en charge Non pris en charge Non pris en charge
Action<T1> Function Non pris en charge Non pris en charge Non pris en charge Non pris en charge
Action<T1, T2> Function Non pris en charge Non pris en charge Non pris en charge Non pris en charge
Action<T1, T2, T3> Function Non pris en charge Non pris en charge Non pris en charge Non pris en charge
Func<TResult> Function Non pris en charge Non pris en charge Non pris en charge Non pris en charge
Func<T1, TResult> Function Non pris en charge Non pris en charge Non pris en charge Non pris en charge
Func<T1, T2, TResult> Function Non pris en charge Non pris en charge Non pris en charge Non pris en charge
Func<T1, T2, T3, TResult> Function Non pris en charge Non pris en charge Non pris en charge Non pris en charge

Les conditions suivantes s’appliquent au mappage de type et aux valeurs marshalées :

  • La colonne Array of indique si le type .NET peut être marshalé en tant que JSArray. Exemple : C# int[] (Int32) mappé à JSArray de Number.
  • Quand une valeur JS est passée en C# avec un type incorrect, le framework lève une exception dans la plupart des cas. Le framework n’effectue pas de contrôle de type au moment de la compilation en JS.
  • JSObject, Exception, Task et ArraySegment créent GCHandle et un proxy. Vous pouvez déclencher la suppression dans le code de développeur, ou autoriser le GC (nettoyage de la mémoire) .NET à supprimer les objets plus tard. Ces types entraînent une surcharge importante au niveau des performances.
  • Array : Le marshaling d’un tableau crée une copie du tableau en JS ou dans .NET.
  • MemoryView
    • MemoryView est une classe JS qui permet au runtime .NET WebAssembly de marshaler Span et ArraySegment.
    • Contrairement au marshaling d’un tableau, le marshaling de Span ou ArraySegment ne crée pas de copie de la mémoire sous-jacente.
    • MemoryView peut uniquement être correctement instancié par le runtime .NET WebAssembly. Il n’est donc pas possible d’importer une méthode JS en tant que méthode .NET possédant un paramètre Span ou ArraySegment.
    • Un MemoryView créé pour un Span est uniquement valide pour la durée de l’appel d’interopérabilité. Dans la mesure où Span est alloué sur la pile des appels, qui ne persiste pas après l’appel d’interopérabilité, il n’est pas possible d’exporter une méthode .NET qui retourne Span.
    • Un MemoryView créé pour un ArraySegment survit après l’appel d’interopérabilité, et est utile pour partager de la mémoire tampon. L’appel de dispose() sur un MemoryView créé pour ArraySegment supprime le proxy, et dissocie le tableau .NET sous-jacent. Nous vous recommandons d’appeler dispose() dans un bloc try-finally pour MemoryView.

Certaines combinaisons de mappages de types dans JSMarshalAs qui nécessitent des types génériques imbriqués ne sont actuellement pas prises en charge. Par exemple, la tentative de matérialisation d’un tableau à partir d’un Promise tel que [return: JSMarshalAs<JSType.Promise<JSType.Array<JSType.Number>>>()] génère une erreur de compilation. La solution de contournement appropriée varie en fonction du scénario, mais ce scénario spécifique est exploré plus en détail dans la section Limitations du mappage de types.

Primitives JS

L’exemple suivant illustre comment [JSImport] tire parti des mappages de types de plusieurs types JS primitifs, et également l’utilisation de JSMarshalAs, avec lequel des mappages explicites sont requis lors de de la compilation.

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

Dans Program.Main :

await PrimitivesUsage.Run();

L’exemple précédent affiche la sortie suivante dans la console de débogage du navigateur :

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

Objets JSDate

L’exemple présenté dans cette section illustre comment importer des méthodes qui possèdent un objet JS Date en tant que retour ou paramètre. Les dates sont sérialisées dans l’interopérabilité par valeur, ce qui signifie qu’elles sont copiées de la même façon que les primitives JS.

Un objet Date est indépendant du fuseau horaire. Un DateTime .NET est ajusté par rapport à son DateTimeKind lorsqu’il est sérialisé vers une Date, mais les informations de fuseau horaire ne sont pas conservées. Envisagez d’initialiser un DateTime avec un DateTimeKind.Utc ou un DateTimeKind.Local cohérent par rapport à la valeur qu’il représente.

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 DateTime(1968, 12, 21, 12, 51, 0, DateTimeKind.Utc);
        DateInterop.LogValueAndType(date);
        date = DateInterop.IncrementDay(date);
        DateInterop.LogValueAndType(date);
    }
}

Dans Program.Main :

await DateUsage.Run();

L’exemple précédent affiche la sortie suivante dans la console de débogage du navigateur :

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)

Les informations de fuseau horaire précédentes (GMT-0500 (Eastern Standard Time)) dépendent du fuseau horaire local de votre ordinateur/navigateur.

Références d’objet JS

Chaque fois qu’une méthode JS renvoie une référence d’objet, elle est représentée dans .NET en tant que JSObject. L’objet JS d’origine continue d’exister dans la délimitation JS, tandis que le code .NET peut y accéder et le modifier par référence via le JSObject. Bien que le type lui-même dévoile une API limitée, la possibilité de contenir une référence d’objet JS et de la renvoyer ou de la transmettre au sein de la délimitation de l’interopérabilité permet de prendre en charge plusieurs scénarios d’interopérabilité.

Le JSObject fournit des méthodes permettent d’accéder aux propriétés, mais ne fournit pas d’accès direct aux méthodes d’instance. Comme le montre la méthode Summarize suivante, les méthodes d’instance sont accessibles indirectement en implémentant une méthode statique qui considère l’instance en tant que paramètre.

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

Dans Program.Main :

await JSObjectUsage.Run();

L’exemple précédent affiche la sortie suivante dans la console de débogage du navigateur :

{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

Interopérabilité asynchrone

De nombreuses API JS sont asynchrones et signalent un achèvement via un rappel, un Promise, ou une méthode asynchrone. Ignorer les fonctionnalités asynchrones n’est souvent pas une option, car du code à venir peut dépendre de l’achèvement de l’opération asynchrone et doit être attendu.

Les méthodes JS utilisant le mot clé async ou renvoyant un Promise peuvent être attendues en C# à l’aide d’une méthode renvoyant un Task. Comme indiqué ci-dessous, le mot clé async n’est pas utilisé dans la méthode C# avec l’attribut [JSImport], car celui-ci n’utilise pas le mot clé await. Toutefois, le code destiné à appeler la méthode utilise généralement le mot clé await et est marqué comme async, comme indiqué dans l’exemple PromisesUsage.

Un JS avec un rappel, tel que setTimeout, peut être encapsulé dans un Promise avant d’être renvoyé à partir de JS. Encapsuler un rappel dans un Promise, comme illustré dans la fonction affectée à Wait2Seconds, n’est approprié que lorsque le rappel est appelé exactement une seule fois. Autrement, un Action C# peut être transmis pour écouter un rappel qui peut être appelé zéro ou plusieurs fois, ceci étant présenté dans la section Abonnement aux événementsJS.

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

N’utilisez pas le mot clé async dans la signature de méthode C#. Renvoyer Task ou Task<TResult> est suffisant.

Lors de l’appel de méthodes JS asynchrones, nous voulons souvent attendre que la méthode JS termine son exécution. Si l’on charge une ressource ou que l’on effectue une requête, nous voulons de préférence que le code suivant suppose que l’action est terminée.

Si le shim JS renvoie un Promise, alors C# peut le traiter comme un Task/Task<TResult> pouvant être attendu.

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

Dans Program.Main :

await PromisesUsage.Run();

L’exemple précédent affiche la sortie suivante dans la console de débogage du navigateur :

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'

Limitations du mappage des types

Certains mappages de types dans la définition de JSMarshalAs qui nécessitent des types génériques imbriqués ne sont actuellement pas pris en charge. Par exemple, le renvoi d’un Promise pour un tableau tel que [return: JSMarshalAs<JSType.Promise<JSType.Array<JSType.Number>>>()] génère une erreur de compilation. La solution de contournement appropriée varie en fonction du scénario, mais une option consiste à représenter le tableau comme une référence JSObject. Cela peut suffire si l’accès à des éléments individuels dans .NET n’est pas nécessaire et que la référence peut être transmise à d’autres méthodes JS qui agissent sur le tableau. Une méthode dédiée peut également considérer la référence en tant que paramètre JSObject et renvoyer le tableau matérialisé, comme illustré par l’exemple UnwrapJSObjectAsIntArray suivant. Dans ce cas, la méthode JS ne possède pas de vérification de type et le développeur est chargé de s’assurer qu’un JSObject encapsulant le type de tableau approprié est transmis.

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

Dans Program.Main :

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

Considérations relatives aux performances

La sérialisation des appels et la surcharge d’objets de suivi au niveau de la délimitation de l’interopérabilité sont plus coûteuses que les opérations .NET natives, mais devraient toujours fournir des performances acceptables pour une application web classique avec une demande modérée.

Les proxys d’objets, tels que JSObject, qui conservent des références dans la délimitation de l’interopérabilité, incluent une surcharge de mémoire supplémentaire et ont un impact sur la façon dont le processus de collecte des déchets affecte ces objets. De plus, la mémoire disponible peut être épuisée sans déclencher le processus de collecte des déchets dans certains scénarios, car l’utilisation de la mémoire par JS et par .NET n’est pas partagée. Ce risque est important lorsqu’un nombre excessif d’objets volumineux est référencé dans la délimitation de l’interopérabilité par des objets JS relativement petits, ou inversement, si des objets .NET volumineux sont référencés par des proxys JS. Dans ce cas, nous vous recommandons de suivre des modèles de suppression déterministes avec des étendues using tirant parti de l’interface IDisposable pour les objets JS.

Les benchmarks suivants, qui tirent parti de l’exemple de code précédent, montrent que les opérations d’interopérabilité sont à peu près un ordre de grandeur plus lent que ceux qui restent dans la délimitation .NET. Les opérations d’interopérabilité restent malgré tout relativement rapides. Prenez également en compte que les caractéristiques de l’appareil d’un utilisateur ont un impact sur les performances.

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

Dans Program.Main :

JSObjectBenchmark.Run();

L’exemple précédent affiche la sortie suivante dans la console de débogage du navigateur :

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

Abonnement à des événements JS

Le code .NET peut s’abonner aux événements JS et gérer les événements JS en transmettant une Action C# à une fonction JS pour agir en tant que gestionnaire. Le code shim JS gère l’abonnement à l’événement.

Avertissement

L’interaction avec les propriétés individuelles du DOM via l’interopérabilité JS, comme les instructions de cette section le montrent, est relativement lente et peut entraîner la création de nombreux proxys qui génèrent une forte utilisation du processus de collecte des déchets. Le modèle suivant n’est généralement pas recommandé. Utilisez uniquement le modèle suivant pour quelques éléments, pas plus. Pour plus d’informations, consultez la section Considérations en matière de performances.

Une des particularités de removeEventListener est que celui-ci nécessite une référence à la fonction précédemment transmise à addEventListener. Lorsqu’une Action C# est transmise dans la délimitation de l’interopérabilité, elle est encapsulée dans un objet proxy JS. Par conséquent, transmettre la même Action C# à addEventListener et à removeEventListener entraîne la génération de deux objets proxy JS différents qui encapsulent l’Action. Ces références sont différentes, removeEventListener n’est donc pas en mesure de trouver l’écouteur d’événement à supprimer. Pour résoudre ce problème, les exemples suivants encapsulent l’Action C# dans une fonction JS et renvoient la référence en tant que JSObject à partir de l’appel d’abonnement pour qu’elle soit ultérieurement transmise à l’appel de désabonnement. Étant donné que l’Action C# est renvoyée et transmise en tant que JSObject, la même référence est utilisée pour les deux appels, et l’écouteur d’événement peut être supprimé.

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

Dans Program.Main :

await EventsUsage.Run();

L’exemple précédent affiche la sortie suivante dans la console de débogage du navigateur :

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.

Scénarios d’interopérabilité JS[JSImport]/[JSExport]

Les articles suivants traitent particulièrement de l’exécution d’un module WebAssembly .NET dans un hôte JS, tel qu’un navigateur :