首頁 >web前端 >js教程 >前端進階(十二):詳解事件循環機制

前端進階(十二):詳解事件循環機制

PHPz
PHPz原創
2017-04-04 17:59:251875瀏覽

前端進階(十二):詳解事件循環機制

Event Loop

JavaScript的學習零散而龐雜,因此很多時候我們學到了一些東西,但是卻沒辦法感受到自己的進步,甚至過了不久,就把學到的東西給忘了。為了解決自己的這個困擾,在學習的過程中,我一直試著在尋找一條核心的線索,只要我根據這條線索,我就能夠一點一點的進步。

前端基礎進階正是圍繞這條線索慢慢展開,而事件循環機制(Event Loop),則是這條線索的最關鍵的知識點。所以,我就馬不停蹄的去深入的學習了事件循環機制,並總結出了這篇文章跟大家分享。

事件循環機制從整體上的告訴了我們所寫的JavaScript程式碼的執行順序。但是在我學習的過程中,找到的許多國內部落格文章對於它的講解淺嚐輒止,不得其法,很多文章在圖中畫個圈就表示循環了,看了之後也沒感覺明白了多少。但他又如此重要,以致於當我們想要面試中高階職位時,事件循環機制總是繞不開的話題。特別是ES6中正式加入了Promise物件之後,對於新標準中事件循環機制的理解就變得更加重要。這就很尷尬了。

最近有兩篇比較火的文章也表達了這個問題的重要性。

這個前端面試在搞事
80% 應徵者都不及格的JS 面試題

但是很遺憾的是,大神們告訴了大家這個知識點很重要,卻並沒有告訴大家為什麼會這樣。所以當我們在面試時遇到這樣的問題時,就算你知道了結果,面試官再進一步問一下,我們依然懵逼。

在學習事件循環機制之前,我默認你已經懂得如下概念,如果仍然有疑問,可以回過頭去看看我以前的文章。

  • 執行上下文(Execution context)

  • #函數呼叫堆疊(call stack)

  • 佇列資料結構(queue)

  • #Promise(我會在下一篇文章專門總結Promise的詳細使用與自訂封裝)

因為chrome瀏覽器中新標準中的事件循環機制與nodejs幾乎一樣,因此此處就以整合nodejs一起來理解,其中會介紹到幾個nodejs有,但是瀏覽器中沒有的API,大家只需要了解就好,不一定要知道她是如何使用。例如process.nextTick,setImmediate

OK,那我就先拋出結論,然後以例子與圖示詳細給大家示範事件循環機制。

  • 我們知道JavaScript的一大特點就是單線程,而這個線程中擁有唯一的一個事件循環。

    當然新標準中的web worker涉及到了多線程,我對它了解也不多,這裡就不討論了。

  • JavaScript程式碼的執行過程中,除了依靠函數呼叫堆疊來搞定函數的執行順序外,還依靠任務佇列(task queue)來搞定另外一些程式碼的執行。

前端進階(十二):詳解事件循環機制

佇列資料結構

  • #一個執行緒中,事件循環是唯一的,但是任務隊列可以擁有多個。

  • 任務佇列又分為macro-task(巨集任務)與micro-task(微任務),在最新標準中,它們分別稱為task與jobs。

  • macro-task大概包含:script(整體程式碼), setTimeout, setInterval, setImmediate, I/O, UI r#endering。

  • micro-task大概包含: process.nextTick, Promise, Object.observe(已廢棄), MutationObserver(html5新特性)

  • setTimeout/Promise等我們稱之為任務來源。而進入任務隊列的是他們指定的具體執行任務。

    // setTimeout中的回调函数才是进入任务队列的任务
    setTimeout(function() {
      console.log('xxxx');
    })
  • 來自不同任務來源的任務會進入到不同的任務佇列。其中setTimeout與setInterval是同源的。

  • 事件迴圈的順序,決定了JavaScript程式碼的執行順序。它從script(整體程式碼)開始第一次循環。之後全域上下文進入函數呼叫堆疊。直到呼叫堆疊清空(只剩下全域),然後執行所有的micro-task。當所有可執行的micro-task執行完畢之後。迴圈再次從macro-task開始,找到其中一個任務佇列執行完畢,然後再執行所有的micro-task,這樣就一直循環下去。

  • 其中每一個任務的執行,無論是macro-task或micro-task,都是藉由函數呼叫堆疊來完成。

