一些概念
单线程&非阻塞I/O
-
JavaScript语言本身是单线程的。什么是单线程?可以理解为一次只能执行一个任务,所有的任务在执行开始前排成一个队列,等待顺序执行
-
为什么是单线程?如果JS有是多线程的,一个线程在某个DOM节点上添加内容,同时另一个线程删除了这个节点,那浏览器以谁为准?所以为了避免复杂性,JavaScript从诞生起就是单线程的
-
非阻塞I/O:I/O即Input/Output,非阻塞和阻塞的区别就在于在系统接收输入到输出期间,能不能接收其他的输入
这里举一个例子:食堂排队打饭 / 餐厅点餐
- 食堂排队打饭: 我们排成一队打饭,阿姨为排到的人打饭(
Input
)时,是不会理会后边的人想要什么的,直到给当前的人打完餐(Output
)后,才会接受下一个人的需求(下一个Input
),这就是阻塞I/O - 餐厅点餐:我们进入餐厅后,服务员来为我们点餐(
Input
),点餐结束后,服务员将菜单传给后厨(扔进任务队列
----后边会详细介绍);接着服务员会为下一个人点餐(下一个Input
),直到后厨做好,服务员根据餐桌位置把菜端上来(Output
);在此期间,服务员不断地点餐,送餐,后边的人不需要等待前边的人上完菜再点餐,这就是非阻塞I/O
- 食堂排队打饭: 我们排成一队打饭,阿姨为排到的人打饭(
-
因为非阻塞I/O的特性,也造就了JS能够高效率地执行代码,也能够承载高并发
JS异步
- 异步(Asynchronous, async)是与同步(Synchronous, sync)相对的概念,我们来看下面这张图:
同步的流程中,所有任务需要等待上一个任务完成后才能开始执行; ----- 同步流程中,总执行时间是所有同步任务执行时间的总和。
而异步的任务则不需要等待前边的任务执行完成,就可以开始本次任务的执行,任务之间互相不受干扰; ---- 异步流程中,总执行时间是耗时最长的异步任务所需时间。 - 对于JS来说,执行代码过程中有能够 立即执行 的操作(比如声明、赋值、循环等,也称同步任务),还有一些 非常耗时 的操作(比如定时器、网络请求、文件读写、事件监听等,也称异步任务),如果让他们像前边的任务一样老老实实地等待,这样对于单线程的JS来说执行效率就非常低,甚至会形成假死状态
- 所以JS的宿主环境(浏览器、Node.js)会为这些耗时的操作单独开辟线程,比如网络请求线程、定时器触发线程、浏览器事件触发线程、文件读写线程等,等这些任务被其他线程执行完成后,再通过回调的方式返回,这就是JS异步
事件循环(Event Loop)
先上个画了1个小时的图
- JS主线程顺序读取代码,形成一个执行栈(execution context stack)
- 碰到耗时的操作,也就是需要异步执行的代码,主线程就根据异步类型分派给其他的异步线程去处理这些操作,当他们处理完成后,将结果扔给任务队列
- 当主线程把所有执行栈中的内容执行完成后,开始循环读取任务队列中,有完成的,就把这些异步的回调(callback)拉到主线程继续执行,如此反复,称为事件循环(Event Loop)
JS处理异步的历程
callback
- 最开始,我们基本都是通过函数传参callback回调函数来解决异步问题
- 来看下面这段代码,假设我们请一位先生吃饭,就会有:
执行一下,控制台告诉我们结果是let status = 'hungry' // 饿了 function eat() { // 吃饭是需要花时间的,这里给个定时器模拟 setTimeout(() => { status = 'full' // 吃饱了 }, 500) } eat() // 开始吃饭 console.log(status) // 吃饱了么?
hungry
。白吃了?其实并不是,这就相当于人刚说要开始吃,还没动筷子呢,咱就问人吃饱没,那不是很不礼貌么 - 所以,咱得礼貌些,一定要在人吃完了再问。这里我们用callback回调函数的方式来问
再次执行,那指定是饱了let status = 'hungry' // 饿了 /** * @param callback {function} 吃完饭后的回调函数 * */ function eat(callback) { // 吃饭是需要花时间的,这里给个定时器模拟 setTimeout(() => { status = 'full' // 吃饱了 callback && callback() // 吃完后调用回调函数,这里对回调函数做一个判空处理 }, 500) } // 开始吃饭 eat(() => { console.log(status) // 吃饱了么? --- full })
full
,因为咱是在人家吃完以后才问的 - 我们再假设另外一种情况,如果这位先生饭量比较大,一碗饭根本不够吃,最终吃几碗能饱完全看人心情,但是我们的钱包只够吃三碗饭的,实在吃不饱也没办法了:
先不管这个人吃没吃饱,我们看代码,三碗吃饭下来,一层套一层的回调函数+条件判断,代码就显得很乱。这里逻辑还算是简单的,如果碰到复杂的,或者嵌套层数更多的情况,代码维护起来就很困难,这就是经典的回调地狱(let status = 'hungry' function eat(callback) { setTimeout(() => { // 我们给个随机数模拟吃饱的概率 if(Math.random() > 0.8) { status = 'full' } callback && callback() }, 500) } // 第一碗 eat(() => { if(status === 'full') { console.log('full at 1st') } else { // 第二碗 eat(() => { if(status === 'full') { console.log('full at 2nd') } else { // 第三碗 eat(() => { if(status === 'full') { console.log('full at 3rd') } else { // 三碗都吃不饱,没钱了,再见吧 console.log('bye') } }) } }) } })
callback hell
)问题。下面引入Promise来解决回调地狱
Promise
-
Promise是
es6
中很重要的一个概念,也是我们最常用的异步解决方案。它是一个构造函数,所以我们需要使用new
关键字来创建一个Promise:let promise = new Promise()
-
Promise译为承诺,表示承诺在未来有一个确切的答复;分别有以下三个状态,也称为状态机
pending
---- 未解决状态,也是初始状态resolved/fulfilled
---- 成功状态rejected
---- 失败状态
我们实例化一个Promise的时候,它就会进入pending状态,直到我们告诉它是成功
resolve()
还是失败reject()
,Promise才会改变状态;这里还有一点需要注意,Promise的回调默认会返回一个新的Promise,如果回调中是
return
语句,返回的Promise就是resloved
状态,如果回调中采用throw error
语法,返回的Promise则是rejected
状态的(这一点在后边讲解async/await
也会提及) -
resolve
的内容会走到then
回调中,reject
的内容会走到Promise后的第一个catch
(后边会介绍)中,不管成功失败,都会走一个finally
:// 面试 function interview() { return new Promise((resolve, reject) => { setTimeout(() => { if(Math.random() > 0.5) { resolve('success') // 面试成功 } else { reject(new Error('fail')) // 面试失败 } }, 500) }) } // 开始面试 interview() .then(res => { console.log(res) }) .catch(err => { console.log(err.message) }) .finally(() => { console.log('whatever') })
-
解决回调地狱,我们拟定三轮面试,三轮面试全部成功才算成功,否则就是失败
/** * @param round {string} 面试轮数 * */ function interview(round) { return new Promise((resolve, reject) => { setTimeout(() => { if(Math.random() > 0.5) { resolve('success') // 面试成功 } else { reject(new Error('fail at ' + round)) // 面试失败 } }, 500) }) } // 第一轮 interview('1st') .then(() => { return interview('2nd') // 第二轮 }) .then(() => { return interview('3rd') // 第三轮 }) .then(() => { console.log('success') }) .catch(err => { console.log(err.message) // 所有的reject都会走到这,因为这是所有Promise后的第一个catch })
看得出来,所有的回调变成了链式调用,错误捕捉只需要一个
catch
拦截即可,这样大大提高了代码的可读性和可维护性 -
并发异步问题:假定一个场景,我们同时面试多家公司,只有都成功了才说明咱是大牛,否则就是菜鸡;正常思维我们会开启一个计数器,面试通过就对计数器
+1
,最后等待一定时间,再通过判断计数器是否到达面试总数来决定自己是菜鸡还是大牛:/** * @param name {string} 公司名称 * */ function interview(name) { return new Promise((resolve, reject) => { setTimeout(() => { if(Math.random() > 0.5) { resolve('success') // 面试成功 } else { reject(new Error('fail at ' + name)) // 面试失败 } }, 500) }) } let count = 0; // 同时面试三家 interview('alibaba').then(() => { count++ }).catch(err => { console.log(err.message) }) interview('baidu').then(() => { count++ }).catch(err => { console.log(err.message) }) interview('tencent').then(() => { count++ }).catch(err => { console.log(err.message) }) // 等待一定时间判断是否都面试成功 setTimeout(() => { if(count === 3) { console.log('you are perfect!') } }, 600)
这是我们知道每个
Promise
的结束时间,最终等待超过最长的即可,但是如果每轮面试的时间都是未知的呢,怎么在最后做判断?往下看 -
引入
Promise.all()
解决异步并发问题,Promise.all([])
接受一个由 Promise组成的数组 作为参数,当参数中所有的Promise
都成功后才会进入Promise.all
的then
回调中,并且会将三个resolve
返回值作为数组返回给Promise.all
的then
,否则都会进入catch
回调中/** * @param name {string} 公司名称 * */ function interview(name) { return new Promise((resolve, reject) => { setTimeout(() => { if(Math.random() > 0.5) { resolve('success') // 面试成功 } else { reject(new Error('fail at ' + name)) // 面试失败 } }, 500) }) } // 同时面试三家 Promise .all([interview('alibaba'), interview('baidu'), interview('tencent')]) .then((res) => { console.log('you are perfect!') // 这里可以吧res打印出来看一下,是三个Promise返回结果组成的数组 console.log(res) // [ 'success', 'success', 'success' ] }) .catch(err => { console.log(err.message) })
这样一来,不管每个面试多长时间,我们都能清晰地判断是否都完事了
async/await
async/await
可以让我们用同步的思维去编写异步代码,被称为JS异步问题的终极解决方案async
是修饰function
的关键字,async function
其实是一个Promise
的语法糖。观察下边代码,我们执行async function
返回的结果就是一个Promise
const success = async function() { return 'success' } const fail = async function() { throw new Error('fail') } console.log(success) // [AsyncFunction: success] console.log(fail) // [AsyncFunction: fail] console.log(success()) // Promise { 'success' } console.log(fail()) // Promise { <rejected> }
await
是async functon
中的一个关键字,用来阻止后边的代码立即执行,并且可以用同步的方法获取到Promise
的执行结果
执行结果是(async function() { let status = 'hungry' await new Promise((resolve) => { setTimeout(() => { status = 'full' resolve() }, 500) }) console.log(status) // full }())
full
,但是看代码,并没有在回调中打印状态,而是直接在外部打印,并且能得到full
的状态,是因为await
关键字起了作用,它的存在让异步编程回归到了同步- 那么用
async/await
的方式来写三轮面试的方式呢
这段用同步的方式写出来的异步代码,我们拿来运行一下,就能够清晰地知道面试的过程,包括哪一轮通过,到第几轮失败。/** * @param round {string} 面试轮数 * */ function interview(round) { return new Promise((resolve, reject) => { setTimeout(() => { if(Math.random() > 0.5) { resolve('success') // 面试成功 } else { reject(new Error('fail at ' + round)) // 面试失败 } }, 500) }) } (async function() { try { let round1 = await interview('1st') console.log('round 1st', round1) let round2 = await interview('2nd') console.log('round 2nd', round2) let round3 = await interview('3rd') console.log('round 3rd', round3) } catch (e) { return console.log(e.message) } console.log('all success') }())
注意:我们正常在外部是无法使用try/catch
来捕捉Promise
返回的error
,但是使用async/await
就可以轻松在外部捕捉到
总结
优先级
介绍了三种异步解决方案,那么我们在编码过程中应该如何选择呢?
推荐优先级:async/await
> Promise
> callback
- Promise > callback
- Promise 的链式调用能够让我们用线性思维去编写代码
- Promise 能够解决 callback 方式所产生的回调地狱问题
- async/await > Promise
- async/await 允许我们用更容易理解的同步思维去编写代码
- async/await 能够通过
try/catch
来捕捉 Promise只能在catch()
中捕获的错误 - 通过async/await解决三轮面试的案例可以看出,async/await 在接受中间值方面表现得更加优秀
兼容性
因为 Promise是 es6
语法,async/await是 es8
语法,所以兼容性方面还是有所欠缺,以下是它们在 can i use 网站中的表现:
but,这些问题在我们编码的时候可以通过构建工具来解决,如 babel 可以把我们的高版本JS代码转换成浏览器能够通吃的兼容性代码
结束语
异步的问题一直是JS三座大山(原型与原型链,作用域及闭包,异步和单线程)之一,平常编码也经常碰到,希望本文能对大家有所帮助!