Partilhar via


Interoperabilidade de [JSImport]/[JSExport] JavaScript no .NET WebAssembly

Observação

Esta não é a versão mais recente deste artigo. Para a versão atual, consulte a versão .NET 9 deste artigo.

Advertência

Esta versão do ASP.NET Core não é mais suportada. Para obter mais informações, consulte a Política de suporte do .NET e .NET Core. Para a versão atual, consulte a versão .NET 9 deste artigo.

Importante

Estas informações referem-se a um produto de pré-lançamento que pode ser substancialmente modificado antes de ser lançado comercialmente. A Microsoft não oferece garantias, expressas ou implícitas, em relação às informações fornecidas aqui.

Para a versão atual, consulte a versão .NET 9 deste artigo.

Por Aaron Shumaker

Este artigo explica como interagir com JavaScript (JS) no WebAssembly do lado do cliente usando a API de interoperabilidade (System.Runtime.InteropServices.JavaScript) JS[JSImport]/[JSExport].

[JSImport] / [JSExport] interoperabilidade é aplicável ao executar um módulo .NET WebAssembly em um host JS nos seguintes cenários:

Pré-requisitos

SDK do .NET (versão mais recente)

Qualquer um dos seguintes tipos de projeto:

Aplicativo de exemplo

Ver ou transferir código de exemplo (como transferir): Selecione uma pasta de versão 8.0 ou posterior que corresponda à versão do .NET que você está adotando. Dentro da pasta 'versão', acede ao exemplo chamado WASMBrowserAppImportExportInterop.

JS interoperabilidade utilizando atributos [JSImport]/[JSExport]

O atributo [JSImport] é aplicado a um método .NET para indicar que um método JS correspondente deve ser chamado quando o método .NET é chamado. Isso permite que os desenvolvedores do .NET definam "importações" que permitem que o código .NET chame JS. Além disso, um Action pode ser passado como parâmetro e JS pode invocar a ação para dar suporte a um retorno de chamada ou padrão de subscrição a eventos.

O atributo [JSExport] é aplicado a um método .NET para exposição ao código JS. Isso permite que o código JS inicie chamadas para o método .NET.

Importando métodos JS

O exemplo a seguir importa um método JS interno padrão (console.log) para C#. [JSImport] limita-se à importação de métodos de objetos acessíveis globalmente. Por exemplo, log é um método definido no objeto console, que é definido no objeto globalmente acessível globalThis. O método console.log é mapeado para um método proxy C#, ConsoleLog, que aceita uma cadeia de caracteres para a mensagem de log:

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

No Program.Main, ConsoleLog é chamado com a mensagem para registrar:

GlobalInterop.ConsoleLog("Hello World!");

A saída aparece no console do navegador.

O seguinte demonstra a importação de um método declarado em JS.

O método personalizado seguinte JS (globalThis.callAlert) cria um diálogo de alerta (window.alert) com a mensagem passada em text:

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

O método globalThis.callAlert é mapeado para um método proxy C# (CallAlert), que aceita uma cadeia de caracteres para a mensagem:

using System.Runtime.InteropServices.JavaScript;

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

No Program.Main, CallAlert é chamado, passando o texto da mensagem de diálogo de alerta:

GlobalInterop.CallAlert("Hello World");

A classe C# que declara o método [JSImport] não tem uma implementação. Em tempo de compilação, uma classe parcial gerada pelo código-fonte contém o código .NET que implementa a organização da chamada e os tipos para invocar o método JS correspondente. No Visual Studio, usar as opções Go To Definition ou Go To Implementation navega, respectivamente, para a classe parcial gerada pela fonte ou para a classe parcial definida pelo desenvolvedor.

No exemplo anterior, a declaração de globalThis.callAlertJS intermediário é usada para encapsular o código JS existente. Este artigo refere-se informalmente à declaração de JS intermédia como um JS shim. JS ajustes preenchem a diferença entre a implementação do .NET e as capacidades/bibliotecas de JS existentes. Em muitos casos, como no exemplo trivial anterior, o JS shim não é necessário, e os métodos podem ser importados diretamente, como demonstrado no exemplo ConsoleLog anterior. Como este artigo demonstra nas próximas seções, um JS shim pode:

  • Encapsular lógica adicional.
  • Mapeie manualmente os tipos.
  • Reduza o número de objetos ou chamadas que cruzam o limite de interoperabilidade.
  • Mapeie manualmente chamadas estáticas para métodos de instância.

