前言
异步相关的概念可以参考。Javascript单线程的机制带来的好处就是在代码运行时可以确保代码访问的变量不会受到其它线程的干扰。试想如果当你遍历一个数组的时候,另外一个线程修改了这个数组,那就乱了套了。setTimeout/setInterval, 浏览器端的ajax, Node里的IO等的运用都是建立在正确的理解异步(e.g. Event loop, Event queue)的基础上。
异步循环
假设我有一个含文件名的数组,我想依次读取文件直到第一次成功读取某文件,返回文件内容。也就是如果含文件名的数组是['a.txt', 'b.txt'],那就先读a.txt,如果成功返回a.txt内容。读取失败的话读b.txt。依此类推。读文件的话Node分别提供了同步方法跟异步方法。
假设我们有2个文件:a.txt(文件内容也为a.txt)跟b.txt(文件内容也为b.txt)。
同步的写法比较简单:
let fs = require('fs'), path = require('path');function readOneSync(files) { for(let i = 0, len = files.length; i < len; i++) { try { return fs.readFileSync(path.join(__dirname, files[i]), 'utf8'); } catch(e) { //ignore } } throw new Error('all fail');}console.log(readOneSync(['a.txt', 'b.txt'])); //a.txtconsole.log(readOneSync(['filenotexist', 'b.txt'])); //b.txt复制代码
同步写法最大的问题就是会阻塞事件队列里的其它事件处理。假设读取的文件非常大耗时久,会导致app在此期间无响应。异步IO的话可以有效避免这个问题。但是需要在回调里处理调用的顺序(i.e. 在上一个文件读取的回调里进行是否读取下一个文件的判断和操作)。
let fs = require('fs'), path = require('path');function readOne(files, cb) { function next(index) { let fileName = files[index]; fs.readFile(path.join(__dirname, fileName), 'utf8', (err, data) => { if(err) { return next(index + 1); } else { return cb(data); } }); } next(0);}readOne(['a.txt', 'b.txt'], console.log); //a.txtreadOne(['filenotexist', 'b.txt'], console.log); //b.txt复制代码
异步的写法需要传一个回调函数(i.e. cb)用来对返回结果进行操作。同时定义了一个方法next用来在读取文件失败时递归调用自己(i.e. next)读取下一个文件。
同时发起多个异步请求
假设现在我有一个含文件名的数组,我想同时异步读取这些文件。全部读取成功时调用成功回调。任意一个失败的话调用失败回调。
let fs = require('fs'), path = require('path');function readAllV1(files, onsuccess, onfail) { let result = []; files.forEach(file => { fs.readFile(path.join(__dirname, file), 'utf8', (err, data) => { if(err) { onfail(err); } else { result.push(data); if(result.length === files.length) { onsuccess(result); } } }); });}readAllV1(['a.txt', 'b.txt'], console.log, console.log); //结果不确定性复制代码
这里有个问题。因为读取文件的操作是同时异步触发的,取决于文件的读取时间,早读完的文件的handler会被先放入事件队列里。这会导致最后result数组里的内容跟files的文件名并非对应的。举个例子, 假设files是['a.txt', 'b.txt'], a.txt是100M, b.txt是10kb, 2个同时异步读取,因为b.txt比较小所以先读完了,这时候b.txt对应的readFile里的回调在事件队列里的顺序会先于a.txt的。当读取b.txt的回调运行时,result.push(data)会把b.txt的内容先塞入result中。最后返回的result就会是[${b.txt的文件内容}, ${a.txt的文件内容}]。当对返回的结果有顺序要求的时候,我们可以简单的修改下:
let fs = require('fs'), path = require('path');function readAllV2(files, onsuccess, onfail) { let result = []; files.forEach((file, index) => { fs.readFile(path.join(__dirname, file), 'utf8', (err, data) => { if(err) { onfail(err); } else { result[index] = data; if(result.length === files.length) { onsuccess(result); } } }); });}readAllV2(['a.txt', 'b.txt'], console.log, console.log); //结果不确定性复制代码
看起来好像是木有问题了。但是!
let arr = [];arr[1] = 'a';console.log(arr.length); //2复制代码
按照readAllV2的实现,假设在a.txt还未读完的时候,b.txt先读完了,我们设了result[1] = data。这时候if(result.length === files.length)是true的,直接就调用了成功回调。。所以我们不能依赖于result.length来做检查。
let fs = require('fs'), path = require('path');function readAllV3(files, onsuccess, onfail) { let result = [], counter = 0; files.forEach((file, index) => { fs.readFile(path.join(__dirname, file), 'utf8', (err, data) => { if(err) { onfail(err); } else { result[index] = data; counter++; if(counter === files.length) { onsuccess(result); } } }); });}readAllV3(['a.txt', 'b.txt'], console.log, console.log); //[ 'a.txt', 'b.txt' ]复制代码
如果对Promise比较熟悉的话,Promise里有个实现的就是这个效果。
同步跟异步回调函数不要混用,尽量保持接口的一致性
假设我们实现一个带缓存的读取文件方法。当缓存里没有的时候我们去异步读取文件,有的话直接从缓存里面取。
let fs = require('fs'), path = require('path'), cache = {};function readWithCacheV1(file, onsuccess, onfail) { if(cache[file]) { onsuccess(cache[file]); } else { fs.readFile(path.join(__dirname, file), 'utf8', (err, data) => { if(err) { onfail(err); } else { cache[file] = data; onsuccess(data); } }); }}复制代码
具体看下上面的实现:
- 当缓存里有数据时,是同步进行调用了成功回调onsuccess。
cache['a.txt'] = 'hello'; //mock一下缓存里的数据readWithCacheV1('a.txt', console.log);//同步调用,要等调用完后才进入下一个statementconsole.log('after you');//输出结果:helloafter you复制代码
- 当缓存没有数据时,是异步调用。
readWithCacheV1('a.txt', console.log);//缓存没数据。异步调用console.log('after you');//输出结果:after youhello复制代码
这就造成了不一致性, 程序的执行顺序不可预测容易导致bug车祸现场。要保持一致性的话可以统一采取异步调用的形式,用setTimeout包装下。
let fs = require('fs'), path = require('path'), cache = {};function readWithCacheV2(file, onsuccess, onfail) { if(cache[file]) { setTimeout(onsuccess.bind(null, cache[file]),0); } else { fs.readFile(path.join(__dirname, file), 'utf8', (err, data) => { if(err) { onfail(err); } else { cache[file] = data; onsuccess(data); } }); }}复制代码
重新跑下有缓存跟没有缓存2种情况:
- 当缓存里有数据时,通过setTimeout异步调用```javascriptcache['a.txt'] = 'hello';readWithCacheV2('a.txt', console.log);console.log('after you');
//输出结果:
after youhello* 当缓存没有数据时,```javascriptreadWithCacheV2('a.txt', console.log);console.log('after you');//输出结果:after youhello复制代码
Reference
Code
Notice
- 如果您觉得该让您有所收获,请「Star 」支持楼主。
- 如果您想持续关注楼主的最新系列文章,请「Watch」订阅