[翻譯]理解非同步JavaScript

Yukiiiiiiiiiii發表於2018-12-09

寫在文章前

這篇文章是翻譯自Sukhjinder AroraUnderstanding Asynchronous JavaScript。這篇文章描述了非同步和同步JavaScript是如何在執行環境中,使用呼叫棧,訊息佇列,作業佇列,以及事件迴圈來工作的。文章如有翻譯不好的地方還望多多包涵。

理解非同步JavaScript

眾所周知,JavaScript 是單執行緒的程式語言,那就意味著在同一個時間只能有一件事發生。通俗的講,JavaScript引擎每一個執行緒一次只能處理一個宣告。

雖然單執行緒語言可以簡化寫程式碼的過程,因為你不用擔心併發的問題,但這樣同時也意味著你無法在不鎖住主執行緒的情況下,執行像網路訪問這種長時間的操作。

想象一下,從API請求資料的這個情況。伺服器可能需要一些時間來處理請求,同時阻塞主執行緒使網頁無響應。

這就是非同步Javascript可以發揮作用的地方了。使用非同步JavaScript(例如像回撥,promises,和async/await),你就可以在不鎖住主執行緒的情況下執行長時間的網路請求。

你沒有必要學習所有這些概念來成為一個出色JavaScript工程師,這些只是對你很有幫助而已:)

所以廢話不多說,我們開始吧。

同步JavaScript是怎麼工作的呢?

在我們深入瞭解非同步JavaScript之前,讓我們先來了解一下同步的JavaScript程式碼是如何在引擎內部執行的。舉個例子:

const second = () => {
   console.log('hello there');
}

const first = () => {
    console.log('hi,there');
    second();
    console.log('The End');
}

first();
複製程式碼

在我們想要理解上面程式碼是如何在JavaScript引擎執行的之前,我們需要先要理解執行上下文和呼叫棧的概念(也叫執行棧)。

執行上下文

執行上下文是JavaScript程式碼被評估和執行的地方的抽象概念。每當任何js程式碼執行的時候,他們就執行在執行上下文內部。

函式執行在函式的執行上下文內,全域性程式碼執行在全域性的執行上下文內。每個函式都有自己的執行上下文。

呼叫棧

呼叫棧就像他名字裡展示的那樣,他是一個具有後進先出的棧結構,它用於儲存程式碼執行期間建立的所有執行上下文。

JavaScript是擁有單一呼叫棧的,因為它是單執行緒的語言。呼叫棧的LIFO(後進先出結構)決定了東西只能從棧的頂部新增或者刪除。

讓我們回到上面的程式碼片段,然後嘗試理解一下上面的程式碼片段是怎麼在JavaScript引擎內部執行的。

 const second = () => {
   console.log('hello there');
}

const first = () => {
    console.log('hi,there');
    second();
    console.log('The End');
}

first();
複製程式碼

上面程式碼的呼叫棧:

程式碼片段的呼叫棧
)

所以到底發生了什麼呢?

當程式碼執行的時候,一個全域性的執行上下文就被建立了(表示為main())然後將他壓入呼叫棧的頂部。當first()被呼叫的時候,first()又被壓入呼叫棧的頂部。

接下來,console.log('hi,there')又被壓入棧的頂部,當它執行結束,他就從棧中彈出了。之後,我們呼叫了second(),所以second()函式就被壓入棧頂。

console.log('Hello there!')被壓入棧頂,並且當它執行結束就被彈出。 此時,second()函式執行結束,所以從棧中彈出。

console.log('The End')被壓入棧頂然後再結束的時候被移出。然後,first()函式執行結束,被移出呼叫棧。

此時,整個程式結束呼叫,所以全域性執行上下文(main())從棧中彈出。

非同步JavaScript到底是怎麼執行的呢?

現在我們已經對呼叫棧有個大致瞭解了,也知道了同步的JavaScript是怎麼工作的,現在我們回到非同步JavaScript這個話題。

什麼是鎖?

我們想象一下我們正在使用同步的方式進行影象處理或者網路請求。比如:

const processImage = (image) => {
    //對影象進行處理
    console.log('Image Processed');
}