純文字表述確實有點乾澀,因此,這裡我們透過2個例子,來逐步理解事件循環的具體順序。

// demo01  出自于上面我引用文章的一个例子,我们来根据上面的结论,一步一步分析具体的执行过程。
// 为了方便理解,我以打印出来的字符作为当前的任务名称
setTimeout(function() {
    console.log('timeout1');
})

new Promise(function(resolve) {
    console.log('promise1');
    for(var i = 0; i < 1000; i++) {
        i == 99 && resolve();
    }
    console.log('promise2');
}).then(function() {
    console.log('then1');
})

console.log('global1');

首先,事件循環從巨集任務佇列開始,這個時候,在巨集任務佇列中,只有一個script(整體程式碼)任務。每一個任務的執行順序,都依賴函數呼叫堆疊來搞定,而當遇到任務來源時,則會先分發任務到對應的佇列中去,所以,上面例子的第一步執行如下圖所示。

前端進階(十二):詳解事件循環機制

首先script任務開始執行,全域上下文入堆疊

第二步:script任務執行時首先遇到了setTimeout,setTimeout為一個巨集任務來源,那麼他的作用就是將任務分發到它對應的佇列中。

setTimeout(function() {
    console.log('timeout1');
})

前端進階(十二):詳解事件循環機制

巨集任務timeout1進入setTimeout佇列

第三步:script執行時遇到Promise實例。 Promise建構子中的第一個參數,是在new的時候執行,因此不會進入任何其他的佇列,而是直接在當前任務直接執行了,而後續的.then則會被分發到micro-task的Promise隊列中去。

因此,當建構函式執行時,裡面的參數會進入函式呼叫堆疊執行。 for循環不會進入任何佇列,因此程式碼會依序執行,所以這裡的promise1和promise2會依序輸出。

前端進階(十二):詳解事件循環機制

promise1入棧執行,這時promise1被最先輸出

前端進階(十二):詳解事件循環機制

resolve在在循環中入棧執行

前端進階(十二):詳解事件循環機制

建構子執行完畢的過程中,resolve執行完畢出棧,promise2輸出,promise1頁出棧,then執行時, Promise任務then1進入對應佇列

script任務繼續往下執行,最後只有一句輸出了globa1,然後,全域任務就執行完畢了。

第四步:第一個巨集任務script執行完畢之後,就開始執行所有的可執行的微任務。這時候,微任務中,只有Promise佇列中的一個任務then1,因此直接執行就行了,執行結果輸出then1,當然,他的執行,也是進入函數呼叫堆疊中執行的。

前端進階(十二):詳解事件循環機制

執行所有的微任務

第五步:當所有的micro-tast執行完畢之後,表示第一輪的迴圈就結束了。這時候就得開始第二輪的循環。第二輪循環仍然從巨集任務macro-task開始。

前端進階(十二):詳解事件循環機制

微任務被清空

這個時候,我們發現巨集任務中,只有在setTimeout佇列中還要一個timeout1的任務等待執行。因此就直接執行即可。

前端進階(十二):詳解事件循環機制

timeout1入棧執行

這時候宏任務佇列與微任務佇列都沒有任務了,所以程式碼就不會再輸出其他東西了。

那麼上面這個範例的輸出結果就顯而易見。大家可以自行嘗試體會。

这个例子比较简答,涉及到的队列任务并不多,因此读懂了它还不能全面的了解到事件循环机制的全貌。所以我下面弄了一个复制一点的例子,再给大家解析一番,相信读懂之后,事件循环这个问题,再面试中再次被问到就难不倒大家了。

// demo02
console.log('golb1');

setTimeout(function() {
    console.log('timeout1');
    process.nextTick(function() {
        console.log('timeout1_nextTick');
    })
    new Promise(function(resolve) {
        console.log('timeout1_promise');
        resolve();
    }).then(function() {
        console.log('timeout1_then')
    })
})

setImmediate(function() {
    console.log('immediate1');
    process.nextTick(function() {
        console.log('immediate1_nextTick');
    })
    new Promise(function(resolve) {
        console.log('immediate1_promise');
        resolve();
    }).then(function() {
        console.log('immediate1_then')
    })
})

process.nextTick(function() {
    console.log('glob1_nextTick');
})
new Promise(function(resolve) {
    console.log('glob1_promise');
    resolve();
}).then(function() {
    console.log('glob1_then')
})

setTimeout(function() {
    console.log('timeout2');
    process.nextTick(function() {
        console.log('timeout2_nextTick');
    })
    new Promise(function(resolve) {
        console.log('timeout2_promise');
        resolve();
    }).then(function() {
        console.log('timeout2_then')
    })
})

process.nextTick(function() {
    console.log('glob2_nextTick');
})
new Promise(function(resolve) {
    console.log('glob2_promise');
    resolve();
}).then(function() {
    console.log('glob2_then')
})

setImmediate(function() {
    console.log('immediate2');
    process.nextTick(function() {
        console.log('immediate2_nextTick');
    })
    new Promise(function(resolve) {
        console.log('immediate2_promise');
        resolve();
    }).then(function() {
        console.log('immediate2_then')
    })
})

这个例子看上去有点复杂,乱七八糟的代码一大堆,不过不用担心,我们一步一步来分析一下。

第一步:宏任务script首先执行。全局入栈。glob1输出。

前端進階(十二):詳解事件循環機制

script首先执行

第二步,执行过程遇到setTimeout。setTimeout作为任务分发器,将任务分发到对应的宏任务队列中。

setTimeout(function() {
    console.log('timeout1');
    process.nextTick(function() {
        console.log('timeout1_nextTick');
    })
    new Promise(function(resolve) {
        console.log('timeout1_promise');
        resolve();
    }).then(function() {
        console.log('timeout1_then')
    })
})

前端進階(十二):詳解事件循環機制

timeout1进入对应队列

第三步:执行过程遇到setImmediate。setImmediate也是一个宏任务分发器,将任务分发到对应的任务队列中。setImmediate的任务队列会在setTimeout队列的后面执行。

setImmediate(function() {
    console.log('immediate1');
    process.nextTick(function() {
        console.log('immediate1_nextTick');
    })
    new Promise(function(resolve) {
        console.log('immediate1_promise');
        resolve();
    }).then(function() {
        console.log('immediate1_then')
    })
})

前端進階(十二):詳解事件循環機制

进入setImmediate队列

第四步:执行遇到nextTick,process.nextTick是一个微任务分发器,它会将任务分发到对应的微任务队列中去。

process.nextTick(function() {
    console.log('glob1_nextTick');
})

前端進階(十二):詳解事件循環機制

nextTick

第五步:执行遇到Promise。Promise的then方法会将任务分发到对应的微任务队列中,但是它构造函数中的方法会直接执行。因此,glob1_promise会第二个输出。

new Promise(function(resolve) {
    console.log('glob1_promise');
    resolve();
}).then(function() {
    console.log('glob1_then')
})

前端進階(十二):詳解事件循環機制

先是函数调用栈的变化

前端進階(十二):詳解事件循環機制

然后glob1_then任务进入队列

第六步:执行遇到第二个setTimeout。

setTimeout(function() {
    console.log('timeout2');
    process.nextTick(function() {
        console.log('timeout2_nextTick');
    })
    new Promise(function(resolve) {
        console.log('timeout2_promise');
        resolve();
    }).then(function() {
        console.log('timeout2_then')
    })
})

