問題
先日、Blazor Advent Calendar 2024 に、@MNakae_IG さんから下記投稿を頂きました。
技術記事 Advent Calendar アプリを、GitHub リポジトリを記事コンテンツの保管場所として活用しつつ、GitHub Pages にデプロイされた Blazor WebAssembly アプリで構築する、という読み応えのある内容です。
そしてこの Advent Calendar アプリは技術記事投稿用ということで、記事内に書かれたソースコードの構文ハイライトも、その筋では有名な JavaScript ライブラリ、Prism.js を呼び出すことで実装されていました。
ただ、記事中にてその Prism.js による構文ハイライトが、「基本的には読み込むだけで利用可能だと思うのですが今回はうまく働きませんでした」と記されています。
原因としては「動的に記事マークダウンファイルを取得」しているために、「Prism.js が読み込まれたタイミングではまだハイライトすべきソースコード部分」がないのでは、とのことで、それで以下の実装で回避したとのことでした。
以下のようなJSを別途呼び出すことで解決しました。(もっと良い方法あるかもですが)
window.applyPrism = function () { setTimeout(function () { Prism.highlightAll(); }, 50); };
ただ、ご本人も言及しているとおり「もっと良い方法」がないか、気になるところでしょう。とくに、setTimeout
に指定している 50 msec の待機もなかなかの曲者です。実際、ブラウザの開発者ツールのネットワークタブにて通信速度を抑えるよう設定し、記事ソースの取得に 50 msec 以上かかってしまった場合を模擬してみると、またしても構文ハイライトが効いていない記事が表示されてしまいました。
どうしてそうなるのか
さて、この Advent Calendar アプリの場合において、どうしてこうもうまく構文ハイライトが適用できないのかというと、「OnInitializedAsync
内の、最初の非同期処理待ち (await) で、いったん初回の DOM は描画されるから」 が答えになるかと思います。
詳しくは下記記事を参照ください。
別の言い方をすると、「OnInitializedAsync
内で非同期処理があると、OnInitializedAsync
内に記述したすべての処理が完遂するより前に、OnAfterRenderAsync
が呼び出されるから」 ということになります。
このことが、この Advent Calendar アプリ上でどうして構文ハイライトがうまく適用されないことになるのかというと、以下にコードの抜粋とそれに記載したコメントで説明してみました。
protected override async Task OnInitializedAsync()
{
...
// 記事コンテンツを GitHub から取得... している最中に、
// いったん描画されてしまう、つまり、OnAfterRenderAsync が走る!
var markdown = await Http.GetStringAsync(url);
...
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// なので、記事コンテンツの DOM ツリーがブラウザ上に構築される前に、
// 以下の構文ハイライト適用処理が呼び出されてしまう!
// (50 msec の遅延を挟んでも、記事取得が長引けば、やはり構文ハイライト処理は空振りしてしまう!)
if (firstRender)
{
await JS.InvokeVoidAsync("applyPrism");
}
}
どうでしょう、これで、どうして構文ハイライトがうまく適用されないのか、おわかりいただけるでしょうか。
よりよい解決方法
以上を踏まえた上で、よりよい解決方法としては、「記事コンテンツを組み立て終わってのちの、初回の OnAfterRenderAsync
呼び出し時」 というタイミングを確実に掴んで、そのタイミングで Prism.js による構文ハイライト適用処理を呼び出せるとよいはずです。
ということで自分の作例はこうです。
まず、この Razor コンポーネントの初期化段階を表現した enum
型とそれを保持するフィールド変数を @code
ブロックに追加します。
...
// 初期化段階を表現した enum 型
private enum InitializingState
{
Loading, // 記事コンテンツ取得中
BuiltContent, // 記事コンテンツの構築完了
SyntaxHighlighted // 構文ハイライト適用完了
}
// 現在、どの初期化段階に至ったのかを示すフィールド変数
// (初期状態は記事コンテンツの取得中)
private InitializingState initializingState = InitializingState.Loading;
...
そして次に、OnInitializedAsync
内ですが、記事コンテンツの取得や、Markdown からの変換処理などを経て、ページ上にレンダリングされる HTML が完成したら、初期化段階を示すフィールド変数を更新します。
...
protected override async Task OnInitializedAsync()
{
...
var markdown = await Http.GetStringAsync(url);
...
// フィールド変数 content に、表示される記事コンテンツの HTML 文字列を格納完了!
content = Markdig.Markdown.ToHtml(document, pipeline);
// そうしたら、初期化段階フィールド変数を
// "BuiltContent" (記事コンテンツ構築完了) に進める
initializingState = InitializingState.BuiltContent;
...
}
...
最後に OnAfterRenderAsync
です。このライフサイクルメソッドは、再描画が発生するたびに毎回呼び出されるわけですが、ここで、初期化段階フィールド変数を参照することで、記事コンテンツの構築完了後の呼び出しであることが把握できます。そのタイミングで、Prism.js の構文ハイライト適用関数を呼び出せば完成です。
...
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// 記事コンテンツ構築完了後の描画完了呼び出しの場合にのみ...
if (initializingState == InitializingState.BuiltContent)
{
// 初期化段階フィールド変数を "SyntaxHighlighted" にした上で...
initializingState = InitializingState.SyntaxHighlighted;
// Prism.js を使って構文ハイライトを適用!👍
await JS.InvokeVoidAsync("Prism.highlightAll");
}
}
...
以上のコード変更を適用した上で改めてこの Advent Calendar の動作確認をしてみますと、記事コンテンツの取得に時間がかかっても確実に構文ハイライトが適用されるようになったことを確認できました。
おわりに
以上、 OnInitializedAsync
の "途中" であっても 、直感に反して (?) OnAfterRenderAsync
が呼び出されることが、動作の不安定さを招いていた、という話でした。
「何かしたあと直近の再描画完了時 = OnAfterRenderAsync
呼び出し時、に、カクカクシカジカの処理を実行したい」というケースはアプリによっては頻出するパターンです。今回は Razor コンポーネントに初期化段階を示す状態フィールド変数を設けることで、Prism.js の呼び出しタイミングを制御しましたが、これをよりうまく実装できるよりスマートな方法があるといいのにな、と常々思っています。いいアイディアがあったら、共有いただけるとうれしいです。
Happy Coding! :)