次の方法で共有


.NET WebAssembly での JavaScript [JSImport]/[JSExport] 相互運用

Note

これは、この記事の最新バージョンではありません。 現在のリリースについては、この記事の .NET 8 バージョンを参照してください。

警告

このバージョンの ASP.NET Core はサポート対象から除外されました。 詳細については、「.NET および .NET Core サポート ポリシー」を参照してください。 現在のリリースについては、この記事の .NET 8 バージョンを参照してください。

重要

この情報はリリース前の製品に関する事項であり、正式版がリリースされるまでに大幅に変更される可能性があります。 Microsoft はここに示されている情報について、明示か黙示かを問わず、一切保証しません。

現在のリリースについては、この記事の .NET 8 バージョンを参照してください。

作成者: Aaron Shumaker

この記事では、JS[JSImport]/[JSExport]相互運用 (System.Runtime.InteropServices.JavaScript API) を使用して、クライアント側 WebAssembly で JavaScript (JS) と対話する方法について説明します。

[JSImport]/[JSExport] 相互運用は、次のシナリオの JS ホストで .NET WebAssembly モジュールを実行するときに適用されます。

前提条件

.NET SDK (最新バージョン)

次のいずれかのプロジェクトの種類です。

サンプル アプリ

サンプル コードの表示またはダウンロード (ダウンロード方法): バージョン 8.0 以降または採用する .NET のバージョンに一致するフォルダーを選びます。 そのバージョン フォルダー内で、WASMBrowserAppImportExportInterop という名前のサンプルにアクセスします。

[JSImport]/[JSExport] 属性を使用する JS 相互運用

.NET メソッドの呼び出し時には、対応する JS メソッドが呼び出される必要があることを示すため、[JSImport] 属性は .NET メソッドに適用されます。 これにより、.NET 開発者は、.NET コードで JS の呼び出しが可能になる "imports" を定義できます。 さらに、Action をパラメーターとして渡すことができます。また、JS はコールバックまたはイベント サブスクリプション パターンをサポートするためのアクションを呼び出すことができます。

[JSExport] 属性は .NET メソッドに適用され、JS コードに公開されます。 これにより、JS コードで .NET メソッドの呼び出しを開始できます。

JS メソッドのインポート

次の例では、標準の組み込み JS メソッド (console.log) を C# にインポートします。 [JSImport] は、グローバルにアクセス可能なオブジェクトのインポート メソッドに限定されます。 たとえば、log は、console オブジェクトで定義されたメソッドで、グローバルにアクセス可能なオブジェクト globalThis で定義されます。 console.log メソッドは、C# プロキシ メソッド (ConsoleLog) にマッピングされ、次のログ メッセージの文字列を受け取ります。

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

Program.Main では、ConsoleLog が呼び出され、メッセージがログに記録されます。

GlobalInterop.ConsoleLog("Hello World!");

出力はブラウザーのコンソールに表示されます。

次は、JS で宣言されたメソッドをインポートする方法を示しています。

次のカスタム JS メソッド (globalThis.callAlert) は、アラート ダイアログ (window.alert) を生成し、メッセージを text に渡します。

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

globalThis.callAlert メソッドは、C# プロキシ メソッド (CallAlert) にマッピングされ、次のメッセージの文字列を受け取ります。

using System.Runtime.InteropServices.JavaScript;

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

Program.Main では、CallAlert が呼び出され、アラート ダイアログ メッセージのテキストが渡されます。

GlobalInterop.CallAlert("Hello World");

[JSImport] メソッドを宣言する C# クラスには実装されていません。 コンパイル時に、ソース生成された部分クラスには、対応する JS メソッドを呼び出すための呼び出しと型のマーシャリングを実装する .NET コードが含まれています。 Visual Studio では、Go To Definition オプションまたは Go To Implementation オプションを使用して、ソースで生成された部分クラスまたは開発者が定義した部分クラスのいずれかにそれぞれ移動します。

上記の例では、Intermediate globalThis.callAlertJS 宣言を使用して、既存の JS コードをラップしています。 この記事では、Intermediate JS 宣言を JS shim として非公式に参照しています。 JS shim は、.NET 実装と既存の JS 機能/ライブラリの間のギャップを埋めます。 上記の簡単な例のように、多くの場合、JS shim は必要ありません。先ほどの ConsoleLog の例で示したように、メソッドを直接インポートできます。 この記事の今後のセクションで示すように、JS shim で次が可能になります。

  • 追加のロジックをカプセル化する。
  • 型を手動でマッピングする。
  • 相互運用の境界を越えるオブジェクトまたは呼び出しの数を減らす。
  • 静的呼び出しをインスタンス メソッドに手動でマッピングする。

