JavaScript 事件迴圈詳解(翻譯)

YaHuiLiang(Ryou)發表於2019-03-04

最近在搜尋更詳細的關於JS事件處理的資料。發現國內的大部分blog都是相互抄襲。而MDM對於這裡的解釋也並不多。翻閱了部分文章,發現這篇文章很有價值。故譯之。雖然文章寫於2013年,但是依然具有很高參考價值

原文:The JavaScript Event Loop: Explained

這篇文章是講什麼的?

對於目前Web瀏覽器上最流行的指令碼語言JavaScript。這篇文章為你提供了該語言基本的事件驅動模型的講解,它與那些典型的有求必應的語言比如Ruby, Python, Java不同。在這篇文章中,我會為你解釋這些JavaScript中併發模型的核心概念,包括事件迴圈,訊息佇列來幫助你提高對這門已經使用過,但是還沒有徹底理解的語言更深入的理解。

誰適合讀這篇文章?

這篇文章面向那些已經開始似乎用JavaScript語言從事Web開發的工程師,或者是計劃從事這項工作的人員。如果你已經非常熟悉JavaScript的事件迴圈機制,那麼你會覺得這篇文章的內容對於你來說已經再熟悉不過了。對於那些沒有對事件迴圈充分了解的人,我希望這篇文章能夠幫助到你,這樣才能讓你更好的理解你每天面對的程式碼。

非阻塞 I/O(Non-blocking I/O)

JavaScript幾乎所有的I/O都是非阻塞的。包括HTTP請求,資料訪問,讀寫磁碟。一個單執行緒在執行時去處理這些操作,提供一個回撥函式,然後接著去做其它的事情。當操作完成了,這個回撥函式提供的訊息會被推送到佇列中。在某個時間點,訊息從佇列中被移除,緊接著回撥函式就被觸發了。

雖然這個互動模型對於很多開發者來說已經非常熟悉了 — 比如 mousedownclick 事件的處理,- 但是這與那種典型伺服器端同步的請求處理不同。

讓我們對比一下向 www.google.com 發出請求後將返回的程式碼輸出到控制檯。首先,Ruby 的話:

    response = Faraday.get `http://www.google.com`
    puts response
    puts `Done!`
複製程式碼

執行路徑大概是這個樣子的:

  1. get 方法被執行,然後執行執行緒開始等待,直到收到響應
  2. Google 收到響應並且返回到回撥並儲存到一個變數中
  3. 變數的值(返回的響應結果)輸出到控制檯中
  4. Done 被輸出到控制檯

讓我們利用 Node.js 中完成一樣的事情看看:

    request(`http://www.google.com`, function(error, response, body) {
    console.log(body);
    });
     
    console.log(`Done!`);
複製程式碼

看起來一個顯著的不同和不同的行為:

  1. 請求函式被執行,傳遞一個匿名函式作為一個回撥執行函式,在收到響應後執行
  2. Done 被馬上輸出到控制套
  3. 有時候,響應返回了,我們的回撥函式也執行力,響應的主體被輸出到了控制檯

事件迴圈

將請求的響應以回撥函式的方式處理,允許 JavaScript在等待非同步操作成功返回並執行回撥函式之前可以做一些其他的事情。但是,在記憶體中怎麼執行這些回撥的呢?執行順序是什麼樣的呢?什麼導致他們被呼叫的呢?

JavaScript 的執行環境有一個用於儲存訊息和用於關聯回撥函式的訊息佇列。這些訊息以事件被註冊的順序進行排列(比如滑鼠點選事件或者是 HTTP 請求響應事件)。比如使用者點選一個按鈕,如果沒有該事件的回撥函式被註冊,那麼就沒有訊息加入佇列。

在迴圈中,佇列輪詢下一個訊息(每一次輪詢被當作是一個 tick),如果有訊息,那麼就執行訊息對應的回撥。

JavaScript 事件迴圈詳解(翻譯)

回撥函式的呼叫作為呼叫堆疊的初始幀,由於JavaScript是單執行緒的,訊息輪詢和處理會被停止,直到堆疊內的回撥函式全部返回。後續函式呼叫(同步)向堆疊新增新的呼叫(例如初始化顏色)。

    function init() {
    var link = document.getElementById("foo");
     
    link.addEventListener("click", function changeColor() {
    this.style.color = "burlywood";
    });
    }
     
    init();
