[{"data":1,"prerenderedAt":3823},["ShallowReactive",2],{"page-\u002Fnetwork\u002Fwebsocket的相关实践指导":3},{"id":4,"title":5,"body":6,"description":12,"extension":3812,"meta":3813,"navigation":280,"path":3819,"seo":3820,"stem":3821,"__hash__":3822},"content\u002Fnetwork\u002FWebSocket的相关实践指导.md","WebSocket的相关实践指导",{"type":7,"value":8,"toc":3803},"minimark",[9,13,18,29,190,193,535,538,925,928,1628,1662,1665,1668,1673,1680,1716,1973,1986,2138,2168,2171,2178,2251,2257,2400,2407,2452,2455,2461,2606,2609,2612,2632,2956,2965,2968,2971,3081,3084,3087,3090,3121,3139,3143,3154,3162,3753,3766,3779,3799],[10,11,12],"p",{},"这个笔记用于指导ws协议的相关实践，本身是对tcp协议里大致提了ws一些的补充，这里可以链接过去[[TCP协议]]",[14,15,17],"h2",{"id":16},"signalr","SignalR",[10,19,20,24,25,28],{},[21,22,23],"strong",{},"SignalR 是微软推出的一个实时通信库","，核心目标是让服务器能",[21,26,27],{},"主动推送","消息给客户端，而不需要客户端反复轮询。客户端和服务器协商后，自动选择可用的最高优先级方式，开发者不需要关心底层细节。",[30,31,36,40,44,55,65,71,77,83,88,93,100,106,111,114,118,122,125,129,133,136,140,144,147,151,155,159,163,166,168,171,174,176,178,181,186],"svg",{"viewBox":32,"width":33,"role":34,"xmlns":35},"0 0 680 378","100%","img","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[37,38,39],"title",{},"SignalR 与 useWebSocket 的抽象层次对比",[41,42,43],"desc",{},"SignalR 覆盖从业务代码到服务端 Hub 的完整五层，useWebSocket 只有三层且不提供服务端、group 与广播能力。",[45,46],"rect",{"x":47,"y":48,"width":49,"height":50,"rx":51,"fill":52,"stroke":53,"strokeWidth":54},"40","32","600","326","20","#F7F6F3","#D3D1C7","0.5",[56,57,64],"text",{"x":58,"y":59,"fontFamily":60,"fontSize":61,"fontWeight":62,"fill":63},"60","58","system-ui, -apple-system, 'Segoe UI', sans-serif","15","500","#2C2C2A","SignalR vs useWebSocket：各自覆盖到哪一层",[56,66,70],{"x":58,"y":67,"fontFamily":60,"fontSize":68,"fill":69},"78","12","#5F5E5A","左边是一整套端到端框架，右边只是一层浏览器端封装",[56,72,76],{"x":58,"y":73,"fontFamily":60,"fontSize":74,"fontWeight":62,"fill":75},"106","13","#0C447C","\nSignalR\n",[56,78,82],{"x":79,"y":73,"fontFamily":60,"fontSize":80,"fill":81},"130","11","#185FA5","\n端到端框架 · 含服务端\n",[56,84,87],{"x":85,"y":73,"fontFamily":60,"fontSize":74,"fontWeight":62,"fill":86},"376","#085041","\nuseWebSocket\n",[56,89,92],{"x":90,"y":73,"fontFamily":60,"fontSize":80,"fill":91},"466","#0F6E56","\n客户端 composable\n",[45,94],{"x":58,"y":95,"width":96,"height":97,"rx":98,"fill":99,"stroke":81,"strokeWidth":54},"118","280","38","8","#E6F1FB",[56,101,105],{"x":102,"y":103,"fontFamily":60,"fontSize":74,"fontWeight":62,"fill":104},"76","133","#042C53","你的业务代码",[56,107,110],{"x":102,"y":108,"fontFamily":109,"fontSize":80,"fill":81},"148","ui-monospace, SFMono-Regular, Menlo, monospace","hub.invoke(\"SendMessage\", msg)",[45,112],{"x":58,"y":113,"width":96,"height":97,"rx":98,"fill":99,"stroke":81,"strokeWidth":54},"164",[56,115,117],{"x":102,"y":116,"fontFamily":60,"fontSize":74,"fontWeight":62,"fill":104},"179","Hub 抽象",[56,119,121],{"x":102,"y":120,"fontFamily":109,"fontSize":80,"fill":81},"194","invoke \u002F on \u002F group · 跨两端",[45,123],{"x":58,"y":124,"width":96,"height":97,"rx":98,"fill":99,"stroke":81,"strokeWidth":54},"210",[56,126,128],{"x":102,"y":127,"fontFamily":60,"fontSize":74,"fontWeight":62,"fill":104},"225","Hub Protocol",[56,130,132],{"x":102,"y":131,"fontFamily":109,"fontSize":80,"fill":81},"240","0x1E 分隔的 JSON 信封",[45,134],{"x":58,"y":135,"width":96,"height":97,"rx":98,"fill":99,"stroke":81,"strokeWidth":54},"256",[56,137,139],{"x":102,"y":138,"fontFamily":60,"fontSize":74,"fontWeight":62,"fill":104},"271","传输协商",[56,141,143],{"x":102,"y":142,"fontFamily":109,"fontSize":80,"fill":81},"286","WS → SSE → Long Polling 择优",[45,145],{"x":58,"y":146,"width":96,"height":97,"rx":98,"fill":99,"stroke":81,"strokeWidth":54},"302",[56,148,150],{"x":102,"y":149,"fontFamily":60,"fontSize":74,"fontWeight":62,"fill":104},"317","原生 WebSocket",[56,152,154],{"x":102,"y":153,"fontFamily":109,"fontSize":80,"fill":81},"332","浏览器提供，协议层",[45,156],{"x":85,"y":95,"width":157,"height":97,"rx":98,"fill":158,"stroke":91,"strokeWidth":54},"264","#E1F5EE",[56,160,105],{"x":161,"y":103,"fontFamily":60,"fontSize":74,"fontWeight":62,"fill":162},"392","#04342C",[56,164,165],{"x":161,"y":108,"fontFamily":109,"fontSize":80,"fill":91},"自己定信封 {\"type\":\"chat\"}",[45,167],{"x":85,"y":113,"width":157,"height":97,"rx":98,"fill":158,"stroke":91,"strokeWidth":54},[56,169,170],{"x":161,"y":116,"fontFamily":60,"fontSize":74,"fontWeight":62,"fill":162},"useWebSocket",[56,172,173],{"x":161,"y":120,"fontFamily":109,"fontSize":80,"fill":91},"重连 · 心跳 · 响应式",[45,175],{"x":85,"y":124,"width":157,"height":97,"rx":98,"fill":158,"stroke":91,"strokeWidth":54},[56,177,150],{"x":161,"y":127,"fontFamily":60,"fontSize":74,"fontWeight":62,"fill":162},[56,179,180],{"x":161,"y":131,"fontFamily":109,"fontSize":80,"fill":91},"只有这一种传输，不降级",[56,182,185],{"x":85,"y":183,"fontFamily":60,"fontSize":68,"fill":184},"272","#993C1D","\n服务端、group、广播都得你自己写\n",[56,187,189],{"x":85,"y":188,"fontFamily":60,"fontSize":68,"fill":184},"292","\n它到浏览器为止，不跨这条线\n",[10,191,192],{},"对于多标签共享一条，也就是SharedWorker，SignalR是唯一现成的库。",[194,195,200],"pre",{"className":196,"code":197,"language":198,"meta":199,"style":199},"language-ts shiki shiki-themes github-light github-dark","\u002F\u002F workers\u002Fchat.shared.ts —— 用 dist\u002Fwebworker\u002Fsignalr.js，打包器会按 worker 条件解析\nconst conn = new HubConnectionBuilder().withUrl('\u002Fhubs\u002Fchat').withAutomaticReconnect().build()\nawait conn.start()\n\nconst ports: MessagePort[] = []\n\nself.onconnect = (e: MessageEvent) => {\n  const port = e.ports[0]\n  ports.push(port)\n  port.onmessage = ({ data }) => {\n    if (data.type === 'send') conn.send('SendMessage', data.user, data.text)\n  }\n  port.start()\n}\n\n\u002F\u002F 一条连接扇出给所有标签页\nconn.on('ReceiveMessage', (user: string, text: string) => {\n  for (const p of ports) p.postMessage({ type: 'ReceiveMessage', user, text })\n})\n\n","ts","",[201,202,203,212,261,275,282,305,310,342,362,374,398,427,433,442,448,453,459,499,529],"code",{"__ignoreMap":199},[204,205,208],"span",{"class":206,"line":207},"line",1,[204,209,211],{"class":210},"sJ8bj","\u002F\u002F workers\u002Fchat.shared.ts —— 用 dist\u002Fwebworker\u002Fsignalr.js，打包器会按 worker 条件解析\n",[204,213,215,219,223,226,229,233,237,240,243,247,250,253,255,258],{"class":206,"line":214},2,[204,216,218],{"class":217},"szBVR","const",[204,220,222],{"class":221},"sj4cs"," conn",[204,224,225],{"class":217}," =",[204,227,228],{"class":217}," new",[204,230,232],{"class":231},"sScJk"," HubConnectionBuilder",[204,234,236],{"class":235},"sVt8B","().",[204,238,239],{"class":231},"withUrl",[204,241,242],{"class":235},"(",[204,244,246],{"class":245},"sZZnC","'\u002Fhubs\u002Fchat'",[204,248,249],{"class":235},").",[204,251,252],{"class":231},"withAutomaticReconnect",[204,254,236],{"class":235},[204,256,257],{"class":231},"build",[204,259,260],{"class":235},"()\n",[204,262,264,267,270,273],{"class":206,"line":263},3,[204,265,266],{"class":217},"await",[204,268,269],{"class":235}," conn.",[204,271,272],{"class":231},"start",[204,274,260],{"class":235},[204,276,278],{"class":206,"line":277},4,[204,279,281],{"emptyLinePlaceholder":280},true,"\n",[204,283,285,287,290,293,296,299,302],{"class":206,"line":284},5,[204,286,218],{"class":217},[204,288,289],{"class":221}," ports",[204,291,292],{"class":217},":",[204,294,295],{"class":231}," MessagePort",[204,297,298],{"class":235},"[] ",[204,300,301],{"class":217},"=",[204,303,304],{"class":235}," []\n",[204,306,308],{"class":206,"line":307},6,[204,309,281],{"emptyLinePlaceholder":280},[204,311,313,316,319,321,324,328,330,333,336,339],{"class":206,"line":312},7,[204,314,315],{"class":235},"self.",[204,317,318],{"class":231},"onconnect",[204,320,225],{"class":217},[204,322,323],{"class":235}," (",[204,325,327],{"class":326},"s4XuR","e",[204,329,292],{"class":217},[204,331,332],{"class":231}," MessageEvent",[204,334,335],{"class":235},") ",[204,337,338],{"class":217},"=>",[204,340,341],{"class":235}," {\n",[204,343,345,348,351,353,356,359],{"class":206,"line":344},8,[204,346,347],{"class":217},"  const",[204,349,350],{"class":221}," port",[204,352,225],{"class":217},[204,354,355],{"class":235}," e.ports[",[204,357,358],{"class":221},"0",[204,360,361],{"class":235},"]\n",[204,363,365,368,371],{"class":206,"line":364},9,[204,366,367],{"class":235},"  ports.",[204,369,370],{"class":231},"push",[204,372,373],{"class":235},"(port)\n",[204,375,377,380,383,385,388,391,394,396],{"class":206,"line":376},10,[204,378,379],{"class":235},"  port.",[204,381,382],{"class":231},"onmessage",[204,384,225],{"class":217},[204,386,387],{"class":235}," ({ ",[204,389,390],{"class":326},"data",[204,392,393],{"class":235}," }) ",[204,395,338],{"class":217},[204,397,341],{"class":235},[204,399,401,404,407,410,413,416,419,421,424],{"class":206,"line":400},11,[204,402,403],{"class":217},"    if",[204,405,406],{"class":235}," (data.type ",[204,408,409],{"class":217},"===",[204,411,412],{"class":245}," 'send'",[204,414,415],{"class":235},") conn.",[204,417,418],{"class":231},"send",[204,420,242],{"class":235},[204,422,423],{"class":245},"'SendMessage'",[204,425,426],{"class":235},", data.user, data.text)\n",[204,428,430],{"class":206,"line":429},12,[204,431,432],{"class":235},"  }\n",[204,434,436,438,440],{"class":206,"line":435},13,[204,437,379],{"class":235},[204,439,272],{"class":231},[204,441,260],{"class":235},[204,443,445],{"class":206,"line":444},14,[204,446,447],{"class":235},"}\n",[204,449,451],{"class":206,"line":450},15,[204,452,281],{"emptyLinePlaceholder":280},[204,454,456],{"class":206,"line":455},16,[204,457,458],{"class":210},"\u002F\u002F 一条连接扇出给所有标签页\n",[204,460,462,465,468,470,473,476,479,481,484,487,489,491,493,495,497],{"class":206,"line":461},17,[204,463,464],{"class":235},"conn.",[204,466,467],{"class":231},"on",[204,469,242],{"class":235},[204,471,472],{"class":245},"'ReceiveMessage'",[204,474,475],{"class":235},", (",[204,477,478],{"class":326},"user",[204,480,292],{"class":217},[204,482,483],{"class":221}," string",[204,485,486],{"class":235},", ",[204,488,56],{"class":326},[204,490,292],{"class":217},[204,492,483],{"class":221},[204,494,335],{"class":235},[204,496,338],{"class":217},[204,498,341],{"class":235},[204,500,502,505,507,509,512,515,518,521,524,526],{"class":206,"line":501},18,[204,503,504],{"class":217},"  for",[204,506,323],{"class":235},[204,508,218],{"class":217},[204,510,511],{"class":221}," p",[204,513,514],{"class":217}," of",[204,516,517],{"class":235}," ports) p.",[204,519,520],{"class":231},"postMessage",[204,522,523],{"class":235},"({ type: ",[204,525,472],{"class":245},[204,527,528],{"class":235},", user, text })\n",[204,530,532],{"class":206,"line":531},19,[204,533,534],{"class":235},"})\n",[10,536,537],{},"后端部分可能长这样：",[194,539,543],{"className":540,"code":541,"language":542,"meta":199,"style":199},"language-cs shiki shiki-themes github-light github-dark","\u002F\u002F Hubs\u002FChatHub.cs —— 一个类就是一组可被客户端调用的方法\npublic sealed class ChatHub : Hub\n{\n    \u002F\u002F 客户端 conn.invoke('SendMessage', user, text) 落到这里\n    public Task SendMessage(string user, string text)\n        => Clients.All.SendAsync(\"ReceiveMessage\", user, text);\n\n    public Task\u003Cstring> Ping() => Task.FromResult(\"pong\");   \u002F\u002F 有返回值，客户端 await 得到 \"pong\"\n\n    \u002F\u002F group 是白送的，不用自己维护连接表\n    public Task JoinRoom(string room)\n        => Groups.AddToGroupAsync(Context.ConnectionId, $\"room:{room}\");\n\n    public Task SendToRoom(string room, string text)\n        => Clients.Group($\"room:{room}\").SendAsync(\"ReceiveMessage\", \"room\", text);\n}\n\n\u002F\u002F Program.cs\nbuilder.Services.AddSignalR(o =>\n{\n    o.KeepAliveInterval     = TimeSpan.FromSeconds(15); \u002F\u002F 默认值，服务端发 {\"type\":6} ping\n    o.ClientTimeoutInterval = TimeSpan.FromSeconds(30); \u002F\u002F 默认 2x，30s 内无数据判死\n    o.MaximumReceiveMessageSize = 32 * 1024;            \u002F\u002F 单条上限 32KB，超了直接断\n});\napp.MapHub\u003CChatHub>(\"\u002Fhubs\u002Fchat\");\n\n\n","cs",[201,544,545,550,570,575,580,609,628,632,671,675,680,698,729,733,756,794,798,802,807,823,828,852,874,897,903],{"__ignoreMap":199},[204,546,547],{"class":206,"line":207},[204,548,549],{"class":210},"\u002F\u002F Hubs\u002FChatHub.cs —— 一个类就是一组可被客户端调用的方法\n",[204,551,552,555,558,561,564,567],{"class":206,"line":214},[204,553,554],{"class":217},"public",[204,556,557],{"class":217}," sealed",[204,559,560],{"class":217}," class",[204,562,563],{"class":231}," ChatHub",[204,565,566],{"class":235}," : ",[204,568,569],{"class":231},"Hub\n",[204,571,572],{"class":206,"line":263},[204,573,574],{"class":235},"{\n",[204,576,577],{"class":206,"line":277},[204,578,579],{"class":210},"    \u002F\u002F 客户端 conn.invoke('SendMessage', user, text) 落到这里\n",[204,581,582,585,588,591,593,596,599,601,603,606],{"class":206,"line":284},[204,583,584],{"class":217},"    public",[204,586,587],{"class":231}," Task",[204,589,590],{"class":231}," SendMessage",[204,592,242],{"class":235},[204,594,595],{"class":217},"string",[204,597,598],{"class":231}," user",[204,600,486],{"class":235},[204,602,595],{"class":217},[204,604,605],{"class":231}," text",[204,607,608],{"class":235},")\n",[204,610,611,614,617,620,622,625],{"class":206,"line":307},[204,612,613],{"class":217},"        =>",[204,615,616],{"class":235}," Clients.All.",[204,618,619],{"class":231},"SendAsync",[204,621,242],{"class":235},[204,623,624],{"class":245},"\"ReceiveMessage\"",[204,626,627],{"class":235},", user, text);\n",[204,629,630],{"class":206,"line":312},[204,631,281],{"emptyLinePlaceholder":280},[204,633,634,636,638,641,643,646,649,652,654,657,660,662,665,668],{"class":206,"line":344},[204,635,584],{"class":217},[204,637,587],{"class":231},[204,639,640],{"class":235},"\u003C",[204,642,595],{"class":217},[204,644,645],{"class":235},"> ",[204,647,648],{"class":231},"Ping",[204,650,651],{"class":235},"() ",[204,653,338],{"class":217},[204,655,656],{"class":235}," Task.",[204,658,659],{"class":231},"FromResult",[204,661,242],{"class":235},[204,663,664],{"class":245},"\"pong\"",[204,666,667],{"class":235},");   ",[204,669,670],{"class":210},"\u002F\u002F 有返回值，客户端 await 得到 \"pong\"\n",[204,672,673],{"class":206,"line":364},[204,674,281],{"emptyLinePlaceholder":280},[204,676,677],{"class":206,"line":376},[204,678,679],{"class":210},"    \u002F\u002F group 是白送的，不用自己维护连接表\n",[204,681,682,684,686,689,691,693,696],{"class":206,"line":400},[204,683,584],{"class":217},[204,685,587],{"class":231},[204,687,688],{"class":231}," JoinRoom",[204,690,242],{"class":235},[204,692,595],{"class":217},[204,694,695],{"class":231}," room",[204,697,608],{"class":235},[204,699,700,702,705,708,711,714,717,720,723,726],{"class":206,"line":429},[204,701,613],{"class":217},[204,703,704],{"class":235}," Groups.",[204,706,707],{"class":231},"AddToGroupAsync",[204,709,710],{"class":235},"(Context.ConnectionId, ",[204,712,713],{"class":245},"$\"room:",[204,715,716],{"class":245},"{",[204,718,719],{"class":235},"room",[204,721,722],{"class":245},"}",[204,724,725],{"class":245},"\"",[204,727,728],{"class":235},");\n",[204,730,731],{"class":206,"line":435},[204,732,281],{"emptyLinePlaceholder":280},[204,734,735,737,739,742,744,746,748,750,752,754],{"class":206,"line":444},[204,736,584],{"class":217},[204,738,587],{"class":231},[204,740,741],{"class":231}," SendToRoom",[204,743,242],{"class":235},[204,745,595],{"class":217},[204,747,695],{"class":231},[204,749,486],{"class":235},[204,751,595],{"class":217},[204,753,605],{"class":231},[204,755,608],{"class":235},[204,757,758,760,763,766,768,770,772,774,776,778,780,782,784,786,788,791],{"class":206,"line":450},[204,759,613],{"class":217},[204,761,762],{"class":235}," Clients.",[204,764,765],{"class":231},"Group",[204,767,242],{"class":235},[204,769,713],{"class":245},[204,771,716],{"class":245},[204,773,719],{"class":235},[204,775,722],{"class":245},[204,777,725],{"class":245},[204,779,249],{"class":235},[204,781,619],{"class":231},[204,783,242],{"class":235},[204,785,624],{"class":245},[204,787,486],{"class":235},[204,789,790],{"class":245},"\"room\"",[204,792,793],{"class":235},", text);\n",[204,795,796],{"class":206,"line":455},[204,797,447],{"class":235},[204,799,800],{"class":206,"line":461},[204,801,281],{"emptyLinePlaceholder":280},[204,803,804],{"class":206,"line":501},[204,805,806],{"class":210},"\u002F\u002F Program.cs\n",[204,808,809,812,815,817,820],{"class":206,"line":531},[204,810,811],{"class":235},"builder.Services.",[204,813,814],{"class":231},"AddSignalR",[204,816,242],{"class":235},[204,818,819],{"class":231},"o",[204,821,822],{"class":217}," =>\n",[204,824,826],{"class":206,"line":825},20,[204,827,574],{"class":235},[204,829,831,834,836,839,842,844,846,849],{"class":206,"line":830},21,[204,832,833],{"class":235},"    o.KeepAliveInterval     ",[204,835,301],{"class":217},[204,837,838],{"class":235}," TimeSpan.",[204,840,841],{"class":231},"FromSeconds",[204,843,242],{"class":235},[204,845,61],{"class":221},[204,847,848],{"class":235},"); ",[204,850,851],{"class":210},"\u002F\u002F 默认值，服务端发 {\"type\":6} ping\n",[204,853,855,858,860,862,864,866,869,871],{"class":206,"line":854},22,[204,856,857],{"class":235},"    o.ClientTimeoutInterval ",[204,859,301],{"class":217},[204,861,838],{"class":235},[204,863,841],{"class":231},[204,865,242],{"class":235},[204,867,868],{"class":221},"30",[204,870,848],{"class":235},[204,872,873],{"class":210},"\u002F\u002F 默认 2x，30s 内无数据判死\n",[204,875,877,880,882,885,888,891,894],{"class":206,"line":876},23,[204,878,879],{"class":235},"    o.MaximumReceiveMessageSize ",[204,881,301],{"class":217},[204,883,884],{"class":221}," 32",[204,886,887],{"class":217}," *",[204,889,890],{"class":221}," 1024",[204,892,893],{"class":235},";            ",[204,895,896],{"class":210},"\u002F\u002F 单条上限 32KB，超了直接断\n",[204,898,900],{"class":206,"line":899},24,[204,901,902],{"class":235},"});\n",[204,904,906,909,912,914,917,920,923],{"class":206,"line":905},25,[204,907,908],{"class":235},"app.",[204,910,911],{"class":231},"MapHub",[204,913,640],{"class":235},[204,915,916],{"class":231},"ChatHub",[204,918,919],{"class":235},">(",[204,921,922],{"class":245},"\"\u002Fhubs\u002Fchat\"",[204,924,728],{"class":235},[10,926,927],{},"我们在前端的代码就长这样，你可以在composables里完成：",[194,929,931],{"className":196,"code":930,"language":198,"meta":199,"style":199},"import { HubConnectionBuilder, HubConnectionState, LogLevel } from '@microsoft\u002Fsignalr'\n\nlet conn: HubConnection | null = null   \u002F\u002F 模块级单例，别在组件里各建一条\n\nexport function useChatHub() {\n  const status = ref(HubConnectionState.Disconnected)\n  const messages = shallowRef\u003CChatMessage[]>([])\n\n  async function connect() {\n    if (conn) return conn\n\n    conn = new HubConnectionBuilder()\n      .withUrl('\u002Fhubs\u002Fchat')                 \u002F\u002F 第一跳是 POST \u002Fhubs\u002Fchat\u002Fnegotiate，不是 WS\n      .withAutomaticReconnect()              \u002F\u002F 源码默认退避 [0, 2s, 10s, 30s]，之后放弃\n      .configureLogging(LogLevel.Warning)\n      .build()\n\n    \u002F\u002F 服务端 -> 客户端：这个 key 必须和服务端 SendAsync(\"ReceiveMessage\") 的字符串完全一致\n    conn.on('ReceiveMessage', (user: string, text: string) => {\n      messages.value = [...messages.value, { user, text }]\n    })\n\n    conn.onreconnecting(e => console.warn('reconnecting', e?.message))\n    conn.onreconnected(async () => {\n      \u002F\u002F 重连后 connectionId 变了，之前 join 的 group 全部丢失，必须重新 join\n      await conn!.invoke('JoinRoom', 'lobby')\n    })\n    conn.onclose(e => { status.value = HubConnectionState.Disconnected })\n\n    await conn.start()\n    status.value = conn.state\n    await conn.invoke('JoinRoom', 'lobby')\n    return conn\n  }\n\n  \u002F\u002F invoke 等返回值（RPC 语义）；send 发完就走，少一个 RTT 的等待\n  const sendMessage = (user: string, text: string) =>\n    conn?.state === HubConnectionState.Connected\n      ? conn.send('SendMessage', user, text)\n      : Promise.resolve()\n\n  \u002F\u002F 服务端 IAsyncEnumerable\u003CT> \u002F ChannelReader\u003CT> 持续推，客户端订阅\n  function subscribeCounter() {\n    const sub = conn!.stream\u003Cnumber>('Counter', 10)\n    sub.subscribe({ next: n => console.log(n), complete: () => {}, error: e => console.error(e) })\n    return () => sub.dispose()\n  }\n\n  onScopeDispose(() => { conn?.stop(); conn = null })\n  return { status, messages, connect, sendMessage, subscribeCounter }\n}\n\n",[201,932,933,947,951,976,980,994,1009,1029,1033,1045,1058,1062,1075,1092,1104,1114,1122,1126,1131,1164,1180,1185,1189,1217,1236,1241,1270,1275,1297,1302,1314,1325,1344,1352,1357,1362,1368,1399,1410,1427,1443,1448,1454,1465,1502,1560,1577,1582,1587,1614,1623],{"__ignoreMap":199},[204,934,935,938,941,944],{"class":206,"line":207},[204,936,937],{"class":217},"import",[204,939,940],{"class":235}," { HubConnectionBuilder, HubConnectionState, LogLevel } ",[204,942,943],{"class":217},"from",[204,945,946],{"class":245}," '@microsoft\u002Fsignalr'\n",[204,948,949],{"class":206,"line":214},[204,950,281],{"emptyLinePlaceholder":280},[204,952,953,956,958,960,963,966,969,971,973],{"class":206,"line":263},[204,954,955],{"class":217},"let",[204,957,222],{"class":235},[204,959,292],{"class":217},[204,961,962],{"class":231}," HubConnection",[204,964,965],{"class":217}," |",[204,967,968],{"class":221}," null",[204,970,225],{"class":217},[204,972,968],{"class":221},[204,974,975],{"class":210},"   \u002F\u002F 模块级单例，别在组件里各建一条\n",[204,977,978],{"class":206,"line":277},[204,979,281],{"emptyLinePlaceholder":280},[204,981,982,985,988,991],{"class":206,"line":284},[204,983,984],{"class":217},"export",[204,986,987],{"class":217}," function",[204,989,990],{"class":231}," useChatHub",[204,992,993],{"class":235},"() {\n",[204,995,996,998,1001,1003,1006],{"class":206,"line":307},[204,997,347],{"class":217},[204,999,1000],{"class":221}," status",[204,1002,225],{"class":217},[204,1004,1005],{"class":231}," ref",[204,1007,1008],{"class":235},"(HubConnectionState.Disconnected)\n",[204,1010,1011,1013,1016,1018,1021,1023,1026],{"class":206,"line":312},[204,1012,347],{"class":217},[204,1014,1015],{"class":221}," messages",[204,1017,225],{"class":217},[204,1019,1020],{"class":231}," shallowRef",[204,1022,640],{"class":235},[204,1024,1025],{"class":231},"ChatMessage",[204,1027,1028],{"class":235},"[]>([])\n",[204,1030,1031],{"class":206,"line":344},[204,1032,281],{"emptyLinePlaceholder":280},[204,1034,1035,1038,1040,1043],{"class":206,"line":364},[204,1036,1037],{"class":217},"  async",[204,1039,987],{"class":217},[204,1041,1042],{"class":231}," connect",[204,1044,993],{"class":235},[204,1046,1047,1049,1052,1055],{"class":206,"line":376},[204,1048,403],{"class":217},[204,1050,1051],{"class":235}," (conn) ",[204,1053,1054],{"class":217},"return",[204,1056,1057],{"class":235}," conn\n",[204,1059,1060],{"class":206,"line":400},[204,1061,281],{"emptyLinePlaceholder":280},[204,1063,1064,1067,1069,1071,1073],{"class":206,"line":429},[204,1065,1066],{"class":235},"    conn ",[204,1068,301],{"class":217},[204,1070,228],{"class":217},[204,1072,232],{"class":231},[204,1074,260],{"class":235},[204,1076,1077,1080,1082,1084,1086,1089],{"class":206,"line":435},[204,1078,1079],{"class":235},"      .",[204,1081,239],{"class":231},[204,1083,242],{"class":235},[204,1085,246],{"class":245},[204,1087,1088],{"class":235},")                 ",[204,1090,1091],{"class":210},"\u002F\u002F 第一跳是 POST \u002Fhubs\u002Fchat\u002Fnegotiate，不是 WS\n",[204,1093,1094,1096,1098,1101],{"class":206,"line":444},[204,1095,1079],{"class":235},[204,1097,252],{"class":231},[204,1099,1100],{"class":235},"()              ",[204,1102,1103],{"class":210},"\u002F\u002F 源码默认退避 [0, 2s, 10s, 30s]，之后放弃\n",[204,1105,1106,1108,1111],{"class":206,"line":450},[204,1107,1079],{"class":235},[204,1109,1110],{"class":231},"configureLogging",[204,1112,1113],{"class":235},"(LogLevel.Warning)\n",[204,1115,1116,1118,1120],{"class":206,"line":455},[204,1117,1079],{"class":235},[204,1119,257],{"class":231},[204,1121,260],{"class":235},[204,1123,1124],{"class":206,"line":461},[204,1125,281],{"emptyLinePlaceholder":280},[204,1127,1128],{"class":206,"line":501},[204,1129,1130],{"class":210},"    \u002F\u002F 服务端 -> 客户端：这个 key 必须和服务端 SendAsync(\"ReceiveMessage\") 的字符串完全一致\n",[204,1132,1133,1136,1138,1140,1142,1144,1146,1148,1150,1152,1154,1156,1158,1160,1162],{"class":206,"line":531},[204,1134,1135],{"class":235},"    conn.",[204,1137,467],{"class":231},[204,1139,242],{"class":235},[204,1141,472],{"class":245},[204,1143,475],{"class":235},[204,1145,478],{"class":326},[204,1147,292],{"class":217},[204,1149,483],{"class":221},[204,1151,486],{"class":235},[204,1153,56],{"class":326},[204,1155,292],{"class":217},[204,1157,483],{"class":221},[204,1159,335],{"class":235},[204,1161,338],{"class":217},[204,1163,341],{"class":235},[204,1165,1166,1169,1171,1174,1177],{"class":206,"line":825},[204,1167,1168],{"class":235},"      messages.value ",[204,1170,301],{"class":217},[204,1172,1173],{"class":235}," [",[204,1175,1176],{"class":217},"...",[204,1178,1179],{"class":235},"messages.value, { user, text }]\n",[204,1181,1182],{"class":206,"line":830},[204,1183,1184],{"class":235},"    })\n",[204,1186,1187],{"class":206,"line":854},[204,1188,281],{"emptyLinePlaceholder":280},[204,1190,1191,1193,1196,1198,1200,1203,1206,1209,1211,1214],{"class":206,"line":876},[204,1192,1135],{"class":235},[204,1194,1195],{"class":231},"onreconnecting",[204,1197,242],{"class":235},[204,1199,327],{"class":326},[204,1201,1202],{"class":217}," =>",[204,1204,1205],{"class":235}," console.",[204,1207,1208],{"class":231},"warn",[204,1210,242],{"class":235},[204,1212,1213],{"class":245},"'reconnecting'",[204,1215,1216],{"class":235},", e?.message))\n",[204,1218,1219,1221,1224,1226,1229,1232,1234],{"class":206,"line":899},[204,1220,1135],{"class":235},[204,1222,1223],{"class":231},"onreconnected",[204,1225,242],{"class":235},[204,1227,1228],{"class":217},"async",[204,1230,1231],{"class":235}," () ",[204,1233,338],{"class":217},[204,1235,341],{"class":235},[204,1237,1238],{"class":206,"line":905},[204,1239,1240],{"class":210},"      \u002F\u002F 重连后 connectionId 变了，之前 join 的 group 全部丢失，必须重新 join\n",[204,1242,1244,1247,1249,1252,1255,1258,1260,1263,1265,1268],{"class":206,"line":1243},26,[204,1245,1246],{"class":217},"      await",[204,1248,222],{"class":235},[204,1250,1251],{"class":217},"!",[204,1253,1254],{"class":235},".",[204,1256,1257],{"class":231},"invoke",[204,1259,242],{"class":235},[204,1261,1262],{"class":245},"'JoinRoom'",[204,1264,486],{"class":235},[204,1266,1267],{"class":245},"'lobby'",[204,1269,608],{"class":235},[204,1271,1273],{"class":206,"line":1272},27,[204,1274,1184],{"class":235},[204,1276,1278,1280,1283,1285,1287,1289,1292,1294],{"class":206,"line":1277},28,[204,1279,1135],{"class":235},[204,1281,1282],{"class":231},"onclose",[204,1284,242],{"class":235},[204,1286,327],{"class":326},[204,1288,1202],{"class":217},[204,1290,1291],{"class":235}," { status.value ",[204,1293,301],{"class":217},[204,1295,1296],{"class":235}," HubConnectionState.Disconnected })\n",[204,1298,1300],{"class":206,"line":1299},29,[204,1301,281],{"emptyLinePlaceholder":280},[204,1303,1305,1308,1310,1312],{"class":206,"line":1304},30,[204,1306,1307],{"class":217},"    await",[204,1309,269],{"class":235},[204,1311,272],{"class":231},[204,1313,260],{"class":235},[204,1315,1317,1320,1322],{"class":206,"line":1316},31,[204,1318,1319],{"class":235},"    status.value ",[204,1321,301],{"class":217},[204,1323,1324],{"class":235}," conn.state\n",[204,1326,1328,1330,1332,1334,1336,1338,1340,1342],{"class":206,"line":1327},32,[204,1329,1307],{"class":217},[204,1331,269],{"class":235},[204,1333,1257],{"class":231},[204,1335,242],{"class":235},[204,1337,1262],{"class":245},[204,1339,486],{"class":235},[204,1341,1267],{"class":245},[204,1343,608],{"class":235},[204,1345,1347,1350],{"class":206,"line":1346},33,[204,1348,1349],{"class":217},"    return",[204,1351,1057],{"class":235},[204,1353,1355],{"class":206,"line":1354},34,[204,1356,432],{"class":235},[204,1358,1360],{"class":206,"line":1359},35,[204,1361,281],{"emptyLinePlaceholder":280},[204,1363,1365],{"class":206,"line":1364},36,[204,1366,1367],{"class":210},"  \u002F\u002F invoke 等返回值（RPC 语义）；send 发完就走，少一个 RTT 的等待\n",[204,1369,1371,1373,1376,1378,1380,1382,1384,1386,1388,1390,1392,1394,1396],{"class":206,"line":1370},37,[204,1372,347],{"class":217},[204,1374,1375],{"class":231}," sendMessage",[204,1377,225],{"class":217},[204,1379,323],{"class":235},[204,1381,478],{"class":326},[204,1383,292],{"class":217},[204,1385,483],{"class":221},[204,1387,486],{"class":235},[204,1389,56],{"class":326},[204,1391,292],{"class":217},[204,1393,483],{"class":221},[204,1395,335],{"class":235},[204,1397,1398],{"class":217},"=>\n",[204,1400,1402,1405,1407],{"class":206,"line":1401},38,[204,1403,1404],{"class":235},"    conn?.state ",[204,1406,409],{"class":217},[204,1408,1409],{"class":235}," HubConnectionState.Connected\n",[204,1411,1413,1416,1418,1420,1422,1424],{"class":206,"line":1412},39,[204,1414,1415],{"class":217},"      ?",[204,1417,269],{"class":235},[204,1419,418],{"class":231},[204,1421,242],{"class":235},[204,1423,423],{"class":245},[204,1425,1426],{"class":235},", user, text)\n",[204,1428,1430,1433,1436,1438,1441],{"class":206,"line":1429},40,[204,1431,1432],{"class":217},"      :",[204,1434,1435],{"class":221}," Promise",[204,1437,1254],{"class":235},[204,1439,1440],{"class":231},"resolve",[204,1442,260],{"class":235},[204,1444,1446],{"class":206,"line":1445},41,[204,1447,281],{"emptyLinePlaceholder":280},[204,1449,1451],{"class":206,"line":1450},42,[204,1452,1453],{"class":210},"  \u002F\u002F 服务端 IAsyncEnumerable\u003CT> \u002F ChannelReader\u003CT> 持续推，客户端订阅\n",[204,1455,1457,1460,1463],{"class":206,"line":1456},43,[204,1458,1459],{"class":217},"  function",[204,1461,1462],{"class":231}," subscribeCounter",[204,1464,993],{"class":235},[204,1466,1468,1471,1474,1476,1478,1480,1482,1485,1487,1490,1492,1495,1497,1500],{"class":206,"line":1467},44,[204,1469,1470],{"class":217},"    const",[204,1472,1473],{"class":221}," sub",[204,1475,225],{"class":217},[204,1477,222],{"class":235},[204,1479,1251],{"class":217},[204,1481,1254],{"class":235},[204,1483,1484],{"class":231},"stream",[204,1486,640],{"class":235},[204,1488,1489],{"class":221},"number",[204,1491,919],{"class":235},[204,1493,1494],{"class":245},"'Counter'",[204,1496,486],{"class":235},[204,1498,1499],{"class":221},"10",[204,1501,608],{"class":235},[204,1503,1505,1508,1511,1514,1517,1520,1523,1525,1527,1530,1533,1536,1539,1541,1544,1547,1549,1551,1553,1555,1557],{"class":206,"line":1504},45,[204,1506,1507],{"class":235},"    sub.",[204,1509,1510],{"class":231},"subscribe",[204,1512,1513],{"class":235},"({ ",[204,1515,1516],{"class":231},"next",[204,1518,1519],{"class":235},": ",[204,1521,1522],{"class":326},"n",[204,1524,1202],{"class":217},[204,1526,1205],{"class":235},[204,1528,1529],{"class":231},"log",[204,1531,1532],{"class":235},"(n), ",[204,1534,1535],{"class":231},"complete",[204,1537,1538],{"class":235},": () ",[204,1540,338],{"class":217},[204,1542,1543],{"class":235}," {}, ",[204,1545,1546],{"class":231},"error",[204,1548,1519],{"class":235},[204,1550,327],{"class":326},[204,1552,1202],{"class":217},[204,1554,1205],{"class":235},[204,1556,1546],{"class":231},[204,1558,1559],{"class":235},"(e) })\n",[204,1561,1563,1565,1567,1569,1572,1575],{"class":206,"line":1562},46,[204,1564,1349],{"class":217},[204,1566,1231],{"class":235},[204,1568,338],{"class":217},[204,1570,1571],{"class":235}," sub.",[204,1573,1574],{"class":231},"dispose",[204,1576,260],{"class":235},[204,1578,1580],{"class":206,"line":1579},47,[204,1581,432],{"class":235},[204,1583,1585],{"class":206,"line":1584},48,[204,1586,281],{"emptyLinePlaceholder":280},[204,1588,1590,1593,1596,1598,1601,1604,1607,1609,1611],{"class":206,"line":1589},49,[204,1591,1592],{"class":231},"  onScopeDispose",[204,1594,1595],{"class":235},"(() ",[204,1597,338],{"class":217},[204,1599,1600],{"class":235}," { conn?.",[204,1602,1603],{"class":231},"stop",[204,1605,1606],{"class":235},"(); conn ",[204,1608,301],{"class":217},[204,1610,968],{"class":221},[204,1612,1613],{"class":235}," })\n",[204,1615,1617,1620],{"class":206,"line":1616},50,[204,1618,1619],{"class":217},"  return",[204,1621,1622],{"class":235}," { status, messages, connect, sendMessage, subscribeCounter }\n",[204,1624,1626],{"class":206,"line":1625},51,[204,1627,447],{"class":235},[10,1629,1630,1631,1634,1635,1638,1639,1642,1643,1646,1647,1650,1651,1654,1655,1657,1658,1661],{},"SignalR 官方带 ",[201,1632,1633],{},"dist\u002Fwebworker\u002Fsignalr.js","，源码里的 ",[201,1636,1637],{},"document"," 都写成 ",[201,1640,1641],{},"window.document"," 且有 ",[201,1644,1645],{},"Platform.isBrowser"," 守卫，在没有 ",[201,1648,1649],{},"window"," 的 Worker 里不会炸。",[21,1652,1653],{},"代价","：",[201,1656,1645],{}," 为 false 后不再监听窗口 ",[201,1659,1660],{},"freeze"," 事件，你会在日志里少一条\"标签页被冻结导致断连\"的线索。",[10,1663,1664],{},"和vueuse相比，signalr的优势在于 hub rpc、多传输降级、group、backplane这些在服务端侧，vueuse一个都不提供，也都是SignalR的立身之本。",[10,1666,1667],{},"hub可以叫做集线器，是服务端的核心类，负责连接管理和消息分发。backplane是signalr用于多服务器横向扩展的核心组件，当你应用部署在多台服务器上的时候，它负责让所有服务器之间能够互相转发消息，确保连接到不同服务器的客户端也能收到广播。group是signalr中用于管理链接分组的核心机制，可以让你只向订阅了某类消息的客户端推送，而不是广播给所有人。",[1669,1670,1672],"h3",{"id":1671},"hub-rpc","Hub RPC",[10,1674,1675,1676,1679],{},"它不是\"透明地把本地方法调用传过去\"，而是",[21,1677,1678],{},"方法名当字符串传、服务端反射查表找回来","。这一句话解释了它：",[1681,1682,1683,1710],"ul",{},[1684,1685,1686,1687,1690,1691,1701,1702,1705,1706,1709],"li",{},"服务端查表用的是 ",[201,1688,1689],{},"StringComparer.OrdinalIgnoreCase","，所以 ",[21,1692,1693,1696,1697,1700],{},[201,1694,1695],{},"invoke('SendMessage')"," 和 ",[201,1698,1699],{},"invoke('sendMessage')"," 都能命中","；但客户端 ",[201,1703,1704],{},"conn.on('ReceiveMessage')"," 是 JS 对象键，",[21,1707,1708],{},"精确匹配","。这个不对称是最常踩的坑。",[1684,1711,1712,1713,1654],{},"方法名既然是字符串，重构就没有编译期保护——这才有了",[21,1714,1715],{},"强类型 Hub",[194,1717,1719],{"className":540,"code":1718,"language":542,"meta":199,"style":199},"public interface IChatClient { Task ReceiveMessage(string user, string text); }\n\npublic class ChatHub : Hub\u003CIChatClient>          \u002F\u002F 泛型参数一加，字符串魔法全消失\n{\n    public Task SendMessage(string user, string text)\n        => Clients.All.ReceiveMessage(user, text);   \u002F\u002F 改名不再静默失效\n\n    [HubMethodName(\"say\")]                        \u002F\u002F 对客户端暴露成 'say'\n    public Task Say(string text) => Clients.All.ReceiveMessage(\"me\", text);\n\n    public Task\u003Cstring> Ping() => Task.FromResult(\"pong\");   \u002F\u002F 客户端 await 拿得到\n\n    \u002F\u002F 反向调用客户端并等结果 —— 客户端也要写「有返回值」的 handler\n    public Task\u003Cstring> AskClient(string id) => Clients.Client(id).InvokeAsync\u003Cstring>(\"GetInput\");\n}\n",[201,1720,1721,1755,1759,1783,1787,1809,1824,1828,1847,1877,1881,1912,1916,1921,1969],{"__ignoreMap":199},[204,1722,1723,1725,1728,1731,1734,1737,1740,1742,1744,1746,1748,1750,1752],{"class":206,"line":207},[204,1724,554],{"class":217},[204,1726,1727],{"class":217}," interface",[204,1729,1730],{"class":231}," IChatClient",[204,1732,1733],{"class":235}," { ",[204,1735,1736],{"class":231},"Task",[204,1738,1739],{"class":231}," ReceiveMessage",[204,1741,242],{"class":235},[204,1743,595],{"class":217},[204,1745,598],{"class":231},[204,1747,486],{"class":235},[204,1749,595],{"class":217},[204,1751,605],{"class":231},[204,1753,1754],{"class":235},"); }\n",[204,1756,1757],{"class":206,"line":214},[204,1758,281],{"emptyLinePlaceholder":280},[204,1760,1761,1763,1765,1767,1769,1772,1774,1777,1780],{"class":206,"line":263},[204,1762,554],{"class":217},[204,1764,560],{"class":217},[204,1766,563],{"class":231},[204,1768,566],{"class":235},[204,1770,1771],{"class":231},"Hub",[204,1773,640],{"class":235},[204,1775,1776],{"class":231},"IChatClient",[204,1778,1779],{"class":235},">          ",[204,1781,1782],{"class":210},"\u002F\u002F 泛型参数一加，字符串魔法全消失\n",[204,1784,1785],{"class":206,"line":277},[204,1786,574],{"class":235},[204,1788,1789,1791,1793,1795,1797,1799,1801,1803,1805,1807],{"class":206,"line":284},[204,1790,584],{"class":217},[204,1792,587],{"class":231},[204,1794,590],{"class":231},[204,1796,242],{"class":235},[204,1798,595],{"class":217},[204,1800,598],{"class":231},[204,1802,486],{"class":235},[204,1804,595],{"class":217},[204,1806,605],{"class":231},[204,1808,608],{"class":235},[204,1810,1811,1813,1815,1818,1821],{"class":206,"line":307},[204,1812,613],{"class":217},[204,1814,616],{"class":235},[204,1816,1817],{"class":231},"ReceiveMessage",[204,1819,1820],{"class":235},"(user, text);   ",[204,1822,1823],{"class":210},"\u002F\u002F 改名不再静默失效\n",[204,1825,1826],{"class":206,"line":312},[204,1827,281],{"emptyLinePlaceholder":280},[204,1829,1830,1833,1836,1838,1841,1844],{"class":206,"line":344},[204,1831,1832],{"class":235},"    [",[204,1834,1835],{"class":231},"HubMethodName",[204,1837,242],{"class":235},[204,1839,1840],{"class":245},"\"say\"",[204,1842,1843],{"class":235},")]                        ",[204,1845,1846],{"class":210},"\u002F\u002F 对客户端暴露成 'say'\n",[204,1848,1849,1851,1853,1856,1858,1860,1862,1864,1866,1868,1870,1872,1875],{"class":206,"line":364},[204,1850,584],{"class":217},[204,1852,587],{"class":231},[204,1854,1855],{"class":231}," Say",[204,1857,242],{"class":235},[204,1859,595],{"class":217},[204,1861,605],{"class":231},[204,1863,335],{"class":235},[204,1865,338],{"class":217},[204,1867,616],{"class":235},[204,1869,1817],{"class":231},[204,1871,242],{"class":235},[204,1873,1874],{"class":245},"\"me\"",[204,1876,793],{"class":235},[204,1878,1879],{"class":206,"line":376},[204,1880,281],{"emptyLinePlaceholder":280},[204,1882,1883,1885,1887,1889,1891,1893,1895,1897,1899,1901,1903,1905,1907,1909],{"class":206,"line":400},[204,1884,584],{"class":217},[204,1886,587],{"class":231},[204,1888,640],{"class":235},[204,1890,595],{"class":217},[204,1892,645],{"class":235},[204,1894,648],{"class":231},[204,1896,651],{"class":235},[204,1898,338],{"class":217},[204,1900,656],{"class":235},[204,1902,659],{"class":231},[204,1904,242],{"class":235},[204,1906,664],{"class":245},[204,1908,667],{"class":235},[204,1910,1911],{"class":210},"\u002F\u002F 客户端 await 拿得到\n",[204,1913,1914],{"class":206,"line":429},[204,1915,281],{"emptyLinePlaceholder":280},[204,1917,1918],{"class":206,"line":435},[204,1919,1920],{"class":210},"    \u002F\u002F 反向调用客户端并等结果 —— 客户端也要写「有返回值」的 handler\n",[204,1922,1923,1925,1927,1929,1931,1933,1936,1938,1940,1943,1945,1947,1949,1952,1955,1958,1960,1962,1964,1967],{"class":206,"line":444},[204,1924,584],{"class":217},[204,1926,587],{"class":231},[204,1928,640],{"class":235},[204,1930,595],{"class":217},[204,1932,645],{"class":235},[204,1934,1935],{"class":231},"AskClient",[204,1937,242],{"class":235},[204,1939,595],{"class":217},[204,1941,1942],{"class":231}," id",[204,1944,335],{"class":235},[204,1946,338],{"class":217},[204,1948,762],{"class":235},[204,1950,1951],{"class":231},"Client",[204,1953,1954],{"class":235},"(id).",[204,1956,1957],{"class":231},"InvokeAsync",[204,1959,640],{"class":235},[204,1961,595],{"class":217},[204,1963,919],{"class":235},[204,1965,1966],{"class":245},"\"GetInput\"",[204,1968,728],{"class":235},[204,1970,1971],{"class":206,"line":450},[204,1972,447],{"class":235},[1681,1974,1975],{},[1684,1976,1977,1980,1981,1985],{},[21,1978,1979],{},"Hub 是 transient 的","。官方文档原文：",[1982,1983,1984],"em",{},"\"Hubs are transient: Don't store state in a property of the hub class. Each hub method call is executed on a new hub instance.\""," 所以下面这段是经典的错：",[194,1987,1989],{"className":540,"code":1988,"language":542,"meta":199,"style":199},"public class BadHub : Hub { private int _count; }   \u002F\u002F 每次调用都是新实例，永远是 0\n\n\u002F\u002F 对的：连接级状态放 Context.Items（同一连接跨多次调用保留）\npublic class GoodHub : Hub {\n    public Task Add() {\n        var n = (int)(Context.Items[\"count\"] ?? 0) + 1;\n        Context.Items[\"count\"] = n;\n        return Clients.Caller.SendAsync(\"Count\", n);\n    }\n}\n\n",[201,1990,1991,2021,2025,2030,2045,2056,2097,2111,2129,2134],{"__ignoreMap":199},[204,1992,1993,1995,1997,2000,2002,2004,2006,2009,2012,2015,2018],{"class":206,"line":207},[204,1994,554],{"class":217},[204,1996,560],{"class":217},[204,1998,1999],{"class":231}," BadHub",[204,2001,566],{"class":235},[204,2003,1771],{"class":231},[204,2005,1733],{"class":235},[204,2007,2008],{"class":217},"private",[204,2010,2011],{"class":217}," int",[204,2013,2014],{"class":231}," _count",[204,2016,2017],{"class":235},"; }   ",[204,2019,2020],{"class":210},"\u002F\u002F 每次调用都是新实例，永远是 0\n",[204,2022,2023],{"class":206,"line":214},[204,2024,281],{"emptyLinePlaceholder":280},[204,2026,2027],{"class":206,"line":263},[204,2028,2029],{"class":210},"\u002F\u002F 对的：连接级状态放 Context.Items（同一连接跨多次调用保留）\n",[204,2031,2032,2034,2036,2039,2041,2043],{"class":206,"line":277},[204,2033,554],{"class":217},[204,2035,560],{"class":217},[204,2037,2038],{"class":231}," GoodHub",[204,2040,566],{"class":235},[204,2042,1771],{"class":231},[204,2044,341],{"class":235},[204,2046,2047,2049,2051,2054],{"class":206,"line":284},[204,2048,584],{"class":217},[204,2050,587],{"class":231},[204,2052,2053],{"class":231}," Add",[204,2055,993],{"class":235},[204,2057,2058,2061,2064,2066,2068,2071,2074,2077,2080,2083,2086,2088,2091,2094],{"class":206,"line":307},[204,2059,2060],{"class":217},"        var",[204,2062,2063],{"class":231}," n",[204,2065,225],{"class":217},[204,2067,323],{"class":235},[204,2069,2070],{"class":217},"int",[204,2072,2073],{"class":235},")(Context.Items[",[204,2075,2076],{"class":245},"\"count\"",[204,2078,2079],{"class":235},"] ",[204,2081,2082],{"class":217},"??",[204,2084,2085],{"class":221}," 0",[204,2087,335],{"class":235},[204,2089,2090],{"class":217},"+",[204,2092,2093],{"class":221}," 1",[204,2095,2096],{"class":235},";\n",[204,2098,2099,2102,2104,2106,2108],{"class":206,"line":312},[204,2100,2101],{"class":235},"        Context.Items[",[204,2103,2076],{"class":245},[204,2105,2079],{"class":235},[204,2107,301],{"class":217},[204,2109,2110],{"class":235}," n;\n",[204,2112,2113,2116,2119,2121,2123,2126],{"class":206,"line":344},[204,2114,2115],{"class":217},"        return",[204,2117,2118],{"class":235}," Clients.Caller.",[204,2120,619],{"class":231},[204,2122,242],{"class":235},[204,2124,2125],{"class":245},"\"Count\"",[204,2127,2128],{"class":235},", n);\n",[204,2130,2131],{"class":206,"line":364},[204,2132,2133],{"class":235},"    }\n",[204,2135,2136],{"class":206,"line":376},[204,2137,447],{"class":235},[10,2139,2140,2143,2144,2147,2148,2151,2152,2155,2156,2159,2160,2163,2164,2167],{},[21,2141,2142],{},"Hub 方法参数会先从 DI 解析","（.NET 7 起的破坏性变更，",[201,2145,2146],{},"DisableImplicitFromServicesParameters"," 可关），所以 ",[201,2149,2150],{},"SendMessage(string a, string b, AppDbContext db)"," 里的 ",[201,2153,2154],{},"db"," 是从容器来的，不是客户端传的；",[21,2157,2158],{},"流式","则是 ",[201,2161,2162],{},"IAsyncEnumerable\u003CT>"," 对客户端的 ",[201,2165,2166],{},"conn.stream\u003CT>()","。",[1669,2169,2170],{"id":2170},"多传输降级",[10,2172,2173,2174,2177],{},"这个能力的前提是：SignalR 的\"连接\"是",[21,2175,2176],{},"逻辑概念","，不绑定任何一种 HTTP 形态：",[30,2179,2181,2184,2187,2190,2194,2198,2203,2207,2211,2214,2217,2221,2225,2229,2234,2239,2244,2248],{"viewBox":2180,"width":33,"role":34,"xmlns":35},"0 0 680 324",[37,2182,2183],{},"SignalR 三种传输的形态与降级顺序",[41,2185,2186],{},"客户端按 negotiate 返回的顺序依次尝试 WebSockets、ServerSentEvents、LongPolling，三种传输的 HTTP 形态各不相同。",[45,2188],{"x":47,"y":2189,"width":49,"height":96,"rx":51,"fill":52,"stroke":53,"strokeWidth":54},"24",[56,2191,2193],{"x":58,"y":2192,"fontFamily":60,"fontSize":61,"fontWeight":62,"fill":63},"50","同一个 Hub 连接，底下的 HTTP 形态可以完全不同",[56,2195,2197],{"x":58,"y":2196,"fontFamily":60,"fontSize":68,"fill":69},"70","优先级由客户端决定，服务端只提供 availableTransports 候选列表",[45,2199],{"x":58,"y":2200,"width":2201,"height":2202,"rx":1499,"fill":158,"stroke":91,"strokeWidth":54},"86","560","56",[56,2204,2206],{"x":2205,"y":95,"fontFamily":60,"fontSize":74,"fontWeight":62,"fill":162},"80","1 · WebSockets",[56,2208,2210],{"x":131,"y":2209,"fontFamily":60,"fontSize":68,"fill":91},"112","升级成一条真正双向的长连接，原生帧",[56,2212,2213],{"x":131,"y":79,"fontFamily":60,"fontSize":68,"fill":91},"首选，几乎总是胜出",[45,2215],{"x":58,"y":2216,"width":2201,"height":2202,"rx":1499,"fill":99,"stroke":81,"strokeWidth":54},"152",[56,2218,2220],{"x":2205,"y":2219,"fontFamily":60,"fontSize":74,"fontWeight":62,"fill":104},"184","2 · ServerSentEvents",[56,2222,2224],{"x":131,"y":2223,"fontFamily":60,"fontSize":68,"fill":81},"178","下行是一条永不结束的响应体，上行另开 POST",[56,2226,2228],{"x":131,"y":2227,"fontFamily":60,"fontSize":68,"fill":81},"196","只能发文本，浏览器对每域名有连接数限制",[45,2230],{"x":58,"y":2231,"width":2201,"height":2202,"rx":1499,"fill":2232,"stroke":2233,"strokeWidth":54},"218","#FAEEDA","#BA7517",[56,2235,2238],{"x":2205,"y":2236,"fontFamily":60,"fontSize":74,"fontWeight":62,"fill":2237},"250","#412402","3 · LongPolling",[56,2240,2243],{"x":131,"y":2241,"fontFamily":60,"fontSize":68,"fill":2242},"244","#854F0B","请求被服务端挂住，有数据就返回，返回后立刻再发一个",[56,2245,2247],{"x":131,"y":2246,"fontFamily":60,"fontSize":68,"fill":2242},"262","兼容性兜底：每个响应结束就是一次「假连接」",[56,2249,2250],{"x":58,"y":188,"fontFamily":60,"fontSize":68,"fill":184},"\n三者形态差别这么大，所以连接必须能被拆成多个 HTTP 请求 —— 这也是它需要 sticky 的原因\n",[10,2252,2253,2256],{},[21,2254,2255],{},"多传输降级的机制","其实是\"先在应用层假装一条连接，再挑具体怎么实现\"。",[194,2258,2262],{"className":2259,"code":2260,"language":2261,"meta":199,"style":199},"language-json shiki shiki-themes github-light github-dark","{\n  \"negotiateVersion\": 1,\n  \"connectionToken\": \"…\",         ← 后续请求都带着它，服务端据此找回这条逻辑连接\n  \"availableTransports\": [\n    { \"transport\": \"WebSockets\",      \"transferFormats\": [\"Text\",\"Binary\"] },\n    { \"transport\": \"ServerSentEvents\",\"transferFormats\": [\"Text\"] },\n    { \"transport\": \"LongPolling\",     \"transferFormats\": [\"Text\",\"Binary\"] }\n  ]\n}\n\n","json",[201,2263,2264,2268,2281,2301,2309,2343,2364,2391,2396],{"__ignoreMap":199},[204,2265,2266],{"class":206,"line":207},[204,2267,574],{"class":235},[204,2269,2270,2273,2275,2278],{"class":206,"line":214},[204,2271,2272],{"class":221},"  \"negotiateVersion\"",[204,2274,1519],{"class":235},[204,2276,2277],{"class":221},"1",[204,2279,2280],{"class":235},",\n",[204,2282,2283,2286,2288,2291,2294,2298],{"class":206,"line":263},[204,2284,2285],{"class":221},"  \"connectionToken\"",[204,2287,1519],{"class":235},[204,2289,2290],{"class":245},"\"…\"",[204,2292,2293],{"class":235},",         ",[204,2295,2297],{"class":2296},"s7hpK","←",[204,2299,2300],{"class":2296}," 后续请求都带着它，服务端据此找回这条逻辑连接\n",[204,2302,2303,2306],{"class":206,"line":277},[204,2304,2305],{"class":221},"  \"availableTransports\"",[204,2307,2308],{"class":235},": [\n",[204,2310,2311,2314,2317,2319,2322,2325,2328,2331,2334,2337,2340],{"class":206,"line":284},[204,2312,2313],{"class":235},"    { ",[204,2315,2316],{"class":221},"\"transport\"",[204,2318,1519],{"class":235},[204,2320,2321],{"class":245},"\"WebSockets\"",[204,2323,2324],{"class":235},",      ",[204,2326,2327],{"class":221},"\"transferFormats\"",[204,2329,2330],{"class":235},": [",[204,2332,2333],{"class":245},"\"Text\"",[204,2335,2336],{"class":235},",",[204,2338,2339],{"class":245},"\"Binary\"",[204,2341,2342],{"class":235},"] },\n",[204,2344,2345,2347,2349,2351,2354,2356,2358,2360,2362],{"class":206,"line":307},[204,2346,2313],{"class":235},[204,2348,2316],{"class":221},[204,2350,1519],{"class":235},[204,2352,2353],{"class":245},"\"ServerSentEvents\"",[204,2355,2336],{"class":235},[204,2357,2327],{"class":221},[204,2359,2330],{"class":235},[204,2361,2333],{"class":245},[204,2363,2342],{"class":235},[204,2365,2366,2368,2370,2372,2375,2378,2380,2382,2384,2386,2388],{"class":206,"line":312},[204,2367,2313],{"class":235},[204,2369,2316],{"class":221},[204,2371,1519],{"class":235},[204,2373,2374],{"class":245},"\"LongPolling\"",[204,2376,2377],{"class":235},",     ",[204,2379,2327],{"class":221},[204,2381,2330],{"class":235},[204,2383,2333],{"class":245},[204,2385,2336],{"class":235},[204,2387,2339],{"class":245},[204,2389,2390],{"class":235},"] }\n",[204,2392,2393],{"class":206,"line":344},[204,2394,2395],{"class":235},"  ]\n",[204,2397,2398],{"class":206,"line":364},[204,2399,447],{"class":235},[10,2401,2402,2403,2406],{},"客户端按这个列表",[21,2404,2405],{},"从上往下试","，谁先通用谁。几个由此推出的结论：",[1681,2408,2409,2415,2422,2433],{},[1684,2410,2411,2414],{},[21,2412,2413],{},"SSE 只能 Text","，所以二进制协议（MessagePack）会自动把 SSE 排除掉。",[1684,2416,2417,2418,2421],{},"LongPolling 是\"每个响应就是一次假连接\"​：服务端把请求挂住（默认 ",[201,2419,2420],{},"PollTimeout"," 90 秒），有数据就吐出来，客户端拿到后立刻再发一个。所以一条逻辑连接会变成成百上千个 HTTP 请求。",[1684,2423,2424,2425,2428,2429,2432],{},"正因为这个，",[21,2426,2427],{},"negotiate 拿到的 token 必须和后续每次请求落到同一台服务器","——这就是 sticky sessions 是硬要求的原因，不是可选项。",[21,2430,2431],{},"​即使挂了 backplane 也仍然需要","（backplane 只解决跨机广播，不解决路由）。",[1684,2434,2435,2436,2439,2440,2443,2444,2451],{},"有个反直觉的代价：",[21,2437,2438],{},"WS 成功时那次 negotiate 是白花的一个 RTT","。所以才有 ",[201,2441,2442],{},"skipNegotiation: true","，但它",[21,2445,2446,2447,2450],{},"只在锁定 ",[201,2448,2449],{},"HttpTransportType.WebSockets"," 时可用","——你没法\"跳过协商再降级\"。",[1669,2453,765],{"id":2454},"group",[10,2456,2457,2458,1654],{},"它和\"用户\"是",[21,2459,2460],{},"两种交叉的聚合维度",[30,2462,2464,2467,2470,2473,2476,2479,2484,2488,2492,2496,2500,2504,2506,2509,2511,2514,2518,2523,2525,2527,2529,2531,2534,2537,2541,2543,2545,2547,2551,2554,2556,2558,2560,2562,2566,2569,2571,2573,2575,2577,2580,2583,2585,2587,2589,2591,2595,2598,2600,2602,2604],{"viewBox":2463,"width":33,"role":34,"xmlns":35},"0 0 680 360",[37,2465,2466],{},"SignalR 推送目标选择器的命中范围",[41,2468,2469],{},"五列代表五个连接，实心表示被该选择器命中。可以看到 group 是一个任意连接集合，user 则会聚合同一用户的多个连接。",[45,2471],{"x":47,"y":2189,"width":49,"height":2472,"rx":51,"fill":52,"stroke":53,"strokeWidth":54},"316",[56,2474,2475],{"x":58,"y":2192,"fontFamily":60,"fontSize":61,"fontWeight":62,"fill":63},"Clients 的每种写法，各自会命中哪些连接",[56,2477,2478],{"x":58,"y":2196,"fontFamily":60,"fontSize":68,"fill":69},"实心 = 被命中。u1 开了两个标签页，所以是两个独立连接",[56,2480,2483],{"x":2236,"y":2481,"textAnchor":2482,"fontFamily":109,"fontSize":80,"fill":63},"94","middle","\nc1\n",[56,2485,2487],{"x":2486,"y":2481,"textAnchor":2482,"fontFamily":109,"fontSize":80,"fill":63},"330","\nc2\n",[56,2489,2491],{"x":2490,"y":2481,"textAnchor":2482,"fontFamily":109,"fontSize":80,"fill":63},"410","\nc3\n",[56,2493,2495],{"x":2494,"y":2481,"textAnchor":2482,"fontFamily":109,"fontSize":80,"fill":63},"490","\nc4\n",[56,2497,2499],{"x":2498,"y":2481,"textAnchor":2482,"fontFamily":109,"fontSize":80,"fill":63},"570","\nc5\n",[56,2501,2503],{"x":2236,"y":2502,"textAnchor":2482,"fontFamily":60,"fontSize":80,"fill":69},"110","\nu1\n",[56,2505,2503],{"x":2486,"y":2502,"textAnchor":2482,"fontFamily":60,"fontSize":80,"fill":69},[56,2507,2508],{"x":2490,"y":2502,"textAnchor":2482,"fontFamily":60,"fontSize":80,"fill":69},"\nu2\n",[56,2510,2508],{"x":2494,"y":2502,"textAnchor":2482,"fontFamily":60,"fontSize":80,"fill":69},[56,2512,2513],{"x":2498,"y":2502,"textAnchor":2482,"fontFamily":60,"fontSize":80,"fill":69},"\nu3\n",[56,2515,2517],{"x":58,"y":2516,"fontFamily":109,"fontSize":68,"fill":162},"144","\nClients.All\n",[2519,2520],"circle",{"cx":2236,"cy":2521,"r":2522,"fill":91},"140","9",[2519,2524],{"cx":2486,"cy":2521,"r":2522,"fill":91},[2519,2526],{"cx":2490,"cy":2521,"r":2522,"fill":91},[2519,2528],{"cx":2494,"cy":2521,"r":2522,"fill":91},[2519,2530],{"cx":2498,"cy":2521,"r":2522,"fill":91},[56,2532,2533],{"x":58,"y":2223,"fontFamily":109,"fontSize":68,"fill":162},"\nClients.Caller\n",[2519,2535],{"cx":2236,"cy":2536,"r":2522,"fill":91},"174",[2519,2538],{"cx":2486,"cy":2536,"r":2522,"fill":2539,"stroke":2540,"strokeWidth":2277},"none","#B4B2A9",[2519,2542],{"cx":2490,"cy":2536,"r":2522,"fill":2539,"stroke":2540,"strokeWidth":2277},[2519,2544],{"cx":2494,"cy":2536,"r":2522,"fill":2539,"stroke":2540,"strokeWidth":2277},[2519,2546],{"cx":2498,"cy":2536,"r":2522,"fill":2539,"stroke":2540,"strokeWidth":2277},[56,2548,2550],{"x":58,"y":2549,"fontFamily":109,"fontSize":68,"fill":162},"212","\nClients.Others\n",[2519,2552],{"cx":2236,"cy":2553,"r":2522,"fill":2539,"stroke":2540,"strokeWidth":2277},"208",[2519,2555],{"cx":2486,"cy":2553,"r":2522,"fill":91},[2519,2557],{"cx":2490,"cy":2553,"r":2522,"fill":91},[2519,2559],{"cx":2494,"cy":2553,"r":2522,"fill":91},[2519,2561],{"cx":2498,"cy":2553,"r":2522,"fill":91},[56,2563,2565],{"x":58,"y":2564,"fontFamily":109,"fontSize":68,"fill":162},"246","\nClients.Client(\"c3\")\n",[2519,2567],{"cx":2236,"cy":2568,"r":2522,"fill":2539,"stroke":2540,"strokeWidth":2277},"242",[2519,2570],{"cx":2486,"cy":2568,"r":2522,"fill":2539,"stroke":2540,"strokeWidth":2277},[2519,2572],{"cx":2490,"cy":2568,"r":2522,"fill":91},[2519,2574],{"cx":2494,"cy":2568,"r":2522,"fill":2539,"stroke":2540,"strokeWidth":2277},[2519,2576],{"cx":2498,"cy":2568,"r":2522,"fill":2539,"stroke":2540,"strokeWidth":2277},[56,2578,2579],{"x":58,"y":96,"fontFamily":109,"fontSize":68,"fill":184},"\nClients.Group(\"room:1\")\n",[2519,2581],{"cx":2236,"cy":2582,"r":2522,"fill":2233},"276",[2519,2584],{"cx":2486,"cy":2582,"r":2522,"fill":2539,"stroke":2540,"strokeWidth":2277},[2519,2586],{"cx":2490,"cy":2582,"r":2522,"fill":2233},[2519,2588],{"cx":2494,"cy":2582,"r":2522,"fill":2539,"stroke":2540,"strokeWidth":2277},[2519,2590],{"cx":2498,"cy":2582,"r":2522,"fill":2233},[56,2592,2594],{"x":58,"y":2593,"fontFamily":109,"fontSize":68,"fill":81},"314","\nClients.User(\"u1\")\n",[2519,2596],{"cx":2236,"cy":2597,"r":2522,"fill":81},"310",[2519,2599],{"cx":2486,"cy":2597,"r":2522,"fill":81},[2519,2601],{"cx":2490,"cy":2597,"r":2522,"fill":2539,"stroke":2540,"strokeWidth":2277},[2519,2603],{"cx":2494,"cy":2597,"r":2522,"fill":2539,"stroke":2540,"strokeWidth":2277},[2519,2605],{"cx":2498,"cy":2597,"r":2522,"fill":2539,"stroke":2540,"strokeWidth":2277},[21,2607,2608],{},"group 是四样东西里最被误解的一个",[21,2610,2611],{},"「服务端推送时的目标集合」",[1681,2613,2614,2621],{},[1684,2615,2616,2617,2620],{},"客户端",[21,2618,2619],{},"不能监听","一个 group，只有服务端能往 group 推。客户端只能\"加入\u002F离开\"。",[1684,2622,2623,2624,2627,2628,2631],{},"而且客户端",[21,2625,2626],{},"不能直接操作 group","——",[201,2629,2630],{},"Groups.AddToGroupAsync"," 是服务端 API，必须由你包一个 Hub 方法暴露出去。这是权限边界：否则任何人都能把自己塞进任意房间。",[194,2633,2635],{"className":540,"code":2634,"language":542,"meta":199,"style":199},"public class ChatHub : Hub\n{\n    \u002F\u002F 客户端 conn.invoke('Join', 'room:1') —— 必须经你自己的方法，这是唯一的入口\n    public async Task Join(string room)\n    {\n        await Groups.AddToGroupAsync(Context.ConnectionId, room);   \u002F\u002F 一定要 await\n    }\n\n    public Task Leave(string room) => Groups.RemoveFromGroupAsync(Context.ConnectionId, room);\n\n    \u002F\u002F 连接建立时自动进组 —— 重连也会重新触发，这是它最可靠的地方\n    public override async Task OnConnectedAsync()\n    {\n        if (Context.UserIdentifier is { } uid)\n            await Groups.AddToGroupAsync(Context.ConnectionId, $\"user:{uid}\");\n        await base.OnConnectedAsync();\n    }\n\n    public Task Push(string room) => Task.WhenAll(\n        Clients.Group(room).SendAsync(\"Msg\", \"给整个组\"),\n        Clients.OthersInGroup(room).SendAsync(\"Msg\", \"组里除我之外\"),\n        Clients.User(\"u1\").SendAsync(\"Msg\", \"u1 的全部连接\"),\n        Clients.Caller.SendAsync(\"Msg\", \"只回给我自己\")\n    );\n}\n\n",[201,2636,2637,2649,2653,2658,2678,2683,2698,2702,2706,2733,2737,2742,2758,2762,2781,2805,2820,2824,2828,2855,2880,2902,2929,2947,2952],{"__ignoreMap":199},[204,2638,2639,2641,2643,2645,2647],{"class":206,"line":207},[204,2640,554],{"class":217},[204,2642,560],{"class":217},[204,2644,563],{"class":231},[204,2646,566],{"class":235},[204,2648,569],{"class":231},[204,2650,2651],{"class":206,"line":214},[204,2652,574],{"class":235},[204,2654,2655],{"class":206,"line":263},[204,2656,2657],{"class":210},"    \u002F\u002F 客户端 conn.invoke('Join', 'room:1') —— 必须经你自己的方法，这是唯一的入口\n",[204,2659,2660,2662,2665,2667,2670,2672,2674,2676],{"class":206,"line":277},[204,2661,584],{"class":217},[204,2663,2664],{"class":217}," async",[204,2666,587],{"class":231},[204,2668,2669],{"class":231}," Join",[204,2671,242],{"class":235},[204,2673,595],{"class":217},[204,2675,695],{"class":231},[204,2677,608],{"class":235},[204,2679,2680],{"class":206,"line":284},[204,2681,2682],{"class":235},"    {\n",[204,2684,2685,2688,2690,2692,2695],{"class":206,"line":307},[204,2686,2687],{"class":217},"        await",[204,2689,704],{"class":235},[204,2691,707],{"class":231},[204,2693,2694],{"class":235},"(Context.ConnectionId, room);   ",[204,2696,2697],{"class":210},"\u002F\u002F 一定要 await\n",[204,2699,2700],{"class":206,"line":312},[204,2701,2133],{"class":235},[204,2703,2704],{"class":206,"line":344},[204,2705,281],{"emptyLinePlaceholder":280},[204,2707,2708,2710,2712,2715,2717,2719,2721,2723,2725,2727,2730],{"class":206,"line":364},[204,2709,584],{"class":217},[204,2711,587],{"class":231},[204,2713,2714],{"class":231}," Leave",[204,2716,242],{"class":235},[204,2718,595],{"class":217},[204,2720,695],{"class":231},[204,2722,335],{"class":235},[204,2724,338],{"class":217},[204,2726,704],{"class":235},[204,2728,2729],{"class":231},"RemoveFromGroupAsync",[204,2731,2732],{"class":235},"(Context.ConnectionId, room);\n",[204,2734,2735],{"class":206,"line":376},[204,2736,281],{"emptyLinePlaceholder":280},[204,2738,2739],{"class":206,"line":400},[204,2740,2741],{"class":210},"    \u002F\u002F 连接建立时自动进组 —— 重连也会重新触发，这是它最可靠的地方\n",[204,2743,2744,2746,2749,2751,2753,2756],{"class":206,"line":429},[204,2745,584],{"class":217},[204,2747,2748],{"class":217}," override",[204,2750,2664],{"class":217},[204,2752,587],{"class":231},[204,2754,2755],{"class":231}," OnConnectedAsync",[204,2757,260],{"class":235},[204,2759,2760],{"class":206,"line":435},[204,2761,2682],{"class":235},[204,2763,2764,2767,2770,2773,2776,2779],{"class":206,"line":444},[204,2765,2766],{"class":217},"        if",[204,2768,2769],{"class":235}," (Context.UserIdentifier ",[204,2771,2772],{"class":217},"is",[204,2774,2775],{"class":235}," { } ",[204,2777,2778],{"class":231},"uid",[204,2780,608],{"class":235},[204,2782,2783,2786,2788,2790,2792,2795,2797,2799,2801,2803],{"class":206,"line":450},[204,2784,2785],{"class":217},"            await",[204,2787,704],{"class":235},[204,2789,707],{"class":231},[204,2791,710],{"class":235},[204,2793,2794],{"class":245},"$\"user:",[204,2796,716],{"class":245},[204,2798,2778],{"class":235},[204,2800,722],{"class":245},[204,2802,725],{"class":245},[204,2804,728],{"class":235},[204,2806,2807,2809,2812,2814,2817],{"class":206,"line":455},[204,2808,2687],{"class":217},[204,2810,2811],{"class":221}," base",[204,2813,1254],{"class":235},[204,2815,2816],{"class":231},"OnConnectedAsync",[204,2818,2819],{"class":235},"();\n",[204,2821,2822],{"class":206,"line":461},[204,2823,2133],{"class":235},[204,2825,2826],{"class":206,"line":501},[204,2827,281],{"emptyLinePlaceholder":280},[204,2829,2830,2832,2834,2837,2839,2841,2843,2845,2847,2849,2852],{"class":206,"line":531},[204,2831,584],{"class":217},[204,2833,587],{"class":231},[204,2835,2836],{"class":231}," Push",[204,2838,242],{"class":235},[204,2840,595],{"class":217},[204,2842,695],{"class":231},[204,2844,335],{"class":235},[204,2846,338],{"class":217},[204,2848,656],{"class":235},[204,2850,2851],{"class":231},"WhenAll",[204,2853,2854],{"class":235},"(\n",[204,2856,2857,2860,2862,2865,2867,2869,2872,2874,2877],{"class":206,"line":825},[204,2858,2859],{"class":235},"        Clients.",[204,2861,765],{"class":231},[204,2863,2864],{"class":235},"(room).",[204,2866,619],{"class":231},[204,2868,242],{"class":235},[204,2870,2871],{"class":245},"\"Msg\"",[204,2873,486],{"class":235},[204,2875,2876],{"class":245},"\"给整个组\"",[204,2878,2879],{"class":235},"),\n",[204,2881,2882,2884,2887,2889,2891,2893,2895,2897,2900],{"class":206,"line":830},[204,2883,2859],{"class":235},[204,2885,2886],{"class":231},"OthersInGroup",[204,2888,2864],{"class":235},[204,2890,619],{"class":231},[204,2892,242],{"class":235},[204,2894,2871],{"class":245},[204,2896,486],{"class":235},[204,2898,2899],{"class":245},"\"组里除我之外\"",[204,2901,2879],{"class":235},[204,2903,2904,2906,2909,2911,2914,2916,2918,2920,2922,2924,2927],{"class":206,"line":854},[204,2905,2859],{"class":235},[204,2907,2908],{"class":231},"User",[204,2910,242],{"class":235},[204,2912,2913],{"class":245},"\"u1\"",[204,2915,249],{"class":235},[204,2917,619],{"class":231},[204,2919,242],{"class":235},[204,2921,2871],{"class":245},[204,2923,486],{"class":235},[204,2925,2926],{"class":245},"\"u1 的全部连接\"",[204,2928,2879],{"class":235},[204,2930,2931,2934,2936,2938,2940,2942,2945],{"class":206,"line":876},[204,2932,2933],{"class":235},"        Clients.Caller.",[204,2935,619],{"class":231},[204,2937,242],{"class":235},[204,2939,2871],{"class":245},[204,2941,486],{"class":235},[204,2943,2944],{"class":245},"\"只回给我自己\"",[204,2946,608],{"class":235},[204,2948,2949],{"class":206,"line":899},[204,2950,2951],{"class":235},"    );\n",[204,2953,2954],{"class":206,"line":905},[204,2955,447],{"class":235},[10,2957,2958,2961,2962,2964],{},[21,2959,2960],{},"group 成员关系是内存态、不持久化","。应用重启就全没了——但因为 ",[201,2963,2816],{}," 在每次（重）连时都会跑，把 join 逻辑写在那里就等于自动重建了。这也是它比\"手动维护一张组表\"省心的地方。",[1669,2966,2967],{"id":2967},"backplane",[10,2969,2970],{},"它解决的是上面那个\"映射存在哪\"的直接后果：",[30,2972,2973,2976,2979,2997,2999,3002,3005,3010,3015,3019,3025,3029,3032,3036,3040,3043,3046,3048,3051,3054,3057,3060,3063,3066,3069,3072,3075,3077],{"viewBox":2180,"width":33,"role":34,"xmlns":35},[37,2974,2975],{},"SignalR backplane 如何把消息扇出到所有实例",[41,2977,2978],{},"两个服务器各自只持有本地连接，消息先发布到 Redis，再由每个服务器投递给自己的本地客户端。",[2980,2981,2982],"defs",{},[2983,2984,2990],"marker",{"id":2985,"viewBox":2986,"refX":98,"refY":2987,"markerWidth":2988,"markerHeight":2988,"orient":2989},"ar2","0 0 10 10","5","6","auto-start-reverse",[2991,2992],"path",{"d":2993,"fill":2539,"stroke":2994,"strokeWidth":2995,"strokeLineCap":2996,"strokeLineJoin":2996},"M2 1L8 5L2 9","#888780","1.5","round",[45,2998],{"x":47,"y":2189,"width":49,"height":96,"rx":51,"fill":52,"stroke":53,"strokeWidth":54},[56,3000,3001],{"x":58,"y":2192,"fontFamily":60,"fontSize":61,"fontWeight":62,"fill":63},"backplane 让 Clients.All 真的等于「所有」",[56,3003,3004],{"x":58,"y":2196,"fontFamily":60,"fontSize":68,"fill":69},"没有它时，Clients.All 只等于「连到我这台的」—— 这是扩容后第一个撞上的墙",[45,3006],{"x":3007,"y":2200,"width":3008,"height":3009,"rx":1499,"fill":2232,"stroke":2233,"strokeWidth":54},"230","220","44",[56,3011,3014],{"x":3012,"y":3013,"textAnchor":2482,"fontFamily":60,"fontSize":74,"fontWeight":62,"fill":2237},"340","104","Redis pub\u002Fsub",[56,3016,3018],{"x":3012,"y":3017,"textAnchor":2482,"fontFamily":109,"fontSize":80,"fill":2242},"121","backplane channel",[206,3020],{"x1":3021,"y1":113,"x2":3022,"y2":3023,"stroke":2994,"strokeWidth":2995,"markerStart":3024,"markerEnd":3024},"185","268","126","url(#ar2)",[206,3026],{"x1":3027,"y1":3023,"x2":3028,"y2":113,"stroke":2994,"strokeWidth":2995,"markerStart":3024,"markerEnd":3024},"412","495",[45,3030],{"x":2205,"y":3031,"width":124,"height":3009,"rx":1499,"fill":99,"stroke":81,"strokeWidth":54},"168",[56,3033,3035],{"x":3021,"y":3034,"textAnchor":2482,"fontFamily":60,"fontSize":74,"fontWeight":62,"fill":104},"186","Server 1",[56,3037,3039],{"x":3021,"y":3038,"textAnchor":2482,"fontFamily":60,"fontSize":80,"fill":81},"203","只认识本地的连接与组",[45,3041],{"x":3042,"y":3031,"width":124,"height":3009,"rx":1499,"fill":99,"stroke":81,"strokeWidth":54},"390",[56,3044,3045],{"x":3028,"y":3034,"textAnchor":2482,"fontFamily":60,"fontSize":74,"fontWeight":62,"fill":104},"Server 2",[56,3047,3039],{"x":3028,"y":3038,"textAnchor":2482,"fontFamily":60,"fontSize":80,"fill":81},[2991,3049],{"d":3050,"fill":2539,"stroke":2994,"strokeWidth":54},"M150 212 V226 H120",[2991,3052],{"d":3053,"fill":2539,"stroke":2994,"strokeWidth":54},"M220 212 V226 H250",[2991,3055],{"d":3056,"fill":2539,"stroke":2994,"strokeWidth":54},"M460 212 V226 H430",[2991,3058],{"d":3059,"fill":2539,"stroke":2994,"strokeWidth":54},"M530 212 V226 H560",[2519,3061],{"cx":2209,"cy":3062,"r":2522,"fill":91},"236",[2519,3064],{"cx":3065,"cy":3062,"r":2522,"fill":91},"258",[2519,3067],{"cx":3068,"cy":3062,"r":2522,"fill":91},"422",[2519,3070],{"cx":3071,"cy":3062,"r":2522,"fill":91},"568",[56,3073,3074],{"x":2209,"y":2246,"textAnchor":2482,"fontFamily":60,"fontSize":80,"fill":69},"\n本地连接\n",[56,3076,3074],{"x":3071,"y":2246,"textAnchor":2482,"fontFamily":60,"fontSize":80,"fill":69},[56,3078,3080],{"x":58,"y":3079,"fontFamily":60,"fontSize":68,"fill":184},"288","\n成员关系仍留在各实例内存里，backplane 只搬消息、不搬状态\n",[21,3082,3083],{},"backplane 的根源问题",[21,3085,3086],{},"进程内内存",[201,3088,3089],{},"Clients.All",[194,3091,3093],{"className":540,"code":3092,"language":542,"meta":199,"style":199},"builder.Services.AddSignalR()\n    .AddStackExchangeRedis(\"redis:6379\");   \u002F\u002F Hub 代码一行不改\n\n",[201,3094,3095,3103],{"__ignoreMap":199},[204,3096,3097,3099,3101],{"class":206,"line":207},[204,3098,811],{"class":235},[204,3100,814],{"class":231},[204,3102,260],{"class":235},[204,3104,3105,3108,3111,3113,3116,3118],{"class":206,"line":214},[204,3106,3107],{"class":235},"    .",[204,3109,3110],{"class":231},"AddStackExchangeRedis",[204,3112,242],{"class":235},[204,3114,3115],{"class":245},"\"redis:6379\"",[204,3117,667],{"class":235},[204,3119,3120],{"class":210},"\u002F\u002F Hub 代码一行不改\n",[10,3122,3123,3130,3131,3134,3135,3138],{},[21,3124,3125,3126,3129],{},"它换掉的是 ",[201,3127,3128],{},"HubLifetimeManager"," 这个抽象的实现","（",[201,3132,3133],{},"DefaultHubLifetimeManager"," 内存版 → ",[201,3136,3137],{},"RedisHubLifetimeManager","），而不是让你改业务代码。这个抽象是全篇的关键：所有\"谁来记住谁在哪\"的逻辑都被它收口了。",[14,3140,3142],{"id":3141},"vueuse实践","Vueuse实践",[10,3144,3145,3146,3149,3150,3153],{},"VueUse 是一套基于 ",[21,3147,3148],{},"Composition API"," 的 Vue 工具函数集合，",[201,3151,3152],{},"@vueuse\u002Fcore"," 是它的核心包，提供了大量开箱即用的组合式函数。具有纯函数式、响应式、SSR友好、类型完整、体积小等优点。覆盖了状态、元素、浏览器、传感器、网络、时间、工具等方面。",[10,3155,3156,3158,3159,1654],{},[201,3157,170],{}," 只对应上面「客户端 → 原生 WebSocket」这一列，",[21,3160,3161],{},"服务端、信封、group 全要自己写",[194,3163,3165],{"className":196,"code":3164,"language":198,"meta":199,"style":199},"\u002F\u002F app\u002Fcomposables\u002FuseChatSocket.ts\nimport { useWebSocket } from '@vueuse\u002Fcore'   \u002F\u002F 经 @vueuse\u002Fnuxt 已自动导入，这行可省\n\ninterface Envelope { type: 'chat' | 'ping' | 'pong'; user?: string; text?: string }\n\nexport function useChatSocket(url: string) {\n  const messages = shallowRef\u003CChatMessage[]>([])\n\n  const { status, data, send, open, close, ws } = useWebSocket\u003CEnvelope>(url, {\n    \u002F\u002F 这两个默认都是 false，必须显式开（SignalR 恰好相反，默认就开）\n    autoReconnect: {\n      retries: 10,\n      delay: n => Math.min(1000 * 2 ** n, 30_000),   \u002F\u002F 1s -> 2s -> 4s ... 封顶 30s\n      onFailed: () => console.warn('重连放弃'),\n    },\n    heartbeat: {\n      message: JSON.stringify({ type: 'ping' }),\n      responseMessage: JSON.stringify({ type: 'pong' }),\n      interval: 25_000,      \u002F\u002F 卡在 nginx 60s 的一半\n      pongTimeout: 5_000,    \u002F\u002F 5s 等不到 pong 就重连\n    },\n    immediate: false,        \u002F\u002F 先不连，由页面决定（比如等登录态就绪）\n\n    onMessage(_, ev) {\n      const msg = JSON.parse(ev.data) as Envelope\n      if (msg.type === 'pong') return              \u002F\u002F 心跳回执不进业务队列\n      messages.value = [...messages.value, msg as ChatMessage]\n    },\n    onDisconnected: (_, ev) => console.warn('closed', ev.code, ev.reason), \u002F\u002F 1006 = 异常断开\n  })\n\n  function sendChat(user: string, text: string) {\n    if (status.value !== 'OPEN') return false\n    \u002F\u002F 第二参 useBuffer=true：未连接时排队，连上后补发\n    send(JSON.stringify({ type: 'chat', user, text }), true)\n    return true\n  }\n\n  onScopeDispose(close)     \u002F\u002F 组件卸载自动断，避免\"走后门\"的连接\n  return { status, data, messages, open, close, ws, sendChat }\n}\n",[201,3166,3167,3172,3187,3191,3240,3244,3265,3281,3285,3333,3338,3343,3352,3394,3414,3419,3424,3445,3463,3476,3490,3494,3508,3512,3529,3556,3575,3595,3599,3632,3637,3641,3666,3686,3691,3717,3724,3728,3732,3742,3749],{"__ignoreMap":199},[204,3168,3169],{"class":206,"line":207},[204,3170,3171],{"class":210},"\u002F\u002F app\u002Fcomposables\u002FuseChatSocket.ts\n",[204,3173,3174,3176,3179,3181,3184],{"class":206,"line":214},[204,3175,937],{"class":217},[204,3177,3178],{"class":235}," { useWebSocket } ",[204,3180,943],{"class":217},[204,3182,3183],{"class":245}," '@vueuse\u002Fcore'",[204,3185,3186],{"class":210},"   \u002F\u002F 经 @vueuse\u002Fnuxt 已自动导入，这行可省\n",[204,3188,3189],{"class":206,"line":263},[204,3190,281],{"emptyLinePlaceholder":280},[204,3192,3193,3196,3199,3201,3204,3206,3209,3211,3214,3216,3219,3222,3224,3227,3229,3231,3233,3235,3237],{"class":206,"line":277},[204,3194,3195],{"class":217},"interface",[204,3197,3198],{"class":231}," Envelope",[204,3200,1733],{"class":235},[204,3202,3203],{"class":326},"type",[204,3205,292],{"class":217},[204,3207,3208],{"class":245}," 'chat'",[204,3210,965],{"class":217},[204,3212,3213],{"class":245}," 'ping'",[204,3215,965],{"class":217},[204,3217,3218],{"class":245}," 'pong'",[204,3220,3221],{"class":235},"; ",[204,3223,478],{"class":326},[204,3225,3226],{"class":217},"?:",[204,3228,483],{"class":221},[204,3230,3221],{"class":235},[204,3232,56],{"class":326},[204,3234,3226],{"class":217},[204,3236,483],{"class":221},[204,3238,3239],{"class":235}," }\n",[204,3241,3242],{"class":206,"line":284},[204,3243,281],{"emptyLinePlaceholder":280},[204,3245,3246,3248,3250,3253,3255,3258,3260,3262],{"class":206,"line":307},[204,3247,984],{"class":217},[204,3249,987],{"class":217},[204,3251,3252],{"class":231}," useChatSocket",[204,3254,242],{"class":235},[204,3256,3257],{"class":326},"url",[204,3259,292],{"class":217},[204,3261,483],{"class":221},[204,3263,3264],{"class":235},") {\n",[204,3266,3267,3269,3271,3273,3275,3277,3279],{"class":206,"line":312},[204,3268,347],{"class":217},[204,3270,1015],{"class":221},[204,3272,225],{"class":217},[204,3274,1020],{"class":231},[204,3276,640],{"class":235},[204,3278,1025],{"class":231},[204,3280,1028],{"class":235},[204,3282,3283],{"class":206,"line":344},[204,3284,281],{"emptyLinePlaceholder":280},[204,3286,3287,3289,3291,3294,3296,3298,3300,3302,3304,3307,3309,3312,3314,3317,3320,3322,3325,3327,3330],{"class":206,"line":364},[204,3288,347],{"class":217},[204,3290,1733],{"class":235},[204,3292,3293],{"class":221},"status",[204,3295,486],{"class":235},[204,3297,390],{"class":221},[204,3299,486],{"class":235},[204,3301,418],{"class":221},[204,3303,486],{"class":235},[204,3305,3306],{"class":221},"open",[204,3308,486],{"class":235},[204,3310,3311],{"class":221},"close",[204,3313,486],{"class":235},[204,3315,3316],{"class":221},"ws",[204,3318,3319],{"class":235}," } ",[204,3321,301],{"class":217},[204,3323,3324],{"class":231}," useWebSocket",[204,3326,640],{"class":235},[204,3328,3329],{"class":231},"Envelope",[204,3331,3332],{"class":235},">(url, {\n",[204,3334,3335],{"class":206,"line":376},[204,3336,3337],{"class":210},"    \u002F\u002F 这两个默认都是 false，必须显式开（SignalR 恰好相反，默认就开）\n",[204,3339,3340],{"class":206,"line":400},[204,3341,3342],{"class":235},"    autoReconnect: {\n",[204,3344,3345,3348,3350],{"class":206,"line":429},[204,3346,3347],{"class":235},"      retries: ",[204,3349,1499],{"class":221},[204,3351,2280],{"class":235},[204,3353,3354,3357,3359,3361,3363,3366,3369,3371,3374,3376,3379,3382,3385,3388,3391],{"class":206,"line":435},[204,3355,3356],{"class":231},"      delay",[204,3358,1519],{"class":235},[204,3360,1522],{"class":326},[204,3362,1202],{"class":217},[204,3364,3365],{"class":235}," Math.",[204,3367,3368],{"class":231},"min",[204,3370,242],{"class":235},[204,3372,3373],{"class":221},"1000",[204,3375,887],{"class":217},[204,3377,3378],{"class":221}," 2",[204,3380,3381],{"class":217}," **",[204,3383,3384],{"class":235}," n, ",[204,3386,3387],{"class":221},"30_000",[204,3389,3390],{"class":235},"),   ",[204,3392,3393],{"class":210},"\u002F\u002F 1s -> 2s -> 4s ... 封顶 30s\n",[204,3395,3396,3399,3401,3403,3405,3407,3409,3412],{"class":206,"line":444},[204,3397,3398],{"class":231},"      onFailed",[204,3400,1538],{"class":235},[204,3402,338],{"class":217},[204,3404,1205],{"class":235},[204,3406,1208],{"class":231},[204,3408,242],{"class":235},[204,3410,3411],{"class":245},"'重连放弃'",[204,3413,2879],{"class":235},[204,3415,3416],{"class":206,"line":450},[204,3417,3418],{"class":235},"    },\n",[204,3420,3421],{"class":206,"line":455},[204,3422,3423],{"class":235},"    heartbeat: {\n",[204,3425,3426,3429,3432,3434,3437,3439,3442],{"class":206,"line":461},[204,3427,3428],{"class":235},"      message: ",[204,3430,3431],{"class":221},"JSON",[204,3433,1254],{"class":235},[204,3435,3436],{"class":231},"stringify",[204,3438,523],{"class":235},[204,3440,3441],{"class":245},"'ping'",[204,3443,3444],{"class":235}," }),\n",[204,3446,3447,3450,3452,3454,3456,3458,3461],{"class":206,"line":501},[204,3448,3449],{"class":235},"      responseMessage: ",[204,3451,3431],{"class":221},[204,3453,1254],{"class":235},[204,3455,3436],{"class":231},[204,3457,523],{"class":235},[204,3459,3460],{"class":245},"'pong'",[204,3462,3444],{"class":235},[204,3464,3465,3468,3471,3473],{"class":206,"line":531},[204,3466,3467],{"class":235},"      interval: ",[204,3469,3470],{"class":221},"25_000",[204,3472,2324],{"class":235},[204,3474,3475],{"class":210},"\u002F\u002F 卡在 nginx 60s 的一半\n",[204,3477,3478,3481,3484,3487],{"class":206,"line":825},[204,3479,3480],{"class":235},"      pongTimeout: ",[204,3482,3483],{"class":221},"5_000",[204,3485,3486],{"class":235},",    ",[204,3488,3489],{"class":210},"\u002F\u002F 5s 等不到 pong 就重连\n",[204,3491,3492],{"class":206,"line":830},[204,3493,3418],{"class":235},[204,3495,3496,3499,3502,3505],{"class":206,"line":854},[204,3497,3498],{"class":235},"    immediate: ",[204,3500,3501],{"class":221},"false",[204,3503,3504],{"class":235},",        ",[204,3506,3507],{"class":210},"\u002F\u002F 先不连，由页面决定（比如等登录态就绪）\n",[204,3509,3510],{"class":206,"line":876},[204,3511,281],{"emptyLinePlaceholder":280},[204,3513,3514,3517,3519,3522,3524,3527],{"class":206,"line":899},[204,3515,3516],{"class":231},"    onMessage",[204,3518,242],{"class":235},[204,3520,3521],{"class":326},"_",[204,3523,486],{"class":235},[204,3525,3526],{"class":326},"ev",[204,3528,3264],{"class":235},[204,3530,3531,3534,3537,3539,3542,3544,3547,3550,3553],{"class":206,"line":905},[204,3532,3533],{"class":217},"      const",[204,3535,3536],{"class":221}," msg",[204,3538,225],{"class":217},[204,3540,3541],{"class":221}," JSON",[204,3543,1254],{"class":235},[204,3545,3546],{"class":231},"parse",[204,3548,3549],{"class":235},"(ev.data) ",[204,3551,3552],{"class":217},"as",[204,3554,3555],{"class":231}," Envelope\n",[204,3557,3558,3561,3564,3566,3568,3570,3572],{"class":206,"line":1243},[204,3559,3560],{"class":217},"      if",[204,3562,3563],{"class":235}," (msg.type ",[204,3565,409],{"class":217},[204,3567,3218],{"class":245},[204,3569,335],{"class":235},[204,3571,1054],{"class":217},[204,3573,3574],{"class":210},"              \u002F\u002F 心跳回执不进业务队列\n",[204,3576,3577,3579,3581,3583,3585,3588,3590,3593],{"class":206,"line":1272},[204,3578,1168],{"class":235},[204,3580,301],{"class":217},[204,3582,1173],{"class":235},[204,3584,1176],{"class":217},[204,3586,3587],{"class":235},"messages.value, msg ",[204,3589,3552],{"class":217},[204,3591,3592],{"class":231}," ChatMessage",[204,3594,361],{"class":235},[204,3596,3597],{"class":206,"line":1277},[204,3598,3418],{"class":235},[204,3600,3601,3604,3607,3609,3611,3613,3615,3617,3619,3621,3623,3626,3629],{"class":206,"line":1299},[204,3602,3603],{"class":231},"    onDisconnected",[204,3605,3606],{"class":235},": (",[204,3608,3521],{"class":326},[204,3610,486],{"class":235},[204,3612,3526],{"class":326},[204,3614,335],{"class":235},[204,3616,338],{"class":217},[204,3618,1205],{"class":235},[204,3620,1208],{"class":231},[204,3622,242],{"class":235},[204,3624,3625],{"class":245},"'closed'",[204,3627,3628],{"class":235},", ev.code, ev.reason), ",[204,3630,3631],{"class":210},"\u002F\u002F 1006 = 异常断开\n",[204,3633,3634],{"class":206,"line":1304},[204,3635,3636],{"class":235},"  })\n",[204,3638,3639],{"class":206,"line":1316},[204,3640,281],{"emptyLinePlaceholder":280},[204,3642,3643,3645,3648,3650,3652,3654,3656,3658,3660,3662,3664],{"class":206,"line":1327},[204,3644,1459],{"class":217},[204,3646,3647],{"class":231}," sendChat",[204,3649,242],{"class":235},[204,3651,478],{"class":326},[204,3653,292],{"class":217},[204,3655,483],{"class":221},[204,3657,486],{"class":235},[204,3659,56],{"class":326},[204,3661,292],{"class":217},[204,3663,483],{"class":221},[204,3665,3264],{"class":235},[204,3667,3668,3670,3673,3676,3679,3681,3683],{"class":206,"line":1346},[204,3669,403],{"class":217},[204,3671,3672],{"class":235}," (status.value ",[204,3674,3675],{"class":217},"!==",[204,3677,3678],{"class":245}," 'OPEN'",[204,3680,335],{"class":235},[204,3682,1054],{"class":217},[204,3684,3685],{"class":221}," false\n",[204,3687,3688],{"class":206,"line":1354},[204,3689,3690],{"class":210},"    \u002F\u002F 第二参 useBuffer=true：未连接时排队，连上后补发\n",[204,3692,3693,3696,3698,3700,3702,3704,3706,3709,3712,3715],{"class":206,"line":1359},[204,3694,3695],{"class":231},"    send",[204,3697,242],{"class":235},[204,3699,3431],{"class":221},[204,3701,1254],{"class":235},[204,3703,3436],{"class":231},[204,3705,523],{"class":235},[204,3707,3708],{"class":245},"'chat'",[204,3710,3711],{"class":235},", user, text }), ",[204,3713,3714],{"class":221},"true",[204,3716,608],{"class":235},[204,3718,3719,3721],{"class":206,"line":1364},[204,3720,1349],{"class":217},[204,3722,3723],{"class":221}," true\n",[204,3725,3726],{"class":206,"line":1370},[204,3727,432],{"class":235},[204,3729,3730],{"class":206,"line":1401},[204,3731,281],{"emptyLinePlaceholder":280},[204,3733,3734,3736,3739],{"class":206,"line":1412},[204,3735,1592],{"class":231},[204,3737,3738],{"class":235},"(close)     ",[204,3740,3741],{"class":210},"\u002F\u002F 组件卸载自动断，避免\"走后门\"的连接\n",[204,3743,3744,3746],{"class":206,"line":1429},[204,3745,1619],{"class":217},[204,3747,3748],{"class":235}," { status, data, messages, open, close, ws, sendChat }\n",[204,3750,3751],{"class":206,"line":1445},[204,3752,447],{"class":235},[10,3754,3755,3757,3758,3761,3762,3765],{},[201,3756,390],{}," 是「最新一条」，不是队列。**​ 它是个 ",[201,3759,3760],{},"ShallowRef\u003CT | null>","，只保留最后收到的消息。你以为它会累积？不会。要历史就必须自己在 ",[201,3763,3764],{},"onMessage"," 里攒（上面就是这么做的）。",[10,3767,3768,3769,1696,3772,3775,3776,3778],{},"**",[201,3770,3771],{},"autoReconnect",[201,3773,3774],{},"heartbeat"," 默认都是 ",[201,3777,3501],{},"。​ SignalR 是默认开、默认有值；VueUse 是默认关。不显式配，断线了它不会自己回来，也不会发心跳。",[10,3780,3781,3786,3787,3790,3791,3794,3795,3798],{},[21,3782,3783,3785],{},[201,3784,3774],{}," 发的是应用层消息，不是协议层 ping。因为浏览器 JS 拿不到协议 ping（这里绕不开）。所以","你的服务端必须能识别 ",[201,3788,3789],{},"{\"type\":\"ping\"}"," 并回 ",[201,3792,3793],{},"{\"type\":\"pong\"}","**——否则 ",[201,3796,3797],{},"pongTimeout"," 一到就误判断线，然后开始无限重连风暴。这是最容易炸的一处",[3800,3801,3802],"style",{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s7hpK, html code.shiki .s7hpK{--shiki-default:#B31D28;--shiki-default-font-style:italic;--shiki-dark:#FDAEB7;--shiki-dark-font-style:italic}",{"title":199,"searchDepth":214,"depth":214,"links":3804},[3805,3811],{"id":16,"depth":214,"text":17,"children":3806},[3807,3808,3809,3810],{"id":1671,"depth":263,"text":1672},{"id":2170,"depth":263,"text":2170},{"id":2454,"depth":263,"text":765},{"id":2967,"depth":263,"text":2967},{"id":3141,"depth":214,"text":3142},"md",{"date":3814,"tags":3815},"2026-12-12",[3816,3817,16,3818],"network","websocket","vueuse","\u002Fnetwork\u002Fwebsocket的相关实践指导",{"description":12},"network\u002FWebSocket的相关实践指导","sQY55Igk6ZXaFbybBoTs6eEjF00aemMyxJl50CxI3PQ",1791871181665]