关于 use-js-interop
该技能为 Blazor 开发者提供 JavaScript 互操作的实现规范,涵盖模块生命周期、调用时机、批处理和回调安全。重点是使用组件旁置模块与强类型封装,减少边界调用问题。
主要功能
- 01使用旁置 .razor.js 模块
- 02在渲染后或事件处理中调用 JS
- 03合并相关互操作调用
- 04用强类型类封装模块生命周期
- 05安全处理回调、断连与异步释放
适用场景
- 01为 Blazor 组件接入浏览器端 JavaScript
- 02减少服务器端 SignalR 互操作往返
- 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 |