【JS 口袋書】第 4 章:JS 引擎底層的工做原理

做者:valentinogagliardi
譯者:前端小智
來源:github

阿里雲最近在作活動,低至2折,有興趣能夠看看:
https://promotion.aliyun.com/...

爲了保證的可讀性,本文采用意譯而非直譯。javascript

有沒有想過瀏覽器如何讀取和運行JS代碼? 這看起來很神奇,咱們能夠經過瀏覽器提供的控制檯來了解背後的一些原理。html

在Chrome中打開瀏覽器控制檯,而後查看Sources這欄,在右側能夠到一個 Call Stack 盒子。前端

clipboard.png

JS 引擎是一個能夠編譯和解釋咱們的JS代碼強大的組件。 最受歡迎的JS 引擎是V8,由 Google Chrome 和 Node.j s使用,SpiderMonkey 用於Firefox,以及Safari/WebKit使用的 JavaScriptCore。java

雖然如今 JS 引擎不是幫咱們處理全面的工做。可是每一個引擎中都有一些較小的組件爲咱們作繁瑣的的工做。node

其中一個組件是調用堆棧(Call Stack),與全局內存和執行上下文一塊兒運行咱們的代碼。git

Js 引擎和全局內存(Global Memory)

JavaScript 是編譯語言同時也是解釋語言。信不信由你,JS 引擎在執行代碼以前只須要幾微秒就能編譯代碼。程序員

這聽起來很神奇,對吧?這種神奇的功能稱爲JIT(及時編譯)。這個是一個很大的話題,一本書都不足以描述JIT是如何工做的。但如今,咱們午餐能夠跳過編譯背後的理論,將重點放在執行階段,儘管如此,這仍然頗有趣。github

考慮如下代碼:編程

var num = 2;
function pow(num) {
    return num * num;
}

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

現實比這更微妙。首先,讀取這段代碼的不是瀏覽器,是JS引擎。JS引擎讀取代碼,一旦遇到第一行,就會將幾個引用放入全局內存

全局內存(也稱爲堆)JS引擎保存變量和函數聲明的地方。所以,回到上面示例,當 JS引擎讀取上面的代碼時,全局內存中放入了兩個綁定。

clipboard.png

即便示例只有變量和函數,也要考慮你的JS代碼在更大的環境中運行:在瀏覽器中或在Node.js中。 在這些環境中,有許多預約義的函數和變量,稱爲全局變量。 全球記憶將比num和pow更多。

上例中,沒有執行任何操做,可是若是咱們像這樣運行函數會怎麼樣呢:

var num = 2;
function pow(num) {
    return num * num;
}
pow(num);

如今事情變得有趣了。當函數被調用時,JavaScript引擎會爲全局執行上下文調用棧騰出空間。

JS引擎:它們是如何工做的? 全局執行上下文和調用堆棧

剛剛瞭解了 JS引擎如何讀取變量和函數聲明,它們最終被放入了全局內存(堆)中。

但如今咱們執行了一個JS函數,JS引擎必須處理它。怎麼作?每一個JS引擎中都有一個基本組件,叫調用堆棧

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

一旦執行,若是其餘函數仍然被阻塞,它們就不能離開調用堆棧。請注意,這個有助於你理解「JavaScript是單線程的」這句話。

回到咱們的例子,當函數被調用時,JS引擎將該函數推入調用堆棧

clipboard.png

同時,JS 引擎還分配了一個全局執行上下文,這是運行JS代碼的全局環境,以下所示

clipboard.png

想象全局執行上下文是一個海洋,其中全局函數像魚同樣遊動,多美好! 但現實遠非那麼簡單, 若是我函數有一些嵌套變量或一個或多個內部函數怎麼辦?

即便是像下面這樣的簡單變化,JS引擎也會建立一個本地執行上下文:

var num = 2;
function pow(num) {
    var fixed = 89;
    return num * num;
}
pow(num);

注意,我在pow函數中添加了一個名爲fixed的變量。在這種狀況下,pow函數中會建立一個本地執行上下文,fixed 變量被放入pow函數中的本地執行上下文中。

