9. 【Blazor全栈开发实战指南】--Blazor调用JavaScript

一、为什么需要JavaScript互操作

Blazor的核心承诺是"用C#写一切",但现实情况是:浏览器平台提供了大量只能通过JavaScript访问的原生API(如Clipboard APINotification APIWeb BluetoothResizeObserver等),以及数以万计的成熟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.alertconsole.logmyApp.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序列化自动完成。传递基本类型(intstringbooldouble)时直接对应;传递复杂对象时,框架使用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的完整机制:IJSRuntimeInvokeVoidAsyncInvokeAsync<T>是互操作的两大基础武器;ElementReference是从C#定位并操作DOM节点的桥梁;ES模块配合IJSObjectReference实现了JS代码的封装与隔离,是大型项目的最佳实践;复杂对象通过JSON自动序列化传递;IAsyncDisposable确保了JS引用的正确释放。

但互操作是双向的。上文的拖放示例留下了一个悬念:JS端的drop事件处理完毕后,需要通知Blazor组件更新UI,这需要JS端主动"回调"C#方法。下一章,我们将讲解这个反方向的调用:如何用DotNetObjectReference[JSInvokable]特性让JavaScript"敲开"C#组件的大门,以及一个集成Chart.js图表库的完整实战示例。

相关推荐
wuqingshun3141591 小时前
如何停止一个正在退出的线程
java·开发语言·jvm
我命由我123451 小时前
Element Plus - Form 的 resetField 方法观察记录
开发语言·前端·javascript·vue.js·html·html5·js
朱包林2 小时前
Python基础
linux·开发语言·ide·python·visualstudio·github·visual studio
清空mega2 小时前
《Vue3 项目结构详解:components、views、assets、router、stores 到底该怎么理解?》
前端·javascript·vue.js
Barkamin2 小时前
队列的实现(Java)
java·开发语言
hixiong1233 小时前
C# OpenvinoSharp使用RAD进行缺陷检测
开发语言·人工智能·c#·openvino
小浪花a3 小时前
计算机二级python-jieba库
开发语言·python
雨雨雨雨雨别下啦3 小时前
Vue——小白也能学!Day6
前端·javascript·vue.js
骇客野人3 小时前
自己手搓磁盘清理工具(JAVA版)
java·开发语言