WOGO Tech blog
🌶️

【続編】WindowsアプリUI開発はもうつらくない! Blazor × Tailwind CSS完全版

に公開

こんにちは。WOGOのインターンをしている野口です。
前回の記事ではWPFのBlazorアプリにTailwindcssをミニマムに導入する方法を解説しました。今回はその記事の中でできないと言っていたBlazorのHot Reloadと、TailwindcssのJITモードを共存させる方法について考えていきたいと思います。
https://zenn.dev/wogo_techblog/articles/811ec4e5c7a0e4

時代はDXなんだ

.NET Hot Reload applies code changes, including changes to stylesheets, to a running app without restarting the app and without losing app state. [1]

BlazorのHot Reload、もとい.NET Hot Reload(以下Hot Reload)は引用の通りリビルドや再起動を挟まずとも状態を維持したままスタイルシートの変更を含むRazorコンポーネントやC#の変更をHookして発火し、それらを実行中のアプリに適用することを可能にします。

TailwindcssのJITモードとは、監視対象としたファイルの変更をHookしてスタイルをオンデマンドでビルドするというものです。

WPFのBlazorアプリのUI開発にTailwindcssを導入する際に前者2つが共存させることができれば、動的なスタイル生成と状態を維持したアプリへの反映が再ビルドすることなく実現できる、最高のDXが手に入るというわけです。ここまで来ればほぼReact+Tailwindcssと同じ感覚でUIを開発できるでしょう。

しかし、そう簡単に行かないというのが現実です。ただ単にビルドの開始と同時にTailwind CLIをJITモードで起動させただけでは、Razorコンポーネント上で新しいスタイルを適用してTailwindcssがビルドしてもHot Reloadで反映させることができません

どうすればいいのでしょうか?答えはMicrosoft Learnのドキュメント密林奥地にありました。というわけで今回はHot ReloadとTailwind CLIのJITモードを共存させる方法を解説していきたいと思います。

開発環境

  • windows11 x64
  • .NET8以降
  • IDE
    • Visual Studio 2022 Community Edition
    • Visual Studio 2026 Community Edition
    • Visual Studio Code + C# dev kit
  • NuGetパッケージ
    • Microsoft.AspNetCore.Components.WebView.Wpf
    • Microsoft.Extensions.DependencyInjection
  • Tailwind CLI v4.1.18

Hot ReloadとTailwind CLIのJITモードの共存方法

共存が難しい所以

ではなぜ、Hot ReloadはTailwindcssのスタイルの変更を感知できないのでしょうか?結論から言ってしまえばHot Reloadが/wwwroot内の静的ファイルの変更を感知できないからです。WPF上のBlazor WebViewの静的ファイルは/wwwroot内に配置する必要があるため、静的ファイルであるTailwind CLIのビルド生成物は/wwwroot内に置かれる必要があります。当然JITモードでオンデマンドにビルドされたスタイルもその場所に記述されているわけですからHot Reloadはその変更を感知することができないというわけです。

共存ロジック

Hot ReloadがWebViewの静的ファイルの変更を感知できないなら、こちらでWebViewの更新をしてあげればいいのです。すなわち

  1. ビルド時にTailwindCLIのJITモードを同時に起動(終了時にも同様)
  2. .NET Hot Reloadの発火イベントをフックする
  3. イベントを受け取り、JavaScript経由でCSSを強制リロード

このようなロジックで共存が実現できるはずです。

実装

特に断りがなければファイルはプロジェクトルートに配置しています。プロジェクト名はYourProjectです

YourProject/
├── TailwindProcess.cs
├── HotReloadHandler.cs
├── CssHotReloadSync.razor
├── App.xaml
├── App.xaml.cs
├── App.razor
├── YourProject.csproj
├── wwwroot/
│   ├── index.html
│   └── css
...

1.ビルド時にTailwindCLIのJITモードを同時に起動(終了時にも同様)

TailwindProcess.csに記述します。_processを使用してTailwind CLIの起動、終了処理を行います。

TailwindProcess.cs
using System.Diagnostics;
using System.IO;
using System.Text;

public class TailwindProcess
{
    private Process? _process;

