.NET WebAssembly 中的 JavaScript [JSImport]
/[JSExport]
Interop
注意
這不是這篇文章的最新版本。 如需目前的版本,請參閱 本文的 .NET 9 版本。
警告
不再支援此版本的 ASP.NET Core。 如需詳細資訊,請參閱 .NET 和 .NET Core 支持原則。 如需目前的版本,請參閱 本文的 .NET 9 版本。
本文說明如何使用 JS[JSImport]
/[JSExport]
Interop (System.Runtime.InteropServices.JavaScript API),與用戶端 WebAssembly 中的 JavaScript (JS) 互動。
在下列案例中,於 JS 主機中執行 .NET WebAssembly 模組時,適用 [JSImport]
/[JSExport]
Interop:
- JavaScript `[JSImport]`/`[JSExport]` Interop 搭配 WebAssembly Browser App 專案。
- JavaScript JSImport/JSExport Interop 搭配 ASP.NET Core Blazor。
- 其他支援
[JSImport]
/[JSExport]
Interop 的 .NET WebAssembly 平台。
必要條件
任何下列專案類型:
- 根據 JavaScript `[JSImport]`/`[JSExport]` Interop 搭配 WebAssembly Browser App 專案建立的 WebAssembly Browser App 專案。
- 根據 JavaScript JSImport/JSExport Interop 搭配 ASP.NET Core Blazor建立的 Blazor 用戶端專案。
- 針對支援
[JSImport]
/[JSExport]
Interop (System.Runtime.InteropServices.JavaScript API) 的商業或開放原始碼平台所建立的專案。
範例應用程式
檢視或下載範例程式碼 (如何下載):選取與您採用之 .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.callAlert
JS 宣告是用來包裝現有的 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.Number 或 System.Runtime.InteropServices.JavaScript.JSType.BigInt,因此需要 JSMarshalAsAttribute<T>,以避免發生編譯時間錯誤。
不支援下列型別對應案例:
- 傳遞 Action 或 Func<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
) 對應至 JSNumber
的Array
。- 將 JS 值傳遞至使用錯誤型別值的 C# 時,架構在大部分情況下會擲回例外狀況。 架構不會在 JS 中執行編譯時間型別檢查。
JSObject
、Exception
、Task
和ArraySegment
建立GCHandle
及 Proxy。 您可以在開發人員程式碼中觸發處置,或稍後允許 .NET 記憶體回收 (GC) 處置物件。 這些型別具有顯著的效能額外負荷。Array
:封送處理陣列會在 JS 或 .NET 中建立陣列的複本。MemoryView
MemoryView
是 .NET WebAssembly 執行階段的 JS 類別,用於封送處理Span
和ArraySegment
。- 與封送處理陣列不同,封送處理
Span
或ArraySegment
不會建立基礎記憶體的複本。 MemoryView
只能由 .NET WebAssembly 執行階段正確具現化。 因此,無法將 JS 方法匯入為具有Span
或ArraySegment
參數的 .NET 方法。- 為
Span
建立的MemoryView
只在 Interop 呼叫期間才有效。 如同Span
在呼叫堆疊上配置,在 Interop 呼叫之後不會保存,因此無法匯出傳回Span
的 .NET 方法。 - 針對
ArraySegment
建立的MemoryView
在 Interop 呼叫之後存留下來,而且有助於共用緩衝區。 在針對ArraySegment
建立的MemoryView
上呼叫dispose()
會處置 Proxy,並取消釘選基礎 .NET 陣列。 我們建議在MemoryView
的try-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.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 方法傳回物件參考時,其在 .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
關鍵字。 傳回 Task 或 Task<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 傳遞至 addEventListener
與 removeEventListener
會產生兩個包裝 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 模組,例如瀏覽器: