本文为原创投稿文章,未经允许,请勿转载。
作者:翟灿东,网名路易斯,平安健康前端工程师。有四年前端架构及开发经验。熟悉正则,坚持原著,深度思考,力求简单通俗叙事。博客地址: http://louiszhai.github.io。
责编:陈秋歌,寻求报道或者投稿请发邮件至chenqg#csdn.net,或加微信:Rachel_qg。
了解更多前沿技术资讯,获取深度技术文章推荐,请关注CSDN研发频道微博。
开发环境页面热更新早已是主流,我们不光要吃着火锅唱着歌,享受热更新高效率的快感,更要深入下去探求其原理。
刚好最近解决webpack-hot-middleware
热更新延迟问题的过程中,我深入接触了EventSource技术。遂本文由此开篇,进一步讲解webpack-hot-middleware
,browser-sync
背后的技术。
webpack-hot-middleware
中间件是Webpack的一个plugin,通常结合webpack-dev-middleware
一起使用。借助它可以实现浏览器的无刷新更新(热更新),即Webpack里的HMR(Hot Module Replacement)。如何配置请参考 webpack-hot-middleware,如何理解其相关插件请参考 手把手深入理解 Webpack dev middleware 原理与相关 plugins。
Webpack加入webpack-hot-middleware
后,内存中的页面将包含HMR相关JS,加载页面后,Network栏可以看到如下请求:
__webpack_hmr是一个type
为EventSource的请求, 从Time
栏可以看出:默认情况下,服务器每十秒推送一条信息到浏览器。
如果此时关闭开发服务器,浏览器由于重连机制,将持续抛出类似GET http://www.test.com/__webpack_hmr 502 (Bad Gateway)
这样的错误。重新启动开发服务器后,重连将会成功,此时便会刷新页面。接下来我们将跳出该中间件,讲解其所使用到的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请参考HTML5 Cross Browser Polyfills。
另外,如果需要支持跨域调用,请设置响应头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+ |
接下来,说说我遇到的Webpack热更新延迟问题。
Webpack借助webpack-hot-middleware插件,实现了网页热更新机制,正常情况下,浏览器打开 http://localhost:8080 这样的网页即可开始调试。然而实际开发中,由于远程服务器需要种cookie登录态到特定的域名上等原因,因此本地往往会用nginx做一层反向代理。即把 http://www.test.com 的请求转发到 http://localhost:8080 上(配置过程这里不详述,具体请参考Ajax知识体系大梳理-ajax调试技巧)。转发过后,发现热更新便延迟了。
原因是nginx默认开启的buffer机制缓存了服务器推送的片段信息,缓存达到一定的量才会返回响应内容。只要关闭proxy_buffering即可。如下:
server {
listen 80;
server_name www.test.company.com;
location / {
proxy_pass http://localhost:8080;
proxy_buffering off;
}
}
开发中使用browser-sync
插件调试,一个网页里的所有交互动作(包括滚动、输入、点击等等),可以实时地同步到其他所有打开该网页的设备,能够节省大量的手工操作时间,从而带来流畅的开发调试体验。目前browser-sync
可以结合Gulp
或Grunt
一起使用,其API请参考:Browsersync API。
为什么browser-sync
不使用EventSource技术进行代码推送呢?这是因为browser-sync
插件共做了两件事:
以上,browser-sync
使用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通信协议。该协议基于Frame而非Stream(EventSource是基于Stream的)。
相对于HTTP协议,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+ | - |
WebSocket传输的数据基于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”。
关于Frame的更多理论介绍不妨读读 学习WebSocket协议—从顶层到底层的实现原理(修订版)。
关于Frame的数据帧解析不妨读读 WebSocket(贰) 解析数据帧 及其后续文章。
浏览器上,新建一个ws对象十分简单。
let ws = new WebSocket('ws://127.0.0.1:10103/');// 本地使用10103端口进行测试
新建的WebSocket对象如下所示:
这中间包含了一次Websocket握手的过程,我们分两步来理解。
第一步,客户端请求。
这是一个GET请求,主要字段如下:
Connection: Upgrade
Upgrade: websocket
Sec-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个形参,依次为:
// 若verify定义如下
function verify(info, cb){
//一旦拥有第二个形参,如果不调用,默认将通过验权
cb(false, 401, '权限不够');// 此时表示验权失败,HTTP状态码为401,错误信息为"权限不够"
return true;// 一旦拥有第二个形参,响应就被cb接管了,返回什么值都不会影响前面的处理结果
}
除了port
和 verifyClient
设置外,其它设置项及更多API,请参考文档 ws-doc。
客户端发送消息。
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之间的值,具体请参考CloseEvent - Web APIs)。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。
<input type="file" id="file"/>
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是一种特殊的数据包,它只包含一些元数据,能够在不影响原通信的情况下维持住连接。
根据规范RFC 6455,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作为响应(Sending websocket ping/pong frame from browser),无法通过JS API主动向服务端发送Ping Frame。因此对于web服务,可以采取服务端主动ping的方式,来保持住链接。实际应用中,服务端还需要设置心跳的周期,以保证心跳连接可以一直持续。同时,还应该有重发机制,若连续几次没有收到心跳连接的回复,则认为连接已经断开,此时便可以关闭Websocket连接了。
WebSocket出世已久,很多优秀的大神基于此开发出了各式各样的库。其中Socket.IO是一个非常不错的开源WebSocke库,旨在抹平浏览器之间的兼容性问题。它基于Node.js,支持以下方式优雅降级:
如何在项目中使用Socket.IO,请参考第一章 socket.io 简介及使用。
EventSource,本质依然是HTTP,它仅提供服务端到客户端的单向文本数据传输,不需要心跳连接,连接断开会持续触发重连。
WebSocket协议,基于TCP协议,它提供双向数据传输,支持二进制,需要心跳连接,连接断开不会重连。
EventSource更轻量和简单,WebSocket支持性更好(因其支持IE10+)。通常来说,使用EventSource能够完成的功能,使用WebSocket一样能够做到,使用时若遇到连接断开或抛错,请及时调用各自的close
方法主动释放资源。
本问就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论。
参考文章
欢迎加入“CSDN前端开发者”群,与更多专家、技术同行进行热点、难点技术交流。请扫描以下二维码加群主微信,申请入群,务必注明「姓名+公司+职位」。