Carregando declarações JavaScript

JS declarações que devem ser importadas com [JSImport] são normalmente carregadas no contexto da mesma página ou JS host que carregou o .NET WebAssembly. Isto pode ser conseguido com:

  • Um bloco <script>...</script> declarando JSembutido .
  • Uma declaração de origem de script (src) (<script src="./some.js"></script>) que carrega um arquivo JS externo (.js).
  • Um módulo JS ES6 (<script type='module' src="./moduleName.js"></script>).
  • Um módulo JS ES6 carregado usando JSHost.ImportAsync do .NET WebAssembly.

Exemplos neste artigo usam JSHost.ImportAsync. Ao chamar ImportAsync, o .NET WebAssembly do lado do cliente solicita o arquivo usando o parâmetro moduleUrl e, portanto, espera que o arquivo seja acessível como um ativo da Web estático, da mesma forma que uma marca <script> recupera um arquivo com uma URL src. Por exemplo, o seguinte código C# dentro de um projeto WebAssembly Browser App mantém o arquivo JS (.js) no caminho /wwwroot/scripts/ExampleShim.js:

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

Dependendo da plataforma que está carregando o WebAssembly, uma URL prefixada por pontos, como ./scripts/, pode se referir a um subdiretório incorreto, como /_framework/scripts/, porque o pacote WebAssembly é inicializado por scripts de estrutura em /_framework/. Nesse caso, prefixar a URL com ../scripts/ refere-se ao caminho correto. O prefixo com /scripts/ funciona se o site estiver hospedado na raiz do domínio. Uma abordagem típica envolve a configuração do caminho base correto para um determinado ambiente com uma marca HTML <base> e o uso do prefixo /scripts/ para se referir ao caminho relativo ao caminho base. Os prefixos da notação til ~/ não são suportados pelo JSHost.ImportAsync.

Importante

Se JS for carregado de um módulo JavaScript, [JSImport] atributos deverão incluir o nome do módulo como o segundo parâmetro. Por exemplo, [JSImport("globalThis.callAlert", "ExampleShim")] indica que o método importado foi declarado em um módulo JavaScript chamado "ExampleShim".

Mapeamentos de tipo

Parâmetros e tipos de retorno na assinatura do método .NET são convertidos automaticamente para ou a partir de tipos JS apropriados em tempo de execução, se um mapeamento único for suportado. Isso pode resultar em valores convertidos por valor ou referências encapsuladas em um tipo de proxy. Este processo é conhecido como marshalling do tipo . Use JSMarshalAsAttribute<T> para controlar como os parâmetros do método importado e os tipos de retorno são tratados.

Alguns tipos não têm um mapeamento de tipo padrão. Por exemplo, um long pode ser empacotado como System.Runtime.InteropServices.JavaScript.JSType.Number ou System.Runtime.InteropServices.JavaScript.JSType.BigInt, de modo que o JSMarshalAsAttribute<T> é necessário para evitar um erro em tempo de compilação.

Os seguintes cenários de mapeamento de tipo são suportados:

  • Passagem de Action ou Func<TResult> como parâmetros, que são processados como métodos JS chamáveis. Isso permite que o código .NET invoque ouvintes em resposta a retornos de chamada ou eventos JS.
  • Passando referências de JS e referências de objetos geridos do .NET em ambas as direções, que são encaminhadas como objetos proxy e mantidas vivas através do limite de interoperabilidade até que o proxy seja recolhido pelo coletor de lixo.
  • Marshalização de métodos assíncronos JS ou de um JS Promise com um resultado Task, e vice-versa.

A maioria dos tipos organizados funciona em ambas as direções, como parâmetros e como valores de retorno, em métodos importados e exportados.

A tabela a seguir indica os mapeamentos de tipo suportados.

