首頁 >web前端 >js教程 >js執行機制實例詳解

js執行機制實例詳解

小云云
小云云原創
2018-03-14 17:21:532617瀏覽

想要理解JavaScript的運作機制,需要分別深刻理解幾個點:JavaScript的單執行緒機制、任務佇列(同步任務和非同步任務)、事件和回呼函數、定時器、Event Loop(事件循環)。

JavaScript的單執行緒機制

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檢查是否有等待被呼​​叫的函數。

下面用一

張導圖來說明主執行緒和任務佇列。

js執行機制實例詳解

導圖要表達的內容用文字來表達的話:

  • 同步和非同步任務分別進入不同的執行”場所”,同步的進入主線程,非同步的進入Event Table並註冊函數。

  • 當指定的事情完成時,Event Table會將這個函數移入Event Queue。

  • 主執行緒內的任務執行完畢為空,會去Event Queue讀取對應的函數,進入主執行緒執行。

  • 上述過程會不斷重複,也就是常說的Event Loop(事件循環)。

事件與回呼函數

事件

「任務佇列」是一個事件的佇列(也可以理解成是訊息的佇列),IO設備完成一項任務,就會在「任務佇列」中新增一個事件,表示相關的非同步任務可以進入「執行堆疊」。接著主執行緒讀取“任務佇列”,查看裡面有哪些事件。

「任務佇列」中的事件,除了IO設備的事件以外,還包括一些使用者產生的事件(例如滑鼠點擊、頁面滾動等等)。只要指定過回呼函數,這些事件發生時就會進入“任務佇列”,等待主執行緒讀取。

回呼函數

所謂「回呼函數」(callback),就是那些會被主執行緒掛起來的程式碼。非同步任務必須指定回呼函數,當主執行緒開始執行非同步任務,就是執行對應的回呼函數。

「任務佇列」是一個先進先出的資料結構,排在前面的事件,優先被主執行緒讀取。主執行緒的讀取過程基本上是自動的,只要執行堆疊一清空,「任務佇列」上第一位的事件就會自動進入主執行緒。但是,如果包含“定時器”,主執行緒首先要檢查執行時間,某些事件只有到了規定的時間,才能返回主執行緒。

Event Loop

主執行緒從「任務佇列」讀取事件,這個過程是循環不斷的,所以整個的運行機制又稱為「Event Loop」(事件循環)。

為了更好地理解Event Loop,下面參考Philip Roberts的演講中的一張圖。

Event Loop

上圖中,主執行緒在運行時,產生了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也是单线程的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指定的是在下次”事件循环”触发,所以很显然,前者总是比后者发生得早,而且执行效率也高(因为不用检查”任务队列”)。

Promise

除了广义的同步任务和异步任务,任务还有更精细的定义:

  • macro-task(宏任务):包括整体代码script,setTimeout,setInterval

  • micro-task(微任务):Promise,process.nextTick

事件循环,宏任务,微任务的关系如图所示:

 

按照宏任务和微任务这种分类方式,JS的执行机制是

  • 执行一个宏任务,过程中如果遇到微任务,就将其放到微任务的【事件队列】里

  • 当前宏任务执行完成后,会查看微任务的【事件队列】,并将里面全部的微任务依次执行完

请看下面的例子:

setTimeout(function(){
     console.log('定时器开始啦')
 });

 new Promise(function(resolve){
     console.log(&#39;马上执行for循环啦&#39;);     for(var i = 0; i < 10000; i++){
         i == 99 && resolve();
     }
 }).then(function(){
     console.log(&#39;执行then函数啦&#39;)
 }); console.log(&#39;代码执行结束&#39;);
  • 首先执行script下的宏任务,遇到setTimeout,将其放到宏任务的【队列】里

  • 遇到 new Promise直接执行,打印”马上执行for循环啦”

  • 遇到then方法,是微任务,将其放到微任务的【队列里】

  • 打印 “代码执行结束”

  • 本轮宏任务执行完毕,查看本轮的微任务,发现有一个then方法里的函数, 打印”执行then函数啦”

  • 到此,本轮的event loop 全部完成。

  • 下一轮的循环里,先执行一个宏任务,发现宏任务的【队列】里有一个 setTimeout里的函数,执行打印”定时器开始啦”

所以最后的执行顺序是【马上执行for循环啦 — 代码执行结束 — 执行then函数啦 — 定时器开始啦】

我们来分析一段较复杂的代码,看看你是否真的掌握了js的执行机制:

console.log(&#39;1&#39;);

setTimeout(function() {
    console.log(&#39;2&#39;);    process.nextTick(function() {
        console.log(&#39;3&#39;);
    })
    new Promise(function(resolve) {
        console.log(&#39;4&#39;);
        resolve();
    }).then(function() {
        console.log(&#39;5&#39;)
    })
})process.nextTick(function() {
    console.log(&#39;6&#39;);
})
new Promise(function(resolve) {
    console.log(&#39;7&#39;);
    resolve();
}).then(function() {
    console.log(&#39;8&#39;)
})

setTimeout(function() {
    console.log(&#39;9&#39;);    process.nextTick(function() {
        console.log(&#39;10&#39;);
    })
    new Promise(function(resolve) {
        console.log(&#39;11&#39;);
        resolve();
    }).then(function() {
        console.log(&#39;12&#39;)
    })
})

第一轮事件循环流程分析如下:

  • 整体script作为第一个宏任务进入主线程,遇到console.log,输出1。

  • 遇到setTimeout,其回调函数被分发到宏任务Event Queue中。我们暂且记为setTimeout1

  • 遇到process.nextTick(),其回调函数被分发到微任务Event Queue中。我们记为process1

  • 遇到Promisenew Promise直接执行,输出7。then被分发到微任务Event Queue中。我们记为then1

  • 又遇到了setTimeout,其回调函数被分发到宏任务Event Queue中,我们记为setTimeout2

宏任务Event Queue 微任务Event Queue
setTimeout1 process1
setTimeout2 then1

*   上表是第一轮事件循环宏任务结束时各Event Queue的情况,此时已经输出了1和7。

  • 我们发现了process1then1两个微任务。

  • 执行process1,输出6。

  • 执行then1,输出8。

好了,第一轮事件循环正式结束,这一轮的结果是输出1,7,6,8。那么第二轮时间循环从setTimeout1宏任务开始:

  • 首先输出2。接下来遇到了process.nextTick(),同样将其分发到微任务Event Queue中,记为process2new Promise立即执行输出4,then也分发到微任务Event Queue中,记为then2

宏任务Event Queue 微任务Event Queue
setTimeout2 process2

then2

*   第二轮事件循环宏任务结束,我们发现有process2then2两个微任务可以执行。
*   输出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

*   第三轮事件循环宏任务执行结束,执行两个微任务process3then3
*   输出10。
*   输出12。
*   第三轮事件循环结束,第三轮输出9,11,10,12。

整段代码,共进行了三次事件循环,完整的输出为1,7,6,8,2,4,3,5,9,11,10,12。
(请注意,node环境下的事件监听依赖libuv与前端环境不完全相同,输出顺序可能会有误差)

以上是js執行機制實例詳解的詳細內容。更多資訊請關注PHP中文網其他相關文章!

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