Interop de JavaScript [JSImport]
/[JSExport]
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.
Aviso
Esta versão do ASP.NET Core não tem mais suporte. Para obter mais informações, consulte a Política de Suporte do .NET e do .NET Core. Para a versão atual, consulte a versão .NET 9 deste artigo.
Importante
Essas informações relacionam-se ao produto de pré-lançamento, que poderá ser substancialmente modificado antes do lançamento comercial. A Microsoft não oferece nenhuma garantia, explícita ou implícita, quanto à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 JS[JSImport]
/[JSExport]
interop (System.Runtime.InteropServices.JavaScriptAPI).
[JSImport]
/[JSExport]
interop é aplicável ao executar um módulo .NET WebAssembly em um JS host nos seguintes cenários:
- Interop JavaScript '[JSmport]'/'[JSExport]' com um projeto de aplicativo de navegador WebAssembly.
- Interoperabilidade JSScript/JSExport com ASP.NET Core Blazor.
- Outras plataformas .NET WebAssembly que dão suporte à
[JSImport]
/[JSExport]
interop.
Pré-requisitos
SDK do .NET (versão mais recente)
Qualquer um dos seguintes tipos de projeto:
- Um projeto de aplicativo de navegador WebAssembly criado de acordo com a interop JavaScript '[JSImport]'/'[JSExport]' com um projeto de aplicativo de navegador WebAssembly.
- Um projeto Blazor do lado do cliente criado de acordo com a interop de JSImportJSExport de JavaScript com ASP.NET Core Blazor.
- Um projeto criado para uma plataforma comercial ou de código aberto que dá suporte à
[JSImport]
/[JSExport]
interop (System.Runtime.InteropServices.JavaScript API).
Aplicativo de exemplo
Exibir ou baixar o código de exemplo (como baixar): selecione a pasta versão 8.0 ou posterior que corresponde à versão do .NET que você está adotando. Na pasta de versão, acesse a amostra chamada WASMBrowserAppImportExportInterop
.
JS Interop usando [JSImport]
/[JSExport]
atributos
O [JSImport]
atributo é aplicado a um método .NET para indicar que um JSmétodo 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 do .NET chame o JS. Além disso, um Action pode ser passado como um parâmetro e JS pode invocar a ação para dar suporte a um retorno de chamada ou padrão de assinatura de evento.
O [JSExport]
atributo é aplicado a um método .NET para expô-lo ao JS código. Isso permite que JS o código inicie chamadas para o método .NET.
Métodos de JS importação
O exemplo a seguir importa um JSmétodo interno padrão (console.log
) para C#. [JSImport]
é limitado a métodos de importação de objetos acessíveis globalmente. Por exemplo, log
é um método definido no console
objeto, que é definido no objeto globalmente acessível globalThis
. O console.log
método é mapeado para um método de 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);
}
Em Program.Main
, ConsoleLog
é chamado com a mensagem para registrar:
GlobalInterop.ConsoleLog("Hello World!");
A saída é exibida no console do navegador.
O exemplo a seguir demonstra a importação de um método declarado no JS.
O JS método personalizado a seguir (globalThis.callAlert
) gera um diálogo de alerta (window.alert
) com a mensagem transmitida 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);
}
Em Program.Main
, CallAlert
é chamado, passando o texto para a 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. No tempo de compilação, uma classe parcial gerada pela origem contém o código .NET que implementa o marshalling da chamada e os tipos para invocar o método correspondente JS. No Visual Studio, usar as opções Ir para Definição ou Ir para Implementação, respectivamente, navega para a classe parcial gerada pela origem ou para a classe parcial definida pelo desenvolvedor.
No exemplo anterior, a declaração intermediária globalThis.callAlert
JS é usada para encapsular o código existente JS. Este artigo refere-se informalmente à declaração intermediária JS como um JS calço. JS os calços preenchem a lacuna entre a implementação do .NET e os recursos/bibliotecas existentes JS. Em muitos casos, como o exemplo trivial anterior, o calço JS não é necessário e os métodos podem ser importados diretamente, conforme demonstrado no exemplo anterior ConsoleLog
. Como este artigo demonstra nas próximas seções, um JS calço pode:
- Encapsular lógica adicional.
- Mapear manualmente os tipos.
- Reduzir o número de objetos ou chamadas que cruzam o limite de interop.
- Mapear manualmente chamadas estáticas para métodos de instância.
Carregar declarações JavaScript
As declarações JS que devem ser importadas com [JSImport]
são normalmente carregadas no contexto da mesma página ou JS host que carregou o .NET WebAssembly. Isso pode ser realizado da seguinte forma:
- Um
<script>...</script>
bloco declarando em linha JS. - Uma declaração de origem (
src
) de script (<script src="./some.js"></script>
) que carrega um arquivo externo JS (.js
). - Um JS módulo ES6 (
<script type='module' src="./moduleName.js"></script>
). - Um JS módulo 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 esteja acessível como um ativo da Web estático, da mesma forma que uma tag <script>
recupera um arquivo com um URL src
. Por exemplo, o seguinte código C# em um projeto de aplicativo de navegador WebAssembly mantém o JS arquivo (.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 ponto, 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 o URL com ../scripts/
refere-se ao caminho correto. A prefixação com /scripts/
funciona se o site estiver hospedado na raiz do domínio. Uma abordagem típica envolve configurar o caminho base correto para o ambiente fornecido com uma tag HTML <base>
e usar o prefixo /scripts/
para se referir ao caminho relativo ao caminho base. Os prefixos de notação ~/
til não são suportados por JSHost.ImportAsync.
Importante
Se JS for carregado de um módulo JavaScript, os atributos [JSImport]
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 de ou para tipos JS apropriados em runtime se houver suporte para um mapeamento exclusivo. Isso pode resultar em valores convertidos por valor ou referências encapsuladas em um tipo de proxy. Esse processo é conhecido como empacotamento de tipo. Use JSMarshalAsAttribute<T> para controlar como os parâmetros do método importado e tipos de retorno são empacotados.
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, portanto, 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:
- Passando Action ou Func<TResult> como parâmetros, que são empacotados como métodos chamáveis JS. Isso permite que o código .NET invoque ouvintes em resposta a retornos de chamada ou eventos JS.
- Passando referências JS e referências de objeto gerenciado do .NET em qualquer direção, que são empacotadas como objetos proxy e mantidas ativas através do limite de interop até que o proxy seja coletado como lixo.
- Empacotamento de métodos assíncronos JS ou um JS
Promise
com um resultado Task e vice-versa.
A maioria dos tipos que sofrem realização de marshal funciona em ambas as direções, como parâmetros e como valores retornados, em métodos importados e exportados.
A tabela a seguir indica os mapeamentos de tipo com suporte.
.NET | JavaScript | Nullable |
Task para Promise |
Opcional JSMarshalAs |
Array of |
---|---|---|---|---|---|
Boolean |
Boolean |
Com suporte | Com suporte | Com suporte | Sem suporte |
Byte |
Number |
Com suporte | Com suporte | Com suporte | Com suporte |
Char |
String |
Com suporte | Com suporte | Com suporte | Sem suporte |
Int16 |
Number |
Com suporte | Com suporte | Com suporte | Sem suporte |
Int32 |
Number |
Com suporte | Com suporte | Com suporte | Com suporte |
Int64 |
Number |
Com suporte | Com suporte | Sem suporte | Sem suporte |
Int64 |
BigInt |
Com suporte | Com suporte | Sem suporte | Sem suporte |
Single |
Number |
Com suporte | Com suporte | Com suporte | Sem suporte |
Double |
Number |
Com suporte | Com suporte | Com suporte | Com suporte |
IntPtr |
Number |
Com suporte | Com suporte | Com suporte | Sem suporte |
DateTime |
Date |
Com suporte | Com suporte | Sem suporte | Sem suporte |
DateTimeOffset |
Date |
Com suporte | Com suporte | Sem suporte | Sem suporte |
Exception |
Error |
Sem suporte | Com suporte | Com suporte | Sem suporte |
JSObject |
Object |
Sem suporte | Com suporte | Com suporte | Com suporte |
String |
String |
Sem suporte | Com suporte | Com suporte | Com suporte |
Object |
Any |
Sem suporte | Com suporte | Sem suporte | Com suporte |
Span<Byte> |
MemoryView |
Sem suporte | Sem suporte | Sem suporte | Sem suporte |
Span<Int32> |
MemoryView |
Sem suporte | Sem suporte | Sem suporte | Sem suporte |
Span<Double> |
MemoryView |
Sem suporte | Sem suporte | Sem suporte | Sem suporte |
ArraySegment<Byte> |
MemoryView |
Sem suporte | Sem suporte | Sem suporte | Sem suporte |
ArraySegment<Int32> |
MemoryView |
Sem suporte | Sem suporte | Sem suporte | Sem suporte |
ArraySegment<Double> |
MemoryView |
Sem suporte | Sem suporte | Sem suporte | Sem suporte |
Task |
Promise |
Sem suporte | Sem suporte | Com suporte | Sem suporte |
Action |
Function |
Sem suporte | Sem suporte | Sem suporte | Sem suporte |
Action<T1> |
Function |
Sem suporte | Sem suporte | Sem suporte | Sem suporte |
Action<T1, T2> |
Function |
Sem suporte | Sem suporte | Sem suporte | Sem suporte |
Action<T1, T2, T3> |
Function |
Sem suporte | Sem suporte | Sem suporte | Sem suporte |
Func<TResult> |
Function |
Sem suporte | Sem suporte | Sem suporte | Sem suporte |
Func<T1, TResult> |
Function |
Sem suporte | Sem suporte | Sem suporte | Sem suporte |
Func<T1, T2, TResult> |
Function |
Sem suporte | Sem suporte | Sem suporte | Sem suporte |
Func<T1, T2, T3, TResult> |
Function |
Sem suporte | Sem suporte | Sem suporte | Sem suporte |
As seguintes condições se aplicam ao mapeamento de tipos e valores que sofrem realização de marshal:
- A coluna
Array of
indica se o tipo .NET pode sofrer realização de marshal como um JSArray
. Exemplo: C#int[]
(Int32
) mapeado para JSArray
deNumber
s. - Ao passar um valor JS para C# com um valor do tipo errado, a estrutura gera uma exceção na maioria dos casos. A estrutura não executa verificação de tipo em tempo de compilação no JS.
JSObject
,Exception
,Task
eArraySegment
criamGCHandle
e um proxy. Você pode disparar o descarte no código do desenvolvedor ou permitir que a coleta de lixo (GC, na sigla em inglês) do .NET descarte os objetos posteriormente. Esses tipos têm uma sobrecarga significativa de desempenho.Array
: o marshaling de uma matriz cria uma cópia da matriz no JS ou no .NET.MemoryView
MemoryView
é uma classe JS para o runtime do .NET WebAssembly realizar marshaling deSpan
eArraySegment
.- Ao contrário do marshaling de uma matriz, realizar marshaling de um
Span
ouArraySegment
não cria uma cópia da memória subjacente. MemoryView
só pode ter uma instância criada corretamente pelo runtime do WebAssembly do .NET. Portanto, não é possível importar um método JS como um método .NET que tenha um parâmetro deSpan
ouArraySegment
.MemoryView
criado para umSpan
só é válido durante a chamada de interoperabilidade. ComoSpan
é 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 umSpan
.MemoryView
criado para umArraySegment
sobrevive após a chamada de interoperabilidade e é útil para compartilhar um buffer. Chamardispose()
em umMemoryView
criado para umArraySegment
descarta o proxy e desafixa a matriz .NET subjacente. É recomendável chamardispose()
em um blocotry-finally
paraMemoryView
.
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 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 esse cenário específico é explorado mais detalhadamente na seção Limitações de mapeamento de tipo.
JS primitivos
O exemplo a seguir demonstra o [JSImport]
aproveitando mapeamentos de tipo de vários tipos primitivos JS e o uso de JSMarshalAs
, em que 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");
}
}
No 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
Objetos JSDate
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 empacotadas entre interop por valor, o que significa que são copiadas da mesma maneira que os JS primitivos.
Um objeto Date
é independente 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);
}
}
No 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.
Referências de objeto JS
Sempre que um método JS retorna uma referência de objeto, ele é representado no .NET como um JSObject. O objeto original JS continua seu tempo de vida dentro do marco de delimitação JS, enquanto o código .NET pode acessá-lo e modificá-lo por referência por meio de 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 pelo marco de delimitação 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 aos métodos de instância. Como o método 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);
}
}
No 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
Interop assíncrona
Muitas APIs JS são assíncronas e sinalizam a conclusão por meio de um retorno de chamada, um Promise
, ou um método assíncrono. Ignorar recursos assíncronos geralmente não é uma opção, pois o código subsequente pode depender da conclusão da operação assíncrona e deve ser aguardado.
Métodos JS que usam a palavra-chave async
ou retornam um Promise
podem ser aguardados em C# por um método que retorna um Task. Conforme demonstrado abaixo, a palavra-chave async
não é usada no método C# com o atributo [JSImport]
porque ele não usa a palavra-chave await
dentro dele. No entanto, o consumo de código que chama o método normalmente usaria a palavra-chave await
e seria marcado como async
, conforme demonstrado no exemplo PromisesUsage
.
JS com um retorno de chamada, como um setTimeout
, pode ser encapsulado em um Promise
antes de retornar de JS. Encapsular um retorno de chamada em um Promise
, conforme demonstrado na função atribuída a Wait2Seconds
, somente é apropriado quando o retorno de chamada é chamado exatamente uma vez. Caso contrário, um C# Action pode ser passado para escutar um retorno de chamada que pode ser chamado zero ou muitas 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#. Retornar Task ou Task<TResult> é suficiente.
Ao chamar métodos assíncronos JS, geralmente queremos aguardar até que o método JS conclua a execução. Se estivermos carregando um recurso ou fazendo uma solicitação, provavelmente desejamos que o código seguinte assuma que a ação foi concluída.
Se o shim JS retornar um Promise
, o C# poderá tratá-lo como um Task/Task<TResult> aguardável.
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}'");
}
}
}
No 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
No momento, não há suporte para alguns mapeamentos de tipo que exigem tipos genéricos aninhados na definição JSMarshalAs
. 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 usar a referência JSObject como um parâmetro e retornar a matriz materializada, conforme demonstrado pelo exemplo a seguir UnwrapJSObjectAsIntArray
. Nesse caso, o método JS não tem verificação de tipo e o desenvolvedor tem a responsabilidade de garantir que o JSObject com quebra de linha do 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 o desempenho
O empacotamento de chamadas e a sobrecarga de objetos de acompanhamento no marco de delimitação de interop são mais caros do que as operações nativas do .NET, mas ainda devem demonstrar um desempenho aceitável para um aplicativo Web típico com demanda moderada.
Proxies de objeto, como JSObject, que mantêm referências no marco de delimitação de interop, 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 ser esgotada sem disparar a coleta de lixo em alguns cenários porque a pressão de memória de JS e .NET não é compartilhada. Esse risco é significativo quando um número excessivo de objetos grandes é referenciado através do marco de delimitação de interop 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 parâmetros de comparação a seguir, que aproveitam o código de exemplo anterior, demonstram que as operações de interop são aproximadamente uma ordem de magnitude mais lentas do que aquelas que permanecem dentro do limite do .NET, mas as operações de interop 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;
}
}
No 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
Assinando eventos JS
O código .NET pode assinar eventos JS e manipular eventos JS passando um C# Action para uma função JS para atuar como um manipulador. O código de shim JS manipula a assinatura do evento.
Aviso
A interação com propriedades individuais do DOM por meio da interop JS, como demonstram as diretrizes nesta seção, é relativamente lenta e pode levar à criação de muitos proxies que criam alta pressão de coleta de lixo. O padrão a seguir geralmente não é recomendado. Use o seguinte padrão para não mais do que alguns elementos. Para obter mais informações, confira a seção Considerações sobre desempenho.
Uma nuance de removeEventListener
é que ele requer uma referência à função passada anteriormente para addEventListener
. Quando um C# Action é passado pelo marco de delimitação de interop, ele é encapsulado em um objeto proxy JS. Portanto, passar o mesmo C# Action para ambos addEventListener
e removeEventListener
resulta na geração de dois objetos proxy diferentes JS encapsulando o Action. Essas referências são diferentes, portanto, removeEventListener
não é capaz de encontrar o ouvinte de eventos a ser removido. Para resolver esse problema, os exemplos a seguir encapsulam o C# Action em uma função JS e retornam a referência como um JSObject da chamada de assinatura para passar posteriormente para a chamada de cancelamento de assinatura. Como o C# Action é retornado e passado como um JSObject, a mesma referência é usada para ambas as chamadas e o ouvinte 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");
}
}
No 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 interop
Os artigos a seguir se concentram na execução de um módulo .NET WebAssembly em um host JS, como um navegador: