1.异步编程的终极解决方案
前文结尾时提到,async/await是异步编程的'终极'解决方案,而终极二字就体现在,使用async/await来操作异步无论是逻辑上还是语义上都与同步操作无限接近(当然只是形式上像,没有改变异步的本质,后面会解释)。
先来看一下之前使用Generator函数控制异步流程的代码
function* gen() {
const res1 = yield promisify_readFile("./text1.txt");
console.log(res1.toString());
const res2 = yield promisify_readFile("./text2.txt");
console.log(res2.toString());
}
co(gen);
下面使用async/await实现
async function asyncReadFile() {
const res1 = await promisify_readFile("./text1.txt");
console.log(res1.toString());
const res2 = await promisify_readFile("./text2.txt");
console.log(res2.toString());
}
asyncReadFile()
可以看到,从形式上看使用async/await进行异步流程处理无需执行器,函数可以像普通函数一样执行,这意味着async函数内置了Generator函数的执行器。从语义上看,async关键字表示函数内部有异步操作,await关键字表示要等待异步操作执行完毕,相比于Generator函数用*声明以及yield表达式划分状态要更加友好。
下面具体介绍async函数和await关键字的特点。
2.async函数和await关键字的特点
2.1 async函数返回值
async函数返回的是Promise对象,因此可以为async函数指定then,catch等方法。
asyncReadFile().then(() => {
console.log("end");
});
既然async函数返回的是Promise对象,那其结果和状态由什么决定呢
- 当async函数内部的return有返回值时,该参数会成为then方法成功回调的参数(即Promise的结果值),状态变为成功。
const promisifyTimeOut = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve('timeOut')
}, 500);
})
}
const asyncTimeOut = async () => {
const res = await promisifyTimeOut()
return res
};
asyncTimeOut().then(
(res) => {
console.log('success' + res);
},
(r) => {
console.log('err' + r);
}
);
//success timeOut
- 当async函数内部抛出错误时,状态会立即变为失败,并执行then方法的失败回调或catch方法。
const asyncTimeOut = async () => {
const res = await promisifyTimeOut()
throw res
};
asyncTimeOut().then(
(res) => {
console.log('success' + res);
},
(r) => {
console.log('err' + r);
}
);
// err timeOut
利用这一点可以,我们可以进行对async函数的错误处理,后面会介绍。
2.2 await关键字的特点
- await命令只能用在async函数之中,用在普通函数中会报错。
- await命令后面如果是一个 Promise 对象,返回该Promise 对象的结果值,如果不是 Promise 对象,就直接返回对应的值 。
(async function(){
const res1 = await Promise.resolve('foo')
console.log(res1)
const res2 = await 'bar'
console.log(res2)
})()
// foo
// bar
3.async函数的错误处理
前面提到,async函数内部抛出错误时,其状态会立即变为失败并执行失败回调(假设指定了失败回调)。因此任何一个await关键字后面的Promise状态变为rejected都会导致async函数立即中断执行。
const promisifyTimeOut = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject("some err");
}, 500);
});
};
const asyncTimeOut = async () => {
await promisifyTimeOut();
console.log("foo");
};
asyncTimeOut().catch((r) => console.log(r));
// some err
上面代码,await后的异步抛出错误,async函数中断执行导致foo没有被打印。如果不想让async函数内部一抛出错误就终止执行,可以将可能抛出错误的Promise包在try...catch代码块中 ,或者为可能抛出错误的Promise指定失败回调(指定then方法或catch方法),下面以try...catch为例演示。
const asyncTimeOut = async () => {
try {
await promisifyTimeOut()
} catch (error) {
console.log(error)
}
console.log("foo");
};
asyncTimeOut().catch((r) => console.log(r))
// some err
// foo
如果使用上述两种方法进行错误处理,则async函数指定的失败回调将不生效(假设不在catch语句或Promise失败回调中将错误抛出)。另外,多个await语句可以一起包在try...catch中进行统一错误处理。
4.async函数的实现原理
其实,经过前面对co模块的讨论,以及上面对async函数特点的介绍,我们可以知道,async/await就是Generator函数的语法糖,我们只需根据其特点进行封装,具体如下。
- async函数内置Generator函数执行器。
- async函数返回Promise,要等内部所有Promise执行完后再改变状态,函数内部抛出错误,状态立即变为rejected。
我们假设async的内置执行器叫做spawn函数,那么async函数的结构就是这样的
const async = (gen) => {
return () => {
return spawn(gen);
};
};
接下来实现执行器,其原理与前面讨论的co模块基本一致
function spawn(genF) {
return new Promise(function (resolve, reject) {
const gen = genF();
function step(data) {
let res;
try {
res = gen.next(data);
} catch (e) {
// 内部抛出错误 状态变为rejet
return reject(e);
}
if (res.done) {
return resolve(res.value);
}
// 为异步指定成功/失败回调 成功则继续执行 失败则立即rejected
Promise.resolve(res.value).then(step, (r) => reject(r));
}
step();
});
}
下面简单测试一下
const promisify = (data) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(data);
}, 300);
});
};
function* testGen() {
const res1 = yield promisify(1);
console.log(res1);
const res2 = yield promisify(2);
console.log(res2);
return res2;
}
const async = (gen) => {
return () => {
return spawn(gen);
};
};
// 得到async函数
const asyncFoo = async(testGen);
// 得到async函数的执行结果
const res = asyncFoo();
setTimeout(() => {
console.log(res);
}, 1000);
// 1
// 2
// Promise { 2 }
5.async函数与执行环境栈
在前面对JavaScript执行上下文的讨论时我们知道,JavaScript引擎在执行代码之前, 会创建一个执行环境栈,之后创建全局执行上下文并将它压入栈中作为栈底。每遇到一个函数执行时,都会为该函数创建执行上下文,并将其推入执行环境栈中,形成一个由执行上下文构成的堆栈(context stack)。每个上下文都有一个与之相关联的变量对象,包含了当前上下文的变量,函数,形参等。栈是“后进先出”的数据结构,因此最后产生的上下文环境首先执行完成并出栈,然后再执行它下层的上下文,栈底永远是全局上下文,当浏览器窗口关闭,全局上下文才会出栈。
Generator函数不是这样,执行Generator函数产生的上下文,遇到yield命令时,会暂时退出堆栈,但是并不消失,变量对象里面的所有变量和对象会冻结在当前状态。等到执行next命令时,执行上下文会重新加入执行环境栈,冻结的变量和对象恢复执行。而async函数是Generator函数的语法糖,因此他也有一样的特性,即async 函数可以保留运行堆栈。
下面用一个例子进行对比说明
const timeOut = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 500);
});
};
(function () {
for (let i = 0; i < 3; i++) {
timeOut().then(() => {
console.log(i);
});
}
console.log("end");
})();
// end
// 0 1 2
上面代码会先打印end 之后012几乎同时打印,原因不难分析,由于promise.then方法不会将当前上下文冻结,因此循环的进行不受影响,而因为then方法中的回调会异步执行,因此三个log语句会几乎同时被加入任务队列,最终造成上述的执行结果。
下面用async/await重写上面代码
(async function () {
for (let i = 0; i < 3; i++) {
await timeOut();
console.log(i);
}
console.log("end");
})();
// 0 1 2 end
上面代码会依次打印0 1 2 end。
分析原因,由于async 函数可以保留当前上下文环境,当遇到await命令,当前上下文的所有状态都被冻结,包括for循环在内的所有代码都会暂停执行,因此造成上述执行结果。
其实,这条特性可以理解为, await命令后面的所有代码都会进入异步任务队列。 await相当于then的语法糖,其后面的代码都进入了promise.then的回调函数中,会进入任务队列异步执行。
利用这一点,我们可以实现休眠器。
function sleep(interval) {
return new Promise((resolve) => {
setTimeout(resolve, interval);
});
}
// 用法
async function Async(timeOut) {
await sleep(timeOut);
console.log("foo!");
}
Async(1000);
// 一秒后打印foo!
关于上述特性,有两点需要说明
1.await语句冻结的只是async函数的上下文,即async函数后面的代码执行不会被阻塞。这也就说明,async/await只是写起来像同步代码,异步的本质没有改变。
async function Async(timeOut) {
await sleep(timeOut);
console.log("foo!");
}
Async(0)
console.log('end!')
// end!
// foo!
2. 上面说到,遇到await关键字,其后所有代码都将被冻结,因此await语句下面的异步任务也会等到await语句的异步结束后再执行。这点对于具有依赖关系的异步(继发关系)的处理是非常友好的。但同样的,如果两个异步没有继发关系,则尽量不要这么写,因为会造成阻塞。可以使用Promise.all()等方式让他们并发执行,而不是继发执行。
function sleep(interval) {
return new Promise((resolve) => {
setTimeout(() => {
console.log("foo!");
resolve();
}, interval);
});
}
async function Async() {
await Promise.all([sleep(500), sleep(500)]);
console.log("end");
}
Async();
// 两个异步并发执行 几乎同时打印foo!
网友评论