翻譯:瘋狂的技術宅javascript
從Call Stack,Global Memory,Event Loop,Callback Queue到 Promises 和 Async/Await 的 JavaScript引擎之旅!java
你有沒有想過瀏覽器是如何讀取和運行 JavaScript 代碼的嗎?這看起來很神奇,但你能夠學到一些發生在幕後的事情。讓咱們經過介紹 JavaScript 引擎的精彩世界在這種語言中盡情暢遊。node
在 Chrome 中打開瀏覽器控制檯,而後查看「Sources」標籤。你會看到一個有趣的命名:Call Stack(在Firefox中,你能夠在代碼中插入一個斷點後看到調用棧):git
什麼是調用棧(Call Stack)?看上去像是有不少東西正在運行,即便是隻執行幾行代碼也是如此。實際上,並非在全部 Web 瀏覽器上都能對 JavaScript 作到開箱即用。程序員
有一個很大的組件來編譯和解釋咱們的 JavaScript 代碼:它就是 JavaScript 引擎。最受歡迎的 JavaScript 引擎是V8,在 Google Chrome 和 Node.js 中使用,SpiderMonkey 用於 Firefox,以及 Safari/WebKit 所使用的 JavaScriptCore。github
今天的 JavaScript 引擎是個很傑出的工程,儘管它不可能覆蓋瀏覽器工做的方方面面,可是每一個引擎都有一些較小的部件在爲咱們努力工做。編程
其中一個組件是調用棧,它與全局內存和執行上下文一塊兒運行咱們的代碼。你準備好迎接他們了嗎?前端工程化
我認爲 JavaScript 既是編譯型語言又是解釋型語言。信不信由你,JavaScript 引擎在執行以前實際上編譯了你的代碼。數組
是否是聽起來很神奇?這種魔術被稱爲 JIT(即時編譯)。它自己就是一個很大的話題,即便是一本書也不足以描述 JIT 的工做原理。可是如今咱們能夠跳過編譯背後的理論,專一於執行階段,這仍然是頗有趣的。
先看如下代碼:
var num = 2;
function pow(num) {
return num * num;
}
複製代碼
若是我問你如何在瀏覽器中處理上述代碼?你會說些什麼?你可能會說「瀏覽器讀取代碼」或「瀏覽器執行代碼」。
現實中比那更加微妙。首先不是瀏覽器而是引擎讀取該代碼片斷。 JavaScript引擎讀取代碼,當遇到第一行時,就會將一些引用放入全局內存中。
**全局內存(也稱爲堆)**是 JavaScript 引擎用來保存變量和函數聲明的區域。因此回到前面的例子,當引擎讀取上面的代碼時,全局內存中被填充了兩個綁定:
即便例子中只有變量和函數,也要考慮你的 JavaScript 代碼在更大的環境中運行:瀏覽器或在 Node.js 中。在這些環境中,有許多預約義的函數和變量,被稱爲全局。全局內存將比 num 和 pow 所佔用的空間更多。記住這一點。
此時沒有執行任何操做,可是若是嘗試像這樣運行咱們的函數會怎麼樣:
var num = 2;
function pow(num) {
return num * num;
}
pow(num);
複製代碼
將會發生什麼?如今事情變得有趣了。當一個函數被調用時,JavaScript 引擎會爲另外兩個盒子騰出空間:
在上一節你瞭解了 JavaScript 引擎是如何讀取變量和函數聲明的,他們最終進入了全局內存(堆)。
可是如今咱們執行了一個 JavaScript 函數,引擎必需要處理它。怎麼處理?每一個 JavaScript 引擎都有一個基本組件,稱爲調用棧。
調用棧是一個棧數據結構:這意味着元素能夠從頂部進入,但若是在它們上面還有一些元素,就不能離開棧。 JavaScript 函數就是這樣的。
當函數開始執行時,若是被某些其餘函數卡住,那麼它沒法離開調用堆棧。請注意,由於這個概念有助於理解「JavaScript是單線程」這句話。
可是如今讓咱們回到上面的例子。 當調用該函數時,引擎會將該函數壓入調用堆棧中:
我喜歡將調用棧看做是一疊薯片。若是尚未先吃掉頂部的全部薯片,就吃不到到底部的薯片!幸運的是咱們的函數是同步的:它是一個簡單的乘法,能夠很快的獲得計算結果。
同時,引擎還分配了全局執行上下文,這是 JavaScript 代碼運行的全局環境。這是它的樣子:
想象一下全局執行環境做爲一個海洋,其中 JavaScript 全局函數就像魚同樣在裏面游泳。多麼美好!但這只是故事的一半。若是函數有一些嵌套變量或一個或多個內部函數怎麼辦?
即便在下面的簡單變體中,JavaScript 引擎也會建立本地執行上下文:
var num = 2;
function pow(num) {
var fixed = 89;
return num * num;
}
pow(num);
複製代碼
請注意,我在函數 pow 中添加了一個名爲 fixed 的變量。在這種狀況下,本地執行上下文中將包含一個用於保持固定的框。我不太擅長在小方框裏畫更小的框!你如今必須運用本身的想象力。
本地執行上下文將出如今 pow 附近,包含在全局執行上下文中的綠色框內。 你還能夠想象,對於嵌套函數中的每一個嵌套函數,引擎都會建立更多的本地執行上下文。這些框能夠很快的到達它們該去的地方。
咱們說 JavaScript 是單線程的,由於有一個調用棧處理咱們的函數。也就是說,若是有其餘函數等待執行,函數是不能離開調用棧的。
當處理同步代碼時,這不是什麼問題。例如,計算兩個數字的和就是同步的,而且以微秒作爲運行單位。可是當進行網絡通訊和與外界的互動時呢?
幸運的是 JavaScript引擎被默認設計爲異步。即便他們一次能夠執行一個函數,也有一種方法可讓外部實體執行較慢的函數:在咱們的例子中是瀏覽器。咱們稍後會探討這個話題。
這時,你應該瞭解到當瀏覽器加載某些 JavaScript 代碼時,引擎會逐行讀取並執行如下步驟:
到此爲止,你腦子裏應該有了一個 JavaScript 引擎同步機制的全景圖。在接下來的部分中,你將看到異步代碼如何在 JavaScript 中工做以及爲何這樣工做。
全局內存、執行上下文和調用棧解釋了同步 JavaScript 代碼在瀏覽器中的運行方式。然而咱們還錯過了一些東西。當有異步函數運行時會發生什麼?
我所指的異步函數是每次與外界的互動都須要一些時間才能完成的函數。例如調用 REST API 或調用計時器是異步的,由於它們可能須要幾秒鐘才能運行完畢。 如今的 JavaScript 引擎都有辦法處理這種函數而不會阻塞調用堆棧,瀏覽器也是如此。
請記住,調用堆棧一次只能夠執行一個函數,**甚至一個阻塞函數均可以直接凍結瀏覽器。**幸運的是,JavaScript 引擎很是智能,而且能在瀏覽器的幫助下解決問題。
當咱們運行異步函數時,瀏覽器會接受該函數並運行它。考慮下面的計時器:
setTimeout(callback, 10000);
function callback(){
console.log('hello timer!');
}
複製代碼
你確定屢次見到過 setTimeout ,可是你可能不知道它不是一個內置的 JavaScript 函數。即當 JavaScript 誕生時,語言中並無內置的 setTimeout。
實際上 setTimeout 是所謂的 Browser 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 如今只作一件事:它應檢查調用棧是否爲空。若是回調隊列中有一些函數,而且若是調用棧是空閒的,那麼這時應將回調送到調用棧。在完成後執行該函數。
這是用於處理異步和同步代碼的 JavaScript 引擎的大圖:
想象一下,callback() 已準備好執行。當 pow() 完成時,**調用棧爲空,事件循環推送 **callback()。就是這樣!即便我簡化了一些東西,若是你理解了上面的圖,那麼就能夠理解 JavaScript 的一切了。
請記住:Browser API、回調隊列和事件循環是異步 JavaScript 的支柱。
若是你喜歡視頻,我建議去看 Philip Roberts 的視頻:事件循環是什麼。這是關於時間循環的最好的解釋之一。
youtube: www.youtube.com/embed/8aGhZ…
堅持下去,由於咱們尚未使用異步 JavaScript。在後面的內容中,咱們將詳細介紹 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 Promise。 ES6 Promise 是對 JavaScript 語言的補充,旨在解決可怕的回調地獄。但 Promise 是什麼?
JavaScript Promise 是將來事件的表示。Promise 可以以 success 結束:用行話說就是它已經 resolved(已經完成)。但若是 Promise 出錯,咱們會說它處於rejected狀態。 Promise 也有一個默認狀態:每一個新Promise都以 pending 狀態開始。
要建立新的 Promise,能夠經過將回調函數傳給要調用的 Promise 構造函數的方法。回調函數可使用兩個參數:resolve 和 reject。讓咱們建立一個新的 Promise,它將在5秒後 resolve(你能夠在瀏覽器的控制檯中嘗試這些例子):
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 ,由於它是第二個參數。可是若是你打算使用 reject**,就不能省略 resolve**。換句話說,如下代碼將沒法工做,最終將以 resolved 的 Promise 結束:
// Can't omit 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 是將來發生的事件的書籤。事件以掛起狀態開始,能夠成功(resolved,fulfilled)或失敗(rejected)。 Promise 能夠返回數據,經過把 then 附加到 Promise 來提取數據。在下一節中,咱們將看到如何處理來自 Promise 的錯誤。
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 有一種處理異步錯誤的方法,就像它們是同步的同樣。
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() 來建立和 reject Promise:
Promise.reject({msg: 'Rejected!'}).catch(err => console.log(err));
複製代碼
Promise 並非在孤軍奮戰。 Promise API 提供了一系列將 Promise 組合在一塊兒的方法。其中最有用的是Promise.all,它接受一系列 Promise 並返回一個Promise。問題是當任何一個Promise rejected時,Promise.all 就會 rejects 。
Promise.race 在數組中的一個 Promise 結束後當即 resolves 或 reject。若是其中一個Promise rejects ,它仍然會rejects。
較新版本的 V8 也將實現兩個新的組合器:**Promise.allSettled **和 Promise.any。 Promise.any 仍處於提案的早期階段:在撰寫本文時,還不支持。
Promise.any 能夠代表任何 Promise 是否 fullfilled。與 Promise.race 的區別在於 Promise.any 不會 reject,即便是其中一個Promise 被 rejected。
最有趣的是 Promise.allSettled。它仍然須要一系列的 Promise,但若是其中一個 Promise rejects 的話 ,它不會被短路。當你想要檢查 Promise 數組中是否所有已解決時,它是有用的。能夠認爲它老是和 Promise.all 對着幹。
若是你還記得前面的章節**,JavaScript 中的每一個異步回調函數都會在被推入調用棧以前在回調隊列中結束**。可是在 Promise 中傳遞的回調函數有不一樣的命運:它們由微任務隊列處理,而不是由回調隊列處理。
你應該注意一個有趣的現象:微任務隊列優先於回調隊列。當事件循環檢查是否有任何新的回調準備好被推入調用棧時,來自微任務隊列的回調具備優先權。
Jake Archibald 在任務、微任務、隊列和時間表一文中更詳細地介紹了這些機制,這是一篇很棒的文章。
JavaScript 正在快速發展,每一年咱們都會不斷改進語言。Promise 彷佛是到達了終點,但 **ECMAScript 2017(ES8)的新語法誕生了:async / await **。
async/await 只是一種風格上的改進,咱們稱之爲語法糖。 async/await 不會以任何方式改變 JavaScript(請記住,JavaScript 必須向後兼容舊瀏覽器,不該破壞現有代碼)。
它只是一種基於 Promise 編寫異步代碼的新方法。讓咱們舉個例子。以前咱們用 then 的 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 處理程序。運行上面的代碼將致使 「抓住我,若是你能夠」,而後「無論怎樣我都會跑!」。
實際上咱們不但願 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。這必須是與團隊討論以後的選擇。可是若是你本身工做的話,不管你使用簡單的 Promise 仍是 async/await 都是屬於我的偏好的問題。
JavaScript 是一種用於Web的腳本語言,具備先被編譯而後再由引擎解釋的特性。在最流行的 JavaScript 引擎中,有 Google Chrome 和 Node.js 使用的V8,爲網絡瀏覽器 Firefox 構建的 SpiderMonkey,由Safari使用的 JavaScriptCore。
JavaScript 引擎有不少部分組成:調用棧、全局內存、事件循環和回調隊列。全部這些部分在完美的調整中協同工做,以便在 JavaScript 中處理同步和異步代碼。
JavaScript 引擎是單線程的,這意味着只有一個用於運行函數的調用堆棧。這種限制是 JavaScript 異步性質的基礎:全部須要時間的操做必須由外部實體(例如瀏覽器)或回調函數負責。
爲了簡化異步代碼流程,ECMAScript 2015 給咱們帶來了 Promises。 Promise 是一個異步對象,用於表示異步操做的失敗或成功。但改進並無止步於此。 2017年 async/await誕生了:它是 Promise 的一種風格上的彌補,能夠用來編寫異步代碼,就好像它是同步的同樣。