在 2022 年時我曾經在 Vue CLI move to Vite 介紹過使用臺灣高手MakotoAtsu做的 Visual Studio 模板 AspNetCore_Vite_Starter ,完美整合 Vue 3 與 Dotnet Web API在同專案一次啟動。 但這有一個缺點,就是它必須在 ClientApp 目錄下產生開發憑證,在開發憑證過期時不會自動更新,同事常常會不知道為什麼發生錯誤。
因此我在最近一次新專案開發時,特地委請 Opus 5.5 幫我重新設計一個方式,使用另一個 Helper 來處理轉埠的問題。除了 Vue 改用 http 執行,不再用 https 執行外,其他使用與之前並無大差別。在原來的 Program.cs 改成
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.SpaServices; // 提供 UseProxyToSpaDevelopmentServer 等 SPA 擴充方法
app.UseSpaStaticFiles();
app.UseSpa(spa =>
{
spa.Options.SourcePath = "ClientApp";
if (app.Environment.IsDevelopment())
{
// 移掉原來的 spa.UseViteDevelopmentServer(sourcePath: "ClientApp");
// 開發時把非 API 請求轉給 Vite 開發伺服器(http),瀏覽器只連 Kestrel,
// Vite 不需要 HTTPS 憑證。Vite 尚未執行時會自動啟動 npm run dev。
Task<Uri> viteUri = ViteDevServer.EnsureStarted(
Path.Combine(app.Environment.ContentRootPath, "ClientApp"),
5173,
app.Lifetime,
app.Logger);
spa.UseProxyToSpaDevelopmentServer(() => viteUri);
}
});還要另外寫一個 ViteDevServer.cs 取代ViteHelper.cs,它會使用 P/Invoke 去偵測 Vite 的 port,確保在停止時會同步停止 Vite process,避免殘留 process在背景。傳統使用 ApplicationStopping 在 VS 停止偵錯時無法觸發,因此利用 Windows Job Object 確保父程序被強制終止時,子程序樹(Node.js)能隨之被 OS 一併回收。發布時透過 dotnet publish 觸發 npm run build,並將編譯出的 dist 由 ASP.NET Core 的 UseSpaStaticFiles / MapFallbackToFile 接管。以下是 ViteDevServer.cs 完整程式碼
using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Net.NetworkInformation; using System.Threading.Tasks; namespace DemoProject.Helper
{ /// <summary> /// 開發環境用:讓 dotnet run 一鍵啟動 Vite 開發伺服器。 /// 不處理 HTTPS 憑證,不再匯出 dotnet dev-certs 給 Vite(Kestrel 到 Vite 只走本機 http), /// 也不自動 npm install(缺 node_modules 時直接提示,避免啟動時默默跑很久)。 /// </summary> [ExcludeFromCodeCoverage] public static class ViteDevServer { /// <summary> /// 若指定埠尚未有人監聽,就在 <paramref name="sourcePath"/> 執行 npm run dev; /// 回傳的 Task 會在 Vite 開始監聽後完成,交給 UseProxyToSpaDevelopmentServer 等待 /// (等待上限為 SpaOptions.StartupTimeout),因此不會卡住後端啟動。 /// </summary> /// <param name="sourcePath">前端專案目錄(絕對路徑)</param> /// <param name="port">Vite 監聽埠,須與 vite.config.ts 的 server.port 一致</param> /// <param name="lifetime">用來在網站停止時一併結束 Vite</param> /// <param name="logger">記錄啟動狀況</param> /// <returns>Vite 開發伺服器的位址</returns> public static Task<Uri> EnsureStarted(string sourcePath, int port, IHostApplicationLifetime lifetime, ILogger logger) { Uri uri = new Uri($"http://localhost:{port}"); // 已有程式在監聽(例如自己另開了 npm run dev,或上次未正常結束留下的 Vite)就直接沿用 if (IsPortInUse(port)) { logger.LogInformation("埠 {Port} 已在使用中,沿用現有的 Vite 開發伺服器", port); return Task.FromResult(uri); } if (!Directory.Exists(Path.Combine(sourcePath, "node_modules"))) { throw new InvalidOperationException($"找不到 {sourcePath}\\node_modules,請先在該目錄執行 npm install"); } // VS 停止偵錯是直接終止程序,ApplicationStopping 不會執行; // 先把本程序加入 Job Object,之後啟動的 Vite 程序樹會隨本程序結束而被系統一併終止 if (OperatingSystem.IsWindows() && !KillOnExitJob.TryAssignCurrentProcess()) { logger.LogWarning("無法建立 Job Object,VS 停止偵錯時 Vite 可能殘留,下次啟動會沿用"); } // 不轉向輸出:Vite 的訊息會直接顯示在 dotnet run 的主控台。 // Windows 上 npm 是 npm.cmd,需透過 cmd /c 執行。 ProcessStartInfo startInfo = OperatingSystem.IsWindows() ? new ProcessStartInfo("cmd", "/c npm run dev") : new ProcessStartInfo("npm", "run dev"); startInfo.WorkingDirectory = sourcePath; startInfo.UseShellExecute = false; Process process = Process.Start(startInfo) ?? throw new InvalidOperationException("無法啟動 npm run dev"); logger.LogInformation("已啟動 Vite 開發伺服器(PID {Pid}),等待埠 {Port}", process.Id, port); // 網站正常停止時結束整個程序樹(cmd → npm → node),避免殘留的 Vite 佔住埠。 // Windows 上 Job Object 已涵蓋此情況,這裡主要給非 Windows 環境使用 lifetime.ApplicationStopping.Register(() => { try { if (!process.HasExited) { process.Kill(entireProcessTree: true); } } catch (InvalidOperationException) { // 程序已自行結束(例如主控台按 Ctrl+C 時 Vite 也一起收到中斷訊號),不需處理 } }); return WaitForPortAsync(process, port, uri); } /// <summary> /// 每 0.5 秒檢查一次,直到 Vite 開始監聽;若 npm 程序先結束(例如設定錯誤、埠衝突),則直接失敗。 /// 逾時由呼叫端(SpaProxy 的 StartupTimeout)控制。 /// </summary> private static async Task<Uri> WaitForPortAsync(Process process, int port, Uri uri) { while (!IsPortInUse(port)) { if (process.HasExited) { throw new InvalidOperationException($"npm run dev 已結束(結束代碼 {process.ExitCode}),請查看上方 Vite 輸出"); } await Task.Delay(500); } return uri; } /// <summary> /// 檢查本機是否已有程式監聽該埠(IPv4 與 IPv6 都算;Vite 的 localhost 可能只綁 ::1) /// </summary> private static bool IsPortInUse(int port) => IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpListeners().Any(e => e.Port == port); /// <summary> /// Windows Job Object:設定 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE 後,job 的最後一個 handle 關閉時 /// (即本程序結束,不論正常結束或被 TerminateProcess 強制終止),job 內所有程序都會被系統終止。 /// 子程序預設會繼承父程序的 job,因此只要本程序先加入,之後啟動的 cmd、npm、node 都會在 job 內。 /// </summary> [System.Runtime.Versioning.SupportedOSPlatform("windows")] private static class KillOnExitJob { private const int JobObjectExtendedLimitInformation = 9; private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000; /// <summary> /// job handle 刻意保存在靜態欄位且永不關閉:關閉它就會立刻終止 job 內所有程序(包含本程序) /// </summary> private static IntPtr jobHandle; /// <summary> /// 建立 job 並把本程序加入;已加入過則直接回傳成功 /// </summary> /// <returns>成功回傳 true</returns> public static bool TryAssignCurrentProcess() { if (jobHandle != IntPtr.Zero) { return true; } IntPtr job = CreateJobObject(IntPtr.Zero, null); if (job == IntPtr.Zero) { return false; } JOBOBJECT_EXTENDED_LIMIT_INFORMATION info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; int length = System.Runtime.InteropServices.Marshal.SizeOf<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>(); // Windows 8 起支援巢狀 job,即使 VS 已把本程序放進它自己的 job 也能再加入 if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, ref info, (uint)length) || !AssignProcessToJobObject(job, Process.GetCurrentProcess().Handle)) { CloseHandle(job); return false; } jobHandle = job; return true; } [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] private struct JOBOBJECT_BASIC_LIMIT_INFORMATION { public long PerProcessUserTimeLimit; public long PerJobUserTimeLimit; public uint LimitFlags; public UIntPtr MinimumWorkingSetSize; public UIntPtr MaximumWorkingSetSize; public uint ActiveProcessLimit; public UIntPtr Affinity; public uint PriorityClass; public uint SchedulingClass; } [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] private struct IO_COUNTERS { public ulong ReadOperationCount; public ulong WriteOperationCount; public ulong OtherOperationCount; public ulong ReadTransferCount; public ulong WriteTransferCount; public ulong OtherTransferCount; } [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION { public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; public IO_COUNTERS IoInfo; public UIntPtr ProcessMemoryLimit; public UIntPtr JobMemoryLimit; public UIntPtr PeakProcessMemoryUsed; public UIntPtr PeakJobMemoryUsed; } [System.Runtime.InteropServices.DllImport("kernel32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode, SetLastError = true)] private static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string? lpName); [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] private static extern bool SetInformationJobObject(IntPtr hJob, int infoType, ref JOBOBJECT_EXTENDED_LIMIT_INFORMATION lpJobObjectInfo, uint cbJobObjectInfoLength); [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] private static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess); [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] private static extern bool CloseHandle(IntPtr hObject); } } }
留言