    public void Start()
    {
        // プロジェクトルートを取得
        string projectRoot = FindProjectRoot();        
        if (string.IsNullOrEmpty(projectRoot))
        {
            Debug.WriteLine("[Tailwind] Error: Project root not found.");
            return;
        }
        
        //ProjectRoot配下のExternalToolsフォルダにtailwindcss-windows-x64.exeがある想定
        var tailwindExe = Path.Combine(projectRoot,"ExternalTools\\tailwindcss-windows-x64.exe");
        // tailwindcssの実行ファイルが存在するか確認,なければ終了。ディレクトリ名は適宜変更してください
        if (!File.Exists(tailwindExe))
        {
            Debug.WriteLine($"[Tailwind] Error: {tailwindExe} not found.");
            return;
        }

        _process = new Process
        {
            StartInfo = new ProcessStartInfo
            {
                FileName = tailwindExe,
                Arguments = "-i Styles/app.tailwind.css -o wwwroot/css/app.css --watch",
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                StandardOutputEncoding = Encoding.UTF8,//Tailwindの出力が文字化けする場合があるのでUTF8に設定
                StandardErrorEncoding = Encoding.UTF8,//同上
                UseShellExecute = false,
                CreateNoWindow = true,
                WorkingDirectory = projectRoot
            }
        };

        _process.OutputDataReceived += (s, e) => { if (e.Data != null) Debug.WriteLine($"[Tailwind][out] {e.Data}"); };
        _process.ErrorDataReceived += (s, e) => { if (e.Data != null) Debug.WriteLine($"[Tailwind][err] {e.Data}"); };
        //Tailwind CLIのログはerrに流れるので注意    

        try
        {
            _process.Start();
            _process.BeginOutputReadLine();
            _process.BeginErrorReadLine();
            Debug.WriteLine("[Tailwind] Watcher process started.");
        }
        catch (Exception ex)
        {
            Debug.WriteLine($"[Tailwind] Failed to start: {ex.Message}");
        }
    }

    public void Stop(){
        if (_process != null && !_process.HasExited){
            try{
                _process.Kill(true);
                Debug.WriteLine("[Tailwind] Watcher process stopped.");
            }
            catch { /* 特に何もなければ無視 */ }
        }
    }

    //ProjectRootを探すヘルパーメソッド
    private string FindProjectRoot(){
        var directoryInfo = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory);
        while (directoryInfo != null){
            if (File.Exists(Path.Combine(directoryInfo.FullName, "YourProject.csproj"))){
                return directoryInfo.FullName;
            }
            directoryInfo = directoryInfo.Parent;
        }
        return string.Empty;
    }
}

これをApp.xaml.csに記述することによってアプリの起動時、終了時にTailwind CLIも同様の動作をすることが可能になります

App.xaml.cs
//Projectのnamespace内
    public partial class App : Application
    {
        // Tailwind管理用インスタンス
        private TailwindProcess? _tailwindProcess;

        protected override void OnStartup(StartupEventArgs e)
        {
            base.OnStartup(e);

            // デバッグ実行時のみTailwindのWatchを開始
#if DEBUG
            _tailwindProcess = new TailwindProcess();
            _tailwindProcess.Start();
#endif
        }
        protected override void OnExit(ExitEventArgs e)
        {
            // アプリ終了時にTailwindのプロセスも止める
#if DEBUG
            _tailwindProcess?.Stop();
#endif
            base.OnExit(e);
        }
    }

2..NET Hot Reloadの発火イベントをフックする

HotReloadHandler.csに記述します。MetadataUpdateHandlerAttribute[2]を使用します。こんなものが用意されているとは驚きですね、、、。

HotReloadHandler.cs
using System.Reflection.Metadata;

// MetadataUpdateHandlerAttributeの使用Attributeは省略可
[assembly: MetadataUpdateHandler(typeof(MyBlazor_WPFFrame.HotReloadHandler))]

namespace YourProject
{
    public static class HotReloadHandler
    {
        // UI側(Razorコンポーネント)に通知を送るためのイベント
        public static event Action? OnHotReload;

        // ホットリロードが発生した瞬間にVSから呼ばれるメソッド
        public static void UpdateApplication(Type[]? updatedTypes)
        {
            // イベントを発火させる
            OnHotReload?.Invoke();
        }

        public static void ClearCache(Type[]? updatedTypes)
        {
            // キャッシュクリアが必要なら
        }
    }
}

Hot Reloadが発生するとOnHotReloadがInvokeします。

