实现Javascript异步编程的不同方式

@LiuYueKai 2017-06-08 05:43:25发表于 iuap-design/blog

实现Javascript异步编程的不同方式

在js中异步是得天独厚的特点和优势,但是也经常会遇到需要同步执行的需求。为了满足同步需求,一直有人提出各种各样的方案。

从回调函数,到promise对象,再到Generator函数,再到async函数。本文尝试通过简单的示例代码对各个时期的实现方案进行归纳总结,希望再遇到同步问题,仅通过本文章就可以获取适合的解决方案。

以下代码可在git中查看,通过node运行。

基础代码

以下代码模拟读取文件过程,为异步方法,后续的同步实现均基于此部分代码。

const callback = function(error, fileData) {
    console.log(fileData);
};

const readFileFun = function(fileName, callback) {
    var time = 1000;
    if (fileName == 1) {
        time = 5000;
    } else if (fileName == 2) {
        time = 3000;
    } else if (fileName == 3) {
        time = 1000;
    }
    setTimeout(function() {
        var fileStr = 'now file data is for : ' + fileName;
        callback(undefined, fileStr);
    }, time);
};

const needReadFileArr = [1, 2, 3];

方法说明

  • readFileFun为读取的核心方法,通过setTimeout来模拟读取文件所需花费的时间
  • callback在读取完成之后调用用于打印日志。

实现效果

依次读取文件1,2,3,且读取时间分别为5000,3000,1000。要求读取结果为1,2,3。

默认执行并不能达到同步效果

console.log('默认执行顺序');
readFileFun(1,callback);
readFileFun(2,callback);
readFileFun(3,callback);
// 默认执行的结果为:
// now file data is for : 3
// now file data is for : 2
// now file data is for : 1

由于文件3读取时间较短,因此最先完成并打印,按照此方式执行是肯定不能达到的想要的效果的。

同步实现过程

回调方式

基础方式

console.log('通过回调方式实现');
readFileFun(1, function(e, fileData) {
    callback(e, fileData);
    readFileFun(2, function(e, fileData) {
        callback(e, fileData);
        readFileFun(3, function(e, fileData) {
            callback(e, fileData)
        });
    });
});

此方式可以实现效果,但是如果需要读取的文件过多,则会出现回调地狱,代码的可读性很成问题。

递归方式

console.log('依然为回调方式抽象为递归实现');
var i = 0;
var readFileArrFun = function(readFileArr, i) {
    readFileFun(readFileArr[i], function(e, fileData) {
        callback(e, fileData);
        i++;
        if (i < readFileArr.length) {
            readFileArrFun(readFileArr, i);
        }
    });
};
readFileArrFun(needReadFileArr, i);

抽象为递归函数之后代码的可读性提高了很多,算是一个基本可行的方案。

Promise对象

Promise构造函数接受一个函数作为参数,该函数的两个参数分别是 resolve 方法和 reject 方法。

  • 如果异步操作成功,则用 resolve 方法将 Promise 对象的状态,从「未完成」变为「成功」(即从 pending 变为 resolved)

  • 如果异步操作失败,则用 reject 方法将 Promise 对象的状态,从「未完成」变为「失败」(即从 pending 变为 rejected)

Promise对象的then接收一个function用于处理回调,参数为之前resolve方法传入的值。

注意点:无法取消 Promise,一旦新建它就会立即执行,无法中途取消。更多内容参考网络资料

***总结:Promise 的写法只是回调函数的改进,使用then方法以后,异步任务的两段执行看得更清楚了,除此以外,并无新意(网络语),但是由于后续介绍的Generator函数以及async函数都可以接收Promise对象,因此还是希望对Promise 加深了解。

基础方式

console.log('通过Promise方式实现');
new Promise(function(resolve, reject) {
    readFileFun(1, function(e, fileData) {
        resolve(fileData);
    });
}).then(function(fileData) {
    callback(undefined, fileData);
    new Promise(function(resolve, reject) {
        readFileFun(2, function(e, fileData) {
            resolve(fileData);
        })
    }).then(function(fileData) {
        callback(undefined, fileData);
        new Promise(function(resolve, reject) {
            readFileFun(3, function(e, fileData) {
                resolve(fileData);
            })
        }).then(function(fileData) {
            callback(undefined, fileData);
        });
    });
});

可以看到代码量依然很大,对于读取文件过多的情况依旧不适用。

递归方式

console.log('依然为promise方式抽象为递归实现');
var i = 0;
var readFileArrFun = function(readFileArr, i) {
    new Promise(function(resolve, reject) {
        readFileFun(readFileArr[i], function(e, fileData) {
            resolve(fileData);
        });
    }).then(function(fileData) {
        callback(undefined, fileData);
        i++;
        if (i < readFileArr.length) {
            readFileArrFun(readFileArr, i);
        }
    });
};
readFileArrFun(needReadFileArr, i);

抽象为递归方法之后的可读性提高了很多,算是一个基本可行的方案。

原有方法的Promise版

var readFileFunPromise = function(fileName) {
    return new Promise(function(resolve, reject) {
        readFileFun(fileName, function(e, fileData) {
            resolve(fileData);
        });
    });
};

readFileFunPromise(1).then(function(fileData) {
    callback(undefined, fileData);
    readFileFunPromise(2).then(function(fileData) {
        callback(undefined, fileData);
        readFileFunPromise(3).then(function(fileData) {
            callback(undefined, fileData);
        });
    });
});

此方式构建了新的function,返回原有方法的Promise对象。

这种方式虽然与基础方式无太大区别,但是为后续Generator函数以及async函数获取Promise对象提供了思路,因此特别说明。

Generator函数

基础方式

console.log('通过Generator函数实现的执行顺序');
function run(generatorFunction) {
    var generatorItr = generatorFunction(needReadFileArr, resume);

    function resume(e, fileData) {
        callback(e, fileData)
        generatorItr.next();
    }
    generatorItr.next();
};

var gen = function* (readFileArr, cb) {
    for (var i = 0; i < readFileArr.length; i++) {
        yield readFileFun(readFileArr[i], cb)
    }
};

run(gen);

Generator在执行到yield的时候会暂停,直到下次调用next才会继续执行,因此上面的方法就是在readFileFun的回调中调用next,保证每次执行readFileFun则会暂停,而进入回调之后则继续执行。

可以看到Generator函数在书写上已经有了很大的进步,极大的提高了可读性。但是缺点也是显而易见的,需要自己编写对应的执行器,为了解决此问题,才出现了后续的co函数库。

co函数库

co 函数库可以让你不用编写 Generator 函数的执行器。yield后面可以跟Promise对象以及thunk函数。本例介绍Promise对象的用法。

console.log('通过co库来自动执行Generator')

var co = require('co');

var readFileFunPromise = function(fileName) {
    return new Promise(function(resolve, reject) {
        readFileFun(fileName, function(e,fileData) {
            resolve(fileData);
        });
    });
};

var gen = function* () {
    for (var i = 0; i < needReadFileArr.length; i++) {
        var fileData = yield readFileFunPromise(needReadFileArr[i]);
        console.log(fileData);
    }
};
co(gen);

再做进一步优化,支持传入参数来控制读取文件的路径,代码如下:

var gen1 = function* (readFileArr) {
    for (var i = 0; i < readFileArr.length; i++) {
        var fileData = yield readFileFunPromise(readFileArr[i]);
        console.log(fileData);
    }
}

var fn = co.wrap(gen1);
fn(needReadFileArr);

上面的代码虽然不需要创建执行器,但是仍旧需要构建Promise对象,代码量还是有点多,为进一步减少编写难度,此时出现了thunkify函数库。

co函数库与thunkify函数库

const co = require('co');
const thunkify = require('thunkify');
const readFileFunTh = thunkify(readFileFun);
var gen = function* (){
  for (var i = 0; i < needReadFileArr.length; i++) {
      var fileData = yield readFileFunTh(needReadFileArr[i]);
      console.log(fileData);
  }
}
co(gen);

可以看到通过co函数库以及thunkify函数库,代码量已经得到了极大的精简。

注意:在使用此方式时原有函数的回掉函数的第一个参数必须为error,否则会报错,有兴趣的小伙伴可以试试直接将第一个参数设置为返回值

async函数

console.log('通过async/awite 来实现');
var readFileFunPromise = function(fileName) {
    return new Promise(function(resolve, reject) {
        readFileFun(fileName, function(e,fileData) {
            resolve(fileData);
        });
    });
};

var asyncFun = async function() {
    for (var i = 0; i < needReadFileArr.length; i++) {
        var fileData = await readFileFunPromise(needReadFileArr[i]);
        console.log(fileData);
    }
};

asyncFun()

async 函数对 Generator 函数的改进,体现在以下三点。

  • 内置执行器。 Generator 函数的执行必须靠执行器,所以才有了 co 函数库,而 async 函数自带执行器。也就是说,async 函数的执行,与普通函数一模一样,只要一行
  • 更好的语义。 async 和 await,比起星号和 yield,语义更清楚了。async 表示函数里有异步操作,await 表示紧跟在后面的表达式需要等待结果
  • 更广的适用性。 co 函数库约定,yield 命令后面只能是 Thunk 函数或 Promise 对象,而 async 函数的 await 命令后面,可以跟 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时等同于同步操作)

更多内容参照阮一峰

promiseify函数库

console.log('通过async/awite 来实现,promiseify优化获取Promise过程');

var promiseify = require('promiseify');
var readFileFunPromise = promiseify(readFileFun);

var asyncFun = async function() {
    for (var i = 0; i < needReadFileArr.length; i++) {
        var fileData = await readFileFunPromise(needReadFileArr[i]);
        console.log(fileData);
    }
};

asyncFun()

通过promiseify函数库简化获取Promise对象的过程,使代码量进一步减少。

整体总结

  • 回掉函数的第一个参数必须是error
  • Generator函数以及async函数推荐使用Promise对象,保持统一
  • Generator函数为ES6语法,async函数为ES7语法,如果可能推荐使用async函数