Mert Özen Her Satırda Daha İleri
Frontend Blazor — #10

Blazor'da JavaScript Interop: IJSRuntime, Collocated JS Module, DotNetObjectReference ve .NET 10 Yenilikleri

Mert Özen 15 Kas 2026 12 dk 7 görüntülenme
Blazor'da JavaScript Interop: IJSRuntime, Collocated JS Module, DotNetObjectReference ve .NET 10 Yenilikleri

Blazor'da JS, component'in yanındaki .razor.js module'ü olarak import edilip IJSObjectReference ile çağrılır; JS'ten .NET'e dönüş DotNetObjectReference ve [JSInvokable] ile yapılır, prerendering'de JS olmadığı için çağrılar OnAfterRenderAsync'te başlar. Bookstore panelinde Chart.js ile satış grafiğini .NET 10'daki InvokeConstructorAsync, GetValueAsync ve SetValueAsync ile kuruyor, ISBN kopyalama düğmesini, JSDisconnectedException'ı ve 32 KB mesaj sınırını gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.404, ASP.NET Core 10.0.15) ve Chart.js 4.6.1 ile derlenip denenmiştir; testlerde bUnit 2.15.2 kullanılmış, tarayıcı davranışı Playwright 1.65'in headless Chromium'uyla gözlenmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) yönetim paneline bu yazıda bir satış grafiği ve kitap sayfasına "ISBN'i (International Standard Book Number; kitabın uluslararası standart numarası) kopyala" düğmesi ekliyoruz. Grafik bir JavaScript library'si olan Chart.js ile çiziliyor, sayfa InteractiveServer ve prerendering açık. Chart.js'in sürümünü OnInitializedAsync'te JS'ten okumaya çalışırsan ne olur? İkinci soru: kopyalama düğmesinin JS dosyası sayfa açılırken mi, ilk tıklamada mı iner? Üçüncüsü: tarayıcıdaki JS, sunucudaki bir .NET method'una 40.000 karakterlik bir string gönderirse ne olur?

JS'i Nereye Koymalı: Collocated Module ve import

Blazor'da JavaScript'i component'in yanına, aynı adla .razor.js uzantılı bir ES (ECMAScript; JavaScript'in standardı) module'ü olarak koyabiliyorsun. Kopyalama düğmesi iki dosya:

// Clipboard API güvenli bir context (HTTPS ya da localhost) ve kullanıcının iznini ister.
export async function copyText(text) {
  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch {
    return false;
  }
}
@implements IAsyncDisposable
@inject IJSRuntime JS

<button type="button" class="btn btn-sm btn-outline-secondary ms-2" disabled="@(!RendererInfo.IsInteractive)"
        @onclick="CopyAsync">@label</button>

@code {
    private IJSObjectReference? module;
    private string label = "Copy";

    [Parameter, EditorRequired]
    public string Isbn { get; set; } = "";

    private async Task CopyAsync()
    {
        // Module ilk tıklamada yüklenir; hiç kopyalama yapılmayan sayfalar onu indirmez.
        module ??= await JS.InvokeAsync<IJSObjectReference>("import", "./Components/Books/CopyIsbnButton.razor.js");
        label = await module.InvokeAsync<bool>("copyText", Isbn) ? "Copied" : "Copy failed";
    }

    public async ValueTask DisposeAsync()
    {
        try
        {
            if (module is not null)
            {
                await module.DisposeAsync();
            }
        }
        catch (JSDisconnectedException)
        {
            // Circuit gitti; tarayıcı module'ü zaten bıraktı.
        }
    }
}

JS.InvokeAsync<IJSObjectReference>("import", path) tarayıcıda dinamik bir import() çalıştırıyor ve module'e bir referans döndürüyor; module'ün export ettiği fonksiyonlar bu referans üzerinden InvokeAsync ya da InvokeVoidAsync ile çağrılıyor. Dönüş değeri JSON olarak taşınıyor; copyText'in true/false'u .NET'te bool oldu. Global window fonksiyonları yerine module kullanmanın faydası, kodun yalnız gerektiği yerde yüklenmesi ve isim çakışması olmaması. Playwright'ta clipboard izni verilmiş ve verilmemiş iki tarayıcıyla denedim:

