共用方式為


.NET WebAssembly 中的 JavaScript [JSImport]/[JSExport] Interop

注意

這不是這篇文章的最新版本。 如需目前的版本,請參閱 本文的 .NET 9 版本。

警告

不再支援此版本的 ASP.NET Core。 如需詳細資訊,請參閱 .NET 和 .NET Core 支持原則。 如需目前的版本,請參閱 本文的 .NET 9 版本。

重要

這些發行前產品的相關資訊在產品正式發行前可能會有大幅修改。 Microsoft 對此處提供的資訊,不做任何明確或隱含的瑕疵擔保。

如需目前的版本,請參閱 本文的 .NET 9 版本。

作者:Aaron Shumaker

本文說明如何使用 JS[JSImport]/[JSExport] Interop (System.Runtime.InteropServices.JavaScript API),與用戶端 WebAssembly 中的 JavaScript (JS) 互動。

在下列案例中,於 JS 主機中執行 .NET WebAssembly 模組時,適用 [JSImport]/[JSExport] Interop:

必要條件

.NET SDK (最新版本)

任何下列專案類型:

範例應用程式

檢視或下載範例程式碼 (如何下載):選取與您採用之 .NET 版本相符的 8.0 或更新版本資料夾。 在版本資料夾中,存取名為 WASMBrowserAppImportExportInterop 的範例。

使用 [JSImport]/[JSExport] 屬性的 JS Interop

[JSImport] 屬性會套用至 .NET 方法,指出呼叫 .NET 方法時應該呼叫對應的 JS 方法。 這可讓 .NET 開發人員定義「匯入」,讓 .NET 程式碼能夠呼叫 JS。 此外, Action 也可以作為參數傳遞,且 JS 可以叫用動作以支援回撥或事件訂閱模式。

[JSExport] 屬性會套用至 .NET 方法,以向 JS 程序碼公開。 這可讓 JS 程式碼起始對 .NET 方法的呼叫。

匯入 JS 方法

下列範例會將標準的內建 JS 方法 (console.log) 匯入 C# 中。 [JSImport] 僅限於匯入可全域存取物件的方法。 例如,log 是在 console 物件上定義的方法,該方法定義在可全域存取的物件 globalThis上。 console.log 方法對應至 C# Proxy 方法 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) 會使用傳入 text 的訊息,繁衍 警示對話方塊 (window.alert)

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

globalThis.callAlert 方法對應至 C# Proxy 方法 (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# 類別沒有實作。 在編譯期間,來源產生的部分類別包含的 .NET 程序碼會實作呼叫與型別的封送處理,以叫用對應 JS 方法。 在 Visual Studio 中,使用 [移至定義] 或 [移至實作] 選項,分別瀏覽至來源產生的部分類別或開發人員定義的部分類別。

在上述範例中,中繼 globalThis.callAlertJS 宣告是用來包裝現有的 JS 程序碼。 本文非正式地將中繼 JS 宣告稱為 JS 填充碼。 JS 填充碼會填滿 .NET 實作與現有 JS 功能/程式庫之間的差距。 在許多情況下,例如上述的簡單範例,不需要 JS 填充碼,並可以直接匯入方法,如先前的 ConsoleLog 範例所示。 如本文在後續章節的示範,JS 填充碼可以:

  • 封裝其他邏輯。
  • 手動對應型別。
  • 減少跨越 Interop 界限的物件或呼叫數目。
  • 手動將靜態呼叫對應至執行個體方法。

載入 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 參數要求檔案,因此預期該檔案可以作為靜態 Web 資產來存取,這與 <script> 標記擷取含有 src URL 的檔案所用的方式大致相同。 例如,WebAssembly Browser App 專案中的下列 C# 程式碼維護路徑為 /wwwroot/scripts/ExampleShim.js 的 JS 檔案 (.js):

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

視載入 WebAssembly 的平台而定,像是 ./scripts/ 以點為前置詞的 URL 可能會參考不正確的子目錄,例如 /_framework/scripts/,因為 WebAssembly 套件是由 /_framework/ 下的架構指令碼初始化。 在這種情況下,於 URL 前面加上 ../scripts/ 才會參考正確的路徑。 如果網站裝載於網域根目錄,才適合以 /scripts/ 為前置詞。 一般方法牽涉到使用 HTML <base> 標記設定指定環境的正確基底路徑,以及使用 /scripts/ 前置詞來參考相對於基底路徑的路徑。 JSHost.ImportAsync 不支援波狀符號標記法 ~/ 前置詞。