前端進階(十二):詳解事件循環機制

timeout2进入对应队列

第七步:先后遇到nextTick与Promise

process.nextTick(function() {
    console.log('glob2_nextTick');
})
new Promise(function(resolve) {
    console.log('glob2_promise');
    resolve();
}).then(function() {
    console.log('glob2_then')
})

前端進階(十二):詳解事件循環機制

glob2_nextTick与Promise任务分别进入各自的队列

第八步:再次遇到setImmediate。

setImmediate(function() {
    console.log('immediate2');
    process.nextTick(function() {
        console.log('immediate2_nextTick');
    })
    new Promise(function(resolve) {
        console.log('immediate2_promise');
        resolve();
    }).then(function() {
        console.log('immediate2_then')
    })
})

前端進階(十二):詳解事件循環機制

nextTick

这个时候,script中的代码就执行完毕了,执行过程中,遇到不同的任务分发器,就将任务分发到各自对应的队列中去。接下来,将会执行所有的微任务队列中的任务。

其中,nextTick队列会比Promie先执行。nextTick中的可执行任务执行完毕之后,才会开始执行Promise队列中的任务。

当所有可执行的微任务执行完毕之后,这一轮循环就表示结束了。下一轮循环继续从宏任务队列开始执行。

这个时候,script已经执行完毕,所以就从setTimeout队列开始执行。

前端進階(十二):詳解事件循環機制

第二轮循环初始状态

setTimeout任务的执行,也依然是借助函数调用栈来完成,并且遇到任务分发器的时候也会将任务分发到对应的队列中去。

只有当setTimeout中所有的任务执行完毕之后,才会再次开始执行微任务队列。并且清空所有的可执行微任务。

setTiemout队列产生的微任务执行完毕之后,循环则回过头来开始执行setImmediate队列。仍然是先将setImmediate队列中的任务执行完毕,再执行所产生的微任务。

当setImmediate队列执行产生的微任务全部执行之后,第二轮循环也就结束了。

大家需要注意这里的循环结束的时间节点。

当我们在执行setTimeout任务中遇到setTimeout时,它仍然会将对应的任务分发到setTimeout队列中去,但是该任务就得等到下一轮事件循环执行了。例子中没有涉及到这么复杂的嵌套,大家可以动手添加或者修改他们的位置来感受一下循环的变化。

OK,到这里,事件循环我想我已经表述得很清楚了,能不能理解就看读者老爷们有没有耐心了。我估计很多人会理解不了循环结束的节点。

当然,这些顺序都是v8的一些实现。我们也可以根据上面的规则,来尝试实现一下事件循环的机制。

// 用数组模拟一个队列
var tasks = [];

// 模拟一个事件分发器
var addFn1 = function(task) {
    tasks.push(task);
}

// 执行所有的任务
var flush = function() {
    tasks.map(function(task) {
        task();
    })
}

// 最后利用setTimeout/或者其他你认为合适的方式丢入事件循环中
setTimeout(function() {
    flush();
})

// 当然,也可以不用丢进事件循环,而是我们自己手动在适当的时机去执行对应的某一个方法

var dispatch = function(name) {
    tasks.map(function(item) {
        if(item.name == name) {
            item.handler();
        }
    })
}

// 当然,我们把任务丢进去的时候,多保存一个name即可。
// 这时候,task的格式就如下
demoTask =  {
    name: 'demo',
    handler: function() {}
}

// 于是,一个订阅-通知的设计模式就这样轻松的被实现了

这样,我们就模拟了一个任务队列。我们还可以定义另外一个队列,利用上面的各种方式来规定他们的优先级

因此,在老的浏览器没有支持Promise的时候,就可以利用setTimeout等方法,来模拟实现Promise,具体如何做到的,下一篇文章我们慢慢分析。


以上是前端進階(十二):詳解事件循環機制的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn