use-js-interop

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

JS Interop in Blazor

Blazor中的JS互操作

1. Collocated JS Modules

1. 共存JS模块

Always use collocated
.razor.js
files with
export
— never global
window.*
functions or
<script>
tags.
javascript
// ChartPanel.razor.js — placed next to ChartPanel.razor
export function initialize(canvas, dotNetRef) { /* ... */ }
export function updateData(points) { /* ... */ }
export function dispose() { /* ... */ }
Import paths: same project =
"./Components/ChartPanel.razor.js"
, RCL =
"./_content/{AssemblyName}/..."
.
始终使用带有
export
的共存
.razor.js
文件——绝不使用全局
window.*
函数或
<script>
标签。
javascript
// ChartPanel.razor.js — 与ChartPanel.razor放在同一目录下
export function initialize(canvas, dotNetRef) { /* ... */ }
export function updateData(points) { /* ... */ }
export function dispose() { /* ... */ }
导入路径:同一项目 =
"./Components/ChartPanel.razor.js"
,RCL =
"./_content/{AssemblyName}/..."

2. Lifecycle Timing

2. 生命周期时机

All JS interop must happen in
OnAfterRenderAsync
or event handlers
— never in
OnInitialized
,
OnParametersSet
, or constructors. JS is not available during server prerendering.
Use a typed interop wrapper (see Section 4) — never call
InvokeAsync
/
InvokeVoidAsync
with raw string literals:
csharp
private ChartInterop? _chart;

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        _chart = new ChartInterop(JS);
        await _chart.InitializeAsync(_canvasRef);
    }
}
Parameter changes: set a flag in
OnParametersSet
, apply in
OnAfterRenderAsync
:
csharp
private bool _dataChanged;

protected override void OnParametersSet() => _dataChanged = true;

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender) { /* init */ }
    else if (_dataChanged && _chart is not null)
    {
        _dataChanged = false;
        await _chart.UpdateDataAsync(DataPoints);
    }
}
所有JS互操作必须在
OnAfterRenderAsync
或事件处理程序中执行
——绝不能在
OnInitialized
OnParametersSet
或构造函数中执行。服务器预渲染期间JS不可用。
使用类型化互操作包装器(见第4节)——绝不使用原始字符串字面量调用
InvokeAsync
/
InvokeVoidAsync
csharp
private ChartInterop? _chart;

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        _chart = new ChartInterop(JS);
        await _chart.InitializeAsync(_canvasRef);
    }
}
参数变更:在
OnParametersSet
中设置标记,在
OnAfterRenderAsync
中应用:
csharp
private bool _dataChanged;

protected override void OnParametersSet() => _dataChanged = true;

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender) { /* 初始化 */ }
    else if (_dataChanged && _chart is not null)
    {
        _dataChanged = false;
        await _chart.UpdateDataAsync(DataPoints);
    }
}

3. Batch Related Operations

3. 批量相关操作

Each JS interop call crosses the .NET-to-JS boundary (and in Blazor Server, the SignalR circuit). Batching applies in both directions — .NET→JS and JS→.NET.
每次JS互操作调用都会跨越.NET到JS的边界(在Blazor Server中则是SignalR连接)。批量处理适用于双向——.NET→JS和JS→.NET。

.NET → JS: merge consecutive calls

.NET → JS:合并连续调用

If the C# side makes two or more JS calls in a row, combine them into one JS function:
csharp
// ❌ Two round-trips — theme and locale are always applied together
await _module.InvokeVoidAsync("applyTheme", theme);
await _module.InvokeVoidAsync("applyLocale", locale);

// ❌ Result of one call feeds into another — both can stay in JS
var token = await _module.InvokeAsync<string>("createAccessToken");
await _module.InvokeVoidAsync("storeToken", token);
javascript
// ✅ One call applies both — no data dependency, no reason for two trips
export function applyPreferences(theme, locale) {
    document.documentElement.dataset.theme = theme;
    document.documentElement.lang = locale;
}