clipboard permission granted:
   module requests before the click: 0
   button: Copied, module: /Components/Books/CopyIsbnButton.o2wp8bvs7c.razor.js
   clipboard: 9780141439549
clipboard permission not granted:
   module requests before the click: 0
   button: Copy failed, module: /Components/Books/CopyIsbnButton.o2wp8bvs7c.razor.js

İkinci sorunun cevabı: ilk tıklamada. Sayfa açılırken module isteği yok; module referansı ??= ile ilk kullanımda alınıyor ve sonra saklanıyor. Dosya adındaki o2wp8bvs7c bir fingerprint: App.razor'daki <ImportMap /> kodda yazdığımız ./Components/Books/CopyIsbnButton.razor.js adresini içerik hash'i taşıyan dosyaya eşliyor, böylece dosya uzun süre cache'lenebiliyor. İzin yoksa Clipboard API bir hata fırlatıyor; JS'te yakalayıp false dönüyoruz, kullanıcı "Copy failed" görüyor. Component'in testi bUnit'in JS interop taklidiyle tarayıcısız:

    private const string ModulePath = "./Components/Books/CopyIsbnButton.razor.js";

    public JsInteropTests() => Renderer.SetRendererInfo(new RendererInfo("Server", isInteractive: true));

    [Theory]
    [InlineData(true, "Copied")]
    [InlineData(false, "Copy failed")]
    public void The_button_shows_what_the_clipboard_call_returned(bool copied, string expected)
    {
        // bUnit'in JSInterop'u strict: component'in yaptığı her çağrı kurulmuş olmalı.
        BunitJSModuleInterop module = JSInterop.SetupModule(ModulePath);
        module.Setup<bool>("copyText", "9780141439549").SetResult(copied);
        IRenderedComponent<CopyIsbnButton> button = Render<CopyIsbnButton>(p => p.Add(b => b.Isbn, "9780141439549"));

        button.Find("button").Click();

        Assert.Equal(expected, button.Find("button").TextContent.Trim());
        Assert.Single(module.Invocations["copyText"]);
    }

bUnit varsayılan olarak strict modda; component'in yaptığı her JS çağrısının kurulmuş olması gerekiyor, kurulmayan çağrı testi düşürüyor. Bu, component'in beklemediğin bir JS çağrısı yapmasını da yakalıyor.

Prerendering ve OnAfterRenderAsync

Prerendering sırasında component sunucuda düz HTML üretiyor; ortada bir tarayıcı, yani çağrılacak bir JavaScript yok. Deneme için grafik component'inde Chart.js sürümünü OnInitializedAsync'te okudum:

System.InvalidOperationException: JavaScript interop calls cannot be issued at this time. This is because the component is being statically rendered. When prerendering is enabled, JavaScript interop calls can only be performed during the OnAfterRenderAsync lifecycle method.
   at Microsoft.AspNetCore.Components.Server.Circuits.RemoteJSRuntime.BeginInvokeJS(JSInvocationInfo& invocationInfo)

İlk sorunun cevabı: sayfa 500 döndü. Hata mesajı çözümü de söylüyor: JS çağrıları OnAfterRenderAsync'te, çünkü bu method prerendering'de hiç çağrılmıyor ve interaktif render'da element'ler DOM'a (Document Object Model; tarayıcının sayfayı tuttuğu ağaç yapısı) girdikten sonra çalışıyor. firstRender kontrolü de JS tarafındaki kurulumun her render'da tekrarlanmasını önlüyor. Kopyalama düğmesinde bu sorun yok, çünkü JS'e yalnız kullanıcı tıkladığında gidiyoruz; o anda component zaten interaktif.

.NET'ten JS'e: IJSObjectReference ve .NET 10 API'leri

Satış raporu sayfası kategori başına satılan adedi Chart.js ile bar grafiği olarak gösteriyor. Veri 09'daki IDbContextFactory ile Entity Framework Core'dan (EF Core; .NET'in veritabanı library'si) geliyor (SalesReport, iptal edilen siparişler hariç). Grafik component'i:

