🐙

.NET 8 の Blazor の静的 サーバー レンダリング (SSR) で JavaScript を使う方法

2023/11/26に公開

はじめに

.NET 8 の Blazor で追加された静的 サーバー レンダリング (SSR) で JavaScript を使う方法を紹介します。
SSR では、普通に script タグをページに追加しても JavaScript が実行されません。この動作自体は今まであった Blazor Server や Blazor WebAssembly でも同じで基本的には OnAfterRenderAsync で JavaScript を読み込んで実行する必要があります。

SSR では OnAfterRenderOnAfterRenderAsync メソッドは呼び出されないので別の方法で JavaScript を読み込んで実行するようにする必要があります。そのため、Blazor のドキュメントの JavaScript 相互運用の所に静的サーバー レンダリングという項目が追加されています。

https://learn.microsoft.com/ja-jp/aspnet/core/blazor/javascript-interoperability/static-server-rendering?view=aspnetcore-8.0

このドキュメントが最初ぱっと読んだだけだと、どういう風に動いているのかわかりませんでした。数か月後の自分がこのドキュメントを読んだときにまた理解するのに時間がかかりそうなので、自分なりに説明を書いておきます。

本文

SSR でのページで JavaScript を使うには JavaScript で頑張る必要があります。このドキュメントの内容を理解するには以下のことを理解している必要があります。

JavaScript イニシャライザー

Blazor では wwwroot/アセンブリ名.lib.module.js という JavaScript ファイルが自動的にインポートされます。これは普通の Blazor のアプリのプロジェクトでも Razor クラスライブラリのプロジェクトでも同じです。

詳細は以下のドキュメントを参照してください。

https://learn.microsoft.com/ja-jp/aspnet/core/blazor/fundamentals/startup?view=aspnetcore-8.0

この Blazor Web App の JavaScript イニシャライザーで以下の関数が export されていると自動的に呼び出されます。

  • beforeWebStart(options)
    • Blazor Web App の起動前に呼び出されます。
  • afterWebStarted(blazor)
    • Blazor Web App の起動後に呼び出されます。
  • beforeServerStart(options, extensions)
    • 最初の Blazor Server が開始される前に呼び出されます。
  • afterServerStarted(blazor)
    • 最初の Blazor Server が開始された後に呼び出されます。
  • beforeWebAssemblyStart(options, extensions)
    • WebAssemlby が開始される前に呼び出されます。
  • afterWebAssemblyStarted(blazor)
    • WebAssemlby が開始された後に呼び出されます。

個々の詳細は上記のドキュメントを参照してください。今回重要なのは Blazor Web App の起動後に呼び出される afterWebStarted です。この関数で page-script というカスタムエレメントを定義して、そこで Blazor の画面遷移の度に必要に応じて JavaScript のファイルを import して各種関数を呼び出すということをしています。

blazorenhancedload イベント

JavaScript で afterWebStarted の関数に渡されてくる blazor というオブジェクトには enhancedload というイベントがあります。このイベントは Blazor の拡張ページ遷移 (Enhanced page navigation) が起きる度に呼び出されます。

カスタム エレメントの登録

ここまでの内容を踏まえて静的サーバー レンダリングのドキュメントにある長い JavaScript イニシャライザーのコードを見てみましょう。

プロジェクト名.lib.module.js
const pageScriptInfoBySrc = new Map();

function registerPageScriptElement(src) {
    if (!src) {
        throw new Error('Must provide a non-empty value for the "src" attribute.');
    }

    let pageScriptInfo = pageScriptInfoBySrc.get(src);

    if (pageScriptInfo) {
        pageScriptInfo.referenceCount++;
    } else {
        pageScriptInfo = { referenceCount: 1, module: null };
        pageScriptInfoBySrc.set(src, pageScriptInfo);
        initializePageScriptModule(src, pageScriptInfo);
    }
}

function unregisterPageScriptElement(src) {
    if (!src) {
        return;
    }

    const pageScriptInfo = pageScriptInfoBySrc.get(src);
    if (!pageScriptInfo) {
        return;
    }

    pageScriptInfo.referenceCount--;
}

async function initializePageScriptModule(src, pageScriptInfo) {
    if (src.startsWith("./")) {
        src = new URL(src.substr(2), document.baseURI).toString();
    }

    const module = await import(src);

    if (pageScriptInfo.referenceCount <= 0) {
        return;
    }

    pageScriptInfo.module = module;
    module.onLoad?.();
    module.onUpdate?.();
}