.NET Javascript Nullable Task paraPromise JSMarshalAs opcional Array of
Boolean Boolean Suportado Suportado Suportado Não suportado
Byte Number Suportado Suportado Suportado Suportado
Char String Suportado Suportado Suportado Não suportado
Int16 Number Suportado Suportado Suportado Não suportado
Int32 Number Suportado Suportado Suportado Suportado
Int64 Number Suportado Suportado Não suportado Não suportado
Int64 BigInt Suportado Suportado Não suportado Não suportado
Single Number Suportado Suportado Suportado Não suportado
Double Number Suportado Suportado Suportado Suportado
IntPtr Number Suportado Suportado Suportado Não suportado
DateTime Date Suportado Suportado Não suportado Não suportado
DateTimeOffset Date Suportado Suportado Não suportado Não suportado
Exception Error Não suportado Suportado Suportado Não suportado
JSObject Object Não suportado Suportado Suportado Suportado
String String Não suportado Suportado Suportado Suportado
Object Any Não suportado Suportado Não suportado Suportado
Span<Byte> MemoryView Não suportado Não suportado Não suportado Não suportado
Span<Int32> MemoryView Não suportado Não suportado Não suportado Não suportado
Span<Double> MemoryView Não suportado Não suportado Não suportado Não suportado
ArraySegment<Byte> MemoryView Não suportado Não suportado Não suportado Não suportado
ArraySegment<Int32> MemoryView Não suportado Não suportado Não suportado Não suportado
ArraySegment<Double> MemoryView Não suportado Não suportado Não suportado Não suportado
Task Promise Não suportado Não suportado Suportado Não suportado
Action Function Não suportado Não suportado Não suportado Não suportado
Action<T1> Function Não suportado Não suportado Não suportado Não suportado
Action<T1, T2> Function Não suportado Não suportado Não suportado Não suportado
Action<T1, T2, T3> Function Não suportado Não suportado Não suportado Não suportado
Func<TResult> Function Não suportado Não suportado Não suportado Não suportado
Func<T1, TResult> Function Não suportado Não suportado Não suportado Não suportado
Func<T1, T2, TResult> Function Não suportado Não suportado Não suportado Não suportado
Func<T1, T2, T3, TResult> Function Não suportado Não suportado Não suportado Não suportado

Aplicam-se as seguintes condições à cartografia de tipo e aos valores agrupados:

  • A coluna Array of indica se o tipo .NET pode ser empacotado como um JSArray. Exemplo: C# int[] (Int32) mapeado para JSArray de Numbers.
  • Ao passar um valor JS para C# com um valor do tipo errado, o framework lança uma exceção na maioria das vezes. A estrutura não realiza a verificação de tipo em tempo de compilação em JS.
  • JSObject, Exception, Task e ArraySegment criam GCHandle e um proxy. Você pode acionar o descarte no código do desenvolvedor ou permitir que de coleta de lixo (GC) do .NET elimine os objetos posteriormente. Esses tipos carregam uma sobrecarga de desempenho significativa.
  • Array: A organização de uma matriz cria uma cópia da matriz no JS ou na plataforma .NET.
  • MemoryView
    • MemoryView é uma classe JS para o tempo de execução do .NET WebAssembly para gerir Span e ArraySegment.
    • Ao contrário de empacotar uma matriz, empacotar uma Span ou ArraySegment não cria uma cópia da memória subjacente.
    • MemoryView só pode ser instanciado corretamente pelo runtime do .NET WebAssembly. Portanto, não é possível importar um método JS como um método .NET que tenha um parâmetro de Span ou ArraySegment.
    • MemoryView criado para um Span só é válido durante a chamada de interoperabilidade. Como Span é alocado na pilha de chamadas, que não persiste após a chamada de interoperabilidade, não é possível exportar um método .NET que retorna um Span.
    • MemoryView criado para um ArraySegment sobrevive após a chamada de interoperabilidade e é útil para compartilhar um buffer. Chamar dispose() em um MemoryView criado para um ArraySegment elimina o proxy e desafixa a matriz .NET subjacente. Recomendamos contactar dispose() num bloco de try-finally para MemoryView.

Algumas combinações de mapeamentos de tipo que exigem tipos genéricos aninhados em JSMarshalAs não são suportadas no momento. Por exemplo, tentar materializar uma matriz a partir de um Promise como [return: JSMarshalAs<JSType.Promise<JSType.Array<JSType.Number>>>()] gera um erro em tempo de compilação. Uma solução alternativa apropriada varia dependendo do cenário, mas este cenário específico é mais explorado na seção Limitações de mapeamento de Tipo.

JS primitivos

O exemplo a seguir demonstra [JSImport] aproveitando mapeamentos de tipo de vários tipos de JS primitivos e o uso de JSMarshalAs, onde mapeamentos explícitos são necessários em tempo de compilação.

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

Em Program.Main:

await PrimitivesUsage.Run();

O exemplo anterior exibe a seguinte saída no console de depuração do navegador:

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

JS Date objetos

