概要
由于Javascript是单线程,异步模式对于Javascript这门语言就变得非常重要,从ES5.1开始一直到现在,Javascript异步编程解决方案也经历了一系列的升级优化,这篇专题尝试理清这中间的变化过程以及每种解决方案的优缺点
同步模式和异步模式
同步模式:同步模式也就是执行代码时按顺序执行,后面任务执行的前提是它前面的任务已全部成功执行完。这种模式的优势在于简单、易于理解。
异步模式:异步执行模式有几个核心概念需要厘清,如:event loop、执行栈、消息队列、WebAPI
概念:异步执行模式的API是不会等待执行任务结束才开始下一个任务,对于耗时任务(如定时器、异步请求)都会让浏览器交给单独的线程去处理,js引擎线程是不会等待这种耗时任务执行完成的。WebAPI:某些API如setTimeout会依赖外部环境(如浏览器),当js引擎线程在遇到定时器函数时,会通知浏览器单独开启一个线程用于计算倒计时,并在时间到期后将回调函数加入到消息队列中。在这个过程中,js引擎线程是不会等待的,通知完浏览器后它就继续执行后续任务了。这种依赖于外部环境的API可以称之为WebAPI,当然,在Node.js环境中就可以称之为NodeAPI了。我们需要清楚的知道,这类API不会占用js引擎线程,而是由外部环境(如浏览器)提供单独的线程去处理,并在处理完成后将回调函数加入到消息队列。执行栈:可以理解为js引擎线程工作的区域event loop:它的作用就是不断检查执行栈是否为空,并在执行栈为空时将消息队列中的回调函数从头部依次取出,并放入到执行栈中执行。消息队列:用于存储一些异步任务的回调函数,并在执行栈为空时被取出,放入到执行栈中依次执行。
回调函数
回调函数可以说是Javascript所有异步编程方案的根基,所有的异步编程方案都依赖于回调函数,它的重要性不言而喻。
回调函数的使用,关键点就在于把待执行函数作为参数传递给依赖函数,并在依赖函数中调用它来处理依赖数据。
这里有几个概念依赖函数、依赖数据、待执行函数可以通过下面代码理清:
// 依赖函数
function foo(exerciser){
// 异步获取网络数据 开始
const asyncResult = '我是通过Ajax请求取得的数据';
// 异步获取网络数据 结束
exerciser(asyncResult);
}
// 待执行函数
function exerciser(data){
console.log(data);
}
上面代码中,foo函数即是依赖函数,而依赖数据则是asyncResult,待执行函数exerciser则是专门用来处理依赖数据的。
但是如果直接使用传统的回调方式去解决业务代码中复杂的异步流程,极有可能会陷入回调嵌套过深,代码难以阅读维护的情况。
这也不是说回调函数就完全不提倡使用了,实际上在合适的情形下,我们依然可以使用回调函数去解决某些业务问题,但是大多数情况下,可能还是其他异步解决方案如async-await更加合适。
Promise异步编程
Promise这种机制最早由CommonJS社区提出,后来在ES2015中被纳入了Javascript语言规范。
Promise的基本概念
概念:以一个对象来表示一个异步任务结束时到底是成功还是失败。这种对象最开始的状态是pending(进行中),随着异步任务的结束,状态要么变为Fulfilled(成功),要么是Rejected(失败)。
Note:Promise的状态变化不可逆,也就是说只有两种状态变化,要么由pending变为Fulfilled,要么是由pending变为Rejected。这倒是也颇为符合Promise这个英文单词的含义。
Promise的基本使用
Promise是ES2015引入的一个API,它是一个构造函数,用于生成promise对象,并且参数是一个函数,在函数中可以指定何时将pending状态修改为Fulfilled或Rejected。
下面示例展示如何使用Promise构造函数:
const foo = new Promise(function(resolve, reject){
// 异步任务开始,假设asyncResult是通过异步请求获取的数据
const asyncResult = {
state: true,
value: '我势必成为矮个子中最好的三分球选手!'
}
// 异步任务结束
if (asyncResult['state']) {
resolve(asyncResult['value']);
} else {
reject(new Error('吆西,出错了'))
}
})
foo.then(function(fulfilled_data){
console.log('resolved', fulfilled_data);
},function(rejected_reason){
console.log('rejected', rejected_reason);
})
// resolved 我势必成为矮个子中最好的三分球选手!
示例代码中,根据异步任务得到的数据 asyncResult 来进行判断,当 state 值为 true 时,调用 resolve 函数改变 promise 对象的状态为 Fulfilled,而在 false 时调用 reject 函数将其变为 Rejected。
这里传入 Promise 构造函数的函数中有两个参数,一个是 resolve 函数,这是 Javascript 引擎指定只有在调用它之后,promise 的状态才会由 pending 变为 Fulfilled,它的调用也代表着 promise 内部任务的成功执行(通常是异步任务)。而 reject 函数与之相反。
调用 resolve 函数和 reject 函数时,传入的参数也有区别,传入 resolve 函数的通常是异步任务的结果,而传入 reject 函数的则是异步任务失败的原因(通常是 Error 对象)。
Promise的注意事项
Note:新建 promise 时,回调函数内部的任务可以不仅仅是异步任务,但即使是同步任务(例如做个加法运算),then 方法所指定的回调函数也依然会被加入到当前事件循环的任务队列中。这也就意味着 then 方法内指定的代码必然在本轮同步代码执行完毕后才会从任务队列中取出被执行。下面通过一个示例代码验证这个说法:
console.log('global start');
const foo = new Promise(function(resolve, reject){
console.log('promise inner start');
// 同步任务 开始
const bar = 3 + 4;
// 同步任务 结束
resolve(bar);
console.log('promise inner after resolve');
})
foo.then(data => console.log(data))
console.log('global end');
// global start
// promise inner start
// promise inner after resolve
// global end
// 7
可以看到then方法指定的打印promise结果(7)的代码在最后被执行,说明上面的说法是成立的,即使promise内部是同步任务,then方法所指定的回调函数也依然会被加入到任务队列中。
Note:promise实例新建时,其参数(参数为一个函数)内的代码会立即执行。关于这一点,在上面的示例代码中已有体现,就不再举例说明。
Note:调用 resolve 或 reject 函数并不会终止 promise 参数函数的继续执行。如上面示例代码中的promise inner after resolve在 resolve 函数执行后却依然被执行并打印,且打印结果在 global end之前。但是,正常来说,调用resolve或者reject函数后,promise的使命就完成了,因此不应该在这之后继续执行代码,保险的做法是在执行这两个函数时加上return语句。如:
new Promise((resolve, reject) => {
return resolve(1);
// 后面的语句不会执行
console.log(2);
}).then((data) => console.log(data))
// 1
使用Promise封装ajax请求
Ajax 请求是典型的异步任务,如果能利用 Promise 封装 Ajax 请求,利用 then 方法去处理异步任务的结果。那么处理异步任务结果的代码,就不必以嵌套的形式写在 ajax 对象的 onload 函数中,避免了函数嵌套。
// 可以粘贴下面代码至浏览器进行验证,如果接口地址失效,可以自行替换
function getJson(url, params){
return new Promise(function(resolve, reject){
const 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();
})
}
// 这是我使用easy mock生成的一个在线mock接口,如果失效了,请自行替换(just json)
const url = 'https://www.easy-mock.com/mock/5f4fbecc66f90555e2209ea0/example/#!method=get';
getJson(url).then(function(data){
console.log(data.data.name);
});
// 圆学姐喜欢阿空吗?
从表象理解Promise:Promise 的本质也就是使用回调函数的方式去定义异步任务结束后所需要执行的任务。只不过,这里的回调函数是通过then方法传入,并且then方法分别指定了成功和失败时应该传入不同的函数去处理。
Promise使用的最大误区:当遇到异步任务嵌套的逻辑时,在使用Promise使用时如果按照ES5时代回调函数的写法,如下:
// 假设现在从接口1中获取foo数据,并根据foo到接口2中获取bar数据,然后再根据bar数据到接口3中获取foobar数据,最后展示foobar
getJson('接口1').then(function(data1){
getJson('接口2', data1).then(function(data2){
getJson('接口3',data2).then(function(data3){
// 渲染最后的foobar
render(data3);
})
})
})
示例代码中依然采用了回调的方式去处理异步任务的结果,这就不可避免的产生了函数嵌套,这和ES5时代的回调地狱没有任何区别,甚至因为引入新的API增加了复杂度。
实际上,上面这种嵌套使用Promise是最大的误区,记住,回调嵌套的方式需要极力避免。只有利用好Promise的链式调用,才能真正体现Promise的实际价值。
Promise的链式调用
说到链式调用,会让我下意识的想起jquery,对于同一个dom元素的多种dom操作可以使用链式调用的形式以一行代码结束。如:$('#app').addClass('active').attr('title', 'i am a title');
我们应该清楚,jquery的Api之所以能实现这种链式操作,是因为它的上一个Api返回了jquery对象,通过继续调用jquery对象的Api也就实现了链式调用。
而Promise可以链式调用的原理类似与此,因为then方法的返回值也是一个promise对象。不过,二者有个明显的区别是jquery Api返回的对象是同一个jquery对象,而promise的then方法返回的promise是一个全新的promise,只不过它保存了上一次then方法的返回值。
下面通过一段示例代码展示promise的链式调用:
function getJson(url, params){
return new Promise(function(resolve, reject){
const 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();
})
}
const url = 'https://www.easy-mock.com/mock/5f4fbecc66f90555e2209ea0/example/#!method=get';
// getJson函数在上面示例代码中
getJson(url).then(function(data){
// 1. 可以直接返回一个字符串(当然,其他数据类型也可以)
console.log(data.data.name);
// 圆学姐喜欢阿空吗?
return data.data.name;
}).then(function(data){
console.log(data);
// 圆学姐喜欢阿空吗?
// 2. 可以返回一个新的promise
return new Promise(function(resolve,reject){
resolve(`${data} - 我表示怀疑`)
})
}).then(function(data){
console.log(data);
// 圆学姐喜欢阿空吗?- 我表示怀疑
// 3 可以什么都不返回
}).then(function(data){
console.log(data);
// undefined
})
Note:在Promise链式调用的过程中,每一个then方法都是为上一个then方法返回的promise对象添加状态变更后的回调。
小结:
- Promise对象的then方法会返回一个全新的Promise对象
- 链式调用时,后面的then方法就是在为上一个then方法返回的promise注册回调
- 链式调用时,前面then方法中回调函数的返回值会作为后面then方法回调的参数
如何捕获Promise的错误
Promise的错误捕获既可以选择在then方法的第二个参数中指定回调函数去处理,也可以在使用catch方法捕获,但实际非常不推荐使用在then方法中直接指定,而更倾向于使用catch方法,理由如下:
- then方法中指定回调无法捕获thne方法第一个回调函数中发生的错误
// ① 在then方法的第二个参数中指定回调
const foo = new Promise((resolve, reject) => {
resolve('foo')
});
foo.then((data) => {
console.log(data);
if (data !== 'bar'){
throw new Error('is not bar');
}
}, (err) => {
console.log(err);
})
// foo
// Uncaught (in promise) Error: is not bar
// ② 使用catch方法
const foo = new Promise((resolve, reject) => {
resolve('foo')
});
foo.then((data) => {
console.log(data);
if (data !== 'bar'){
throw new Error('is not bar');
}
}).catch((err) => {
console.log(err);
})
// foo
// Error: is not bar
从上线的打印结果可以清楚的看到,在then方法的第一个回调参数中抛出的错误无法被第二个参数的回调函数正常捕获,而catch方法可以捕获。
- catch方法可以在链式调用的结尾捕获之前的所有promise抛出的错误,与链式调用更搭
const foo = new Promise((resolve, reject) => {
resolve('1')
});
foo
.then((data) => {
return new Promise((resolve, reject) => {
resolve(`${data}-2`)
});
})
.then((data) => {
return `${data}-3`;
})
.then((data) => {
if (data !== '1-2-3-4') {
throw new Error('not 1234')
}
})
.then((data) => {
console.log('第四步');
})
.catch((err) => {
console.log(err);
return '我是catch方法的返回结果'
})
.then((data) => {
console.log(data);
})
// Error: not 1234
// 我是catch方法的返回结果
上面示例代码中捕获了第三个then方法中抛出的错误,并且第四个then方法中的console语句没有执行,说明最后一个catch语句捕获了它前面所有then方法中的第一个错误,这也说明了then方法中产生的错误会在链式调用时向后传播,直到某个then方法捕获。
-Note:catch方法其实是 then(undefined, () => {}) 方法的简写,实际也是then方法,调用之后也会返回promise对象,这也是为什么catch方法返回的结果会在最后一个then方法中被打印的原因。
Promise的静态方法
Promise.resolve():将现有对象转为Promise对象
// ① 参数是一个promise,返回该promise
const promise = new Promise((resolve) => {
resolve('foo');
})
const foo = Promise.resolve(promise);
console.log(foo === promise); // true
// ② 参数是一个thenable对象,适用于将ES6未诞生之前,某些库自己编写的promise对象转为现有的promise对象(这些对象都具有then方法),thenable对象指的是具有then方法的对象。
const promise = {
then(resolve,reject){
resolve('foo');
}
}
const bar = Promise.resolve(promise);
bar.then((data) => {
console.log(data); // foo
})
// ③ 参数是一个普通数据类型,如字符串,会返回一个状态为Fulfilled的promise
const foo = Promise.resolve('name');
foo.then((data) => {
console.log(data); // name
})
Promise.all():该方法用于将多个promise实例(不是会被自动转为promise)组合成一个新的promise实例,新的promise实例如(const p = Promise.all([p1, p2, p3])),具有以下特点:- 只有p1、p2、p3的状态都变为fulfilled,p的状态才会变成fulfilled,此时p1、p2、p3的返回值组成一个数组,传递给p的then方法指定的回调
- 只要p1、p2、p3之中有一个被rejected,p的状态就变成rejected,此时第一个被reject的实例的返回值,会传递给p的catch方法指定的回调
// 生成一个Promise对象的数组
const promises = [2, 3, 5, 7, 11, 13].map(function (id) {
return getJSON('/post/' + id + ".json");
});
Promise.all(promises).then(function (posts) {
// ...
}).catch(function(reason){
// ...
});
详情可参阅 ECMAScript标准入门
Promise.race:Promise.race()方法同样是将多个 Promise 实例,包装成一个新的 Promise 实例。如:const p = Promise.race([p1, p2, p3]),该实例具有以下特点:- 只要p1、p2、p3之中有一个实例率先改变状态,p的状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给p的回调函数。
- 该方法可用于实现ajax请求超时控制。
function getJson(url, params){
return new Promise(function(resolve, reject){
const 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();
})
}
const url = 'https://www.easy-mock.com/mock/5f4fbecc66f90555e2209ea0/example/#!method=get';
const request = getJson(url);
const timeout = new Promise((resolve,reject) => {
setTimeout(() => reject(new Error('timeout')), 500)
})
Promise.race([request, timeout])
.then(value => {
console.log(value);
})
.catch((err) => {
console.log(err);
})
// {success: true, data: {…}} - 这是网络正常下的结果
// Error: timeout - 在google调试工具栏network中将网络改为slow 3G后
总结下 Promise.all 和 Promise.race 方法的区别
- all 方法返回的 promise 会等待 all 方法中的所有参数(promise)成功执行后,状态才会变为 fulfilled,并且只要有一个 promise 状态变为 rejected,那么返回的 promise 状态也会变为 rejected
- race方法返回的 promise 只会等待第一个结束后的 promise,第一个状态改变的 promise 的状态也就会是返回的 promise 的状态,类似于一种竞争的机制,和 race 单词(竞争、赛跑)的意思倒是颇为符合。
Generator生成器异步解决方案
Promise异步编程方案和传统回调函数相比,最大的优势在于可以使用链式调用的方式解决异步嵌套的问题。例如:
// promise chain
getJson('/api/url1')
.then(value => {
return getJson(value.url)
})
.then(value => {
return getJson(value.url)
})
.then(value => {
return getJson(value.url)
})
.catch(err => {
console.log(err);
})
// 理想的同步化代码
const result1 = getJson('/api/url1');
const result2 = getJson(result1.url);
const result3 = getJson(result2.url);
const result4 = getJson(result3.url);
上面示例中链式调用的方式固然解决了传统回调的回调函数嵌套问题,但实际上回调函数依然是存在的,只是以一种链条(chain)的方式存在而已。与我们理想的同步化代码还是存在一定差距。
为了实现连续异步调用的扁平化编程,Generator函数诞生了。Generator函数与普通函数相比,有几个特性需要注意:
- 函数名前或者后需要加一个*号以标识
- 函数内部使用yield语句控制异步流程
- 函数调用后返回一个生成器对象
- 函数内的代码执行需要通过生成器对象的next方法手动控制
- Generator函数使用时需要搭配co库以自动执行
下面通过一个示例展示Generator函数的扁平化异步编程:
// 内部使用try catch捕获g.throw抛出的错误
function *main() {
try{
const users = yield getJson('/api/users.json')
const posts = yield getJson('/api/posts.json')
const urls = yield getJson('/api/urls.json')
} catch (e) {
console.log(e);
}
}
// 这是模仿co库编写的自动执行器函数
function co(generator) {
const g = generator()
function handle(result) {
if (result.done) return
result.value.then(data => {
handle(g.next(data))
}, error => {
// promose出错后会被捕获并抛出到Generator函数体中
g.throw(error)
})
}
handle(g.next())
}
co(main)
对Generator函数的认知:Generator 函数结合 co 库第一次真正意义上实现了异步编程的扁平化,虽然从 Async 函数出来后,它的确不被开发者直接使用了。但是我们需要清楚,Async函数的实现原理还是依赖 Generator 函数。
Async函数异步编程
ECMAScrpt2017引入了async函数。引入async函数的原因就是为了解决Generator函数需要配合自动执行器(co)才能方便使用的问题,async函数和Generator函数相比,具有以下特点:
- 内置执行器,不必再依赖co模块
- 语法使用起来更简单,声明async函数的方法只需要在普通函数名前增加async关键词
- 返回值是一个promise
- async函数内部使用await控制异步流程,并且await目前只能在async函数内使用
下面通过一个示例了解async函数的强大:
// 除了一个await关键词,几乎是等同于同步代码的编程方式,每一个await语句的执行前提都是它上一个await函数已经执行完毕
async main() {
const users = await getJson('/api/users.json')
const posts = await getJson('/api/posts.json')
const urls = await getJson('/api/urls.json')
}