深入js基礎:從記憶體機制、解析機制到執行機制(長文預警)

大王叫我來爬山發表於2019-11-27

文章由來

最近有些懶, 加上公司專案較多, 沒有來得及更。這次選題比較糾結, 本來想繼續圍繞webpack, 但無奈在私下和一些同學聊天時, 無意中發現大部分同學對Js執行機制和記憶體機制並不是很瞭解。在做了一些分享後, 於是決心梳理下Js的底層基礎和原理, 所以有了這篇文章, 主要面向初中級前端同學。

這篇文章能學到什麼?

堆,棧,垃圾回收機制,執行環境,執行上下文,執行棧,變數物件,活動物件,詞彙環境,呼叫棧,作用域,變數提升,單執行緒,多執行緒,協程,事件迴圈,瀏覽器核心執行緒,事件佇列,微任務,巨集任務.....

相信還是有相當多一部分同學對上面提及的名詞僅僅停留在聽過或是瞭解一點的階段,本文會一一對上面提到的專業術語進行歸納總結分析,通讀此文,相信你的JavaScript基礎會更加紮實。

什麼是堆?什麼是棧?js記憶體機制是什麼?

任何一種語言都有資料型別的分類,某種語言是如何存放和分配這些資料的,這就是記憶體管理。那棧和堆與記憶體有什麼關聯呢? 首先要理解的是棧和堆也就是棧記憶體和堆記憶體, 它們都存放於記憶體中.

堆記憶體

用於儲存Js中的複雜資料型別,當我們建立一個物件或陣列或new一個例項的時候,就會在堆記憶體中開闢一段空間給它,用於存放。

堆記憶體可以動態地分配記憶體大小,生存期也不必事先告訴編譯器,因為它是在執行時動態分配記憶體的,但缺點是,由於要在執行時動態分配記憶體,存取速度較慢。

棧記憶體

用於儲存Js中的簡單資料型別及物件的引用指標, 該指標指向的是堆記憶體中對應的值

棧記憶體的特點是後進先出, 可以直接存取的,而堆記憶體是不可以直接存取的記憶體。對於我們的物件來數就是一種大的資料型別而且是佔用空間不定長的型別,所以說物件是放在堆裡面的,但物件名稱是放在棧裡面的,這樣通過物件名稱就可以使用物件了。

垃圾回收機制

什麼是垃圾? 宣告瞭一個變數, 不管是原始還是複雜型別, 都會儲存到記憶體中, 在程式碼執行完成後, 那些不在會被使用的資料就是所謂的"垃圾", 等待回收.

垃圾回收機制, 其實就是一種如何處理這些"垃圾"資料的機制, 它會自動回收.

它主要運用“標記清除”演算法,垃圾回收從全域性出發,把那些從“根”部無法觸及到的不再引用(引用計數為0)的資料,定義為垃圾資料,進行回收。

什麼是無法觸及到的資料呢?

var a = 1
var b = 2
var obj = {
  c: 3,
  d: 4
}
複製程式碼

上面程式碼定義了變數a, b, obj, 它們是在全域性定義的, 當指令碼執行完畢後, 變數a, b, obj的引用計數均為1, 此時垃圾回收並不會回收a, b, obj(因為此時通過window這個根,可以訪問到變數a, b的值,以及變數obj的引用),除非手動的將其釋放

a = null
b = null
obj = null
複製程式碼

更簡單的說,如果你宣告的變數,不管是原始資料型別還是複雜資料型別,只要是從全域性window物件出發,可以訪問到的資料,就不會被垃圾回收器回收。

var obj = {
  c: 3,
  d: 4
}
var user = obj
obj = null
複製程式碼

上面程式碼在全域性定義了一個物件{c: 3, d: 4}(為了方便表述,我們將這個物件起名為A物件),它被儲存在堆記憶體中,使用變數obj接受A的引用,並定義了user變數,也引用了A物件,之後將obj重新賦值為null, 此時A物件是否會被回收呢?

答案是不會的,上面說過,只要是從全域性window物件出發,可以訪問到的資料,就不會被垃圾回收器回收。

A物件怎麼能從全域性訪問到呢?即 window.user 引用了 A 物件,所以不會被回收

如果手動設定 user = null, 這時從 window 出發,沒有任何一個渠道可以訪問到這個 A 物件,那麼A物件就會被回收

所以,垃圾回收機制實際上回收的是記憶體中的資料,而不是變數,但是一個變數指向的資料被回收了,這個變數留著還有什麼用呢?垃圾回收器自然也會將無用的變數進行回收

瞭解了 JavaScript的記憶體機制, 知道了js中不同變數儲存在不同記憶體中, 那麼 JavaScript是如何解析這些程式碼並執行呢? 我們都知道 JavaScript 的執行是分兩種階段, 預解析和執行, 那其中的機制是什麼呢? 彆著急, 讓我們先聊聊js的執行環境。

JavaScript執行環境和執行棧

執行環境(執行上下文)

Javascript 的執行環境也就是執行上下文, 通俗的理解, 就是 Javascript 的某段程式碼是在哪個環境執行的, 它的上文是誰? 下文是誰?

為什麼要引入執行上下文這個概念呢? 要執行程式碼, 首先得立個規矩, 你得先讓我知道, 我寫的某段程式碼在哪個環境中執行, 能訪問到哪些變數, 然後我才能書寫正確的邏輯.

那麼這裡說的環境究竟有哪些呢? JavaScript 中有三種執行上下文型別, 分別是全域性執行上下文, 函式執行上下文, Eval 函式執行上下文. 三者的具體描述就不詳細展開了, 很多文章都有詳細的闡述, 這裡只講兩點吧.

  • 全域性執行上下文只有一個, 且在瀏覽器關閉後出棧;
  • 函式執行上下文有多個, 每呼叫一個函式, 都會建立新的上下文環境.

執行棧(呼叫棧)

細心的同學發現,全域性上下文在瀏覽器關閉後會出棧。 哦哦,我知道了,這個棧就是棧記憶體嘛,非也。

這裡的棧,是執行上下文棧,簡稱執行棧或呼叫棧, 裡面儲存了程式碼執行期間建立的所有執行上下文。 既然是棧,就有後進先出的特點。來看一段程式碼, 以最快速的方式瞭解下執行棧的執行順序:

// test.js
var foo = 1
function bar() {
  function baz() {}
  baz()
}
bar()
複製程式碼
  • 在執行這段程式碼時, JS引擎建立全域性執行上下文, 並將其推入到執行棧
  • 呼叫bar, 建立 bar 函式的執行上下文, 推入執行棧, 那麼此時 Javascript 執行環境就是 bar 函式的執行環境
  • bar函式內部呼叫了 baz 函式, 再次建立 baz 上下文, 推入執行棧, 此時 Javascript 執行環境就是 baz 函式的執行環境
  • baz 函式執行完後, 執行棧將 baz 函式的執行上下文彈出, 此時 Javascript 執行環境變為了 bar 函式的執行環境
  • bar 函式執行完後, 執行棧將 bar 函式的執行上下文彈出, 此時 Javascript 執行環境變為了 全域性執行環境
  • 我關掉瀏覽器,執行棧清空。瀏覽器都關了,要什麼執行環境呢?