const netWorkRequest = (url) => {
    //網路資源請求
    return someData;
}

const greeting = () => {
    console.log('Hello World');
}

processImage(logo.jpg);
networkRequest('www.somerandomurl.com');
greeting();
複製程式碼

影象的處理和網路請求很花時間。所以當processImage()函式被呼叫的時候,花費的時間將取決於影象的大小。

processImage()函式結束,將會被從呼叫棧移出。之後networkRequest()函式被呼叫並且被壓入棧中。所以又要花費一些時間來結束呼叫。

最後當networkRequest()函式結束,greeting()函式被呼叫,因為他只包含一個console.log宣告,而且console.log宣告執行的非常地塊,所以greeting()函式很快的就結束呼叫了。

如你所見,我們必須要等,等到函式(就像processImage()networkRequest())結束執行。這就意味著這些函式被鎖在呼叫棧或者主執行緒裡。 所以在上述程式碼執行期間我們不能執行任何其他的操作,這不絕不是我們想要的。

所以怎麼解決?

最簡單的解決辦法就是非同步回撥。我們使用非同步回撥讓我們的程式碼不被鎖住。舉個栗子:

const networkRequest = () => {
    setTimeout(() => {
        console.log('Async Code');
    },2000);
};

console.log('Hello World');

networkRequest();
複製程式碼

在這裡我使用了setTimeout方法來模擬網路請求。請注意setTimeout不是Javascript引擎的一部分,它是Web Api(瀏覽器中)和 C/C++ (在node.js)中的一部分。

為了理解這段程式碼是如何執行的,我們需要理解更多的概念,比如像事件迴圈和回撥佇列(也叫做任務佇列或者訊息佇列)。

JavaScript執行環境的大致概覽

事件迴圈,WEB API, 訊息佇列/任務佇列不是JavaScript引擎的一部分,他們是瀏覽器的JavaScript執行時環境或者Node.js JavaScript 執行環境的一部分。 在Nodejs中,網路介面被C/C++ API 取代.

現在,讓我們回到上面的程式碼,然後看一看他們是怎麼以非同步的方式執行的。

const networkRequest = () => {
  setTimeout(() => {
    console.log('Async Code');
  }, 2000);
};

console.log('Hello World');
networkRequest();
console.log('The End');
複製程式碼

事件迴圈

當上面的程式碼在瀏覽器載入的時候,console.log('Hello World')入棧並且當呼叫結束的出棧。接下來,呼叫的是networkRequest(),所以它被推入棧頂。

接下來setTimeout()方法被呼叫,所以被壓入棧頂。setTimeout函式有2個引數:1) 回撥函式 2)以ms為單位的時間。 setTimeout在Web API環境中開始了一個為時2s的計時器。此時,setTimeout已經結束了,所以被彈出棧,接著,console.log('The End')被壓入棧,執行然後在結束後從棧中移出。

與此同時,計時器到時間了,現在回撥被推入到資訊佇列,但回撥並沒有被立即執行,而是被放到了事件迴圈開始的地方。

事件迴圈

事件迴圈的責任就是檢視呼叫棧並確定呼叫棧是否為空。如果呼叫棧為空,他就會檢視訊息佇列來確定是否有任何掛起的回撥函式等待被執行。

在這個例子中訊息佇列中包括一個回撥函式,並且此時呼叫棧為空。因此事件迴圈把回撥函式壓入棧頂。

在那之後,console.log(‘Async Code‘)這條語句被壓入棧頂,執行,然後從棧中彈出。此時回撥函式結束了,所以它被從棧中彈出,然後整個程式結束執行。

DOM 事件

訊息佇列中也包括DOM事件中的回撥函式比如點選事件和鍵盤事件,例如:

document.querySelector('.btn').addEventListener('click',(event) => {
    console.log('Button Clicked');
})
複製程式碼

在DOM事件裡,事件監聽器位於Web API 環境中等待某個事件發生(在這個例子中是點選事件),並且當該事件發生的時候,回撥函式則被放置在訊息佇列中等待被執行。

事件迴圈會再次檢查呼叫棧是否為空,如果為空的話,它會把事件回撥壓入棧中,然後回撥函式則被執行。

我們已經學習了非同步回撥和DOM 事件是如何執行的,他們使用訊息佇列來儲存所有等待被執行的回撥。

ES6 作業佇列/ 微任務佇列

ES6介紹了一種被JavaScript 中Promises使用的叫做作業佇列/微任務佇列的概念。訊息佇列和作業佇列的區別就在於作業佇列會比訊息佇列擁有更高的優先順序,也就是說作業佇列/微任務佇列中的Promise的任務會比訊息佇列中的回撥函式先執行。

例如:

console.log('Script start');

setTimeout(() => {
    console.log('setTimeout');
},0);

new Promise((resolve,reject) => {
    resolve('Promise resolved');
}).then(res => console.log(res))
.catch(err => console.log(err));

console.log('Script End');
複製程式碼

輸出:

Script start
Script End
Promise resolved
setTimeout
複製程式碼

我們可以看到promise是在setTimeout之前被執行的,因為promise的返回是儲存在微任務佇列中的,它比訊息佇列擁有更高的優先順序。

讓我們看下一個例子,這次有兩個Promises和兩個setTimeout。

    console.log('Script start');
    
    setTimeout(() => {
        console.log('setTimeout 1');
    },0);
    setTimeout(() => {
        console.log('setTimeout 2');
    },0);
    
    
    new Promise((resolve,reject) => {
        resolve('Promise 1 resolved');
    }).then(res => console.log(res))
    .catch(err => console.log(err));
    
    new Promise((resolve,reject) => {
        resolve('Promise 2 resolved');
    }).then(res => console.log(res))
    .catch(err => console.log(err));
    
    
    console.log('Script End');
複製程式碼

這一次輸出:

Script start
Script End
Promise 1 resolved
Promise 2 resolved
setTimeout 1
setTimeout 2
複製程式碼

我們可以看到兩個promise都是在setTimeout回撥的前面執行的,因為事件迴圈機制中,微任務佇列中的任務要優先於訊息佇列/任務佇列中的任務。

當事件迴圈正在執行微任務佇列中的任務時,如果另一個promise處於resolved的狀態的話,他會被新增到同一個微任務佇列的尾部,並且他會比訊息佇列中的回撥先執行,不管回撥函式已經等待執行了多久了。(優先順序高果然就是能為所欲為= =)。

舉個例子:

console.log('Script start');

setTimeout(() => {
  console.log('setTimeout');
}, 0);

new Promise((resolve, reject) => {
    resolve('Promise 1 resolved');
  }).then(res => console.log(res));
  
new Promise((resolve, reject) => {
  resolve('Promise 2 resolved');
  }).then(res => {
       console.log(res);
       return new Promise((resolve, reject) => {
         resolve('Promise 3 resolved');
       })
     }).then(res => console.log(res));
console.log('Script End');
複製程式碼

這次的輸出:

Script start
Script End
Promise 1 resolved
Promise 2 resolved
Promise 3 resolved
setTimeout
複製程式碼

所以所有在微任務佇列中的任務都將在訊息佇列中的任務之前執行。也就是說,事件迴圈將會在執行任何訊息佇列的回撥之前,首先清空微任務佇列中的任務。

總結

我們已經學習了非同步JavaScript是如何工作的,以及一些其他的概念比如說呼叫棧,事件迴圈,訊息/任務佇列以及工作/微任務佇列,他們在一起構成了JavaScript的執行環境。再重申一下,雖然您沒有必要將這些所有的概念都學習,來成為一個出色的JavaScript開發人員,但瞭解這些概念會很有幫助:)

今天的文章就這樣啦,如果你覺得這篇文章對你很有幫助,請點選旁邊的鼓掌按鈕,你也可以在MediumTwitter上面follow我。如果你有任何的疑問,歡迎在下面留言,我會很開心的幫助你的:)

譯者結語

如果你對我的翻譯或者內容有什麼意見或者建議歡迎在下面留言告訴我,喜歡文章就給個贊吧,非常感謝您的閱讀,Hava a nice day:)

相關文章