javascript分段读取的实现
更新日期:
最近需要做这样一个需求,就是一个接口请求,服务器端执行时间比较长,过了好久才会返回内容,这个体验是很不好的。在浏览器端就会感觉浏览器死掉了。
优化方案就是给前端浏览器一些提示,所以需要一种实时的进度条一样的东西。告诉用户,当前到底执行到什么程度了。
问题实例化
首先以一个简单的例子来大概说明下问题,你去餐厅一屁股坐下来点完菜,菜要7秒种才能上来。(这边假设7秒已经很长时间了):
为了更容易理解,我们尽量使用原生的node代码实现。
服务端代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | var http = require('http'); var fs = require('fs'); var url = require('url'); http.createServer(function (req, res) { var path = url.parse(req.url).pathname; if(path === '/api'){ //调用的接口点菜 //这是个需要7秒才能完成的任务 setTimeout(function() { res.end('心好累,7秒后菜才好了。。。'); }, 7000); } if(path === '/'){ //不是ajax接口,直接返回前端的html内容 var indexStr = fs.readFileSync('index.html'); res.setHeader('Content-Type', 'text/html; charset=UTF-8'); res.end(indexStr); } }).listen(3000); console.log('Server listening on port 3000'); |
前端 index.html代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | <!DOCTYPE html> <html> <head> <title>长连接测试</title> <script type="text/javascript" src='http://lib.sinaapp.com/js/jquery/1.7.2/jquery.min.js'></script> <script type="text/javascript"> function _start(node){ $(node).attr('disabled','disabled'); /*后面前端代码基本只修改这边的其他的不变*/ /*修改区域开始*/ $.ajax({ url: "/api", async: false, success:function(data){ $('body').append('<div>'+data+'</div>'); } }) /*修改区域结束*/ } </script> </head> <body> 我就是个打酱油的。。 <button onclick="_start(this)">点菜</button> </body> </html> |
我们以一个setTimeout来模拟一个7秒才能完成的任务.
运行后,访问:localhost:3000
我们会看到index.html的内容,点击点菜按钮,会ajax请求/api的内容。7秒后我们才能看到内容。体验非常不好。我们需要改进下,在任务执行的过程中提前返回数据通知浏览器给些进度提示。
要实现这个需求,就我知道的有下面这些技术:
ajax 轮询(polling)
这是一种最古老,最简单粗暴的方式。轮询说白了就是不停的用ajax发请求问服务器,当前执行到什么程度了。
就好像你去餐厅一屁股坐下来点完菜,菜一直没上来,然后你每5秒种就叫服务员跑到厨房问下厨师菜几分熟了。
所以一般我们的做法是前端:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | var interId = null; //先调用耗时接口,就是你开始点菜 $.ajax({ url: "/api", success:function(data){ //成功后,就可以取消轮询了。 clearInterval(interId); $('body').append('<div>'+data+'</div>'); } }) //使用轮询去查状态,开始叫服务员去问菜烧到几分熟了 function queryPercent(){ $.ajax({ url: "/pencent", success:function(pencent){ $('body').append('<div>当前进度'+pencent+'</div>'); } }) } interId = setInterval(queryPercent,500) |
后端一般是这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | var http = require('http'); var fs = require('fs'); var url = require('url'); //定义一个菜几分熟的变量,在实际运用中,可能是针对不同请求存储在数据库中的。 //这边为了简单直接放在全局 var percent = '0%'; http.createServer(function (req, res) { var path = url.parse(req.url).pathname; //查看菜几分熟了 if(path === '/pencent'){ res.end(percent); } //调用的接口点菜 if(path === '/api'){ percent = '0%'; //5分熟的时候更新下状态 setTimeout(function() { percent = '50%'; }, 3500); //这是个需要7秒才能完成的任务 setTimeout(function() { res.end('心好累,7秒后菜才好了。。。'); }, 7000); } if(path === '/'){ //不是ajax接口,直接返回前端的html内容 var indexStr = fs.readFileSync('index.html'); res.setHeader('Content-Type', 'text/html; charset=UTF-8'); res.end(indexStr); } }).listen(3000); console.log('Server listening on port 3000'); |
主要就是/api
这个接口会更新一个全局的进度变量,这样我们可以再开一个接口,给前端不停的轮询请求查看进度。就是每500毫秒就让服务员去问一次。
结果是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | 当前进度0% 当前进度0% 当前进度0% 当前进度0% 当前进度0% 当前进度0% 当前进度50% 当前进度50% 当前进度50% 当前进度50% 当前进度50% 当前进度50% 当前进度50% 心好累,7秒后菜才好了。。。 |
这样的缺点是很明显的,浪费很多请求。造成很多不必要的开销。
长连接(Comet),分段传输
上面是额外开了个接口获取进度,而如果我们使用了长连接技术。可以不需要/pencent
这个接口。
长连接说白了,就是浏览器跟服务器发一个请求,这个请求一直不断开,而服务器程序每过一段时间就返回一段数据。达到一种分块读取的效果。有数据就提前返回,而不用等所有数据都准备好了再返回。
这项技术的实现,归功于http1.1实现的 Transfer-Encoding: chunked
。
当你设置了这个 http头。服务器的数据就不会整体的返回,而是一段一段的返回。可以参考这段wiki
nodejs原生支持分块读取,默认就打开了Transfer-Encoding: chunked
。我们调用res.write(data)
就会提前将数据分块返回给浏览器端。而在php里面 不仅需要改写header还要调用flush来提前响应。
我们修改下服务端代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | var http = require('http'); var fs = require('fs'); var url = require('url'); http.createServer(function (req, res) { var path = url.parse(req.url).pathname; //调用的接口点菜 if(path === '/api'){ //5分熟的时候更新下状态 setTimeout(function() { //提前响应数据 res.write('当前进度50%'); }, 3500); //这是个需要7秒才能完成的任务 setTimeout(function() { res.end('心好累,7秒后菜才好了。。。'); }, 7000); } if(path === '/'){ //不是ajax接口,直接返回前端的html内容 var indexStr = fs.readFileSync('index.html'); res.setHeader('Content-Type', 'text/html; charset=UTF-8'); res.end(indexStr); } }).listen(3000); console.log('Server listening on port 3000'); |
如果这时候你直接使用浏览器访问http://localhost:3000/api
就会发现数据已经是一点一点的出来的了。
当然我们需要程序化的调用,前端使用分下面几种方式:
ajax读取分段数据
XMLHttpRequest其实有一个状态readyState = 3
标识数据正在传输中。因此我们可以这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | var lastIndex = 0; var query = new XMLHttpRequest(); query.onreadystatechange = function () { if (query.readyState === 3) { //每次返回的数据responseText会包含上次的数据,所以需要手动substring一下 var info = query.responseText.substring(lastIndex); $('body').append('<div>'+info+'</div>'); lastIndex = query.responseText.length; } } query.open("GET", "/api", true); query.send(null); |
上面的代码我在chrome下面测试通过,显然这东西兼容性很差,ie什么的就不要指望了。
使用iframe来调用
这也是一种曾经流行的方式,特点就是兼容性比较好。我们知道我们之前直接访问http://localhost:3000/api
,页面上已经会一点点的出来数据了。我们可以在服务器端在数据外面包一层script标记,这样就可以调用前端页面上的函数,达到一种分段处理数据的目的。
首先改造下核心的服务端代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | //调用的接口点菜 if(path === '/api'){ //这边一定要设置为text/html; charset=UTF-8,不然就不会有分段效果 res.setHeader('Content-Type', 'text/html; charset=UTF-8'); //5分熟的时候更新下状态 setTimeout(function() { res.write('<script> top.read("当前进度50%") </script>'); }, 3500); //这是个需要7秒才能完成的任务 setTimeout(function() { res.end('<script> top.read("心好累,7秒后菜才好了。。。") </script>'); }, 7000); } |
可以看到我们在数据外面包了一层script标签还有方法。
然后前端代码,使用一个隐藏的iframe来加载接口:
1 2 3 4 5 | window.read = function(info){ $('body').append('<div>'+info+'</div>'); } $('body').append('<iframe style="display:none" src="/api"></iframe>'); |
当iframe加载时,一块块加载,加载一块就会调用父iframe的read方法。这样就达到了一点点提示的目的。
实际上这也是bigpie这种技术的主要实现方式,只不过不需要iframe,直接在当前页面更新视图就好了。这边就不扯了。
另外按照这个原理,这边我还尝试了下 动态插入script的方式,但是发现不管怎样都不会有分段调用的过程,应该是浏览器会等js全部加载完之后才会执行里面的代码。
总之这种方式实现了一个接口分段返回信息的功能,但是只是单向的服务端传输,不存在可操作性。
长轮询(long polling)
这是后来比较流行的一种方式,Facebook,Plurk都曾经使用过。这个技术被称为服务器推送技术。其实原理也很简单,就是一个请求过去了,不要马上返回,等数据有更新了,再返回。这样可以减少很多无意义的请求。
跟上面的polling的对比就是,轮询是每5秒就去问一次,不管状态有没有更新。而长轮询是服务员跑过去问了,但是状态没更新就先不回去,因为回去了再跑过来是没意义的。所以就等状态更新后再返回告诉客人,熟到几分了。
比如上面的例子,只有5分熟的时候才会更新状态,所以如果用轮询的方式,可能来来回回好几趟,但是返回的结果一直都是0%.完全没有意义。
我们把上面的改造成长轮询:
前端js:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | //先调用耗时接口,就是你开始点菜 $.ajax({ url: "/api", success:function(data){ $('body').append('<div>'+data+'</div>'); } }) //叫服务员去问菜烧到几分熟了,状态更新了再回来告诉我,没到100%就立即再去问。 function queryPercent(){ $.ajax({ url: "/pencent", success:function(pencent){ $('body').append('<div>当前进度'+pencent+'</div>'); if (pencent != '100%') { queryPercent(); } } }) } queryPercent(); |
服务端改造为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 | var http = require('http'); var fs = require('fs'); var url = require('url'); //定义一个菜几分熟的变量,在实际运用中,可能是针对不同请求存储在数据库中的。 //这边为了简单直接放在全局 var percent = '0%'; var isPencentUpate = false; http.createServer(function (req, res) { var path = url.parse(req.url).pathname; //查看菜几分熟了 if(path === '/pencent'){ //实际应用中这边最好使用事件机制。否则只是把轮询放到了后端而已。 var tId = setInterval(function(){ if (isPencentUpate){ isPencentUpate = false; clearInterval(tId); res.end(percent); } },100); } //调用的接口点菜 if(path === '/api'){ percent = '0%'; //5分熟的时候更新下状态 setTimeout(function() { isPencentUpate = true; percent = '50%'; }, 3500); //这是个需要7秒才能完成的任务 setTimeout(function() { isPencentUpate = true; percent = '100%'; res.end('心好累,7秒后菜才好了。。。'); }, 7000); } if(path === '/'){ //不是ajax接口,直接返回前端的html内容 var indexStr = fs.readFileSync('index.html'); res.setHeader('Content-Type', 'text/html; charset=UTF-8'); res.end(indexStr); } }).listen(3000); console.log('Server listening on port 3000'); |
结果为:
1 2 3 | 当前进度50% 心好累,7秒后菜才好了。。。 当前进度100% |
/pencent
的请求只会发两次,只在服务端程序发现状态变更的时候请求才会返回数据。也就是一种主动推送的概念。
这种技术,不仅减少了请求,而且弥补了上面长连接的不可交互的弊端。但是因为一直维持着一个连接会比较占用资源。特别是对php,ruby这种一个请求一个进程的模型来说是硬伤,不过node没有这个问题。基于事件的请求模型使他天生就适合这种方式。
使用flash插件(Flash XMLSocket)
虽然苹果放弃了flash,虽然越来越多的前端放弃flash转投h5的怀抱,但是不得不承认,有的时候flash还是可以实现很多功能。
主要是,使用javascript跟flash通信,用flash提供的XMLSocket来实现。但是这种毕竟已经越来越被淘汰了,这边就不展开细讲了。
另外据说还有种使用更小众的Java Applet的socket接口来实现的。这个也不考虑了。早就淘汰了n年的东西了。
WebSocket
上面提到的插件方式,说白了都是使用javascript借助别人的socket实现。万幸的是html5已经提出了websocket的概念,javascript也可以在浏览器端实现socket了。虽然ie系列肯定不支持,但是我们还是有必要了解下。
说了这么多,我们先要科普下socket。socket也叫做套接字,提供了一种面向tcp、udp的编程方式。我们知道http协议是无状态的一次请求型的。只有浏览器端发起请求才能建立一次会话。而socket可以建立双向的通信。
首先我们撇开浏览器,看下nodejs里面的socket用法:
我们先建立一个socket服务端(server.js):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | var net = require('net'); var server = net.createServer(function(c) { //'connection' listener console.log('server connected'); //这边的c是一个net.Socket实例,本质上是一个可读可写流。 c.on('end', function() { console.log('server disconnected'); }); //这边调用write,客户端那边可以使用data监听到数据 c.write('客户端你好!\r\n'); c.write('客户端你幸苦了!\r\n'); //调用end同时会触发客户端那边的实例的end事件 c.end(); //客户端那边写过来的数据可以使用data事件获取到。 c.on('data', function(data) { console.log(data.toString()); }); }); server.listen(8124, function() { //'listening' listener console.log('server bound'); }); |
运行它
我们建立个socket客户端去连接这个服务端(client.js):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | var net = require('net'); var client = net.connect({port: 8124},function() { //'connect' listener console.log('client connected'); //写数据到服务端 client.write('服务端你好!\r\n'); }); //获取服务器端写过来的数据 client.on('data', function(data) { console.log(data.toString()); //client.end(); }); client.on('end', function() { console.log('client disconnected'); }); |
运行client.js
服务端会打出
1 2 3 4 5 | server bound server connected 服务端你好! server disconnected |
客户端会打出:
1 2 3 4 5 | client connected 客户端你好! 客户端你幸苦了! client disconnected |
这就是很简单的一个socket程序,可以看到这是一种双工通信。服务端可以写数据到客户端,客户端也可以写数据到服务端。相当的简洁高效。通过上面的例子我们可以理解socket的通信方式。
而我们现在需要的是一个服务端的socket不停的更新菜的信息,以及一个浏览器的客户端socket接受信息给用户展示。我们该怎么实现html5的socket呢:
可能第一反应就是服务端就用上面说的那个socket写法好了嘛。这是不对的,因为html5的socket协议跟这个node原生的socket是不同的,所以是不能结合使用的。详细websocket的介绍可以看这里,我们需要使用nodejs实现websocket draft-76的协议才行。万幸的是,社区里面已经有很成熟的模块:ws。这个就是个实现了websocket协议的服务端socket库。
首先安装ws:
1 | npm install --save ws |
服务端代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | var http = require('http'); var fs = require('fs'); var url = require('url'); var net = require('net'); http.createServer(function (req, res) { var path = url.parse(req.url).pathname; if(path === '/'){ //不是ajax接口,直接返回前端的html内容 var indexStr = fs.readFileSync('index.html'); res.setHeader('Content-Type', 'text/html; charset=UTF-8'); res.end(indexStr); } }).listen(3000); var WebSocketServer = require('ws').Server , wss = new WebSocketServer({ port: 8124 }); wss.on('connection', function connection(ws) { //5分熟的时候更新下状态 setTimeout(function() { ws.send('当前进度50%'); }, 3500); //这是个需要7秒才能完成的任务 setTimeout(function() { ws.send('心好累,7秒后菜才好了。。。'); }, 7000); }); |
客户端代码:
1 2 3 4 5 6 7 | var read = function(info){ $('body').append('<div>'+info+'</div>'); } var websocket = new WebSocket("ws://127.0.0.1:8124/"); websocket.onmessage = function(evt){ read(evt.data); } |
当然我这边只用到了简单的服务端给浏览器发消息。并不是真正的双工通信。不过可以看到,这种写法真的是最优雅的。
socket.io
上面的那么多写法,都是前人的各种经验,奈何兼容性总是欠缺,于是socket.io出来了。集大成者,兼容处理了上面的所有方式。他会按照这个顺序来挨个的尝试:
WebSocket
flash socket
XHR Polling 长连接
XHR 分段读取
Iframe 分段读
JSONP Polling 轮询
另外它同时封装了前端浏览器跟nodejs部分的api。使socket调用更加简单方便,并且可以跟普通的http请求结合在一起。
它分为两个库,一个客户端js,一个服务端nodejs库
我们看下有了socket.io我们的编码方式。
先安装服务端socket.ionpm install socket.io
服务端代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | var http = require('http'); var fs = require('fs'); var url = require('url'); var net = require('net'); var server = http.createServer(function (req, res) { var path = url.parse(req.url).pathname; if(path === '/'){ //不是ajax接口,直接返回前端的html内容 var indexStr = fs.readFileSync('index.html'); res.setHeader('Content-Type', 'text/html; charset=UTF-8'); res.end(indexStr); } }).listen(3000); //将server对象托管给socket.io var io = require('socket.io')(server); //监听connection事件 io.on('connection', function(socket){ //5分熟的时候更新下状态 setTimeout(function() { //fire一个pencent事件,这样可以在客户端监听这个事件获取数据 io.emit('pencent', '当前进度50%'); }, 3500); //这是个需要7秒才能完成的任务 setTimeout(function() { io.emit('pencent', '心好累,7秒后菜才好了。。。'); }, 7000); }); |
前端js代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | <!DOCTYPE html> <html> <head> <title>长连接测试</title> <script src="https://cdn.socket.io/socket.io-1.2.0.js"></script> <script type="text/javascript" src='http://lib.sinaapp.com/js/jquery/1.7.2/jquery.min.js'></script> <script type="text/javascript"> function _start(node){ $(node).attr('disabled','disabled'); var socket = io('http://localhost:3000'); socket.on('pencent', function(data){ $('body').append('<div>'+data+'</div>'); }); } </script> </head> <body> 我就是个打酱油的。。 <button onclick="_start(this)">点菜</button> </body> </html> |
可以看到使用socket.io直接就可以用事件的方式来传递消息了,非常的简单优雅,socket.io的底层也是使用的ws来实现的websocket协议,在这个上面再封装了事件机制,同时对于低级浏览器会做降级处理使用上面说的几种技术一个个的尝试。当然我没有看过socket.io的源码,这只是猜测。之后有空还是要详细看下源码,应该还有很多东西可以挖掘。
参考资料
上面的总结探索都是网上各种查资料,再自己写例子实验出来的,感谢下面这些文章:
[1] 使用Node.JS构建Long Polling应用程序
[2] 基于 HTTP 长连接的“服务器推”技术
[3] Socket 通讯
[4] Browser 與 Server 持續同步的作法介紹
结语
因为一个简单的需求,上网找资料。一下子牵扯出了好多知识点,不得不感慨程序员这行真是学习永无止尽。下次有空再详细看下socket.io的详细实现,相信又是一大堆新的知识。