本文首发于CSDN网站,下面的版本又经过进一步的修订。
原文:开发环境页面热更新早已是主流,我们不光要吃着火锅唱着歌,享受热更新高效率的快感,更要深入下去探求其原理。
要知道,触类则旁通,常见的需求如赛事网页推送比赛结果、网页实时展示投票或点赞数据、在线评论或弹幕、在线聊天室等,都需要借助热更新功能,才能达到实时的端对端的极致体验。
刚好,最近解决webpack-hot-middleware
热更新延迟问题的过程中,我深入接触了EventSource技术。遂本文由此开篇,进一步讲解webpack-hot-middleware
,browser-sync
背后的技术。
webpack-hot-middleware
webpack-hot-middleware
中间件是webpack的一个plugin,通常结合webpack-dev-middleware
一起使用。借助它可以实现浏览器的无刷新更新(热更新),即webpack里的HMR(Hot Module Replacement)。如何配置请参考 ,如何理解其相关插件请参考 。
webpack加入webpack-hot-middleware
后,内存中的页面将包含HMR相关js,加载页面后,Network栏可以看到如下请求:
__webpack_hmr是一个type
为EventSource的请求, 从Time
栏可以看出:默认情况下,服务器每十秒推送一条信息到浏览器。
如果此时关闭开发服务器,浏览器由于重连机制,将持续抛出类似GET http://www.test.com/__webpack_hmr 502 (Bad Gateway)
这样的错误。重新启动开发服务器后,重连将会成功,此时便会刷新页面。
以上这些便是我们使用时感受到的最初的印象。当然,停留在使用层面不是我们的目标,接下来我们将跳出该中间件,讲解其所使用到的EventSource
技术。
EventSource
EventSource 不是一个新鲜的技术,它早就随着H5规范提出了,正式一点应该叫Server-sent events
,即SSE
。
鉴于传统的通过ajax轮训获取服务器信息的技术方案已经过时,我们迫切需要一个高效的节省资源的方式去获取服务器信息,一旦服务器资源有更新,能够及时地通知到客户端,从而实时地反馈到用户界面上。EventSource就是这样的技术,它本质上还是HTTP,通过response流实时推送服务器信息到客户端。
新建一个EventSource对象非常简单。
const es = new EventSource('/message');// /message是服务端支持EventSource的接口
新创建的EventSource对象拥有如下属性:
属性 | 描述 |
---|---|
url(只读) | es对象请求的服务器url |
readyState(只读) | es对象的状态,初始为0,包含CONNECTING (0),OPEN (1),CLOSED (2)三种状态 |
withCredentials | 是否允许带凭证等,默认为false,即不支持发送cookie |
服务端实现/message
接口,需要返回类型为 text/event-stream
的响应头。
var http = require('http');http.createServer(function(req,res){ if(req.url === '/message'){ res.writeHead(200,{ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); setInterval(function(){ res.write('data: ' + +new Date() + '\n\n'); }, 1000); }}).listen(8888);
我们注意到,为了避免缓存,Cache-Control 特别设置成了 no-cache,为了能够发送多个response, Connection被设置成了keep-alive.。发送数据时,请务必保证服务器推送的数据以 data:
开始,以\n\n
结束,否则推送将会失败(原因就不说了,这是约定的)。
以上,服务器每隔1s主动向客户端发送当前时间戳,为了接受这个信息,客户端需要监听服务器。如下:
es.onmessage = function(e){ console.log(e.data); // 打印服务器推送的信息}
如下是消息推送的过程:
你以为es只能监听message事件吗?并不是,message只是缺省的事件类型。实际上,它可以监听任何指定类型的事件。
es.addEventListener("####", function(e) {// 事件类型可以随你定义 console.log('####:', e.data);},false);
服务器发送不同类型的事件时,需要指定event字段。
res.write('event: ####\n');res.write('data: 这是一个自定义的####类型事件\n');res.write('data: 多个data字段将被解析成一个字段\n\n');
如下所示:
可以看到,服务端指定event事件名为"####"后,客户端触发了对应的事件回调,同时服务端设置的多个data字段,客户端使用换行符连接成了一个字符串。
不仅如此,事件流中还可以混合多种事件,请看我们是怎么收到消息的,如下:
除此之外,es对象还拥有另外3个方法: onopen()
、onerror()
、close()
,请参考如下实现。
es.onopen = function(e){// 链接打开时的回调 console.log('当前状态readyState:', es.readyState);// open时readyState===1}es.onerror = function(e){// 出错时的回调(网络问题,或者服务下线等都有可能导致出错) console.log(es.readyState);// 出错时readyState===0 es.close();// 出错时,chrome浏览器会每隔3秒向服务器重发原请求,直到成功. 因此出错时,可主动断开原连接.}
使用EventSource技术实时更新网页信息十分高效。实际使用中,我们几乎不用担心兼容性问题,主流浏览器都了支持EventSource,当然,除了掉队的IE系。对于不支持的浏览器,其PolyFill方案请参考。
CORS
另外,如果需要支持跨域调用,请设置响应头Access-Control-Allow-Origin': '*'
。
如需支持发送cookie,请设置响应头Access-Control-Allow-Origin': req.headers.origin
和 Access-Control-Allow-Credentials:true
,并且创建es对象时,需要明确指定是否发送凭证。如下:
var es = new EventSource('/message', { withCredentials: true}); // 创建时指定配置才是有效的es.withCredentials = true; // 与ajax不同,这样设置是无效的
以下是主流浏览器对EventSource的CORS的支持:
Firefox | Opera | Chrome | Safari | iOS | Android |
---|---|---|---|---|---|
10+ | 12+ | 26+ | 7.0+ | 7.0+ | 4.4+ |
nginx配置
既然说到了EventSource,便有必要谈谈遇到的坑,接下来,就说说我遇到的webpack热更新延迟问题。
如我们所知,webpack借助webpack-hot-middleware插件,实现了网页热更新机制,正常情况下,浏览器打开 这样的网页即可开始调试。然而实际开发中,由于远程服务器需要种cookie登录态到特定的域名上等原因,因此本地往往会用nginx做一层反向代理。即把 的请求转发到 上(配置过程这里不详述,具体请参考)。转发过后,发现热更新便延迟了。
原因是nginx默认开启的buffer机制缓存了服务器推送的片段信息,缓存达到一定的量才会返回响应内容。只要关闭proxy_buffering即可。配置如下所示:
server { listen 80; server_name www.test.company.com; location / { proxy_pass http://localhost:8080; proxy_buffering off; }}
至此,EventSource部分便告一段落。学习讲究由浅入深,循序渐进。后面我将重点讲解的browser-sync
热更新机制,请耐心细读。
browser-sync
开发中使用browser-sync
插件调试,一个网页里的所有交互动作(包括滚动,输入,点击等等),可以实时地同步到其他所有打开该网页的设备,能够节省大量的手工操作时间,从而带来流畅的开发调试体验。目前browser-sync
可以结合Gulp
或Grunt
一起使用,其API请参考:。
通过上面的了解,我们知道EventSouce
的使用是比较便捷的,那为什么browser-sync
不使用EventSource技术进行代码推送呢?这是因为browser-sync
插件共做了两件事:
开发更新了一段新的逻辑,服务器实时推送代码改动信息。数据流:服务器 —> 浏览器,使用EventSource技术同样能够实现。
用户操作网页,滚动、输入或点击等,操作信息实时发送给服务器,然后再由服务器将操作同步给其他已打开的网页。数据流:浏览器 —> 服务器 —> 浏览器,该部分功能EventSource技术已无能为力。
以上,browser-sync
使用WebSocket技术达到实时推送代码改动和用户操作两个目的。至于它是如何计算推送内容,根据不同推送内容采取何种响应策略,不在本次讨论范围之内。下面我们将讲解其核心的WebSocket技术。
WebSocket
WebSocket是基于TCP的全双工通讯的协议,它与EventSource有着本质上的不同.(前者基于TCP,后者依然基于HTTP) 该协议于2011年被IETF定为标准RFC6455,后被RFC7936补充. WebSocket api也被W3C定为标准。
WebSocket使用和HTTP相同的TCP端口,默认为80, 统一资源标志符为ws,运行在TLS之上时,默认使用443,统一资源标志符为wss。它通过101 switch protocol
进行一次TCP握手,即从HTTP协议切换成WebSocket通信协议。
相对于HTTP协议,WebSocket拥有如下优点:
全双工,实时性更强。
相对于http携带完整的头部,WebSocket请求头部明显减少。
保持连接状态,不用再验权了。
二进制支持更强,Websocket定义了二进制帧,处理更轻松。
Websocket协议支持扩展,可以自定义的子协议,如
permessage-deflate
扩展。
支持性
优秀技术的落地,调研兼容性是必不可少的环节。所幸的是,现代浏览器对WebSocket的支持比较友好,如下是PC端兼容性:
IE/Edge | Firefox | Chrome | Safari | Opera |
---|---|---|---|---|
10+ | 11+ | 16+ | 7+ | 12.1+ |
如下是mobile端兼容性:
iOS Safari | Android | Android Chrome | Android UC | QQ Browser | Opera Mini |
---|---|---|---|---|---|
7.1+ | 4.4+ | 57+ | 11.4+ | 1.2+ | - |
Frame
根据RFC6455文档,WebSocket协议基于Frame而非Stream(EventSource是基于Stream的)。因此其传输的数据都是Frame(帧)。想要了解数据的往返,弄懂协议处理过程,Frame的解读是必不可少。如下便是Frame的结构:
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-------+-+-------------+-------------------------------+ |F|R|R|R| opcode|M| Payload len | Extended payload length | |I|S|S|S| (4) |A| (7) | (16/64) | |N|V|V|V| |S| | (if payload len==126/127) | | |1|2|3| |K| | | +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + | Extended payload length continued,if payload len == 127 | + - - - - - - - - - - - - - - - +-------------------------------+ | |Masking-key,if MASK set to 1 | +-------------------------------+-------------------------------+ | Masking-key (continued) | Payload Data | +-------------------------------- - - - - - - - - - - - - - - - + : Payload Data continued ... : + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + | Payload Data continued ... | +---------------------------------------------------------------+
第一个字节包含FIN、RSV、Opcode。
FIN:size为1bit,标示是否最后一帧。
%x0
表示还有后续帧,%x1
表示这是最后一帧。RSV1、2、3,每个size都是1bit,默认值都是0,如果没有定义非零值的含义,却出现了非零值,则WebSocket链接将失败。
-
Opcode,size为4bits,表示『payload data』的类型。如果收到未知的opcode,连接将会断开。已定义的opcode值如下:
%x0: 代表连续的帧%x1: 文本帧%x2: 二进制帧%x3~7: 预留的非控制帧%x8: 关闭握手帧%x9: ping帧,后续心跳连接会讲到%xA: pong帧,后续心跳连接会讲到%xB~F: 预留的非控制帧
第二个字节包含Mask、Payload len。
-
Mask:size为1bit,标示『payload data』是否添加掩码。所有从客户端发送到服务端的帧都会被置为1,如果置1,
Masking-key
便会赋值。//若server是一个WebSocket服务端实例//监听客户端消息server.on('message', function(msg, flags) { console.log('client say: %s', msg); console.log('mask value:', flags.masked);// true,进一步佐证了客户端发送到服务端的Mask帧都会被置为1});//监听客户端pong帧响应server.on('pong', function(msg, flags) { console.log('pong data: %s', msg); console.log('mask value:', flags.masked);// true,进一步佐证了客户端发送到服务端的Mask帧都会被置为1});
Payload len:size为7bits,即使是当做无符号整型也只能表示0~127的值,所以它不能表示更大的值,因此规定"Payload data"长度小于或等于125的时候才用来描述数据长度。如果
Payload len==126
,则使用随后的2bytes(16bits)来存储数据长度。如果Payload len==127
,则使用随后的8bytes(64bits)来存储数据长度。
以上,扩展的Payload len可能占据第三至第四个或第三至第十个字节。紧随其后的是"Mask-key"。
Mask-key:size为0或4bytes(32bits),默认为0,与前面Mask呼应,从客户端发送到服务端的帧都包含4bytes(32bits)的掩码,一旦掩码被设置,所有接收到的"payload data"都必须与该值以一种算法做异或运算来获取真实值。
Payload data:size为"Extension data" 和 "Application data" 的总和,一般"Extension data"数据为空。
Extension data:默认为0,如果扩展被定义,扩展必须指定"Extension data"的长度。
Application data:占据"Extension data"之后剩余帧的空间。
关于Frame的更多理论介绍不妨读读 。
关于Frame的数据帧解析不妨读读 及其后续文章。
建立连接
了解了Frame的数据结构后,我们来实际练习下。浏览器上,新建一个ws对象十分简单。如下:
let ws = new WebSocket('ws://127.0.0.1:10103/');// 本地使用10103端口进行测试
新建的WebSocket对象如下所示:
这中间包含了一次Websocket握手的过程,我们分两步来理解。
第一步,客户端请求。
这是一个GET请求,主要字段如下:
Connection: UpgradeUpgrade: websocketSec-WebSocket-Key:61x6lFN92sJHgzXzCHfBJQ==Sec-WebSocket-Version:13
Connection字段指定为Upgrade,表示客户端希望连接升级。
Upgrade字段设置为websocket,表示希望升级至Websocket协议。
Sec-WebSocket-Key字段是随机字符串,服务器根据它来构造一个SHA-1的信息摘要。
Sec-WebSocket-Version表示支持的Websocket版本。RFC6455要求使用的版本是13。
甚至我们可以从请求截图里看出,Origin是file://
,而Host是127.0.0.1:10103
,明显不是同一个域下,但依然可以请求成功,说明Websocket协议是不受同源策略限制的(同源策略限制的是http协议)。
第二步,服务端响应。
Status Code: 101 Switching Protocols 表示Websocket协议通过101状态码进行握手。
Sec-WebSocket-Accept字段是由Sec-WebSocket-Key字段加上特定字符串"258EAFA5-E914-47DA-95CA-C5AB0DC85B11",计算SHA-1摘要,然后再base64编码之后生成的. 该操作可避免普通http请求,被误认为Websocket协议。
Sec-WebSocket-Extensions字段表示服务端对Websocket协议的扩展。
以上,WebSocket构造器不止可以传入url,还能传入一个可选的协议名称字符串或数组。
ws = new WebSocket('ws://127.0.0.1:10103/', ['abc','son_protocols']);
服务端实现
等等,我们慢一点,上面好像漏掉了一步,似乎没有提到服务端是怎么实现的。请继续往下看:
先做一些准备。ws是一个nodejs版的WebSocketServer实现。使用 npm install ws
即可安装。
var WebSocketServer = require('ws').Server, server = new WebSocketServer({port: 10103});server.on('connection', function(s) { s.on('message', function(msg) { //监听客户端消息 console.log('client say: %s', msg); }); s.send('server ready!');// 连接建立好后,向客户端发送一条消息});
以上,new WebSocketServer()
创建服务器时如需权限验证,请指定verifyClient
为验权的函数。
server = new WebSocketServer({ port: 10103, verifyClient: verify});function verify(info){ console.log(Object.keys(info));// [ 'origin', 'secure', 'req' ] console.log(info.orgin);// "file://" return true;// 返回true时表示验权通过,否则客户端将抛出"HTTP Authentication failed"错误}
以上,verifyClient
指定的函数只有一个形参,若为它显式指定两个形参,那么第一个参数同上info,第二个参数将是一个cb
回调函数。该函数用于显式指定拒绝时的HTTP状态码等,它默认拥有3个形参,依次为:
result,布尔值类型,表示是否通过权限验证。
code,数值类型,若result值为false时,表示HTTP的错误状态码。
name,字符串类型,若result值为false时,表示HTTP状态码的错误信息。
// 若verify定义如下function verify(info, cb){ //一旦拥有第二个形参,如果不调用,默认将通过验权 cb(false, 401, '权限不够');// 此时表示验权失败,HTTP状态码为401,错误信息为"权限不够" return true;// 一旦拥有第二个形参,响应就被cb接管了,返回什么值都不会影响前面的处理结果}
除了port
和 verifyClient
设置外,其它设置项及更多API,请参考文档 。
发送和监听消息
接下来,我们来实现消息收发。如下是客户端发送消息。
ws.onopen = function(e){ // 可发送字符串,ArrayBuffer 或者 Blob数据 ws.send('client ready!);};
客户端监听信息。
ws.onmessage = function(e){ console.log('server say:', e.data);};
如下是浏览器的运行截图。
消息的内容都在Frames栏,第一条彩色背景的信息是客户端发送的,第二条是服务端发送的。两条消息的长度都是13。
如下是Timing栏,不止是WebSocket,包括EventSource,都有这样的黄色高亮警告。
该警告说明:请求还没完成。实际上,直到一方连接close掉,请求才会完成。
关闭连接
说到close,ws的close方法比es的略复杂。
语法:close(short code,string reason);
close默认可传入两个参数。code是数字,表示关闭连接的状态号,默认是1000,即正常关闭。(code取值范围从0到4999,其中有些是保留状态号,正常关闭时只能指定为1000或者3000~4999之间的值,具体请参考)。reason是UTF-8文本,表示关闭的原因(文本长度需小于或等于123字节)。
由于code 和 reason都有限制,因此该方法可能抛出异常,建议catch下.
try{ ws.close(1001, 'CLOSE_GOING_AWAY');}catch(e){ console.log(e);}
ws对象还拥有onclose和onerror监听器,分别监听关闭和错误事件。(注:EventSource没有onclose监听)
拥有的属性
ws的readyState属性拥有4个值,比es的readyState的多一个CLOSING的状态。
常量 | 描述 | EventSource(值) | WebSocket(值) |
---|---|---|---|
CONNECTING | 连接未初始化 | 0 | 0 |
OPEN | 连接已就绪 | 1 | 1 |
CLOSING | 连接正在关闭 | - | 2 |
CLOSED | 连接已关闭 | 2 | 3 |
另外,除了两种都有的url属性外,WebSocket对象还拥有更多的属性。
属性 | 描述 |
---|---|
binaryType | 被传输二进制内容的类型,有blob,arraybuffer两种 |
bufferedAmount | 待传输的数据的长度 |
extensions | 表示服务器选用的扩展 |
protocol | 指的是构造器第二个参数传入的子协议名称 |
文件上传
以前一直是使用ajax做文件上传,实际上,Websocket上传文件也是一把好刀. 其send方法可以发送String,ArrayBuffer,Blob共三种数据类型,发送二进制文件完全不在话下。
由于各个浏览器对Websocket单次发送的数据有限制,所以我们需要将待上传文件切成片段去发送。如下是实现。
1) html。
2) js。
const ws = new WebSocket('ws://127.0.0.1:10103/');// 连接服务器const fileSelect = document.getElementById('file');const size = 1024 * 128;// 分段发送的文件大小(字节)let curSize, total, file, fileReader;fileSelect.onchange = function(){ file = this.files[0];// 选中的待上传文件 curSize = 0;// 当前已发送的文件大小 total = file.size;// 文件大小 ws.send(file.name);// 先发送待上传文件的名称 fileReader = new FileReader();// 准备读取文件 fileReader.onload = loadAndSend; readFragment();// 读取文件片段};function loadAndSend(){ if(ws.bufferedAmount > size * 5){// 若发送队列中的数据太多,先等一等 setTimeout(loadAndSend,4); return; } ws.send(fileReader.result);// 发送本次读取的片段内容 curSize += size;// 更新已发送文件大小 curSize < total ? readFragment() : console.log('upload successed!');// 下一步操作}function readFragment(){ const blob = file.slice(curSize, curSize + size);// 获取文件指定片段 fileReader.readAsArrayBuffer(blob);// 读取文件为ArrayBuffer对象}
3) server(node)。
var WebSocketServer = require('ws').Server, server = new WebSocketServer({port: 10103}),// 启动服务器 fs = require('fs');server.on('connection', function(wsServer){ var fileName, i = 0;// 变量定义不可放在全局,因每个连接都不一样,这里才是私有作用域 server.on('message', function(data, flags){// 监听客户端消息 if(flags.binary){// 判断是否二进制数据 var method = i++ ? 'appendFileSync' : 'writeFileSync'; // 当前目录下写入或者追加写入文件(建议加上try语句捕获可能的错误) fs[method]('./' + fileName, data,'utf-8'); }else{// 非二进制数据则认为是文件名称 fileName = data; } }); wsServer.send('server ready!');// 告知客户端服务器已就绪});
运行效果如下:
上述测试代码中没有过多涉及服务器的存储过程。通常,服务器也会有缓存区上限,如果客户端单次发送的数据量超过服务端缓存区上限,那么服务端也需要多次读取。
心跳连接
生产环境下上传一个文件远比本地测试来得复杂。实际上,从客户端到服务端,中间存在着大量的网络链路,如路由器,防火墙等等。一份文件的上传要经过中间的层层路由转发,过滤。这些中间链路可能会认为一段时间没有数据发送,就自发切断两端的连接。这个时候,由于TCP并不定时检测连接是否中断,而通信的双方又相互没有数据发送,客户端和服务端依然会一厢情愿的信任之前的连接,长此以往,将使得大量的服务端资源被WebSocket连接占用。
正常情况下,TCP的四次挥手完全可以通知两端去释放连接。但是上述这种普遍存在的异常场景,将使得连接的释放成为梦幻。
为此,早在websocket协议实现时,设计者们便提供了一种 Ping/Pong Frame的心跳机制。一端发送Ping Frame,另一端以 Pong Frame响应。这种Frame是一种特殊的数据包,它只包含一些元数据,能够在不影响原通信的情况下维持住连接。
根据规范,Ping Frame包含一个值为9的opcode,它可能携带数据。收到Ping Frame后,Pong Frame必须被作为响应发出。Pong Frame包含一个值为10的opcode,它将包含与Ping Frame中相同的数据。
借助ws包,服务端可以这么来发送Ping Frame。
wsServer.ping();
同时,需要监听客户端响应的pong Frame.
wsServer.on('pong', function(data, flags) { console.log(data);// "" console.log(flags);// { masked: true,binary: true }});
以上,由于Ping Frame 不带数据,因此作为响应的Pong Frame的data值为空串。遗憾的是,目前浏览器只能被动发送Pong Frame作为响应(),无法通过JS API主动向服务端发送Ping Frame。因此对于web服务,可以采取服务端主动ping的方式,来保持住链接。实际应用中,服务端还需要设置心跳的周期,以保证心跳连接可以一直持续。同时,还应该有重发机制,若连续几次没有收到心跳连接的回复,则认为连接已经断开,此时便可以关闭Websocket连接了。
Socket.IO
WebSocket出世已久,很多优秀的大神基于此开发出了各式各样的库。其中是一个非常不错的开源WebSocke库,旨在抹平浏览器之间的兼容性问题。它基于Node.js,支持以下方式优雅降级:
Websocket
Adobe® Flash® Socket
AJAX long polling
AJAX multipart streaming
Forever Iframe
JSONP Polling
如何在项目中使用Socket.IO,请参考。
小结
EventSource,本质依然是HTTP,它仅提供服务端到客户端的单向文本数据传输,不需要心跳连接,连接断开会持续触发重连。
WebSocket协议,基于TCP协议,它提供双向数据传输,支持二进制,需要心跳连接,连接断开不会重连。
EventSource更轻量和简单,WebSocket支持性更好(因其支持IE10+)。通常来说,使用EventSource能够完成的功能,使用WebSocket一样能够做到,反之却不行,使用时若遇到连接断开或抛错,请及时调用各自的close
方法主动释放资源。
本问就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论。
本文作者:
本文链接:
参考文章