複製程式碼

在這個例子中,當使用者點選頁面元素,然後一個onclick 事件被觸發,一個訊息被壓入佇列中。當訊息被壓入佇列,他的回撥函式changeColor被執行。當changeColor返回的時候(也可能是丟擲異常),事件迴圈就繼續執行。只要changeColor被指定為onclick的回撥函式,後面在該元素的點選會導致更多的訊息(以及相關changeColor回撥的訊息)被壓入佇列。

佇列中的額外訊息

如果函式在你的程式碼中被非同步呼叫(比如 setTimeout),在之後的事件迴圈中,回撥函式會以另一個訊息佇列的一部分被執行。比如:

    function f() {
        console.log("foo");
        setTimeout(g, 0);
        console.log("baz");
        h();
    }
     
    function g() {
        console.log("bar");
    }
     
    function h() {
        console.log("blix");
    }
     
    f();

複製程式碼

由於setTimeout是非阻塞的,它會在0毫秒後被執行,並且並不是作為此訊息的一部分被處理。在這個例子中,setTimeout被呼叫,傳入一個回撥函式 g 和 一個超時事件 0 毫秒。當時間到了以後,一個以g為回撥函式的訊息將會被壓入佇列中。控制檯會輸出類似: foo, baz, blix 然後在下一次事件迴圈輸出: bar。如果在同一個呼叫幀中(譯者注:就是一個函式內)執行了兩次 setTimeout,傳入相同的值(譯者注: 時間間隔)。他們會按照先後順序執行。

Web Workers

Web Worker 允許你將昂貴的操作轉入到獨立到執行緒中執行,節約主要執行緒去做其它事情。worker具有獨立的訊息佇列,事件迴圈,和獨立的記憶體空間。worker 與主執行緒通過訊息來完成通訊,這看起來有點像之前的事件處理那樣。

JavaScript 事件迴圈詳解(翻譯)

首先,我們的 worker:

    // our worker, which does some CPU-intensive operation
    var reportResult = function(e) {
        pi = SomeLib.computePiToSpecifiedDecimals(e.data);
        postMessage(pi);
    };
     
    onmessage = reportResult;
複製程式碼

然後是我們的js程式碼:

    // our main code, in a <script>-tag in our HTML page
    var piWorker = new Worker("pi_calculator.js");
    var logResult = function(e) {
        console.log("PI: " + e.data);
    };
     
    piWorker.addEventListener("message", logResult, false);
    piWorker.postMessage(100000);
複製程式碼

在這個例子中,主執行緒衍生並啟動一個worker,然後並將logResult這個回撥加入到事件迴圈。在worker中,reportResult被註冊到自己的message事件中。當worker從主執行緒接收到訊息,worker就會返回一個訊息,因此就會導致reportResult被執行。

當進行壓棧的時候,一個訊息會被推送到主執行緒,並被壓入訊息堆疊(然後執行回撥函式)。通過這個方式,開發人員可以將cpu密集型操作委託給獨立的執行緒,釋放主執行緒去繼續處理訊息和事件。

關於閉包

JavaScript支援在回撥函式中使用閉包,該回撥在執行時維持對建立它們的環境的訪問,即使回撥執行完建立了新的呼叫堆疊。對於知道回撥是以不同的訊息被執行的要比知道回撥被建立更有趣。思考下面的程式碼:

    function changeHeaderDeferred() {
        var header = document.getElementById("header");
     
        setTimeout(function changeHeader() {
            header.style.color = "red";
     
            return false;
        }, 100);
     
        return false;
    }
     
    changeHeaderDeferred();
複製程式碼

這個例子中,changeHeaderDeferred執行的時候包含了header變數。setTimeout被執行,100毫秒後一個訊息被新增到訊息佇列中。changeHeaderDeferred函式返回了false,結束了這次處理 – 但是回撥函式中依然保留著header的引用,所以沒有被垃圾回收。當第二個訊息被處理的時候,函式體外(changeHeaderDeferred)它依然保持這對header的宣告。第二次處理完後,header才被垃圾回收處理。

相關文章