作用域和閉包

一、理解 JavaScript 的作用域、作用域鏈和內(nèi)部原理

作用域

javascript 擁有一套設(shè)計良好的規(guī)則來存儲變量,并且之后可以方便地找到這些變量,這套規(guī)則被稱為作用域。

作用域就是代碼的執(zhí)行環(huán)境,全局執(zhí)行環(huán)境就是全局作用域,函數(shù)的執(zhí)行環(huán)境就是私有作用域,它們都是棧內(nèi)存。

作用域鏈

當代碼在一個環(huán)境中執(zhí)行時,會創(chuàng)建變量對象的一個作用域鏈(作用域形成的鏈條),由于變量的查找是沿著作用域鏈來實現(xiàn)的,所以也稱作用域鏈為變量查找的機制。

  • 作用域鏈的前端,始終都是當前執(zhí)行的代碼所在環(huán)境的變量對象
  • 作用域鏈中的下一個對象來自于外部環(huán)境,而在下一個變量對象則來自下一個外部環(huán)境,一直到全局執(zhí)行環(huán)境
  • 全局執(zhí)行環(huán)境的變量對象始終都是作用域鏈上的最后一個對象

內(nèi)部環(huán)境可以通過作用域鏈訪問所有外部環(huán)境,但外部環(huán)境不能訪問內(nèi)部環(huán)境的任何變量和函數(shù)。

內(nèi)部原理

  • 編譯

    以 var a = 2;為例,說明 javascript 的內(nèi)部編譯過程,主要包括以下三步:

    • 分詞(tokenizing)

      把由字符組成的字符串分解成有意義的代碼塊,這些代碼塊被稱為詞法單元(token)

      var a = 2;被分解成為下面這些詞法單元:var、a、=、2、;。這些詞法單元組成了一個詞法單元流數(shù)組

      [
        "var": "keyword",
        "a": "identifier",
        "=": "assignment",
        "2": "integer",
        ";": "eos" (end of statement)
      ]
      
    • 解析(parsing)

      把詞法單元流數(shù)組轉(zhuǎn)換成一個由元素逐級嵌套所組成的代表程序語法結(jié)構(gòu)的樹,這個樹被稱為“抽象語法樹” (Abstract Syntax Tree, AST)

      var a = 2;的抽象語法樹中有一個叫 VariableDeclaration 的頂級節(jié)點,接下來是一個叫 Identifier(它的值是 a)的子節(jié)點,以及一個叫 AssignmentExpression 的子節(jié)點,且該節(jié)點有一個叫 Numericliteral(它的值是 2)的子節(jié)點

      {
        operation: "=",
        left: {
          keyword: "var",
          right: "a"
        }
        right: "2"
      }
      
    • 代碼生成

      將 AST 轉(zhuǎn)換為可執(zhí)行代碼的過程被稱為代碼生成

      var a=2;的抽象語法樹轉(zhuǎn)為一組機器指令,用來創(chuàng)建一個叫作 a 的變量(包括分配內(nèi)存等),并將值 2 儲存在 a 中

      實際上,javascript 引擎的編譯過程要復(fù)雜得多,包括大量優(yōu)化操作,上面的三個步驟是編譯過程的基本概述

      任何代碼片段在執(zhí)行前都要進行編譯,大部分情況下編譯發(fā)生在代碼執(zhí)行前的幾微秒。javascript 編譯器首先會對 var a=2;這段程序進行編譯,然后做好執(zhí)行它的準備,并且通常馬上就會執(zhí)行它

  • 執(zhí)行

    簡而言之,編譯過程就是編譯器把程序分解成詞法單元(token),然后把詞法單元解析成語法樹(AST),再把語法樹變成機器指令等待執(zhí)行的過程

    實際上,代碼進行編譯,還要執(zhí)行。下面仍然以 var a = 2;為例,深入說明編譯和執(zhí)行過程

    • 編譯

      • 編譯器查找作用域是否已經(jīng)有一個名稱為 a 的變量存在于同一個作用域的集合中。如果是,編譯器會忽略該聲明,繼續(xù)進行編譯;否則它會要求作用域在當前作用域的集合中聲明一個新的變量,并命名為 a

      • 編譯器將 var a = 2;這個代碼片段編譯成用于執(zhí)行的機器指令

      依據(jù)編譯器的編譯原理,javascript 中的重復(fù)聲明是合法的

      // test在作用域中首次出現(xiàn),所以聲明新變量,并將20賦值給test
      var test = 20
      // test在作用域中已經(jīng)存在,直接使用,將20的賦值替換成30
      var test = 30
      
    • 執(zhí)行

      • 引擎運行時會首先查詢作用域,在當前的作用域集合中是否存在一個叫作 a 的變量。如果是,引擎就會使用這個變量;如果否,引擎會繼續(xù)查找該變量

      • 如果引擎最終找到了變量 a,就會將 2 賦值給它。否則引擎會拋出一個異常

  • 查詢

    在引擎執(zhí)行的第一步操作中,對變量 a 進行了查詢,這種查詢叫做 LHS 查詢。實際上,引擎查詢共分為兩種:LHS 查詢和 RHS 查詢

    從字面意思去理解,當變量出現(xiàn)在賦值操作的左側(cè)時進行 LHS 查詢,出現(xiàn)在右側(cè)時進行 RHS 查詢

    更準確地講,RHS 查詢與簡單地查找某個變量的值沒什么區(qū)別,而 LHS 查詢則是試圖找到變量的容器本身,從而可以對其賦值

    function foo(a) {
      console.log(a) // 2
    }
    foo(2)
    

    這段代碼中,總共包括 4 個查詢,分別是:

    1、foo(...)對 foo 進行了 RHS 引用

    2、函數(shù)傳參 a = 2 對 a 進行了 LHS 引用

    3、console.log(...)對 console 對象進行了 RHS 引用,并檢查其是否有一個 log 的方法

    4、console.log(a)對 a 進行了 RHS 引用,并把得到的值傳給了 console.log(...)

  • 嵌套

    在當前作用域中無法找到某個變量時,引擎就會在外層嵌套的作用域中繼續(xù)查找,直到找到該變量,或抵達最外層的作用域(也就是全局作用域)為止

    function foo(a) {
      console.log(a + b)
    }
    var b = 2
    foo(2) // 4
    

    行 RHS 引用,沒有找到;接著,引擎在全局作用域中查找 b,成功找到后,對其進行 RHS 引用,將 2 賦值給 b

  • 異常

    為什么區(qū)分 LHS 和 RHS 是一件重要的事情?因為在變量還沒有聲明(在任何作用域中都無法找到變量)的情況下,這兩種查詢的行為不一樣

    • RHS

      • 如果 RHS 查詢失敗,引擎會拋出 ReferenceError(引用錯誤)異常
      // 對b進行RHS查詢時,無法找到該變量。也就是說,這是一個“未聲明”的變量
      function foo(a) {
        a = b
      }
      foo() // ReferenceError: b is not defined
      
      • 如果 RHS 查詢找到了一個變量,但嘗試對變量的值進行不合理操作,比如對一個非函數(shù)類型值進行函數(shù)調(diào)用,或者引用 null 或 undefined 中的屬性,引擎會拋出另外一種類型異常:TypeError(類型錯誤)異常
      function foo() {
        var b = 0
        b()
      }
      foo() // TypeError: b is not a function
      
    • LHS

      • 當引擎執(zhí)行 LHS 查詢時,如果無法找到變量,全局作用域會創(chuàng)建一個具有該名稱的變量,并將其返還給引擎
      function foo() {
        a = 1
      }
      foo()
      console.log(a) // 1
      
      • 如果在嚴格模式中 LHS 查詢失敗時,并不會創(chuàng)建并返回一個全局變量,引擎會拋出同 RHS 查詢失敗時類似的 ReferenceError 異常
      function foo() {
        'use strict'
        a = 1
      }
      foo()
      console.log(a) // ReferenceError: a is not defined
      
  • 原理

    function foo(a) {
      console.log(a)
    }
    foo(2)
    

    以上面這個代碼片段來說明作用域的內(nèi)部原理,分為以下幾步:

    【1】引擎需要為 foo(...)函數(shù)進行 RHS 引用,在全局作用域中查找 foo。成功找到并執(zhí)行

    【2】引擎需要進行 foo 函數(shù)的傳參 a=2,為 a 進行 LHS 引用,在 foo 函數(shù)作用域中查找 a。成功找到,并把 2 賦值給 a

    【3】引擎需要執(zhí)行 console.log(...),為 console 對象進行 RHS 引用,在 foo 函數(shù)作用域中查找 console 對象。由于 console 是個內(nèi)置對象,被成功找到

    【4】引擎在 console 對象中查找 log(...)方法,成功找到

    【5】引擎需要執(zhí)行 console.log(a),對 a 進行 RHS 引用,在 foo 函數(shù)作用域中查找 a,成功找到并執(zhí)行

    【6】于是,引擎把 a 的值,也就是 2 傳到 console.log(...)中

    【7】最終,控制臺輸出 2