O exemplo nesta seção demonstra a importação de métodos que têm um objeto JS Date como seu retorno ou parâmetro. As datas são agrupadas através do valor de interoperabilidade, o que significa que são copiadas da mesma forma que JS primitivos.

Um objeto Date é agnóstico de fuso horário. Um DateTime .NET é ajustado em relação ao seu DateTimeKind quando empacotado para um Date, mas as informações de fuso horário não são preservadas. Considere inicializar um DateTime com um DateTimeKind.Utc ou DateTimeKind.Local consistente com o valor que ele representa.

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

Em Program.Main:

await DateUsage.Run();

O exemplo anterior exibe a seguinte saída no console de depuração do navegador:

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)

As informações de fuso horário anteriores (GMT-0500 (Eastern Standard Time)) dependem do fuso horário local do seu computador/navegador.

JS referências de objeto

Sempre que um método JS retorna uma referência de objeto, ele é representado no .NET como um JSObject. O objeto JS original continua seu tempo de vida dentro do limite JS, enquanto o código .NET pode acessá-lo e modificá-lo por referência através do JSObject. Embora o tipo em si exponha uma API limitada, a capacidade de manter uma referência de objeto JS e retorná-la ou passá-la através do limite de interoperabilidade permite o suporte para vários cenários de interoperabilidade.

O JSObject fornece métodos para acessar propriedades, mas não fornece acesso direto a métodos de instância. Como o método de Summarize a seguir demonstra, os métodos de instância podem ser acessados indiretamente implementando um método estático que usa a instância como um parâmetro.

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

Em Program.Main:

await JSObjectUsage.Run();

O exemplo anterior exibe a seguinte saída no console de depuração do navegador:

{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

Interoperabilidade assíncrona

Muitas APIs JS são assíncronas e sinalizam a conclusão por meio de um retorno de chamada, um Promiseou um método assíncrono. Ignorar os recursos assíncronos muitas vezes não é uma opção, pois o código subsequente pode depender da conclusão da operação assíncrona e deve ser aguardado.

JS métodos usando a palavra-chave async ou retornando um Promise podem ser aguardados em C# por um método que retorna um Task. Como demonstrado abaixo, a palavra-chave async não é usada no método C# com o atributo [JSImport] porque não usa a palavra-chave await dentro dele. No entanto, consumir código chamando o método normalmente utilizaria a palavra-chave await e seria assinalado como async, como demonstrado no exemplo PromisesUsage.

JS com um retorno de chamada, como um setTimeout, pode ser envolvido num Promise antes de retornar de JS. Envolver um callback em um Promise, como demonstrado na função atribuída a Wait2Seconds, é apenas apropriado quando o callback é chamado exatamente uma vez. Caso contrário, um Action C# pode ser passado para monitorizar uma chamada de retorno que pode ser invocada zero ou várias vezes, o que é demonstrado na seção Assinando eventos JS.

PromisesShim.js:

export function wait2Seconds() {
  // This also demonstrates wrapping a callback-based API in a promise to
  // make it awaitable.
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(); // Resolve promise after 2 seconds
    }, 2000);
  });
}

// Return a value via resolve in a promise.
export function waitGetString() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("String From Resolve"); // Return a string via promise
    }, 500);
  });
}

export function waitGetDate() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(new Date('1988-11-24')); // Return a date via promise
    }, 500);
  });
}

// Demonstrates an awaitable fetch.
export function fetchCurrentUrl() {
  // This method returns the promise returned by .then(*.text())
  // and .NET awaits the returned promise.
  return fetch(globalThis.window.location, { method: 'GET' })
    .then(response => response.text());
}

// .NET can await JS methods using the async/await JS syntax.
export async function asyncFunction() {
  await wait2Seconds();
}

// A Promise.reject can be used to signal failure and is bubbled to .NET code
// as a JSException.
export function conditionalSuccess(shouldSucceed) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (shouldSucceed)
        resolve(); // Success
      else
        reject("Reject: ShouldSucceed == false"); // Failure
    }, 500);
  });
}

Não use a palavra-chave async na assinatura do método C#. Devolver Task ou Task<TResult> é suficiente.

Ao chamar métodos JS assíncronos, geralmente queremos esperar até que o método JS conclua a execução. Se estiver carregando um recurso ou fazendo uma solicitação, provavelmente queremos que o código a seguir assuma que a ação foi concluída.