@implements IAsyncDisposable
@inject IJSRuntime JS

<p class="text-muted">Chart.js @chartVersion</p>
<canvas @ref="canvas" height="110"></canvas>

@code {
    private ElementReference canvas;
    private IJSObjectReference? module;
    private IJSObjectReference? chart;
    private DotNetObjectReference<SalesChart>? selfReference;
    private string? chartVersion;

    [Parameter, EditorRequired]
    public IReadOnlyList<CategorySales> Sales { get; set; } = [];

    [Parameter]
    public EventCallback<CategorySales> OnCategorySelected { get; set; }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender)
        {
            return;
        }

        // Prerendering sırasında JS yok; en erken nokta ilk interaktif render.
        module = await JS.InvokeAsync<IJSObjectReference>("import", "./Components/Reports/SalesChart.razor.js");
        chartVersion = await JS.GetValueAsync<string>("Chart.version");
        chart = await JS.InvokeConstructorAsync("Chart", canvas, new
        {
            type = "bar",
            data = new
            {
                labels = Sales.Select(s => s.Category),
                datasets = new[] { new { label = "Copies sold", data = Sales.Select(s => s.Copies) } }
            }
        });
        selfReference = DotNetObjectReference.Create(this);
        await module.InvokeVoidAsync("onBarClick", chart, selfReference);
        StateHasChanged();
    }

Burada .NET 10'da gelen iki API var. GetValueAsync<T> bir JS property'sini okuyor; Chart.version için JS tarafında ayrı bir fonksiyon yazmadık. InvokeConstructorAsync bir JS constructor'ını new ile çağırıp oluşan nesnenin referansını döndürüyor: new Chart(canvas, config) doğrudan C#'tan. canvas bir ElementReference; @ref ile render sırasında dolduruluyor ve JS'e gerçek DOM element'i olarak gidiyor. Config bir anonymous object; JSON'a serileşip Chart.js'in beklediği yapıya dönüşüyor. .NET 9'da bunların her biri için module'e küçük bir fonksiyon yazmak gerekiyordu.

Üçüncü yeni API SetValueAsync. Kullanıcı Refresh'e bastığında grafiği yeniden oluşturmak yerine mevcut nesnenin verisini değiştiriyoruz:

    // Parent'tan yeni veri: yeni bir grafik oluşturmak yerine mevcut grafiğin verisini değiştir.
    protected override async Task OnParametersSetAsync()
    {
        if (chart is not null)
        {
            await chart.SetValueAsync("data.labels", Sales.Select(s => s.Category));
            await chart.SetValueAsync("data.datasets.0.data", Sales.Select(s => s.Copies));
            await chart.InvokeVoidAsync("update");
        }
    }

    // SalesChart.razor.js'ten tıklanan bar'ın index'iyle çağrılır.
    [JSInvokable]
    public Task SelectCategory(int index) => OnCategorySelected.InvokeAsync(Sales[index]);

data.datasets.0.data bir yol: chart.data.datasets[0].data. Playwright ile sayfayı açıp araya bir sipariş ekledim, Refresh'e bastım, sonra bir bar'a tıkladım:

label: Chart.js 4.6.1
scripts loaded for this page: /Components/Layout/ReconnectModal.abdmv1u4y3.razor.js, /Components/Reports/SalesChart.9p3beka6z1.razor.js, /lib/chart.js/dist/chart.umd.min.xaf4rub35u.js
chart data: Classics=6, Dystopian=6, Fantasy=2, Science Fiction=5
after a new order and Refresh: Classics=6, Dystopian=6, Fantasy=6, Science Fiction=5 (same chart instance: 1 chart)
click on the Fantasy bar -> /books?category=fantasy, first book: The Hobbit

Chart.js ve module yalnız bu sayfada indi (ReconnectModal layout'un kendi script'i). Yeni siparişle Fantasy 2'den 6'ya çıktı ve sayfada hâlâ tek bir chart instance'ı var; yeniden oluşturmadan güncellendi. Chart.js'in UMD (Universal Module Definition; hem global script hem module olarak yüklenebilen paket biçimi) dosyası module'ün başındaki import ile yükleniyor ve window.Chart'ı tanımlıyor; InvokeConstructorAsync("Chart", ...) bu global'i buluyor.

JS'ten .NET'e: DotNetObjectReference ve [JSInvokable]

Bar'a tıklamak JS'te olan bir event; sonucu .NET'e taşımak için JS'e .NET nesnesinin bir referansını veriyoruz:

// Chart.js'in UMD build'i yüklendiğinde window.Chart'ı tanımlar.
import '../../lib/chart.js/dist/chart.umd.min.js';

// Tıklanan bar'ın index'ini DotNetObjectReference üzerinden .NET'e iletir.
export function onBarClick(chart, dotNetRef) {
  chart.options.onClick = (event, elements) => {
    if (elements.length > 0) {
      dotNetRef.invokeMethodAsync('SelectCategory', elements[0].index);
    }
  };
  chart.update();
}

DotNetObjectReference.Create(this) component'i JS'e açıyor; JS'teki dotNetRef.invokeMethodAsync('SelectCategory', index), [JSInvokable] ile işaretli method'u çağırıyor. Method OnCategorySelected EventCallback'ini tetikliyor ve sayfa kitap listesine o kategoriyle gidiyor. Chart.js'in onClick fonksiyonunu C#'tan veremezdik; fonksiyonlar JSON'a serileşmiyor. Bu yüzden küçük bir module fonksiyonu nesneyi alıp handler'ı bağlıyor. Sınırı burada çizmek iyi bir kural: nesne oluşturma ve property okuma/yazma C#'tan, callback ve DOM event'i gibi davranış JS'te.

DotNetObjectReference, JS tarafı onu tuttuğu sürece .NET nesnesini canlı tutar. Dispose edilmezse component sayfadan gitse bile garbage collector onu toplayamaz; InteractiveServer'da bu, circuit kapanana kadar biriken bir memory leak demek. DisposeAsync'te selfReference?.Dispose() bu yüzden var.

Dispose ve JSDisconnectedException

Grafik component'i kapanırken Chart.js nesnesini destroy ediyor ve JS referanslarını bırakıyor:

    public async ValueTask DisposeAsync()
    {
        try
        {
            if (chart is not null)
            {
                await chart.InvokeVoidAsync("destroy");
                await chart.DisposeAsync();
            }
            if (module is not null)
            {
                await module.DisposeAsync();
            }
        }
        catch (JSDisconnectedException)
        {
            // Circuit gitti, sayfa da gitti: tarayıcıda temizlenecek bir şey kalmadı.
        }
        selfReference?.Dispose();
    }

catch bloğunun sebebi InteractiveServer'daki bir durum: component'ler bazen circuit'in bağlantısı koptuktan sonra dispose ediliyor. Kullanıcı sekmeyi kapattığında circuit 06'da gördüğümüz üç dakika sonunda, Blazor.pauseCircuit() ile duraklatıldığında hemen atılıyor. O anda tarayıcıya mesaj gönderecek bir bağlantı yok. Deneme için catch'i exception'ı tekrar fırlatacak şekilde değiştirip circuit'i duraklattığımda panelin log'u (kısaltılmış):

warn: Microsoft.AspNetCore.Components.Server.Circuits.RemoteRenderer[100]
      Unhandled exception rendering component: JavaScript interop calls cannot be issued at this time. This is because the circuit has disconnected and is being disposed.
      Microsoft.JSInterop.JSDisconnectedException: JavaScript interop calls cannot be issued at this time. This is because the circuit has disconnected and is being disposed.
         at Microsoft.AspNetCore.Components.Server.Circuits.RemoteJSRuntime.BeginInvokeJS(JSInvocationInfo& invocationInfo)
         ...
         at Bookstore.Admin.Components.Reports.SalesChart.DisposeAsync()
fail: Microsoft.AspNetCore.Components.Server.Circuits.CircuitHost[111]
      Unhandled exception in circuit '...'.

Kullanıcı bir şey görmüyor, sayfa zaten gitmiş; ama her kapanan sekme log'a bir fail satırı yazıyor ve gerçek hataları bu kalabalıkta bulmak zorlaşıyor. Tarayıcı tarafında temizlenecek bir şey de kalmadı, çünkü sayfa ve module'ler zaten yok. Dokümanın önerdiği kalıp tam bu: dispose'ta JSDisconnectedException'ı yakala ve yut. WebAssembly'de circuit olmadığı için bu exception oluşmuyor.

Server'da Mesaj Boyutu ve Diğer Kurallar

InteractiveServer'da JS'ten .NET'e her çağrı SignalR üzerinden bir mesaj ve sunucunun kabul ettiği mesaj boyutu varsayılan olarak 32 KB (HubOptions.MaximumReceiveMessageSize). Deneme için module'e .NET'e verilen uzunlukta bir string gönderen bir fonksiyon ekleyip önce 30.000, sonra 40.000 karakterle çağırdım (çıktı kısaltılmış, zaman damgası çıkarıldı):

server log:      ReceiveText: 30000 characters
browser console: Error: Connection disconnected with error 'Error: Server returned an error on close: Connection closed with an error.'.

Üçüncü sorunun cevabı: 30.000 karakter geldi; 40.000 karakterlik mesaj geldiğinde sunucu bağlantıyı kapattı ve circuit düştü, varsayılan log seviyesinde sunucuda sebep bile görünmedi. Kullanıcı reconnect ekranını görüyor. Limit sunucuyu büyük mesajlarla doldurulmaya karşı koruyor; büyütmek mümkün ama doküman bunun DoS (Denial of Service; sunucuyu isteklerle boğup hizmet veremez hâle getirme) riskini artırdığını söylüyor. Büyük veriyi (bir dosya, uzun bir metin) JS'ten .NET'e parça parça ya da stream olarak (IJSStreamReference) taşı; doküman .NET'ten JS'e giden mesajlar için framework'ün böyle bir sınır koymadığını söylüyor.

Dokümanın birkaç kuralı daha var. Blazor'un render ettiği DOM'u JS ile değiştirme; Blazor DOM'un bir kopyasını tutuyor ve bir sonraki diff'te senin değişikliğini ya geri alır ya da beklenmedik sonuçlar üretir. Chart.js bu açıdan güvenli, çünkü yalnız <canvas>'ın içine çiziyor ve Blazor canvas'ın piksellerini takip etmiyor. Inline event handler'lar (onclick="...") yerine addEventListener kullan; inline handler'lar CSP (Content Security Policy; tarayıcıya hangi script'lerin çalışabileceğini söyleyen header) ile engellenebiliyor. Component dispose olurken DOM'u JS interop ile temizlemeye çalışma; doküman bunun için JS tarafında element'in DOM'dan çıkışını izleyen bir MutationObserver öneriyor.

05'te Static SSR (Static Server-Side Rendering; sayfanın her istekte sunucuda render edilmesi) sayfalarındaki script'leri bu yazıya bırakmıştık. Static SSR sayfasında circuit yok, dolayısıyla IJSRuntime ile çağrı da yok; enhanced navigation sayfayı baştan yüklemediği için sayfaya özel <script>'ler de ikinci ziyarette çalışmayabiliyor. Doküman iki yol veriyor: afterWebStarted JS initializer'ında blazor.addEventListener("enhancedload", ...) ile her enhanced güncellemeden sonra çalışmak, ya da script'i onLoad, onUpdate, onDispose fonksiyonları olan bir module'e çevirip onları çağıran küçük bir PageScript component'iyle sayfaya bağlamak.

WebAssembly'de Senkron Interop ve JSImport/JSExport

WebAssembly'de .NET ve JS aynı tarayıcı thread'inde çalışıyor; bu yüzden orada IJSInProcessRuntime ile senkron çağrılar mümkün: Invoke, .NET 10'da da InvokeConstructor, GetValue ve SetValue. Server'da her çağrı bir ağ gidiş-dönüşü olduğu için senkron versiyonları yok; Auto ya da iki modda çalışan bir component yazıyorsan async API'lerle kalmak en güvenlisi.

WebAssembly'de bir de .NET'in kendi alt seviye interop'u var: System.Runtime.InteropServices.JavaScript namespace'indeki [JSImport] ve [JSExport]. Bunlar IJSRuntime'ın JSON serileştirmesini atlayıp source generator ile tipli bağlar üretiyor ve sık çağrılan, performansa duyarlı kodda fark yaratıyor; ama yalnız WebAssembly'de çalışıyor, Server'da yok. Panelimizdeki gibi her iki modda çalışan component'ler için IJSRuntime doğru seçim.

Üretimde Dikkat

Server'da her JS çağrısı kullanıcının ağ gecikmesi kadar sürüyor ve varsayılan zaman aşımı bir dakika (CircuitOptions.JSInteropDefaultCallTimeout). Bir render'da arka arkaya on JS çağrısı on gidiş-dönüş demek; grafik örneğinde ayarları tek bir constructor çağrısında topladık, güncellemede üç çağrı yaptık. Sık güncellenen bir grafikte bunları tek bir module fonksiyonunda birleştirmek ölçülebilir fark yaratır.

Üçüncü taraf bir JS library'si eklediğinde onu kendin barındır ve sürümünü sabitle; Chart.js bu yazıda wwwroot/lib/chart.js'te, lisans dosyasıyla birlikte duruyor ve MapStaticAssets onu fingerprint'li adla sunuyor. Bir CDN'den (Content Delivery Network; statik içeriği kullanıcıya yakın sunuculardan dağıtan ağ) yüklemek istersen CSP'de o adrese izin vermen ve SRI (Subresource Integrity; dosyanın hash'ini kontrol ederek değiştirilmediğinden emin olma) kullanman gerekir.

