前言
本文章源自《JavaScript知识小册》专栏,感兴趣的话还请关注点赞收藏.
上一篇文章:《JavaScript令人心烦的this》
异步
JavaScript是单线程语言,代表着在同一时间内只能处理一件事情。
跟Java
等支持多线程的语言天然不一样,多线程编程会有并发、锁、线程池等概念和知识,而JS
单线程反而简单许多,而也正是因为这个单线程特性,JS
非常依赖异步回调,比如网络请求的时候,不能老老实实在原地等着网络请求响应,而是发起网络请求后马上跑去处理别的事,等到网络请求响应了之后再回过头来处理响应。[像极了那为生活不停奔波劳碌的你我]
Tips: 浏览器或node.js环境都支持新建JS进程,比如Web Worker。但也只是多进程,并没有改变JavaScript单线程的本质。
异步场景
JavaScript
中异步的应用场景主要有以下
- 网络请求
- 定时任务
setTimeout
和setInterval
- 图片加载
[可以获取img标签,并设置onload属性为某个函数,那么等到img标签加载完图片后,这个回调会被自动调用]
callback
最简单的异步,通过回调函数
的方式来实现
javascript
setTimeout(() => {console.log(123)}, 10)
console.log(456)
这里用setTimeout
提交了一个输出123的函数,在10毫秒后就会执行。JS
在执行代码的时候会经历如下步骤
- 执行
setTimeout
提交任务,不会原地傻傻等待,而是立马跑去处理下一行代码
- 输出456
- 10毫秒过后,回调输出123
callback hell
异步是基于回调函数
的方式来实现,但如果是遇到强调执行顺序的多重异步
的情况下,则会产生回调地狱
。比如像下面的同时发起多个网络请求,上一个请求的响应作为本次发起请求的参数这样,多层回调函数嵌套很容易把人绕晕
javascript
function post(url, data, callback) {
// 伪代码,发起网络请求
const res = http(url, data)
callback(res)
}
post('https://xxxx/login', {phone: '135xxxxxx'}, (res) => {
let {userId} = res
post('https://xxxx/userInfo', {userId}, (res) => {
let {userName, shopCartList} = res
post('https://xxxx/order', {userId, userName, shopCartList}, (res) => {
alert('购买成功')
})
})
})
Promise
Promise
是解决Callback hell
的一种方式,可以让开发从回调函数
的层层嵌套中解放出来,变成是一种链式调用的开发方式,即原先像剥洋葱似的得一层层剥,而现在则像是管道排水似的,一节节管道连成一条线就可以排水了。
上边的回调地狱换成使用Promise
的方式则为如下形式
javascript
function post(url, data) {
return new Promise((resolve, reject) => {
try {
// 伪代码,发起网络请求
const res = http(url, data)
// 成功之后调用resolve()并将请求响应res作为结果返回,触发then()
resolve(res)
} catch (e) {
console.log(e)
// 失败调用reject()并将错误e作为参数传递,触发catch()
reject(e)
}
})
}
post('https://xxxx/login', {phone: '135xxxxxx'})
.then((res) => {
let {userId} = res
return post('https://xxxx/userInfo', {userId})
})
.then((res) => {
let {userName, shopCardList} = res
return post('https://xxx/order', {userId, userName, shopCardList})
}).then(res => {
alert('下单成功')
}).catch(e => {
alert(`购买出错 ${e}`)
})
可以看到代码没有了之前的一层层嵌套,而是在返回的Promise
对象后面不断地then()
处理下一步业务,catch()
处理发生的异常,形成了一条线过的链式调用
resolve返回
当Promise
调用resolve
时传入的值为普通对象或基本类型时,那么then
里收到的参数是原模原样的值
javascript
function http(){
return new Promise((resolve, reject) => {
setTimeout(() => resolve(100), 10) //
})
}
http().then((res) => console.log(res))
输出100
同时还可以往resolve
里传入Promise
scss
function http() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(
// 传递给resolve的依然是个Promise,最后返回的依然是resolve(100),如果这里只是resolve(new Promise(...)),没有再继续调用resolve(...),那么then(res)中的res不存在值
new Promise((resolve, reject) => {
resolve(100)
})
), 10)
})
}
http().then((res) => console.log(res))
输出100
then返回
then
函数中的返回值跟上边resolve
差不多
javascript
http().then((res) => {
console.log(`res is ${res}`)
return 1 // 返回普通的值
}).then((res) => {
console.log(`res is ${res}`) // 输出1
return new Promise((resolve, reject) => { // 返回Promise
setTimeout(() => resolve(2), 10)
})
}).then((res) => {
console.log(`res is ${res}`) // 输出2,也就是resolve(2)的结果
return // 无返回
}).then((res) => {
console.log(`res is ${res}`) // 输出undefined
})
依次输出 res is 100
res is 1
res is 2
res is undefined
三种状态
Promise
存在三种状态,分别是pending
resolved
rejected
最初始的状态是pending
javascript
const p = new Promise((resolve, reject) => {
})
console.log(p)
输出
调用resolve
方法可以使Promise
对象转变为pending
状态
javascript
const p = new Promise((resolve, reject) => {
resolve(1)
})
console.log(p)
输出
调用reject
方法使Promise
对象转变为rejected
状态
javascript
const p = new Promise((resolve, reject) => {
reject()
})
console.log(p)
输出
需要注意的是,Promise状态转变只会有一次,且不可逆
,只能从pending
转为resolved
或者转为rejected
,而不会是从pending
转为resolved
,再转为rejected
状态和回调之间的关系
Promise
不同状态和后续回调函数的关系如下
pending
状态下,不会触发then
或catch
回调resolved
状态,才会触发后续的then
回调rejected
状态,才会触发后续的catch
回调,若没有catch
回调,则会直接抛出错误
then catch连接
使用Promise
时then
和catch
各种连接使用也是个比较有意思的点
最基本的then
和catch
组合
javascript
Promise.resolve().then(() => console.log(1))
.catch(() => console.log(-1))
.then(() => console.log(2))
输出1 2
,catch
回调没有被触发,没有输出-1
,这是因为在catch
前的then
回调函数执行时没有产生错误
稍作改动,在第一个then
回调函数中抛出自定义异常
javascript
Promise.resolve().then(() => {
console.log(1)
throw new Error("custom err")
})
.catch((e) => {
console.log(-1)
console.log(e) // 输出Error: custom err
})
.then(() => console.log(2))
输出1 -1 Error: custom err 2
那么如果在catch
里再次抛出异常的话
javascript
Promise.resolve().then(() => {
console.log(1)
throw new Error("custom err")
})
.catch((e) => {
console.log(-1)
throw e
})
.then(() => console.log(2))
.catch((e) => {
console.log(3)
console.log(e)
})
输出1 -1 3 Error: custom err
,因为第一个catch
中再次抛异常了,所以紧跟着的then
不会被执行,反而是第二个catch
中的代码被执行了
从这里几个示例其实可以总结出这么一个概念,调用Promise.resolve()
我们可以获取到执行结果,并在后紧跟着调用then
进行下一步业务处理,或者catch
进行异常处理。然后在then
方法之后还可以继续跟着then
或者catch
,然后还可以无限地继续跟着then
和catch
。那么可以理解为,每个then或者catch返回的还是一个Promise对象
可能有点太抽象,换代码说话
javascript
Promise.resolve().then(() => {
console.log(1)
}).then(() => console.log(2))
其实等同于
javascript
Promise.resolve().then(() => {
console.log(1)
return Promise.resolve()
}).then(() => {
console.log(2)
return Promise.resolve()
})
接着
javascript
Promise.resolve().then(() => {
console.log(1)
throw new Error('custom err')
}).then(() => {
console.log(2)
}).catch((e) => {
console.log(-1)
console.log(e)
})
其实等同于
javascript
Promise.resolve().then(() => {
console.log(1)
return Promise.reject('custom err')
}).then(() => {
console.log(2)
}).catch((e) => {
console.log(-1)
console.log(e)
})
给出一长串Promise
try
catch
连接,然后考察哪些代码会执行,哪些代码不会执行。这种在面试的时候很常见,但只要深入掌握Promise
的话也不难。
只要记住,
- 若上一个
then
执行代码没抛出异常,那么这个then
就会执行。 - 若上一个
then
执行过程中抛出异常了,这个then
不会被执行,而是执行catch
,如果没catch
,那么就会抛出Uncaught (in promise) xxx
- 若
catch
中继续抛出异常,那么下一个catch
会被触发。(如果有下一个catch
的话) - 若
catch
中没有抛出异常,那么是这个catch
后紧跟的then
被触发,而下一个catch
不会被触发
async/await
基于Callback
的异步回调,因为回调地狱
的存在,所以有了Promise
来优化,但Promise
也是基于回调函数,虽然链式调用then
catch
让回调处理平铺开来,但还是要传递一个个回调函数,而async await
则在于它让开发者在编写异步处理的时候,就好像在写同步代码一样,忘记异步回调这回事,当然async await
也是基于Promise
的,只不过是语法糖
而已。重点在于能让我们像写"同步"代码一样处理异步逻辑,但本质上"它不是真正的同步",因为JavaScript是单线程的,所以异步回调永远都存在
async
和await
是配对使用的,不能单独使用。async
算是 function
的一个修饰符,表示这个方法内的代码会涉及到异步,然后await
则用在被async
修饰的function
中,用在方法体中调用的异步或同步方法 前,表示等待该方法执行完毕,再执行下一步操作
javascript
function http() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(100)
}, 500)
})
}
async function doHttp() {
console.log('start')
const res = await http()
console.log(`res is ${res}`)
}
doHttp()
先是输出start
,然后等了一下输出res is 100
可以看到不再像之前那样写上then
catch
回调才能对Promise
返回做处理,整个过程就好像是在写console.log(1); console.log(2); console.log(3)
似的,整体代码都是从上往下一行行执行,即便存在异步也是如此
async/await 和Promise的关系
async
await
并不是来彻底取代Promise
的,相反它其实依赖于Promsie
首先用async
修饰的函数,返回的是Promise
对象
javascript
async function print(){}
console.log(print())
用async
修饰的print
方法,即便里面没有任何代码,但执行print
方法后,输出的是处于resolved
状态的Promise
对象
即便在print
中手动return 100
javascript
async function print(){
return 100
}
console.log(print())
输出也还是个状态为resolved
的Promise
对象
而且再看如下代码,print()
后面一样可以紧跟then
catch
javascript
async function print() {
return 100 // 等同于 return Promise.resolve(100)
}
print().then((res) => console.log(res)) // 输出100
await
相当于等待Promise
执行完成,也就相当于then
,具体看如下代码
javascript
async function print() {
return 100
}
// 定义匿名函数并立即调用
(async function(){
const val = await print() // 调用一个返回Promise对象的方法,并等到Promise状态变为resolved才会执行后续代码,当然这里并不会乖乖地等,因为JavaScript是单线程的,在Promise还没有变为resolved前,JavaScript会跑去执行别的任务
console.log(val)
})() // 输出100
当执行到await print()的时候,会等待print()执行完成,且返回的Promise状态为resolved时,const val = await print()的变量定义及赋值操作 和 后续代码才会继续执行,所以说await 相当于Promise.resolve(100).then((res) => {const val = res; console.log(res)})
如果不加await
,你会发现不等Promise
完成,JavaScript就立即执行完所有代码,并且此时val不是明确的100,而是一个Promise
对象
javascript
async function print() {
return 100
}
(async function(){
const val = print() // 取消await
console.log(val)
})()
输出
还有一点是await
后面不一定非要是Promise
对象,当然如果不是Promise
对象,也没有使用await
的必要
javascript
(async function () {
const val = await 100 // 可以变相当做是await Promise.resolve(100)
console.log(val) // 输出100
})()
try/catch
使用Promise
时紧跟catch
来进行异常捕获,async/await
同样也是使用catch
进行异常捕获,不过方式就要稍微变一下
javascript
async function print() {
throw new Error("err:!00")
return 100
}
(async function () {
try {
const val = await print() // await 相当于Promise.then,而因为print()抛出异常,所以是返回状态为rejected的Promise对象,因此const val = ... 这句代码都不会执行完,后续代码更不会执行
console.log(val)
} catch (e) {
console.log(e) // 使用try{...}catch(e){...} 来进行异常捕获
}
})()
Event Loop
Event Loop
即事件循环
,它其实就是JavaScript
异步回调的原理,也就是有了这么一套机制才保证了JavaScript
在遇到网络请求等异步操作时,不会傻傻原地等待[低性能]
,而是跑去执行别的任务,晚点再回过头来处理异步任务的返回[高性能]
首先是最简单的同步代码执行
arduino
console.log(1)
console.log(2)
console.log(3)
在浏览器环境中代码执行过程大致如下,JavaScript
执行代码过程是一行行执行的,当JavaScript
遇到console.log(1)
这行代码时,会将其推入到Call Stack
中进行执行,执行完毕则浏览器控制台相应输出1
,当console.log(1)
执行完毕后,调用栈将会被清空,接着是第2行代码入栈,执行,输出,出栈,第3行...以此类推
当遇到setTimeout
,setInterval
这种异步任务的时候,就没有这么简单了
javascript
console.log(1)
setTimeout(() => console.log('callback'), 20)
console.log(2)
console.log(3)
同步代码执行过程如上,不再解释。在执行到setTimeout()
的时候,的确也是把setTimeout()
放入到调用栈中执行,但是因为setTimeout
的回调需要20毫秒后才触发,所以在执行完setTimeout
这行代码的同时,会将回调当做一个晚点触发的任务放到另一个待处理队列中
,JavaScript
在20毫秒这段期间内,会继续执行console.log(2) console.log(3)
,直到20毫秒结束,setTimeout
的回调需要被触发了,JavaScript
才会真正执行回调函数,浏览器控制台输出callback
Tips: 因为setTimeout,setInterval,DOM事件监听是浏览器环境才有的,所以Web APIS可以理解为是另一个调用栈,是存放浏览器定义的相关API的执行任务
不过到这里其实也还是简略版
的异步同步调用机制,接下来便是引出Event Loop
。
其实在20毫秒后需要触发setTimeout
回调时,会将该回调当做一个任务添加到Callback Queue[回调队列]
中等待执行,当JavaScript
执行完所有同步代码之后,会看看Callback Queue
是否存在待处理任务,存在的话,把任务取出来放入到Call Stack
中执行,最后浏览器输出callback
。而这个过程则是依赖于EventLoop这个机制[重点:Event Loop不是指某个对象,某个新API,某个语法糖,而是一套机制]
也就是说在执行同步异步代码整个过程中会经历如下步骤
- 执行同步代码,放入
Call Stack
,执行完成结束 - 执行异步代码,放入
Call Stack
,因为是异步,所以延迟执行,定时执行,网络请求之类的回调,会将其放入另一个队列中[Promise这种不是说也是放入WebAPIs这个队列,可以理解为异步任务回调会被存放到某个地方,等待后续调用,要明白这个概念,而不是纠结具体放到哪里去]
- 异步任务回调需要被触发时,会将其作为一个任务放到
Callback Queue
中进行排队等待 [Event Loop]机制
:当JavaScript
执行完所有同步代码后,会查看Callback Queue
是否存在待处理任务,有的话则会将其取出来放入Call Stack
中进行执行
Tips:Event Loop这种机制,其实跟网络请求的IO多路复用类似,若是熟悉Java网络编程的话,会知道Netty中有个类叫做NioEventLoop,这两个前端后端Event Loop 机制其实都是一样的
宏任务/微任务
宏任务macroTask
微任务microTask
是对Callback Queue
中的任务的一个类型区分。
- 宏任务包括:
setTimeout
,setInterval
,Ajax
,DOM事件
- 微任务包括:
Promise
async/await
Tips:
微任务执行的时机要比宏任务更早。
javascript
setTimeout(() => console.log(123), 20) // 宏任务
console.log(789) // 同步代码
Promise.resolve().then(() => console.log(456)) // 微任务
以上代码中,微任务Promise
要早于宏任务setTimeout
执行,但是因为两者都涉及异步回调,所以输出为
789
456
123
微任务先于宏任务执行理由
首先进一步引申出整个事情的全貌,JavaScript是单线程的,在浏览器环境中不能只执行JavaScript代码,DOM渲染也依赖于JavaScript线程,所以在EventLoop触发前,还存在DOM渲染
,也就是在Call Stack
和Callback Queue
之间还存在DOM渲染
需要处理
大体流程如下:
- 执行完所有同步代码,清空
Call Stack
- 如果需要重新渲染
DOM
的话,渲染DOM
- 查看
Callback Queue
是否存在任务,存在则执行 - 再次执行
Call Stack
中的入栈任务 - 如此往复...无尽循环...
在引入了DOM渲染时机
之后,这里可以进一步细分宏任务
和微任务
间执行循序的区别,那就是微任务是在DOM渲染前触发,宏任务是在DOM渲染后触发
而具体原理则还得引出另一个队列Micro Task Queue[微任务对列]
。
像setTimeout
setInterval
等这种宏任务,其实是浏览器提供的API,所以这些异步任务会被放入WebAPIs
中等待,等到时机成熟后会将任务放入Callback Queue
中,等待EventLoop
机制触发,然后将入队任务取出并执行。
而Promise
不是浏览器规范提供的,跟浏览器内核也没啥关系,所以Promise
任务不会被放入WebAPIs队列
中,更不会被放入Callback Queue
,而是单独有一个Micro Callback Queue
存放,而执行Micro Callback Queue
中任务的时机,正好在DOM渲染
前,所以这也是为什么微任务执行永远早于宏任务
Tips: 微任务是ES6语法规定的,宏任务是浏览器规定的,两者算是不同体系,所以两种类型的任务会放在不同的队列