JavaScript异步总结

前言

JavaScript与其他语言最大的不同点就在于其异步操作。在JavaScript中,有3种异步的调用方法:callbacks、Promises和async/await。其中callbacks是最为传统最为老派的方式,没有那么高效灵活,一般仅在必要时使用。Promises是现代JavaScript异步的支柱,允许在异步操作结束后再根据结果进行后续操作。async/await则是基于Promises的、同步风格的方式,具有更高的可读性。

此外,由于JavaScript的语言特性较为灵活,再加上异步操作本来就很复杂,保持一个良好的编程风格就显得格外重要。在本文的最后部分将对编码风格进行讨论。

但是在讨论这些之前,先让我们了解下JavaScript的异步操作是如何工作的,以及一些常用的异步函数之间有什么不同。

JavaScript异步

异步的概念

在编写一般的同步代码时,如果遇到比较费时的任务,比如I/O操作、网络操作或者计算量较大的函数时,程序会卡死在此处,直到相应的操作完成。这将极大地降低用户的体验。

异步正是为了解决这一问题而生的。当遇到比较费时当任务时,可以运用异步来避免原地等待,而是设置当任务完成后需要调用的函数。callbacks方法最直观地体现了这一点。这样一来,程序可以非阻塞地继续运行下去,而如果刚才的任务完成了,触发相应的事件调用对应的回调函数。

异步的概念非常直观,但是实际上手又是另外一回事了。首先我们需要了解的就是JavaScript中的异步到底是怎么运行的。

JavaScript异步的原理

第一个需要记住的概念是JavaScript在一般情况下都是单线程的(后来我了解到其实JavaScript是可以多线程的,但是必须是在完全不同的context中才行)。即使你的CPU是多核的,JavaScript也只能在其中一个核上运行。单线程保证了JavaScript的异步不会过于复杂,由于同一时间只有一个执行点,JavaScript也不需要锁的机制。但是也正因为如此,JavaScript的异步往往和我们一开始想象的并不一样。

1
2
3
for (var i=0; i<3; i++) {
setTimeout(function(){ console.log(i); }, 0);
}

运行上述代码后,将输出3个3。原因在于,虽然设置了回调函数在0ms后启动,但是只有在线程空闲时,JavaScript的事件处理器才会运行。(另一个原因是这里使用了var来声明i,如果使用let则不会有问题)

基于上面的例子,我们也同样可以得出另一个容易造成困扰的结论:setTimeout中的第二个参数设置的时间并不能保证回调函数在这么长时间之后运行,而只是设置了一个下限。这个结论对于setInterval函数来说也同样适用。

常用异步函数

setTimeout()

setTimeout的一般使用方法为

1
setTimeout(func[, delay, [, param1, param2, ...]])

代表(至少)delay毫秒之后运行func。param则是func的参数。

特别的,当delay设置为0时,代表在主线程完成之后立即调用func。

另一种常用的调用方式是递归调用,从而达到和setInterval函数类似的效果。

1
2
3
4
setTimeout(function run () {
// Do something ...
setTimeout(run, delay);
}, delay);

setInterval不同的是,使用这种方式的delay不包含回调函数执行的时间,而setInterval中则相反。

还有一个相关的函数是clearTimeout,其调用方式为

1
clearTimeout(timeoutID);

其中timeoutID是调用setTimeout后得到的返回值。

setInterval()

setInterval的常用方式为

1
setInterval(func, delay[, param1, param2, ...]);

代表每隔(至少)delay毫秒后运行func。param是func的参数。

取消setInterval的方法和取消setTimeout类似,可以采用如下方式

1
clearInterval(intervalID);

其中timeoutID是调用setTimeout后得到的返回值。

值得注意的是clearTimeoutclearInterval是在同一个列表里寻找要清除的entries的,所以实际上二者是可以混用的。但是为了程序的可读性,还是应该使用对应的函数。

requestAnimationFrame()

上面两个函数的好处在于可以灵活控制delay的时间。但是在处理动画时,由于JavaScript的事件处理机制,无法保持最佳的帧数,有时甚至会掉帧。另外,由于没有进行相关的优化,当页面切换出去或者动画部分已经被滚轮移出屏幕时,相应的程序仍然会运行。

requestAnimationFrame就是为了解决这些问题而生的。其常见的调用方法为

1
requestAnimationFrame(func);

代表在下一次重新绘制之前调用func函数。通常func的执行频率为每秒60次,但在大多数遵循W3C建议的浏览器中,func执行的频率与屏幕的刷新率相匹配。并且,为了提高性能,当requestAnimationFrame所在的窗口或者iframe运行在后台标签页或者是被隐藏时,requestAnimationFrame会被暂停调用。

异步调用的不同方式

callbacks

之前写的部分使用的基本都是callbacks的方法。简单来说就是定义某个事件发生后需要调用的函数。除了上面所说的与时间有关的三个函数外,通常的使用方法为

1
el.addEventListener(event, func);

代表当网页上的el元素发生event事件时,调用func函数。

该方法的优点是兼容性好,所有浏览器都支持该方法。

缺点在于:

  • 太多回调会使得代码复杂且难以阅读(callback hell)。
  • 如果要处理回调链中的错误,必须在回调链中的每个回调函数内分别处理。
  • 无法保证按照定义的顺序进行回调。

