前言
最近在跟朋友闲聊时讨论到一个问题,同样都是异步处理,为什么setTimeout
回调抛出的异常不能被try catch
,
try {
setTimeout(() => {
throw new Error();
},0);
} catch (e) {
// 实际上并没有catch到错误
}
async
函数里await
发生的异常却可以try catch
,
async function getUserNameById(){
throw new Error()
}
async function getUserName(){
const userId=await getUserId()
const userName=await getUserNameById(userId)
return userName
}
这个问题很有意思,之前只是大家都在说因为setTimeout
里的错误被异步抛出的,我们也就这么记下了,具体发生了什么咱也不知道。
但作为一个有追求的技术人,这个case值得我们分析一下。
消息队列与事件循环
关于setTimeout,在定时器到期后执行一个函数或指定的一段代码,也就是我们所说的异步行为。
而浏览器页面是单线程架构,主线程要做的事情有很多,既要处理DOM,又要处理样式布局,以及各种事件响应,等等等等。那么主线程是怎么一个人做到处理这么多东西的呢,这就是我们今天要分析的重点。
我们的代码通常都是按我们书写的顺序执行的,就像这段代码:
代码语言:javascript复制function main() {
const sum = 1 2
const mul = 2 * 3
const div = 8 / 4
console.log({sum})
console.log({mul})
console.log({div})
}
主线程就会按顺序执行我们的代码,在所有代码执行完成后,main方法退出。
不过并不是所有的任务都是事前安排好的,大部分情况下,任务都是在主线程运行的过程中产生的,比如在主线程执行时,我们点击了一个按钮,这种情况上面的代码是无法处理的。
那怎么办?
为了能够接收用户的输入事件,我们可以写一个死循环来源源不断地读取用户的输入,比如每两个数字输入我们就计算它对应的和。通过这种形式,我们某种程度上实现了新任务的增加:
代码语言:javascript复制
function getInput() {
const input=xxx
return input
}
function main() {
while (true){
const first=getInput()
const second=getInput()
result=first second
}
}
注意,实际这种场景都是一般由底层C/C 代码来处理的(V8底层是由C 实现的),这里为了方便大家理解,我们用伪代码表示了用户键盘输入事件的接收。
在这里我们就引入了事件循环机制以及事件的概念
- 循环会一直执行,去获取底层键盘的输入,然后计算最后的结果
- 线程运行过程中,会等待用户输入数字,等待过程中线程处于暂停状态,不会处理其它任务
不过这么做并不是没有问题,我们相当于把任务的类型写死了,也没有真正能处理来自外部的其它任务。(比如点击按钮发起一个网络请求)我们能不能把任务都放在什么地方,不管是预先代码里定义的,或者是临时产生的,都放在一个约定的地方,然后主线程按顺序去取任务来执行?
当然是可以的,按先来后到顺序去取任务
这就让我们想到了数组等数据结构,不过这种场景,任务取出来后后面的其他任务就自动“往上冒”,这种场景队列更合适。
队列具有先进先出的特点,满足了我们先来的任务先处理的需求,同时我们也不需要维护该取哪个任务的索引,直接从队列头部拿就好了。这样,我们新增的任务都放到队列的尾部,主线程循环地从队列中取出任务去执行。
说了这么多,我们还是来直接上代码:
代码语言:javascript复制let event;
while ((event = getNextEvent())) {
getListeners(event).forEach((listener) => {
listener(event);
});
}
在这里getNextEvent
会返回下一个事件,如果当前没有事件要处理,就会阻塞当前线程。getListeners
会返回一系列的响应某个事件的监听器。
这就是事件循环(Event Loop)的概念,事件循环在很多系统中都有应用,Android、Chrome等等等等(想当年我还在做安卓的时候,可没少被问handler的处理机制/(ㄒoㄒ)/~~)
顺带一提,JS是单线程的,但是Chrome不是,浏览器还是会去利用机器多核心的优势去处理任务,比如有专门的下载进程等等,总不能让我们的主线程去下载东西吧~不过对我们没有影响,任何其他进程需要主线程做什么时,他们也会通过IPC机制,通过渲染进程的IO线程,把任务放进消息队列,等待主线程去处理,其他流程就跟我们上面讨论的流程一致啦~
现在我们可以解答一开始提出的问题的第一部分,我们可以总结出:
- 初始代码调用
setTimeout
只是往消息队列里添加了一个任务,
try {
const handler=() => {
throw new Error();
}
setTimeout(handler,0);
} catch (e) {
}
此时只执行了除了handler
的其它代码,之后当前任务就执行完成了 2. 等handler
实际被执行时,实际上是在下一次事件循环里面被处理的,而不是在一开始调用setTimeout
的地方,
handler()
这个时候已经没有try catch
了。
所以setTimeout
等函数外try catch
就没用。
那async/await怎么可以?
别急,听我慢慢道来~
想要了解async/await
,我们就要先了解Promise
,要了解Promise
,我们就得先了解微任务
。
使用单线程,我们可以保证只有一个线程处理UI渲染,不会出现经典的多线程的问题。不过页面使用单线程是有缺点的:
任务没有优先级,完全凭先来后到处理
微任务
由于队列先进先出的特点,我们总是要等前一个任务执行完成才能执行下一个任务,而前面的任务要执行多久我们是不知道的。比如说我们需要监听DOM
的改动然后做对应的业务逻辑处理。一般我们会设计一套通用的监听接口,通过观察者模式来处理,当产生变化时,渲染引擎同步地调用这些接口。
DOM
变化会非常频繁,如果每次变化我们都直接调用相应的监听接口,那么当前执行的任务时间会往后延长,执行效率因此受到影响。
如果把这些监听行为做成异步事件添加到消息队列的尾部,那么又会影响具体的监听的性能,我们不知道此时消息队列中有多少任务在排队,监听回调执行的时机也就不确定了。
那该如何权衡效率跟实时性呢?这个时候微任务就应运而生了,我们来看看微任务是怎么解决这个问题的。
通常消息队列中的任务都是宏任务,每个宏任务都包含一个微任务队列,在执行宏任务的过程中,如果DOM有变化,我们就把对应的事件添加到微任务列表中,这样就不会影响到宏任务的执行,然后等一个宏任务执行结束后,引擎不急着去执行下一个宏任务,会经历一个检查点(其实也就是当前宏任务的执行上下文的析构函数,在析构函数里会去检查微任务队列)此时会执行当前宏任务中的微任务,因为DOM
变化事件都保存在微任务队列中,所以既能不影响当前执行的宏任务,又能快速响应。
Promise
Promise
是基于微任务
实现的一门技术,已经在前端领域广泛使用,比如Node
的一些API
就逐渐改为返回一个Promise
了。
我们先来回顾下JS的异步编程模型,经过上面的介绍,我们应该已经非常熟悉事件循环系统了,我们把一些异步操作放进消息队列里来等待执行。不过一旦项目稍微有点复杂起来,我们就很容易使自己陷入回调地狱,代码让人凌乱。而且每个回调处理结果都要处理成功或失败这两种情况,又大大增加了代码的混乱程度。
还是通过代码来展开,我们先假设我们有个从后端接口获取数据的需求,使用xhr
来实现大致逻辑如下:
function request(){
const onResolve=(response)=>{console.log(response) }
const onReject=(error)=>{console.log(error) }
const xhr = new XMLHttpRequest()
xhr.ontimeout = function(e) { onReject(e)}
xhr.onerror = function(e) { onReject(e) }
xhr.onreadystatechange = function () { onResolve(xhr.response) }
xhr.open('Get', 'https://xxx', true);
xhr.timeout = 3000
xhr.responseType = "text"
xhr.send();
}
这里实现了很多回调以及模板代码,其实我们一般只关心请求的输入跟输出(对于网络请求来说,也就是请求的数据,成功返回数据的回调以及处理错误的回调)
代码语言:javascript复制function getRequest(url) {
return {
method: 'Get',
url,
headers: '',
body: '',
credentials: false,
sync: true,
responseType: 'text'
}
}
function fetch(url, resolve, reject) {
const request = getRequest(url)
const xhr = new XMLHttpRequest()
xhr.ontimeout = function (e) {
reject(e)
}
xhr.onerror = function (e) {
reject(e)
}
xhr.onreadystatechange = function () {
if (xhr.status === 200)
resolve(xhr.response)
}
xhr.open(request.method, request.url, request.sync);
xhr.timeout = request.timeout;
xhr.responseType = request.responseType;
xhr.send();
}
fetch(getRequest('https://xxx'),
function resolve(data) {
console.log(data)
}, function reject(e) {
console.log(e)
})
现在假设我们要访问三个接口,每个接口依赖前面一个接口返回的数据,那我们大概就会这么写:
代码语言:javascript复制fetch('https://xxx',
function resolve(response) {
console.log(response)
fetch('https://xxx',
function resolve(response) {
console.log(response)
fetch('https://xxx',
function resolve(response) {
console.log(response)
}, function reject(e) {
console.log(e)
})
}, function reject(e) {
console.log(e)
})
}, function reject(e) {
console.log(e)
})
这段代码看上去是不是很乱?
- 嵌套很多,我们在上一步的回调函数中执行了新的请求
- 每次请求都要处理获得数据以及处理异常这两种情况
这就是回调地狱
。
知道了痛点,我们处理的思路就很清晰了:
- 解决回调地狱
- 合并多个任务的错误处理
可能这么说还是有点抽象,不过Promise
已经帮我们解决了上面的问题,所以我们先来看一段Promise
代码。
引入Promise来解决问题
首先把我们的fetch
方法改为Promise
实现:
function fetch(url) {
const request=getRequest(url)
const executor=(resolve, reject) =>{
const xhr = new XMLHttpRequest()
xhr.open(request.method, request.url, request.sync)
xhr.ontimeout = function (e) { reject(e) }
xhr.onerror = function (e) { reject(e) }
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
resolve(this.responseText, this)
} else {
const error = {
code: xhr.status,
response: xhr.response
}
reject(error, this)
}
}
}
xhr.send()
}
return new Promise(executor)
}
然后我们利用新的fetch方法来处理业务请求:
代码语言:javascript复制const firstStep = fetch('https://xxx')
const secondStep = firstStep.then(value => {
console.log(value)
return fetch('https://xxx')
})
const thirdStep = secondStep.then(value => {
console.log(value)
return fetch('https://xxx')
})
thirdStep.catch(error => {
console.log(error)
})
从上面的代码中,我们可以发现:
- 在调用
fetch
时,会返回一个Promise
对象 fetch
的主要业务流程都在executor
函数中执行了- 如果
excutor
函数中的业务执行成功了,会调用resolve
函数;否则调用reject
函数。 - 在
excutor
函数中调用resolve
函数时,触发promise.then
设置的回调函数;而调用reject
函数时,触发promise.catch
设置的回调函数。
通过引入Promise
,以上逻辑就变得非常线性了,Promise
通过回调函数的延迟绑定
以及将回调函数onResolve
的返回值穿透到最外层,消灭了回调地狱,而错误会一直向后传递,直到被onReject
或者catch
处理。
Promise与微任务
我们到现在也没说明Promise
与微任务
的具体关系,两者究竟是怎么回事呢?
再来看这样一段代码:
代码语言:javascript复制function executor(resolve, reject) {
resolve(100)
}
function onResolve(value){
console.log(value)
}
const pse = new Promise(executor)
pse.then(onResolve)
对于上面这段代码,我们需要重点关注下它的执行顺序。
首先执行new Promise
时,Promise
的构造函数会被执行,接下来,Promise
参数executor
函数,然后在executor
中执行了resolve
,resolve
函数是在V8
内部实现的,那么resolve
函数到底做了什么呢?我们知道,执resolve
函数,会触发pse.then
设置的回调函数onResolve
,所以可以推测resolve
函数内部调用了通过pse.then
设置的onResolve
函数。
不过这里需要注意一下,由于Promise
采用了回调函数延迟绑定
,所以在执行resolve
函数的时候,回调函数还没有绑定,那么只能推迟回调函数的执行。
还是来看一段代码,我们下面来实现一个Promise
(毕竟手写Promise
也是很多面试的考点),我们会实现它的构造函数、resolve
方法以及then
方法,借此来瞅瞅Promise
的背后都做了啥。
function Promise(executor) {
let onResolve_ = null
let onReject_ = null
this.then = function (onResolve, onReject) {
onResolve_ = onResolve
onReject_=onReject
};
function resolve(value) {
//setTimeout(()=>{
onResolve_(value)
// },0)
}
executor(resolve, null);
}
接下来是一个很普通的调用:
代码语言:javascript复制
function executor(resolve, reject) {
resolve(100)
}
const pse = new Promise(executor)
function onResolve(value){
console.log(value)
}
pse.then(onResolve)
执行这段代码,我们发现报错了,大意是:
代码语言:javascript复制Uncaught TypeError: onResolve_ is not a function
这个错误就是延迟绑定导致的,在调用到onResolve_
函数的时候,Promise.then
还没有执行,所以执行上述代码的时候,当然会报xxx is not a function
的错了。
那怎么实现resolve
延迟调用onResolve_
呢?
我们可以在resolve
函数里面加上一个定时器,让其延时执行onResolve_
函数,就像这样:
function resolve(value) {
setTimeout(()=>{
onResolve_(value)
},0)
}
用定时器来推迟onResolve
的执行效率并不是太高,好在我们有微任务,所以在V8
把这个定时器改造成了微任务
了,这样既可以让onResolve_
延时被调用,又提升了代码的执行效率。这就是为什么大家常说Promise
是微任务的原因了。
所以async/await到底是啥
Promise
也不是万能的,如果使用不当,在then
回调里处理其它请求,也会导致代码里充斥着then
函数回调,这又会导致开发者再次陷入回调地狱的恐惧之中。
所以ES7
引入了async/await
,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力,并且使得代码逻辑更加清晰。
async function test(){
try{
const response1 = await fetch('https://xxx')
console.log('response1')
console.log(response1)
let response2 = await fetch('https://xxx')
console.log('response2')
console.log(response2)
}catch(err) {
console.error(err)
}
}
test()
我们发现整个异步处理的逻辑都是使用同步代码的方式来实现的,而且还支持try catch
来捕获异常,这就是完全在写同步代码,更加符合我们的直觉,但是这是怎么让异步代码变得跟同步代码一样的呢,有点像黑魔法。
要想了解清楚async/await
的工作原理,首先我们就要说到生成器。
生成器
函数是一个带星号函数,而且是可以暂停执行和恢复执行的。
我们可以看下面这段代码:
代码语言:javascript复制function* numGenerator() {
console.log("开始执行第一段")
yield 'generator 2'
console.log("开始执行第二段")
yield 'generator 2'
console.log("开始执行第三段")
yield 'generator 2'
console.log("执行结束")
return 'generator 2'
}
console.log('main 0')
const gen = numGenerator()
console.log(gen.next().value)
console.log('main 1')
console.log(gen.next().value)
console.log('main 2')
console.log(gen.next().value)
console.log('main 3')
console.log(gen.next().value)
console.log('main 4')
执行上面这段代码,观察输出结果,你会发现函数numGenerator
并不是一次执行完的,全局代码和numGenerator
函数交替执行。其实这就是生成器函数的特性,可以暂停执行,也可以恢复执行。
下面我们就来看看生成器函数的具体使用方式:
在生成器函数内部执行一段代码,如果遇到yield
关键字,那么V8
将返回关键字后面的内容给外部,并暂停该函数的执行。
外部函数可以通过next
方法恢复函数的执行。
关于函数的暂停和恢复,这可是闻所未闻呀!其实这种概念有点类似于线程上的协程,在一个线程上同时只有一个协程在运行,大家交替执行。
基于生成器函数出现了很多执行器框架,比如大名鼎鼎的co
,使得我们可以像在写同步代码一样写异步代码。
function* test() {
const response1 = yield fetch('https://xxx')
console.log('response1')
console.log(response1)
const response2 = yield fetch('https://xxx')
console.log('response2')
console.log(response2)
}
co(test());
到这儿,我们就大概能知道生成器的妙用了,然后JS
又引入了async/await
,使我们可以告别生成器跟执行器的写法。
现在我们可以分别说说async/await
。
async
我们先来看看async到底是什么?根据MDN
定义,async
是一个通过异步执行并隐式返回Promise
作为结果的函数。
这里需要重点关注两个词:异步执行
和隐式返回Promise
。
关于异步执行的原因,我们一会儿再分析。这里我们先来看看是如何隐式返回Promise
的。
先来看下面的代码:
代码语言:javascript复制
async function test() {
return 2
}
console.log(test()) // Promise {<resolved>: 2}
执行这段代码,我们可以看到调用async
声明的test
函数返回了一个Promise
对象,状态是resolved
,返回结果如下所示:
Promise {<resolved>: 2}
await
我们知道了async
函数返回的是一个Promise
对象,那下面我们再结合文中这段代码来看看await
到底是什么:
async function test() {
console.log(1)
const a = await 100
console.log(a)
console.log(2)
}
console.log(0)
test()
console.log(3)
观察上面这段代码,你能判断出打印出来的内容是什么吗?这得先来分析async
结合await
到底会发生什么。
首先,执行console.log(0)
这个语句,打印出来0
。
紧接着就是执行test
函数,由于test
函数是被async
标记过的,所以当进入该函数的时候,V8
会保存当前的调用栈等信息,然后执行test
函数中的console.log(1)
语句,并打印出1
。
接下来就执行到test
函数中的await 100
这个语句了,我们就把这个语句拆开,来看看V8
都做了哪些事情。
当执行到await 100
时,会默认创建一个Promise
对象,代码如下所示:
const promise_ = new Promise((resolve,reject){
resolve(100)
})
在这个promise_
对象创建的过程中,我们可以看到在executor
函数中调用了resolve
函数,引擎会将该任务提交给微任务队列。
然后V8
会暂停当前协程的执行,将主线程的控制权转交给父协程执行,同时会将promise_
对象返回给父协程。
接下来继续执行父协程的流程,这里我们执行console.log(3)
,并打印出来3
。随后父协程将执行结束,在结束之前,会进入微任务的检查点,然后执行微任务队列,微任务队列中有resolve(100)
的任务等待执行,执行到这里的时候,会触发promise_.then
中的回调函数。
该回调函数被激活以后,会将主线程的控制权交给test
函数的协程,并同时将value
值传给该协程。test
协程激活之后,会把刚才的value
值赋给了变量a
,然后test
协程继续执行后续语句,执行完成之后,将控制权归还给父协程。
解答第二个问题
讲了这么大一段,我们现在终于理解开头的第二个问题了。对于await
来说,不管最终Promise
是resolve
还是reject
,都会返回给父协程,如果父协程收到的是一个error
,那么外围的try catch
就会执行。
结语
经过今天这么一通分析,想必目前JS
的异步编程就难不倒大家了。通过一些问题的表象去一步一步挖掘底层的原理跟逻辑,着实比较有意思~
之前大家面试过程中应该都会遇到类似这样的面试题:
代码语言:javascript复制function add(x, y) {
console.log(1)
setTimeout(function() { // timer1
console.log(2)
}, 1000)
}
add(); // 调用函数
setTimeout(function() { // timer2
console.log(3)
})
new Promise(function(resolve) {
console.log(4)
setTimeout(function() { // timer3
console.log(5)
}, 100)
for(var i = 0; i < 100; i ) {
i === 99 && resolve()
}
}).then(function() {
setTimeout(function() { // timer4
console.log(6)
}, 0)
console.log(7)
})
console.log(8)
让我们说出具体的打印顺序,大家应该有不少是死记了宏任务跟微任务的执行顺序,通过现在底层原理的分析,相比我们已经能很轻松地说出来了。
Happy Coding~