Javascript语言的执行环境是单线程。即一次只能完成一个任务。若有多个任务则需排队逐个执行——前一个任务完成,再执行后一个任务。
这种执行模式实现简单,执行环境相对单纯。但随着前端业务日渐复杂,事务和请求等日渐增多,这种单线程执行方式在复杂的业务下势必效率低下,只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。常见的浏览器无响应(假死),往往就是因为某一段Javascript代码长时间运行(比如死循环),导致整个页面卡在这个地方,其他任务无法执行。
为避免和解决这种问题,JS语言将任务执行模式分为异步和同步。同步模式”就是上一段的模式,后一个任务等待前一个任务结束,然后再执行,程序的执行顺序与任务的排列顺序是一致的、同步的;”异步模式”则完全不同,每一个任务有一个或多个回调函数(callback),前一个任务结束后,不是执行后一个任务,而是执行回调函数,后一个任务则是不等前一个任务结束就执行,所以程序的执行顺序与任务的排列顺序是不一致的、异步的。
“异步模式”非常重要。在浏览器端,耗时很长的操作都应该异步执行,避免浏览器失去响应,最好的例子就是Ajax操作。在服务器端,”异步模式”甚至是唯一的模式,因为执行环境是单线程的,如果允许同步执行所有http请求,服务器性能会急剧下降,很快就会失去响应。
1.回调函数
异步编程最基本方法。
首先需要声明,回调函数只是一种实现,并不是异步模式特有的实现。回调函数同样可以运用到同步(阻塞)的场景下以及其他一些场景。
回调函数的英文定义:A callback is a function that is passed as an argument to another function and is executed after its parent function has completed。
字面上的理解,回调函数就是一个参数,将这个函数作为参数传到另一个函数里面,当那个函数执行完之后,再执行传进去的这个函数。这个过程就叫做回调。
在JavaScript中,回调函数具体的定义为: 函数A作为参数(函数引用)传递到另一个函数B中,并且这个函数B执行函数A。我们就说函数A叫做回调函数。如果没有名称(函数表达式),就叫做匿名回调函数。
用一个通俗的生活例子比喻一下就是:约会结束后你送你女朋友回家,离别时,你肯定会说:“到家了给我发条信息,我很担心你。” 然后你女朋友回家以后还真给你发了条信息。其实这就是一个回调的过程。你留了个参数函数(要求女朋友给你发条信息)给你女朋友,然后你女朋友回家,回家的动作是主函数。她必须先回到家以后,主函数执行完了,再执行传进去的函数,然后你就收到一条信息了。
假定有两个函数f1和f2,后者等待前者的执行结果。
f1();
f2();
复制代码
若f1是一个很耗时的任务,可以考虑改写f1,把f2写成f1的回调函数。
function f1(callback){setTimeout(function () {// f1的任务代码callback();}, 1000);}复制代码
执行代码就变成下面这样:
f1(f2);
复制代码
采用这种方式,我们把同步操作变成了异步操作,f1不会堵塞程序运行,相当于先执行程序的主要逻辑,将耗时的操作推迟执行。
另一个例子:
//定义主函数,回调函数作为参数
function A(callback) {
callback();
console.log('我是主函数');
}
//定义回调函数
function B(){
setTimeout("console.log('我是回调函数')", 3000);//模仿耗时操作
}
//调用主函数,将函数B传进去
A(B);
//输出结果
我是主函数
我是回调函数
复制代码
上面的代码中,我们先定义了主函数和回调函数,然后再去调用主函数,将回调函数传进去。
定义主函数的时候,我们让代码先去执行callback()回调函数,但输出结果却是后输出回调函数的内容。这就说明了主函数不用等待回调函数执行完,可以接着执行自己的代码。所以一般回调函数都用在耗时操作上面。比如ajax请求,比如处理文件等。
再来一个更俗的例子:
问:你有事去隔壁寝室找同学,发现人不在,你怎么办呢?方法1,每隔几分钟再去趟隔壁寝室,看人在不方法2,拜托与他同寝室的人,看到他回来时叫一下你 前者是轮询,后者是回调。 那你说,我直接在隔壁寝室等到同学回来可以吗? 可以啊,只不过这样原本你可以省下时间做其他事,现在必须浪费在等待上了。把原来的非阻塞的异步调用变成了阻塞的同步调用。 JavaScript的回调是在异步调用场景下使用的,使用回调性能好于轮询。复制代码
对于回调函数,一般在同步情境下是最后执行的,而在异步情境下有可能不执行,因为事件没有被触发或者条件不满足,所以请忽略上上个例子中的小问题,并不是一定回调函数就要执行。
同时补充回调函数应用场合和优缺点:
- 资源加载:动态加载js文件后执行回调,加载iframe后执行回调,ajax操作回调,图片加载完成执行回调,AJAX等等。
- DOM事件及Node.js事件基于回调机制(Node.js回调可能会出现多层回调嵌套的问题)。
- setTimeout的延迟时间为0,这个hack经常被用到,settimeout调用的函数其实就是一个callback的体现。
- 链式调用:链式调用的时候,在赋值器(setter)方法中(或者本身没有返回值的方法中)很容易实现链式调用,而取值器(getter)相对来说不好实现链式调用,因为你需要取值器返回你需要的数据而不是this指针,如果要实现链式方法,可以用回调函数来实现。
- setTimeout、setInterval的函数调用得到其返回值。由于两个函数都是异步的,即:他们的调用时序和程序的主流程是相对独立的,所以没有办法在主体里面等待它们的返回值,它们被打开的时候程序也不会停下来等待,否则也就失去了setTimeout及setInterval的意义了,所以用return已经没有意义,只能使用callback。callback的意义在于将timer执行的结果通知给代理函数进行及时处理。
回调函数这种方式的优点是比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数,而且可以”去耦合“,有利于实现模块化。缺点是整个程序都要变成事件驱动型,运行流程会变得很不清晰。
2.Promise对象
随着ES6标准的发布,处理异步数据流的解决方案又有了新的变化。promise就是这其中的一个。我们都知道,在传统的ajax请求中,当异步请求之间的数据存在依赖关系的时候,就可能产生很难看的多层回调,这样会使代码逻辑很容易造成混乱不便于阅读和后期维护,俗称”回调地狱”(callback hell)。另一方面,往往错误处理的代码和正常的业务代码耦合在一起,造成代码会极其难看。为了让编程更美好,我们就需要引入promise
来降低异步编程的复杂性。
所以某种程度上说,promise是对上面说到的回调函数处理异步编程的一个进阶方案。首先Promise是CommandJS提出的一种规范,其目的是为异步编程提供统一接口。
简单说,Promise的思想是,每一个异步任务返回一个Promise对象,该对象有一个then方法,允许指定回调函数。形如这种形式:
f1().then(f2);
复制代码
对于函数f1,使用Jquery实现以下改写:
function f1(){var dfd = $.Deferred();setTimeout(function () {// f1的任务代码dfd.resolve();}, 500);return dfd.promise;}复制代码
这样写的优点在于,回调函数变成了链式写法,程序的流程可以看得很清楚,而且有一整套的配套方法,可以实现许多强大的功能。这也就是Promise处理异步编程的其中的一个方便之处。
再举一个制定多个回调函数的例子,其形式为:
f1().then(f2).then(f3);
复制代码
当指定发生错误时的回调函数,其形式为:
f1().then(f2).fail(f3);
复制代码
在此补充一点,promise中,如果一个任务已经完成,再添加回调函数,该回调函数会立即执行。所以,你不用担心是否错过了某个事件或信号。这种方法的缺点就是编写和理解,都相对比较难。
展开谈论一下Promise:Promise实际上就是一个特殊的Javascript对象,反映了”异步操作的最终值”。”Promise”直译过来有预期的意思,因此,它也代表了某种承诺,即无论你异步操作成功与否,这个对象最终都会返回一个值给你。
代码示例
const promise = new Promise((resolve, reject) => {
$.ajax('https://github.com/users', (value) => {
resolve(value);
}).fail((err) => {
reject(err);
});
});
promise.then((value) => {
console.log(value);
},(err) => {
console.log(err);
});
//也可以采取下面这种写法
promise.then(value => console.log(value)).catch(err => console.log(err));
复制代码
上面的例子,会在Ajax请求成功后调用resolve
回调函数来处理结果,如果请求失败则调用reject
回调函数来处理错误。Promise对象内部包含三种状态,分别为pending,fulfilled和rejected。这三种状态可以类比于我们平常在ajax数据请求过程的pending,success,error。一开始请求发出后,状态是Pending,表示正在等待处理完毕,这个状态是中间状态而且是单向不可逆的。成功获得值后状态就变为fulfilled,然后将成功获取到的值存储起来,后续可以通过调用then
方法传入的回调函数来进一步处理。而如果失败了的话,状态变为rejected,错误可以选择抛出(throw)或者调用reject
方法来处理。
Promise基本语法如下
Promise实例必须实现then这个方法
then()必须可以接收两个函数作为参数
then()返回的必须是一个Promise实例
eg //如果低版本浏览器不支持Promise,通过cdn这种方式 作者:浪里行舟 链接:https://juejin.im/post/5b1962616fb9a01e7c2783a8 来源:掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 复制代码
Promise还可以做更多的事情,比如,有若干个异步任务,需要先做任务1,如果成功后再做任务2,任何任务失败则不再继续并执行错误处理函数。要串行执行这样的异步任务,不用Promise需要写一层一层的嵌套代码。
有了Promise,我们只需要简单地写job1.then(job2).then(job3).catch(handleError);
其中job1、job2和job3都是Promise对象。
比如我们想实现第一个图片加载完成后,再加载第二个图片,如果其中有一个执行失败,就执行错误函数:
var src1 = 'https://www.imooc.com/static/img/index/logo_new.png'
var result1 = loadImg(src1) //result1是Promise对象
var src2 = 'https://img1.mukewang.com/545862fe00017c2602200220-100-100.jpg'
var result2 = loadImg(src2) //result2是Promise对象
result1.then(function (img1) {
console.log('第一个图片加载完成', img1.width)
return result2 // 链式操作
}).then(function (img2) {
console.log('第二个图片加载完成', img2.width)
}).catch(function (ex) {
console.log(ex)
})复制代码
Promise的常用方法
除了串行执行若干异步任务外,Promise还可以并行执行异步任务。试想一个页面聊天系统,我们需要从两个不同的URL分别获得用户的个人信息和好友列表,这两个任务是可以并行执行的,用Promise.all()实现如下:
var p1 = new Promise(function (resolve, reject) {
setTimeout(resolve, 500, 'P1');
});
var p2 = new Promise(function (resolve, reject) {
setTimeout(resolve, 600, 'P2');
});
// 同时执行p1和p2,并在它们都完成后执行then:
Promise.all([p1, p2]).then(function (results) {
console.log(results); // 获得一个Array: ['P1', 'P2']
});复制代码
有些时候,多个异步任务是为了容错。比如,同时向两个URL读取用户的个人信息,只需要获得先返回的结果即可。这种情况下,用Promise.race()实现:
var p1 = new Promise(function (resolve, reject) {
setTimeout(resolve, 500, 'P1');
});
var p2 = new Promise(function (resolve, reject) {
setTimeout(resolve, 600, 'P2');
});
Promise.race([p1, p2]).then(function (result) {
console.log(result); // 'P1'
});复制代码
由于p1执行较快,Promise的then()将获得结果'P1'。p2仍在继续执行,但执行结果将被丢弃。
总结:Promise.all接受一个promise对象的数组,待全部完成之后,统一执行success;
Promise.race接受一个包含多个promise对象的数组,只要有一个完成,就执行success。
对上面的例子做下修改,加深对这两者的理解:
var src1 = 'https://www.imooc.com/static/img/index/logo_new.png'
var result1 = loadImg(src1)
var src2 = 'https://img1.mukewang.com/545862fe00017c2602200220-100-100.jpg'
var result2 = loadImg(src2)
Promise.all([result1, result2]).then(function (datas) {
console.log('all', datas[0])//"https://www.imooc.com/static/img/index/logo_new.png">
console.log('all', datas[1])//"https://img1.mukewang.com/545862fe00017c2602200220-100-100.jpg">
})
Promise.race([result1, result2]).then(function (data) {
console.log('race', data)//"https://img1.mukewang.com/545862fe00017c2602200220-100-100.jpg">
})复制代码
如果我们组合使用Promise,就可以把很多异步任务以并行和串行的方式组合起来执行。
Promise.reject(reason): 返回一个新的promise
对象,用reason值直接将状态变为rejected
。
const promise2 = new Promise((resolve, reject) => {
reject('Failed');
});
const promise2 = Promise.reject('Failed');
复制代码
上面两种写法是等价的。
Promise.resolve(value): 返回一个新的promise对象,这个promise对象是被resolved的。与reject类似,下面这两种写法也是等价的。
const promise2 = new Promise((resolve, reject) => {
resolve('Success');
});
const promise2 = Promise.resolve('Success');
复制代码
then 利用这个方法访问值或者错误原因。其回调函数就是用来处理异步处理返回值的。
catch 利用这个方法捕获错误,并处理。
3.Async/Await简介与用法
简介
- async/await是写异步代码的新方式,以前的方法有回调函数和Promise。
- async/await是基于Promise实现的,它不能用于普通的回调函数。
- async/await与Promise一样,是非阻塞的。
- async/await使得异步代码看起来像同步代码,这正是它的魔力所在。
语法
用promise示例和asyn/await示例两段代码演示:
promise
const makeRequest = () =>
getJSON()
.then(data => {
console.log(data)
return "done"
})
makeRequest()
复制代码
async/await
const makeRequest = async () => {
console.log(await getJSON())
return "done"
}
makeRequest()
复制代码
它们有一些细微不同:
函数前面多了一个aync关键字。await关键字只能用在aync定义的函数内。async函数会隐式地返回一个promise,该promise的reosolve值就是函数return的值。(示例中reosolve值就是字符串”done”)
第1点暗示我们不能在最外层代码中使用await,因为不在async函数内。
// 不能在最外层代码中使用await
await makeRequest()
// 这是会出事情的
makeRequest().then((result) => {
// 代码
})
复制代码
await getJSON()表示console.log会等到getJSON的promise成功reosolve之后再执行。
相对于promise,async/await的优势有哪些
1.简洁
由示例可知,使用Async/Await明显节约了不少代码。我们不需要写.then,不需要写匿名函数处理Promise的resolve值,也不需要定义多余的data变量,还避免了嵌套代码。这些小的优点会迅速累计起来,这在之后的代码示例中会更加明显。
2.错误处理
Async/Await让try/catch可以同时处理同步和异步错误。在下面的promise示例中,try/catch不能处理JSON.parse的错误,因为它在Promise中。我们需要使用.catch,这样错误处理代码非常冗余。并且,在我们的实际生产代码会更加复杂。
const makeRequest = () => {
try {
getJSON()
.then(result => {
// JSON.parse可能会出错
const data = JSON.parse(result)
console.log(data)
})
// 取消注释,处理异步代码的错误
// .catch((err) => {
// console.log(err)
// })
} catch (err) {
console.log(err)
}
}
复制代码
使用aync/await的话,catch能处理JSON.parse错误
const makeRequest = async () => {
try {
// this parse may fail
const data = JSON.parse(await getJSON())
console.log(data)
} catch (err) {
console.log(err)
}
}复制代码
3.条件语句
下面示例中,需要获取数据,然后根据返回数据决定是直接返回,还是继续获取更多的数据。
|
这些代码看着就头痛。嵌套(6层),括号,return语句很容易让人感到迷茫,而它们只是需要将最终结果传递到最外层的Promise。
上面的代码使用async/await编写可以大大地提高可读性:
|
4. 中间值
你很可能遇到过这样的场景,调用promise1,使用promise1返回的结果去调用promise2,然后使用两者的结果去调用promise3。你的代码很可能是这样的:
|
如果promise3不需要value1,可以很简单地将promise嵌套铺平。如果你忍受不了嵌套,你可以将value 1 & 2 放进Promise.all来避免深层嵌套:
|
这种方法为了可读性牺牲了语义。除了避免嵌套,并没有其他理由将value1和value2放在一个数组中。
使用async/await的话,代码会变得异常简单和直观。
|
5. 错误栈
下面示例中调用了多个Promise,假设Promise链中某个地方抛出了一个错误:
|
Promise链中返回的错误栈没有给出错误发生位置的线索。更糟糕的是,它会误导我们;错误栈中唯一的函数名为callAPromise,然而它和错误没有关系。(文件名和行号还是有用的)。
然而,async/await中的错误栈会指向错误所在的函数:
|
在开发环境中,这一点优势并不大。但是,当你分析生产环境的错误日志时,它将非常有用。这时,知道错误发生在makeRequest比知道错误发生在then链中要好。
6. 调试
最后一点,也是非常重要的一点在于,async/await能够使得代码调试更简单。2个理由使得调试Promise变得非常痛苦:
- 不能在返回表达式的箭头函数中设置断点
const markRequest = () => {
return callAPromise ()
.then (() => callAPromise())
.then (() => callAPromise())
.then (() => callAPromise())
.then (() => callAPromise())
}
复制代码
- 如果你在.then代码块中设置断点,使用Step Over快捷键,调试器不会跳到下一个.then,因为它只会跳过异步代码。使用await/async时,你不再需要那么多箭头函数,这样你就可以像调试同步代码一样跳过await语句。
const markRequest = async () => { await callAPromise() await callAPromise() await callAPromise() await callAPromise() await callAPromise() }复制代码
总结
对于常用的不同异步编程处理方案,个人观点是针对不同的业务场景可根据情况选择合适高效的方案,各有优势劣势,没必要顶一个踩一个,虽然技术不断发展优化,但有些技术不至于淘汰如此之快,存在即合理。