Blazor 最大的卖点是全栈用 C# 开发,但浏览器里毕竟有大量只有 JavaScript 才能碰到的能力,比如操作 DOM、调用摄像头、使用 echarts 这类成熟的 JS 库。这时候就需要用到 JS Interop(JavaScript 互操作),它是 Blazor 提供的一座桥梁,让 C# 代码和 JavaScript 代码可以双向调用。这篇文章会把 C# 调用 JS 的几种典型方式完整梳理一遍,并说明各自的适用场景和注意事项。

基础方式:通过 IJSRuntime 注入调用全局 JS 函数
最常用的方式是通过依赖注入拿到 IJSRuntime 实例,然后调用 InvokeAsync 或 InvokeVoidAsync 执行一个全局作用域中存在的 JS 函数。InvokeVoidAsync 用于没有返回值的场景,性能比 InvokeAsync 略好,因为它不需要等待序列化结果。
先在 wwwroot 下的 index.html(WebAssembly)或 _Host.cshtml(Server)里引入一段脚本:
function showAlert(message) {
alert(message);
}
function getPageTitle() {
return document.title;
}
然后在组件里这样调用:
@inject IJSRuntime JS
@code {
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JS.InvokeVoidAsync("showAlert", "来自 C# 的消息");
var title = await JS.InvokeAsync<string>("getPageTitle");
}
}
}
有一个非常关键的时序问题:JS Interop 必须放在 OnAfterRenderAsync 且判断 firstRender 为 true 时执行,而不能放在 OnInitializedAsync 里。原因是在预渲染(prerender)阶段,组件运行在服务端,此时页面还没有真正渲染到浏览器中,DOM 并不存在,JS 调用会直接抛出异常。这是新手最常踩的坑之一。
参数传递方面,Blazor 会自动做 JSON 序列化。基本类型如 string、int、bool 可以直接传,复杂对象会被序列化成 JSON 传给 JS,JS 端拿到的就是一个普通对象。但要注意 DateTime、Dictionary<string, object> 这类类型在序列化时可能产生意外行为,建议显式定义 DTO,不要用匿名对象嵌套过深。
进阶方式:JS 隔离模块与 IJSObjectReference
直接调用全局函数虽然简单,但污染全局命名空间,也无法利用现代前端的模块化能力。Blazor 从 .NET 5 开始支持 JS 模块隔离,做法是把 JS 文件写成 ES Module,放在 wwwroot 下与组件库对应的路径中,然后用 InvokeAsync<IJSObjectReference> 导入它。
假设有一个 wwwroot/js/exampleJsInterop.js 文件:
export function showPrompt(message) {
return prompt(message, '请输入内容');
}
export function focusElement(element) {
element.focus();
}
export function dispose() {
// 清理资源
}
C# 端的调用代码如下:
@inject IJSRuntime JS
@code {
private IJSObjectReference? module;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
module = await JS.InvokeAsync<IJSObjectReference>(
"import", "./js/exampleJsInterop.js");
}
}
public async ValueTask PromptAsync()
{
var result = await module!.InvokeAsync<string>("showPrompt", "你的名字是?");
Console.WriteLine(result);
}
public async ValueTask DisposeAsync()
{
if (module is not null)
{
await module.DisposeAsync();
}
}
}
IJSObjectReference 的好处是它持有对 JS 模块或对象的引用,后续调用都在这个模块作用域内进行,函数名不会与其他脚本冲突。组件销毁时记得调用 DisposeAsync 释放引用,否则在 Server 模式下会造成内存泄漏,因为 JS 端的对象会一直驻留在浏览器的 circuit 里。
另外还有一种不带版本控制的 IJSInProcessObjectReference,仅适用于 WebAssembly 场景,因为它绕过了序列化直接同步调用,性能开销最小。如果你的应用只跑在 WebAssembly 上且对性能敏感,可以优先考虑它。
反向调用:JS 调用 C# 方法与 DotNetObjectReference
JS Interop 是双向的。JS 端可以通过 DotNet.invokeMethodAsync 调用 C# 的静态方法,前提是该方法标注了 [JSInvokable] 特性。静态调用不需要持有对象引用,适合无状态的回调场景。
public class ToastService
{
[JSInvokable]
public static Task ShowMessage(string msg)
{
Console.WriteLine($"JS 传来: {msg}");
return Task.CompletedTask;
}
}
DotNet.invokeMethodAsync('MyApp', 'ShowMessage', 'hello from js');
如果要调用实例方法,就必须借助 DotNetObjectReference<T> 把对象包装后传给 JS。JS 拿到的引用可以反复调用,用完之后要调用它的 dispose 方法释放,防止 .NET 端对象被 GC 钉住无法回收。
@code {
private DotNetObjectReference<MyComponent>? selfRef;
protected override void OnInitialized()
{
selfRef = DotNetObjectReference.Create(this);
}
[JSInvokable]
public void HandleCallback(int count)
{
// JS 回调时进入这里
}
public async Task Start()
{
await JS.InvokeVoidAsync("startListener", selfRef);
}
public void Dispose()
{
selfRef?.Dispose();
}
}
需要特别注意的是,被 JSInvokable 标注的实例方法在对象被垃圾回收后调用会抛出异常。典型的坑是:JS 里有个定时器持续回调,而组件已经切走被销毁了,此时回调会报 System.ObjectDisposedException。稳妥的做法是在回调方法内部做判空和状态检查,并在 Dispose 中先通知 JS 停止回调,再释放 DotNetObjectReference。
常见问题与托管模型差异
Server 和 WebAssembly 两种托管模型下 JS Interop 的底层通道完全不同。WebAssembly 中 C# 代码直接运行在浏览器里,调用 JS 几乎没有网络开销;而 Server 模式下每一次互操作都要经过 SignalR 连接往返一次,如果高频调用(比如在鼠标移动事件里每帧调用),性能会非常糟糕。优化思路是批量传数据,把多次小调用合并成一次大调用,或者把循环逻辑整体下沉到 JS 端执行。
序列化也是常见的报错来源。互操作的返回值走 JSON 序列化,如果你在 C# 端期望 InvokeAsync<int>,而 JS 返回的是 undefined 或 null,会抛出 JSON 反序列化异常。返回值类型要与 JS 实际返回严格匹配,拿不准时可以先接收成 JsonElement 再手动解析。此外,ElementReference 可以作为参数直接传给 JS,用来在 JS 端操作某个 DOM 元素,比传字符串 id 更安全,因为 id 可能重复,而引用是 Blazor 保证的。
最后提醒一点:从 JS 返回给 C# 的复杂对象不要持有 DOM 元素或函数成员,序列化时它们会丢失或报错。跨边界的对象应该设计成纯数据的结构,这样序列化行为才可控。理解了这些规则之后,Blazor 与 JS 的混合开发就能做到扬长避短,既享受 C# 的工程化能力,又保留 JS 生态的丰富资源。
BlazorJS InteropC#调用JavaScript修改时间:2026-09-07 09:08:51