【譯】JavaScript 引擎如何工做?從調用棧到 Promise,幾乎有你須要知道的一切

原文地址javascript

有沒有想過瀏覽器如何讀取和運行JavaScript代碼? 這看起來很神奇,但你能夠獲得一些發生在幕後的線索。java

讓咱們經過介紹JavaScript引擎的精彩世界來沉浸在其語言之中。程序員

在Chrome中打開瀏覽器控制檯,而後查看「Sources」標籤。 你會看到一些盒子,其中有趣的一個叫Call Stack(在Firefox中,你能夠在代碼中插入一個斷點後看到調用堆棧):es6

什麼是調用堆棧? 彷佛有不少事情要講,即便它運行幾行代碼。 事實上,對Web瀏覽器JavaScript並非開箱即用的。編程

編譯和解釋JavaScript代碼是重要的一部分,這就是JavaScript引擎。最流行的JavaScript引擎是V8,谷歌Chrome和Node.js,使用SpiderMonkey的Firefox,使用JavaScriptCore的Safari / WebKit。數組

今天的,avaScript引擎是偉大的項目,並無涵蓋它們的每一個方面。每一個引擎機的工做內容中都有一些小部分,對咱們來講很難。promise

其中一個組件是調用堆棧,它與全局內存和執行上下文一塊兒使運行咱們的代碼成爲可能。準備好了解它們?瀏覽器

目錄

JavaScript引擎和全局內存

我提到JavaScript既是編譯語言同時又是解釋語言。信不信由你,JavaScript引擎在執行以前實際上只用幾微妙編譯了你的代碼。bash

這聽起來很神奇嗎? 它被稱做JIT(即時編譯)。這自己就是一個很大的話題,另外一本書不足以描述JIT的工做原理。可是如今咱們能夠跳過編譯背後的理論,並專一於執行階段,這並不會減小樂趣。網絡

首先考慮如下代碼:

var num = 2;
function pow(num) {
    return num * num;
}
複製代碼

若是我問你如何在瀏覽器中處理上述代碼?你會說些什麼?你可能會說「瀏覽器讀取代碼」或「瀏覽器執行代碼」。

現實比那更微妙。首先,瀏覽器不是讀取該代碼片斷。這是引擎。JavaScript引擎讀取代碼,一旦遇到第一行,它就會將一些引用放入全局內存中。

**全局內存(也稱爲Heap)**是JavaScript引擎保存變量和函數聲明的區域。因此,回到咱們的例子,當引擎讀取上面的代碼時,全局內存中填充了兩個綁定:

var num = 2;
function pow(num) {
    return num * num;
}
pow(num);
複製代碼

將會發生什麼?如今事情變得有趣了。當一個函數被調用時,JavaScript引擎爲另外兩個盒子騰出空間:

  • 全局執行上下文
  • 調用堆棧

讓咱們看看它們在下一節中的含義。

全局執行上下文和調用堆棧

你瞭解了JavaScript引擎如何讀取變量和函數聲明。它們最終在全局內存(Heap)中結束。

可是如今咱們執行了一個JavaScript函數,引擎必需要處理它。怎麼辦?每一個JavaScript引擎都有一個基本組件,叫作調用棧

調用棧是一個棧數據結構:這意味着元素能夠從頂部進入,但若是它們上面有一些元素,它們就不能離開。JavaScript函數就是這樣的。

一旦執行,若是某些其餘功能仍然卡住,則沒法離開調用堆棧。 請注意,在腦海中記着「JavaScript是單線程」有助於理解這個概念。。

可是如今讓咱們回到咱們的例子。調用該函數時,引擎會在調用堆棧中推送該函數:

我喜歡將Call Stack視爲一桶薯片。若是沒有先吃掉頂部的薯片,就不能吃到底部的薯片!幸運的是咱們的功能是同步的:它是一個簡單的乘法,它能夠快速計算出來。

同時,引擎還分配了一個全局執行上下文,這是咱們運行JavaScript代碼的全局環境。這是它的樣子:

想象全局執行上下文是個海洋,其中JavaScript全局函數像魚同樣遊動。如此美妙!但那只是故事的一半。若是咱們的函數有一些嵌套變量或一個或多個內部函數怎麼辦?

即便在以下的簡單變體中,JavaScript引擎也會建立本地執行上下文:

var num = 2;
function pow(num) {
    var fixed = 89;
    return num * num;
}
pow(num);
複製代碼

請注意,我在函數pow中添加了一個名爲fixed的變量。在這種狀況下,本地執行上下文將包含一個用於保持固定的盒子。

我不太擅長在其餘小盒子裏畫小小的盒子!你不得不用你的想象力。

本地執行上下文將出如今 pow 附近,包含在全局執行上下文中的綠色框內。還能夠想象,對於嵌套函數的每一個嵌套函數,引擎都會建立更多本地執行上下文。 這些盒子能夠很快到達目的地!像俄羅斯套娃!

如今回到單線程故事怎麼樣? 這是什麼意思?

JavaScript的單線程和其餘有趣的故事

咱們說 JavaScript是單線程的,由於有一個Call Stack處理咱們的函數。也就是說,若是有其餘函數等待執行,函數不能離開調用堆棧。

處理同步代碼時,這不是問題。例如,兩個數字之間的和是同步的,並以微秒爲單位運行。可是網絡調用和與外界的其餘互動怎麼辦?

幸運的是,JavaScript引擎默認設計爲異步。即便他們一次能夠執行一個函數,也有一種方法可讓外部實體執行較慢的函數:瀏覽器就是一個例子。咱們稍後會探討這個話題。

在此期間,瞭解到當瀏覽器加載某些JavaScript代碼時,引擎會逐行讀取並執行如下步驟:

  • 使用變量和函數聲明填充全局內存(堆)
  • 將每一個函數調用推送到調用堆棧
  • 建立全局執行上下文,其中執行全局函數
  • 建立許多微小的本地執行上下文(若是有內部變量或嵌套函數)

到目前爲止,您應該已經瞭解了每一個JavaScript引擎基礎上的同步機制。在接下來的部分中,您將看到異步代碼在JavaScript中的工做原理以及它爲什麼如此工做。

異步JavaScript,回調隊列和事件循環

全局內存,執行上下文和調用堆棧解釋了同步JavaScript代碼在瀏覽器中的運行方式。 然而,咱們錯過了一些東西。 當有一些異步函數運行時會發生什麼?

經過異步函數,與外界的每次互動都須要一些時間才能完成。 調用REST API或調用計時器是異步的,由於它們可能須要幾秒鐘才能運行。 使用咱們到目前爲止在引擎中的元素,如今有辦法處理這種函數而不會阻塞調用堆棧,瀏覽器也是如此。

請記住,調用堆棧一次能夠執行一個函數,甚至一個阻塞函數也能夠直接凍結瀏覽器。 幸運的是JavaScript引擎是聰明的,而且在瀏覽器的幫助下能夠解決問題。

當咱們運行異步函數時,瀏覽器會獲取該函數併爲咱們運行它。 考慮以下的計時器:

setTimeout(callback, 10000);

function callback(){
    console.log('hello timer!');
}
複製代碼

我肯定你看過setTimeout數百次,但你可能不知道它不是內置的JavaScript函數。也就是說,當JavaScript誕生時,語言中沒有內置的setTimeout。

事實上,setTimeout是所謂的瀏覽器API的一部分,瀏覽器API是瀏覽器免費提供給咱們的便捷工具的集合。這個不錯!這在實踐中意味着什麼?因爲setTimeout是一個瀏覽器API,該功能由瀏覽器直接運行(它會暫時顯示在調用堆棧中,但會當即刪除)。

而後在10秒後,瀏覽器接受咱們傳入的回調函數並將其移動到回調隊列中。此時咱們的JavaScript引擎中還有兩個盒子。 若是您考慮如下代碼:

var num = 2;
function pow(num) {
    return num * num;
}
pow(num);
setTimeout(callback, 10000);
function callback(){
    console.log('hello timer!');
}
複製代碼

咱們能夠這樣完成咱們的插圖:

如你所見,**setTimeout在瀏覽器上下文中運行。10秒後,計時器被觸發,回調功能準備好運行。但首先它必須經過回調隊列。**回調隊列是一個隊列數據結構,顧名思義是一個有序的函數隊列。

每一個異步函數在被推入調用堆棧以前必須經過回調隊列。但誰推進了這個功能?還有另外一個名爲Event Loop的組件。

Event Loop如今只有一個工做:它應檢查Call Stack是否爲空。 若是回調隊列中有一些功能,而且若是調用堆棧是空閒的,那麼是時候將回調推送到調用堆棧。

完成後,執行該功能。 這是用於處理異步和同步代碼的JavaScript引擎的大圖:

想象一下,callback()已準備好執行。 當pow()完成時,Call Stack爲空,Event Loop推送callback()。就是這樣! 即便我簡化了一些事情,若是你理解了上面的插圖,那麼你就能夠理解全部的JavaScript了。

請記住:瀏覽器API,回調隊列和事件循環是異步JavaScript的支柱。

若是您喜歡視頻,我建議觀看Philip Roberts不管如何都要看事件循環。這是Event Loop有史以來最好的解釋之一。

堅持下去,由於異步JavaScript尚未完成。在接下來的部分中,咱們將詳細介紹ES6 Promises。

回調地獄和 ES6 Promises

回調函數在JavaScript中無處不在。 它們用於同步和異步代碼。考慮map方法,例如:

function mapper(element){
    return element * 2;
}
[1, 2, 3, 4, 5].map(mapper);
複製代碼

mapper是在map中傳遞的回調函數。上面的代碼是同步的。但請考慮一個間隔:

function runMeEvery(){
    console.log('Ran!');
}
setInterval(runMeEvery, 5000);
複製代碼

該代碼是異步的,但正如您所看到的,咱們在setInterval中傳遞了回調runMeEvery。回調在JavaScript中很廣泛,因此在這些年裏出現了一個問題:回調地獄。

JavaScript中的回調地獄指的是編程的「風格」,其中回調嵌套在內部嵌套的回調中......在其餘回調中。因爲JavaScript程序員的異步性質多年來陷入了這個陷阱。

說實話,我歷來沒有碰到過極端的回調金字塔,也許是由於我重視可讀代碼並且我老是試着堅持這個原則。若是你以回調地獄結束,那就代表你的功能太多了。

我不會在這裏討論回調地獄,若是你好奇有一個網站,callbackhell.com更詳細地探討了這個問題並提供了一些解決方案。咱們如今要關注的是ES6 Promises。ES6 Promises是JavaScript語言的補充,旨在解決可怕的回調地獄。但不管如何,Promise是什麼?

JavaScript Promise是將來事件的表示。承諾能夠以成功結束:用行話說咱們已經解決了(履行)。但若是Promise出錯,咱們會說它處於拒絕狀態。 Promise也有一個默認狀態:每一個新的Promise都以掛起狀態開始。能夠建立本身的Promise嗎?是。讓咱們進入下一節看看如何作。

建立和使用JavaScript Promises

要建立新的Promise,能夠經過將回調函數傳遞給它來調用Promise構造函數。回調函數能夠採用兩個參數:resolvereject。讓咱們建立一個新的Promise,它將在5秒後解析(您能夠在瀏覽器的控制檯中嘗試這些示例):

const myPromise = new Promise(function(resolve){
    setTimeout(function(){
        resolve()
    }, 5000)
});
複製代碼

正如您所看到的,resolve 是一個函數,爲咱們成功調用Promise而生。Reject 相反地產生一個 rejected Promise:

const myPromise = new Promise(function(resolve, reject){
    setTimeout(function(){
        reject()
    }, 5000)
});
複製代碼

請注意,在第一個示例中,您能夠省略拒絕,由於它是第二個參數。但若是你打算使用 reject,你就不能省略 resolve。換句話說,如下代碼將沒法工做,最終將以已解決的Promise結束:

//不能省略resolve!
const myPromise = new Promise(function(reject){
    setTimeout(function(){
        reject()
    }, 5000)
});
複製代碼

如今,Promise看起來不那麼有用不是嗎?這些示例不向用戶打印任何內容。讓咱們添加一些數據。resolved 和 rejected 的 Promises 均可以返回數據。這是一個例子:

const myPromise = new Promise(function(resolve) {
  resolve([{ name: "Chris" }]);
});
複製代碼

但咱們仍然看不到任何數據。要從Promise中提取數據,須要連接一個名爲then的方法。它須要一個回調(具備諷刺意味的!)來接收實際數據:

const myPromise = new Promise(function(resolve, reject) {
  resolve([{ name: "Chris" }]);
});
myPromise.then(function(data) {
    console.log(data);
});
複製代碼

做爲JavaScript開發人員和其餘人代碼的消費者,將主要與來自外部的Promises進行交互。相反,庫建立者更有可能將遺留代碼包裝在Promise構造函數中,以下所示:

const shinyNewUtil = new Promise(function(resolve, reject) {
  // do stuff and resolve
  // or reject
});
複製代碼

在須要時,咱們還能夠經過調用 Promise.resolve() 來建立和解決Promise:

Promise.resolve({ msg: 'Resolve!'})
.then(msg => console.log(msg));
複製代碼