二、理解詞法作用域和動態(tài)作用域

詞法作用域

編譯器的第一個工作階段叫作分詞,就是把由字符組成的字符串分解成詞法單元。這個概念是理解詞法作用域的基礎(chǔ)

簡單地說,詞法作用域就是定義在詞法階段的作用域,是由寫代碼時將變量和塊作用域?qū)懺谀睦飦頉Q定的,因此當詞法分析器處理代碼時會保持作用域不變

  • 關(guān)系

無論函數(shù)在哪里被調(diào)用,也無論它如何被調(diào)用,它的詞法作用域都只由函數(shù)被聲明時所處的位置決定

function foo(a) {
  var b = a * 2
  function bar(c) {
    console.log(a, b, c)
  }
  bar(b * 3)
}
foo(2) // 2 4 12

在這個例子中有三個逐級嵌套的作用域。為了幫助理解,可以將它們想象成幾個逐級包含的氣泡

image

作用域氣泡由其對應(yīng)的作用域塊代碼寫在哪里決定,它們是逐級包含的

氣泡 1 包含著整個全局作用域,其中只有一個標識符:foo

氣泡 2 包含著 foo 所創(chuàng)建的作用域,其中有三個標識符:a、bar 和 b

氣泡 3 包含著 bar 所創(chuàng)建的作用域,其中只有一個標識符:c

  • 查找

作用域氣泡的結(jié)構(gòu)和互相之間的位置關(guān)系給引擎提供了足夠的位置信息,引擎用這些信息來查找標識符的位置

在代碼片段中,引擎執(zhí)行 console.log(...)聲明,并查找 a、b 和 c 三個變量的引用。它首先從最內(nèi)部的作用域,也就是 bar(...)函數(shù)的作用域開始查找。引擎無法在這里找到 a,因此會去上一級到所嵌套的 foo(...)的作用域中繼續(xù)查找。在這里找到了 a,因此引擎使用了這個引用。對 b 來講也一樣。而對 c 來說,引擎在 bar(...)中找到了它

[注意]詞法作用域查找只會查找一級標識符,如果代碼引用了 foo.bar.baz,詞法作用域查找只會試圖查找 foo 標識符,找到這個變量后,對象屬性訪問規(guī)則分別接管對 bar 和 baz 屬性的訪問

foo = {
  bar: {
    baz: 1
  }
}
console.log(foo.bar.baz) // 1
  • 遮蔽

作用域查找從運行時所處的最內(nèi)部作用域開始,逐級向外或者說向上進行,直到遇見第一個匹配的標識符為止

在多層的嵌套作用域中可以定義同名的標識符,這叫作“遮蔽效應(yīng)”,內(nèi)部的標識符“遮蔽”了外部的標識符

var a = 0
function test() {
  var a = 1
  console.log(a) // 1
}
test()

全局變量會自動為全局對象的屬性,因此可以不直接通過全局對象的詞法名稱,而是間接地通過對全局對象屬性的引用來對其進行訪問

var a = 0
function test() {
  var a = 1
  console.log(window.a) //0
}
test()

通過這種技術(shù)可以訪問那些被同名變量所遮蔽的全局變量。但非全局的變量如果被遮蔽了,無論如何都無法被訪問到

動態(tài)作用域

javascript 使用的是詞法作用域,它最重要的特征是它的定義過程發(fā)生在代碼的書寫階段

那為什么要介紹動態(tài)作用域呢?實際上動態(tài)作用域是 javascript 另一個重要機制 this 的表親。作用域混亂多數(shù)是因為詞法作用域和 this 機制相混淆,傻傻分不清楚

動態(tài)作用域并不關(guān)心函數(shù)和作用域是如何聲明以及在任何處聲明的,只關(guān)心它們從何處調(diào)用。換句話說,作用域鏈是基于調(diào)用棧的,而不是代碼中的作用域嵌套

var a = 2
function foo() {
  console.log(a)
}
function bar() {
  var a = 3
  foo()
}
bar()

【1】如果處于詞法作用域,也就是現(xiàn)在的 javascript 環(huán)境。變量 a 首先在 foo()函數(shù)中查找,沒有找到。于是順著作用域鏈到全局作用域中查找,找到并賦值為 2。所以控制臺輸出 2

【2】如果處于動態(tài)作用域,同樣地,變量 a 首先在 foo()中查找,沒有找到。這里會順著調(diào)用棧在調(diào)用 foo()函數(shù)的地方,也就是 bar()函數(shù)中查找,找到并賦值為 3。所以控制臺輸出 3

兩種作用域的區(qū)別,簡而言之,詞法作用域是在定義時確定的,而動態(tài)作用域是在運行時確定的

三、理解 JavaScript 的執(zhí)行上下文棧,可以應(yīng)用堆棧信息快速定位問題

執(zhí)行上下文

  • 全局執(zhí)行上下文: 這是默認的、最基礎(chǔ)的執(zhí)行上下文。不在任何函數(shù)中的代碼都位于全局執(zhí)行上下文中。它做了兩件事:1. 創(chuàng)建一個全局對象,在瀏覽器中這個全局對象就是 window 對象。2. 將 this 指針指向這個全局對象。一個程序中只能存在一個全局執(zhí)行上下文。
  • 函數(shù)執(zhí)行上下文: 每次調(diào)用函數(shù)時,都會為該函數(shù)創(chuàng)建一個新的執(zhí)行上下文。每個函數(shù)都擁有自己的執(zhí)行上下文,但是只有在函數(shù)被調(diào)用的時候才會被創(chuàng)建。一個程序中可以存在任意數(shù)量的函數(shù)執(zhí)行上下文。每當一個新的執(zhí)行上下文被創(chuàng)建,它都會按照特定的順序執(zhí)行一系列步驟,具體過程將在本文后面討論。
  • Eval 函數(shù)執(zhí)行上下文: 運行在 eval 函數(shù)中的代碼也獲得了自己的執(zhí)行上下文,但由于 Javascript 開發(fā)人員不常用 eval 函數(shù),所以在這里不再討論。

執(zhí)行棧

執(zhí)行棧,在其他編程語言中也被叫做調(diào)用棧,具有 LIFO(后進先出)結(jié)構(gòu),用于存儲在代碼執(zhí)行期間創(chuàng)建的所有執(zhí)行上下文。

當 JavaScript 引擎首次讀取你的腳本時,它會創(chuàng)建一個全局執(zhí)行上下文并將其推入當前的執(zhí)行棧。每當發(fā)生一個函數(shù)調(diào)用,引擎都會為該函數(shù)創(chuàng)建一個新的執(zhí)行上下文并將其推到當前執(zhí)行棧的頂端。

引擎會運行執(zhí)行上下文在執(zhí)行棧頂端的函數(shù),當此函數(shù)運行完成后,其對應(yīng)的執(zhí)行上下文將會從執(zhí)行棧中彈出,上下文控制權(quán)將移到當前執(zhí)行棧的下一個執(zhí)行上下文。

讓我們通過下面的代碼示例來理解這一點:

let a = 'Hello World!';

function first() {
console.log('Inside first function');
second();
console.log('Again inside first function');
}

function second() {
console.log('Inside second function');
}

first();
console.log('Inside Global Execution Context');

當上述代碼在瀏覽器中加載時,JavaScript 引擎會創(chuàng)建一個全局執(zhí)行上下文并且將它推入當前的執(zhí)行棧。當調(diào)用 first() 函數(shù)時,JavaScript 引擎為該函數(shù)創(chuàng)建了一個新的執(zhí)行上下文并將其推到當前執(zhí)行棧的頂端。

當在 first() 函數(shù)中調(diào)用 second() 函數(shù)時,Javascript 引擎為該函數(shù)創(chuàng)建了一個新的執(zhí)行上下文并將其推到當前執(zhí)行棧的頂端。當 second() 函數(shù)執(zhí)行完成后,它的執(zhí)行上下文從當前執(zhí)行棧中彈出,上下文控制權(quán)將移到當前執(zhí)行棧的下一個執(zhí)行上下文,即 first() 函數(shù)的執(zhí)行上下文。

first() 函數(shù)執(zhí)行完成后,它的執(zhí)行上下文從當前執(zhí)行棧中彈出,上下文控制權(quán)將移到全局執(zhí)行上下文。一旦所有代碼執(zhí)行完畢,Javascript 引擎把全局執(zhí)行上下文從執(zhí)行棧中移除。

執(zhí)行上下文是如何被創(chuàng)建的

到目前為止,我們已經(jīng)看到了 JavaScript 引擎如何管理執(zhí)行上下文,現(xiàn)在就讓我們來理解 JavaScript 引擎是如何創(chuàng)建執(zhí)行上下文的。

執(zhí)行上下文分兩個階段創(chuàng)建: 1)創(chuàng)建階段; 2)執(zhí)行階段

創(chuàng)建階段

在任意的 JavaScript 代碼被執(zhí)行前,執(zhí)行上下文處于創(chuàng)建階段。在創(chuàng)建階段中總共發(fā)生了三件事情:

  • 確定 this 的值,也被稱為 This Binding 。
  • LexicalEnvironment(詞法環(huán)境) 組件被創(chuàng)建。
  • VariableEnvironment(變量環(huán)境) 組件被創(chuàng)建。

