搞懂JavaScript引擎運行原理

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

想閱讀更多優質文章請猛戳GitHub博客,一年百來篇優質文章等着你!git

一些名詞

JS引擎 — 一個讀取代碼並運行的引擎,沒有單一的「JS引擎」;,每一個瀏覽器都有本身的引擎,如谷歌有V。github

做用域 — 能夠從中訪問變量的「區域」。瀏覽器

詞法做用域— 在詞法階段的做用域,換句話說,詞法做用域是由你在寫代碼時將變量和塊做用域寫在哪裏來決定的,所以當詞法分析器處理代碼時會保持做用域不變。緩存

塊做用域 — 由花括號{}建立的範圍安全

做用域鏈 — 函數能夠上升到它的外部環境(詞法上)來搜索一個變量,它能夠一直向上查找,直到它到達全局做用域。數據結構

同步 — 一次執行一件事, 「同步」引擎一次只執行一行,JavaScript是同步的。閉包

異步 — 同時作多個事,JS經過瀏覽器API模擬異步行爲異步

事件循環(Event Loop) - 瀏覽器API完成函數調用的過程,將回調函數推送到回調隊列(callback queue),而後當堆棧爲空時,它將回調函數推送到調用堆棧。ide

堆棧 —一種數據結構,只能將元素推入並彈出頂部元素。 想一想堆疊一個字形的塔樓; 你不能刪除中間塊,後進先出。

— 變量存儲在內存中。

調用堆棧 — 函數調用的隊列,它實現了堆棧數據類型,這意味着一次能夠運行一個函數。 調用函數將其推入堆棧並從函數返回將其彈出堆棧。

執行上下文 — 當函數放入到調用堆棧時由JS建立的環境。

閉包 — 當在另外一個函數內建立一個函數時,它「記住」它在之後調用時建立的環境。

垃圾收集 — 當內存中的變量被自動刪除時,由於它再也不使用,引擎要處理掉它。

變量的提高— 當變量內存沒有賦值時會被提高到全局的頂部並設置爲undefined

this —由JavaScript爲每一個新的執行上下文自動建立的變量/關鍵字。

調用堆棧(Call Stack)

看看下面的代碼:

var myOtherVar = 10

function a() {
  console.log('myVar', myVar)
  b()
}

function b() {
  console.log('myOtherVar', myOtherVar)
  c()
}

function c() {
  console.log('Hello world!')
}

a()

var myVar = 5

有幾個點須要注意:

  • 變量聲明的位置(一個在上,一個在下)
  • 函數a調用下面定義的函數b, 函數b調用函數c

當它被執行時你指望發生什麼? 是否發生錯誤,由於ba以後聲明或者一切正常? console.log 打印的變量又是怎麼樣?

如下是打印結果:

"myVar" undefined
"myOtherVar" 10
"Hello world!"

來分解一下上述的執行步驟。

1. 變量和函數聲明(建立階段)

第一步是在內存中爲全部變量和函數分配空間。 但請注意,除了undefined以外,還沒有爲變量分配值。 所以,myVar在被打印時的值是undefined,由於JS引擎從頂部開始逐行執行代碼。

函數與變量不同,函數能夠一次聲明和初始化,這意味着它們能夠在任何地方被調用。

因此以上代碼看起來像這樣子:

var myOtherVar = undefined
var myVar = undefined

function a() {...}
function b() {...}
function c() {...}

這些都存在於JS建立的全局上下文中,由於它位於全局空間中。

在全局上下文中,JS還添加了:

  1. 全局對象(瀏覽器中是 window 對象,NodeJs 中是 global 對象)
  2. this 指向全局對象

2. 執行

接下來,JS 引擎會逐行執行代碼。

myOtherVar = 10在全局上下文中,myOtherVar被賦值爲10

已經建立了全部函數,下一步是執行函數 a()

每次調用函數時,都會爲該函數建立一個新的上下文(重複步驟1),並將其放入調用堆棧。

function a() {
  console.log('myVar', myVar)
  b()
}

以下步驟:

  1. 建立新的函數上下文
  2. a 函數裏面沒有聲明變量和函數
  3. 函數內部建立了 this 並指向全局對象(window)
  4. 接着引用了外部變量 myVarmyVar 屬於全局做用域的。
  5. 接着調用函數 b ,函數b的過程跟 a同樣,這裏不作分析。

下面調用堆棧的執行示意圖:

圖片描述

  1. 建立全局上下文,全局變量和函數。
  2. 每一個函數的調用,會建立一個上下文,外部環境的引用及 this
  3. 函數執行結束後會從堆棧中彈出,而且它的執行上下文被垃圾收集回收(閉包除外)。
  4. 當調用堆棧爲空時,它將從事件隊列中獲取事件。