3.イベントを受け取り、JavaScript経由でCSSを強制リロード

イベント受信はCssHotReloadSync.razorで行い、index.html
にJavascriptで記述されたWebViewの更新ロジックを実行します。

CssHotReloadSync.razor
@using System.Diagnostics
@inject IJSRuntime JS
@implements IDisposable

@code {
    // Tailwindが出力するファイル名(index.htmlで読み込んでいる名前)
    private const string CssFileName = "css/app.css"; // ← 環境に合わせて修正

    protected override void OnInitialized()
    {
        // 静的イベント(HotReloadHandlerで作ったイベント)に登録
        HotReloadHandler.OnHotReload += HandleHotReload;
    }

    public void Dispose()
    {
        // メモリリーク防止のため登録解除
        HotReloadHandler.OnHotReload -= HandleHotReload;
    }

    private async void HandleHotReload()
    {
        try
        {
            // ホットリロードのイベントはUIスレッド外から来る可能性があるため、InvokeAsyncでラップ
            await InvokeAsync(async () =>
            {
                // Tailwindのビルドが完了するのを少しだけ待つ猶予(必要なら調整,おおよそ100msで終わるとは思うが、、、)
                await Task.Delay(100); 

                // JS関数を呼び出してCSSをリフレッシュ
                await JS.InvokeVoidAsync("reloadCss", CssFileName);
                
                // デバッグ出力(VSorVScodeの出力ウィンドウで確認用)
                Debug.WriteLine("[Blazor] Hot Reload event received. CSS refreshed.");
            });
        }
        catch (Exception ex)
        {
            Debug.WriteLine($"[Blazor] Error refreshing CSS: {ex.Message}");
        }
    }
}

これはApp.razor(ルートのRazorコンポーネント)に配置します。Tailwind CLIのビルドを待つためのデバウンスタイムは適切に設定してください。短いと反映がうまく行かないことがあります。

App.razor
@* 追記 *@
#if DEBUG
    <CssHotReloadSync />
#endif
index.html
<!-- any other tags-->
<head>
    <!-- any other tags-->
    <link href="css/app.css" rel="stylesheet" />
    <script>
    window.reloadCss = function (cssFileName) {
        var links = document.getElementsByTagName("link");
        for (var i = 0; i < links.length; i++) {
            var link = links[i];
            // cssFileNameが含まれるlinkタグを探して更新
            if (link.rel === "stylesheet" && link.href.includes(cssFileName)) {
                // クエリパラメータを付け替えてキャッシュ回避
                var newUrl = link.href.split('?')[0] + "?v=" + new Date().getTime();
                link.href = newUrl;
                console.log("[JS] CSS reloaded: " + newUrl);
                return;
            }
        }
    };
    </script>
<head>

Tailwindcssを使用する際、出力先であるCSSファイル(今回は/css/app.css)は確実に存在すると言えます。また、その他のCSSファイルにスタイルを記述することもないのでincludesを使用してlinkを検索しており、見つからなかった場合の処理も書いていませんがプロジェクトの状況によっては改善したほうがいい場合もあると思います。

以上の実装によってHot ReloadとTailwind CLIのJITモードを共存させることができます。

Tips?

ビルド時に自動でTailwind CLIを導入

前回の記事を振り返ってみて、Tailwind CLIをわざわざブラウザを開いてGithubにアクセスしてダウンロードして、、、とやるのは少々煩わしくプロジェクトごとにバージョンの統一なども煩雑になってしまいますよね。せっかく.csprojを書いているのですからここに記述してしまいましょう。

