想要理解JavaScript的運作機制,需要分別深刻理解幾個點:JavaScript的單執行緒機制、任務佇列(同步任務和非同步任務)、事件和回呼函數、定時器、Event Loop(事件循環)。
JavaScript的一個語言特性(也是這門語言的核心)就是單執行緒。單線程簡單地說就是同一時間只能做一件事,當有多個任務時,只能按照一個順序一個完成了再執行下一個。
JavaScript的單執行緒與它的語言用途是有關的。作為一門瀏覽器腳本語言,JavaScript的主要用途是完成使用者互動、操作DOM。這決定了它只能是單線程,否則會導致複雜的同步問題。
設想JavaScript同時有兩個線程,一個線程需要在某個DOM節點上添加內容,而另一個線程的操作是刪除了這個節點,那麼瀏覽器應該以誰為準呢?
所以為了避免複雜性,JavaScript從誕生開始就是單執行緒。
為了提高CPU的使用率,HTML5提出Web Worker標準,允許JavaScript腳本建立多個線程,但是子執行緒完全受主執行緒控制,且不得操作DOM。所以這個標準並沒有改變JavaScript單執行緒的本質。
一個接一個地完成任務也就意味著待完成的任務是需要排隊的,那麼為什麼會需要排隊呢?
通常排隊有以下兩種原因:
任務運算量過大,CPU處於忙碌狀態;
任務所需的東西未準備好所以無法繼續執行,導致CPU閒置,等待輸入輸出設備(I/O設備)。
例如有的任務你需要Ajax取得到資料才能往下執行
由此JavaScript的設計者也意識到,這時完全可以先執行後面已經就緒的任務來提高運作效率,也就是把等待中的任務先掛起放到一邊,等得到需要的東西再執行。就好比接電話時對方離開了一下,這時正好有另一個來電,於是你便把當前通話掛起,等那個通話結束後,再連回之前的通話。
所以也就出現了同步和非同步的概念,任務也被分成了兩種,一種是同步任務(Synchronous),另一種是非同步任務(Asynchronous)。
同步任務:需要執行的任務在主執行緒上排隊,一個接一個,前一個完成了再執行下一個
異步任務:沒有馬上被執行但需要執行的任務,存放在「任務佇列」(task queue)中,「任務佇列」會通知主執行緒什麼時候哪個非同步任務可以執行,然後這個任務就會進入主執行緒並被執行。
所有的同步執行都可以看作是沒有非同步任務的非同步執行
具體來說,非同步執行如下:
(1)所有同步任務都在主執行緒上執行,形成一個執行堆疊(execution context stack)。
也就是所有能馬上執行的任務都在主執行緒上排好了隊,一個接一個的被執行。
(2)主執行緒之外,還有一個「任務佇列」(task queue)。只要非同步任務有了運行結果,就在「任務佇列」之中放置一個事件。
也就是說每個非同步任務準備好了就會立一個唯一的flag,這個flag用來識別對應的非同步任務。
(3)一旦“執行堆疊”中的所有同步任務執行完畢,系統就會讀取“任務佇列”,看看裡面有哪些事件。那些對應的非同步任務,就結束等待狀態,進入執行堆疊開始被執行。
也就是主執行緒把之前的任務做完了之後,就會來看「任務佇列」中的flag,來把對應的非同步任務打包來執行。
(4)主執行緒不斷重複以上三步驟。
#只要主執行緒空了,就會去讀取「任務佇列」。這個過程會不斷重複,這就是JavaScript的運作機制。
那怎麼知道主執行緒執行端為空啊? js引擎存在monitoring process進程,會持續不斷的檢查主執行緒執行堆疊是否為空,一旦為空,就會去Event Queue檢查是否有等待被呼叫的函數。
下面用一
張導圖來說明主執行緒和任務佇列。
導圖要表達的內容用文字來表達的話:
同步和非同步任務分別進入不同的執行”場所”,同步的進入主線程,非同步的進入Event Table並註冊函數。
當指定的事情完成時,Event Table會將這個函數移入Event Queue。
主執行緒內的任務執行完畢為空,會去Event Queue讀取對應的函數,進入主執行緒執行。
上述過程會不斷重複,也就是常說的Event Loop(事件循環)。
「任務佇列」是一個事件的佇列(也可以理解成是訊息的佇列),IO設備完成一項任務,就會在「任務佇列」中新增一個事件,表示相關的非同步任務可以進入「執行堆疊」。接著主執行緒讀取“任務佇列”,查看裡面有哪些事件。
「任務佇列」中的事件,除了IO設備的事件以外,還包括一些使用者產生的事件(例如滑鼠點擊、頁面滾動等等)。只要指定過回呼函數,這些事件發生時就會進入“任務佇列”,等待主執行緒讀取。
所謂「回呼函數」(callback),就是那些會被主執行緒掛起來的程式碼。非同步任務必須指定回呼函數,當主執行緒開始執行非同步任務,就是執行對應的回呼函數。
「任務佇列」是一個先進先出的資料結構,排在前面的事件,優先被主執行緒讀取。主執行緒的讀取過程基本上是自動的,只要執行堆疊一清空,「任務佇列」上第一位的事件就會自動進入主執行緒。但是,如果包含“定時器”,主執行緒首先要檢查執行時間,某些事件只有到了規定的時間,才能返回主執行緒。
主執行緒從「任務佇列」讀取事件,這個過程是循環不斷的,所以整個的運行機制又稱為「Event Loop」(事件循環)。
為了更好地理解Event Loop,下面參考Philip Roberts的演講中的一張圖。
上圖中,主執行緒在運行時,產生了heap(堆疊)和stack(堆疊),堆疊中的程式碼呼叫各種外部API,並在“任務佇列」中加入各種事件(click,load,done)。當堆疊中的程式碼執行完畢,主執行緒就會讀取“任務佇列”,並依序執行那些事件所對應的回呼函數。
執行堆疊中的程式碼(同步任務),總是在讀取「任務佇列」(非同步任務)之前執行。
let data = []; $.ajax({ url:www.javascript.com, data:data, success:() => { console.log('发送成功!'); } })console.log('代码执行结束');
上面是一段簡易的ajax
請求程式碼:
ajax進入Event Table,註冊回呼函數success
。
執行console.log('程式碼執行結束')
。
ajax事件完成,回呼函數success
進入Event Queue。
主執行緒從Event Queue讀取回呼函數success
並執行。
除了放置非同步任務的事件,「任務佇列」還可以放置定時事件,也就是指定某些程式碼在多少時間之後執行。這叫做定時器(timer)功能,也就是定時執行的程式碼。
SetTimeout()
和setInterval()
可以用來註冊在指定時間之後單次或重複呼叫的函數,它們的內部運作機製完全一樣,區別在於前者指定的程式碼是一次執行,後者會在指定毫秒數的間隔裡重複呼叫:
setInterval(updateClock, 60000); //60秒调用一次updateClock()
因為它們都是客戶端JavaScript中重要的全域函數,所以定義為Window物件的方法。
但作為通用函數,其實不會對視窗做什麼事情。
Window物件的setTImeout()
方法用來實作一個函數在指定的毫秒數之後運行。所以它接受兩個參數,第一個是回呼函數,第二個是推遲執行的毫秒。 setTimeout()
和setInterval()
回傳一個值,這個值可以傳遞給clearTimeout()
用來取消這個函數的執行。
console.log(1); setTimeout(function(){console.log(2);}, 1000);console.log(3);
上面程式碼的執行結果是1,3,2,因為setTimeout()
將第二行延後到1000毫秒之後執行。
如果將setTimeout()
的第二個參數設為0,就表示目前程式碼執行完(執行堆疊清空)以後,立即執行(0毫秒間隔)指定的回呼函數。
setTimeout(function(){console.log(1);}, 0);console.log(2)
上面程式碼的執行結果總是2,1,因為只有在執行完第二行以後,系統才會執行「任務佇列」中的回呼函數。
總之,setTimeout(fn,0)
的意思是,指定某個任務在主執行緒最早可得的空閒時間執行,也就是盡可能提早執行。它在「任務佇列」的尾部新增一個事件,因此要等到同步任務和「任務佇列」現有的事件都處理完,才會執行。
HTML5标准规定了
setTimeout()
的第二个参数的最小值(最短间隔),不得低于4毫秒,如果低于这个值,就会自动增加。
需要注意的是,setTimeout()
只是将事件插入了“任务队列”,必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以并没有办法保证回调函数一定会在setTimeout()
指定的时间执行。
由于历史原因,setTimeout()
和setInterval()
的第一个参数可以作为字符串传入。如果这么做,那这个字符串会在指定的超时时间或间隔之后进行求值(相当于执行eval()
)。
Node.js也是单线程的Event Loop,但是它的运行机制不同于浏览器环境。
Node.js的运行机制如下。
(1)V8引擎解析JavaScript脚本。
(2)解析后的代码,调用Node API。
(3)libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。
(4)V8引擎再将结果返回给用户。
除了setTimeout和setInterval这两个方法,Node.js还提供了另外两个与”任务队列”有关的方法:process.nextTick和setImmediate。它们可以帮助我们加深对”任务队列”的理解。
process.nextTick方法可以在当前”执行栈”的尾部—-下一次Event Loop(主线程读取”任务队列”)之前—-触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。setImmediate方法则是在当前”任务队列”的尾部添加事件,也就是说,它指定的任务总是在下一次Event Loop时执行,这与setTimeout(fn, 0)很像。请看下面的例子
process.nextTick(function A() {console.log(1);process.nextTick(function B(){console.log(2);});}); setTimeout(function timeout() {console.log('TIMEOUT FIRED'); }, 0)// 1// 2// TIMEOUT FIRED
上面代码中,由于process.nextTick方法指定的回调函数,总是在当前”执行栈”的尾部触发,所以不仅函数A比setTimeout指定的回调函数timeout先执行,而且函数B也比timeout先执行。这说明,如果有多个process.nextTick语句(不管它们是否嵌套),将全部在当前”执行栈”执行。
现在,再看setImmediate。
setImmediate(function A() {console.log(1); setImmediate(function B(){console.log(2);});}); setTimeout(function timeout() {console.log('TIMEOUT FIRED'); }, 0);
上面代码中,setImmediate与setTimeout(fn,0)各自添加了一个回调函数A和timeout,都是在下一次Event Loop触发。那么,哪个回调函数先执行呢?答案是不确定。运行结果可能是1–TIMEOUT FIRED–2,也可能是TIMEOUT FIRED–1–2。
令人困惑的是,Node.js文档中称,setImmediate指定的回调函数,总是排在setTimeout前面。实际上,这种情况只发生在递归调用的时候。
setImmediate(function (){setImmediate(function A() {console.log(1); setImmediate(function B(){console.log(2);});}); setTimeout(function timeout() {console.log('TIMEOUT FIRED'); }, 0); }); // 1 // TIMEOUT FIRED // 2
上面代码中,setImmediate和setTimeout被封装在一个setImmediate里面,它的运行结果总是1–TIMEOUT FIRED–2,这时函数A一定在timeout前面触发。至于2排在TIMEOUT FIRED的后面(即函数B在timeout后面触发),是因为setImmediate总是将事件注册到下一轮Event Loop,所以函数A和timeout是在同一轮Loop执行,而函数B在下一轮Loop执行。
我们由此得到了process.nextTick和setImmediate的一个重要区别:多个process.nextTick语句总是在当前”执行栈”一次执行完,多个setImmediate可能则需要多次loop才能执行完。事实上,这正是Node.js 10.0版添加setImmediate方法的原因,否则像下面这样的递归调用process.nextTick,将会没完没了,主线程根本不会去读取”事件队列”!
process.nextTick(function foo() {process.nextTick(foo); });
事实上,现在要是你写出递归的process.nextTick,Node.js会抛出一个警告,要求你改成setImmediate。
另外,由于process.nextTick指定的回调函数是在本次”事件循环”触发,而setImmediate指定的是在下次”事件循环”触发,所以很显然,前者总是比后者发生得早,而且执行效率也高(因为不用检查”任务队列”)。
除了广义的同步任务和异步任务,任务还有更精细的定义:
macro-task(宏任务):包括整体代码script,setTimeout,setInterval
micro-task(微任务):Promise,process.nextTick
事件循环,宏任务,微任务的关系如图所示:
按照宏任务和微任务这种分类方式,JS的执行机制是
执行一个宏任务,过程中如果遇到微任务,就将其放到微任务的【事件队列】里
当前宏任务执行完成后,会查看微任务的【事件队列】,并将里面全部的微任务依次执行完
请看下面的例子:
setTimeout(function(){ console.log('定时器开始啦') }); new Promise(function(resolve){ console.log('马上执行for循环啦'); for(var i = 0; i < 10000; i++){ i == 99 && resolve(); } }).then(function(){ console.log('执行then函数啦') }); console.log('代码执行结束');
首先执行script下的宏任务,遇到setTimeout,将其放到宏任务的【队列】里
遇到 new Promise直接执行,打印”马上执行for循环啦”
遇到then方法,是微任务,将其放到微任务的【队列里】
打印 “代码执行结束”
本轮宏任务执行完毕,查看本轮的微任务,发现有一个then方法里的函数, 打印”执行then函数啦”
到此,本轮的event loop 全部完成。
下一轮的循环里,先执行一个宏任务,发现宏任务的【队列】里有一个 setTimeout里的函数,执行打印”定时器开始啦”
所以最后的执行顺序是【马上执行for循环啦 — 代码执行结束 — 执行then函数啦 — 定时器开始啦】
我们来分析一段较复杂的代码,看看你是否真的掌握了js的执行机制:
console.log('1'); setTimeout(function() { console.log('2'); process.nextTick(function() { console.log('3'); }) new Promise(function(resolve) { console.log('4'); resolve(); }).then(function() { console.log('5') }) })process.nextTick(function() { console.log('6'); }) new Promise(function(resolve) { console.log('7'); resolve(); }).then(function() { console.log('8') }) setTimeout(function() { console.log('9'); process.nextTick(function() { console.log('10'); }) new Promise(function(resolve) { console.log('11'); resolve(); }).then(function() { console.log('12') }) })
第一轮事件循环流程分析如下:
整体script作为第一个宏任务进入主线程,遇到console.log
,输出1。
遇到setTimeout
,其回调函数被分发到宏任务Event Queue中。我们暂且记为setTimeout1
。
遇到process.nextTick()
,其回调函数被分发到微任务Event Queue中。我们记为process1
。
遇到Promise
,new Promise
直接执行,输出7。then
被分发到微任务Event Queue中。我们记为then1
。
又遇到了setTimeout
,其回调函数被分发到宏任务Event Queue中,我们记为setTimeout2
。
宏任务Event Queue | 微任务Event Queue |
---|---|
setTimeout1 | process1 |
setTimeout2 | then1 |
* 上表是第一轮事件循环宏任务结束时各Event Queue的情况,此时已经输出了1和7。
我们发现了process1
和then1
两个微任务。
执行process1
,输出6。
执行then1
,输出8。
好了,第一轮事件循环正式结束,这一轮的结果是输出1,7,6,8。那么第二轮时间循环从setTimeout1
宏任务开始:
首先输出2。接下来遇到了process.nextTick()
,同样将其分发到微任务Event Queue中,记为process2
。new Promise
立即执行输出4,then
也分发到微任务Event Queue中,记为then2
。
宏任务Event Queue | 微任务Event Queue |
---|---|
setTimeout2 | process2 |
then2 |
* 第二轮事件循环宏任务结束,我们发现有process2
和then2
两个微任务可以执行。
* 输出3。
* 输出5。
* 第二轮事件循环结束,第二轮输出2,4,3,5。
* 第三轮事件循环开始,此时只剩setTimeout2了,执行。
* 直接输出9。
* 将process.nextTick()
分发到微任务Event Queue中。记为process3
。
* 直接执行new Promise
,输出11。
* 将then
分发到微任务Event Queue中,记为then3
。
宏任务Event Queue | 微任务Event Queue |
---|---|
process3 | |
then3 |
* 第三轮事件循环宏任务执行结束,执行两个微任务process3
和then3
。
* 输出10。
* 输出12。
* 第三轮事件循环结束,第三轮输出9,11,10,12。
整段代码,共进行了三次事件循环,完整的输出为1,7,6,8,2,4,3,5,9,11,10,12。
(请注意,node环境下的事件监听依赖libuv与前端环境不完全相同,输出顺序可能会有误差)
以上是js執行機制實例詳解的詳細內容。更多資訊請關注PHP中文網其他相關文章!