打开潘多拉盒子:JavaScript异步编程
用 Promise 优化嵌套回调
setTimeout(
() => {
console.log(1);
setTimeout(
() => {
console.log(2);
setTimeout(
() => {
console.log(3);
},
1000
);
},
1000
);
},
1000
);
们用了 3 次 setTimeout,每次都接受两个参数,第一个参数是一个函数,用以打印当前跑的距离,以及递归调用奔跑逻辑,第二个参数用于模拟奔跑耗时 1000 毫秒。这个问题其实代表了实际编程中一类很常见的 JavaScript 异步编程问题。
抽取相同逻辑:
var run = (steps, callback) => {
setTimeout(
() => {
console.log(steps);
callback();
},
1000
);
};
run(1, () => {
run(2, () => {
run(3, () => {});
});
});
Promise,就如同字面意思“承诺”一样,定义在当前,但行为发生于未来。它的构造方法中接受一个函数,并且这个函数接受 resolve 和 reject 两个参数,前者在未来的执行成功时会被调用,后者在未来的执行失败时会被调用。
这个 Promise 对象,并不是在程序一开始就初始化的,而是在未来的某一时刻,前一步操作完成之后才会得到执行,这一点非常关键,并且这是一种通过给原始代码添加函数包装的方式实现了这里的“定义、传递、但不执行”的要求。
var run = steps =>
() =>
new Promise((resolve, reject) => {
setTimeout(
() => {
console.log(steps);
resolve(); // 一秒后的未来执行成功,需要调用
},
1000
);
});
Promise.resolve()
.then(run(1))
.then(run(2))
.then(run(3));
async/await:
- async 用于标记当前的函数为异步函数;
- await 用于表示它的后面要返回一个 Promise 对象,在这个 Promise 对象得到异步结果以后,再继续往下执行。
var run = async steps => {
await wait(1000);
console.log(steps);
}
await run(1);
await run(2);
await run(3);
纵观这个小狗奔跑的问题,我们一步一步把晦涩难懂的嵌套回调代码,优化成了易读、易理解的“假同步”代码。聪明的程序员总在努力地创造各种工具,去改善代码异步调用的表达能力,但是越是深入,就越能发现,最自然的表达,似乎来自于纯粹的同步代码。
用生成器来实现协程
协程,Coroutine,简单说就是一种通用的协作式多任务的子程序,它通过任务执行的挂起与恢复,来实现任务之间的切换。
这里提到的“协作式”,是一种多任务处理的模式,它和“抢占式”相对。如果是协作式,每个任务处理的逻辑必须主动放弃执行权(挂起),将继续执行的资源让出来给别的任务,直到重新获得继续执行的机会(恢复);而抢占式则完全将任务调度交由第三方,比如操作系统,它可以直接剥夺当前执行任务的资源,分配给其它任务。
JavaScript 的协程是通过生成器来实现的,执行的主流程在生成器中可以以 yield 为界,进行协作式的挂起和恢复操作,从而在外部函数和生成器内部逻辑之间跳转,而 JavaScript 引擎会负责管理上下文的切换。
JavaScript 和迭代有关的两个协议:
- 第一个是可迭代协议,它允许定义对象自己的迭代行为,比如哪些属性方法是可以被 for 循环遍历到的;
- 第二个是迭代器协议,它定义了一种标准的方法来依次产生序列的下一个值(next() 方法),如果序列是有限长的,并且在所有的值都产生后,将有一个默认的返回值。
在 JavaScript 中,生成器对象是由生成器函数 function* 返回,且符合“可迭代协议”和“迭代器协议”两者。function* 和 yield 关键字通常一起使用,yield 用来在生成器的 next() 方法执行时,标识生成器执行中断的位置,并将 yield 右侧表达式的值返回。见下面这个简单的例子:
function* IdGenerator() {
let index = 1;
while (true)
yield index++;
}
var idGenerator = IdGenerator();
console.log(idGenerator.next());
console.log(idGenerator.next());
输出:
{value: 1, done: false}
{value: 2, done: false}
生成器可不是只能往外返回,还能往里传值。具体说,yield 右侧的表达式会返回,但是在调用 next() 方法时,入参会被替代掉 yield 及右侧的表达式而参与代码运算。我们将上面的例子小小地改动一下:
function* IdGenerator() {
let index = 1, factor = 1;
while (true) {
factor = yield index; // 位置①
index = yield factor * index; // 位置②
}
}
调用:
var calculate = (idGenerator) => {
console.log(idGenerator.next());
console.log(idGenerator.next(1));
console.log(idGenerator.next(2));
console.log(idGenerator.next(3));
};
calculate(IdGenerator());
image.png
异步错误处理
Promise 的异常处理
还记得上面介绍的 Promise 吗?它除了支持 resolve 回调以外,还支持 reject 回调,前者用于表示异步调用顺利结束,而后者则表示有异常发生,中断调用链并将异常抛出:
var exe = (flag) =>
() => new Promise((resolve, reject) => {
console.log(flag);
setTimeout(() => { flag ? resolve("yes") : reject("no"); }, 1000);
});
上面的代码中,flag 参数用来控制流程是顺利执行还是发生错误。在错误发生的时候,no 字符串会被传递给 reject 函数,进一步传递给调用链:
Promise.resolve()
.then(exe(false))
.then(exe(true));
你看,上面的调用链,在执行的时候,第二行就传入了参数 false,它就已经失败了,异常抛出了,因此第三行的 exe 实际没有得到执行,你会看到这样的执行结果:
false
Uncaught (in promise) no
但是,有时候我们需要捕获错误,而继续执行后面的逻辑,该怎样做?这种情况下我们就要在调用链中使用 catch 了:
Promise.resolve()
.then(exe(false))
.catch((info) => { console.log(info); })
.then(exe(true));
这种方式下,异常信息被捕获并打印,而调用链的下一步,也就是第四行的 exe(true) 可以继续被执行。我们将看到这样的输出:
var run = async () => {
try {
await exe(false)();
await exe(true)();
} catch (e) {
console.log(e);
}
}
run();
async/await 下的异常处理
利用 async/await 的语法糖,我们可以像处理同步代码的异常一样,来处理异步代码:
简单说明一下 ,定义一个异步方法 run,由于 await 后面需要直接跟 Promise 对象,因此我们通过额外的一个方法调用符号 () 把原有的 exe 方法内部的 Thunk 包装拆掉,即执行 exe(false)() 或 exe(true)() 返回的就是 Promise 对象。在 try 块之后,我们使用 catch 来捕捉。运行代码,我们得到了这样的输出:
false
no