因此,執(zhí)行上下文可以在概念上表示如下:

ExecutionContext = {
  ThisBinding = <this value>,
  LexicalEnvironment = { ... },
  VariableEnvironment = { ... },
}

This Binding:

在全局執(zhí)行上下文中, this 的值指向全局對象,在瀏覽器中, this 的值指向 window 對象。

在函數(shù)執(zhí)行上下文中, this 的值取決于函數(shù)的調(diào)用方式。如果它被一個對象引用調(diào)用,那么 this 的值被設(shè)置為該對象,否則 this 的值被設(shè)置為全局對象或 undefined (嚴格模式下)。例如:

let person = {
  name: 'peter',
  birthYear: 1994,
  calcAge: function() {
    console.log(2018 - this.birthYear);  
  }
}

person.calcAge();
// 'this' 指向 'person', 因為 'calcAge' 是被 'person' 對象引用調(diào)用的。

let calculateAge = person.calcAge;
calculateAge();
// 'this' 指向全局 window 對象,因為沒有給出任何對象引用

詞法環(huán)境(Lexical Environment)

官方 ES6 文檔將詞法環(huán)境定義為:

詞法環(huán)境是一種規(guī)范類型,基于 ECMAScript 代碼的詞法嵌套結(jié)構(gòu)來定義標識符與特定變量和函數(shù)的關(guān)聯(lián)關(guān)系。詞法環(huán)境由環(huán)境記錄(environment record)和可能為空引用(null)的外部詞法環(huán)境組成。

簡而言之,詞法環(huán)境是一個包含 標識符變量映射 的結(jié)構(gòu)。(這里的 標識符 表示變量/函數(shù)的名稱, 變量 是對實際對象【包括函數(shù)類型對象】或原始值的引用)

在詞法環(huán)境中,有兩個組成部分:(1) 環(huán)境記錄(environment record) (2) 對外部環(huán)境的引用

  1. 環(huán)境記錄 是存儲變量和函數(shù)聲明的實際位置。
  2. 對外部環(huán)境的引用 意味著它可以訪問其外部詞法環(huán)境。

詞法環(huán)境有兩種類型:

  • 全局環(huán)境(在全局執(zhí)行上下文中)是一個沒有外部環(huán)境的詞法環(huán)境。全局環(huán)境的外部環(huán)境引用為 null 。它擁有一個全局對象(window 對象)及其關(guān)聯(lián)的方法和屬性(例如數(shù)組方法)以及任何用戶自定義的全局變量, this 的值指向這個全局對象。
  • 函數(shù)環(huán)境,用戶在函數(shù)中定義的變量被存儲在 環(huán)境記錄 中。對外部環(huán)境的引用可以是全局環(huán)境,也可以是包含內(nèi)部函數(shù)的外部函數(shù)環(huán)境。

注意:對于 函數(shù)環(huán)境 而言, 環(huán)境記錄 還包含了一個 arguments 對象,該對象包含了索引和傳遞給函數(shù)的參數(shù)之間的映射以及傳遞給函數(shù)的參數(shù)的 長度(數(shù)量) 。例如,下面函數(shù)的 arguments 對象如下所示:

function foo(a, b) {
var c = a + b;
}
foo(2, 3);

// arguments 對象
Arguments: {0: 2, 1: 3, length: 2},

環(huán)境記錄同樣有兩種類型(如下所示):

  • 聲明性環(huán)境記錄 存儲變量、函數(shù)和參數(shù)。一個函數(shù)環(huán)境包含聲明性環(huán)境記錄。
  • 對象環(huán)境記錄 用于定義在全局執(zhí)行上下文中出現(xiàn)的變量和函數(shù)的關(guān)聯(lián)。全局環(huán)境包含對象環(huán)境記錄。

抽象地說,詞法環(huán)境在偽代碼中看起來像這樣:

GlobalExectionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // 標識符綁定在這里 
      outer: <null>
    }
  }
}

FunctionExectionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // 標識符綁定在這里 
      outer: <Global or outer function environment reference>
    }
  }
}

變量環(huán)境:

它也是一個詞法環(huán)境,其 EnvironmentRecord 包含了由 VariableStatements 在此執(zhí)行上下文創(chuàng)建的綁定。

如上所述,變量環(huán)境也是一個詞法環(huán)境,因此它具有上面定義的詞法環(huán)境的所有屬性。

在 ES6 中, LexicalEnvironment 組件和 VariableEnvironment 組件的區(qū)別在于前者用于存儲函數(shù)聲明和變量( letconst )綁定,而后者僅用于存儲變量( var )綁定。

讓我們結(jié)合一些代碼示例來理解上述概念:

let a = 20;
const b = 30;
var c;

function multiply(e, f) {
  var g = 20;
  return e *f *g;
}

c = multiply(20, 30);

執(zhí)行上下文如下所示:

GlobalExectionContext = {
  ThisBinding: <Global Object>,
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // 標識符綁定在這里  
      a: < uninitialized >,
      b: < uninitialized >,
      multiply: < func >
    }  
    outer: <null>
  },
  VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // 標識符綁定在這里  
      c: undefined,
    }  
    outer: <null>
  }
}

FunctionExectionContext = {
  ThisBinding: <Global Object>,
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // 標識符綁定在這里  
      Arguments: {0: 20, 1: 30, length: 2},
    },  
    outer: <GlobalLexicalEnvironment>
  },
  VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // 標識符綁定在這里  
      g: undefined
    },  
    outer: <GlobalLexicalEnvironment>
  }
}

注意:只有在遇到函數(shù) multiply 的調(diào)用時才會創(chuàng)建函數(shù)執(zhí)行上下文。

你可能已經(jīng)注意到了 letconst 定義的變量沒有任何與之關(guān)聯(lián)的值,但 var 定義的變量設(shè)置為 undefined 。

這是因為在創(chuàng)建階段,代碼會被掃描并解析變量和函數(shù)聲明,其中函數(shù)聲明存儲在環(huán)境中,而變量會被設(shè)置為 undefined (在 var 的情況下)或保持未初始化(在 letconst 的情況下)。

