两个try catch引起的对JS事件循环的思考

2022-11-07 18:43:59 浏览数 (1)

前言

最近在跟朋友闲聊时讨论到一个问题,同样都是异步处理,为什么setTimeout回调抛出的异常不能被try catch

代码语言:javascript复制
try {
    setTimeout(() => {
        throw new Error();
    },0);
} catch (e) {
    // 实际上并没有catch到错误
}

async函数里await发生的异常却可以try catch

代码语言:javascript复制
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 实现的),这里为了方便大家理解,我们用伪代码表示了用户键盘输入事件的接收。

在这里我们就引入了事件循环机制以及事件的概念

  1. 循环会一直执行,去获取底层键盘的输入,然后计算最后的结果
  2. 线程运行过程中,会等待用户输入数字,等待过程中线程处于暂停状态,不会处理其它任务

不过这么做并不是没有问题,我们相当于把任务的类型写死了,也没有真正能处理来自外部的其它任务。(比如点击按钮发起一个网络请求)我们能不能把任务都放在什么地方,不管是预先代码里定义的,或者是临时产生的,都放在一个约定的地方,然后主线程按顺序去取任务来执行?

当然是可以的,按先来后到顺序去取任务这就让我们想到了数组等数据结构,不过这种场景,任务取出来后后面的其他任务就自动“往上冒”,这种场景队列更合适。

队列具有先进先出的特点,满足了我们先来的任务先处理的需求,同时我们也不需要维护该取哪个任务的索引,直接从队列头部拿就好了。这样,我们新增的任务都放到队列的尾部,主线程循环地从队列中取出任务去执行。

说了这么多,我们还是来直接上代码:

代码语言:javascript复制
let event;
while ((event = getNextEvent())) {
  getListeners(event).forEach((listener) => {
    listener(event);
  });
}

在这里getNextEvent会返回下一个事件,如果当前没有事件要处理,就会阻塞当前线程。getListeners会返回一系列的响应某个事件的监听器。

这就是事件循环(Event Loop)的概念,事件循环在很多系统中都有应用,Android、Chrome等等等等(想当年我还在做安卓的时候,可没少被问handler的处理机制/(ㄒoㄒ)/~~)

顺带一提,JS是单线程的,但是Chrome不是,浏览器还是会去利用机器多核心的优势去处理任务,比如有专门的下载进程等等,总不能让我们的主线程去下载东西吧~不过对我们没有影响,任何其他进程需要主线程做什么时,他们也会通过IPC机制,通过渲染进程的IO线程,把任务放进消息队列,等待主线程去处理,其他流程就跟我们上面讨论的流程一致啦~

现在我们可以解答一开始提出的问题的第一部分,我们可以总结出:

  1. 初始代码调用setTimeout只是往消息队列里添加了一个任务,
代码语言:javascript复制
try {
    const handler=() => {
        throw new Error();
    }
    setTimeout(handler,0);
} catch (e) {

}

此时只执行了除了handler的其它代码,之后当前任务就执行完成了 2. 等handler实际被执行时,实际上是在下一次事件循环里面被处理的,而不是在一开始调用setTimeout的地方,

代码语言:javascript复制
handler()

这个时候已经没有try catch了。

所以setTimeout等函数外try catch就没用。

那async/await怎么可以?

别急,听我慢慢道来~

想要了解async/await,我们就要先了解Promise,要了解Promise,我们就得先了解微任务

使用单线程,我们可以保证只有一个线程处理UI渲染,不会出现经典的多线程的问题。不过页面使用单线程是有缺点的:

任务没有优先级,完全凭先来后到处理

微任务

由于队列先进先出的特点,我们总是要等前一个任务执行完成才能执行下一个任务,而前面的任务要执行多久我们是不知道的。比如说我们需要监听DOM的改动然后做对应的业务逻辑处理。一般我们会设计一套通用的监听接口,通过观察者模式来处理,当产生变化时,渲染引擎同步地调用这些接口。

DOM变化会非常频繁,如果每次变化我们都直接调用相应的监听接口,那么当前执行的任务时间会往后延长,执行效率因此受到影响。

