Blazor Server,即運行在伺服器上的 Blazor 應用程式,它的優點是應用程式在首次運行時,客戶端不需要下載運行時。但它的代碼是在伺服器上執行的,然後通過 SignalR 通信來更新客戶端的 UI,所以它要求必須建立 Web Socket 連接。 用於 Blazor 應用的 Signal ...
Blazor Server,即運行在伺服器上的 Blazor 應用程式,它的優點是應用程式在首次運行時,客戶端不需要下載運行時。但它的代碼是在伺服器上執行的,然後通過 SignalR 通信來更新客戶端的 UI,所以它要求必須建立 Web Socket 連接。
用於 Blazor 應用的 SignalR Hub 是 ComponentHub,預設的連接地址是 /_blazor。多數時候我們不需要修改它,但人是一種喜歡折騰的動物,既然 MapBlazorHub 方法的重載也允許我們修改地址,那咱們何不試試。
app.MapBlazorHub("/myapp"); app.MapFallbackToPage("/_Host");
我把 ComponentHub 的通信地址改為 /myapp。這時候,客戶端上就不能使用 blazor.server.js 中的預設行為了,咱們必須手動啟動 Blazor 應用了(因為自動啟動用的是預設的 /_blazor 地址)。
<script src="_framework/blazor.server.js" autostart="false"></script> <script> Blazor.start({ configureSignalR: (connbuilder) => { connbuilder.withUrl("myapp"); } }); </script>
在引用 blazor.server.js 文件時,加上一個 autostart = "false",表示 blazor 應用手動啟動。哦,這個 autostart 是怎麼來的?來,咱們看看源代碼。在 BootCommon.ts 文件中,定義有一個名為 shouldAutoStart 的函數,而且它已導出。看名字就知道,它用來判斷是否自動啟動 Blazor 應用。
export function shouldAutoStart(): boolean { return !!(document && document.currentScript && document.currentScript.getAttribute('autostart') !== 'false'); }
現在,你明白這個 autostart 特性是怎麼回事了吧。
在調用 Blazor.start 方法時咱們要設定一個配置項—— configureSignalR。它指定一個函數,函數的參數是 HubConnectionBuilder 對象。這是 signalR.js 中的類型。再調用 withUrl 方法更改連接地址,預設的代碼是這樣的。
const connectionBuilder = new HubConnectionBuilder() .withUrl('_blazor') .withHubProtocol(hubProtocol);
很遺憾的是,運行後發現並不成功。
其實咱們的代碼並沒有錯,問題其實是出在 Blazor 自身的“八阿哥”上。別急,老周接下來一層層剝出這個問題,你會感嘆,官方團隊竟然會犯“高級錯誤”。
咱們先來解釋這個奇葩的錯誤信息,什麼JSON格式不對?什麼無效的字元“<”?這個錯誤信息很容易誤導你,咱們看看下麵的圖。
在請求到 blazor.server.js 腳本後,訪問了一個 /initializers 地址。這個 fetch 是 Blazor 應用發出的,其目的是問一下伺服器,在 Blazor 應用啟動前後,有沒用自定義的初始化腳本。
為啥會有這個請求?我們來看看伺服器端的源代碼。
public static ComponentEndpointConventionBuilder MapBlazorHub( this IEndpointRouteBuilder endpoints, string path, Action<HttpConnectionDispatcherOptions> configureOptions) { …… var hubEndpoint = endpoints.MapHub<ComponentHub>(path, configureOptions); var disconnectEndpoint = endpoints.Map( (path.EndsWith('/') ? path : path + "/") + "disconnect/", endpoints.CreateApplicationBuilder().UseMiddleware<CircuitDisconnectMiddleware>().Build()) .WithDisplayName("Blazor disconnect"); var jsInitializersEndpoint = endpoints.Map( (path.EndsWith('/') ? path : path + "/") + "initializers/", endpoints.CreateApplicationBuilder().UseMiddleware<CircuitJavaScriptInitializationMiddleware>().Build()) .WithDisplayName("Blazor initializers"); return new ComponentEndpointConventionBuilder(hubEndpoint, disconnectEndpoint, jsInitializersEndpoint); }
如你所見,當你調用 MapBlazorHub 方法時,它同時註冊了兩個終結點:
a、/_blazor/disconnect/:斷開 SignalR 連接時訪問,由 CircuitDisconnectMiddleware 中間件負責處理。
b、/_blazor/initializers/:對,這個就是咱們在瀏覽器中看到的那個,請求初始化腳本的。由 CircuitJavaScriptInitializationMiddleware 中間件負責處理。
老周改了 Hub 地址是 myapp,所以這兩路徑應變為 /myapp/disconnect/ 和 /myapp/initializers/。
這個 initialicaers/ 地址返回的數據要求是 JSON 格式的,是一個字元串數組,表示初始化腳本的文件路徑。
咱們繼續跟蹤,找到 CircuitJavaScriptInitializationMiddleware 中間件。
public async Task InvokeAsync(HttpContext context) { await context.Response.WriteAsJsonAsync(_initializers); }
就這?對,就一行,_initializers 是選項類 CircuitOptions 的 JavaScriptInitializers 屬性。
internal IList<string> JavaScriptInitializers { get; } = new List<string>();
這廝還是 internal 的,也就是說你寫的代碼不能修改它。它是用 CircuitOptionsJavaScriptInitializersConfiguration 對象來設置的。
public void Configure(CircuitOptions options) { var file = _environment.WebRootFileProvider.GetFileInfo($"{_environment.ApplicationName}.modules.json"); if (file.Exists) { var initializers = JsonSerializer.Deserialize<string[]>(file.CreateReadStream()); for (var i = 0; i < initializers.Length; i++) { var initializer = initializers[i]; options.JavaScriptInitializers.Add(initializer); } } }
老周解釋一下:上面代碼是說在 Web 目錄下(預設就是靜態文件專用的 wwwroot)下找到一個名為 {你的應用}.modules.json 的文件,然後讀出來,再添加到 JavaScriptInitializers 屬性中。
假如我們應用程式叫 BugApp,那麼要找的這個JSON文件就是 BugApp.modules.json。這個JSON文件既可以自動生成,也可以你手動添加。
你在 wwwroot 目錄下添加一個 js 文件,命名為 BugApp.lib.module.js。在生成項目時,會自動產生這個 JSON 文件。在生成後你是看不到 BugApp.modules.json 文件的,而是在 Debug|Release 目錄下有個 BugApp.staticwebassets.runtime.json。
{ "ContentRoots": [ "C:\\XXXX\\BugApp\\wwwroot\\", "C:\\XXXX\\BugApp\\obj\\Debug\\net7.0\\jsmodules\\" ], "Root": { …… "BugApp.modules.json": { "Children": null, "Asset": { "ContentRootIndex": 1, "SubPath": "jsmodules.build.manifest.json" }, "Patterns": null } }, …… } }
終於見到它了,它指向的是 obj 目錄下的 jsmodules.build.manifest.json 文件,ContentRootIndex : 1 表示 ContentRoots 節點中的第二個元素,即 obj\Debug\net7.0\jsmodules\jsmodules.build.manifest.json。打開這個文件看看有啥。
[ "BugApp.lib.module.js" ]
如果你找不到這個文件,說明你沒有生成項目,生成一下就有了。註意,這個文件只有你【發佈】項目後才會出現在 wwwroot 目錄下的。
看到沒?就是一個 JSON 數組,然後列出我剛剛添加的 js 腳本。客戶端訪問 ./initializers 就是為了獲得這個文件。現在你回想一下瀏覽器報的那個錯誤,是不是知道為什麼會說無效的 JSON 文件了吧。
客戶端所請求的地址仍是預設的 /_blazor/initializers/ ,而我已經改為 /myapp 了,它本應該請求 /myapp/initializers 的,可是,blazor 並沒這麼做。那,我們能在 js 代碼中配置嗎?唉!官方團隊犯的“高級”錯誤,居然把 URL 寫死在代碼中。可以看看 JSInitializers.Server.ts 文件中是怎麼寫的。
export async function fetchAndInvokeInitializers(options: Partial<CircuitStartOptions>) : Promise<JSInitializer> { const jsInitializersResponse = await fetch('_blazor/initializers', { method: 'GET', credentials: 'include', cache: 'no-cache', }); …… }
你看是不是這樣?都 TM 的硬編碼了,還怎麼配置?哦,還沒回答一個問題:既然找到問題所在了,那為什麼會報無效 JSON 格式的錯誤?答:因為 /_blazor 被我改了,所以請求 /_blazor/initializers 是 404 的,但,我們為了讓 Blazor 能啟動,調用了 MapFallbackToPage 方法作為後備。
app.MapFallbackToPage("/_Host");
這樣就導致在訪問 /_blazor/initializers 得到404後轉而返回 /_Host,也就是說,/initializers 獲取一個 HTML 文檔,HTML 文檔的第一個字元不就是“<”嗎,所以就是無效字元了,不是JSON。
所以,你說,這不是“八阿哥”是啥?如果你非要改掉預設地址,又想正常獲取初始化腳本,咋整?
A方案:下載 TypeScript 源碼,自己修改,然後編譯。
B方案:我們在 HTTP 管道上加個中間件,把 /myapp 改回 /_blazor。
這裡老周演示一下 B 方案。
// Blazor signalR Hub 的自定義地址 const string NewBlazorHubUrl = "/myapp"; app.UseStaticFiles(); // 要在路由中間件之前改地址 app.Use(async (context, next) => { if(context.Request.Path.StartsWithSegments("/_blazor", StringComparison.OrdinalIgnoreCase)) { var repl = context.Request.Path.ToString().Replace("/_blazor", NewBlazorHubUrl); context.Request.Path = repl; } await next(); }); // 註意順序 app.UseRouting(); app.MapBlazorHub(NewBlazorHubUrl); app.MapFallbackToPage("/_Host");
因為新地址是 /myapp 開頭,我們只要把以 /_blazor 開頭的地址改為 /myapp 開頭就行了。這個中間件一定要在路由中間件之前改地址。改地址後再做路由匹配才有意義。
當然,想簡潔一點的,還可以用 URL Rewrite。
var rwtopt = new RewriteOptions() .AddRewrite("^_blazor/(.+)", "myapp/$1", true); app.UseRewriter(rwtopt); app.UseRouting(); app.MapBlazorHub("/myapp"); app.MapFallbackToPage("/_Host"); app.Run();
替換時用的正則表達式,我們匹配 _blazor 後的內容,即 initializers,然後替換為 myapp + initializers。“$1”引用正則中匹配的分組,即“.+”,匹配一個以上任意字元。URL 重寫時,不需要指定開頭的“/”,所以處理的是 _blazor/... 而不是 /_blazor/...。
前面我們提到了 BugApp.lib.module.js 腳本。乾脆咱們也寫一個自定義腳本。在 wwwroot 目錄下添加一個 BugApp.lib.module.js 文件。BugApp 是項目名稱,你要根據實際來改。
export function beforeStart() { console.log("Blazor應用即將啟動"); } export function afterStarted() { console.log("Blazor應用已啟動"); }
在這個腳本中,我們要導出兩個函數:
beforeStart:在 Blazor 啟動之前被調用。
afterStarted:在 Blazor 啟動之後被調用。
現在,再次運行程式,用開發人員工具查看“控制台”消息,會看到這兩條輸出。
想玩直觀一點的話,也可以修改 HTML 文檔。
export function beforeStart() { let ele = document.createElement("div"); // 設置樣式 ele.style = 'color: green; margin-top: 16px'; // 文本 ele.textContent = "Blazor應用即將啟動"; document.body.append(ele); } export function afterStarted() { let ele = document.createElement("div"); ele.style = 'color: orange; margin-top: 15px'; ele.textContent = "Blazor應用已經啟動"; document.body.append(ele); }
運行之後,頁面上會動態加了兩個 <div> 元素。
XXX.lib.module.js 這個文件名是固定的,如果想自定義文件名,或想返回多個 js 文件,可以自己手動處理。
在 wwwroot 目錄下添加一個名為 BugApp.modules.json 的文件。
[ "abc.js", "def.js", "opq.js" ]
以JSON數組的格式把你想用的初始化腳本寫上。再次運行程式,就會下載這個文件,讀取三個文件並將其下載。
你得註意,你指定的這些腳本必須是可訪問,有效的,不然 Blazor 會啟動失敗。
好了,今天就說到這兒了,主要是發現了一個“八阿哥”。