Promises

Promises定义了一个异步方法及其状态。其状态包括:

  • 待定(pending),代表其刚被创建出来的状态,既不是成功也不是失败。
  • 解决(resolved),代表异步方法已经返回。
    • 一个成功resolved的Promise称为fullfilled,其返回一个值作为then()中回调函数的参数
    • 一个不成功的resolved被称为rejected,其返回一个错误信息作为catch()中捕获到的错误

pending状态只存在于Promise被创建出来时,而fullfilled
一个令人迷惑的点在于,这些状态和改变状态的函数的命名方式并不是一致的。到达fullfilled的状态需要使用resolve()函数,到达rejected状态则需要使用reject()函数。虽然其实resolve和reject只是Promise构造函数的两个参数(见下文),可以随意命名,但是习惯上还是使用这两个令人迷惑的名称。

得到一个Promise有两种方法,一是直接使用构造函数new出来,另一种是使用一般方法调用异步函数(函数定义前带有async的那种)。在此处重点说明第一种方法,第二种方法将在下一部分中涉及。

1
2
3
4
5
let timeoutPromise = new Promise((resolve, reject) => {
setTimeout(function(){
resolve('Success!');
}, 2000);
});

执行完上述语句后得到一个timeoutPromise,该Promise在创建时为pending状态。当2000毫秒之后,setTimeout的回调函数启动,将该Promise的状态变为fullfilled。这个状态的改变则会影响其后的thencatch的执行。

1
2
3
timeoutPromise.then(function(msg){
console.log(msg);
})

上述语句定义了当timeoutPromise的状态变为fullfilled之后要执行的函数:打印fullfilled传递的信息。

值得注意的是,如果在then()中的函数也返回一个Promise,那么多个then语句可以串联起来。而catch语句则一般放在所有的then语句后面。和同步语句类似,finally函数在这里也是可用的。

1
2
3
4
5
6
7
8
9
timeoutPromise
.then(msg => { /* do something async */ })
.then(ret => { /* do something */ })
.catch(e => {
console.log('Error: ' + e.message);
})
.finally(() => {
console.log('Finished.');
});

有时候,需要等待多个Promise变为fullfilled之后再进行下一步操作,这就需要用到Promise.all()函数。

1
2
3
Promise.all([a, b, c]).then(values => {
...
});

在上述代码中,只有当abc全部fullfilled之后才会执行then语句,并且在then语句中的函数的参数将为一个数组,分别对应的abcresolve函数的输入。而如果有任何一个Promise失败,则整个块都将变为rejected,然后进入catch部分。

async/await

async可以被放在函数声明之前,将函数的返回值变为Promise,而函数原来的返回值(如果有的话)将通过自动传递到then()中。该Promise将在函数内部定义的代码全部执行完毕之后,进入fullfilled状态。

1
2
async function hello() { return "Hello" };
hello().then((value) => console.log(value));

await只能在使用async声明的函数(异步函数)中使用。其作用是等待一个异步函数的调用完成(或者说等待返回的这个Promise的状态变为fillfilled)。并且会使得异步函数直接返回原来的返回值(而不是一个Promise)。在该异步函数执行完成之前,将不会执行await语句后面的部分。

1
2
3
4
async function hello() {
return greeting = await Promise.resolve("Hello");
};
hello().then(alert);

在使用asyncawait的函数中,异常处理将和同步代码类似,可以直接使用try/catch的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async function myFetch() {
try {
let response = await fetch('coffee.jpg');
let myBlob = await response.blob();

let objectURL = URL.createObjectURL(myBlob);
let image = document.createElement('img');
image.src = objectURL;
document.body.appendChild(image);
} catch(e) {
console.log(e);
}
}
myFetch();

或者也可以使用Promises部分所描述的那样。因为catch函数不但能捕获Promise链中的错误,也能捕获try代码块中的错误。

async/await方法使得异步代码阅读起来和同步代码类似。但是这种方法也有一定的缺陷。在async函数中,程序会在await处等待相关函数执行完毕再运行下一行代码。所以程序可能因为大量的Promises相继await和执行而变慢。

1
2
3
4
5
6
7
8
9
10
11
12
13
function timeoutPromise(interval) {
return new Promise((resolve, reject) => {
setTimeout(function(){
resolve("done");
}, interval);
});
};

async function timeTest() {
await timeoutPromise(1000);
await timeoutPromise(1000);
await timeoutPromise(1000);
}

上述语句将花费大约3000毫秒来执行,因为这3个Promise必须依次被创建和等待。

1
2
3
4
5
6
7
8
9
async function timeTest() {
const timeoutPromise1 = timeoutPromise(1000);
const timeoutPromise2 = timeoutPromise(1000);
const timeoutPromise3 = timeoutPromise(1000);

await timeoutPromise1;
await timeoutPromise2;
await timeoutPromise3;
}

这种方法则只需要约1000毫秒就能执行完毕,因为3个Promise是同时被创建的。这种使用方法和Promise.all()函数类似。

未来计划

未来可能会参考《JavaScript异步编程》和《编写可维护的JavaScript》增加编码风格的部分,以及对现在所写的内容做一些补充修改。

参考资料

Mozilla JavaScript Documentation

0%