JavaScript微任务与宏任务(浏览器)

mattuy 2020年02月01日 443次浏览

问题描述

最近在用Ionic框架(基于Angular),有这么一个需求:
先调用history.go(-delta)返回到某个页面,再调用Angular的Router#navigate()导航到新的页面。

大致代码如下:

go(delta, url) {
    history.go(delta)
    this.router.navigateByUrl(url, { replaceUrl: true })
}

问题出来了,代码执行后只返回到了之前的页面,并没有正确导航到url参数指定的页面。

原因大概是,Angular监听了popstate事件以响应浏览器的回退操作。然而浏览器的事件触发是异步的,即history.go(delta)执行后,要等到下一轮事件循环Angular才能捕捉到回退事件,并渲染对应的组件。然而这时router.navigateByUrl()已经执行完毕,因此看到的是回退后的页面而不是希望导航到的新页面。

原理分析

JavaScript的事件循环主要是靠两个队列:宏任务(macro task)队列和微任务(micro task)队列。浏览器按下面的顺序执行队列中的任务:

macro task 1  
    micro task 1  
    micro task 2
    micro task 3
    ......
macro task 2
    micro task 1
    micro task 2
    micro task 3
......

即,在执行完宏任务后必须执行完当前的所有微任务才能执行下一个宏任务。而浏览器环境下,UI事件是宏任务,Promise#then()是微任务。

当go()函数被执行时,history.go(delta)执行后,popstate事件被推入宏任务队列,然后执行navigationByUrl()。Angular的路由导航也是异步的,但在没有路由守卫的情况下,navigateByUrl()函数发起的整个工作流程都只是微任务(可以认为是一连串立即被resolve的Promise),所以在下一个宏任务被读取之前,新页面的导航已经先于回退操作完成。

go函数执行过程如下:

go
    history.go()
        宏任务队列推入popstate事件
    navigateByUrl()
        微任务队列推入NavigationStart
        ...
...
微任务队列弹出NavigationStart
...
微任务队列推入NavigationEnd
...
微任务队列弹出NavigationEnd,新页面导航完成
...
微任务队列空
...
宏任务弹出popstate事件,解析history.go()回退到的url
渲染回退到的页面

解决方法

知道了原理就好解决问题了。只需要等到下一次宏任务再执行新页面的导航就好了。

go(delta, url) {
    history.go(delta)
    setTimeout(() => {
        this.router.navigateByUrl(url, { replaceUrl: true })
    }, 0)
}

注意,setTimeout和setInterval函数创建的都是宏任务。另外,这里说的都是浏览器环境下的JavaScript,部分说法对nodejs并不适用。