JavaScript 宣言の読み込み

[JSImport] でインポートされることを目的とした JS 宣言は、通常、.NET WebAssembly を読み込んだ同じページまたは JS ホストのコンテキストに読み込まれます。 これは、次を使用して実現できます。

  • インライン JS を宣言する <script>...</script> ブロック。
  • 外部 JS ファイル (.js) を読み込むスクリプト ソース (src) 宣言 (<script src="./some.js"></script>)。
  • JS ES6 モジュール (<script type='module' src="./moduleName.js"></script>)。
  • .NET WebAssembly から JSHost.ImportAsync を使用して読み込まれた JS ES6 モジュール。

この記事の例では、JSHost.ImportAsync を使用しています。 ImportAsync を呼び出すとき、クライアント側の .NET WebAssembly は moduleUrl パラメーターを使用してファイルを要求するため、<script> タグが src URL を持つファイルを取得する方法とほぼ同じ方法で、静的 Web アセットとしてファイルにアクセスできる必要があります。 たとえば、WebAssembly Browser App プロジェクト内の次の C# コードは、JS ファイル (.js) をパス /wwwroot/scripts/ExampleShim.jsに保持します。

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

WebAssembly パッケージは /_framework/ のフレームワーク スクリプトによって初期化されるため、WebAssembly を読み込むプラットフォームによっては、./scripts/ などのドットがプレフィックスになっている URL が正しくないサブディレクトリ (/_framework/scripts/ など) を参照している可能性があります。 その場合、URL の前に ../scripts/ を付けると、正しいパスが参照されます。 /scripts/ をプレフィックスとして付けると、サイトがドメインのルートでホストされている場合に機能します。 一般的なアプローチでは、HTML の <base> タグを使用して特定の環境の正しいベース パスを構成し、/scripts/ プレフィックスを使用してベース パスを基準にしたパスを参照する必要があります。 チルダ表記 ~/ のプレフィックスは、JSHost.ImportAsync ではサポートされていません。

重要

JS が JavaScript モジュールから読み込まれる場合は、[JSImport] 属性に 2 番目のパラメーターとしてモジュール名を含める必要があります。 たとえば、[JSImport("globalThis.callAlert", "ExampleShim")] は、インポートされたメソッドが "ExampleShim" という名前の JavaScript モジュールで宣言されたことを示しています。

型のマッピング

.NET メソッド シグネチャのパラメーターと戻り値の型は、一意のマッピングがサポートされている場合、実行時に適切な JS 型との間で自動的に変換されます。 これにより、プロキシ型でラップされた値または参照によって変換された値になる可能性があります。 このプロセスは、型のマーシャリングと呼ばれます。 インポートされたメソッド パラメーターと戻り値の型のマーシャリング方法を制御するには、JSMarshalAsAttribute<T> を使用します。

一部の型には既定の型マッピングがありません。 たとえば、longSystem.Runtime.InteropServices.JavaScript.JSType.Number または System.Runtime.InteropServices.JavaScript.JSType.BigInt としてマーシャリングできるため、コンパイル時のエラーを回避するには JSMarshalAsAttribute<T> が必要です。

次の型マッピングのシナリオはサポートされています。

  • パラメーターとして Action または Func<TResult> を渡す。これは、呼び出し可能な JS メソッドとしてマーシャリングされます。 これにより、.NET コードは、JS コールバックまたはイベントに応答してリスナーを呼び出すことができます。
  • JS の参照と .NET マネージド オブジェクトの参照を双方向に渡す。これはプロキシ オブジェクトとしてマーシャリングされ、プロキシにガベージ コレクションが実行されるまで相互運用の境界を越えて存続します。
  • 非同期の JS メソッドまたは Task 結果を含む JS Promise をマーシャリングする (またはその逆)。

マーシャリングされた型のほとんどは、インポートされたメソッドとエクスポートされたメソッドの両方で、パラメーターおよび戻り値として、両方向で動作します。

次の表には、サポートされている型マッピングが示されています。

