本文主要探讨现阶段浏览器端可行的实时通讯方案,以及它们的发展历史。php
这里以sockjs
做为切入点,这是一个流行的浏览器实时通讯库,提供了'类Websocket'、一致性、跨平台的API,旨在浏览器和服务器之间建立一个低延迟、全双工、支持跨域的实时通讯信道. 主要特色就是仿生Websocket,它会优先使用Websocket做为传输层,在不支持WebSocket的环境回退使用其余解决方案,例如XHR-Stream、轮询. html
因此sockjs
自己就是浏览器实时通讯方案的编年史, 本文也是按照由新到老这样的顺序来介绍这些解决方案.前端
相似sockjs的解决方案还有 socket.io若是你以为文章不错,请不要吝惜你的点赞👍,鼓励笔者写出更精彩的文章git
目录程序员
WebSocket其实不是本文的主角,并且网上已经有不少教程,本文的目的是介绍WebSocket以外的一些回退方案,在浏览器不支持Websocket的状况下, 能够选择回退到这些方案.github
在此介绍Websocket以前,先来了解一些HTTP的基础知识,毕竟WebSocket自己是借用HTTP协议实现的。web
HTTP协议是基于TCP/IP之上的应用层协议,也就是说HTTP在TCP链接中进行请求和响应的,浏览器会为每一个请求创建一个TCP链接,请求等待服务端响应,在服务端响应后关闭链接:segmentfault
后来人们发现为每一个HTTP请求都创建一个TCP链接,太浪费资源了,能不能不要着急关闭TCP链接,而是将它复用起来, 在一个TCP链接中进行屡次请求。跨域
这就有了HTTP持久链接(HTTP persistent connection, 也称为HTTP keep-alive), 它利用同一个TCP链接来发送和接收多个HTTP请求/响应。持久链接的方式能够大大减小等待时间, 双方不须要从新运行TCP握手,这对前端静态资源的加载也有很大意义:浏览器
Ok, 如今回到WebSocket, 浏览器端用户程序并不支持和服务端直接创建TCP链接,可是上面咱们看到每一个HTTP请求都会创建TCP链接, TCP是可靠的、全双工的数据通讯通道,那咱们何不直接利用它来进行实时通讯? 这就是Websocket的原理!
咱们这里经过一张图,通俗地理解一下Websocket的原理:
经过上图能够看到,WebSocket除最初创建链接时须要借助于现有的HTTP协议,其余时候直接基于TCP完成通讯。这是浏览器中最靠近套接字的API,能够实时和服务端进行全双工通讯. WebSocket相比传统的浏览器的Comet)(下文介绍)技术, 有不少优点:
它的接口也很是简单:
const ws = new WebSocket('ws://localhost:8080/socket'); // 错误处理 ws.onerror = (error) => { ... } // 链接关闭 ws.onclose = () => { ... } // 链接创建 ws.onopen = () => { // 向服务端发送消息 ws.send("ping"); } // 接收服务端发送的消息 ws.onmessage = (msg) => { if(msg.data instanceof Blob) { // 处理二进制信息 processBlob(msg.data); } else { // 处理文本信息 processText(msg.data); } }
本文不会深刻解析Websocket的协议细节,有兴趣的读者能够看下列文章:
若是不考虑低版本IE,基本上WebSocket不会有什么兼容性上面的顾虑. 下面列举了Websocket一些常见的问题, 当没法正常使用Websocket时,能够利用sockjs或者socket.io这些方案回退到传统的Comet技术方案.
浏览器兼容性。
XHR-Streming, 中文名称‘XHR流’, 这是WebSocket的最佳替补方案. XHR-streaming的原理也比较简单:服务端使用分块传输编码(Chunked transfer encoding)的HTTP传输机制进行响应,而且服务器端不终止HTTP响应流,让HTTP始终处于持久链接状态,当有数据须要发送给客户端时再进行写入数据。
没理解?不要紧,咱们一步一步来, 先来看一下正常的HTTP请求处理是这样的:
// Node.js代码 const http = require('http') const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain', // 设置内容格式 'Content-Length': 11, // 设置内容长度 }) res.end('hello world') // 响应 })
客户端会当即接收到响应:
那么什么是分块传输编码呢?
在HTTP/1.0以前, 响应是必须做为一整块数据返回客户端的(如上例),这要求服务端在发送响应以前必须设置Content-Length
, 浏览器知道数据的大小后才能肯定响应的结束时间。这让服务器响应动态的内容变得很是低效,它必须等待全部动态内容生成完,再计算Content-Length, 才能够发送给客户端。若是响应的内容体积很大,须要占用不少内存空间.
HTTP/1.1引入了Transfer-Encoding: chunked;
报头。 它容许服务器发送给客户端应用的数据能够分为多个部分, 并以一个或多个块发送,这样服务器能够发送数据而不须要提早计算发送内容的总大小。
有了分块传输机制后,动态生成内容的服务器就能够维持HTTP长链接, 也就是说服务器响应流不结束,TCP链接就不会断开.
如今咱们切换为分块传输编码模式, 且咱们不终止响应流,看会有什么状况:
const http = require('http') const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' // 'Content-Length': 11, // 🔴将Content-Length报头去掉,Node.js默认就是使用分块编码传输的 }) res.write('hello world') // res.end() // 🔴不终止输出流 })
咱们会发现请求会一直处于Pending状态(绿色下载图标),除非出现异常、服务器关闭或显式关闭链接(好比设置超时机制),请求是永远不会终止的。可是即便处于Pending状态客户端仍是能够接收数据,没必要等待请求结束:
基于这个原理咱们再来建立一个简单的ping-pong服务器:
const server = http.createServer((req, res) => { if (req.url === '/ping') { // ping请求 if (pendingResponse == null) { res.writeHead(500); res.write('session not found'); res.end(); return; } res.writeHead(200) res.end() // 给客户端推流 pendingResponse.write('pong\n'); } else { // 保存句柄 res.writeHead(200, { 'Content-Type': 'text/plain', }); res.write('welcome to ping\n'); pendingResponse = res } });
测试一下,在另外一个窗口访问/ping
路径:
Ok! 这就是XHR-Streaming!
那么Ajax怎么接收这些数据呢? ①一种作法是在XMLHttpRequest
的onreadystatechange
事件处理器中判断readyState
是否等于XMLHttpRequest.LOADING
;②另一种作法是在xhr.onprogress
事件处理器中处理。下面是ping客户端实现:
function listen() { const xhr = new XMLHttpRequest(); xhr.onprogress = () => { // 注意responseText是获取服务端发送的全部数据,若是要获取未读数据,则须要进行划分 console.log('progress', xhr.responseText); } xhr.open('POST', HOST); xhr.send(null); } function ping() { const xhr = new XMLHttpRequest(); xhr.open('POST', HOST + '/ping'); xhr.send(null); } listen(); setInterval(ping, 5000);
慢着,不要高兴得太早😰. 若是运行上面的代码会发现onprogress
并无被正常的触发, 具体缘由笔者也没有深刻研究,我发现sockjs的服务器源码里面会预先写入2049个字节,这样就能够正常触发onprogress事件了:
const server = http.createServer((req, res) => { if (req.url === '/ping') { // ping请求 // ... } else { // 保存句柄 res.writeHead(200, { 'Content-Type': 'text/plain', }); res.write(Array(2049).join('h') + '\n'); pendingResponse = res } });
最后再图解一下XHR-streaming的原理:
总结一下XHR-Streaming的特色:
经过XHR-Streaming,能够容许服务端连续地发送消息,无需每次响应后再去创建一个链接, 因此它是除了Websocket以外最为高效的实时通讯方案. 但它也并非天衣无缝。
好比XHR-streaming链接的时间越长,浏览器会占用过多内存,并且在每一次新的数据到来时,须要对消息进行划分,剔除掉已经接收的数据. 所以sockjs对它进行了一点优化, 例如sockjs默认只容许每一个xhr-streaming链接输出128kb数据,超过这个大小时会关闭输出流,让浏览器从新发起请求.
了解了XHR-Streaming, 就会以为EventSource
并非什么新鲜玩意: 它就是上面讲的XHR-streaming
, 只不过浏览器给它提供了标准的API封装和协议, 你抓包一看和XHR-streaming没有太大的区别:
上面能够看到请求的Accept
为text/event-stream
, 且服务端写入的数据都有标准的约定, 即载荷须要这样组织:
const data = `data: ${payload}\r\n\r\n`
EventSource的API和Websocket相似, 实例:
const evtSource = new EventSource('sse.php'); // 链接打开 evtSource.onopen = () => {} // 接受消息 evtSource.onmessage = function(e) { // do something // ... console.log("message: " + e.data) // 关闭流 evtSource.close() } // 异常 evtSource.onerror = () => {}
由于是标准的,浏览器调试也比较方便,不须要借助第三方抓包工具:
这是一种古老的‘秘术’😂,虽然咱们可能永远都不会再用到它,可是它的实现方式比较有意思(相似于JSONP这种黑科技), 因此仍是值得讲一下。
HtmlFile的另外一个名字叫作永久帧(forever-frame)
, 顾名思义, 浏览器会打开一个隐藏的iframe,这个iframe会请求一个分块传输编码的html文件(Transfer-Encoding: chunked), 和XHR-Streaming同样,这个请求永远都不会结束,服务器会不断在这个文档上输出内容。这里面的要点是现代浏览器都会增量渲染html文件,因此服务器能够经过添加script标签在客户端执行某些代码,先来看个抓包的实例:
从上图能够看出:
<script>
标签,script的代码就是将数据传递给callback。利用浏览器会被下载边解析HTML文档的特性,新增的script会立刻被执行最后仍是用流程图描述一下:
除了IE六、7如下不支持,大部分浏览器都支持这个方案,当浏览器不支持XHR-streaming
时,能够做为最佳备胎。
轮询是最粗暴(或者说最简单),也是效率最低下的‘实时’通讯方案,这种方式的原理就是按期向服务器发起请求, 拉取最新的消息队列:
这种轮询方式比较合适服务器的信息按期更新的场景,如天气和股票行情信息。举个例子股票信息每隔5分钟更新一次,这时候客户端按期轮询, 且轮询间隔和服务端更新频率保持一致是一种理想的方式。
可是若是追求实时性,轮询会致使一些严重的问题:
还有一种优化的轮询方法,称为长轮询(Long Polling),sockjs就是使用这种轮询方式, 长轮询指的是浏览器发送一个请求到服务器,服务器只有在有可用的新数据时才响应:
客户端向服务端发起一个消息获取请求,服务端会将当前的消息队列返回给客户端,而后关闭链接。当消息队列为空时,服务端不会当即关闭链接,而是等待指定的时间间隔,若是在这个时间间隔内没有新的消息,则由客户端主动超时关闭链接。
另一个要点是,客户端的轮询请求只有在上一个请求链接关闭后才会从新发起。这就解决了上文的请求轰炸问题。服务端能够控制客户端的请求时序,由于在服务端未响应以前,客户端不会发送额外的请求(在超时期间内)。