一、DotNetObjectReference:将C#对象暴露给JavaScript
上一章我们完成了"C#调用JavaScript"这个方向,本章转向另一个方向:让JavaScript主动调用C#方法。这在集成事件驱动的JavaScript库时非常关键——当第三方库内部发生某个事件(如图表被点击、编辑器内容变化、定时任务触发),它需要一种机制来通知Blazor组件。
核心工具是DotNetObjectReference<T>,它将一个C#对象包装成一个可被JavaScript引用的句柄,JS侧通过这个句柄调用对象上标记了[JSInvokable]的公共方法。
DotNetObjectReference的典型生命周期是:在OnAfterRenderAsync中创建并传递给JS,在Dispose中释放。释放是必须的——不释放会导致.NET侧的对象被GC Roots所持有,永远无法被垃圾回收:
// 生命周期管理的标准模式privateDotNetObjectReference<MyComponent>?dotNetRef;protectedoverrideasyncTaskOnAfterRenderAsync(boolfirstRender){if(firstRender){// 创建对当前组件实例的引用dotNetRef=DotNetObjectReference.Create(this);// 将引用传给JS,JS持有这个句柄awaitJS.InvokeVoidAsync("initializeWithCallback",dotNetRef);}}publicasyncValueTaskDisposeAsync(){// 释放引用,允许C#对象被垃圾回收dotNetRef?.Dispose();}二、[JSInvokable]:可被JavaScript调用的C#方法
在C#类中,任何需要被JavaScript调用的公共方法都必须标记[JSInvokable]特性。默认情况下,JavaScript端调用时使用的方法名与C#方法名相同(大小写敏感),也可以通过[JSInvokable("customName")]自定义:
// JavaScript 调用时使用 "UpdateData"[JSInvokable]publicvoidUpdateData(stringnewValue){currentValue=newValue;StateHasChanged();// 通知Blazor重渲染}// JavaScript 调用时使用 "handleResult"(自定义名称)[JSInvokable("handleResult")]publicasyncTask<string>ProcessResult(intcode){varresult=awaitSomeService.ProcessAsync(code);returnresult.ToString();}[JSInvokable]方法可以有参数(JSON自动反序列化)和返回值(JSON自动序列化),也可以是async Task或async Task<T>——JavaScript侧调用时返回的是Promise,可以正常await。
除了通过DotNetObjectReference调用实例方法,[JSInvokable]也可以标记静态方法,此时不需要传递任何对象引用,JavaScript直接通过程序集名和方法名调用:
// 静态方法:JS调用 DotNet.invokeMethodAsync('MyApp', 'GetAppVersion')[JSInvokable]publicstaticstringGetAppVersion(){return"1.2.3";}三、实战:集成Chart.js构建交互式图表
理论的最佳检验是实践。我们来完整实现一个集成了Chart.js的动态折线图组件,它支持从C#端动态更新数据,并在用户点击图表数据点时,将点击信息回调给Blazor组件:
第一步:在HTML中引入Chart.js
在App.razor的<head>中引入Chart.js CDN(生产环境建议下载到本地wwwroot):
<scriptsrc="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script><scripttype="module"src="/js/chartInterop.js"></script>这里注意type="module"——我们的互操作代码使用ES模块语法,必须以module方式加载。
第二步:编写JavaScript互操作模块
// wwwroot/js/chartInterop.js// 存储已创建的图表实例,key为canvas元素的idconstcharts=newMap();/** * 创建折线图 * @param {HTMLElement} canvasElement - canvas DOM元素 * @param {object} chartData - 初始数据 { labels: string[], datasets: object[] } * @param {object} dotNetRef - .NET对象引用,用于回调 */exportfunctioncreateLineChart(canvasElement,chartData,dotNetRef){constctx=canvasElement.getContext('2d');constchart=newChart(ctx,{type:'line',data:chartData,options:{responsive:true,animation:{duration:500},plugins:{legend:{position:'top'},},onClick:async(event,elements)=>{// 用户点击图表时,回调Blazor组件if(elements.length>0){constelement=elements[0];constdatasetIndex=element.datasetIndex;constdataIndex=element.index;constvalue=chart.data.datasets[datasetIndex].data[dataIndex];constlabel=chart.data.labels[dataIndex];// invokeMethodAsync 调用 [JSInvokable] 标记的方法// 参数按JSON传递,在C#侧自动反序列化awaitdotNetRef.invokeMethodAsync('OnChartPointClicked',{datasetIndex,dataIndex,value,label});}}}});// 使用canvas元素的id作为key存储图表实例charts.set(canvasElement.id,chart);}/** * 更新图表数据(无需重建整个图表) * @param {string} canvasId - canvas元素的id * @param {number[][]} newData - 每个数据集的新数据数组 * @param {string[]} newLabels - 新的X轴标签数组 */exportfunctionupdateChartData(canvasId,newData,newLabels){constchart=charts.get(canvasId);if(!chart)return;chart.data.labels=newLabels;newData.forEach((data,index)=>{if(chart.data.datasets[index]){chart.data.datasets[index].data=data;}});// 触发图表重绘chart.update();}/** * 销毁图表,释放Canvas上下文资源 * @param {string} canvasId */exportfunctiondestroyChart(canvasId){constchart=charts.get(canvasId);if(chart){chart.destroy();charts.delete(canvasId);}}第三步:编写Blazor组件
@*Components/Shared/LineChart.razor*@ @inject IJSRuntime JS @implements IAsyncDisposable<divclass="chart-container"style="position: relative; height: 300px;">@*@ref获取canvas元素的引用;id用于JS侧存储图表实例*@<canvasid="@canvasId"@ref="canvasRef"></canvas></div>@if(lastClickedPointisnotnull){<pclass="chart-info">您点击了:<strong>@lastClickedPoint.Label</strong>,值为<strong>@lastClickedPoint.Value</strong></p>}@code{// 接收图表标签和数据集作为参数[Parameter]publicList<string>Labels{get;set;}=[];[Parameter]publicList<ChartDataset>Datasets{get;set;}=[];// 当图表内数据点被点击时,通知父组件[Parameter]publicEventCallback<ChartPointInfo>OnPointClicked{get;set;}privateElementReferencecanvasRef;// 使用固定的唯一ID,确保多个图表组件共存时不冲突privatereadonlystringcanvasId=$"chart-{Guid.NewGuid():N}";privateIJSObjectReference?jsModule;privateDotNetObjectReference<LineChart>?dotNetRef;privateChartPointInfo?lastClickedPoint;protectedoverrideasyncTaskOnAfterRenderAsync(boolfirstRender){if(firstRender){// 加载ES模块jsModule=awaitJS.InvokeAsync<IJSObjectReference>("import","/js/chartInterop.js");// 创建当前组件的 .NET 引用,用于JS回调dotNetRef=DotNetObjectReference.Create(this);// 构建Chart.js所需的数据格式varchartData=new{labels=Labels,datasets=Datasets.Select(d=>new{label=d.Label,data=d.Data,borderColor=d.Color,backgroundColor=d.Color+"33",// 加透明度tension=0.4// 曲线平滑度})};// 调用JS模块中的 createLineChart 函数,传入DOM元素、数据和.NET引用awaitjsModule.InvokeVoidAsync("createLineChart",canvasRef,chartData,dotNetRef);}}// 当父组件传入的数据发生变化时,更新图表(无需重建)protectedoverrideasyncTaskOnParametersSetAsync(){// jsModule为null说明还没完成首次渲染初始化,跳过if(jsModuleisnull)return;varnewData=Datasets.Select(d=>d.Data).ToList();awaitjsModule.InvokeVoidAsync("updateChartData",canvasId,newData,Labels);}// 此方法被 JavaScript 端的 onClick 事件通过 dotNetRef 调用// 必须是 public,才能被 DotNetObjectReference 反射发现[JSInvokable]publicasyncTaskOnChartPointClicked(ChartPointInfopointInfo){lastClickedPoint=pointInfo;// 触发父组件订阅的回调awaitOnPointClicked.InvokeAsync(pointInfo);// 手动触发重渲染,更新 lastClickedPoint 的显示StateHasChanged();}publicasyncValueTaskDisposeAsync(){if(jsModuleisnotnull){// 通知JS侧销毁图表,释放Canvas资源awaitjsModule.InvokeVoidAsync("destroyChart",canvasId);awaitjsModule.DisposeAsync();}// 必须释放,否则 this(组件实例)将被JS侧持有并无法GCdotNetRef?.Dispose();}}// 数据模型publicclassChartDataset{publicstringLabel{get;set;}=string.Empty;publicList<double>Data{get;set;}=[];publicstringColor{get;set;}="#0078d4";}publicclassChartPointInfo{publicintDatasetIndex{get;set;}publicintDataIndex{get;set;}publicdoubleValue{get;set;}publicstringLabel{get;set;}=string.Empty;}第四步:在父页面中使用图表组件
@page"/dashboard"<h1>销售仪表盘</h1><LineChartLabels="@months"Datasets="@datasets"OnPointClicked="HandlePointClick"/><button@onclick="AddRandomData">添加随机数据</button>@if(clickInfoisnotnull){<p>点击详情:@clickInfo.Label 月,@clickInfo.Value 万元</p>}@code{privateList<string>months=["1月","2月","3月","4月","5月","6月"];privateList<ChartDataset>datasets=[new(){Label="2024年",Data=[85,92,78,110,95,130],Color="#0078d4"},new(){Label="2023年",Data=[72,80,65,88,76,105],Color="#ff6b35"}];privateChartPointInfo?clickInfo;privatereadonlyRandomrng=new();privatevoidHandlePointClick(ChartPointInfoinfo){clickInfo=info;}privatevoidAddRandomData(){months.Add($"{months.Count+1}月");foreach(vardsindatasets){ds.Data.Add(Math.Round(60+rng.NextDouble()*80,1));}}}这个完整示例展示了双向互操作的全貌:C#通过jsModule.InvokeVoidAsync创建和更新图表(C#→JS方向),Chart.js通过dotNetRef.invokeMethodAsync回调OnChartPointClicked(JS→C#方向)。两个方向相互配合,让Razor组件与JavaScript库形成了有机的整体。
四、总结
本章完成了JavaScript互操作的反向链路:DotNetObjectReference.Create(this)将C#对象包装为JS可见的引用;[JSInvokable]标记的公共方法可被JS通过dotNetRef.invokeMethodAsync调用;通过Chart.js集成的完整实战,我们演示了从图表创建、数据动态更新到点击回调的全流程,也强调了dotNetRef.Dispose()和jsModule.DisposeAsync()对于防止内存泄漏的重要性。
至此,第三章JavaScript互操作的两个方向均已讲解完毕。一个独立运行的前端离不开数据来源,而数据通常来自后端API。下一章我们进入第四章,探讨如何构建ASP.NET Core Web API服务器,以及Blazor WebAssembly如何通过HttpClient安全、高效地与API进行数据交换。