在WebView2中实现.NET与JavaScript双向互操作:脱离Blazor框架的实践
在开发桌面应用时,将现代Web前端技术与.NET后端逻辑结合是一种常见模式,WebView2组件为此提供了强大支持。Blazor框架提供了一套优秀的.NET与JavaScript(JS)互操作机制,但在某些特定场景下,直接使用BlazorWebView可能会引入不必要的复杂性或限制。例如,BlazorWebView默认不支持直接通过HTTP协议加载Web内容进行调试,生产环境打包也相对繁琐。本篇文章旨在探讨如何从BlazorWebview中提炼出核心的.NET与JS互操作能力,使其在不完全依赖Blazor框架的情况下,独立应用于基于WebView2的.NET应用,甚至扩展到传统.NET Framework平台。
一、独立互操作组件的使用
我们首先介绍如何在一个WPF项目中使用这个独立封装的互操作组件。目前,该组件已支持.NET Framework下的WPF和MAUI安卓平台。
1. 安装
通过NuGet包管理器搜索并安装名为HSoft.WebView.NetFramework.WPF的软件包(此包名为原文作者所用,实际应用中您可根据自定义封装进行调整)。
<!-- NuGet 安装命令示例 -->
Install-Package HSoft.WebView.NetFramework.WPF
2. 集成WebView组件
在您的XAML文件中引入组件命名空间,并在窗体中使用该自定义WebView组件。
<Window
x:Class="YourApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:wvf="clr-namespace:HSoft.WebView.NetFramework.WPF;assembly=HSoft.WebView.NetFramework.WPF"
Title="主窗口" Height="450" Width="800"
mc:Ignorable="d">
<Grid>
<wvf:WebView Source="http://localhost:5173" />
</Grid>
</Window>
开发模式下,Source属性可以直接指向您的前端开发服务器地址。生产环境中,通常会将Web页面资源嵌入到程序集中。为此,您需要在项目文件(.csproj)中添加以下配置,以便将wwwroot目录下的所有文件作为嵌入式资源打包:
<Project Sdk="Microsoft.NET.Sdk">
<!-- ... 其他项目配置 ... -->
<ItemGroup>
<EmbeddedResource Include="wwwroot\**\*" />
</ItemGroup>
<!-- ... 其他项目配置 ... -->
</Project>
如果您的网页入口文件位于wwwroot\index.html,则生产环境下的Source应设置为http://0.0.0.0/index.html。
二、互操作核心机制解析
实现.NET与JavaScript互操作的核心在于利用Microsoft.JSInterop(.NET侧)和@microsoft/dotnet-js-interop(JS侧)这两个库提供的抽象层,并通过WebView2的进程间通信(IPC)机制作为实际的数据传输通道。简而言之,就是使用chrome.webview.postMessage发送消息,并使用chrome.webview.addEventListener("message", ...)接收消息。
1. JavaScript 端实现
在前端应用中,我们需要引入@microsoft/dotnet-js-interop包,并使用DotNet.attachDispatcher方法注册自定义的消息发送与接收逻辑。以下是关键的JavaScript代码片段。
import { DotNet } from "@microsoft/dotnet-js-interop";
let interopDispatcher: DotNet.ICallDispatcher;
// 初始化调度器,关联自定义的通信方法
interopDispatcher = DotNet.attachDispatcher({
sendByteArray: sendBytesToDotNet,
beginInvokeDotNetFromJS: initiateDotNetCallFromJS,
endInvokeJSFromDotNet: completeJsMethodCall,
});
/**
* 将消息发送到.NET宿主
* @param message 消息内容,通常是一个数组
*/
function postMessageToDotNet(message: any[]): void {
(window as any).chrome.webview.postMessage(message);
}
/**
* 当JS需要向.NET发送字节数组时调用
* @param id 传输ID
* @param data 字节数组
*/
function sendBytesToDotNet(id: number, data: Uint8Array): void {
const base64EncodedData = byteArrayToBase64(data);
postMessageToDotNet(["ReceiveBytesFromJS", id, base64EncodedData]);
}
/**
* 当JS调用.NET方法时,通知.NET宿主
* @param callId 调用ID
* @param assemblyName 程序集名称
* @param methodName 方法标识符
* @param dotNetObjectId .NET对象ID (如果调用的是实例方法)
* @param jsonArguments 方法参数的JSON字符串
*/
function initiateDotNetCallFromJS(
callId: number,
assemblyName: string | null,
methodName: string,
dotNetObjectId: number | null,
jsonArguments: string
): void {
console.log("JS准备调用.NET方法:", methodName);
postMessageToDotNet([
"InvokeDotNetFromJS",
callId ? callId.toString() : null,
assemblyName,
methodName,
dotNetObjectId || 0,
jsonArguments,
]);
}
/**
* 当.NET调用的JS方法执行完毕后,JS通知.NET结果
* @param callId 调用ID
* @param succeeded 调用是否成功
* @param resultOrError 结果或错误信息
*/
function completeJsMethodCall(
callId: number,
succeeded: boolean,
resultOrError: any
): void {
console.log("JS方法执行完毕,通知.NET...");
postMessageToDotNet([
"JsCallCompleted",
callId ? callId.toString() : null,
succeeded,
resultOrError,
]);
}
/**
* 将Uint8Array转换为Base64字符串
* @param data Uint8Array数据
* @returns Base64字符串
*/
function byteArrayToBase64(data: Uint8Array): string {
let binaryString = "";
for (let i = 0; i < data.length; i++) {
binaryString += String.fromCharCode(data[i]);
}
return btoa(binaryString);
}
/**
* 将Base64字符串转换为Uint8Array
* @param base64String Base64字符串
* @returns Uint8Array数据
*/
function base64ToByteArray(base64String: string): Uint8Array {
const binary = atob(base64String);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
// 监听来自.NET宿主的消息并进行分发处理
(window as any).chrome.webview.addEventListener("message", (event: any) => {
const receivedPayload = JSON.parse(event.data);
switch (receivedPayload[0]) {
case "DotNetCallFinished": { // .NET方法调用完成的通知
interopDispatcher.endInvokeDotNetFromJS(
receivedPayload[1],
receivedPayload[2],
receivedPayload[3]
);
break;
}
case "InvokeJSFromDotNet": { // .NET调用JS方法的请求
interopDispatcher.beginInvokeJSFromDotNet(
receivedPayload[1],
receivedPayload[2],
receivedPayload[3],
receivedPayload[4],
receivedPayload[5]
);
break;
}
case "SendByteArrayToJS": { // .NET向JS发送字节数组
const dataIdentifier = receivedPayload[1];
const base64Data = receivedPayload[2];
const byteData = base64ToByteArray(base64Data);
interopDispatcher.receiveByteArray(dataIdentifier, byteData);
break;
}
default: {
console.error(`收到未知类型的消息: ${event.data}`);
}
}
});
// 将DotNet对象暴露到全局window对象,以便外部调用
(window as any)["DotNetInteropBridge"] = DotNet;
export { DotNet };
2. .NET 端实现
在.NET侧,我们需要创建一个自定义的JSRuntime实现(例如,继承自Microsoft.JSInterop.JSRuntime)。这个自定义运行时将负责处理以下几个方面:
- 接收JS消息: 监听WebView2的
CoreWebView2.WebMessageReceived事件。当JavaScript通过chrome.webview.postMessage发送消息时,此事件会被触发。解析接收到的JSON消息,根据消息类型(例如"InvokeDotNetFromJS","JsCallCompleted","ReceiveBytesFromJS"),将数据分发给Microsoft.JSInterop.DotNetDispatcher的相应方法(如BeginInvokeDotNet、EndInvokeJS、ReceiveByteArray)。 - 发送消息到JS: 重写
JSRuntime中的抽象方法,例如BeginInvokeJS、EndInvokeDotNet、SendByteArray。这些方法会在.NET需要调用JS代码或通知JS操作完成时被框架调用。在这些方法的实现中,您需要将相关的调用信息序列化为JSON字符串,并通过WebView2.CoreWebView2.PostWebMessageAsString方法发送到前端。
通过这种方式,我们建立了一个完整的双向通信管道,使得.NET和JavaScript可以无缝地互相调用方法和传递数据。具体的.NET代码实现涉及对JSRuntime的继承和对WebView2事件的处理,此处不再详述具体代码,感兴趣的开发者可以参考Microsoft.JSInterop的源码和WebView2的官方文档。