同步与异步
# 同步与异步
在 ES5 中,异步编程的传统解决方案是通过回调函数或事件监听。
在 ES6 中,Promise 成为了异步编程的一种更合理更强大的解决方案。
在 ES7 中,async/await 优化了 ES6 异步编程的解决方案,比 Promise 更加优美。
# 同步与异步概念
# 代码举例
同步:同步就是后一个任务等待前一个任务执行完毕后,再执行,执行顺序和任务的排列顺序一致。
异步:异步是非阻塞的,异步逻辑与主逻辑相互独立,主逻辑不需要等待异步逻辑完成,而是可以立刻继续下去。
举个例子:
console.log('a');
console.log('b');
console.log('c');
setTimeout(() => {console.log('我是一段异步操作')}, 2000);
console.log('d');
console.log('e');
2
3
4
5
6
7
8
从以上代码的输出结果可以看出,异步函数的好处是不会阻塞主任务。
再举个例子:
console.log('a');
console.log('b');
console.log('c');
setTimeout(() => {console.log('我是写在前面的异步操作')}, 2000);
setTimeout(() => {console.log('我是写在后面的异步操作')}, 500);
console.log('d');
console.log('e');
2
3
4
5
6
7
8
9
10
上述代码的执行结果表明:当有多个异步函数时,跟书写顺序无关,谁先返回,就先执行谁的回调。
# 过程分析
运行以下 JavaScript,猜想控制台会输出什么内容,并分析执行过程是怎样的:
const foo = () => console.log("First");
const bar = () => setTimeout(() => console.log("Second"), 500);
const baz = () => console.log("Third");
bar()
foo()
baz()
2
3
4
5
6
7
分析执行过程(其中 4、5 两个步骤的先后顺序不绝对,主要看倒计时多久):
- 调用
bar
函数,它进入调用栈;bar
返回一个setTimeout
函数,也进入调用栈执行。 setTimeout
开启定时器后,它的回调进入 Web API。于此同时,执行完成的setTimeout
函数和bar
从调用堆栈中弹出。- 定时器开始倒计时,同时
foo
函数调用并打印出字符串"First"
,然后返回执行结果(undefined
)。 baz
函数被调用。- 定时器倒计时结束,回调函数添加到消息队列中。
baz
函数打印出字符串"Third"
,然后返回执行结果(undefined
)。baz
指行完成后,事件循环看到调用栈为空,从消息队列中取出回调函数,将其添加到调用栈。- 回调函数打印出字符串
"Second"
。 - 全部执行完毕。
动图演示执行过程:
(代码执行过程,图来源于网络 - 文末已附原文链接)
在这个过程中,需要引入三个概念:调用栈,事件循环,消息队列。
- 调用栈:每次执行任务时,任务(以同步模式执行的代码)进入调用栈并执行,执行完后任务弹出调用栈。
- 事件循环(Event Loop):它只做一件事:负责监听调用栈和消息队列,一旦调用栈中所有的任务都结束了,事件循环就从消息队列中取出第一个回调函数,将它压入调用栈;如果消息队列中暂时是空的,事件循环就会先暂停,等回函数调进消息队列(如果有)。
- 消息队列:存放异步任务的回调,先进先出。
补充:任务进入消息队列的顺序由宏任务与微任务的时序决定,参考下一章的知识点,本文先不考虑。
下面这张图可以很清晰的表达出同步任务和异步任务的执行顺序:
(同步任务和异步任务的执行时间线)
我们可以根据这张图归纳出一般的执行流程:
- JavaScript 线程依次执行每行代码。对于同步代码,将它放到调用栈中执行,执行完后然后弹出调用栈(例如
console.log()
)。 - 某个时刻,JavaScript 线程发起了一个异步调用(例如
setTimeout
)。setTimeout
函数进入调用栈,开启倒计时器,然后setTimeout
函数弹出调用栈。- JavaScript 线程继续往后执行。
- 异步线程(Web API)单独执行这个异步任务(倒计时器),执行完这个任务之后,将任务的回调放到消息队列。
- JavaScript 主线程完成所有的同步任务后,此时调用栈清空状态。
- 接下来,如果倒计时结束,回调函数就会被放入消息队列;否则事件循环继续保持等待状态。
- 事件循环依次从消息队列中取出任务,将它压入调用栈并执行,执行方式相当于开启了新一轮的执行,与上面相同。
- 最后,调用栈和消息队列中都没有需要执行的任务了,整体代码结束。
- 众所周知,JavaScript 是单线程的,但浏览器不是单线程的,例如上面使用的倒计时器
setTimeout
,浏览器内部就会有一个单独的线程去负责倒数,等时间到了就将回调放入消息队列。- 平时所说的 JavaScript 单线程,是指执行我们代码的是一个线程,而其它一些内部的 API(例如
setTimeout
)会用单独的线程去执行。所以这里的同步与异步,指的是运行环境提供的 API 是以同步模式还是异步模式的方式去工作。
# 回调函数
所有异步编程方案的根基都是回调函数。
回调函数:由调用者定义,交给执行者执行的函数。回调函数一个很明显的特点是「由我们定义,我们不去调用,但最后执行了」。
// callback 就是回调函数
// 就是把函数作为参数传递,缺点是不利于阅读,执行顺序混乱。
function foo(callback) {
setTimeout(function() {
callback()
}, 3000)
}
foo(function() {
console.log('这就是一个回调函数')
console.log('调用者定义这个函数,执行者执行这个函数')
console.log('其实就是调用者告诉执行者异步任务结束后应该做什么')
})
2
3
4
5
6
7
8
9
10
11
12
13
除了传递回调函数参数这种方式,还有其他的一些实现异步的方式,例如:事件机制和发布订阅。这些也都是基于回调函数基础之上的变体。
下面介绍几种使用回调方式来完成异步流程的方案。
# ajax(jQuery)
$.ajax
是 jQuery 中一个异步请求的方法,它的用法如下代码所示:
console.log(1);
$.ajax({
type: "get",
url: "https://wenyuan.github.io/apis/articles/list/",
success: function(res) {
console.log(res)
}
});
console.log(2);
2
3
4
5
6
7
8
9
10
11
上述代码是一种传统异步编程的解决方案:通过回调函数实现。但它有一个致命的缺点,就是容易写出回调地狱。
假设多个请求存在依赖性,你可能就会写出如下代码:
ajax(url, () => {
// 处理逻辑
ajax(url1, () => {
// 处理逻辑
ajax(url2, () => {
// 处理逻辑
})
})
})
2
3
4
5
6
7
8
9
# Promise
Promise 是 ES6 中异步编程的一种解决方案,比传统的解决方案(回调函数或事件监听)更合理且更强大。
它有三个状态:
- pending:初始状态
- fulfilled:操作成功
- rejected:操作失败
当 promise 状态发生改变,就会触发 then()
里的响应函数处理后续步骤。
状态改变,只有两种可能:
- 从 pending 变为 fulfilled
- 从 pending 变为 rejected
# 基础用法
下述代码演示了 Promise 基础用法:
const promise = new Promise(function(resolve, reject) {
if (true) {
resolve('success'); // 异步请求成功,将请求结果 resolve 出去
} else {
reject('fail'); // 异步请求失败,将错误信息 reject 出去
}
});
promise.then(res => {
console.log(res); // 成功 resolve('success')
}).catch(error => {
console.log(error); // 失败 reject('fail')
})
2
3
4
5
6
7
8
9
10
11
12
13
从上面的代码可以看出,Promise 本质上也是使用回调函数去定义异步任务结束后所需要执行的任务。这里的回调函数是通过 then 方法传递的,且 Promise 将回调分成了两种,分别是成功后的回调和失败后的回调。
# Promise 使用案例
下面演示一个用 Promise 封装 Ajax 请求的例子:
/**
* Ajax Demo
* @param {string} get 请求的 url
* @return {Promise}
*/
function ajax(url) {
return new Promise(function(resolve, reject) {
let xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = 'json';
xhr.onload = function() {
if (this.status === 200) {
resolve(this.response);
} else {
reject(new Error(this.statusText));
}
};
xhr.send();
})
};
ajax('/api/users.json').then(function(res) {
console.log(res);
}, function(error) {
console.log(error);
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
上面代码中,ajax 是对 XMLHttpRequest 对象的封装,用于发出一个针对 JSON 数据的 HTTP GET 请求,并且返回一个 Promise 对象。需要注意的是,在 ajax 内部,resolve 函数和 reject 函数调用时,都带有参数。
# Promise 链式调用
- Promise 对象的 then 方法会返回一个全新的 Promise 对象。
- 后面的 then 方法就是在为上一个 then 返回的 Promise 注册回调。
- 前面 then 方法中回调函数的返回值会作为后面 then 方法回调的参数,如果没有返回值,默认返回 undefined。
- 如果回调中返回的是 Promise,那后面 then 方法的回调会等待它的结束(也就是后面这个 then 方法在为我们返回的 Promise 注册回调)。
ajax('/api/users.json')
.then(function(value) {
console.log(1)
return ajax('/api/urls.json')
})
.then(function(value) {
console.log(2)
console.log(value)
})
.then(function(value) {
console.log(3)
})
.then(function(value) {
console.log(4)
return 'hello'
})
.then(function(value) {
console.log(5)
console.log(value)
})
// 输出结果
1
2
[{name: 'CODING 修炼小册', url: "wenyuan.github.io"}, {name: '百度', url: "www.baidu.com"}]
3
4
5
"hello"
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Promise 异常处理
# 1)应该总是使用 catch 方法来捕获 Promise 异常。
我们知道,有两种方法可以捕获 Promise 对象抛出的异常:
then()
方法的第二个参数指定rejected
状态的回调函数catch
方法指定发生错误时的回调函数
一般来说,应该总是使用 catch 方法来捕获 Promise 异常。
原因是 catch 方法不但可以捕获异步操作抛出的错误(即状态为 rejected 时),而且 then 方法指定的回调函数中如果抛出错误,也会被 catch 方法捕获。
// bad
ajax('/api/users.json')
.then(function(data) {
// success
}, function(err) {
// error
});
// good
ajax('/api/users.json')
.then(function(data) {
// success
})
.catch(function(err) {
// error
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
在链式调用中,每个 then 方法都会返回一个全新的 Promise 对象。也就是说链式调用中的 catch 其实是给它前面一个 then 方法返回的 Promise 对象指定失败回调,并不是直接给第一个 Promise 对象指定的。
但由于在同一个 Promise 链中,Promise 对象的错误具有「冒泡」性质,会一直向后传递,直到被捕获为止。也就是说,错误总是会被下一个 catch 语句捕获。(通过 then 方法的第二个参数来捕获异常的话,不具有这种功能)
ajax('/api/users.json')
.then(function(value) {
console.log(1)
})
.then(function(value) {
console.log(2)
})
.then(function(value) {
console.log(3)
})
.catch(function(error) {
console.log('error:', error)
})
2
3
4
5
6
7
8
9
10
11
12
13
但这种写法也有个问题,就是链路中间但凡有一个方法中抛出异常被 catch 了,那么这个链路下面的 catch 就都不会捕捉到了。
所以 catch 要控制好,必要的话可以在链路中间单独 catch(一个 Promise 异常会被就近的 catch 捕捉)。
如下链式调用代码,先请求登录接口,然后请求保存接口:
// 情形一
// 登录接口设置了捕获错误函数,保存接口没有设置
// 则先捕获登录接口中的错误,如果登录接口没有抛出错误,则捕获保存接口中的错误
ajax('/api/login')
.then(function(value) {
return ajax('/api/save')
})
.catch(function(error) {
console.log('error:', error)
})
// 情形二
// 登录接口和保存接口都设置了捕获错误函数
// 则各自捕获各自的错误
ajax('/api/login')
.then(function(value) {
return ajax('/api/save').catch(function(error) { console.log('error in save:', error) })
})
.catch(function(error) {
console.log('error in login:', error)
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 2)try/catch 无法捕获异步异常。
try/catch 能捕获到的仅仅是 try 模块内执行的同步方法的异常,异步方法所产生的异常(例如 Ajax 请求、定时器),都无法被捕获到。
try {
throw new Error('同步方法的异常')
} catch (error) {
console.log(error)
}
try {
setTimeout(function () {
throw new Error('异步方法的异常')
}, 500)
} catch (error) {
console.log(error)
}
2
3
4
5
6
7
8
9
10
11
12
13
(try/catch 无法捕获异步异常)
要捕获异步异常,就需要借助 Promise 了:
const promise = new Promise(function(resolve, reject) {
setTimeout(function () {
reject('异步方法的异常')
}, 500)
})
promise.then(value => {
console.log(value)
}).catch(error => {
console.log(error)
})
2
3
4
5
6
7
8
9
10
11
# Promise 静态方法
# Promise.resolve()
该方法用于把一个值转换为 Promise 对象。
// 将字符串转换为 Promise 对象后,在 fulfilled(操作成功)中可以拿到这个字符串
Promise.resolve('foo')
.then(function (value) {
console.log(value) // "foo"
})
// 等价于
new Promise(function(resolve, reject) {
resolve('foo')
})
2
3
4
5
6
7
8
9
10
// 如果参数是另一个 Promise 对象,则该 Promise 对象会被原样返回
const p1 = ajax('/api/users.json')
const p2 = Promise.resolve(p1)
console.log(p1 === p2) // true
2
3
4
# Promise.reject()
该方法用于创建一个「失败」的 Promise 对象。
// 无论传入什么数据,它都会成为 rejected(操作失败)的理由
Promise.reject(new Error('rejected'))
.catch(function(error) {
console.log(error) // Error: rejected
})
2
3
4
5
# Promise 并行执行
# Promise.all
Promise.all
可以将多个 Promise 实例(假定为 p1
、p2
、p3
)包装成一个新的 Promise 实例(假定为 p
),适用于同时请求多个接口(相互间没有依赖)的场景。
成功和失败分成两种情况。
- 全部成功:
p
的状态变为成功,此时p1
、p2
、p3
的返回值组成一个数组(数组中元素顺序就是就是p1
、p2
、p3
传入时的顺序),传递给p
的回调函数。 - 只要有一个失败:
p
的状态就变为失败,此时第一个被reject
的实例的返回值,会传递给p
的回调函数。
下述代码演示了 Promise.all
的用法:
const p1 = new Promise((resolve, reject) => {
resolve('success 1');
});
const p2 = new Promise((resolve, reject) => {
resolve('success 2');
});
const p3 = new Promise((resolve, reject) => {
reject('fail');
});
const p = Promise.all([p1, p2]).then(result => {
console.log(result);
}).catch(error => {
console.log(error);
});
// ['success 1'. 'success 2']
Promise.all([p1, p2, p3]).then(result => {
console.log(result);
}).catch(error => {
console.log(error);
})
// 'fail'
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Promise.race
顾名思义,Promise.race
就是赛跑的意思,意思就是说,Promise.race([p1, p2, p3])
里面哪个结果获得的快,就返回那个结果,不管结果本身时成功状态还是失败状态。
下述代码演示了 Promise.race
的用法:
let p1 = new Promise((resolve, reject) => {
setTimeout(() => {
resolve('success');
}, 1000)
});
let p2 = new Promise((resolve, reject) => {
setTimeout(() => {
reject('fail');
}, 500)
});
Promise.race([p1, p2]).then(result => {
console.log(result);
}).catch(error => {
console.log(error);
});
// 'fail'
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# async/await
async/await 是 ES7 的新标准,也是用来处理异步的,是目前异步的终极解决方案。它其实是 Generator 函数的改进,背后原理就是 Promise。
async 意义:
- 在函数前加上 async,函数的任何返回值都会被包装成 Promise
- 函数内部引入了 await,如果不用 async 会报错
await 意义:
- 求值关键字,计算返回的 Promise 中的值,或者表达式(
await 100*100
) - 阻塞当前线程
# 基础用法
下述代码演示了 async/await 基础用法:
这段代码主要体现了:await 会阻塞后面的代码,等主程序执行完,再回来执行。
此外,await 后面既可以是一个 async 函数(返回 Promise 对象),也可以是一个正常函数。
async function f1() {
return new Promise((resolve, reject) => {
setTimeout(() => {resolve('world')}, 2000);
});
}
async function f2() {
var result = await f1();
// 阻塞
console.log('hello');
console.log(result);
}
f2();
// 等待两秒后,才依次打印出 'hello','world'
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 错误处理
async/await 中的错误处理需要通过 try/catch 来实现,否则一旦 reject
,会直接抛出错误(Uncaught (in promise) xxx
),后面的代码就都不会执行了。
async/await 结合 try/catch 的代码如下所示:
async function f1() {
return Promise.reject('fail');
// 与下面代码等价
// return new Promise((resolve, reject) => {reject('fail')})
}
async function f2() {
try {
let result = await f1();
} catch (e) {
console.log(e);
}
}
f2();
// 'fail'
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 总结
本文简单介绍了 Promise 与 async/await 的用法,如果需要了解更多,可以阅读阮一峰老师编写的《ES6标准入门(第3版)》中 Promise 对象 (opens new window) 和 async 函数 (opens new window) 两个章节。
# 参考资料
- 《ES6标准入门(第3版)》
- JavaScript Visualized: Event Loop (opens new window)(很赞)
(完)