一、为什么需要JavaScript互操作
Blazor的核心承诺是"用C#写一切",但现实情况是:浏览器平台提供了大量只能通过JavaScript访问的原生API(如Clipboard API、Notification API、Web Bluetooth、ResizeObserver等),以及数以万计的成熟JavaScript库(如图表库ECharts/Chart.js、富文本编辑器Quill、地图库Leaflet),它们无法被基本的HTML属性绑定所替代。此外,某些精细的DOM操作(如使某个元素获取焦点、测量元素尺寸)也需要直接调用JavaScript。
Blazor为此设计了官方的互操作机制------JavaScript Interop(JSInterop) ,由IJSRuntime接口承载。通过它,C#代码可以调用任意JavaScript函数,传递参数并接收返回值,整个过程类型安全且异步。
二、IJSRuntime:基础调用
IJSRuntime服务由Blazor框架自动注册,在任何需要的地方通过@inject或构造函数注入即可获取。它提供两个核心方法:
InvokeVoidAsync(string identifier, params object?[] args):调用没有返回值的JavaScript函数。InvokeAsync<TValue>(string identifier, params object?[] args):调用有返回值的JavaScript函数,返回值被反序列化为指定的C#类型。
identifier参数是JavaScript函数的全局路径,以.分隔访问路径(如window.alert、console.log、myApp.utils.formatDate)。让我们从最简单的例子开始:
csharp
@* Components/Pages/JsDemo.razor *@
@page "/js-demo"
@inject IJSRuntime JS
<button @onclick="ShowAlert">调用 alert</button>
<button @onclick="GetWindowSize">获取窗口尺寸</button>
<p>窗口大小:@windowWidth x @windowHeight</p>
@code {
private int windowWidth;
private int windowHeight;
private async Task ShowAlert()
{
// InvokeVoidAsync 对应不需要获取返回值的场景
// "alert" 对应 window.alert,全局函数可以省略 window. 前缀
await JS.InvokeVoidAsync("alert", "来自 Blazor 的问候!");
}
private async Task GetWindowSize()
{
// InvokeAsync<T> 返回一个 C# 对象,JSON 反序列化自动完成
// 这里 window.innerWidth 是 JavaScript 属性,不能直接调用
// 需要先在 JS 中定义一个函数来读取并返回它(见下文)
windowWidth = await JS.InvokeAsync<int>("getWindowWidth");
windowHeight = await JS.InvokeAsync<int>("getWindowHeight");
}
}
对应的JavaScript函数定义在wwwroot下的JS文件中(.NET 10项目通常放在wwwroot/app.js,然后在App.razor的<head>中用<script src="app.js"></script>引用):
javascript
// wwwroot/app.js
function getWindowWidth() {
return window.innerWidth;
}
function getWindowHeight() {
return window.innerHeight;
}
三、传递复杂对象与ES模块化调用
基本的全局函数调用适合简单场景,但在大型项目中,将所有JS函数堆到全局命名空间会造成命名冲突和维护困难。.NET 10的Blazor推荐使用ES模块 (ES Module)来组织JavaScript代码,通过IJSObjectReference动态导入模块,实现更好的封装与作用域隔离。
首先,将与某个组件相关的JS代码编写为ES模块,文件名习惯上与组件名对应:
javascript
// wwwroot/js/fileUploader.js
// ES模块只需导出函数,不污染全局命名空间
export function initDropZone(element, dotNetRef) {
element.addEventListener('dragover', (e) => {
e.preventDefault();
element.classList.add('drag-over');
});
element.addEventListener('dragleave', () => {
element.classList.remove('drag-over');
});
element.addEventListener('drop', async (e) => {
e.preventDefault();
element.classList.remove('drag-over');
const files = Array.from(e.dataTransfer.files).map(f => ({
name: f.name,
size: f.size,
type: f.type
}));
// 通知 .NET 端有文件被拖入(dotNetRef 的用法见下一章)
await dotNetRef.invokeMethodAsync('OnFilesDropped', files);
});
}
export function disposeDropZone(element) {
// 清理事件监听器(实际中可能需要保存匿名函数的引用才能移除)
element.replaceWith(element.cloneNode(true));
}
在Blazor组件中,使用IJSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/fileUploader.js")动态导入ES模块,得到一个IJSObjectReference代表该模块对象,再通过它调用模块内导出的函数:
csharp
@* Components/Shared/FileDropZone.razor *@
@inject IJSRuntime JS
@implements IAsyncDisposable
<div @ref="dropZoneElement" class="drop-zone">
<p>拖拽文件到此处上传</p>
@foreach (var file in droppedFiles)
{
<p>@file.Name(@file.Size 字节)</p>
}
</div>
@code {
// @ref 将此 div 对应的 DOM 元素引用绑定到 C# 变量
private ElementReference dropZoneElement;
// 持有 JS 模块的引用
private IJSObjectReference? jsModule;
private List<FileInfo> droppedFiles = [];
// 在首次渲染完成后初始化,此时 dropZoneElement 已绑定到真实 DOM 节点
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// 动态import ES模块,返回模块的引用
jsModule = await JS.InvokeAsync<IJSObjectReference>(
"import", "./js/fileUploader.js");
// 调用模块中导出的 initDropZone 函数,传入 DOM 元素引用
// ElementReference 会被自动序列化为JS侧可用的DOM元素句柄
await jsModule.InvokeVoidAsync("initDropZone", dropZoneElement);
}
}
// JS 端回调此方法(通过 DotNetObjectReference,详见下一章)
[JSInvokable]
public void OnFilesDropped(FileInfo[] files)
{
droppedFiles = [.. files];
StateHasChanged();
}
// 组件销毁时释放 JS 对象引用,避免内存泄漏
public async ValueTask DisposeAsync()
{
if (jsModule is not null)
{
await jsModule.InvokeVoidAsync("disposeDropZone", dropZoneElement);
await jsModule.DisposeAsync();
}
}
}
这段代码揭示了几个重要的实践原则。其一,ElementReference是Blazor对DOM元素的C#端引用,将其作为参数传给JS互操作方法时,JS侧会接收到对应的真实DOM元素------这是从C#控制具体DOM节点的唯一方式,也是集成需要操作DOM的第三方库的关键。其二,IJSObjectReference本身是一个非托管资源(持有JS侧的对象引用),组件销毁时必须调用DisposeAsync()将其释放,否则会造成JavaScript端的内存泄漏;因此组件实现IAsyncDisposable接口而非同步的IDisposable。其三,OnAfterRenderAsync(firstRender: true)是初始化JS的正确时机,确保DOM已就绪。
四、传递复杂对象与超时控制
C#对象和JavaScript对象之间的传递通过JSON序列化自动完成。传递基本类型(int、string、bool、double)时直接对应;传递复杂对象时,框架使用System.Text.Json序列化为JSON字符串后传递,JS侧接收到的是一个普通的JavaScript对象,C#侧的泛型类型参数决定了如何反序列化返回值:
csharp
@code {
// 从C#传递复杂对象到JS
private async Task SaveToLocalStorage()
{
var settings = new AppSettings
{
Theme = "dark",
Language = "zh-CN",
FontSize = 14
};
// 对象会被序列化为 JSON,JS 侧接收到 { theme: "dark", language: "zh-CN", fontSize: 14 }
await JS.InvokeVoidAsync("localStorage.setItem",
"appSettings", JsonSerializer.Serialize(settings));
}
// 从JS接收复杂对象到C#
private async Task<WindowInfo> GetWindowInfo()
{
// JS函数返回一个对象,框架自动反序列化为 WindowInfo 类型
return await JS.InvokeAsync<WindowInfo>("getWindowInfo");
}
}
public record AppSettings
{
public string Theme { get; init; } = "light";
public string Language { get; init; } = "zh-CN";
public int FontSize { get; init; } = 14;
}
public record WindowInfo
{
public int Width { get; init; }
public int Height { get; init; }
public double DevicePixelRatio { get; init; }
}
在某些场景下,JS调用可能因为网络或JS异常而长时间不返回。可以使用带有CancellationToken的重载来控制超时:
csharp
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try
{
var result = await JS.InvokeAsync<string>("slowOperation", cts.Token, arg1, arg2);
}
catch (TaskCanceledException)
{
// 超时处理
}
catch (JSException ex)
{
// JavaScript 侧抛出异常时,会在 C# 侧以 JSException 呈现
Console.WriteLine($"JS错误:{ex.Message}");
}
五、总结
本章系统介绍了Blazor从C#调用JavaScript的完整机制:IJSRuntime的InvokeVoidAsync和InvokeAsync<T>是互操作的两大基础武器;ElementReference是从C#定位并操作DOM节点的桥梁;ES模块配合IJSObjectReference实现了JS代码的封装与隔离,是大型项目的最佳实践;复杂对象通过JSON自动序列化传递;IAsyncDisposable确保了JS引用的正确释放。
但互操作是双向的。上文的拖放示例留下了一个悬念:JS端的drop事件处理完毕后,需要通知Blazor组件更新UI,这需要JS端主动"回调"C#方法。下一章,我们将讲解这个反方向的调用:如何用DotNetObjectReference和[JSInvokable]特性让JavaScript"敲开"C#组件的大门,以及一个集成Chart.js图表库的完整实战示例。