這就是為什么你可以在聲明之前訪問 var 定義的變量(盡管是 undefined ),但如果在聲明之前訪問 letconst 定義的變量就會提示引用錯誤的原因。

這就是我們所謂的變量提升。

執(zhí)行階段

這是整篇文章中最簡單的部分。在此階段,完成對所有變量的分配,最后執(zhí)行代碼。

注:在執(zhí)行階段,如果 Javascript 引擎在源代碼中聲明的實際位置找不到 let 變量的值,那么將為其分配 undefined 值。

錯誤堆棧的裁剪

Node.js 才支持這個特性,通過 Error.captureStackTrace 來實現(xiàn),Error.captureStackTrace 接收一個 object 作為第 1 個參數(shù),以及可選的 function 作為第 2 個參數(shù)。其作用是捕獲當前的調(diào)用棧并對其進行裁剪,捕獲到的調(diào)用棧會記錄在第 1 個參數(shù)的 stack 屬性上,裁剪的參照點是第 2 個參數(shù),也就是說,此函數(shù)之前的調(diào)用會被記錄到調(diào)用棧上面,而之后的不會。

讓我們用代碼來說明,首先,把當前的調(diào)用棧捕獲并放到 myObj 上:

const myObj = {};
function c() {}
function b() {
  // 把當前調(diào)用棧寫到 myObj 上
  Error.captureStackTrace(myObj);
  c();
}
function a() {
  b();
}

// 調(diào)用函數(shù) a
a();

// 打印 myObj.stack
console.log(myObj.stack);

// 輸出會是這樣
//    at b (repl:3:7) <-- Since it was called inside B, the B call is the last entry in the stack
//    at a (repl:2:1)
//    at repl:1:1 <-- Node internals below this line
//    at realRunInThisContextScript (vm.js:22:35)
//    at sigintHandlersWrap (vm.js:98:12)
//    at ContextifyScript.Script.runInThisContext (vm.js:24:12)
//    at REPLServer.defaultEval (repl.js:313:29)
//    at bound (domain.js:280:14)
//    at REPLServer.runBound [as eval] (domain.js:293:12)
//    at REPLServer.onLine (repl.js:513:10)

上面的調(diào)用棧中只有 a -> b,因為我們在 b 調(diào)用 c 之前就捕獲了調(diào)用?!,F(xiàn)在對上面的代碼稍作修改,然后看看會發(fā)生什么:

const myObj = {};
function d() {
  // 我們把當前調(diào)用棧存儲到 myObj 上,但是會去掉 b 和 b 之后的部分
  Error.captureStackTrace(myObj, b);
}
function c() {
  d();
}
function b() {
  c();
}
function a() {
  b();
}

// 執(zhí)行代碼
a();

// 打印 myObj.stack
console.log(myObj.stack);

// 輸出如下
//    at a (repl:2:1) <-- As you can see here we only get frames before b was called
//    at repl:1:1 <-- Node internals below this line
//    at realRunInThisContextScript (vm.js:22:35)
//    at sigintHandlersWrap (vm.js:98:12)
//    at ContextifyScript.Script.runInThisContext (vm.js:24:12)
//    at REPLServer.defaultEval (repl.js:313:29)
//    at bound (domain.js:280:14)
//    at REPLServer.runBound [as eval] (domain.js:293:12)
//    at REPLServer.onLine (repl.js:513:10)
//    at emitOne (events.js:101:20)

在這段代碼里面,因為我們在調(diào)用 Error.captureStackTrace 的時候傳入了 b,這樣 b 之后的調(diào)用棧都會被隱藏。

現(xiàn)在你可能會問,知道這些到底有啥用?如果你想對用戶隱藏跟他業(yè)務(wù)無關(guān)的錯誤堆棧(比如某個庫的內(nèi)部實現(xiàn))就可以試用這個技巧。

錯誤調(diào)試

1.Error對象和錯誤處理

當程序運行出現(xiàn)錯誤時, 通常會拋出一個 Error 對象. Error 對象可以作為用戶自定義錯誤對象繼承的原型.

Error.prototype 對象包含如下屬性:

constructor–指向?qū)嵗臉?gòu)造函數(shù)

message–錯誤信息

name–錯誤的名字(類型)

上述是 Error.prototype 的標準屬性, 此外, 不同的運行環(huán)境都有其特定的屬性. 在例如 Node, Firefox, Chrome, Edge, IE 10+, Opera 以及 Safari 6+

這樣的環(huán)境中, Error 對象具備 stack 屬性, 該屬性包含了錯誤的堆棧軌跡. 一個錯誤實例的堆棧軌跡包含了自構(gòu)造函數(shù)之后的所有堆棧結(jié)構(gòu).

2.如何查看調(diào)用棧

只查看調(diào)用棧:console.trace

a()
function a() {
  b()
}
function b() {
  c()
}
function c() {
  let aa = 1
}
console.trace()

3.debugger打斷點形式

四、this 的原理以及幾種不同使用場景的取值

作為對象方法調(diào)用

在 JavaScript 中,函數(shù)也是對象,因此函數(shù)可以作為一個對象的屬性,此時該函數(shù)被稱為該對象的方法,在使用這種調(diào)用方式時,this 被自然綁定到該對象

var test = {
  a:0,
  b:0,
  get:function(){
    return this.a;
  }
}

作為函數(shù)調(diào)用

函數(shù)也可以直接被調(diào)用,此時 this 綁定到全局對象。在瀏覽器中,window 就是該全局對象。比如下面的例子:函數(shù)被調(diào)用時,this 被綁定到全局對象,

接下來執(zhí)行賦值語句,相當于隱式的聲明了一個全局變量,這顯然不是調(diào)用者希望的。

function makeNoSense(x) {
  this.x = x;
}

作為構(gòu)造函數(shù)調(diào)用

javaScript 支持面向?qū)ο笫骄幊?,與主流的面向?qū)ο笫骄幊陶Z言不同,JavaScript 并沒有類(class)的概念,而是使用基于原型(prototype)的繼承方式。