如果把这些监听行为做成异步事件添加到消息队列的尾部,那么又会影响具体的监听的性能,我们不知道此时消息队列中有多少任务在排队,监听回调执行的时机也就不确定了。

那该如何权衡效率跟实时性呢?这个时候微任务就应运而生了,我们来看看微任务是怎么解决这个问题的。

通常消息队列中的任务都是宏任务,每个宏任务都包含一个微任务队列,在执行宏任务的过程中,如果DOM有变化,我们就把对应的事件添加到微任务列表中,这样就不会影响到宏任务的执行,然后等一个宏任务执行结束后,引擎不急着去执行下一个宏任务,会经历一个检查点(其实也就是当前宏任务的执行上下文的析构函数,在析构函数里会去检查微任务队列)此时会执行当前宏任务中的微任务,因为DOM变化事件都保存在微任务队列中,所以既能不影响当前执行的宏任务,又能快速响应。

Promise

Promise是基于微任务实现的一门技术,已经在前端领域广泛使用,比如Node的一些API就逐渐改为返回一个Promise了。

我们先来回顾下JS的异步编程模型,经过上面的介绍,我们应该已经非常熟悉事件循环系统了,我们把一些异步操作放进消息队列里来等待执行。不过一旦项目稍微有点复杂起来,我们就很容易使自己陷入回调地狱,代码让人凌乱。而且每个回调处理结果都要处理成功或失败这两种情况,又大大增加了代码的混乱程度。

还是通过代码来展开,我们先假设我们有个从后端接口获取数据的需求,使用xhr来实现大致逻辑如下:

代码语言:javascript复制
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)
    })

这段代码看上去是不是很乱?

  • 嵌套很多,我们在上一步的回调函数中执行了新的请求
  • 每次请求都要处理获得数据以及处理异常这两种情况

这就是回调地狱

知道了痛点,我们处理的思路就很清晰了:

  1. 解决回调地狱
  2. 合并多个任务的错误处理

可能这么说还是有点抽象,不过Promise已经帮我们解决了上面的问题,所以我们先来看一段Promise代码。

引入Promise来解决问题

首先把我们的fetch方法改为Promise实现:

代码语言:javascript复制
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中执行了resolveresolve函数是在V8内部实现的,那么resolve函数到底做了什么呢?我们知道,执resolve函数,会触发pse.then设置的回调函数onResolve,所以可以推测resolve函数内部调用了通过pse.then设置的onResolve函数。

不过这里需要注意一下,由于Promise采用了回调函数延迟绑定,所以在执行resolve函数的时候,回调函数还没有绑定,那么只能推迟回调函数的执行。

还是来看一段代码,我们下面来实现一个Promise(毕竟手写Promise也是很多面试的考点),我们会实现它的构造函数、resolve方法以及then方法,借此来瞅瞅Promise的背后都做了啥。

代码语言:javascript复制
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_函数,就像这样:

代码语言:javascript复制
function resolve(value) {
       setTimeout(()=>{
           onResolve_(value)
         },0)
 }

用定时器来推迟onResolve的执行效率并不是太高,好在我们有微任务,所以在V8把这个定时器改造成了微任务了,这样既可以让onResolve_延时被调用,又提升了代码的执行效率。这就是为什么大家常说Promise是微任务的原因了。

所以async/await到底是啥

Promise也不是万能的,如果使用不当,在then回调里处理其它请求,也会导致代码里充斥着then函数回调,这又会导致开发者再次陷入回调地狱的恐惧之中。

所以ES7引入了async/await,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力,并且使得代码逻辑更加清晰。

代码语言:javascript复制
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,使得我们可以像在写同步代码一样写异步代码。

代码语言:javascript复制

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,返回结果如下所示:

代码语言:javascript复制
Promise {<resolved>: 2}

await

我们知道了async函数返回的是一个Promise对象,那下面我们再结合文中这段代码来看看await到底是什么:

代码语言:javascript复制

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对象,代码如下所示:

代码语言:javascript复制

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来说,不管最终Promiseresolve还是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~

0 人点赞