Clipboard API yalnız güvenli bir context'te (HTTPS ya da localhost) ve kullanıcının izniyle çalışıyor. Kopyalama başarısız olduğunda kullanıcıya bunu söylemek, düğmeye bastığı hâlde panoda eski bir değerle karşılaşmasından iyidir.

Sık Düşülen Tuzaklar

JS'i OnInitializedAsync'te ya da OnParametersSetAsync'in ilk çalışmasında çağırmak. Prerendering'de 500; yer OnAfterRenderAsync(firstRender).

DotNetObjectReference'ı dispose etmemek. Component'in kendisi, onun referans verdiği service'ler ve veri bellekte kalır.

IJSObjectReference'ı dispose edip JSDisconnectedException'ı yakalamamak. Her kapanan sekme log'a bir hata yazar.

JS'ten .NET'e büyük veri göndermek. 32 KB'yi aşan tek bir mesaj circuit'i düşürür; dosya ve uzun metin için stream API'leri.

Blazor'un render ettiği element'leri JS ile değiştirmek ya da silmek. JS'in kendi yönettiği bir alan gerekiyorsa (grafik, harita, zengin metin editörü) Blazor'a boş bir element ver ve içini tamamen JS'e bırak.

Küçük Bir Deneme

★☆☆ Kopyalama düğmesini 05'teki Static SSR kitap listesine, her satırın yanına @rendermode vermeden koy. Tıklayınca ne oluyor? 01 ve 05'teki @onclick deneyimiyle karşılaştır.

★★☆ Grafiğe bir "Copies" / "Revenue" anahtarı ekle: SalesReport'a kategori başına ciro hesaplayan bir method yaz ve grafiği SetValueAsync ile güncelle. Ciro için decimal toplamı kullan; testlerdeki SQLite'ta ne oluyor, neden?

★★★ Grafiği InteractiveWebAssembly bir component'e taşı ve IJSInProcessRuntime ile senkron InvokeConstructor kullan. Veri için 09'daki gibi .Client'ta bir interface ve iki implementasyon gerekecek. İlk ziyarette (Server) ve sonraki ziyarette (WebAssembly) aynı kod nasıl davranıyor?

Panel artık JS ile konuşabiliyor ama hâlâ kapısı açık: stok endpoint'leri, katalog endpoint'i ve bütün sayfalar herkese açık. Bir sonraki yazıda authentication ve authorization'a geçiyoruz: AuthenticationStateProvider, AuthorizeView, render mode'a göre auth state, API'yi ayrıca korumak ve .NET 10 template'indeki passkey desteği.