相應(yīng)的,JavaScript 中的構(gòu)造函數(shù)也很特殊,如果不使用 new 調(diào)用,則和普通函數(shù)一樣。作為又一項約定俗成的準則,構(gòu)造函數(shù)以大寫字母開頭,

提醒調(diào)用者使用正確的方式調(diào)用。如果調(diào)用正確,this 綁定到新創(chuàng)建的對象上。

function Point(x, y){
  this.x = x;
  this.y = y;
}

在call或者apply,bind中調(diào)用

讓我們再一次重申,在 JavaScript 中函數(shù)也是對象,對象則有方法,apply 和 call 就是函數(shù)對象的方法。

這兩個方法異常強大,他們允許切換函數(shù)執(zhí)行的上下文環(huán)境(context),即 this 綁定的對象。

很多 JavaScript 中的技巧以及類庫都用到了該方法。讓我們看一個具體的例子:

function Point(x, y){
  this.x = x;
  this.y = y;
  this.moveTo = function(x, y){
    this.x = x;
    this.y = y;
  }
}

var p1 = new Point(0, 0);
var p2 = {x: 0, y: 0};
p1.moveTo(1, 1);
p1.moveTo.apply(p2, [10, 10])

五、閉包的實現(xiàn)原理和作用,可以列舉幾個開發(fā)中閉包的實際應(yīng)用

閉包的概念

  • 指有權(quán)訪問另一個函數(shù)作用域中的變量的函數(shù),一般情況就是在一個函數(shù)中包含另一個函數(shù)。

閉包的作用

  • 訪問函數(shù)內(nèi)部變量、保持函數(shù)在環(huán)境中一直存在,不會被垃圾回收機制處理

因為函數(shù)內(nèi)部聲明 的變量是局部的,只能在函數(shù)內(nèi)部訪問到,但是函數(shù)外部的變量是對函數(shù)內(nèi)部可見的,這就是作用域鏈的特點了。

子級可以向父級查找變量,逐級查找,找到為止

因此我們可以在函數(shù)內(nèi)部再創(chuàng)建一個函數(shù),這樣對內(nèi)部的函數(shù)來說,外層函數(shù)的變量都是可見的,然后我們就可以訪問到他的變量了。

function  bar(){
    //外層函數(shù)聲明的變量
    var value=1;

    function foo(){
        console.log(value);
    }
    return foo();
};
var bar2=bar;
//實際上bar()函數(shù)并沒有因為執(zhí)行完就被垃圾回收機制處理掉
//這就是閉包的作用,調(diào)用bar()函數(shù),就會執(zhí)行里面的foo函數(shù),foo這時就會訪問到外層的變量
bar2();

foo()包含bar()內(nèi)部作用域的閉包,使得該作用域能夠一直存活,不會被垃圾回收機制處理掉,這就是閉包的作用,以供foo()在任何時間進行引用。

閉包的優(yōu)點

  • 方便調(diào)用上下文中聲明的局部變量
  • 邏輯緊密,可以在一個函數(shù)中再創(chuàng)建個函數(shù),避免了傳參的問題

閉包的缺點

  • 因為使用閉包,可以使函數(shù)在執(zhí)行完后不被銷毀,保留在內(nèi)存中,如果大量使用閉包就會造成內(nèi)存泄露,內(nèi)存消耗很大

閉包在實際中的應(yīng)用

function addFn(a,b){
    return(function(){
        console.log(a+"+"+b);
    })
}
var test =addFn(a,b);
setTimeout(test,3000);

一般setTimeout的第一個參數(shù)是個函數(shù),但是不能傳值。如果想傳值進去,可以調(diào)用一個函數(shù)返回一個內(nèi)部函數(shù)的調(diào)用,將內(nèi)部函數(shù)的調(diào)用傳給setTimeout。內(nèi)部函數(shù)執(zhí)行所需的參數(shù),外部函數(shù)傳給他,在setTimeout函數(shù)中也可以訪問到外部函數(shù)。

六、理解堆棧溢出和內(nèi)存泄漏的原理,如何防止

內(nèi)存泄露

  • 申請的內(nèi)存執(zhí)行完后沒有及時的清理或者銷毀,占用空閑內(nèi)存,內(nèi)存泄露過多的話,就會導(dǎo)致后面的程序申請不到內(nèi)存。因此內(nèi)存泄露會導(dǎo)致內(nèi)部內(nèi)存溢出

堆棧溢出

  • 內(nèi)存空間已經(jīng)被申請完,沒有足夠的內(nèi)存提供了

標記清除法

在一些編程軟件中,比如c語言中,需要使用malloc來申請內(nèi)存空間,再使用free釋放掉,需要手動清除。而js中是有自己的垃圾回收機制的,一般常用的垃圾收集方法就是標記清除。

標記清除法:在一個變量進入執(zhí)行環(huán)境后就給它添加一個標記:進入環(huán)境,進入環(huán)境的變量不會被釋放,因為只要執(zhí)行流進入響應(yīng)的環(huán)境,就可能用到他們。當變量離開環(huán)境后,則將其標記為“離開環(huán)境”。

常見的內(nèi)存泄露的原因

  • 全局變量引起的內(nèi)存泄露
  • 閉包
  • 沒有被清除的計時器

解決方法

  • 減少不必要的全局變量
  • 減少閉包的使用(因為閉包會導(dǎo)致內(nèi)存泄露)
  • 避免死循環(huán)的發(fā)生

七、如何處理循環(huán)的異步操作

使用自執(zhí)行函數(shù)

1、當自執(zhí)行函數(shù)在循環(huán)當中使用時,自執(zhí)行函數(shù)會在循環(huán)結(jié)束之后才會運行。比如你在自執(zhí)行函數(shù)外面定義一個數(shù)組,在自執(zhí)行函數(shù)當中給這個數(shù)組追加內(nèi)容,你在自執(zhí)行函數(shù)之外輸出時,會發(fā)現(xiàn)這個數(shù)組當中什么都沒有,這就是因為自執(zhí)行函數(shù)會在循環(huán)運行完后才會執(zhí)行。

