🥃 JavaScript 长轮询 WebSocket
长轮询是 与服务器保持持久连接 的最简单的方式,它不使用任何特定的协议。(例如 WebSocket 或者 Server Sent Event)比较容易实现,适用较多场景。
# 常规轮询
定期轮询是从服务器获取新信息的最简单的方式(定期向服务器发送请求)。作为响应,服务器首先通知自己,客户端处于在线状态,然后发送目前为止的消息包。
使用定期轮询的缺点:
- 消息传递存在延迟(两个请求之间);
- 即使没有消息,服务器也会每隔 10 秒被请求轰炸一次,即使用户切换到其他地方或者处于休眠状态,也是如此。就性能而言,这是一个很大的负担。
对于小型服务或许可行。
# 长轮询
是轮询服务器的一种更好的方式,容易实现,可以无延迟传递消息。长轮询流程为:
- 请求发送到服务器;
- 服务器在有消息时之前不会关闭连接;
- 当消息出现时,服务器将对其做出响应;
- 浏览器立即发送新的请求。
对于此方法,浏览器发出一个请求并与服务器之间建立起一个 挂起的连接 的情况是标准的。仅在有消息被传递时,才会重新建立连接。
🌰 例子 / 实现长轮询客户端的函数:
async function subscribe() {
let response = await fetch("/subsribe")
if (response.status == 502) {
// 502 超时错误
// 超时导致连接挂起时间过长,远程服务器或者代理会关闭该连接
// 重新连接
await subsribe()
} else if (response.status != 200) {
// 发生错误时
showMessage(response.status)
// 一秒后重新连接
await new Promise(resolve => setTimeout(resolve, 1000));
await subsribe();
} else {
// 获取消息
let message = await response.text();
// 显示消息
showMessage(message)
// 调用 subsribe() 获取下一条消息
await subsribe();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
subscribe
函数发起了一个fetch
,然后等待响应,处理它,并再次调用自身。
提示
对于服务器,应该可以处理许多挂起的连接。某些服务器架构是每个连接对应一个进程,导致进程数和连接数一样多,而每个进程都会消耗相当多的内存。因此,过多的连接会消耗掉全部内存。
长轮询的使用场景:
- 在消息很少的情况下,长轮询很有效。
- 如果消息比较频繁,那么上面描绘的请求 - 接收消息的图表就会变成锯状状。每个消息都是一个单独的请求,并带有 header,身份验证等增加了开销。此时,应该选择另一种方法(WebSocket 或者 Server Sent Events)
# WebSocket
是一个提供浏览器和服务器之间 建立持久连接 来 交换数据 的方法协议。数据可以作为「数据包」在两个方向上传递,而不会断开连接和其他 HTTP 请求。
对于需要连续数据交换的服务,例如网络游戏,实时交易系统等,WebSocket 尤其有用。
- WebSocket 没有跨源限制。
- 浏览器对 WebSocket 支持很好。
- 可以发送 / 接收字符串和二进制数据。
创建 WebSocket 连接,需要在 URL 使用特殊的协议 ws
:
let socket = new WebSocket("ws://javascript.info");
加密的
wss://
协议,类似于 HTTPS。
wss://
协议不仅是被加密的,而且更可靠。因为ws://
中数据不是加密的,对于任何中间人来说其数据都是可见的。并且,旧的代理服务器不了解 WebSocket,可能会因为奇怪的 header 而中止连接;wss://
是基于 TLS 的 WebSocket,类似于 HTTPS 是基于 TLS 的 HTTP),传输安全层在发送方对数据进行了加密,在接收方进行解密。因此,数据包是通过代理加密传输的。它们看不到传输的里面的内容,且会让这些数据通过。
WebSocket 被建立后,就可以监听 socket
的事件:
open
:连接已建立;message
:接收到数据;error
:连接出错;close
:连接已关闭;
使用 WebSocket 发送数据使用 socket.send(data)
方法。
🌰 例子 / 使用 WebSocket 发送数据:
let socket = new WebSocket("wss://javascript.info/article/websocket/demo/hello");
socket.onopen = function(e) {
alert("[open] Connection established");
alert("Sending to server");
socket.send("My name is John"); // 发送消息
};
socket.onmessage = function(event) {
alert(`[message] Data received from server: ${event.data}`);
};
socket.onclose = function(event) {
if (event.wasClean) {
alert(`[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
} else {
// 例如服务器进程被杀死或网络中断
// 在这种情况下,event.code 通常为 1006
alert('[close] Connection died');
}
};
socket.onerror = function(error) {
alert(`[error] ${error.message}`);
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 建立连接
当 new WebSocket(url)
创建时,建立起了连接。
连接期间,浏览器使用响应头 header 发送请求询问服务器是否支持 WebSocket, 如果支持才会以 WebSocket 协议继续进行。
🌰 例子 / new WebSocket("wss://javascript.info/chat")
请求头内容:
GET /chat
Host: javascript.info
Origin: https://javascript.info
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q==
Sec-WebSocket-Version: 13
2
3
4
5
6
7
Origin
:客户端页面的源。WebSocket 对象是 原生支持跨源 的。没有特殊的 header 或其他限制。~~ 旧的服务器无法处理 WebSocket,因此不存在兼容性问题。~~ 但是Origin
很重要,它允许服务器决定是否使用 WebSocket 与该网站通信;Connection: Upgrade
:表示客户端想要 更改协议;Upgrade: websocket
:更改请求的协议为websocket
;Sec-WebSocket-Key
:浏览器随机生成的安全密钥。Sec-WebSocket-Version
:WebSocket 协议版本,当前为 13。`
注意不可以模拟 WebSocket 握手,例如使用 XMLHttpRequest
或 fetch
设置这些请求头的内容。
如果服务器同意切换为 WebSocket 协议,则响应头内容为(响应码为 101
):
101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g=
2
3
4
同时,这里的
Sec-WebSocket-Accept
是Sec-WebSocket-Key
,浏览器使用它保证响应与请求相对应。
然后就可以使用 WebSocket 协议传输数据。并且之后可以看到,它的结构不是 HTTP。
WebSocket 拓展和子协议:
点击查看
WebSocket 的其他 header: Sec-WebSocket-Extensions
和 Sec-WebSocket-Protocol
,它们描述了扩展和子协议。
Sec-WebSocket-Extensions: deflate-frame
:表示浏览器 支持数据压缩。这个拓展与数据传输有关,拓展了 WebSocket 的协议的功能。Sec-WebSocket-Extensions
header 由浏览器自动发送,其中包含其支持的所有扩展的列表。Sec-WebSocket-Protocol: soap, wamp
:表示不仅要传输数据,还要传输 SOAP / WAMP 协议中的数据。这个可选的 header 是使用
new WebSocket
的第二个参数设置的。它是子协议数组,例如,如果想使用 SOAP 或 WAMP:let socket = new WebSocket("wss://javascript.info/chat", ["soap", "wamp"]);
1
服务器应该使用同意使用的协议和扩展的列表进行响应。
例如:
请求头 header:
GET /chat Host: javascript.info Upgrade: websocket Connection: Upgrade Origin: https://javascript.info Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q== Sec-WebSocket-Version: 13 Sec-WebSocket-Extensions: deflate-frame Sec-WebSocket-Protocol: soap, wamp
1
2
3
4
5
6
7
8
9响应头 header:
101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g= Sec-WebSocket-Extensions: deflate-frame Sec-WebSocket-Protocol: soap
1
2
3
4
5
6
# 数据传输
WebSocket 通信由 frames
(即数据片段)组成,可以从任何一方发送,并且有以下几种类型:
text frames
:文本数据。binary data frames
:二进制数据。ping/pong frames
:用于检查服务器发送的链接。浏览器会自动响应它们。conection close frames
…
在浏览器里,仅仅使用 文本数据或者二进制数据。
使用 WebSocket 的 .send
方法发送文本或者二进制数据:
socket.send(body)
调用允许body
是字符串或二进制格式,包括Blob
,ArrayBuffer
等。不需要额外的设置:直接发送它们就可以了。
当接收到数据,文本总是以 字符串 形式呈现。对于二进制数据,在 Blob
和 ArrayBuffer
格式之间选择。(由 socket.binaryType
属性设置的,默认为 "blob"
,因此二进制数据通常以 Blob
对象呈现。)
Blob
高级二进制对象,可以直接与<a>
,<img>
及其他标签集成在一起。所以为默认的格式。要访问单个数据字节,可以修改为
arrayBuffer
:socket.binaryType = "arraybuffer"; socket.onmessage = (event) => { // event.data 可以是文本(如果是文本),也可以是 arraybuffer(如果是二进制数据) };
1
2
3
4
# 限速
如果应用程序正在生成大量的数据,但是用户的网速很慢。
此时,可以反复调用
socket.send(data)
。但是数据将会被缓存(缓冲存储)在内存中,并且只能在网速允许的情况下尽快将数据发送出去。
socket.bufferedAmount
属性储存了 目前已缓冲的字节数,等待通过网络发送。
🌰 例子 / 使用该属性查看 socket
是否真的可以用于传输:
setInterval(()=> {
if(socket.bufferedAmount == 0) {
socket.send(moreData());
}
}, 100)
2
3
4
5
每 100 ms 检查一次
socket
。仅当所有现有的数据都已被发送出去时(即缓存中没有数据),再发送更多数据。
# 关闭连接
通常,当一方想要关闭连接时(浏览器和服务器都具有相同的权限),它们会发送一个带有 数字码 和文本形式的原因的 connection close frame
。
关闭连接的方法:
socket.close([code], [reason])
[code]
:可选的 特殊的 WebSocket 关闭码。[reason]
:可选的 描述关闭原因的字符串。
当另一方通过 close
事件处理器获取了 关闭码和关闭原因 时。例如:
// 关闭方:
socket.close(1000, "Work complete");
// 另一方
socket.onclose = event => {
// event.code === 1000
// event.reason === "Work complete"
// event.wasClean === true (clean close)
};
2
3
4
5
6
7
8
9
可以通过
event
获取这些属性。
通常的 WebSocket 关闭码:
点击查看
1000
:默认,正常关闭(如果没有指明code
时使用它),1006
:没有办法手动设定这个数字码,表示连接丢失(没有 close frame)。其他数字码,例如:
1001
:一方正在离开,例如服务器正在关闭,或者浏览器离开了该页面,1009
:消息太大,无法处理,1011
: 服务器上发生意外错误,- ……。
注意,小于 1000
的码都是被保留的,如果我们尝试设置这样的码,将会出现错误。
# 连接状态
要获取连接状态,可以通过带有值的 socket.readyState
属性:
0
—— “CONNECTING”:连接还未建立,1
—— “OPEN”:通信中,2
—— “CLOSING”:连接关闭中,3
—— “CLOSED”:连接已关闭。
# Server Sent Events
Server-Sent Event 规范描述了一个内建的类 EventSource
。能保持服务器的连接,并且允许从中接收事件。(与 WebSocket 连接类似,是持久的连接)
WebSocket 与 EventSource 的区别:
WebSocket
EventSource
双向:客户端和服务端都能交换消息 单向:仅服务端能发送消息 二进制和文本数据 仅文本数据 WebSocket 协议 常规 HTTP 协议 相比 WebSocket, EventSource 更加简单。
当需要从服务器接收一个数据流,可能是 聊天消息或者其他信息。
EventSource
支持自动重新连接,而在WebSocket
需要手动实现。并且它支持常规的 HTTP 协议。
# 获取消息
使用 new EventSource(url)
。浏览器将会连接到 url
并保持连接打开,等待事件。
服务器响应的状态码应该为 200,header 为 Content-Type: text/event-stream
,然后保持此连接并以一种特殊的格式写入消息。例如:
data: Message 1
data: Message 2
data: Message 3
data: of two lines
2
3
4
5
6
data
:消息文本,:
后的空格可选;- 消息以两个换行符
\n\n
分隔。 - 要发送一个换行符
\n
的消息,在要换行的位置立即再发送一个data:
(上面的第三条消息)。
实际运用中,复杂的消息一般使用 JSON 编码后发送。换行符在其中编码为 \n
,因此不需要多行 data:
消息。
🌰 例子:
data: {"user":"John","message":"First line\n Second line"}
对于这样类型的信息,都会生成一个 message
事件:
let eventSource = new EventSource("/events/subsribe");
eventSource.onmessage = function(event) {
console.log("new Message", event.data)
// 对于上面的数据流将打印三次
}
// 或 eventSource.addEventListener('message', ...)
2
3
4
5
6
7
# 跨源请求
EventSource
支持跨源请求,就像 fetch
和任何其他网络方法。
所以 EventSource 可以使用任何 URL:
let source = new EventSource("https://another-site.com/events");
远程服务器将会获取到 Origin
header,并且必须以 Access-Control-Allow-Origin
响应来处理。要传递凭证(credentials),应该设置附加选项 withCredentials
,如下:
let source = new EventSource("https://another-site.com/events", {
withCredentials: true
});
2
3
# 重新连接
new EventSource
连接到服务器,如果连接断开,则重新连接。每次重新连接之间有一点小的延迟,默认为几秒钟。
服务器可以使用 retry:
来 设置需要的延迟响应时间(以毫秒为单位)。
retry: 15000
data: Hello, I set the reconnection delay to 15 seconds
2
retry:
既可以与某些数据一起出现,也可以作为独立的消息出现。
在重新连接之前,浏览器需要等待设定的延迟响应时间。例如,如果浏览器知道(从操作系统)此时没有网络连接,它会等到连接出现,然后重试。
如果服务器想要浏览器 停止重新连接,那么应该使用 HTTP 状态码 204 进行响应;
如果浏览器想要 关闭连接,则应该调用
eventSource.close()
;let eventSource new EventSource(...); eventSource.close();
1
2
3如果响应具有不正确的
Content-Type
或者 HTTP 状态码不是 301,307,200 和 204,则不会进行重新连接。在这种情况下,将会发出error
错误事件,并且浏览器不会重新连接。
当连接被关闭时,则无法「重新打开」该连接。如果要重新连接,就要重新创建
new EventSource()
。
# 连接 id
当一个连接由于网络问题而中断时,客户端和服务器都无法确定哪些消息已经收到哪些没有收到。为了正确地恢复连接,每条消息都应该有一个 id
字段,如下:
data: Message 1
id: 1
data: Message 2
id: 2
data: Message 3
data: of two lines
id: 3
2
3
4
5
6
7
8
9
当收到具有 id
的消息时,浏览器会:
- 将属性
eventSource.lastEventId
设置为其值。 - 重新连接后,发送带有
id
的 headerLast-Event-ID
,以便服务器可以重新发送后面的消息。
注意:
id
被服务器附加到data
消息后,以确保在收到消息后lastEventId
会被更新。
# 连接状态
EventSource
对象有 readyState
连接状态属性,该属性具有:
EventSource.CONNECTING = 0;
:连接中或者重新连接中;EventSource.OPEN = 1;
:已连接;EventSource.CLOSED = 2;
:连接已关闭;
对象创建完成或者连接断开后,它始终是 EventSource.CONNECTING
(等于 0
)。
可以通过查询该属性获得 EventSource
的状态。
# 连接事件
EventSource
的对象会默认生成三个事件:
message
:收到消息,可以用event.data
访问。open
:连接已打开。error
:无法建立连接,例如,服务器返回 HTTP 500 状态码。
服务器可以在事件开始时使用 event: ...
指定另一种类型事件。
🌰 例子:
event: join
data: Bob
data: Hello
event: leave
data: Bob
2
3
4
5
6
7
处理自定义事件 join
,必须使用 addEventListener
:
eventSource.addEventListener('join', event => {
alert(`Joined ${event.data}`);
});
eventSource.addEventListener('message', event => {
alert(`Said: ${event.data}`);
});
eventSource.addEventListener('leave', event => {
alert(`Left ${event.data}`);
});
2
3
4
5
6
7
8
9
10
11