所以,回顧一下JavaScript Promise是將來發生的事件的書籤。事件以掛起狀態啓動,能夠成功(已解決,已履行)或失敗(已拒絕)。 Promise能夠返回數據,而後經過附加到Promise來提取數據。在下一節中,咱們將看到如何處理來自Promise的錯誤。

ES6 Promises中的錯誤處理

JavaScript中的錯誤處理一直很簡單,至少對於同步代碼而言。請考慮如下示例:

function makeAnError() {
  throw Error("Sorry mate!");
}
try {
  makeAnError();
} catch (error) {
  console.log("Catching the error! " + error);
}
複製代碼

輸出將是:

Catching the error! Error: Sorry mate!
複製代碼

錯誤是預期的catch塊。如今讓咱們嘗試使用異步函數:

function makeAnError() {
  throw Error("Sorry mate!");
}
try {
  setTimeout(makeAnError, 5000);
} catch (error) {
  console.log("Catching the error! " + error);
}
複製代碼

因爲setTimeout,上面的代碼是異步的。若是咱們運行它會發生什麼?

throw Error("Sorry mate!");
  ^
Error: Sorry mate!
    at Timeout.makeAnError [as _onTimeout] (/home/valentino/Code/piccolo-javascript/async.js:2:9)
複製代碼

此次輸出是不一樣的。錯誤沒有經過catch塊。它能夠自由地在堆棧中傳播。

那是由於try / catch僅適用於同步代碼。若是你很好奇,Node.js中的錯誤處理會詳細解釋這個問題。

幸運的是,Promise有一種處理異步錯誤的方法,就像它們是同步的同樣。若是你回憶起上一節中的 reject, 產生了一個拒絕的 Promise:

const myPromise = new Promise(function(resolve, reject) {
  reject('Errored, sorry!');
});
複製代碼

在上面的例子中,咱們可使用catch處理程序處理錯誤,再次採起回調:

const myPromise = new Promise(function(resolve, reject) {
  reject('Errored, sorry!');
});
myPromise.catch(err => console.log(err));
複製代碼

咱們也能夠調用Promise.reject()來建立和拒絕Promise:

Promise.reject({msg: 'Rejected!'}).catch(err => console.log(err));
複製代碼

回顧一下:當一個Promise被填滿時,then處理程序運行,而catch處理程序運行被拒絕的Promises。但這不是故事的結局。稍後咱們將看到async / await如何與try / catch很好地協做。

ES6 Promises 組合:Promise.all,Promise.allSettled,Promise.any

Promises 並不意味着孤軍奮戰。 Promise API 提供了許多將 Promise 組合在一塊兒的方法。其中最有用的是Promise.all,它接受一個 Promises 數組並返回一個Promise。當數組中的任何 Promise 是 reject 時,Promise.all 返回 reject。

只要數組中的一個Promise 有結果,Promise.race 就會是 resolves 或者 reject。若是其中一個 Promise 拒絕,它仍然會 reject。

較新版本的V8也將實現兩個新的組合器:Promise.allSettled 和 Promise.any。 Promise.any仍然處於提案的早期階段:在撰寫本文時,仍然沒有人支持它。

但理論是Promise.any能夠代表任何Promise是否都知足 resolve。與Promise.race的區別在於Promise.any不會reject,即便其中一個Promise 是 reject。

不管如何,二者中最有趣的是Promise.allSettled。它仍然須要一系列Promise,但若是其中一個Promise 是 reject,它不會短路。當您想要檢查Promise數組是否所有返回,它是有用的,不管最終是否是拒絕。把它想象成 Promise.all 地對立面。

ES6 Promises和microtask隊列

若是你記得之前的部分,**JavaScript中的每一個異步回調函數都會在被推入調用堆棧以前在回調隊列中結束。**可是在Promise中傳遞的回調函數有不一樣的命運:它們由Microtask Queue(微任務隊列)處理,而不是由Callback Queue(回調隊列)處理。

你應該注意一個有趣的怪癖:微任務隊列優先於回調隊列。當事件循環檢查是否有任何新的回調準備好被推入調用堆棧時,來自微任務隊列的回調具備優先權。

Jake Archibald在任務,微任務,隊列和日程安排中更詳細地介紹了這些機制,這是值得一讀。

JavaScript引擎:它們如何工做?異步進化:從 Promises 到 async/await

JavaScript正在快速發展,每一年咱們都會不斷改進語言。 Promises彷佛是到達點,可是在ECMAScript 2017(ES8)中誕生了一種新的語法:async / await。

async/await只是一種風格上的改進,咱們稱之爲語法糖。 async/await不會以任何方式改變JavaScript(請記住,JavaScript必須向後兼容舊瀏覽器,不該破壞現有代碼)。

它只是一種基於Promises編寫異步代碼的新方法。讓咱們舉個例子。以前咱們用相應的保存Promise:

const myPromise = new Promise(function(resolve, reject) {
  resolve([{ name: "Chris" }]);
});
myPromise.then((data) => console.log(data))
複製代碼

如今 使用 async/await,咱們能夠從讀者的角度看待同步的異步代碼。 咱們能夠將Promise包裝在標記爲async的函數中,而後等待結果:

const myPromise = new Promise(function(resolve, reject) {
  resolve([{ name: "Chris" }]);
});
async function getData() {
  const data = await myPromise;
  console.log(data);
}
getData();
複製代碼

有道理嗎? 如今,有趣的是,異步函數將始終返回Promise,而且沒有人阻止您這樣作:

async function getData() {
  const data = await myPromise;
  return data;
}
getData().then(data => console.log(data));
複製代碼

錯誤怎麼樣? async/await 提供的一個好處就是有機會使用 try/catch。 (這裏介紹了處理異步函數中的錯誤以及如何測試它們)。 讓咱們再看一下Promise,咱們使用catch處理程序來處理錯誤:

const myPromise = new Promise(function(resolve, reject) {
  reject('Errored, sorry!');
});
myPromise.catch(err => console.log(err));
複製代碼

使用異步函數,咱們能夠重構如下代碼:

async function getData() {
  try {
    const data = await myPromise;
    console.log(data);
    // or return the data with return data
  } catch (error) {
    console.log(error);
  }
}
getData();
複製代碼

不是每一個人都接受這種風格。 try/catch 可使你的代碼嘈雜。雖然使用 try/catch 還有另外一個怪癖要指出。請考慮如下代碼,在try塊中引起錯誤:

async function getData() {
  try {
    if (true) {
      throw Error("Catch me if you can");
    }
  } catch (err) {
    console.log(err.message);
  }
}
getData()
  .then(() => console.log("I will run no matter what!"))
  .catch(() => console.log("Catching err"));
複製代碼

兩個字符串中的哪個打印到控制檯? 請記住,try/catch是一個同步構造,但咱們的異步函數產生一個Promise。 他們在兩條不一樣的軌道上行駛,好比兩列火車。

但他們永遠不會見面! 也就是說,throw引起的錯誤永遠不會觸發 getData()的 catch 處理程序。 運行上面的代碼將致使「Catch me if you can」,而後是「I will run no matter what!」。

在現實世界中,咱們不但願throw觸發當時的處理程序。 一種可能的解決方案是從函數返回Promise.reject():

async function getData() {
  try {
    if (true) {
      return Promise.reject("Catch me if you can");
    }
  } catch (err) {
    console.log(err.message);
  }
}
複製代碼

如今錯誤處理是預期的那樣了:

getData()
  .then(() => console.log("I will NOT run no matter what!"))
  .catch(() => console.log("Catching err"));
"Catching err" // output
複製代碼

除此以外,async / await彷佛是在JavaScript中構建異步代碼的最佳方式。咱們能夠更好地控制錯誤處理,代碼看起來更清晰。

不管如何,我不建議將全部JavaScript代碼重構爲async / await。這些是必須與團隊討論的選擇。可是若是你單獨工做,不管你使用簡單的Promises, 仍是 async/await 它都是我的偏好的問題。

總結

JavaScript 是一種用於Web的腳本語言,具備首先編譯而後由引擎解釋的特性。 在最流行的JavaScript引擎中,有谷歌Chrome和Node.js使用的V8,爲網絡瀏覽器Firefox構建的SpiderMonkey,以及Safari使用的JavaScriptCore。

JavaScript引擎有不少使人激動的部分:調用堆棧,全局內存,事件循環,回調隊列。全部這些部分在完美調整中協同工做,以便在JavaScript中處理同步和異步代碼。

JavaScript引擎是單線程的,這意味着有一個用於運行函數的Call Stack。這種限制是JavaScript異步性質的基礎:全部須要時間的操做必須由外部實體(例如瀏覽器)或回調函數負責。

爲了簡化異步代碼流,ECMAScript 2015給咱們帶來了 Promise。Promise是一個異步對象,用於表示任何異步操做的失敗或成功。但改進並無止步於此。在2017年,async/await誕生了:它是Promise的一種風格彌補,使得編寫異步代碼成爲可能,就好像它是同步的同樣。

感謝閱讀並敬請關注此博客!

pic
相關文章
相關標籤/搜索