mcpField
U
use-js-interop

use-js-interop

github.com/dotnet开发5.8k3.1k 次安装1 天前更新
代码仓库

关于 use-js-interop

该技能为 Blazor 开发者提供 JavaScript 互操作的实现规范,涵盖模块生命周期、调用时机、批处理和回调安全。重点是使用组件旁置模块与强类型封装,减少边界调用问题。

主要功能

  1. 01使用旁置 .razor.js 模块
  2. 02在渲染后或事件处理中调用 JS
  3. 03合并相关互操作调用
  4. 04用强类型类封装模块生命周期
  5. 05安全处理回调、断连与异步释放

适用场景

  1. 01为 Blazor 组件接入浏览器端 JavaScript
  2. 02减少服务器端 SignalR 互操作往返
  3. 03处理 JS 回调、组件状态更新与资源清理

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dotnet/skills --skill use-js-interop

安装后在 Claude Code 中输入/use-js-interop确认已加载。

详细介绍

Blazor 中的 JS 互操作

1. 并置的 JS 模块

始终使用带有 export 的并置 .razor.js 文件 — 绝不使用全局 window.* 函数或 <script> 标签。

// ChartPanel.razor.js — placed next to ChartPanel.razor
export function initialize(canvas, dotNetRef) { /* ... */ }
export function updateData(points) { /* ... */ }
export function dispose() { /* ... */ }

导入路径:同一项目 = "./Components/ChartPanel.razor.js",RCL = "./_content/{AssemblyName}/..."。

2. 生命周期时机

所有 JS 互操作都必须在 OnAfterRenderAsync 或事件处理程序中进行 — 绝不能在 OnInitialized、OnParametersSet 或构造函数中进行。服务器预渲染期间无法使用 JS。

使用强类型互操作包装器(见第 4 节)— 绝不要使用原始字符串字面量调用 InvokeAsync/InvokeVoidAsync:

private ChartInterop? _chart;

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

参数变更:在 OnParametersSet 中设置标志,然后在 OnAfterRenderAsync 中应用:

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);
    }
}

3. 批处理相关操作

每次 JS 互操作调用都会跨越 .NET 到 JS 的边界(在 Blazor Server 中还会经过 SignalR 电路)。两个方向都适用批处理 — .NET→JS 和 JS→.NET。

.NET → JS:合并连续调用

如果 C# 端连续进行两次或更多 JS 调用,请将它们合并到一个 JS 函数中:

// ❌ 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);
// ✅ 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;
}
JS → .NET:批处理回调

当 JS 需要将多条数据发送回 .NET 时,应在一次 invokeMethodAsync 调用中发送,而不是分别进行多次回调:

// ❌ 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 });

规则:如果任一端的两次互操作调用总是同时发生,就将它们合并到一个函数中。

4. 强类型互操作包装器

使用普通类封装某项功能的互操作,并由该类管理模块生命周期:

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) { }
    }
}

组件创建并使用该包装器,不需要任何魔法字符串:

@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. 用于 JS 到 .NET 回调的 DotNetObjectReference

_dotNetRef = DotNetObjectReference.Create(this);
await _module.InvokeVoidAsync("initialize", _dotNetRef);

在 JS 端,将 dotNetRef 封装在一个类中。使用 async/await 和 try/catch(而不是 .catch())来防范电路中断。在顶部定义 .NET 方法名称常量:

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();
}

规则:

  • [JSInvokable] 方法必须为 public — private/internal 会在运行时静默失败
  • 在 [JSInvokable] 回调中,用 InvokeAsync 包装 StateHasChanged:
    [JSInvokable]
    public async Task OnClipboardChanged(string text)
    {
        await InvokeAsync(() => { _lastClipboard = text; StateHasChanged(); });
    }
    
  • 在 JS 中调用 invokeMethodAsync 时,始终使用 try/catch — 电路中断会引发异常
  • 在 JS 中使用 const 定义 .NET 方法名称字符串 — 可避免拼写错误导致的静默失败
  • 在 DisposeAsync 中释放 DotNetObjectReference

6. 释放与服务器安全

始终实现 IAsyncDisposable。先调用 JS 清理,然后释放引用。捕获 JSDisconnectedException,以处理 Blazor Server 电路中断:

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

通过 @ref 传递 DOM 元素,而不是字符串 ID:

<canvas @ref="_canvasRef" width="600" height="400"></canvas>
await _chart.InitializeAsync(_canvasRef);

检查清单

  • JS 位于带有 export 的并置 .razor.js 文件中 — 不使用 window.* 全局函数
  • 所有互操作都在 OnAfterRenderAsync 或事件处理程序中进行 — 绝不在预渲染期间进行
  • IAsyncDisposable 会捕获 JSDisconnectedException
  • 在 DisposeAsync 中释放 DotNetObjectReference;JS 端对 invokeMethodAsync 使用 try/catch
  • [JSInvokable] 方法为 public,并使用 await InvokeAsync(StateHasChanged)
  • 无需返回值时使用 InvokeVoidAsync
  • 使用 ElementReference,而不是字符串 ID
  • 将相关操作批处理为单次互操作调用(包括 .NET→JS 和 JS→.NET)

常见错误检查清单

错误修复方法
对于 CSS 能实现的效果却使用 JS使用 CSS 自定义属性、data- 属性和伪类
大量细粒度互操作调用合并为粗粒度函数 — .NET→JS 和 JS→.NET 均如此
组件直接导入 JS 模块封装在强类型互操作类中
方法名/模块路径使用魔法字符串在互操作类中定义 internal const 字段
为互操作包装器创建接口和实现使用普通类;改为在测试中模拟 IJSRuntime
在 OnInitializedAsync 中调用 JS移至 OnAfterRenderAsync(firstRender)
对无返回值调用使用 InvokeAsync<object>使用 InvokeVoidAsync
使用 IDisposable 处理即发即弃的 JS 调用使用 IAsyncDisposable 并配合 await
全局 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 中通过守卫应用
调用模块前未检查是否为 null使用前检查 module is not null

评论

支持 Markdown