// ✅ Chain stays in JS — the token never needs to cross the boundary
export function createAndStoreToken() {
    const token = crypto.randomUUID();
    sessionStorage.setItem('access-token', token);
    return token;
}
如果C#端连续进行两次或更多JS调用,将它们合并为一个JS函数:
csharp
// ❌ 两次往返——主题和区域设置总是一起应用
await _module.InvokeVoidAsync("applyTheme", theme);
await _module.InvokeVoidAsync("applyLocale", locale);

// ❌ 一次调用的结果作为另一次的输入——可全部在JS中完成
var token = await _module.InvokeAsync<string>("createAccessToken");
await _module.InvokeVoidAsync("storeToken", token);
javascript
// ✅ 一次调用完成两者——无数据依赖,无需两次往返
export function applyPreferences(theme, locale) {
    document.documentElement.dataset.theme = theme;
    document.documentElement.lang = locale;
}

// ✅ 链式操作在JS中完成——token无需跨边界
export function createAndStoreToken() {
    const token = crypto.randomUUID();
    sessionStorage.setItem('access-token', token);
    return token;
}

JS → .NET: batch callbacks

JS → .NET:批量回调

When JS needs to send multiple pieces of data back to .NET, send them in a single
invokeMethodAsync
call rather than making separate callbacks:
javascript
// ❌ Two .NET round-trips from JS
await dotNetRef.invokeMethodAsync(ON_VOLUME_CHANGED, volume);
await dotNetRef.invokeMethodAsync(ON_PLAYBACK_CHANGED, isPlaying);

// ✅ One callback with all data
await dotNetRef.invokeMethodAsync(ON_PLAYER_STATE_CHANGED, { volume, isPlaying });
Rule: if two interop calls always happen together from either side, merge them into one function.
当JS需要向.NET发送多份数据时,通过单次
invokeMethodAsync
调用发送,而非多次单独回调:
javascript
// ❌ 从JS发起两次.NET往返
await dotNetRef.invokeMethodAsync(ON_VOLUME_CHANGED, volume);
await dotNetRef.invokeMethodAsync(ON_PLAYBACK_CHANGED, isPlaying);

// ✅ 一次回调发送所有数据
await dotNetRef.invokeMethodAsync(ON_PLAYER_STATE_CHANGED, { volume, isPlaying });
规则:如果从任意一侧发起的两次互操作调用总是同时发生,将它们合并为一个函数。

4. Typed Interop Wrapper

4. 类型化互操作包装器

Encapsulate interop for a feature in a plain class that owns the module lifecycle:
csharp
public sealed class ChartInterop : IAsyncDisposable
{
    internal const string ModulePath = "./Components/ChartPanel.razor.js";
    internal const string InitMethod = "initialize";
    internal const string UpdateMethod = "updateData";
    internal const string DisposeMethod = "dispose";

    private readonly IJSRuntime _js;
    private IJSObjectReference? _module;

    public ChartInterop(IJSRuntime js) => _js = js;

    private async ValueTask<IJSObjectReference> GetModuleAsync()
        => _module ??= await _js.InvokeAsync<IJSObjectReference>("import", ModulePath);

    public async ValueTask InitializeAsync(ElementReference canvas)
    {
        var module = await GetModuleAsync();
        await module.InvokeVoidAsync(InitMethod, canvas);
    }

    public async ValueTask UpdateDataAsync(IReadOnlyList<DataPoint> points)
    {
        var module = await GetModuleAsync();
        await module.InvokeVoidAsync(UpdateMethod, points);
    }

    public async ValueTask DisposeAsync()
    {
        try
        {
            if (_module is not null)
            {
                await _module.InvokeVoidAsync(DisposeMethod);
                await _module.DisposeAsync();
            }
        }
        catch (JSDisconnectedException) { }
    }
}
The component creates and uses the wrapper with no magic strings:
razor
@inject IJSRuntime JS
@implements IAsyncDisposable

<canvas @ref="_canvasRef" width="600" height="400"></canvas>

@code {
    private ElementReference _canvasRef;
    private ChartInterop? _chart;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            _chart = new ChartInterop(JS);
            await _chart.InitializeAsync(_canvasRef);
        }
    }

    async ValueTask IAsyncDisposable.DisposeAsync()
    {
        if (_chart is not null)
            await _chart.DisposeAsync();
    }
}
Prefer a concrete class over interface + implementation for interop wrappers. For unit testing, substitute
IJSRuntime
directly (it is already an interface).
将某一功能的互操作封装在一个普通类中,由该类管理模块生命周期:
csharp
public sealed class ChartInterop : IAsyncDisposable
{
    internal const string ModulePath = "./Components/ChartPanel.razor.js";
    internal const string InitMethod = "initialize";
    internal const string UpdateMethod = "updateData";
    internal const string DisposeMethod = "dispose";

    private readonly IJSRuntime _js;
    private IJSObjectReference? _module;

    public ChartInterop(IJSRuntime js) => _js = js;

    private async ValueTask<IJSObjectReference> GetModuleAsync()
        => _module ??= await _js.InvokeAsync<IJSObjectReference>("import", ModulePath);

    public async ValueTask InitializeAsync(ElementReference canvas)
    {
        var module = await GetModuleAsync();
        await module.InvokeVoidAsync(InitMethod, canvas);
    }

    public async ValueTask UpdateDataAsync(IReadOnlyList<DataPoint> points)
    {
        var module = await GetModuleAsync();
        await module.InvokeVoidAsync(UpdateMethod, points);
    }

    public async ValueTask DisposeAsync()
    {
        try
        {
            if (_module is not null)
            {
                await _module.InvokeVoidAsync(DisposeMethod);
                await _module.DisposeAsync();
            }
        }
        catch (JSDisconnectedException) { }
    }
}
组件创建并使用该包装器,无需魔法字符串:
razor
@inject IJSRuntime JS
@implements IAsyncDisposable

<canvas @ref="_canvasRef" width="600" height="400"></canvas>

@code {
    private ElementReference _canvasRef;
    private ChartInterop? _chart;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            _chart = new ChartInterop(JS);
            await _chart.InitializeAsync(_canvasRef);
        }
    }

    async ValueTask IAsyncDisposable.DisposeAsync()
    {
        if (_chart is not null)
            await _chart.DisposeAsync();
    }
}
互操作包装器优先使用具体类而非接口+实现。对于单元测试,直接替换
IJSRuntime
即可(它已经是一个接口)。

5. DotNetObjectReference for JS-to-.NET Callbacks

5. 用于JS到.NET回调的DotNetObjectReference

csharp
_dotNetRef = DotNetObjectReference.Create(this);
await _module.InvokeVoidAsync("initialize", _dotNetRef);
On the JS side, wrap the
dotNetRef
in a class. Use
async
/
await
with
try/catch
(not
.catch()
) to guard against circuit loss. Define .NET method name constants at the top:
javascript
const ON_CLIPBOARD_CHANGED = 'OnClipboardChanged';

class ClipboardMonitor {
    #dotNetRef;
    #abortController;

    constructor(dotNetRef) {
        this.#dotNetRef = dotNetRef;
        this.#abortController = new AbortController();
    }

    start() {
        document.addEventListener('copy', async () => {
            try {
                const text = await navigator.clipboard.readText();
                await this.#dotNetRef.invokeMethodAsync(ON_CLIPBOARD_CHANGED, text);
            } catch { /* circuit disconnected or clipboard denied */ }
        }, { signal: this.#abortController.signal });
    }

    dispose() {
        this.#abortController.abort();
    }
}

let monitor;
export function initialize(dotNetRef) {
    monitor = new ClipboardMonitor(dotNetRef);
    monitor.start();
}

export function dispose() {
    monitor?.dispose();
}
Rules:
  • [JSInvokable]
    methods must be
    public
    — private/internal silently fails at runtime
  • Wrap
    StateHasChanged
    in
    InvokeAsync
    inside
    [JSInvokable]
    callbacks:
    csharp
    [JSInvokable]
    public async Task OnClipboardChanged(string text)
    {
        await InvokeAsync(() => { _lastClipboard = text; StateHasChanged(); });
    }
  • Always
    try/catch
    around
    invokeMethodAsync
    in JS — circuit loss throws
  • Use
    const
    for .NET method name strings in JS — prevents typo bugs that silently fail
  • Dispose
    DotNetObjectReference
    in
    DisposeAsync
csharp
_dotNetRef = DotNetObjectReference.Create(this);
await _module.InvokeVoidAsync("initialize", _dotNetRef);
在JS端,将
dotNetRef
包装在一个类中。使用
async
/
await
配合
try/catch
(而非
.catch()
)防止连接丢失。在顶部定义.NET方法名称常量:
javascript
const ON_CLIPBOARD_CHANGED = 'OnClipboardChanged';

class ClipboardMonitor {
    #dotNetRef;
    #abortController;

    constructor(dotNetRef) {
        this.#dotNetRef = dotNetRef;
        this.#abortController = new AbortController();
    }

    start() {
        document.addEventListener('copy', async () => {
            try {
                const text = await navigator.clipboard.readText();
                await this.#dotNetRef.invokeMethodAsync(ON_CLIPBOARD_CHANGED, text);
            } catch { /* 连接断开或剪贴板权限被拒绝 */ }
        }, { signal: this.#abortController.signal });
    }

    dispose() {
        this.#abortController.abort();
    }
}

let monitor;
export function initialize(dotNetRef) {
    monitor = new ClipboardMonitor(dotNetRef);
    monitor.start();
}

export function dispose() {
    monitor?.dispose();
}
规则:
  • [JSInvokable]
    方法必须为
    public
    ——私有/内部方法在运行时会静默失败
  • [JSInvokable]
    回调中,将
    StateHasChanged
    包装在
    InvokeAsync
    内:
    csharp
    [JSInvokable]
    public async Task OnClipboardChanged(string text)
    {
        await InvokeAsync(() => { _lastClipboard = text; StateHasChanged(); });
    }
  • 在JS中,始终在
    invokeMethodAsync
    外围使用
    try/catch
    ——连接丢失会抛出异常
  • 在JS中使用
    const
    定义.NET方法名称字符串——防止导致静默失败的拼写错误
  • DisposeAsync
    中释放
    DotNetObjectReference

6. Disposal and Server Safety

6. 资源释放与服务器端安全性

Always implement
IAsyncDisposable
. Call JS cleanup first, then dispose references. Catch
JSDisconnectedException
for Blazor Server circuit loss:
csharp
public async ValueTask DisposeAsync()
{
    try
    {
        if (_module is not null)
        {
            await _module.InvokeVoidAsync("dispose");
            await _module.DisposeAsync();
        }
    }
    catch (JSDisconnectedException) { }

    _dotNetRef?.Dispose();
}
Never use sync
IDisposable
for JS interop cleanup —
InvokeVoidAsync
returns
ValueTask
and must be awaited.
始终实现
IAsyncDisposable
。先调用JS清理逻辑,再释放引用。针对Blazor Server的连接丢失捕获
JSDisconnectedException
csharp
public async ValueTask DisposeAsync()
{
    try
    {
        if (_module is not null)
        {
            await _module.InvokeVoidAsync("dispose");
            await _module.DisposeAsync();
        }
    }
    catch (JSDisconnectedException) { }

    _dotNetRef?.Dispose();
}
绝不要使用同步
IDisposable
进行JS互操作清理——
InvokeVoidAsync
返回
ValueTask
,必须等待其完成。

7. ElementReference

7. ElementReference

Pass DOM elements via
@ref
, not string IDs:
razor
<canvas @ref="_canvasRef" width="600" height="400"></canvas>
csharp
await _chart.InitializeAsync(_canvasRef);
通过
@ref
传递DOM元素,而非字符串ID:
razor
<canvas @ref="_canvasRef" width="600" height="400"></canvas>
csharp
await _chart.InitializeAsync(_canvasRef);

Checklist

检查清单

  • JS is in collocated
    .razor.js
    with
    export
    — no
    window.*
    globals
  • All interop in
    OnAfterRenderAsync
    or event handlers — never during prerender
  • IAsyncDisposable
    catches
    JSDisconnectedException
  • DotNetObjectReference
    disposed in
    DisposeAsync
    ; JS side has
    try/catch
    around
    invokeMethodAsync
  • [JSInvokable]
    methods are
    public
    and use
    await InvokeAsync(StateHasChanged)
  • InvokeVoidAsync
    used when no return value is needed
  • ElementReference
    instead of string IDs
  • Related operations batched into single interop calls (both .NET→JS and JS→.NET)
  • JS代码位于带有
    export
    的共存
    .razor.js
    文件中——无
    window.*
    全局变量
  • 所有互操作在
    OnAfterRenderAsync
    或事件处理程序中执行——绝不在预渲染期间执行
  • IAsyncDisposable
    捕获
    JSDisconnectedException
  • DotNetObjectReference
    DisposeAsync
    中释放;JS端在
    invokeMethodAsync
    外围使用
    try/catch
  • [JSInvokable]
    方法为
    public
    ,并使用
    await InvokeAsync(StateHasChanged)
  • 无需返回值时使用
    InvokeVoidAsync
  • 使用
    ElementReference
    而非字符串ID
  • 相关操作批量处理为单次互操作调用(.NET→JS和JS→.NET双向)

Common Mistakes Checklist

常见错误检查清单

MistakeFix
Using JS for something achievable with CSSUse CSS custom properties,
data-
attributes, pseudo-classes
Many fine-grained interop callsBatch into coarse functions — both .NET→JS and JS→.NET
Component imports JS module directlyEncapsulate in a strongly typed interop class
Magic strings for method names / module pathsDefine
internal const
fields in the interop class
Interface + implementation for interop wrapperUse a plain class; mock
IJSRuntime
for tests instead
JS calls in
OnInitializedAsync
Move to
OnAfterRenderAsync(firstRender)
InvokeAsync<object>
for void calls
Use
InvokeVoidAsync
IDisposable
with fire-and-forget JS
Use
IAsyncDisposable
with
await
Global
window.*
JS functions
Use collocated
.razor.js
with
export
String element IDs passed to JSUse
ElementReference
with
@ref
[JSInvokable]
on private method
Must be
public
— silently fails otherwise
DotNetObjectReference
not disposed
Dispose in
DisposeAsync
— causes memory leak
StateHasChanged()
without
InvokeAsync
Wrap in
await InvokeAsync(() => { StateHasChanged(); })
JS
invokeMethodAsync
without error handling
Wrap in
try/catch
— circuit loss throws
Bare
dotNetRef
in JS event handlers
Wrap in a class with
#dotNetRef
private field
Magic strings in JS
invokeMethodAsync
calls
Use
const
at module top — typos silently fail at runtime
JS calls in
OnParametersSetAsync
Track changes, apply in
OnAfterRenderAsync
with guard
No null check before calling moduleCheck
module is not null
before use
错误修复方案
使用JS实现可通过CSS完成的功能使用CSS自定义属性、
data-
属性、伪类
大量细粒度的互操作调用批量处理为粗粒度函数——.NET→JS和JS→.NET双向
组件直接导入JS模块封装到强类型互操作类中
使用魔法字符串作为方法名/模块路径在互操作类中定义
internal const
字段
互操作包装器使用接口+实现使用普通类;测试时直接模拟
IJSRuntime
OnInitializedAsync
中调用JS
移至
OnAfterRenderAsync(firstRender)
无返回值调用使用
InvokeAsync<object>
使用
InvokeVoidAsync
使用
IDisposable
配合无需等待的JS操作
使用
IAsyncDisposable
并等待操作完成
使用全局
window.*
JS函数
使用带有
export
的共存
.razor.js
文件
将字符串元素ID传递给JS使用带
@ref
ElementReference
[JSInvokable]
标记私有方法
必须为
public
——否则会静默失败
DotNetObjectReference
未释放
DisposeAsync
中释放——否则会导致内存泄漏
未使用
InvokeAsync
包裹
StateHasChanged()
包裹在
await InvokeAsync(() => { StateHasChanged(); })
JS中
invokeMethodAsync
未做错误处理
包裹在
try/catch
中——连接丢失会抛出异常
JS事件处理程序中直接使用
dotNetRef
使用带有
#dotNetRef
私有字段的类进行包装
JS中
invokeMethodAsync
调用使用魔法字符串
在模块顶部使用
const
定义——拼写错误会导致运行时静默失败
OnParametersSetAsync
中调用JS
跟踪变更,在
OnAfterRenderAsync
中添加判断后应用
调用模块前未做空值检查使用前检查
module is not null