ok,看到這裡你大概知道 JavaScript 執行上下文是如何執行的了,但你可能就要問了, 不是應該先預解析再執行嗎?預解析這一步呢?別慌,拍電影有個手法叫倒敘,現在讓我們回頭再來看看 JavaScript 是如何預解析?要弄明白 JS 的預解析過程,首先得知道執行上下文的建立過程。

執行上下文建立的過程

談到預解析, 你可能第一個想到的就是變數提升和函式宣告的提升, 想想它們為什麼會提升,是在什麼階段進行提升的,提升的機制又是怎麼的呢?所有的這些問題, 在 理解了 JavaScript 執行上下文建立的過程之後,都會得到相應的解釋。

所以你明白為什麼本文先講執行上下文了吧?

在開始講述建立執行上下文的過程之前, 有必要先了解下:識別符號,變數物件,活動物件,詞法環境,作用域...

深入js基礎:從記憶體機制、解析機制到執行機制(長文預警)

1. 識別符號

通俗的講,在JS中所有可以由我們自主命名的都可以稱為是識別符號, 諸如變數名、函式名、屬性名都屬於識別符號

2. 變數物件和活動物件

變數物件和活動物件是 ES3 中的概念,我們在程式中宣告的變數、函式,直譯器是如何並在哪找到我們的定義的資料,答案就是變數物件和活動物件了。

函式內部所有的變數和函式都儲存在一個叫做變數物件中(這部分是隱式的),變數物件被呼叫的時候就稱之為活動物件。

變數物件(variable object)下面簡稱VO 變數物件,就是與執行上下文相關聯的特殊物件,它用來在執行上下文中儲存變數、函式宣告及函式的形參。它是執行上下文的一個屬性。

ExecutionContext = { // 執行上下文
    VO: {
        // 上下文變數
    }
}
複製程式碼

執行上下文不同,變數物件的初始結構、VO的名稱也不同。

  • 全域性執行上下文中,變數物件就是全域性物件本身,在瀏覽器物件模型中,全域性執行上下文中的變數物件就是 window 物件,是可以直接訪問的。
  • 函式的執行上下文-VO是不可直接訪問的,它就是所謂的活動物件(函式被呼叫的時候稱之為活動物件)。

活動物件(activation object)下面簡稱AO活動物件。它儲存著函式上下文中建立的變數和Arguments物件。

FunctionExecutionContext = { // 函式執行上下文
    AO = { // 函式活動物件
      arguments: <ArgO>
    }
}
複製程式碼

3. 詞法環境

詞法環境是一種規範型別,程式碼的詞法巢狀結構來定義 識別符號 與特定 變數 和 函式 關聯關係, 即詞法環境定義了識別符號(變數名)和實際變數物件的對映關係。和詞法環境相關聯的有 詞法環境元件變數環境元件

一臉懵逼吧?這TMD都是啥?官方定義就是這樣,讓人知其然不知其所以然。 這裡我用平民的語法來表述下什麼是詞法環境。

這麼理解,什麼是環境?不就是範圍嗎?什麼是範圍?不就是作用域嗎?那詞法環境不就是詞法作用域嗎?上解釋:

在程式碼寫好的時候,根據程式碼的書寫結構就可以確定變數的作用域,這種作用域就是詞法作用域,也就是詞法環境。

詞法環境是一個大類,你可以把它想象成一個父元件,它有兩個子元件:詞法環境元件和變數環境元件

3.1. 詞法環境元件

詞法環境元件中,主要儲存的是函式宣告和使用letconst宣告變數的繫結

3.2. 變數環境元件

變數環境元件中,主要儲存的是函式表示式和使用var宣告變數的繫結

詞法環境元件和變數環境元件內部還有兩個元件:

  • 環境記錄器:儲存變數和函式宣告的實際位置(類似於ES3中的變數物件)
  • 外部環境的引用:父級的詞法環境(類似於[[Scope]]屬性)

4. 作用域

作用域,就是變數能夠被引用,函式能夠生效的範圍及區域。

我們都知道,Js是靜態作用域,也是詞法作用域,程式碼一旦書寫,就規定了該變數或函式是屬於哪一個作用域。

我們也知道,Js分為全域性作用域、函式作用域、塊級作用域,函式級塊級作用域可以訪問全域性作用域;巢狀函式中,內部函式可以訪問外部函式的作用域...可是,為什麼可以訪問呢?

你可以這麼解釋:因為js程式書寫好的時候,根據程式碼的書寫結構就可以確定變數的作用域,所以區域性的可以訪問全域性的,巢狀函式中,內部的可以訪問外部的。

是嗎?這個僅僅是表象,以函式作用域為例,實際上:

一個函式在建立的時候,都會建立一個內部屬性[[Scope]],其包含著該函式被建立時的父級上下文中的變數物件(事實上,函式是在建立父級執行上下文的過程中被建立的);

該函式在被呼叫時,函式的執行上下文作用域鏈,不僅包含著該函式的活動物件AO,也包含著該函式的[[Scope]]屬性的拷貝。

var x = 10
function foo() {
    var y = 20
}
foo()
// 虛擬碼
// 1. 全域性執行上下文中的VO
// GlobalContext = {
//    VO: {
//        x: 10,
//        this: window,
//        foo: <reference> to Function foo
//    }
// }
// 2. foo建立後,Scope屬性為
// foo.[[Scope]] = {
//    VO: GlobalContext.VO,
//    this: window
// }
// 3. foo呼叫後,建立執行上下文,建立活動物件AO
// FooContext = {
//    AO: {
//        y: 20,
//        this: window
//    }
// }
// foo執行上下文建立後,該執行上下文的作用域鏈為:
// FooContext.Scope = {
//    AO: FooContext.AO,
//    VO: GlobalContext.VO(也就是foo.[[Scope]]的拷貝)
// }
複製程式碼

上文提到,ES3 和 ES5中,對變數儲存的定義完全不同,在ES5中,變數物件的概念已被詞法環境模型所取代,所以下面分開講述下 ES3 和 ES5 中建立上下文的過程。

ES3中執行上下文建立的過程

讓我們通過程式碼及虛擬碼來具體分析下,ES3中是如何建立上下文的:

var x = 10;
function foo() {
  var y = 20;
  function bar() {
    var z = 30;
    alert(x +  y + z);
  }
  bar();
}
foo(); 
複製程式碼
  • 開啟瀏覽器執行該js指令碼,首先建立全域性執行上下文;
    • 建立變數物件(在這裡為全域性物件GlobalObject)並進行初始化,此時全域性上下文中的變數物件如下:
      GlobalContext = {
          GO: {
              x: undefined,
              this: window,
              foo: <reference> to Function foo
          }
      }
      複製程式碼
    • 函式foo此時已經被建立,所以有了 [[Scope]]屬性(函式作用域),其包含著對父級上下文中的變數物件(這裡就是全域性物件GO)的引用,所以,foo的[[Scope]]如下:
      foo.[[Scope]] = {
          GO: {
              x: undefined, // 此時為預解析階段,執行階段會進行賦值
              this: window,
              foo: <reference> to Function foo
          }
      }
      複製程式碼
  • 全域性變數物件初始化完畢後, 進入執行階段
  • 執行階段先對全域性變數物件進行更新賦值(GlobalVO)如下:
    GlobalContext = {
        x: 10,
        this: window,
        foo: <reference> to Function foo
    }
    // 同理,foo函式的[[Scope]]屬性也就變成
    //(因為僅僅是對父級變數物件的引用,下文此類情況不再提及)
    // foo.[[Scope]] = {
    //    GO: {
    //        x: 10,
    //        this: window,
    //        foo: <reference> to Function foo
    //    }
    // }
    複製程式碼
  • 呼叫foo函式(一旦呼叫函式,就要建立函式執行上下文)
  • 開始建立foo函式執行上下文:首先,初始化變數,並填充到 foo 的活動物件(AO)中;其次,還記得上面建立foo函式時,foo函式內部的[[Scope]]屬性嗎?foo執行上下文拷貝函式內部[[Scope]]屬性儲存在上下文中的作用域鏈中,並將剛剛填充的AO放入執行上下文作用域鏈的最頂端;此時foo上下文中的活動物件/變數物件如下:
    FooContext = {
        AO: {
            y: undefined, // 預編譯階段不賦值
            this: window,
            arguments: [],
            bar: <reference> to Function bar // 建立了 bar 函式
        },
        GO: {
            x: 10,
            this: window,
            foo: <reference> to Function foo
        }
    }
    複製程式碼
    • 此時,bar函式已經被建立,所以bar函式建立了內部屬性[[Scope]],其包含著對父級上下文活動物件/變數物件的引用(在這裡為foo函式上下文的活動物件/變數物件)
    bar.[[Scope]] = {
        AO: {
            y: undefined,
            this: window,
            arguments: [],
            bar: <reference> to Function bar
        },
        GO: {
            x: 10,
            this: window,
            foo: <reference> to Function foo
        }
    }
    複製程式碼
  • 執行foo函式, 進行更新賦值操作
    FooContext = {
        AO: {
            y: 20,
            this: window,
            arguments: [],
            bar: <reference> to Function bar
        },
        GO: {
            x: 10,
            this: window,
            foo: <reference> to Function foo
        }
    }
    複製程式碼
  • 呼叫bar函式
  • 呼叫前先建立bar函式執行上下文,如何建立呢?同foo一樣,首先將 bar 的活動物件放入bar的執行上下文作用域鏈的頂部;其次,將bar函式內部屬性[[Scope]]拷貝至bar函式執行上下文的作用域鏈中。此時 bar 的上下文如下
    BarContext = {
        AO: {
            z: undefined,
            this: window,
            arguments: []
        },
        AO: {
            y: 20,
            this: window,
            arguments: [],
            bar: <reference> to Function bar
        },
        GO: {
            x: 10,
            this: window,
            foo: <reference> to Function foo
        }
    }
    複製程式碼
  • 執行bar函式, 進行賦值操作
    BarContext = {
        AO: {
            z: 30,
            this: window,
            arguments: []
        },
        AO: {
            y: 20,
            this: window,
            arguments: [],
            bar: <reference> to Function bar
        },
        GO: {
            x: 10,
            this: window,
            foo: <reference> to Function foo
        }
    }
    複製程式碼
  • 在 bar 函式中,執行 alert(x + y + z)
    • x: 在當前執行上下文中 (BarContext)中查詢變數x,在GO中找到,為10
    • y: 在當前執行上下文中 (BarContext)中查詢變數y,在第二層AO中找到,為20
    • z: 在當前執行上下文中 (BarContext)中查詢變數z,在第一層AO中找到,為30
  • bar函式執行完成,其執行上下文彈出執行棧,執行權交還給foo執行上下文
  • foo執行完,其執行上下文彈出執行棧,執行權交還給全域性執行上下文
  • 全部程式碼執行完,關掉瀏覽器,執行棧清空

再次梳理流程:

  1. 寫好功能程式碼,開啟瀏覽器執行
  2. 建立全域性執行上下文,如何建立?(建立上下文的過程,就是js預解析的過程)
  • 建立並用初始值填充變數物件,將變數物件放入全域性執行上下文中的VO中,無形中對變數做了提升操作
  • 如果全域性程式碼中有函式,該函式宣告已經在上一個步驟中放入變數物件中了,也就是說在全域性變數物件中建立了一個函式
  • 在函式內部建立[[Scope]]屬性,該[[Scope]]屬性包含著父級上下文中的變數物件,這裡就是全域性變數物件(即使不呼叫函式,該[[Scope]]也會被建立)
  • [[Scope]]屬性會一直儲存在函式屬性中,直到函式被回收
  1. 全域性執行上下文建立完後,開始進入執行階段
  2. 對全域性物件變數中使用 var 宣告的變數進行賦值
  3. 遇到函式呼叫時,首先是建立該函式的執行上下文
  • 建立並用初始值填充該函式的啟用物件,將啟用物件放入該函式的執行上下文中的AO中
  • 建立該函式執行上下文的作用域鏈,作用域鏈由啟用物件和該函式的內部[[Scope]]屬性組成
  1. 函式執行上下文建立完後,開始執行函式
  • 對變數進行賦值操作
  1. 函式執行完成後,該函式的執行上下文彈出
  2. 回到全域性執行上下文
  3. 關閉瀏覽器,全域性上下文彈出

要點總結:

  1. ES3中主要圍繞的是變數物件活動物件來建立執行上下文
  2. 每次進入上下文時都會建立並用初始值填充變數物件(變數提升) ,並且其更新發生在程式碼執行階段
  3. js的預解析其實就是發生在建立變數物件的過程中,使用初始值填充變數物件;同時,預解析只解析當前上下文中的變數。所以,你寫好的程式,執行在瀏覽器時,只會解析全域性變數物件,如果有函式,你不呼叫,函式內部的程式碼是不會進行預解析的!
  4. 函式宣告會在進入上下文階段時放入變數/啟用物件(VO / AO)中
  5. 函式生命週期分為建立階段和啟用階段(呼叫),建立階段會建立內部屬性[[Scope]],包含著對外部變數的引用
  6. 函式上下文的作用域鏈是在函式呼叫時建立的,由啟用物件和該函式的內部[[Scope]]屬性組成
  7. js就是通過作用域鏈的規則來進行變數查詢(準確的說應該是執行上下文的作用域鏈)

ES5中執行上下文建立的過程

ES5建立執行上下文與ES3過程類似,只是執行上下文中的解構不同,它主要分為3個步驟

  1. 繫結this值
  2. 建立詞法環境元件(let和const變數的繫結,函式宣告)
  3. 建立變數環境元件(var變數的繫結和函式表示式)

上文說到,詞法環境元件和變數環境元件都是詞法環境,它們都有環境記錄器和對外部環境的引用

實在不好理解,你可以簡單的把環境記錄器看成是ES3中的變數物件或活動物件,把對外部環境的引用看成執行上下文中的作用域。為什麼呢?首先,環境記錄器分為兩種:宣告式環境記錄器和物件環境記錄器,可以把它們分別對應為活動物件和變數物件;也就是說:

在全域性環境中,ES5中用物件環境記錄器來儲存變數和函式,ES3中用變數物件來儲存變數和函式

在函式環境中,ES5中用宣告式環境記錄器來儲存變數和函式,ES3中用活動物件來儲存變數和函式

用虛擬碼來表示ES5中執行上下文如下:

ExecutionContext = {
    ThisBinding = <this value>,
    // 詞法環境
    LexicalEnvironment = {
        // 環境記錄器,儲存著該上下文中通過let const宣告的變數,及函式宣告
        // 根據上下文型別,可以理解為變數物件或活動物件
        EnvironmentRecord: {},
        // 當前上下文對外部環境引用,及作用域
        outer: <>
    },
    // 變數環境
    VariableEnvironment = {
        // 環境記錄器,儲存著該上下文中通過var宣告的變數,及函式表示式
        // 根據上下文型別,可以理解為變數物件或活動物件
        EnvironmentRecord: {},
        // 當前上下文對外部環境引用,及作用域
        outer: <>
    },
}
複製程式碼

除了建立上下文結構的不同,ES3和ES5對於 js 程式的解析和執行過程都是相同的,所以,如果你理解了 es3 對於上下文的建立過程,那麼理解 es5 基本也不在話下了。

其實,仔細看上面的程式碼不難發現,bar 函式就是個閉包,但我們沒有進行分析,下面簡短的看看,理解下閉包的原理

// 稍微改下程式碼,將foo函式的返回值設定為 bar 函式
var x = 10;
function foo() {
  var y = 20;
  function bar() {
    var z = 30;
    alert(x + y + z);
  }
  return bar
}
var fn = foo()
fn()
複製程式碼

什麼是閉包就不用講了吧?其實如果在全域性宣告瞭一個函式,函式內部使用了全域性變數,這個函式就是一個閉包,只不過因為是在全域性,而全域性變數是可以被任何地方引用到的,所以這個閉包有點大,“包”裡裝的是全域性的所有變數;

上面程式碼中,foo函式就是一個引用了全域性變數的閉包函式,當然,如果是全域性的話,說它是閉包確實有點奇怪。所以,我們理解的閉包,其實是:在內部函式中,引用了外部函式的變數,那麼這個內部函式就是閉包,這個“包”中,裝滿了外部函式的變數。參考下我們在上面分析 es3 中建立上下文過程中的步驟理解就很容易了

  1. 建立全域性上下文,初始化變數,並將初始化後的變數填充到全域性上下文的變數物件中;
// 此時全域性上下文的變數物件
{
    x: undefined,
    foo: Function foo() {},
    fn: undefined
}
// 這一步中建立了 foo 函式
// 同時建立了foo函式的內部屬性[[Scope]]
// [[Scope]]屬性就包含了全域性變數物件的引用(一個閉包)
複製程式碼
  1. 建立完成後,執行程式碼,進行變數賦值
    • x = 10
    • 呼叫 foo, 將返回值賦值給 fn 變數
  2. 上一步中呼叫了 foo 函式,所以要建立 foo 函式執行上下文,這裡忽略初始化這一步,直接進入執行階段
// 執行階段的 foo 執行上下文如下:
FooContext = {
    AO: { 
        y: 20,
        bar: Function bar() {}
    },
    VO: { // 全域性變數物件
        x: 10,
        foo: Function foo() {},
        fn: Function bar() {},
    }
}
// 同理,建立foo上下文時,就建立了 bar 函式
// bar 函式的內部屬性[[Scope]]也就建立了,它包含著 foo 上下文中的活動物件和全域性變數物件
// 即:
bar.[[Scope]] = {
    AO: { 
        y: 20,
        bar: Function bar() {}
    },
    VO: {
        x: 10,
        foo: Function foo() {},
        fn: Function bar() {},
    }
}
複製程式碼
  1. foo函式返回了 bar 函式,foo執行完畢,foo執行上下文出棧,回到全域性執行上下文中
  2. 現在上下文是全域性執行上下文,所以,函式foo已經不存在了,foo內部的變數 y 和函式 bar 也不存在了,有的僅僅是 bar 函式的函式定義,並且將其賦值給了全域性的 fn 變數。
    • 你可能就要問了,y 變數不存在了,為什麼 bar 函式還能引用到?這裡就是閉包的功勞了
  3. 還記得 bar 函式建立時的 [[Scope]] 屬性嗎?它有父級函式 foo 的活動物件,也有全域性中的變數物件
  4. 所以,在執行 fn 時,先建立了fn的執行上下文(實際上是 bar 函式定義的執行上下文,再次強調,此時bar已經不存在,存在的只是函式定義),該執行上下文將 bar 函式定義的 [[Scope]] 屬性拷貝一份到該執行上下文中,然後初始化函式內部的變數 z。
// 此時 fn 的執行上下文如下
FnContext = {
    AO: { // bar函式定義的活動物件
        z: 30
    },
    AO: { // foo函式的活動物件
        y: 20,
        bar: Function bar() {}
    },
    VO: {
        x: 10,
        foo: Function foo() {},
        fn: Function bar() {},
    }
}
複製程式碼
  1. 執行階段,alert(x + y + z),延著當前執行上下文中的作用域鏈查詢變數 x, y, z,以此為10,20,30,進行輸出

注意:上面程式碼中:var fn = foo() 將foo函式的返回值賦值給變數 fn,因為存在引用,即 fn 變數引用了 foo 函式的返回值,這個返回值是 bar 函式的定義,所以即使不呼叫 fn,函式的定義及其閉包(foo內部的變數)都會一直存在於記憶體中,不會被垃圾回收機制回收。只有當一個物件或函式沒有任何引用變數引用它時,系統的垃圾回收機制才會在核實的時候回收它。所以慎用閉包,以免導致記憶體洩漏!

給個小建議:如果 es5 中建立執行上下文的過程不理解,可以先看通 es3 中對上下文的建立過程,無非是對變數的儲存結構的不同,其原理都是類似的,只要明白,建立過程其實就是解析的過程和作用域鏈生成的過程就好。

程式與執行緒

對於程式和執行緒傻傻分不清?沒關係,老規矩,上定義:

  • 程式是作業系統分配資源和排程任務的基本單位
  • 執行緒是建立在程式上的一次程式執行單位,一個程式上可以有多個執行緒。

拋開官方枯燥的定義,平民化定義程式與執行緒,拿鑽孔臺車舉例,鑽孔臺車分單臂和多臂,上圖:

深入js基礎:從記憶體機制、解析機制到執行機制(長文預警)

重新定義下程式與執行緒

  • 程式是你能拿出的鑽孔臺車的數量,如果你能拿出一臺,就是單程式,如果能拿出多臺,就是多程式
  • 執行緒是建立在鑽孔臺車種類的基礎上,你是單臂還是多臂,如果是單臂,就是單執行緒,多臂就是多執行緒

這下徹底明白了吧?眾所周知,js是單執行緒語言,也就是單臂,一次只能幹一個事;而瀏覽器是多程式多執行緒,你可以一次開啟多個瀏覽器標籤,這是多程式的體現;每個瀏覽器標籤中(瀏覽器核心)是多執行緒,其分為GUI渲染執行緒、JS引擎執行緒、事件觸發執行緒、定時觸發器執行緒、非同步HTTP請求執行緒。對於具體的執行緒,我們下面在將js執行機制時在詳細闡述。

協程

這裡對協程的介紹,參考了阮一峰老師在ECMAScript 6 入門中對Generator 函式的介紹,其作用是為了更好的控制非同步任務的流程,具體送上傳送門

因為除了在es6中協程的體現並不是很明顯,所以這裡不做展開,後期會考慮專門寫篇關於協程的文章。

JS的執行機制

js程式碼要想執行,必須經過預解析和執行兩個階段。

上文中,我們花了很大篇幅去講js執行上下文及其建立的過程,其實上下文建立的過程,完全可以理解為js的解析機制,在解析完後,上下文建立ok了,此時就需要執行程式碼了。

你可能要說了,還能怎麼執行,按順序從上到下執行唄。

非也,js程式碼也有好多種,同步、非同步、定時器、繫結事件、ajax請求等等。js的執行機制,規定了程式碼的執行順序,從而確保了一個單執行緒語言,不會因為某段程式碼執行時間過長而造成阻塞,這種執行機制,就是 js 的事件迴圈。

其實,在解釋事件迴圈的概念時,很多文章提及到了同步任務非同步任務微任務巨集任務。結合程式碼,基本分析如下:

console.log(1) // 同步
setTimeout(function() {// 非同步
    console.log(2)
}, 10)
console.log(3) // 同步
// 執行棧中,同步先執行,輸出 1
// 非同步任務的回撥函式加入到任務佇列。
// 接著同步任務,輸出 3
// 同步任務執行完成,如果非同步任務有了結果(10ms過後)
// 將任務佇列中非同步的回撥函式加入到執行棧中執行,輸出2
// 結果:1 3 2
複製程式碼

可是一旦加入Promise和process.nextTick,局面就不一樣了

console.log(1) // 同步
setTimeout(function() {// 非同步
    console.log(2)
}, 10)
new Promise((resolve) => {
    console.log('promise') // 同步
    resolve()
}).then(() => {
    console.log('then') // 非同步
})
console.log(3) // 同步
// 執行棧中,同步先執行,輸出 1
// 非同步任務的回撥函式加入到任務佇列。(console.log(2)和console.log('then'))
// 接著同步任務,輸出 promise 和 3
// 同步任務執行完成,如果非同步任務有了結果(10ms過後)
// 將任務佇列中非同步的回撥函式加入到執行棧中執行,輸出 2 then(佇列先進先出)
// 理想結果:1 promise 3 2 then
// 正確輸出:1 promise 3 then 2
複製程式碼

結果對嗎?顯然不對,兩個非同步任務console.log(2)console.log('then')執行順序錯了,佇列不是先進先出的嗎?2先進去,then後進去,為什麼執行結果反過來了?它們都是非同步任務,這時單單拿同步和非同步來解釋已經解釋不同了。

所以,很多文章自然引出了微任務巨集任務

巨集任務:整體程式碼script,setTimeout,setInterval

微任務:Promise,process.nextTick

執行機制變成:先執行整體程式碼(巨集任務),執行完成後,執行微任務,微任務執行完,第一輪事件迴圈完畢;開啟第二輪:讀取任務佇列,看是否有巨集任務,如果有就執行......

上面程式碼,因為promise是微任務,所以它優先於setTimeout執行。

確實這麼解釋並沒有錯,而且是正確的,這裡我並沒有對他們的解釋進行反駁。但是這樣解釋帶來的疑惑就是:

  1. 任務佇列是啥?
  2. 怎麼劃定巨集任務和微任務?
  3. 我怎麼知道整體程式碼script是巨集任務,Promise是微任務?全憑記憶?
  4. 非同步ajax是微任務還是巨集任務?
  5. 使用者點選事件的回撥是微任務還是巨集任務?

這麼多疑問,沒關係,讓我帶著你們一一破解。首先有必要先了解下瀏覽器核心。

瀏覽器核心執行緒和事件佇列

在介紹程式和執行緒時我們談到了瀏覽器核心是多執行緒的,它包括如下幾個執行緒:

  • GUI渲染執行緒:主要用來處理DOM樹解析,渲染,重繪(與DOM相關)等
  • JS引擎執行緒:執行javascript指令碼
  • 事件觸發執行緒:負責管理事件佇列,並交給 js 引擎執行緒執行。諸如DOM繫結的事件(onclick,onmouseenter等)、定時器計時結束、請求結束,當滿足條件後,對應的回撥函式會新增到事件佇列(也就是任務佇列)中。
    • 事件佇列:主要是來存放不同事件(定時器,使用者觸發的事件,請求事件)的回撥函式,當達到了相應的條件(如定時器到時,使用者點選按鈕,請求完成並響應)後,事件觸發執行緒,會將滿足條件的回撥函式,新增到 js 引擎執行緒中的任務佇列中等待執行。
  • 定時器執行緒:setTimeout、setInterval所在的執行緒,主要對定時器進行計時,定時時間到後,由事件觸發執行緒將回撥函式新增到事件佇列
  • 非同步網路請求執行緒:在該執行緒內進行非同步請求,請求狀態變更後,如果有回撥,由事件觸發執行緒將回撥處理新增到事件佇列

深入js基礎:從記憶體機制、解析機制到執行機制(長文預警)

結合瀏覽器核心,我們來看看什麼是微任務和巨集任務,這裡再一次列出部分文章對微任務巨集任務的說明:

巨集任務:整體程式碼script,setTimeout,setInterval

微任務:Promise,process.nextTick

巨集任務

首先明確,巨集任務和微任務,都是非同步任務

所謂的巨集任務,就是需要其它執行緒處理的任務,這裡的其它執行緒包括JS引擎執行緒事件觸發執行緒定時器執行緒非同步網路請求執行緒,所以一旦和上面4個執行緒掛鉤,它就是巨集任務。我們拿上面列出的巨集任務一一解釋下:

  • 整體程式碼script (JS引擎執行緒)
  • setTimeout,setInterval (定時器執行緒)
  • ajax非同步請求(非同步網路請求執行緒)
  • onclick事件繫結(事件觸發執行緒)

是不是比上面給出的巨集任務種類多?所以,記住:凡是涉及到瀏覽器核心執行緒的任務,都是巨集任務。

微任務

微任務通常來說就是需要在當前任務執行結束後立即執行的任務,比如非同步的任務但又不需要 JS引擎執行緒 處理的任務(除字很關鍵!)。

首先它是非同步的,其次,它不需要諸如事件觸發執行緒定時器執行緒非同步網路請求執行緒來處理。來看看Promise,process.nextTick,瀏覽器中有專門的核心執行緒來處理嗎?你可能會說它是由JS引擎執行緒執行的啊,確實,但它是個非同步,所以是微任務。

你可以這麼理解:不是巨集任務的任務就是微任務。

事件迴圈

羅嗦一大堆,終於來到事件迴圈了,有了前面理論的鋪墊,事件迴圈就很好理解了。

再回味下,事件迴圈就是 Javascript 的執行機制,即,規定了 Javascript 程式碼執行的流程:

  1. 執行整段 JavaScript 程式碼,將整段程式碼中的同步任務放入執行棧中執行(上面說了,這個是巨集任務)
  2. 程式碼中如果有 setTimeoutajax等巨集任務,會利用對應的瀏覽器的核心執行緒來處理,達到條件(定時器時間達到,請求完成)後,由事件觸發執行緒將其對應的回撥加入到事件佇列(任務佇列)中
  3. 如果有Promise等微任務,加入微任務佇列,在執行棧執行完當前的同步任務的之後,從微任務佇列中取出微任務,立即執行
  4. 所有的微任務執行完,此時,執行棧中處於閒置狀態(注意:本輪事件迴圈中所有微任務執行完,開啟下輪迴圈)
  5. 以上是第一輪事件迴圈,以下開始第二輪:
  6. 事件佇列將佇列中的任務加入到執行棧,按先進先出的順序執行
  7. 如果此時進入棧中的任務既有同步任務,微任務和巨集任務,那先執行同步任務
  8. 再執行所有的微任務
  9. 第二輪事件迴圈結束,開始第三輪迴圈:
  10. 執行巨集任務...迴圈往復,直到所有任務執行完畢。

下面用三段程式碼由淺入深的分析下:

案例一:只有巨集任務
console.log(111)
setTimeout(function() {
    console.log(222)
}, 5000)
$.ajax({
    url: '',
    success: function() {
        console.log(333)
    }
})
console.log(444)
複製程式碼
  1. 執行程式碼,先執行整段程式碼的同步任務console.log(111),輸出 111
  2. 遇到 setTimeout,將其交給定時器執行緒處理
  3. 遇到 ajax請求,將其交給非同步網路請求執行緒處理,
    • 注意:此時事件佇列只有一個任務: ajax成功的回撥(前提是,這個請求開始到結束響應耗時低於5秒,以下預設不解釋)
    • 咦?為什麼呢?實際上,前面也說了,setTimeout是在定時器執行緒中計時,5秒後才放入事件佇列中,而此時,ajax執行很快,還不到5秒,所以setTimeout的回撥還不會加入到事件佇列中
  4. 執行同步程式碼console.log(444),輸出 444
  5. 因為此程式碼沒有微任務,所以第一輪事件迴圈結束,準備開始第二輪事件迴圈
  6. 同步程式碼執行完,此時執行棧為空,要去事件佇列中取任務
    • 此時注意下,可能事件佇列中還沒有任務
    • 因為定時器執行緒5秒後,才由事件觸發執行緒將其回撥函式交給事件佇列
    • 同時,非同步網路請求執行緒,在請求完成響應200後,才由事件觸發執行緒將其成功的回撥函式交給事件佇列
    • 所以,如果請求時間過長,或執行之前的程式碼時間不足5秒,事件佇列都有可能為空
  7. 假設,ajax請求時間為2秒,此時事件佇列中就有了ajax成功的回撥,取出該回撥,輸出 333
  8. 5秒後,定時器執行緒setTimeout的回撥加入到事件佇列中
  9. 因為執行棧已經為空,所以直接取事件佇列中的setTimeout的回撥,輸出222
  10. 結果:111 444 333 222
  11. 如果ajax請求時間大於定時器的5秒,那麼結果是:111 444 222 333
  • 因為不好限制請求耗時,下面將setTimeout的定時均設定為0
  • setTimeout定時設定為0的意思是:執行程式碼時,一旦執行到setTimeout這行,定時器執行緒會立即把setTimeout的回撥放入事件佇列中,一旦執行棧空閒,立即取出執行。
  • 此時,就不存在ajax和setTimeout不好判斷的問題了。事件佇列中,ajax的回撥會一直在setTimeout回撥之後
案例二:巨集任務和微任務(一)
console.log(111)
setTimeout(function() {
    console.log(222)
}, 0)
new Promise((resolve) => {
    console.log('333')
    resolve()
}).then(() => {
    console.log('444')
})
$.ajax({
    url: '',
    success: function() {
        console.log(555)
    }
})
console.log(666)
複製程式碼
  1. 執行程式碼,先執行整段程式碼的同步任務console.log(111),輸出 111
  2. 遇到 setTimeout,將其交給定時器執行緒處理,0秒後,才由事件觸發執行緒將其回撥函式交給事件佇列
  3. 遇到 Promise,Promise為立即執行函式,所以輸出 333
  4. 它有一個 then 回撥,是個微任務,先不執行
  5. 遇到 ajax請求,將其交給非同步網路請求執行緒處理,請求完成響應200後,由事件觸發執行緒將其成功的回撥函式交給事件佇列
    • 此時假設ajax請求完成,故現在的事件佇列為 ①setTimeout回撥,②ajax回撥
  6. 執行同步程式碼console.log(666),輸出 666
  7. 同步任務執行完,檢視是否有微任務,有微任務,為 then 回撥,開始執行,輸出 444
  8. 沒有其他微任務了,此時執行棧為空,第一輪事件迴圈結束,準備開始第二輪事件迴圈
  9. 去事件佇列中取任務
  10. 首先取出setTimeout的回撥,輸出 222
  11. 然後取出ajax的回撥,輸出 555
  12. 結果:111 333 666 444 222 555
案例三:巨集任務和微任務(二)
console.log(111)
new Promise((resolve) => { // promise1
  console.log(222)
  new Promise((resolve) => { // promise2
    console.log(333)
    resolve()
  }).then(() => {
    console.log(444)
  })
  resolve()
}).then(() => {
  console.log(555)
  new Promise((resolve) => { // promise3
    console.log(666)
    resolve()
  }).then(() => {
    console.log(777)
  })
})
setTimeout(function() {
  console.log(888)
}, 0)
console.log(999)
複製程式碼
  1. 執行程式碼,當前執行棧為全域性執行上下文棧,執行第一行程式碼console.log(111),輸出 111
  2. 遇到promise1,立即執行,輸出 222
  3. promise1內部還包含 promise2,立即執行,輸出 333,將 promise2 的then回撥加入到微任務佇列
  4. promise1 的then回撥加入到微任務佇列,它在promise2 的then回撥之後
  5. 遇到 setTimeout,將其交給定時器執行緒處理,0秒後,才由事件觸發執行緒將其回撥函式交給事件佇列
  6. 執行最後一行程式碼,輸出 999
  7. 執行完所有同步任務,執行棧此時沒有任務了,有微任務嗎?有,微任務佇列中有兩個微任務,依次取出
  8. 取出第一個微任務,為 promise2 的then回撥,加入到執行棧執行,輸出 444
  9. 取出第二個微任務,為 promise1 的then回撥,加入到執行棧執行,輸出 555,此時promise1 的then回撥中又包含了 promise3,執行promise3,輸出 666,將promise3 的then回撥加入到微任務佇列
  10. 執行棧任務為空,微任務佇列中有任務嗎?有,為剛剛加入的 promise3 的then回撥
  11. 取出promise3 的then回撥,加入到執行棧執行,輸出 777
  12. 微任務佇列為空了,第一輪事件迴圈執行完畢,開始第二輪迴圈,去事件佇列中取任務
  13. 執行setTimeout的回撥,輸出 888,沒有其他任務了,事件迴圈結束
  14. 結果:111 222 333 999 444 555 666 777 888
案例四:巨集任務和微任務互相巢狀
console.log(000)
setTimeout(function() {
  console.log(111)
  setTimeout(function() {
    console.log(222)
    new Promise((resolve) => {
      console.log(333)
      resolve()
    }).then(() => {
      console.log(444)
    })
  }, 0)
}, 0)

new Promise((resolve) => {
  console.log(555)
  resolve()
}).then(() => {
  new Promise((resolve) => {
    console.log(666)
    setTimeout(function() {
      resolve()
    }, 0)
  }).then(() => {
    console.log(777)
    setTimeout(function() {
      console.log(888)
    }, 0)
  })
})

$.ajax({
  url: '',
  success: function() {
    console.log(999)
  }
})

console.log(1010)
複製程式碼

是不是快瘋了?這都是什麼玩意,平時寫程式碼誰這麼寫?確實,這麼寫容易引起公憤,但相信,如果這段程式碼你能完整分析,js的執行機制你真的是沒問題了。下面開始分析咯,準備好心態~

  1. 執行程式碼,當前執行棧為全域性執行上下文棧,執行第一行程式碼console.log(000),輸出 0
  2. 遇到第一個setTimeout(這裡叫它setTimeout1回撥),將其交給定時器執行緒處理,0秒後,才由事件觸發執行緒將其回撥函式交給事件佇列
    • 事件佇列目前有:①setTimeout1回撥
  3. 遇到 Promise,立即執行,輸出 555,其 then 回撥為微任務,先不執行
  4. 遇到ajax,將其交給非同步網路請求執行緒處理,假設該請求耗時10秒,10秒後,由事件觸發執行緒將其成功的回撥函式交給事件佇列
  5. 執行最後一行同步程式碼console.log(1010),輸出 1010
  6. 同步程式碼執行完,有微任務嗎?有,promise的then回撥函式,執行
  7. then回撥中又包含一個 Promise,先執行 Promise,輸出 666,其 then 回撥為微任務,先不執行
  8. 然後看到該Promise還包含setTimeout(這裡叫它setTimeout2回撥),所以將其交給定時器執行緒處理,0秒後,才由事件觸發執行緒將其回撥函式交給事件佇列
    • 事件佇列目前有:①setTimeout1回撥,②setTimeout2回撥
  9. 因為其 resolve 在setTimeout中,故,此輪微任務不執行該 then回撥
  10. 還有微任務嗎?沒了,全域性執行上下文棧也為空了。第一輪事件迴圈結束,準備開始第二輪事件迴圈
  11. 去事件佇列中取任務,事件佇列目前有:①setTimeout1回撥,②setTimeout2回撥
  12. 先取出setTimeout1回撥,執行,首先輸出 111
  13. 它內部還有 setTimeout(這裡叫它setTimeout3回撥),將其交給定時器執行緒處理,0秒後,才由事件觸發執行緒將其回撥函式交給事件佇列
    • 事件佇列目前有:①setTimeout2回撥,②setTimeout3回撥
  14. 執行完setTimeout,有微任務嗎?沒有,全域性執行上下文棧為空了。第二輪事件迴圈結束,準備開始第三輪事件迴圈
  15. 去事件佇列中取任務,事件佇列目前有:①setTimeout2回撥,②setTimeout3回撥
  16. 先取出setTimeout2回撥,執行,setTimeout2的回撥函式是啥呢?還記得第一輪事件迴圈中的 resolve 嗎?
  17. 執行resolve,即promise的then回撥,輸出 777
  18. 它內部還有 setTimeout(這裡叫它setTimeout4回撥),將其交給定時器執行緒處理,0秒後,才由事件觸發執行緒將其回撥函式交給事件佇列
    • 事件佇列目前有:②setTimeout3回撥,④setTimeout4回撥
  19. 執行完setTimeout2,有微任務嗎?沒有,全域性執行上下文棧為空了。第三輪事件迴圈結束,準備開始第四輪事件迴圈
  20. 先取出setTimeout3回撥,執行,首先輸出 222
  21. setTimeout3內部有 Promise,執行,輸出 333
  22. 有微任務嗎?有,為promise的then回撥,執行,輸出 444
  23. 沒有微任務了,全域性執行上下文棧為空了。第四輪事件迴圈結束,準備開始第五輪事件迴圈
  24. 先取出setTimeout4回撥,執行,輸出 888
  25. 有微任務嗎?沒有,全域性執行上下文棧為空了。第五輪事件迴圈結束
  26. 10秒過後,事件佇列中有了ajax的回撥
  27. 全域性執行上下文棧此時為空,所以立即取出ajax的回撥,執行,輸出 999

結果為:0, 555, 1010, 666, 111, 777, 222, 333, 444, 888, 999

深入js基礎:從記憶體機制、解析機制到執行機制(長文預警)

如果你沒看懂,正常,一是因為程式碼確實太變態,沒人那麼寫;二確實是我分析的有些羅嗦,沒辦法了,已經盡最大努力簡化分析了。不過真的建議各位同學能仔細分析下它的執行順序,研究明白了,JS的執行機制就徹底明白了。

個人覺得還是有一點遺憾,沒有把JS的執行機制和執行棧相關聯進行解釋,下面最後一個案例,讓我們徹底把JS執行機制和上下文結合起來進行分析。

<script>
console.log(111)
function foo() {
  new Promise(function(resolve) { // Promise1
    console.log(222)
    resolve()
  }).then(function() {
    console.log(333)
  })
  setTimeout(function() { // setTimeout1
    console.log(444)
  }, 0)
}
foo()
new Promise((resolve) => { // Promise2
  console.log(555)
  resolve()
}).then(() => {
  console.log(666)
})
setTimeout(function() { // setTimeout2
  console.log(777)
}, 0)
console.log(888)
</script>
複製程式碼
  1. 全部程式碼進入瀏覽器執行,此時要建立全域性上下文棧,如何建立過程就不重複講解了。
  2. 建立完成,在全域性上下文棧中執行同步任務,輸出 111
  3. 遇到函式foo的呼叫,開始建立foo函式執行上下文棧
    • 此時的執行棧為:[foo函式棧,全域性棧]
  4. 在foo函式執行上下文棧中,遇到Promise1,它的第一個引數為立即執行的,而且引數為一個匿名函式
  5. 建立匿名函式執行上下文棧(這裡叫它匿名棧1),執行console.log(222)輸出 222;遇到呼叫resolve,注意:這個resolve()是將Promise物件的狀態從“未完成”變為“成功”,因為此處Promise1中沒有非同步任務,所以等console.log(222)執行完,會立即呼叫resolve。
    • 此時的執行棧為:[匿名棧1,foo函式棧,全域性棧]
  6. 呼叫resolve,建立匿名函式執行上下文棧(這裡叫它匿名棧2),此時程式跳到 then 方法,因為Promise的 then 方法是一個微任務,所以此時不會執行,將其放入微任務佇列中待執行。
    • 此時的執行棧為:[匿名棧2,匿名棧1,foo函式棧,全域性棧]
  7. resolve呼叫時建立的匿名函式執行上下文棧出棧
    • 此時的執行棧為:[匿名棧1,foo函式棧,全域性棧]
  8. resolve呼叫完後,Promise1中第一個匿名函式沒有其他執行的程式碼,所以匿名棧1也出棧
    • 此時的執行棧為:[foo函式棧,全域性棧]
  9. 此時執行上下文棧為 foo 函式棧,foo函式內部還有個 setTimeout,將它(setTimeout1)交給定時器執行緒處理,0秒後,才由事件觸發執行緒將其回撥函式交給事件佇列
  10. foo函式執行完畢,foo函式棧出棧
    • 此時的執行棧為:[全域性棧]
  11. 回到了全域性執行上下文棧中,遇到Promise2,立即執行它的第一個回撥函式,建立匿名函式執行上下文棧(匿名棧3)
    • 此時的執行棧為:[匿名棧3,全域性棧]
  12. 在當前匿名棧3中,執行輸出 555,呼叫resolve(同上,此處不在詳細書寫棧的變化),將其 then 回撥放入微任務佇列
    • 此時微任務佇列中為:[Promise1的then,Promise2的then]
  13. 匿名函式執行完,匿名棧3出棧
    • 此時的執行棧為:[全域性棧]
  14. 回到全域性棧,遇到setTimeout2,交給定時器執行緒處理,0秒後,才由事件觸發執行緒將其回撥函式交給事件佇列
    • 事件佇列此時為:[setTimeout1回撥,setTimeout2回撥]
  15. 執行最後一行程式碼,輸出 888
  16. 全域性棧中同步程式碼執行完畢,取出微任務佇列執行
    • 微任務佇列為[Promise1的then,Promise2的then],秉承先進先出的原則
  17. 先執行Promise1的then回撥,同樣,建立匿名函式執行上下文棧(匿名棧4),建立完後執行,輸出 333
    • 此時的執行棧為:[匿名棧4,全域性棧]
  18. 匿名棧4出棧,此時的執行棧為:[全域性棧]
  19. 再執行Promise2的then回撥,同樣,建立匿名函式執行上下文棧(匿名棧5),建立完後執行,輸出 666
    • 此時的執行棧為:[匿名棧5,全域性棧]
  20. 執行完後,匿名棧5出棧,此時的執行棧為:[全域性棧],同時微任務佇列為空,第一輪事件迴圈結束,開始第二輪
  21. 取出事件佇列中的任務,事件佇列為:[setTimeout1回撥,setTimeout2回撥],秉承先進先出的原則
  22. 先執行setTimeout1回撥,同樣,建立匿名函式棧6,執行輸出 444,匿名棧6出棧,回到全域性棧
  23. 再執行setTimeout2回撥,同樣,建立匿名函式棧7,執行輸出 777,匿名棧7出棧,回到全域性棧
  24. 結果:111 222 555 888 333 666 444 777

總結收尾

  • 不管是什麼樣的程式碼,有非同步也好,滑鼠事件也好,將它們分門別類,微任務放入微任務佇列,巨集任務由對應執行緒處理,放入事件佇列中;
  • 等同步任務執行完,取微任務佇列,執行所有微任務;
  • 沒有微任務了,執行事件佇列中的程式碼;
  • 微任務佇列和事件佇列,秉承先進先出的原則;
  • 如果有函式呼叫,且函式內部有微任務或巨集任務,其微任務或巨集任務都是在全域性執行上下文棧中執行

在回顧下本文涉及到的知識點

堆,棧,垃圾回收機制,執行環境,執行上下文,執行棧,變數物件,活動物件,詞彙環境,呼叫棧,作用域,變數提升,單執行緒,多執行緒,協程,事件迴圈,瀏覽器核心執行緒,事件佇列,微任務,巨集任務.....

如果你耐著性子看到這,可以說是真不容易。我承認確實寫的有些羅嗦,羅嗦的原因是,想更多的照顧下初入前端,或基礎不紮實的同學。如果你完全掌握了其中的原理,也可以跳躍性的瀏覽一番,看看我的解釋是不是和你的相同,也非常希望各位大佬們能提出寶貴的建議,文中有出現錯誤的地方,歡迎指出,多多學習交流。

相關文章