做用域及做用域鏈

在前面的示例中,全部內容都是全局做用域的,這意味着咱們能夠從代碼中的任何位置訪問它。 如今,介紹下私有做用域以及如何定義做用域。

函數/詞法做用域

考慮以下代碼:

function a() {
  var myOtherVar = 'inside A'

  b()
}

function b() {
  var myVar = 'inside B'

  console.log('myOtherVar:', myOtherVar)

  function c() {
    console.log('myVar:', myVar)
  }

  c()
}

var myOtherVar = 'global otherVar'
var myVar = 'global myVar'
a()

須要注意如下幾點:

  1. 全局做用域和函數內部都聲明瞭變量
  2. 函數c如今在函數b中聲明

打印結果以下:

myOtherVar: "global otherVar"
myVar: "inside B"

執行步驟:

  1. 全局建立和聲明 - 建立內存中的全部函數和變量以及全局對象和 this
  2. 執行 - 它逐行讀取代碼,給變量賦值,並執行函數a
  3. 函數a建立一個新的上下文並被放入堆棧,在上下文中建立變量myOtherVar,而後調用函數b
  4. 函數b 也會建立一個新的上下文,一樣也被放入堆棧中

5,函數b的上下文中建立了 myVar 變量,並聲明函數c

上面提到每一個新上下文會建立的外部引用,外部引用取決於函數在代碼中聲明的位置。

  1. 函數b試圖打印myOtherVar,但這個變量並不存在於函數b中,函數b 就會使用它的外部引用上做用域鏈向上找。因爲函數b是全局聲明的,而不是在函數a內部聲明的,因此它使用全局變量myOtherVar
  2. 函數c執行步驟同樣。因爲函數c自己沒有變量myVar,因此它它經過做用域鏈向上找,也就是函數b,由於myVar函數b內部聲明過。

下面是執行示意圖:

clipboard.png

請記住,外部引用是單向的,它不是雙向關係。例如,函數b不能直接跳到函數c的上下文中並從那裏獲取變量。

最好將它看做一個只能在一個方向上運行的鏈(範圍鏈)。

  • a -> global
  • c -> b -> global

在上面的圖中,你可能注意到,函數是建立新做用域的一種方式。(除了全局做用域)然而,還有另外一種方法能夠建立新的做用域,就是塊做用域

塊做用域

下面代碼中,咱們有兩個變量和兩個循環,在循環從新聲明相同的變量,會打印什麼(反正我是作錯了)?

function loopScope () {
  var i = 50
  var j = 99

  for (var i = 0; i < 10; i++) {}

  console.log('i =', i)

  for (let j = 0; j < 10; j++) {}

  console.log('j =', j)
}

loopScope()

打印結果:

i = 10
j = 99

第一個循環覆蓋了var i,對於不知情的開發人員來講,這可能會致使bug。

第二個循環,每次迭代建立了本身做用域和變量。 這是由於它使用let關鍵字,它與var相同,只是let有本身的塊做用域。 另外一個關鍵字是const,它與let相同,但const常量且沒法更改(指內存地址)。

塊做用域由大括號 {} 建立的做用域

再看一個例子:

function blockScope () {
  let a = 5
  {
    const blockedVar = 'blocked'
    var b = 11

    a = 9000
  }

  console.log('a =', a)
  console.log('b =', b)
  console.log('blockedVar =', blockedVar)
}

blockScope()

打印結果:

a = 9000
b = 11
ReferenceError: blockedVar is not defined
  1. a是塊做用域,但它在函數中,而不是嵌套的,本例中使用var是同樣的。
  2. 對於塊做用域的變量,它的行爲相似於函數,注意var b能夠在外部訪問,可是const blockedVar不能。
  3. 在塊內部,從做用域鏈向上找到 a 並將let a更改成9000

使用塊做用域可使代碼更清晰,更安全,應該儘量地使用它。

事件循環(Event Loop)

接下來看看事件循環。 這是回調,事件和瀏覽器API工做的地方

clipboard.png

咱們沒有過多討論的事情是,也叫全局內存。它是變量存儲的地方。因爲了解JS引擎是如何實現其數據存儲的實際用途並很少,因此咱們不在這裏討論它。

來個異步代碼:

function logMessage2 () {
    console.log('Message 2')
}

console.log('Message 1')

setTimeout(logMessage2, 1000)

console.log('Message 3')

上述代碼主要是將一些 message 打印到控制檯。 利用setTimeout函數來延遲一條消息。 咱們知道js是同步,來看看輸出結果

Message 1
Message 3
Message 2
  1. 打印 Message 1
  2. 調用 setTimeout
  3. 打印 Message 3
  4. 打印 Message 2

它記錄消息3

稍後,它會記錄消息2

setTimeout是一個 API,和大多數瀏覽器 API同樣,當它被調用時,它會向瀏覽器發送一些數據和回調。咱們這邊是延遲一秒打印 Message 2

調用完setTimeout 後,咱們的代碼繼續運行,沒有暫停,打印 Message 3 並執行一些必須先執行的操做。

瀏覽器等待一秒鐘,它就會將數據傳遞給咱們的回調函數並將其添加到事件/回調隊列中( event/callback queue)。 而後停留在隊列中,只有當調用堆棧(call stack)爲空時纔會被壓入堆棧。

圖片描述

代碼示例

要熟悉JS引擎,最好的方法就是使用它,再來些有意義的例子。

簡單的閉包

這個例子中 有一個返回函數的函數,並在返回的函數中使用外部的變量, 這稱爲閉包

function exponent (x) {
  return function (y) {
   //和math.pow() 或者x的y次方是同樣的
    return y ** x
  }
}

const square = exponent(2)

console.log(square(2), square(3)) // 4, 9

console.log(exponent(3)(2)) // 8

塊代碼

咱們使用無限循環將將調用堆棧塞滿,會發生什麼,回調隊列被會阻塞,由於只能在調用堆棧爲空時添加回調隊列。

function blockingCode() {
  const startTime = new Date().getSeconds()

  // 延遲函數250毫秒
  setTimeout(function() {
    const calledAt = new Date().getSeconds()
    const diff = calledAt - startTime
 
    // 打印調用此函數所需的時間
    console.log(`Callback called after: ${diff} seconds`)
  }, 250)

  // 用循環阻塞堆棧2秒鐘
  while(true) {
    const currentTime = new Date().getSeconds()

    // 2 秒後退出
    if(currentTime - startTime >= 2) break
  }

}

blockingCode() // 'Callback called after: 2 seconds'

咱們試圖在250毫秒以後調用一個函數,但由於咱們的循環阻塞了堆棧所花了兩秒鐘,因此回調函數實際是兩秒後纔會執行,這是JavaScript應用程序中的常見錯誤

setTimeout不能保證在設置的時間以後調用函數。相反,更好的描述是,在至少通過這段時間以後調用這個函數。

延遲函數

setTimeout 的設置爲0,狀況是怎麼樣?

function defer () {
  setTimeout(() => console.log('timeout with 0 delay!'), 0)
  console.log('after timeout')
  console.log('last log')
}

defer()

你可能指望它被當即調用,可是,事實並不是如此。

執行結果:

after timeout
last log
timeout with 0 delay!

它會當即被推到回調隊列,但它仍然會等待調用堆棧爲空纔會執行。

用閉包來緩存

Memoization是緩存函數調用結果的過程。

例如,有一個添加兩個數字的函數add。調用add(1,2)返回3,當再次使用相同的參數add(1,2)調用它,此次不是從新計算,而是記住1 + 2是3的結果並直接返回對應的結果。 Memoization能夠提升代碼運行速度,是一個很好的工具。

咱們可使用閉包實現一個簡單的memoize函數。

// 緩存函數,接收一個函數
const memoize = (func) => {
  // 緩存對象
  // keys 是 arguments, values are results
  const cache = {}

  // 返回一個新的函數
  // it remembers the cache object & func (closure)
  // ...args is any number of arguments
  return (...args) => {
    // 將參數轉換爲字符串,以便咱們能夠存儲它
    const argStr = JSON.stringify(args)

    // 若是已經存,則打印
    console.log('cache', cache, !!cache[argStr])

    cache[argStr] = cache[argStr] || func(...args)

    return cache[argStr]
  }
}

const add = memoize((a, b) => a + b)

console.log('first add call: ', add(1, 2))

console.log('second add call', add(1, 2))

執行結果:

cache {} false
first add call:  3
cache { '[1,2]': 3 } true
second add call 3

第一次 add 方法,緩存對象是空的,它調用咱們的傳入函數來獲取值3.而後它將args/value鍵值對存儲在緩存對象中。

在第二次調用中,緩存中已經有了,查找到並返回值。

對於add函數來講,有無緩存看起來可有可無,甚至效率更低,可是對於一些複雜的計算,它能夠節省不少時間。這個示例並非一個完美的緩存示例,而是閉包的實際應用。

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

交流

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

https://github.com/qq44924588...

我是小智,公衆號「大遷世界」做者,對前端技術保持學習愛好者。我會常常分享本身所學所看的乾貨,在進階的路上,共勉!

關注公衆號,後臺回覆福利,便可看到福利,你懂的。

clipboard.png

相關文章
相關標籤/搜索