Se o JS shim retornar um Promise, então o C# pode tratá-lo como um Task/Task<TResult>aguardado.

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

Em Program.Main:

await PromisesUsage.Run();

O exemplo anterior exibe a seguinte saída no console de depuração do navegador:

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'

Limitações do mapeamento de tipo

Alguns mapeamentos de tipo que exigem tipos genéricos aninhados na definição de JSMarshalAs não são atualmente suportados. Por exemplo, retornar um Promise para uma matriz como [return: JSMarshalAs<JSType.Promise<JSType.Array<JSType.Number>>>()] gera um erro em tempo de compilação. Uma solução alternativa apropriada varia dependendo do cenário, mas uma opção é representar a matriz como uma referência JSObject. Isso pode ser suficiente se o acesso a elementos individuais no .NET não for necessário e a referência puder ser passada para outros métodos JS que atuam na matriz. Como alternativa, um método dedicado pode tomar a referência JSObject como um parâmetro e retornar a matriz materializada, como demonstrado pelo exemplo de UnwrapJSObjectAsIntArray a seguir. Nesse caso, o método JS não tem verificação de tipo, e o desenvolvedor tem a responsabilidade de garantir que um JSObject encapsulando o tipo de matriz apropriado seja passado.

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

Em Program.Main:

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

Considerações sobre desempenho

A administração de chamadas e o acompanhamento de objetos através da fronteira de interoperabilidade são mais dispendiosos do que as operações nativas do .NET, mas ainda devem proporcionar um desempenho aceitável para uma aplicação web típica com demanda moderada.

Proxies de objeto, como JSObject, que mantêm referências através do limite de interoperabilidade, têm sobrecarga de memória adicional e afetam como a coleta de lixo afeta esses objetos. Além disso, a memória disponível pode esgotar-se sem que a coleta de lixo seja acionada em alguns cenários, porque a pressão de memória do JS e do .NET não é compartilhada. Esse risco é significativo quando um número excessivo de objetos grandes são referenciados através do limite de interoperabilidade por objetos JS relativamente pequenos, ou vice-versa, onde objetos .NET grandes são referenciados por proxies JS. Nesses casos, recomendamos seguir padrões de descarte determinísticos com escopos using aproveitando a interface IDisposable em objetos JS.

Os benchmarks a seguir, que aproveitam o código de exemplo anterior, demonstram que as operações de interoperabilidade são aproximadamente uma ordem de grandeza mais lentas do que aquelas que permanecem dentro do limite do .NET, mas as operações de interoperabilidade permanecem relativamente rápidas. Além disso, considere que os recursos do dispositivo de um usuário afetam o desempenho.

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

Em Program.Main:

JSObjectBenchmark.Run();

O exemplo anterior exibe a seguinte saída no console de depuração do navegador:

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

Subscrição de eventos JS

O código .NET pode inscrever-se em eventos JS e manipular eventos JS, passando um Action C# para uma função JS para atuar como manipulador. O código shim JS trata da subscrição ao evento.

Advertência

A interação com propriedades individuais do DOM através da interoperabilidade JS, conforme demonstra a orientação nesta seção, é relativamente lenta e pode levar à criação de muitos proxies que geram alta pressão na coleta de lixo. O padrão a seguir geralmente não é recomendado. Utilize o padrão a seguir apenas para alguns elementos. Para obter mais informações, consulte a seção Considerações sobre desempenho.

Uma nuance do removeEventListener é que ele requer uma referência à função passada anteriormente para addEventListener. Quando um Action C# é passado através do limite de interoperabilidade, ele é encapsulado em um objeto proxy JS. Portanto, passar o mesmo Action C# para addEventListener e removeEventListener resulta na geração de dois objetos proxy JS diferentes que encapsulam o Action. Essas referências são diferentes, portanto, removeEventListener não consegue encontrar o listener de eventos para remover. Para resolver este problema, os exemplos a seguir encapsulam o Action C# em uma função JS e retornam a referência como um JSObject da chamada de subscrição para posteriormente passá-la para a de cancelamento. Como o Action C# é retornado e passado como um JSObject, a mesma referência é utilizada para ambas as chamadas, e o listener de eventos pode ser removido.

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

Em Program.Main:

await EventsUsage.Run();

O exemplo anterior exibe a seguinte saída no console de depuração do navegador:

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] cenários de interoperabilidade

Os artigos a seguir se concentram na execução de um módulo .NET WebAssembly em um host JS, como um navegador: