JavaScript 异步编程的演变

最后更新:
阅读次数:

我尝试用更简洁的方式总结阮一峰老师有关异步编程的四篇文章,顺便还有一些自己的看法和一些比较易懂的实例。

详细版本请看阮一峰老师下面的四篇文章

有关异步编程

异步编程的最终目标是让它的写法看起来更像同步编程的写法,而不是让我们深陷回调地狱。

使用异步函数来进行异步编程被认为是目前最好的方法,至于以后是什么样子,让我们一起期待吧!

设定需求

为了能使用例子更直观地表述出 JavaScript 异步编程的变化,我们现在设定下面的一个需求。

  • 需求有以下规则
    • 只能使用 fs.readFile(path, [options,] callback) 来读文件
    • 按顺序读取 5 个文件 (1.txt、2.txt、3.txt、4.txt、5.txt)
    • 打印读取到的数据
    • 不需要向 fs.readFile 函数传选项对象(options)
    • 为了让代码看起来更直观,回调函数不会使用箭头函数写法
    • 做好错误处理工作

为了方便查看读取到的数据,建议上面要被读取的 5 个文件只要有少量的唯一的内容即可。

// 我每次读完文件后打印的数据如下

1111111111
2222222222
333333333
444444444
55555555
读取完毕 ~

回调函数写法

  • 不得不说,纯粹的回调函数写法拥有最正宗的回调地狱(callback hell)
// 纯粹的嵌套回调函数
fs.readFile("1.txt", function(err, data) {
if (err) throw err;
console.log(data.toString());

fs.readFile("2.txt", function(err, data) {
if (err) throw err;
console.log(data.toString());

fs.readFile("3.txt", function(err, data) {
if (err) throw err;
console.log(data.toString());

fs.readFile("4.txt", function(err, data) {
if (err) throw err;
console.log(data.toString());

fs.readFile("5.txt", function(err, data) {
if (err) throw err;
console.log(data.toString());

// 深陷回调地狱,不能自拔
console.log("读取完毕 ~");
});
});
});
});
});

Promise 写法

为了更方便的使用 promise 对象,我们需要把 fs.readFile 用函数封装为其对应的 Promise 版本。

// 封装 fs.readFile 为 Promise 版本
function readFilePromise(path) {
return new Promise(function(resolve, reject) {
fs.readFile(path, function(err, data) {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
}
// 执行任务
readFilePromise("1.txt")
.then(function(data) {
console.log(data.toString());
return readFilePromise("2.txt");
})
.then(function(data) {
console.log(data.toString());
return readFilePromise("3.txt");
})
.then(function(data) {
console.log(data.toString());
return readFilePromise("4.txt");
})
.then(function(data) {
console.log(data.toString());
return readFilePromise("5.txt");
})
.then(function(data) {
console.log(data.toString());
console.log("读取完毕 ~");
})
.catch(function(err) {
console.log(err);
});

Promise 的链式写法的确比回调函数看起来好很多,但是放眼望去,一堆 then,难免有代码冗余之嫌,并且原来的语义也变得不是很清楚。

Generator 函数写法

利用 Generator 函数的 可暂停性 可以对一个或多个异步任务进行封装,封装后,Generator 函数就变为一个封装异步任务的容器。

Generator 函数中,每一个有异步任务的地方,都需要用 yield 语句注明,即在 yield 语句后面书写异步任务的代码。

一般地,yield 语句后可以直接跟异步代码,或者也可以跟使用某种方式封装好的异步代码(常见的封装就是 promise 封装thunk 函数封装)。(使用封装写法的好处是可以使代码看起来更像同步代码)

thunk 函数封装请参考阮一峰老师的文章,我这里只专注 promise 封装

// 使用 generator 函数封装异步任务
function* asyncTaskWrapper() {
let data = yield readFilePromise("1.txt");
console.log(data.toString());

data = yield readFilePromise("2.txt");
console.log(data.toString());

data = yield readFilePromise("3.txt");
console.log(data.toString());

data = yield readFilePromise("4.txt");
console.log(data.toString());

data = yield readFilePromise("5.txt");
console.log(data.toString());

console.log("读取完毕 ~");
}

好了,上面的异步任务倒是封装完了,接下来就该执行了。我们先根据 generator 函数的特性手动地使用 next() 方法一步一步地执行。

// 手动执行上面的 generator 函数
var g = asyncTaskWrapper();

g.next().value.then(function(data) {
g.next(data).value.then(function(data) {
g.next(data).value.then(function(data) {
g.next(data).value.then(function(data) {
g.next(data).value.then(function(data) {
g.next(data);
});
});
});
});
});

通过 next(value) 方法向生成器函数传入的参数将作为上次 yield 语句的返回值

好像,我们又陷入回调地狱了。。。

是的,手动执行 generator 函数不仅累,并且效率低。观察上面手动执行的代码,其实我们可以写一个函数,利用递归来自动执行 generator 函数。

// 工具函数,利用递归使执行 generator 函数自动化
function run(gen) {
let g = gen();

function next(data) {
let result = g.next(data);

if (result.done) {
return result.value;
} else {
result.value.then(function(data) {
next(data);
});
}
}

next();
}

// 运行上面的工具函数,自动执行上面的 generator 函数
run(asyncTaskWrapper);

针对上面的情况,nodejs 界的 tj 大神 写了一个工具模块 co, 专门用来自动执行 Generator 函数。(细节可参看文首阮一峰老师相关的文章)

// 借助 co 模块,自动执行上面的 generator 函数
// 使用 npm install co 来安装模块

const co = require("co");

co(asyncTaskWrapper);

异步函数写法

异步函数使用 async/await 语法,其解决上面的需求的代码如下。

async function asyncTaskWrapper() {
let data = await readFilePromise("1.txt");
console.log(data.toString());

data = await readFilePromise("2.txt");
console.log(data.toString());

data = await readFilePromise("3.txt");
console.log(data.toString());

data = await readFilePromise("4.txt");
console.log(data.toString());

data = await readFilePromise("5.txt");
console.log(data.toString());

console.log("读取完毕 ~");
}

asyncTaskWrapper();

放眼望去,就如同步函数写法一样。拿上面的代码与上一节 generator 函数封装异步任务的代码相比,颇为相似,只不过 async 函数写法就是将 Generator 函数的星号(*)替换成 async,将 yield 替换成 await。

可以说,async 函数就是 Generator 函数的语法糖。

  • 只不过,相对 generator 函数,async 函数还是有所改进的
    • async 函数内置执行器。 Generator 函数的执行必须靠执行器,所以才有了 co 函数库,而 async 函数自带执行器。也就是说,async 函数的执行,与普通函数一模一样,只要一行。
    • 更好的语义
    • 更广的适用性。 co 函数库约定,yield 命令后面只能是 Thunk 函数或 Promise 对象,而 async 函数的 await 命令后面,可以跟 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时等同于同步操作)。

以上。