浅谈关于JS下大批量异步任务按顺序执行解决方案一点思考
前言
最近需要做一个浏览器的, 支持大体积文件上传且要支持断点续传的上传组件, 本来以为很容易的事情, 结果碰到了一个有意思的问题:
循环执行连续的异步任务, 且后一个任务需要等待前一个任务的执行状态
这么说可能有点空泛, 以我做的组件举例:
这个组件本意是为了上传大体积视频, 和支持断点续传, 因为动辄几个G的视频不可能直接把文件读进内存, 只能分片发送(考虑到实际网络状态, 每次发送大小定在了4MB), 而且这么做也符合断点续传的思路.
组件工作流程如下:
- 选定上传文件后, 从H5原生upload组件里取得文件的blob对象 (同步)
- 通过blob对象的slice方法把文件切片 (同步)
- 新建一个Filereader对象, 通过Filereader的readAsArrayBuffer方法读取步骤2中生成的slice (异步)
- 如果步骤3的buffer读取成功(通过监控Filereader的onload事件), 则ajax发送步骤3中的buffer (异步)
- 如果ajax发送成功, 且服务器储存完成, 会向客户端发回一个成功状态码, 如果ajax的response中存在这个状态码, 则进行下一次切片发送 (异步)
从组件工作流程可以发现, 3,4,5中的连续异步任务, 必须要按顺序进行, 且每一步任务间存在相互依赖, 最后还要对这些步骤进行多次循环.
如果只是处理单次的连续异步任务, 通过promise链式调用即可, 但是要循环执行这样的连续异步任务让我想了很久.
后来google了很久也没发现解决方案, 无奈下闭门造车了2天, 想出了3套方案, 权当抛砖引玉, 希望各位给出更好建议
3套方案的核心思想相同, 类似观察者模式, 来控制循环的进行, 区别在于循环的实现不同, 实际上这3套方案也是我自我否定的过程, 不断思考更好的方法, 整个组件代码略长, 在此只挑出问题相关部分, 且省略错误处理部分
方案1
依然以上传组件举例
//循环状态标记,0为初始状态,1为正常,2为出错 let status = 0; /* 新建Filereader,读取文件切片,返回一个promise * 把读取成功的arraybuffer通过reslove传出 */ const createReader = ()=> { return new Promise ((reslove, reject)=> { let reader = new Filereader(); ... reader.onload = ()=> { reslove(reader.result) } reader.onerror = ()=> reject() }) } // ajax发送createReader方法读取到的Buff const createXhr = ()=> { const xhr= new XMLHttpRequest(); return new Promise ((reslove, reject)=> { ... xhr.onreadystatechange= ()=> { ... //如果readyState == 4,status == 200且服务器的状态码存在,更改全局标记为1 status = 1; reslove() } }) } //每一轮循环开始前都检查一次全局状态标记 const checkStatus = ()=> { ... if (status == 1) { loop() } } //循环过程的链式调用 const loop = ()=> { createReader().then(()=> createXhr()).then(()=> checkStatus()); }
方案1是基于初见问题的'想当然'解决方法, 碰到异步任务就promise, 这样的循环长链调用, 写法不优雅, 且错误调试异常麻烦, 更爆炸的是因为闭包问题, 在循环执行中这些内存难以回收, 内存消耗急剧增加, 只能等待循环执行完成
方案2
彻底引入观察者模式, 构造一个简单的EventEmitter, 通过event.on, event.emit的形式完成循环
//模仿node.js的EventEmitter class EventEmitter { constructor() { this.handler = {}; } on(eventName, callback) { if (!this.handles){ this.handles = {}; } if (!this.handles[eventName]) { this.handles[eventName] = []; } this.handles[eventName].push(callback); } emit(eventName,...arg) { if (this.handles[eventName]) { for (var i=0;i<this.handles[eventName].length;i++) { this.handles[eventName][i](...arg); } } } } let ev= new EventEmitter(); ... //监听createReader事件,如果读取buffer成功就触发toajax事件来上传切片 ev.on('createReader', ()=> { let reader = new Filereader(); ... reader.onload = ()=> { ev.emit('toajax') } }) //监听toajax事件,如果上传成功,就触发createReader事件开始读取下一切片 ev.on('toajax', ()=> { let xhr= new XMLHttpRequest(); ... xhr.onreadystatechange = ()=> { //如果readyState == 4,status == 200且服务器的状态码存在 ev.emit('createReader') } })
方案2彻底贯彻'事件', 代码语义更自然, 错误调试也比方案1更为简单, 但内存泄漏问题依然存在
方案3
方案3, 回归方案1的状态管理方式, 但是通过setInterval方法来实现循环.
//全局状态标记 let status = 0; //读取切片 const createReader = ()=> { let reader = new Filereader(); ... reader.onload = ()=>status = 1 } //上传切片 const createXhr = ()=> { let xhr= new XMLHttpRequest(); ... xhr.onreadystatechange = ()=> { ... //如果readyState == 4,status == 200且服务器的状态码存在 status = 2 } } /* 设置一个间隔时间极短的计时器,根据status决定下一步的任务, * 上传完成后定时器自动清除自己 * 另外有判断文件是否上传完成的方法,这里就不写了 */ let timer = setInterval(()=> { if (status == 2) { createReader(); } else if (status == 1) { createXhr(); } else if (status == 3) { clearInterval(timer); } },10)
不可否认, 方案3看上去很low, 如果追求极致的执行效率, 方案3无疑是最蠢的办法, 但是方案三相当于把异步任务转化为了同步任务, 语义简洁, 且没有上面2种方法的内存泄漏问题.
方案3本质上是把while (true)改写成了setInterval, 因为while true会阻塞线程, 各种异步事件的回调也会被一同阻塞, 所以选择了setInterval
总结
当时还尝试过使用Object.defineProperty方法给status 绑一个set方法, 通过每次给status set新值的时候来判断循环, 但是发现这样做依然像是链式调用, 一样存在内存泄漏问题, 这里就不写了.
说实话, 这3个方案感觉都有很大缺陷, 甚至可以说粗浅, 本人入坑前端2个月, 眼界有限无可避免, google无门后, 想到社区来求助, 希望老哥们提供更好的思路.
最后挂上文中提到的上传插件, 因为感觉还有缺陷就没封装, 只做了个demo(前端上传插件用的方案2, 后端拼接文件切片用的方案3)
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持我们。