use-js-interop
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseJS Interop in Blazor
Blazor中的JS互操作
1. Collocated JS Modules
1. 共存JS模块
Always use collocated files with — never global functions or tags.
.razor.jsexportwindow.*<script>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 = , RCL = .
"./Components/ChartPanel.razor.js""./_content/{AssemblyName}/..."始终使用带有的共存文件——绝不使用全局函数或标签。
export.razor.jswindow.*<script>javascript
// ChartPanel.razor.js — 与ChartPanel.razor放在同一目录下
export function initialize(canvas, dotNetRef) { /* ... */ }
export function updateData(points) { /* ... */ }
export function dispose() { /* ... */ }导入路径:同一项目 = ,RCL = 。
"./Components/ChartPanel.razor.js""./_content/{AssemblyName}/..."2. Lifecycle Timing
2. 生命周期时机
All JS interop must happen in or event handlers — never in , , or constructors. JS is not available during server prerendering.
OnAfterRenderAsyncOnInitializedOnParametersSetUse a typed interop wrapper (see Section 4) — never call / with raw string literals:
InvokeAsyncInvokeVoidAsynccsharp
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 , apply in :
OnParametersSetOnAfterRenderAsynccsharp
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互操作必须在或事件处理程序中执行——绝不能在、或构造函数中执行。服务器预渲染期间JS不可用。
OnAfterRenderAsyncOnInitializedOnParametersSet使用类型化互操作包装器(见第4节)——绝不使用原始字符串字面量调用/:
InvokeAsyncInvokeVoidAsynccsharp
private ChartInterop? _chart;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_chart = new ChartInterop(JS);
await _chart.InitializeAsync(_canvasRef);
}
}参数变更:在中设置标记,在中应用:
OnParametersSetOnAfterRenderAsynccsharp
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 call rather than making separate callbacks:
invokeMethodAsyncjavascript
// ❌ 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发送多份数据时,通过单次调用发送,而非多次单独回调:
invokeMethodAsyncjavascript
// ❌ 从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 directly (it is already an interface).
IJSRuntime将某一功能的互操作封装在一个普通类中,由该类管理模块生命周期:
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();
}
}互操作包装器优先使用具体类而非接口+实现。对于单元测试,直接替换即可(它已经是一个接口)。
IJSRuntime5. 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 in a class. Use / with (not ) to guard against circuit loss. Define .NET method name constants at the top:
dotNetRefasyncawaittry/catch.catch()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:
- methods must be
[JSInvokable]— private/internal silently fails at runtimepublic - Wrap in
StateHasChangedinsideInvokeAsynccallbacks:[JSInvokable]csharp[JSInvokable] public async Task OnClipboardChanged(string text) { await InvokeAsync(() => { _lastClipboard = text; StateHasChanged(); }); } - Always around
try/catchin JS — circuit loss throwsinvokeMethodAsync - Use for .NET method name strings in JS — prevents typo bugs that silently fail
const - Dispose in
DotNetObjectReferenceDisposeAsync
csharp
_dotNetRef = DotNetObjectReference.Create(this);
await _module.InvokeVoidAsync("initialize", _dotNetRef);在JS端,将包装在一个类中。使用/配合(而非)防止连接丢失。在顶部定义.NET方法名称常量:
dotNetRefasyncawaittry/catch.catch()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内:InvokeAsynccsharp[JSInvokable] public async Task OnClipboardChanged(string text) { await InvokeAsync(() => { _lastClipboard = text; StateHasChanged(); }); } - 在JS中,始终在外围使用
invokeMethodAsync——连接丢失会抛出异常try/catch - 在JS中使用定义.NET方法名称字符串——防止导致静默失败的拼写错误
const - 在中释放
DisposeAsyncDotNetObjectReference
6. Disposal and Server Safety
6. 资源释放与服务器端安全性
Always implement . Call JS cleanup first, then dispose references. Catch for Blazor Server circuit loss:
IAsyncDisposableJSDisconnectedExceptioncsharp
public async ValueTask DisposeAsync()
{
try
{
if (_module is not null)
{
await _module.InvokeVoidAsync("dispose");
await _module.DisposeAsync();
}
}
catch (JSDisconnectedException) { }
_dotNetRef?.Dispose();
}Never use sync for JS interop cleanup — returns and must be awaited.
IDisposableInvokeVoidAsyncValueTask始终实现。先调用JS清理逻辑,再释放引用。针对Blazor Server的连接丢失捕获:
IAsyncDisposableJSDisconnectedExceptioncsharp
public async ValueTask DisposeAsync()
{
try
{
if (_module is not null)
{
await _module.InvokeVoidAsync("dispose");
await _module.DisposeAsync();
}
}
catch (JSDisconnectedException) { }
_dotNetRef?.Dispose();
}绝不要使用同步进行JS互操作清理——返回,必须等待其完成。
IDisposableInvokeVoidAsyncValueTask7. ElementReference
7. ElementReference
Pass DOM elements via , not string IDs:
@refrazor
<canvas @ref="_canvasRef" width="600" height="400"></canvas>csharp
await _chart.InitializeAsync(_canvasRef);通过传递DOM元素,而非字符串ID:
@refrazor
<canvas @ref="_canvasRef" width="600" height="400"></canvas>csharp
await _chart.InitializeAsync(_canvasRef);Checklist
检查清单
- JS is in collocated with
.razor.js— noexportglobalswindow.* - All interop in or event handlers — never during prerender
OnAfterRenderAsync - catches
IAsyncDisposableJSDisconnectedException - disposed in
DotNetObjectReference; JS side hasDisposeAsyncaroundtry/catchinvokeMethodAsync - methods are
[JSInvokable]and usepublicawait InvokeAsync(StateHasChanged) - used when no return value is needed
InvokeVoidAsync - instead of string IDs
ElementReference - Related operations batched into single interop calls (both .NET→JS and JS→.NET)
- JS代码位于带有的共存
export文件中——无.razor.js全局变量window.* - 所有互操作在或事件处理程序中执行——绝不在预渲染期间执行
OnAfterRenderAsync - 捕获
IAsyncDisposableJSDisconnectedException - 在
DotNetObjectReference中释放;JS端在DisposeAsync外围使用invokeMethodAsynctry/catch - 方法为
[JSInvokable],并使用publicawait InvokeAsync(StateHasChanged) - 无需返回值时使用
InvokeVoidAsync - 使用而非字符串ID
ElementReference - 相关操作批量处理为单次互操作调用(.NET→JS和JS→.NET双向)
Common Mistakes Checklist
常见错误检查清单
| Mistake | Fix |
|---|---|
| Using JS for something achievable with CSS | Use CSS custom properties, |
| Many fine-grained interop calls | Batch into coarse functions — both .NET→JS and JS→.NET |
| Component imports JS module directly | Encapsulate in a strongly typed interop class |
| Magic strings for method names / module paths | Define |
| Interface + implementation for interop wrapper | Use a plain class; mock |
JS calls in | Move to |
| Use |
| Use |
Global | Use collocated |
| String element IDs passed to JS | Use |
| Must be |
| Dispose in |
| Wrap in |
JS | Wrap in |
Bare | Wrap in a class with |
Magic strings in JS | Use |
JS calls in | Track changes, apply in |
| No null check before calling module | Check |
| 错误 | 修复方案 |
|---|---|
| 使用JS实现可通过CSS完成的功能 | 使用CSS自定义属性、 |
| 大量细粒度的互操作调用 | 批量处理为粗粒度函数——.NET→JS和JS→.NET双向 |
| 组件直接导入JS模块 | 封装到强类型互操作类中 |
| 使用魔法字符串作为方法名/模块路径 | 在互操作类中定义 |
| 互操作包装器使用接口+实现 | 使用普通类;测试时直接模拟 |
在 | 移至 |
无返回值调用使用 | 使用 |
使用 | 使用 |
使用全局 | 使用带有 |
| 将字符串元素ID传递给JS | 使用带 |
| 必须为 |
| 在 |
未使用 | 包裹在 |
JS中 | 包裹在 |
JS事件处理程序中直接使用 | 使用带有 |
JS中 | 在模块顶部使用 |
在 | 跟踪变更,在 |
| 调用模块前未做空值检查 | 使用前检查 |