function onEnhancedLoad() {
    for (const [src, { module, referenceCount }] of pageScriptInfoBySrc) {
        if (referenceCount <= 0) {
            module?.onDispose?.();
            pageScriptInfoBySrc.delete(src);
        }
    }

    for (const { module } of pageScriptInfoBySrc.values()) {
        module?.onUpdate?.();
    }
}

export function afterWebStarted(blazor) {
    customElements.define('page-script', class extends HTMLElement {
        static observedAttributes = ['src'];

        attributeChangedCallback(name, oldValue, newValue) {
            if (name !== 'src') {
                return;
            }

            this.src = newValue;
            unregisterPageScriptElement(oldValue);
            registerPageScriptElement(newValue);
        }

        disconnectedCallback() {
            unregisterPageScriptElement(this.src);
        }
    });

    blazor.addEventListener('enhancedload', onEnhancedLoad);
}

まずは、一番下にある afterWebStarted です。これは Blazor Web App の起動後に呼び出される関数です。この関数で page-script というカスタムエレメントを定義しています。このカスタムエレメントは src という属性を持っています。この属性に JavaScript のファイルのパスを指定するとそのファイルが読み込まれて実行されます。実際の JavaScript ファイルの読み込みは initializePageScriptModule で行われています。

この中で import したモジュールに対して onLoadonUpdate という関数が定義されている場合に呼び出しています。

pageScriptInfo.module = module;
module.onLoad?.();
module.onUpdate?.();

onUpdate はこの他に拡張ページ遷移のタイミングで呼び出されるようになっています。さらに、読み込まれたモジュールは pageScriptInfoBySrc という Map で自前の参照カウンターを使って管理されています。そして、何処からも参照されなくなったタイミングで onDispose が呼び出されます。

カスタムエレメントをラップした PageScript コンポーネント

このカスタムエレメントを使うための Blazor コンポーネントが PageScript です。これは以下のようになっています。

PageScript.razor
<page-script src="@Src"></page-script>

@code {
    [Parameter]
    [EditorRequired]
    public string Src { get; set; } = default!;
}

このコンポーネントを使って JavaScript をページに読み込みます。

JavaScript の準備と PageScript コンポーネントを使って読み込む

JavaScript はページと併設されたものを使用するのがスタンダードです。コンポーネントが /Components/Pages.JSInteropPage.razor のような場所にある場合には、JavaScript は /Components/Pages.JSInteropPage.razor.js に置きます。

このスクリプト内では onLoad, onUpdate, onDispose 関数を export しておきます。ここに適切な処理を書いておきましょう。

例えば手元で試した時はこんなコードを書いてみました。onLoad でボタンの要素を取得して click イベントを購読しています。

JSInteropPage.razor.js
export function onLoad() {
    console.log('onLoad()');
    document.getElementById('alertButton').addEventListener('click', () => {
        alert('Hello world.');
    });
}

export function onUpdate() {
    console.log('onUpdate()');
}

export function onDispose() {
    console.log('onDispose()');
}

そして JSInteropPage.razorPageScript コンポーネントを使って読み込みます。

JSInteropPage.razor
@page "/jsinterop"
<PageScript Src="./Components/Pages/JSInteropPage.razor.js" />

<h3>JSInteropPage</h3>

<button id="alertButton">Alert</button>

<NavLink href="jsinterop?param=123">In page nav</NavLink>

このページを表示して Alert ボタンを押すと以下のようになります。

ちゃんと JavaScript が実行されています。因みにページの末尾に置いてある <NavLink href="jsinterop?param=123">In page nav</NavLink> を押すと同じページ内での拡張ページ遷移が起きるので、そのタイミングで onUpdate が呼び出されます。今回の用途だと特に意味はありませんがログがコンソールに出力されます。

JSInteropPage から離れると onDispose が呼び出されます。

今回の用途だと onLoad 関数だけ定義しておけば動きますが他の関数を使うことで、もう少し凝った JS の処理が出来るようになると思います。

まとめ

ということで新しい Blazor の静的サーバー レンダリングでの JavaScript の使い方について解説しました。
本格的な対話動作については InteractiveServerInteractiveWebAssemblyInteractiveAuto を使うことになると思いますが、そこまでしなくても、ちょっとだけ JavaScript を使いたい時などに使えると思います。

注意点としては、拡張ページ遷移や拡張フォーム処理が行われると JavaScript で書き換えたような要素はサーバーからのレスポンスの内容で上書きされるので、DOM の操作とかをするときには注意しましょう。

特定要素以下は JavaScript で面倒をみるという場合は data-permanent 属性を付けることで、その要素以下は Blazor による書き換えが行われなくなります。ここらへんのさじ加減は難しそうですね…。

Microsoft (有志)

Discussion