.NET JavaScript Nullable Task から Promise JSMarshalAs 省略可能 Array of
Boolean Boolean サポートあり サポートあり サポートあり サポートされていません
Byte Number サポートあり サポートあり サポートあり サポートあり
Char String サポートあり サポートあり サポートあり サポートされていません
Int16 Number サポートあり サポートあり サポートあり サポートされていません
Int32 Number サポートあり サポートあり サポートあり サポートあり
Int64 Number サポートあり サポートあり サポートされていません サポートされていません
Int64 BigInt サポートあり サポートあり サポートされていません サポートされていません
Single Number サポートあり サポートあり サポートあり サポートされていません
Double Number サポートあり サポートあり サポートあり サポートあり
IntPtr Number サポートあり サポートあり サポートあり サポートされていません
DateTime Date サポートあり サポートあり サポートされていません サポートされていません
DateTimeOffset Date サポートあり サポートあり サポートされていません サポートされていません
Exception Error サポートされていません サポートあり サポートあり サポートされていません
JSObject Object サポートされていません サポートあり サポートあり サポートあり
String String サポートされていません サポートあり サポートあり サポートあり
Object Any サポートされていません サポートあり サポートされていません サポートあり
Span<Byte> MemoryView サポートされていません サポートされていません サポートされていません サポートされていません
Span<Int32> MemoryView サポートされていません サポートされていません サポートされていません サポートされていません
Span<Double> MemoryView サポートされていません サポートされていません サポートされていません サポートされていません
ArraySegment<Byte> MemoryView サポートされていません サポートされていません サポートされていません サポートされていません
ArraySegment<Int32> MemoryView サポートされていません サポートされていません サポートされていません サポートされていません
ArraySegment<Double> MemoryView サポートされていません サポートされていません サポートされていません サポートされていません
Task Promise サポートされていません サポートされていません サポートあり サポートされていません
Action Function サポートされていません サポートされていません サポートされていません サポートされていません
Action<T1> Function サポートされていません サポートされていません サポートされていません サポートされていません
Action<T1, T2> Function サポートされていません サポートされていません サポートされていません サポートされていません
Action<T1, T2, T3> Function サポートされていません サポートされていません サポートされていません サポートされていません
Func<TResult> Function サポートされていません サポートされていません サポートされていません サポートされていません
Func<T1, TResult> Function サポートされていません サポートされていません サポートされていません サポートされていません
Func<T1, T2, TResult> Function サポートされていません サポートされていません サポートされていません サポートされていません
Func<T1, T2, T3, TResult> Function サポートされていません サポートされていません サポートされていません サポートされていません

型マッピングとマーシャリングされた値には、次の条件が適用されます。

  • Array of 列には、.NET 型を JSArray としてマーシャリングできるかどうかが示されます。 例: Number の JSArray にマップされた C#int[] (Int32)。
  • 間違った型の値を持つ JS 値を C# に渡すと、ほとんどの場合、フレームワークによって例外がスローされます。 フレームワークによって、JS でコンパイル時の型チェックは実行されません。
  • JSObjectExceptionTaskArraySegment では GCHandle とプロキシが作成されます。 開発者コードで破棄をトリガーしたり、.NET ガベージ コレクション (GC) でオブジェクトを後で破棄したりすることができます。 これらの型では、パフォーマンスに大きなオーバーヘッドが伴います。
  • Array: 配列をマーシャリングすると、JS または .NET に配列のコピーが作成されます。
  • MemoryView
    • MemoryView は、.NET WebAssembly ランタイムが SpanArraySegment をマーシャリングするための JS クラスです。
    • 配列のマーシャリングとは異なり、SpanArraySegment をマーシャリングしても、基になるメモリのコピーは作成されません。
    • MemoryView は、.NET WebAssembly ランタイムによってのみ適切にインスタンス化されます。 したがって、Span または ArraySegment のパラメーターを持つ .NET メソッドとして JS メソッドをインポートすることはできません。
    • Span に対して作成された MemoryView は、相互運用呼び出しの期間のみ有効です。 Span は呼び出し履歴に割り当てられ、相互運用呼び出しの後に保持されないため、Span を返す .NET メソッドをエクスポートすることはできません。
    • ArraySegment に対して作成された MemoryView は、相互運用呼び出し後も存続し、バッファーを共有するのに役立ちます。 ArraySegment に対して作成された MemoryViewdispose() を呼び出すと、プロキシが破棄され、基になる .NET 配列の固定が解除されます。 MemoryViewtry-finally ブロックで dispose() を呼び出すことをお勧めします。