重要

如果從 JavaScript 模組載入 JS,則 [JSImport] 屬性必須包含模組名稱做為第二個參數。 例如,[JSImport("globalThis.callAlert", "ExampleShim")] 指出已在名為 "ExampleShim" 的JavaScript 模組中宣告匯入的方法。

型別對應

如果支援唯一的對應,.NET 方法簽章中的參數和傳回型別會自動轉換成適當的 JS 型別,或從其自動轉換。 這可能會導致以 Proxy 型別中包裝的值或參考來轉換值。 此程序稱為「型別封送處理」。 使用 JSMarshalAsAttribute<T> 來控制如何封送處理匯入的方法參數與傳回型別。

某些型別沒有預設的型別對應。 例如, long 可以封送處理為 System.Runtime.InteropServices.JavaScript.JSType.NumberSystem.Runtime.InteropServices.JavaScript.JSType.BigInt,因此需要 JSMarshalAsAttribute<T>,以避免發生編譯時間錯誤。

不支援下列型別對應案例:

  • 傳遞 ActionFunc<TResult> 做為參數,這些參數會封送處理為可呼叫的 JS 方法。 這可讓 .NET 程式碼叫用接聽程式,以回應 JS 回呼或事件。
  • 以任一方向傳遞 JS 參考和 .NET 受控物件參考,這些參考會封送處理為 Proxy 物件,並在 Proxy 進行記憶體回收之前,於 Interop 界限上保持運作。
  • 封送處理非同步 JS 方法或具有 JS Promise 結果的 Task,反之亦然。

大部分封送處理的型別在雙向運作中皆可運作,作為參數和傳回值,用於匯入和匯出的方法。

下表指出支援的型別對應。

.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。 範例:C# int[] (Int32) 對應至 JSNumberArray
  • 將 JS 值傳遞至使用錯誤型別值的 C# 時,架構在大部分情況下會擲回例外狀況。 架構不會在 JS 中執行編譯時間型別檢查。
  • JSObjectExceptionTaskArraySegment 建立 GCHandle 及 Proxy。 您可以在開發人員程式碼中觸發處置,或稍後允許 .NET 記憶體回收 (GC) 處置物件。 這些型別具有顯著的效能額外負荷。
  • Array:封送處理陣列會在 JS 或 .NET 中建立陣列的複本。
  • MemoryView
    • MemoryView 是 .NET WebAssembly 執行階段的 JS 類別,用於封送處理 SpanArraySegment
    • 與封送處理陣列不同,封送處理 SpanArraySegment 不會建立基礎記憶體的複本。
    • MemoryView 只能由 .NET WebAssembly 執行階段正確具現化。 因此,無法將 JS 方法匯入為具有 SpanArraySegment 參數的 .NET 方法。
    • Span 建立的 MemoryView 只在 Interop 呼叫期間才有效。 如同 Span 在呼叫堆疊上配置,在 Interop 呼叫之後不會保存,因此無法匯出傳回 Span 的 .NET 方法。
    • 針對 ArraySegment 建立的 MemoryView 在 Interop 呼叫之後存留下來,而且有助於共用緩衝區。 在針對 ArraySegment 建立的 MemoryView 上呼叫 dispose() 會處置 Proxy,並取消釘選基礎 .NET 陣列。 我們建議在 MemoryViewtry-finally 區塊中呼叫 dispose()

目前不支援某些在 JSMarshalAs 中需要巢狀泛型型別的型別對應組合。 舉例來說,嘗試具體化 Promise 中的陣列,例如 [return: JSMarshalAs<JSType.Promise<JSType.Array<JSType.Number>>>()] 產生編譯時間錯誤。 適當的因應措施會因案例而異,但請在型別對應限制一節中,進一步探索此特定案例。

JS 基本型別

下列範例示範 [JSImport] 運用數種基本 JS 型別的型別對應和使用 JSMarshalAs,進行編譯期間需要明確對應。

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 物件做為其傳回或參數。 日期會依據值跨 Interop 進行封送處理,這表示其用以複製的方式與 JS 基本型別大致相同。

Date 物件與時區無關。 當封送處理至 Date 時,會相對於其 DateTimeKind 來調整 .NET DateTime,但不會保留時區資訊。 請考慮使用與其所表示值一致的 DateTimeKind.UtcDateTimeKind.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 方法傳回物件參考時,其在 .NET中都會表示為 JSObject。 原始 JS 物件會在界限內 JS 繼續其存留期,而 .NET 程式碼可以透過 JSObject 依據參考來予以存取和修改。 雖然型別本身會公開有限的 API,但能夠保留 JS 物件參考並傳回或傳遞至 Interop 界限,可以支援數個 Interop 案例。

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