YourProject.csproj
<Project Sdk="Microsoft.NET.Sdk.Razor">
        <!== some other options -->
	<Target Name="DownloadTailwindCLI" BeforeTargets="Build">
		<PropertyGroup>
			<ToolUrl><!--最新バージョンです-->
				https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.18/tailwindcss-windows-x64.exe</ToolUrl>
			<ToolDir>$(ProjectDir)ExternalTools\</ToolDir>
			<ToolPath>$(ToolDir)tailwindcss-windows-x64.exe</ToolPath>
		</PropertyGroup>
                <!--ディレクトリないなら作成-->
		<MakeDir Directories="$(ToolDir)" Condition="!Exists('$(ToolDir)')" />

		<DownloadFile SourceUrl="$(ToolUrl)" DestinationFolder="$(ToolDir)"
			Condition="!Exists('$(ToolPath)')">
			<Output TaskParameter="DownloadedFile" ItemName="Content" />
		</DownloadFile>

		<Message Text="External tool downloaded to $(ToolPath)" Importance="high"
			Condition="!Exists('$(ToolPath)')" />
	</Target>

	<Target Name="TailwindBuild" BeforeTargets="Build">
		<Exec Command="$(ToolPath) -i ./Styles/app.tailwind.css -o ./wwwroot/css/app.css"
			Condition="'$(Configuration)' == 'Debug'" />
		<Exec Command="$(ToolPath) -i ./Styles/app.tailwind.css -o ./wwwroot/css/app.css --minify "
			Condition="'$(Configuration)' == 'Release'" />
	</Target>
</Project>

IntelliSense

VScodeで使用できるTailwindの公式IntelliSenseやVSで使用できるIntelliSenseはRazorコンポーネント内で使用されているTailwindcssに対しても問題なく効きます。tailwind.extension.jsonファイルが生成されますが特に追記しなくとも勝手に.razorファイルの設定が追加されます。

UIライブラリ

Radix UIが使えないのでshadcn/uiなどを使用することは(少なくともインスタントには)できません。daisy UIなどTailwindcssのみを前提としたUIライブラリであれば使用することができます。
https://daisyui.com/docs/install/standalone/
ビルド時に自動でダウンロードするようにしておきます。

YourProject.csproj
<Project Sdk="Microsoft.NET.Sdk.Razor">
        <!== some other options -->
        <Target Name="DownloadDaisyUI" BeforeTargets="Build">
		<PropertyGroup>
			<DaisyUiUrl>https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.mjs</DaisyUiUrl>
			<DaisyUiThemeUrl>https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.mjs</DaisyUiThemeUrl>
			<ToolDir>$(ProjectDir)Styles\</ToolDir>
			<DaisyUiPath>$(ToolDir)daisyui.mjs</DaisyUiPath>
			<DaisyUiThemePath>$(ToolDir)daisyui-theme.mjs</DaisyUiThemePath>
		</PropertyGroup>

		<MakeDir Directories="$(ToolDir)" Condition="!Exists('$(ToolDir)')" />

		<DownloadFile SourceUrl="$(DaisyUiUrl)" DestinationFolder="$(ToolDir)"
			Condition="!Exists('$(DaisyUiPath)')">
			<Output TaskParameter="DownloadedFile" ItemName="Content" />
		</DownloadFile>

		<Message Text="daisyUI mjs downloaded to $(DaisyUiPath)" Importance="high"
			Condition="!Exists('$(DaisyUiPath)')" />

		<DownloadFile SourceUrl="$(DaisyUiThemeUrl)" DestinationFolder="$(ToolDir)"
			Condition="!Exists('$(DaisyUiThemePath)')">
			<Output TaskParameter="DownloadedFile" ItemName="Content" />
		</DownloadFile>

		<Message Text="daisyUI theme mjs downloaded to $(DaisyUiThemePath)" Importance="high"
			Condition="!Exists('$(DaisyUiThemePath)')" />
	</Target>
<Project>

app.css(Tailwindcssのビルド元ファイル)に追記することで使用可能になります。Tailwind CLIの監視対象から外しておきます。

app.css
@import "tailwindcss";

@source not "./tailwindcss";
/* Tailwind CLIの相対パス */
@source not "./daisyui{,*}.mjs";
/* daisy UIの相対パス(以下同じ) */

@plugin "./daisyui.mjs"; 
@plugin "./daisyui-theme.mjs"{
  /* custom theme here */
}

おわりに

方法論としてはTailwind CLIのビルドをHookしてHot Reloadを発火できないかな、、、とも思っていましたがHot Reloadを意図的に発火させる方法をMicrosoftは提供していないようです。調べた限りですが。
Microsoft Learnと戦って、読みやすいリファレンスのありがたみをひしひしと実感することになりました。この記事が素晴らしきDXを手にしたいすべてのWindowsアプリのUIエンジニアの一助となれば幸いです。

脚注
  1. .NET Hot Reload support for ASP.NET core ↩︎

  2. MetadataUpdateHandlerAttribute クラス ↩︎

WOGO Tech blog
WOGO Tech blog

Discussion