JSMarshalAs で入れ子になったジェネリック型を必要とする型マッピングの一部の組み合わせは、現在サポートされていません。 たとえば、[return: JSMarshalAs<JSType.Promise<JSType.Array<JSType.Number>>>()] などの Promise から配列を具体化しようとすると、コンパイル時のエラーが発生します。 適切な回避策はシナリオによって異なりますが、この特定のシナリオについては、型マッピングの制限事項のセクションで詳しく説明しています。

JS プリミティブ

次の例では、複数のプリミティブ JS 型の型マッピングと、コンパイル時に明示的なマッピングが必要な JSMarshalAs の使用を利用する [JSImport] を示しています。

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

Program.Mainの場合:

await PrimitivesUsage.Run();

上記の例では、ブラウザーのデバッグ コンソールに次の出力が表示されます。

Printed from JSImport of console.log()
1
I'm a string from .NET in your browser!
boolean true
number 58
number 67
number 12
number 9007199254740990
bigint 1234567890123456789n
number 3.140000104904175
number 3.14
string A string

JSDate オブジェクト

このセクションの例では、戻り値またはパラメーターとして JS Date オブジェクトを持つメソッドをインポートする方法を示しています。 日付は値による相互運用にまたがってマーシャリングされます。つまり、JS プリミティブとほぼ同じ方法でコピーされます。

Date オブジェクトはタイムゾーンに依存しません。 .NET DateTime は、Date にマーシャリングされるときに DateTimeKind に関連して調整されますが、タイムゾーンの情報は保持されません。 DateTimeKind.Utc または DateTimeKind.Local (表す値が一致する) を使用して DateTime を初期化することを検討してください。

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

Program.Mainの場合:

await DateUsage.Run();

上記の例では、ブラウザーのデバッグ コンソールに次の出力が表示されます。

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)

上記のタイムゾーンの情報 (GMT-0500 (Eastern Standard Time)) は、コンピューター/ブラウザーのローカル タイムゾーンによって異なります。

JSオブジェクト参照

JS メソッドは、オブジェクト参照を返すたびに、JSObject として .NET で表されます。 元の JS オブジェクトは JS の境界線内で引き続き有効ですが、.NET コードは JSObject 経由の参照によるアクセスおよび変更が可能です。 型自体は限られた API を公開していますが、JS オブジェクト参照を保持し、相互運用の境界を越えて返したり渡したりする機能により、複数の相互運用のシナリオをサポートできます。

JSObject はプロパティにアクセスするためのメソッドを提供しますが、インスタンス メソッドへの直接アクセスは提供しません。 次の Summarize メソッドが示すように、インスタンスをパラメーターとして受け取る静的メソッドを実装することで、インスタンス メソッドに間接的にアクセスできます。

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

Program.Mainの場合:

await JSObjectUsage.Run();

上記の例では、ブラウザーのデバッグ コンソールに次の出力が表示されます。

{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

非同期相互運用

多くの JS API は、コールバック、Promise、または非同期メソッドのいずれかを使用した非同期およびシグナル補完です。 非同期を無視する機能は、多くの場合オプションではありません。後続のコードは非同期処理の完了に依存し、待機する必要があるためです。

async キーワードを使用する、または Promise を返す JS メソッドは、Task を返すメソッドによって C# で待機できます。 次に示すように、async キーワードは、[JSImport] 属性を持つ C# メソッドでは使用されません。このメソッド内では await キーワードを使用しないためです。 ただし、このメソッドを呼び出すコードを使用すると、通常、await キーワードが使用され、PromisesUsage の例に示すように、async としてマークされます。

setTimeout などのコールバックを使用した JS は、JS から返される前に Promise でラップできます。 Wait2Seconds に割り当てられた関数で示されているように、コールバックを Promise でラップすることは、コールバックが 1 回だけ呼び出される場合にのみ適しています。 それ以外の場合は、C# Action を渡して、0 回または複数回呼び出される可能性があるコールバックをリッスンできます。これは、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);
  });
}

C# メソッド シグネチャで async キーワードを使用しないでください。 Task または Task<TResult> を返すことで十分です。

非同期の JS メソッドを呼び出すときは、多くの場合、JS メソッドの実行が完了するまで待機する必要があります。 リソースを読み込んだり、要求を行ったりする場合は、次のコードで操作が完了したと見なす必要があります。

JS shim が Promise を返す場合、C# はそれを待機可能な Task/Task<TResult> として扱うことができます。

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

