JavaScript微任務與宏任務(瀏覽器)
問題描述
最近在用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並不適用。