對於嵌套函數的每一個嵌套函數,引擎都會建立更多的本地執行上下文。

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

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

在處理同步代碼時,這不是問題。例如,兩個數字之間的和是同步的,以微秒爲單位。但若是涉及異步的時候,怎麼辦呢?

幸運的是,默認狀況下JS引擎是異步的。即便它一次執行一個函數,也有一種方法可讓外部(如:瀏覽器)執行速度較慢的函數,稍後探討這個主題。

當瀏覽器加載某些JS代碼時,JS引擎會逐行讀取並執行如下步驟:

  • 將變量和函數的聲明放入全局內存(堆)中
  • 將函數的調用放入調用堆棧
  • 建立全局執行上下文,在其中執行全局函數
  • 建立多個本地執行上下文(若是有內部變量或嵌套函數)

到目前爲止,對JS引擎的同步機制有了基本的瞭解。 在接下來的部分中,講講 JS 異步工做原理。

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

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

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

當咱們運行一個異步函數時,瀏覽器接受該函數並運行它。考慮以下代碼:

setTimeout(callback, 10000);
function callback(){
    console.log('hello timer!');
}

setTimeout 你們都知道得用得不少次了,但你可能不知道它不是內置的JS函數。 也就是說,當JS 出現,語言中沒有內置的setTimeout

setTimeout瀏覽器API( Browser API)的一部分,它是瀏覽器免費提供給咱們的一組方便的工具。這在實戰中意味着什麼?因爲setTimeout是一個瀏覽器的一個Api,函數由瀏覽器直接運行(它會在調用堆棧中出現一下子,但會當即刪除)。

10秒後,瀏覽器接受咱們傳入的回調函數並將其移動到回調隊列(Callback Queu)中。。考慮如下代碼

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

示意圖以下:

clipboard.png

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

每一個異步函數在被放入調用堆棧以前必須經過回調隊列,但這個工做是誰作的呢,那就是事件循環(Event Loop)。

事件循環只有一個任務:它檢查調用堆棧是否爲空。若是回調隊列中(Callback Queue)有某個函數,而且調用堆棧是空閒的,那麼就將其放入調用堆棧中。

完成後,執行該函數。 如下是用於處理異步和同步代碼的JS引擎的圖:

clipboard.png

想象一下,callback() 已準備好執行,當 pow() 完成時,調用堆棧(Call Stack) 爲空,事件循環(Event Look) 將 callback() 放入調用堆中。大概就是這樣,若是你理解了上面的插圖,那麼你就能夠理解全部的JavaScript了。

回調地獄和 ES6 中的Promises

JS 中回調函數無處不在,它們用於同步和異步代碼。 考慮以下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。回調在JS中無處不在,所以就會出現了一個問題:回調地獄

JavaScript 中的回調地獄指的是一種編程風格,其中回調嵌套在回調函數中,而回調函數又嵌套在其餘回調函數中。因爲 JS 異步特性,js 程序員多年來陷入了這個陷阱。

說實話,我歷來沒有遇到過極端的回調金字塔,這多是由於我重視可讀代碼,並且我老是堅持這個原則。若是你在遇到了回調地獄的問題,說明你的函數作得太多。

這裏不會討論回調地獄,若是你好奇,有一個網站,callbackhell.com,它更詳細地探索了這個問題,並提供了一些解決方案。

咱們如今要關注的是ES6的 Promises。ES6 Promises是JS語言的一個補充,旨在解決可怕的回調地獄。但什麼是 Promises 呢?

JS的 Promise是將來事件的表示。 Promise 能夠以成功結束:用行話說咱們已經解決了resolved(fulfilled)。 但若是 Promise 出錯,咱們會說它處於拒絕(rejected )狀態。 Promise 也有一個默認狀態:每一個新的 Promise 都以掛起(pending)狀態開始。

建立和使用 JavaScript 的 Promises

要建立一個新的 Promise,能夠經過傳遞迴調函數來調用 Promise 構造函數。回調函數能夠接受兩個參數:resolvereject。以下所示:

const myPromise = new Promise(function(resolve){
    setTimeout(function(){
        resolve()
    }, 5000)
});

以下所示,resolve是一個函數,調用它是爲了使Promise 成功,別外也可使用 reject 來表示調用失敗。

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

注意,在第一個示例中能夠省略reject,由於它是第二個參數。可是,若是打算使用reject,則不能忽略resolve,以下所示,最終將獲得一個resolved 的承諾,而非 reject

// 不能忽略 resolve !
const myPromise = new Promise(function(reject){
    setTimeout(function(){
        reject()
    }, 5000)
});

如今,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);
});

Promises 的錯誤處理

對於同步代碼而言,JS 錯誤處理大都很簡單,以下所示:

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

將會輸出:

Catching the error! Error: Sorry mate!

如今嘗試使用異步函數:

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()來建立和拒絕一個Promise

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

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

Promise API 提供了許多將Promise組合在一塊兒的方法。 其中最有用的是Promise.all,它接受一個Promises數組並返回一個Promise。 若是參數中 promise 有一個失敗(rejected),此實例回調失敗(reject),失敗緣由的是第一個失敗 promise 的結果。

Promise.race(iterable) 方法返回一個 promise,一旦迭代器中的某個promise解決或拒絕,返回的 promise就會解決或拒絕。

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

Promise.any能夠代表任何Promise是否fullfilled。 與 Promise.race的區別在於Promise.any不會拒絕即便其中一個Promise被拒絕。

不管如何,二者中最有趣的是 Promise.allSettled,它也是 Promise 數組,但若是其中一個Promise拒絕,它不會短路。 當你想要檢查Promise數組是否所有已解決時,它是有用的,不管最終是否拒絕,能夠把它想象成Promise.all 的反對者。

異步進化:從Promises 到 async/await

ECMAScript 2017 (ES8)的出現,推出了新的語法誕生了async/await

async/await只是Promise 語法糖。它只是一種基於Promises編寫異步代碼的新方法, async/await 不會以任何方式改變JS,請記住,JS必須向後兼容舊瀏覽器,不該破壞現有代碼。

來個例子:

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();

有趣的是,async 函數也會返回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函數,咱們能夠重構以上代碼:

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時,還有另外一個怪異的地方須要指出,以下所示:

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"));

運行結果:

clipboard.png

以上兩個字符串都會打印。 請記住, try/catch 是一個同步構造,但咱們的異步函數產生一個Promise。 他們在兩條不一樣的軌道上行駛,好比兩列火車。但他們永遠不會見面, 也就是說,throw 拋出的錯誤永遠不會觸發getData()catch方法。

實戰中,咱們不但願throwthen的處理程序。 一種的解決方案是從函數返回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" // 輸出

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

總結

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

JS引擎包含頗有組件:調用堆棧、全局內存(堆)、事件循環、回調隊列。全部這些組件一塊兒工做,完美地進行了調優,以處理JS中的同步和異步代碼。

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

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

思考

  • 瀏覽器如何理解 JS 代碼?
  • 調用堆棧的主要任務是什麼?
  • 你能描述一下 JS 引擎的主要組件以及它們是如何協同工做的嗎?
  • 微任務隊列是作什麼的 ?
  • Promise 是什麼?
  • 是否能夠處理異步代碼中的錯誤? 若是能夠,怎麼作?
  • 你能說出瀏覽器API的幾個方法嗎

代碼部署後可能存在的BUG無法實時知道,過後爲了解決這些BUG,花了大量的時間進行log 調試,這邊順便給你們推薦一個好用的BUG監控工具 Fundebug

原文:https://github.com/valentinog...

交流

阿里雲最近在作活動,低至2折,有興趣能夠看看:https://promotion.aliyun.com/...

乾貨系列文章彙總以下,以爲不錯點個Star,歡迎 加羣 互相學習。

https://github.com/qq449245884/xiaozhi

由於篇幅的限制,今天的分享只到這裏。若是你們想了解更多的內容的話,能夠去掃一掃每篇文章最下面的二維碼,而後關注我們的微信公衆號,瞭解更多的資訊和有價值的內容。

clipboard.png

每次整理文章,通常都到2點才睡覺,一週4次左右,挺苦的,還望支持,給點鼓勵

相關文章
相關標籤/搜索