Program.Mainの場合:

await PromisesUsage.Run();

上記の例では、ブラウザーのデバッグ コンソールに次の出力が表示されます。

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'

型マッピングの制限

JSMarshalAs 定義で入れ子になったジェネリック型を必要とする一部の型マッピングは、現在サポートされていません。 たとえば、[return: JSMarshalAs<JSType.Promise<JSType.Array<JSType.Number>>>()] などの配列に Promise が返された場合、コンパイル時のエラーが発生します。 適切な回避策はシナリオによって異なりますが、1 つのオプションとして、配列を JSObject 参照として表す方法があります。 これは、.NET 内の個々の要素にアクセスする必要がない場合、配列において動作する他の JS メソッドに参照を渡すことが可能であれば十分です。 または、次の UnwrapJSObjectAsIntArray の例に示すように、専用メソッドで JSObject 参照をパラメーターとして受け取り、具体化された配列を返すことができます。 この場合、JS メソッドには型チェックがなく、開発者は、適切な配列型をラップする JSObject が渡されることを確認する責任があります。

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

Program.Main:

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

パフォーマンスに関する考慮事項

相互運用の境界を越えてオブジェクトを追跡する呼び出しとオーバーヘッドのマーシャリングは、ネイティブの .NET 操作よりもコストがかかりますが、要求が中程度の一般的な Web アプリでは引き続き許容できるパフォーマンスを示す必要があります。

相互運用の境界を越えて参照を維持する JSObject などのオブジェクト プロキシは、追加のメモリ オーバーヘッドを持ち、ガベージ コレクションがこれらのオブジェクトにどのように影響を与えるかに影響します。 また、JS と .NET からのメモリ負荷が共有されないため、一部のシナリオではガベージ コレクションをトリガーせずに使用可能なメモリが使い果たされる可能性があります。 このリスクは、比較的小さい JS オブジェクトによって相互運用の境界を越えて過剰な数の大きなオブジェクトが参照されている場合、または大きな .NET オブジェクトが JS プロキシによって参照される場合は重大なリスクとなります。 このような場合は、JS オブジェクトの IDisposable インターフェイスを利用する using スコープで、次の決定的な破棄パターンに従うことをお勧めします。

先ほどのコード例を活用した次のベンチマークでは、相互運用操作は .NET 境界内に留まるものと比べてほぼ 1 桁分遅いものの、相互運用操作は比較的高速なままであることを示しています。 さらに、ユーザーのデバイス機能がパフォーマンスに影響することを考慮してください。

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

Program.Mainの場合:

JSObjectBenchmark.Run();

上記の例では、ブラウザーのデバッグ コンソールに次の出力が表示されます。

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

JS イベントのサブスクライブ

.NET コードは、JS イベントをサブスクライブし、ハンドラーとして機能する C# Action を JS 関数に渡すことによって、JS イベントを処理できます。 JS shim コードは、イベントのサブスクライブを処理します。

警告

このセクションのガイダンスで示すように、JS 相互運用を使用した DOM の個々のプロパティとの操作は比較的低速であり、高いガベージ コレクションの負荷を作る多くのプロキシの作成につながる可能性があります。 通常、次のパターンは推奨されません。 次のパターンは、ごく一部の要素に対してのみ使用します。 詳細については、「パフォーマンスに関する考慮事項」のセクションを参照してください。

removeEventListener の微妙な違いは、以前に addEventListener に渡された関数への参照が必要であるという点です。 C# Action が相互運用の境界を越えて渡されると、JS プロキシ オブジェクトにラップされます。 そのため、同じ C# ActionaddEventListenerremoveEventListener の両方に渡すと、Action をラップする 2 つの異なる JS プロキシ オブジェクトが生成されます。 これらの参照は異なるため、removeEventListener では削除するイベント リスナーを見つけることができません。 この問題に対処するために、次の例では、JS 関数で C# Action をラップし、サブスクライブ呼び出しから参照を JSObject として返し、後でサブスクライブ解除呼び出しに渡しています。 C# Action が返され、JSObject として渡されるため、両方の呼び出しに同じ参照が使用され、イベント リスナーを削除できます。

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

Program.Mainの場合:

await EventsUsage.Run();

上記の例では、ブラウザーのデバッグ コンソールに次の出力が表示されます。

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] 相互運用のシナリオ

次の記事では、ブラウザーなどの JS ホストで .NET WebAssembly モジュールを実行することに重点を置いて説明しています。