2、當自執(zhí)行函數(shù)在循環(huán)當中使用時,要是自執(zhí)行函數(shù)當中嵌套ajax,那么循環(huán)當中的下標i就不會傳進ajax當中,需要在ajax外面把下標i賦值給一個變量,在ajax中直接調(diào)用這個變量就可以了。

例子:

$.ajax({
    type: "GET",
    dataType: "json",
    url: "***",
    success: function(data) {
        //console.log(data);               
        for (var i = 0; i < data.length; i++) {
            (function(i, abbreviation) {
                $.ajax({
                    type: "GET",
                    url: "/api/faults?abbreviation=" + encodeURI(abbreviation),
                    dataType: "json",
                    success: function(result) {
                        //獲取數(shù)據(jù)后做的事情
                    }
                })
            })(i, data[i].abbreviation);
        }
    }
});

使用遞歸函數(shù)

所謂的遞歸函數(shù)就是在函數(shù)體內(nèi)調(diào)用本函數(shù)。使用遞歸函數(shù)一定要注意,處理不當就會進入死循環(huán)。

const asyncDeal = (i) = > {
    if (i < 3) {
        $.get('/api/changeParts/change_part_standard?part=' + data[i].change_part_name, function(res) {
            //獲取數(shù)據(jù)后做的事情
            i++;
            asyncDeal(i);
        })
    } else {
        //異步完成后做的事情
    }
};
asyncDeal(0);

使用async/await

  • async/await特點

async/await更加語義化,async 是“異步”的簡寫,async function 用于申明一個 function 是異步的; await,可以認為是async wait的簡寫, 用于等待一個異步方法執(zhí)行完成;

async/await是一個用同步思維解決異步問題的方案(等結(jié)果出來之后,代碼才會繼續(xù)往下執(zhí)行)

可以通過多層 async function 的同步寫法代替?zhèn)鹘y(tǒng)的callback嵌套

  • async function語法

自動將常規(guī)函數(shù)轉(zhuǎn)換成Promise,返回值也是一個Promise對象

只有async函數(shù)內(nèi)部的異步操作執(zhí)行完,才會執(zhí)行then方法指定的回調(diào)函數(shù)

異步函數(shù)內(nèi)部可以使用await

  • await語法

await 放置在Promise調(diào)用之前,await 強制后面點代碼等待,直到Promise對象resolve,得到resolve的值作為await表達式的運算結(jié)果

await只能在async函數(shù)內(nèi)部使用,用在普通函數(shù)里就會報錯

const asyncFunc = function(i) {
    return new Promise(function(resolve) {
        $.get(url, function(res) {
            resolve(res);
        })
    });
}
const asyncDeal = async function() {
    for (let i = 0; i < data.length; i++) {
        let res = await asyncFunc(i);
        //獲取數(shù)據(jù)后做的事情
    }
}
asyncDeal();

八、理解模塊化解決的實際問題,可列舉幾個模塊化方案并理解其中原理

CommonJS規(guī)范(同步加載模塊)

允許模塊通過require方法來同步加載所要依賴的其他模塊,然后通過exports或module.exports來導(dǎo)出需要暴露的接口。

使用方式:

// 導(dǎo)入
require("module");
require("../app.js");
// 導(dǎo)出
exports.getStoreInfo = function() {};
module.exports = someValue;

優(yōu)點:

  • 簡單容易使用
  • 服務(wù)器端模塊便于復(fù)用

缺點:

  • 同步加載方式不適合在瀏覽器環(huán)境中使用,同步意味著阻塞加載,瀏覽器資源是異步加載的
  • 不能非阻塞的并行加載多個模塊

為什么瀏覽器不能使用同步加載,服務(wù)端可以?

  • 因為模塊都放在服務(wù)器端,對于服務(wù)端來說模塊加載時
  • 而對于瀏覽器端,因為模塊都放在服務(wù)器端,加載的時間還取決于網(wǎng)速的快慢等因素,如果需要等很長時間,整個應(yīng)用就會被阻塞。
  • 因此,瀏覽器端的模塊,不能采用"同步加載"(CommonJs),只能采用"異步加載"(AMD)。

參照CommonJs模塊代表node.js的模塊系統(tǒng)

AMD(異步加載模塊)

采用異步方式加載模塊,模塊的加載不影響后面語句的運行。所有依賴模塊的語句,都定義在一個回調(diào)函數(shù)中,等到加載完成之后,回調(diào)函數(shù)才執(zhí)行。

使用實例:

// 定義
define("module", ["dep1", "dep2"], function(d1, d2) {...});
// 加載模塊
require(["module", "../app"], function(module, app) {...});

加載模塊require([module], callback);第一個參數(shù)[module],是一個數(shù)組,里面的成員就是要加載的模塊;第二個參數(shù)callback是加載成功之后的回調(diào)函數(shù)。

優(yōu)點:

  • 適合在瀏覽器環(huán)境中異步加載模塊
  • 可以并行加載多個模塊

缺點:

  • 提高了開發(fā)成本,代碼的閱讀和書寫比較困難,模塊定義方式的語義不順暢
  • 不符合通用的模塊化思維方式,是一種妥協(xié)的實現(xiàn)

實現(xiàn)AMD規(guī)范代表require.js

RequireJS對模塊的態(tài)度是預(yù)執(zhí)行。由于 RequireJS 是執(zhí)行的 AMD 規(guī)范, 因此所有的依賴模塊都是先執(zhí)行;即RequireJS是預(yù)先把依賴的模塊執(zhí)行,相當于把require提前了

RequireJS執(zhí)行流程:

  • require函數(shù)檢查依賴的模塊,根據(jù)配置文件,獲取js文件的實際路徑
  • 根據(jù)js文件實際路徑,在dom中插入script節(jié)點,并綁定onload事件來獲取該模塊加載完成的通知。
  • 依賴script全部加載完成后,調(diào)用回調(diào)函數(shù)

CMD規(guī)范(異步加載模塊)

CMD規(guī)范和AMD很相似,簡單,并與CommonJS和Node.js的 Modules 規(guī)范保持了很大的兼容性;在CMD規(guī)范中,一個模塊就是一個文件。