非同步 Interop

許多 JS API 都是非同步的,而且透過回呼、Promise 或非同步方法發出完成訊號。 通常無法選擇忽略非同步功能,因為後續程式碼可能取決於非同步作業完成與否,而且必須等候。

您可以透過傳回 Task 的方法,在 C# 中等候使用 async 關鍵字或傳回 Promise 的 JS 方法。 如下所示範,async 關鍵字不會在 C# 方法上搭配 [JSImport] 屬性使用,因為它不會在其中使用 await 關鍵字。 不過,取用呼叫方法的程式碼通常會使用 await 關鍵字,並標示為 async,如 PromisesUsage 範例中所示。

在從 JS 傳回之前,可以在 Promise 中包裝使用回呼 (例如 setTimeout) 的 JS。 如指派給 Wait2Seconds 的函式所示範,只有在僅會呼叫回呼一次時,才適合在 Promise 中包裝回呼。 否則,可以傳遞 C# Action 來接聽可能呼叫為零次或多次的回呼,如訂閱 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 關鍵字。 傳回 TaskTask<TResult> 已足夠。

我們通常會想要等到 JS 方法完成執行,才呼叫非同步 JS 方法。 如果載入資源或提出要求,可能會希望下列程式碼假設已完成該動作。

如果 JS 填充碼傳回 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 定義中需要巢狀泛型型別的型別對應。 舉例來說,為陣列傳回 Promise,例如 [return: JSMarshalAs<JSType.Promise<JSType.Array<JSType.Number>>>()] 產生編譯時間錯誤。 適當的因應措施會因案例而異,但其中一個選擇是將陣列表示為 JSObject 參考。 如果不需要存取 .NET 內的個別元素,並可將參考傳遞至其他可對陣列採取行動的 JS 方法,這可能就已足夠。 或者,專用方法可以將 JSObject 參考作為參數,並傳回具體化陣列,如下列 UnwrapJSObjectAsIntArray 範例所示範。 在這種情況下,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);

效能考量

跨 Interop 界限封送處理追蹤物件的呼叫和額外負荷,比原生 .NET 作業成本更高,但對於需求視中的一般 Web 應用程式,仍應展示可接受的效能。

例如 JSObject,跨 Interop 界限維護參考的物件 Proxy,具有額外的記憶體額外負荷,並會影響記憶體回收影響這些物件的程度。 此外,在某些案例中,可能會耗盡可用的記憶體,而不會觸發記憶體回收,因為來自 JS 和 .NET 的記憶體壓力不會共用。 當相對較小型的 JS 物件跨 Interop 界限參考過多大型物件時,或反之亦然,在 JS Proxy 參考大型 .NET 物件時,這項風險很大。 在這種情況下,建議您使用下列確定性處置模式,搭配利用 JS 物件上 IDisposable 介面的 using 範圍。

下列會利用先前範例程式碼的效能評定,展示 Interop 作業的速度大致比仍在 .NET 界限內的作業效能低一級,但 Interop 作業仍然相對快速。 此外,請考慮使用者的裝置功能對效能的影響。

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 程式碼可以將 C# Action 傳遞至 JS 函式作為處理常式,來訂閱 JS 事件和處理 JS 事件。 JS 填充碼程式碼會處理訂閱事件。

警告

如本節所示範的指引,透過 JS Interop 與個別的 DOM 屬性互動相對緩慢,並可能導致建立許多產生高記憶體回收壓力的 Proxy。 通常不建議使用下列模式。 請針對幾個元素使用下列模式。 如需詳細資訊,請參閱效能考量一節。

removeEventListener 的細微差別在於,其需要參考先前傳遞至 addEventListener 的函式。 跨 Interop 界限傳遞 C# Action 時,其會包裝在 JS Proxy 物件中。 因此,將相同的 C# Action 傳遞至 addEventListenerremoveEventListener 會產生兩個包裝 Action 的不同 JS Proxy 物件。 這些為不同的參考,因此 removeEventListener 無法找到要移除的事件接聽程式。 若要解決此問題,下列範例會將 C# Action 包裝在 JS 函式中,並將參考傳回為訂閱呼叫中的 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] Interop 案例

下列文章著重於在 JS 主機中執行 .NET WebAssembly 模組,例如瀏覽器: