グローバルスコープを使わないとダメ?
先日 Qiita に下記記事が投稿されました。
"Blazor で Excel ライクなグリッド JS ライブラリ「Handsontable」を使ってみる"
自分は Handsontable というライブラリを知っていなかったので、知見を広げるよい機会になりました。良記事ありがとうございます!
さて上記記事中、以下の記載がありました。
Handsontable のインスタンス変数を最初に
var hot = new Handsontable
としました。
しかし、これだとloadData
関数内でhot
変数を使用すると「Error: Microsoft.JSInterop.JSException: hot is not defined」となってしまいます。
原因はvar
が関数スコープだからです。
グローバルスコープにする必要があるため、windows.hot = new Handsontable
に変更することで動作しました。
確かにこの処置で動作します。しかしこのままですと、以下の二点が気になりました。
- グローバル名前空間の汚染、もしくは衝突
- 同じページ内で 2 つ以上の Handsontable グリッドを使えない
幸い、Blazor が備えている JavaScript 相互運用機能では、JavaScript ファイルに掲載されている関数 (function
) を呼び出すだけでなく、JavaScript オブジェクトの参照を入手し、その JavaScript オブジェクトのメソッドを Blazor 側 (C# 側) から呼び出すことができます。 この仕組みを使うことで、上記2点の気になる課題を解消できます。どういうことか、やってみましょう。
JavaScript 関数呼び出しの戻り値として、JavaScript オブジェクトの参照を受け取れる!
まず JavaScript コード、とくに createGrid
関数からテコ入れしていきます。現在、Handsontable
オブジェクトをインスタンス化し、グローバルスコープである windows.hot
に設定しているところを (下記) ...
function createGrid() {
...
window.hot = new Handsontable(grid, {
...
});
}
下記のとおり、createGrid
関数の戻り値として、Handsontable
オブジェクトを返すように変更します (下記)。
function createGrid() {
...
// 👇 createGrid 関数の戻り値として Handsontable オブジェクトを返すよう、変更!
return new Handsontable(grid, {
...
});
}
次は、この createGrid
JavaScript 関数を呼び出している Blazor 側です。
現状は "InvokeVoidAsync" メソッドで戻り値は取得しない形で createGrid
JavaScript 関数を呼び出していますが (下記)...
protected override async Task OnAfterRenderAsync(bool firstRender)
{
...
await jsRuntime.InvokeVoidAsync("createGrid");
...
これを改め、InvokeAsync<T>
メソッドを使って呼び出すことで、createGrid
JavaScript 関数の戻り値を受け取るようにします。このとき、戻り値の型 <T>
は IJSObjectReference
型としてください。また、戻ってきた IJSObjectReference
オブジェクトを保持するためのフィールド変数も追加しておき、それで戻り値を受け取ります (下記)。
private IJSObjectReference? _grid;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
...
_grid = await jsRuntime.InvokeAsync<IJSObjectReference>("createGrid");
...
IJSObjectReference
は、数値や文字列などのプリミティブ型ではない、JavaScript オブジェクトへを受け取るときに使えるインターフェース型になります。JavaScript 側と Blazor の .NET 側とではまったく世界が違うので、JavaScript 世界のオブジェクトが生身のまままるごと .NET 世界に出現することはできません。なので、.NET 世界から JavaScript 世界に存在するオブジェクトを指し示す "参照" が IJSObjectReference
ということになります。
以上の実装で、Blazor 側の IJSObjectReference _grid
フィールド変数に、JavaScript 側の createGrid
関数が返してよこした Handsontable
オブジェクトへの "参照" が格納されることになります。
JavaScript オブジェクトのメソッドも呼び出せる!
引き続き Blazor 側、データを Handsontable
JavaScript オブジェクトに引き渡すところを見ていきます。
現状は、script.js
内で定義してある loadData
関数を呼び出すことで実装されていますが (下記)...
protected override async Task OnAfterRenderAsync(bool firstRender)
{
...
await jsRuntime.InvokeVoidAsync("loadData", products);
}
先ほどフィールド変数に格納しておいた、Handsontable
JavaScript オブジェクトへの参照である IJSObjectReference
オブジェクトを介して、直接、Handsontable
JavaScript オブジェクトのメソッドを呼び出せます (下記)。
protected override async Task OnAfterRenderAsync(bool firstRender)
{
...
// 👇 同じ "loadData" という名前のためわかりにくいが、
// script.js で定義されている loadData 関数を呼び出しているのではなく、
// _grid フィールド変数が指している Handsontable オブジェクトの loadData メソッドを呼び出している!
await _grid.InvokeVoidAsync("loadData", products);
}
IJSObjectReference
インターフェース型は、IJSRuntime
インターフェース型と同じように InvokeVoidAsync
メソッドや InvokeAsync<T>
メソッドを備えています。両者の違いは、IJSRuntime
インターフェース型ではグローバルスコープに存在する関数を呼び出すのに対し、IJSObjectReference
インターフェース型は、それが指し示しているオブジェクトのメソッド (および JavaScript モジュールがエクスポートしている関数) を呼び出す点です。
ということで、これにて script.js
内に定義されていた loadData
関数は不要となりましたので (下記)、
function createGrid() {
...
}
// 👇 もう使われなくなりました!
function loadData(data) {
window.hot.loadData(data);
}
削除しておきましょう (下記)。
function createGrid() {
...
}
// loadData 関数を削除!
使わなくなった JavaScript オブジェクトへの参照は破棄しましょう
最後に、Razor コンポーネントの寿命にあわせ、使わなくなった JavaScript オブジェクトへの参照を破棄するようにしましょう。.NET 世界側でいつまでも JavaScript 世界のオブジェクトへの参照を持ち続けていると、その JavaScript 世界のオブジェクトがガベージコレクションで回収できず、メモリリークの一因となります。
そのためにまずは Razor コンポーネントで IAsyncDisposable
インターフェースを実装するようにし (下記)、
@implements IAsyncDisposable
...
@code {
...
public async ValueTask DisposeAsync()
{
// このあと、ここを実装します
}
}
Razor コンポーネントの DisposeAsync
メソッド内で、先にフィールド変数 _grid
に格納された Handsontable
JavaScript オブジェクトへの参照を破棄します (下記)。
...
@code {
...
public async ValueTask DisposeAsync()
{
// 👇 IJSObjectReference の DisposeAsync を呼び出して、
// Handsontable JavaScript オブジェクトへの参照を破棄する
if (_grid != null) {
try { await _grid.DisposeAsync(); }
catch (JSDisconnectedException) { }
}
}
}
このように、コンポーネントの破棄時に Handsontable
JavaScript オブジェクトへの参照を破棄することが目的で、 Handsontable
JavaScript オブジェクトへの参照をフィールド変数 _grid
に保持していたのでした。
なお、「createGrid
して loadData
したら以降、.NET 側からは二度と Handsontable
JavaScript オブジェクトをいじらない」ということであれば、コンポーネントのフィールド変数に保持する必要はありません。OnAfterRenderAsync
ライフサイクルメソッド内で await using var grid = ...
のようにローカル変数で受け、スコープ外に出るときに自動で破棄されるようにすればよいです。
なお、一点補足です。
上記実装例では JSDisconnectedException
例外を catch して握り潰していますが、基本的には例外をこのように握り潰すのは決してやってはいけません (せめて ILogger<T>
を DI コンテナから取得してそちらにログ記録する必要がある)。しかしコンポーネントの破棄時における JSDisconnectedException
例外は、とくに Blazor Server の場合、その仕組み上、ブラウザのタブを閉じただけで毎回確定的に発生します。加えて、私が知る限り、JavaScript 世界とのつながりが絶たれてるかどうかを事前にしる術もないことから、この例外発生を回避する方法がありません。いっぽうで、コンポーネントの破棄時に JSDisconnectedException
例外が発生すると何かマズイことがあるかというと、自分の理解の限りでは、そのようなことはありません。そのため、自分ではこれだけは catch してそのままでよいことにしています。
まとめ
以上の実装で、グローバルスコープ上に変数を散らかすこともなくなり、1ページ内に複数の Handsontable グリッドを並べて使うこともできるようになりました。
Blazor 側、.NET の世界だけでは完結せず、どうしても JavaScript 世界の力を借りたい場合、IJSRuntime
サービスだけでなく、IJSObjectReference
も使いこなせるようになると、グッと柔軟性が増しますね。
Learn, Practice, Share :)