定義模塊使用全局函數(shù)define,其接收 factory 參數(shù),factory 可以是一個函數(shù),也可以是一個對象或字符串;

factory 是一個函數(shù),有三個參數(shù),function(require, exports, module):

  • require 是一個方法,接受模塊標識作為唯一參數(shù),用來獲取其他模塊提供的接口:require(id)
  • exports 是一個對象,用來向外提供模塊接口
  • module 是一個對象,上面存儲了與當前模塊相關(guān)聯(lián)的一些屬性和方法

實例:

define(function(require, exports, module) {
  var a = require('./a');
  a.doSomething();
  // 依賴就近書寫,什么時候用到什么時候引入
  var b = require('./b');
  b.doSomething();
});

優(yōu)點:

  • 依賴就近,延遲執(zhí)行
  • 可以很容易在 Node.js 中運行

缺點:

  • 依賴 SPM 打包,模塊的加載邏輯偏重
  • 實現(xiàn)代表庫sea.js:SeaJS對模塊的態(tài)度是懶執(zhí)行, SeaJS只會在真正需要使用(依賴)模塊時才執(zhí)行該模塊

AMD 與 CMD 的區(qū)別

  • 對于依賴的模塊,AMD 是提前執(zhí)行,CMD 是延遲執(zhí)行。不過 RequireJS 從2.0開始,也改成了可以延遲執(zhí)行(根據(jù)寫法不同,處理方式不同)。CMD 推崇 as lazy as possible.
  • AMD推崇依賴前置;CMD推崇依賴就近,只有在用到某個模塊的時候再去require。
// AMD
define(['./a', './b'], function(a, b) {  // 依賴必須一開始就寫好  
   a.doSomething()    
   // 此處略去 100 行    
   b.doSomething()    
   ...
});
// CMD
define(function(require, exports, module) {
   var a = require('./a')   
   a.doSomething()   
   // 此處略去 100 行   
   var b = require('./b') 
   // 依賴可以就近書寫   
   b.doSomething()
   // ... 
});

UMD

  • UMD是AMD和CommonJS的糅合
  • AMD 以瀏覽器第一原則發(fā)展異步加載模塊。
  • CommonJS 模塊以服務(wù)器第一原則發(fā)展,選擇同步加載,它的模塊無需包裝。
  • UMD先判斷是否支持Node.js的模塊(exports)是否存在,存在則使用Node.js模塊模式;在判斷是否支持AMD(define是否存在),存在則使用AMD方式加載模塊。
(function (window, factory) {
    if (typeof exports === 'object') {
    
        module.exports = factory();
    } else if (typeof define === 'function' && define.amd) {
    
        define(factory);
    } else {
    
        window.eventUtil = factory();
    }
})(this, function () {
    //module ...
});

ES6模塊化

  • ES6 在語言標準的層面上,實現(xiàn)了模塊功能,而且實現(xiàn)得相當簡單,完全可以取代 CommonJS 和 AMD 規(guī)范,成為瀏覽器和服務(wù)器通用的模塊解決方案。
  • ES6 模塊設(shè)計思想:盡量的靜態(tài)化、使得編譯時就能確定模塊的依賴關(guān)系,以及輸入和輸出的變量(CommonJS和AMD模塊,都只能在運行時確定這些東西)。

使用方式:

// 導(dǎo)入
import "/app";
import React from “react”;
import { Component } from “react”;
// 導(dǎo)出
export function multiply() {...};
export var year = 2018;
export default ...
...

優(yōu)點:

  • 容易進行靜態(tài)分析
  • 面向未來的 EcmaScript 標準
    缺點:
  • 原生瀏覽器端還沒有實現(xiàn)該標準
  • 全新的命令字,新版的 Node.js才支持。

回到問題“require與import的區(qū)別”

require使用與CommonJs規(guī)范,import使用于Es6模塊規(guī)范;所以兩者的區(qū)別實質(zhì)是兩種規(guī)范的區(qū)別;

CommonJS:

  • 對于基本數(shù)據(jù)類型,屬于復(fù)制。即會被模塊緩存;同時,在另一個模塊可以對該模塊輸出的變量重新賦值。
  • 對于復(fù)雜數(shù)據(jù)類型,屬于淺拷貝。由于兩個模塊引用的對象指向同一個內(nèi)存空間,因此對該模塊的值做修改時會影響另一個模塊。
  • 當使用require命令加載某個模塊時,就會運行整個模塊的代碼。
  • 當使用require命令加載同一個模塊時,不會再執(zhí)行該模塊,而是取到緩存之中的值。也就是說,CommonJS模塊無論加載多少次,都只會在第一次加載時運行一次,以后再加載,就返回第一次運行的結(jié)果,除非手動清除系統(tǒng)緩存。
  • 循環(huán)加載時,屬于加載時執(zhí)行。即腳本代碼在require的時候,就會全部執(zhí)行。一旦出現(xiàn)某個模塊被"循環(huán)加載",就只輸出已經(jīng)執(zhí)行的部分,還未執(zhí)行的部分不會輸出。

ES6模塊

  • ES6模塊中的值屬于【動態(tài)只讀引用】。
  • 對于只讀來說,即不允許修改引入變量的值,import的變量是只讀的,不論是基本數(shù)據(jù)類型還是復(fù)雜數(shù)據(jù)類型。當模塊遇到import命令時,就會生成一個只讀引用。等到腳本真正執(zhí)行時,再根據(jù)這個只讀引用,到被加載的那個模塊里面去取值。
  • 對于動態(tài)來說,原始值發(fā)生變化,import加載的值也會發(fā)生變化。不論是基本數(shù)據(jù)類型還是復(fù)雜數(shù)據(jù)類型。
  • 循環(huán)加載時,ES6模塊是動態(tài)引用。只要兩個模塊之間存在某個引用,代碼就能夠執(zhí)行。

最后:require/exports 是必要通用且必須的;因為事實上,目前你編寫的 import/export 最終都是編譯為 require/exports 來執(zhí)行的。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容