【重學 JavaScript】 - 做用域和閉包

1、理解 JavaScript 的做用域、做用域鏈和內部原理

1.1 做用域

javascript 擁有一套設計良好的規則來存儲變量,而且以後能夠方便地找到這些變量,這套規則被稱爲做用域javascript

做用域就是代碼的執行環境,全局執行環境就是全局做用域,函數的執行環境就是私有做用域,它們都是棧內存。前端

1.2 做用域鏈

當代碼在一個環境中執行時,會建立變量對象的一個做用域鏈(做用域造成的鏈條),因爲變量的查找是沿着做用域鏈來實現的,因此也稱做用域鏈爲變量查找的機制。java

  • 做用域鏈的前端,始終都是當前執行的代碼所在環境的變量對象
  • 做用域鏈中的下一個對象來自於外部環境,而在下一個變量對象則來自下一個外部環境,一直到全局執行環境
  • 全局執行環境的變量對象始終都是做用域鏈上的最後一個對象
內部環境能夠經過做用域鏈訪問全部外部環境,但外部環境不能訪問內部環境的任何變量和函數。

1.3 內部原理

  • 編譯node

    以 var a = 2;爲例,說明 javascript 的內部編譯過程,主要包括如下三步:react

    • 分詞(tokenizing)ajax

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

      var a = 2;被分解成爲下面這些詞法單元:var、a、=、二、;。這些詞法單元組成了一個詞法單元流數組json

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

      把詞法單元流數組轉換成一個由元素逐級嵌套所組成的表明程序語法結構的樹,這個樹被稱爲「抽象語法樹」 (Abstract Syntax Tree, AST)數組

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

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

      將 AST 轉換爲可執行代碼的過程被稱爲代碼生成

      var a=2;的抽象語法樹轉爲一組機器指令,用來建立一個叫做 a 的變量(包括分配內存等),並將值 2 儲存在 a 中

      實際上,javascript 引擎的編譯過程要複雜得多,包括大量優化操做,上面的三個步驟是編譯過程的基本概述

      任何代碼片斷在執行前都要進行編譯,大部分狀況下編譯發生在代碼執行前的幾微秒。javascript 編譯器首先會對 var a=2;這段程序進行編譯,而後作好執行它的準備,而且一般立刻就會執行它

  • 執行

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

    實際上,代碼進行編譯,還要執行。下面仍然以 var a = 2;爲例,深刻說明編譯和執行過程

    • 編譯

      • 編譯器查找做用域是否已經有一個名稱爲 a 的變量存在於同一個做用域的集合中。若是是,編譯器會忽略該聲明,繼續進行編譯;不然它會要求做用域在當前做用域的集合中聲明一個新的變量,並命名爲 a
      • 編譯器將 var a = 2;這個代碼片斷編譯成用於執行的機器指令
      依據編譯器的編譯原理,javascript 中的重複聲明是合法的
      // test在做用域中首次出現,因此聲明新變量,並將20賦值給test
      var test = 20
      // test在做用域中已經存在,直接使用,將20的賦值替換成30
      var test = 30
    • 執行

      • 引擎運行時會首先查詢做用域,在當前的做用域集合中是否存在一個叫做 a 的變量。若是是,引擎就會使用這個變量;若是否,引擎會繼續查找該變量
      • 若是引擎最終找到了變量 a,就會將 2 賦值給它。不然引擎會拋出一個異常
  • 查詢

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

    從字面意思去理解,當變量出如今賦值操做的左側時進行 LHS 查詢,出如今右側時進行 RHS 查詢

    更準確地講,RHS 查詢與簡單地查找某個變量的值沒什麼區別,而 LHS 查詢則是試圖找到變量的容器自己,從而能夠對其賦值

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

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

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

    二、函數傳參 a = 2 對 a 進行了 LHS 引用

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

    四、console.log(a)對 a 進行了 RHS 引用,並把獲得的值傳給了 console.log(...)

  • 嵌套

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

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

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

  • 異常

    爲何區分 LHS 和 RHS 是一件重要的事情?由於在變量尚未聲明(在任何做用域中都沒法找到變量)的狀況下,這兩種查詢的行爲不同

    • RHS

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

      • 當引擎執行 LHS 查詢時,若是沒法找到變量,全局做用域會建立一個具備該名稱的變量,並將其返還給引擎
      function foo() {
        a = 1
      }
      foo()
      console.log(a) // 1
      • 若是在嚴格模式中 LHS 查詢失敗時,並不會建立並返回一個全局變量,引擎會拋出同 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)

    以上面這個代碼片斷來講明做用域的內部原理,分爲如下幾步:

    【1】引擎須要爲 foo(...)函數進行 RHS 引用,在全局做用域中查找 foo。成功找到並執行

    【2】引擎須要進行 foo 函數的傳參 a=2,爲 a 進行 LHS 引用,在 foo 函數做用域中查找 a。成功找到,並把 2 賦值給 a

    【3】引擎須要執行 console.log(...),爲 console 對象進行 RHS 引用,在 foo 函數做用域中查找 console 對象。因爲 console 是個內置對象,被成功找到

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

    【5】引擎須要執行 console.log(a),對 a 進行 RHS 引用,在 foo 函數做用域中查找 a,成功找到並執行

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

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

2、理解詞法做用域和動態做用域

2.1 詞法做用域

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

簡單地說,詞法做用域就是定義在詞法階段的做用域,是由寫代碼時將變量和塊做用域寫在哪裏來決定的,所以當詞法分析器處理代碼時會保持做用域不變

  • 關係

不管函數在哪裏被調用,也不管它如何被調用,它的詞法做用域都只由函數被聲明時所處的位置決定

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

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

image

做用域氣泡由其對應的做用域塊代碼寫在哪裏決定,它們是逐級包含的

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

氣泡 2 包含着 foo 所建立的做用域,其中有三個標識符:a、bar 和 b

氣泡 3 包含着 bar 所建立的做用域,其中只有一個標識符:c

  • 查找

做用域氣泡的結構和互相之間的位置關係給引擎提供了足夠的位置信息,引擎用這些信息來查找標識符的位置

在代碼片斷中,引擎執行 console.log(...)聲明,並查找 a、b 和 c 三個變量的引用。它首先從最內部的做用域,也就是 bar(...)函數的做用域開始查找。引擎沒法在這裏找到 a,所以會去上一級到所嵌套的 foo(...)的做用域中繼續查找。在這裏找到了 a,所以引擎使用了這個引用。對 b 來說也同樣。而對 c 來講,引擎在 bar(...)中找到了它

[注意]詞法做用域查找只會查找一級標識符,若是代碼引用了 foo.bar.baz,詞法做用域查找只會試圖查找 foo 標識符,找到這個變量後,對象屬性訪問規則分別接管對 bar 和 baz 屬性的訪問

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

做用域查找從運行時所處的最內部做用域開始,逐級向外或者說向上進行,直到碰見第一個匹配的標識符爲止

在多層的嵌套做用域中能夠定義同名的標識符,這叫做「遮蔽效應」,內部的標識符「遮蔽」了外部的標識符

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

經過這種技術能夠訪問那些被同名變量所遮蔽的全局變量。但非全局的變量若是被遮蔽了,不管如何都沒法被訪問到

2.2 動態做用域

javascript 使用的是詞法做用域,它最重要的特徵是它的定義過程發生在代碼的書寫階段

那爲何要介紹動態做用域呢?實際上動態做用域是 javascript 另外一個重要機制 this 的表親。做用域混亂多數是由於詞法做用域和 this 機制相混淆,傻傻分不清楚

動態做用域並不關心函數和做用域是如何聲明以及在任何處聲明的,只關心它們從何處調用。換句話說,做用域鏈是基於調用棧的,而不是代碼中的做用域嵌套

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

【1】若是處於詞法做用域,也就是如今的 javascript 環境。變量 a 首先在 foo()函數中查找,沒有找到。因而順着做用域鏈到全局做用域中查找,找到並賦值爲 2。因此控制檯輸出 2

【2】若是處於動態做用域,一樣地,變量 a 首先在 foo()中查找,沒有找到。這裏會順着調用棧在調用 foo()函數的地方,也就是 bar()函數中查找,找到並賦值爲 3。因此控制檯輸出 3

兩種做用域的區別,簡而言之,詞法做用域是在定義時肯定的,而動態做用域是在運行時肯定的

3、理解 JavaScript 的執行上下文棧,能夠應用堆棧信息快速定位問題

3.1 執行上下文

  • 全局執行上下文: 這是默認的、最基礎的執行上下文。不在任何函數中的代碼都位於全局執行上下文中。它作了兩件事:1. 建立一個全局對象,在瀏覽器中這個全局對象就是 window 對象。2. 將 this 指針指向這個全局對象。一個程序中只能存在一個全局執行上下文。
  • 函數執行上下文: 每次調用函數時,都會爲該函數建立一個新的執行上下文。每一個函數都擁有本身的執行上下文,可是隻有在函數被調用的時候纔會被建立。一個程序中能夠存在任意數量的函數執行上下文。每當一個新的執行上下文被建立,它都會按照特定的順序執行一系列步驟,具體過程將在本文後面討論。
  • Eval 函數執行上下文: 運行在 eval 函數中的代碼也得到了本身的執行上下文,但因爲 Javascript 開發人員不經常使用 eval 函數,因此在這裏再也不討論。

3.2 執行棧

執行棧,在其餘編程語言中也被叫作調用棧,具備 LIFO(後進先出)結構,用於存儲在代碼執行期間建立的全部執行上下文。

當 JavaScript 引擎首次讀取你的腳本時,它會建立一個全局執行上下文並將其推入當前的執行棧。每當發生一個函數調用,引擎都會爲該函數建立一個新的執行上下文並將其推到當前執行棧的頂端。

引擎會運行執行上下文在執行棧頂端的函數,當此函數運行完成後,其對應的執行上下文將會從執行棧中彈出,上下文控制權將移到當前執行棧的下一個執行上下文。

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

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 引擎會建立一個全局執行上下文而且將它推入當前的執行棧。當調用 first() 函數時,JavaScript 引擎爲該函數建立了一個新的執行上下文並將其推到當前執行棧的頂端。

當在 first() 函數中調用 second() 函數時,Javascript 引擎爲該函數建立了一個新的執行上下文並將其推到當前執行棧的頂端。當 second() 函數執行完成後,它的執行上下文從當前執行棧中彈出,上下文控制權將移到當前執行棧的下一個執行上下文,即 first() 函數的執行上下文。

當 first() 函數執行完成後,它的執行上下文從當前執行棧中彈出,上下文控制權將移到全局執行上下文。一旦全部代碼執行完畢,Javascript 引擎把全局執行上下文從執行棧中移除。

3.3 執行上下文是如何被建立的

到目前爲止,咱們已經看到了 JavaScript 引擎如何管理執行上下文,如今就讓咱們來理解 JavaScript 引擎是如何建立執行上下文的。

執行上下文分兩個階段建立: 1)建立階段; 2)執行階段

3.4 建立階段

在任意的 JavaScript 代碼被執行前,執行上下文處於建立階段。在建立階段中總共發生了三件事情:

  • 肯定 this 的值,也被稱爲 This Binding 。
  • LexicalEnvironment(詞法環境) 組件被建立。
  • VariableEnvironment(變量環境) 組件被建立。

所以,執行上下文能夠在概念上表示以下:

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

This Binding:

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

在函數執行上下文中, this 的值取決於函數的調用方式。若是它被一個對象引用調用,那麼 this 的值被設置爲該對象,不然 this 的值被設置爲全局對象或 undefined (嚴格模式下)。例如:

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

person.calcAge();
// 'this' 指向 'person', 由於 'calcAge' 是被 'person' 對象引用調用的。

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

3.4.1 詞法環境(Lexical Environment)

官方 ES6 文檔將詞法環境定義爲:

詞法環境是一種規範類型,基於 ECMAScript 代碼的詞法嵌套結構來定義標識符與特定變量和函數的關聯關係。詞法環境由環境記錄(environment record)和可能爲空引用(null)的外部詞法環境組成。

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

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

  1. 環境記錄 是存儲變量和函數聲明的實際位置。
  2. 對外部環境的引用 意味着它能夠訪問其外部詞法環境。

詞法環境有兩種類型:

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

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

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

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

環境記錄一樣有兩種類型(以下所示):

  • 聲明性環境記錄 存儲變量、函數和參數。一個函數環境包含聲明性環境記錄。
  • 對象環境記錄 用於定義在全局執行上下文中出現的變量和函數的關聯。全局環境包含對象環境記錄。

抽象地說,詞法環境在僞代碼中看起來像這樣:

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

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

3.4.2 變量環境:

它也是一個詞法環境,其 EnvironmentRecord 包含了由 VariableStatements 在此執行上下文建立的綁定。

如上所述,變量環境也是一個詞法環境,所以它具備上面定義的詞法環境的全部屬性。

在 ES6 中, LexicalEnvironment 組件和 VariableEnvironment 組件的區別在於前者用於存儲函數聲明和變量( let 和 const )綁定,然後者僅用於存儲變量( var )綁定。

讓咱們結合一些代碼示例來理解上述概念:

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

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

c = multiply(20, 30);

執行上下文以下所示:

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>
  }
}

注意:只有在遇到函數 multiply 的調用時纔會建立函數執行上下文。

你可能已經注意到了 let 和 const 定義的變量沒有任何與之關聯的值,但 var 定義的變量設置爲 undefined 。

這是由於在建立階段,代碼會被掃描並解析變量和函數聲明,其中函數聲明存儲在環境中,而變量會被設置爲 undefined (在 var 的狀況下)或保持未初始化(在 let 和 const 的狀況下)。

這就是爲何你能夠在聲明以前訪問 var 定義的變量(儘管是 undefined ),但若是在聲明以前訪問 let 和 const 定義的變量就會提示引用錯誤的緣由。

這就是咱們所謂的變量提高。

3.5 執行階段

這是整篇文章中最簡單的部分。在此階段,完成對全部變量的分配,最後執行代碼。

注:在執行階段,若是 Javascript 引擎在源代碼中聲明的實際位置找不到 let 變量的值,那麼將爲其分配 undefined 值。

3.6 錯誤堆棧的裁剪

Node.js 才支持這個特性,經過 Error.captureStackTrace 來實現,Error.captureStackTrace 接收一個 object 做爲第 1 個參數,以及可選的 function 做爲第 2 個參數。其做用是捕獲當前的調用棧並對其進行裁剪,捕獲到的調用棧會記錄在第 1 個參數的 stack 屬性上,裁剪的參照點是第 2 個參數,也就是說,此函數以前的調用會被記錄到調用棧上面,而以後的不會。

讓咱們用代碼來講明,首先,把當前的調用棧捕獲並放到 myObj 上:

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

// 調用函數 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)

上面的調用棧中只有 a -> b,由於咱們在 b 調用 c 以前就捕獲了調用棧。如今對上面的代碼稍做修改,而後看看會發生什麼:

const myObj = {};
function d() {
  // 咱們把當前調用棧存儲到 myObj 上,可是會去掉 b 和 b 以後的部分
  Error.captureStackTrace(myObj, b);
}
function c() {
  d();
}
function b() {
  c();
}
function a() {
  b();
}

// 執行代碼
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)

在這段代碼裏面,由於咱們在調用 Error.captureStackTrace 的時候傳入了 b,這樣 b 以後的調用棧都會被隱藏。

如今你可能會問,知道這些到底有啥用?若是你想對用戶隱藏跟他業務無關的錯誤堆棧(好比某個庫的內部實現)就能夠試用這個技巧。

3.7 錯誤調試

3.7.1 Error對象和錯誤處理

當程序運行出現錯誤時, 一般會拋出一個 Error 對象. Error 對象能夠做爲用戶自定義錯誤對象繼承的原型.

Error.prototype 對象包含以下屬性:
 
constructor–指向實例的構造函數

message–錯誤信息

name–錯誤的名字(類型)

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

這樣的環境中, Error 對象具有 stack 屬性, 該屬性包含了錯誤的堆棧軌跡. 一個錯誤實例的堆棧軌跡包含了自構造函數以後的全部堆棧結構.

3.7.2 如何查看調用棧

只查看調用棧:console.trace

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

3.7.3 debugger打斷點形式

4、this 的原理以及幾種不一樣使用場景的取值

4.1 做爲對象方法調用

在 JavaScript 中,函數也是對象,所以函數能夠做爲一個對象的屬性,此時該函數被稱爲該對象的方法,在使用這種調用方式時,this 被天然綁定到該對象

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

4.2 做爲函數調用

函數也能夠直接被調用,此時 this 綁定到全局對象。在瀏覽器中,window 就是該全局對象。好比下面的例子:函數被調用時,this 被綁定到全局對象,

接下來執行賦值語句,至關於隱式的聲明瞭一個全局變量,這顯然不是調用者但願的。

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

4.3 做爲構造函數調用

javaScript 支持面向對象式編程,與主流的面向對象式編程語言不一樣,JavaScript 並無類(class)的概念,而是使用基於原型(prototype)的繼承方式。

相應的,JavaScript 中的構造函數也很特殊,若是不使用 new 調用,則和普通函數同樣。做爲又一項約定俗成的準則,構造函數以大寫字母開頭,

提醒調用者使用正確的方式調用。若是調用正確,this 綁定到新建立的對象上。

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

4.4 在call或者apply,bind中調用

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

這兩個方法異常強大,他們容許切換函數執行的上下文環境(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])

5、閉包的實現原理和做用,能夠列舉幾個開發中閉包的實際應用

5.1 閉包的概念

  • 指有權訪問另外一個函數做用域中的變量的函數,通常狀況就是在一個函數中包含另外一個函數。

5.2 閉包的做用

  • 訪問函數內部變量、保持函數在環境中一直存在,不會被垃圾回收機制處理

由於函數內部聲明 的變量是局部的,只能在函數內部訪問到,可是函數外部的變量是對函數內部可見的,這就是做用域鏈的特色了。

子級能夠向父級查找變量,逐級查找,找到爲止

所以咱們能夠在函數內部再建立一個函數,這樣對內部的函數來講,外層函數的變量都是可見的,而後咱們就能夠訪問到他的變量了。

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

    function foo(){
        console.log(value);
    }
    return foo();
};
var bar2=bar;
//實際上bar()函數並無由於執行完就被垃圾回收機制處理掉
//這就是閉包的做用,調用bar()函數,就會執行裏面的foo函數,foo這時就會訪問到外層的變量
bar2();

foo()包含bar()內部做用域的閉包,使得該做用域可以一直存活,不會被垃圾回收機制處理掉,這就是閉包的做用,以供foo()在任什麼時候間進行引用。

5.3 閉包的優勢

  • 方便調用上下文中聲明的局部變量
  • 邏輯緊密,能夠在一個函數中再建立個函數,避免了傳參的問題

5.4 閉包的缺點

  • 由於使用閉包,可使函數在執行完後不被銷燬,保留在內存中,若是大量使用閉包就會形成內存泄露,內存消耗很大

5.5 閉包在實際中的應用

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

通常setTimeout的第一個參數是個函數,可是不能傳值。若是想傳值進去,能夠調用一個函數返回一個內部函數的調用,將內部函數的調用傳給setTimeout。內部函數執行所需的參數,外部函數傳給他,在setTimeout函數中也能夠訪問到外部函數。

6、理解堆棧溢出和內存泄漏的原理,如何防止

6.1 內存泄露

  • 申請的內存執行完後沒有及時的清理或者銷燬,佔用空閒內存,內存泄露過多的話,就會致使後面的程序申請不到內存。所以內存泄露會致使內部內存溢出

6.2 堆棧溢出

  • 內存空間已經被申請完,沒有足夠的內存提供了

6.3 標記清除法

在一些編程軟件中,好比c語言中,須要使用malloc來申請內存空間,再使用free釋放掉,須要手動清除。而js中是有本身的垃圾回收機制的,通常經常使用的垃圾收集方法就是標記清除。

標記清除法:在一個變量進入執行環境後就給它添加一個標記:進入環境,進入環境的變量不會被釋放,由於只要執行流進入響應的環境,就可能用到他們。當變量離開環境後,則將其標記爲「離開環境」。

6.4 常見的內存泄露的緣由

  • 全局變量引發的內存泄露
  • 閉包
  • 沒有被清除的計時器

6.5 解決方法

  • 減小沒必要要的全局變量
  • 減小閉包的使用(由於閉包會致使內存泄露)
  • 避免死循環的發生

7、如何處理循環的異步操做

7.1 使用自執行函數

一、當自執行函數在循環當中使用時,自執行函數會在循環結束以後纔會運行。好比你在自執行函數外面定義一個數組,在自執行函數當中給這個數組追加內容,你在自執行函數以外輸出時,會發現這個數組當中什麼都沒有,這就是由於自執行函數會在循環運行完後纔會執行。

二、當自執行函數在循環當中使用時,要是自執行函數當中嵌套ajax,那麼循環當中的下標i就不會傳進ajax當中,須要在ajax外面把下標i賦值給一個變量,在ajax中直接調用這個變量就能夠了。

例子:

$.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) {
                        //獲取數據後作的事情
                    }
                })
            })(i, data[i].abbreviation);
        }
    }
});

7.2 使用遞歸函數

所謂的遞歸函數就是在函數體內調用本函數。使用遞歸函數必定要注意,處理不當就會進入死循環。

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

7.3 使用async/await

  • async/await特色

async/await更加語義化,async 是「異步」的簡寫,async function 用於申明一個 function 是異步的; await,能夠認爲是async wait的簡寫, 用於等待一個異步方法執行完成;

async/await是一個用同步思惟解決異步問題的方案(等結果出來以後,代碼纔會繼續往下執行)

能夠經過多層 async function 的同步寫法代替傳統的callback嵌套

  • async function語法

自動將常規函數轉換成Promise,返回值也是一個Promise對象

只有async函數內部的異步操做執行完,纔會執行then方法指定的回調函數

異步函數內部可使用await

  • await語法

await 放置在Promise調用以前,await 強制後面點代碼等待,直到Promise對象resolve,獲得resolve的值做爲await表達式的運算結果

await只能在async函數內部使用,用在普通函數裏就會報錯

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);
        //獲取數據後作的事情
    }
}
asyncDeal();

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

8.1 CommonJS規範(同步加載模塊)

容許模塊經過require方法來同步加載所要依賴的其餘模塊,而後經過exports或module.exports來導出須要暴露的接口。

使用方式:

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

優勢:

  • 簡單容易使用
  • 服務器端模塊便於複用

缺點:

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

爲何瀏覽器不能使用同步加載,服務端能夠?

  • 由於模塊都放在服務器端,對於服務端來講模塊加載時
  • 而對於瀏覽器端,由於模塊都放在服務器端,加載的時間還取決於網速的快慢等因素,若是須要等很長時間,整個應用就會被阻塞。
  • 所以,瀏覽器端的模塊,不能採用"同步加載"(CommonJs),只能採用"異步加載"(AMD)。

參照CommonJs模塊表明node.js的模塊系統

8.2 AMD(異步加載模塊)

採用異步方式加載模塊,模塊的加載不影響後面語句的運行。全部依賴模塊的語句,都定義在一個回調函數中,等到加載完成以後,回調函數才執行。

使用實例:

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

加載模塊require([module], callback);第一個參數[module],是一個數組,裏面的成員就是要加載的模塊;第二個參數callback是加載成功以後的回調函數。

優勢:

  • 適合在瀏覽器環境中異步加載模塊
  • 能夠並行加載多個模塊

缺點:

  • 提升了開發成本,代碼的閱讀和書寫比較困難,模塊定義方式的語義不暢
  • 不符合通用的模塊化思惟方式,是一種妥協的實現

實現AMD規範表明require.js

RequireJS對模塊的態度是預執行。因爲 RequireJS 是執行的 AMD 規範, 所以全部的依賴模塊都是先執行;即RequireJS是預先把依賴的模塊執行,至關於把require提早了

RequireJS執行流程:

  • require函數檢查依賴的模塊,根據配置文件,獲取js文件的實際路徑
  • 根據js文件實際路徑,在dom中插入script節點,並綁定onload事件來獲取該模塊加載完成的通知。
  • 依賴script所有加載完成後,調用回調函數

8.3 CMD規範(異步加載模塊)

CMD規範和AMD很類似,簡單,並與CommonJS和Node.js的 Modules 規範保持了很大的兼容性;在CMD規範中,一個模塊就是一個文件。

定義模塊使用全局函數define,其接收 factory 參數,factory 能夠是一個函數,也能夠是一個對象或字符串;

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

  • require 是一個方法,接受模塊標識做爲惟一參數,用來獲取其餘模塊提供的接口:require(id)
  • exports 是一個對象,用來向外提供模塊接口
  • module 是一個對象,上面存儲了與當前模塊相關聯的一些屬性和方法

實例:

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

優勢:

  • 依賴就近,延遲執行
  • 能夠很容易在 Node.js 中運行

缺點:

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

8.4 AMD 與 CMD 的區別

  • 對於依賴的模塊,AMD 是提早執行,CMD 是延遲執行。不過 RequireJS 從2.0開始,也改爲了能夠延遲執行(根據寫法不一樣,處理方式不一樣)。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()
   // ... 
});

8.5 UMD

  • UMD是AMD和CommonJS的糅合
  • AMD 以瀏覽器第一原則發展異步加載模塊。
  • CommonJS 模塊以服務器第一原則發展,選擇同步加載,它的模塊無需包裝。
  • 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 ...
});

8.6 ES6模塊化

  • ES6 在語言標準的層面上,實現了模塊功能,並且實現得至關簡單,徹底能夠取代 CommonJS 和 AMD 規範,成爲瀏覽器和服務器通用的模塊解決方案。
  • ES6 模塊設計思想:儘可能的靜態化、使得編譯時就能肯定模塊的依賴關係,以及輸入和輸出的變量(CommonJS和AMD模塊,都只能在運行時肯定這些東西)。

使用方式:

// 導入
import "/app";
import React from 「react」;
import { Component } from 「react」;
// 導出
export function multiply() {...};
export var year = 2018;
export default ...
...

優勢:

  • 容易進行靜態分析
  • 面向將來的 EcmaScript 標準

缺點:

  • 原生瀏覽器端尚未實現該標準
  • 全新的命令字,新版的 Node.js才支持。

8.7 回到問題「require與import的區別」

require使用與CommonJs規範,import使用於Es6模塊規範;因此二者的區別實質是兩種規範的區別;

CommonJS:

  • 對於基本數據類型,屬於複製。即會被模塊緩存;同時,在另外一個模塊能夠對該模塊輸出的變量從新賦值。
  • 對於複雜數據類型,屬於淺拷貝。因爲兩個模塊引用的對象指向同一個內存空間,所以對該模塊的值作修改時會影響另外一個模塊。
  • 當使用require命令加載某個模塊時,就會運行整個模塊的代碼。
  • 當使用require命令加載同一個模塊時,不會再執行該模塊,而是取到緩存之中的值。也就是說,CommonJS模塊不管加載多少次,都只會在第一次加載時運行一次,之後再加載,就返回第一次運行的結果,除非手動清除系統緩存。
  • 循環加載時,屬於加載時執行。即腳本代碼在require的時候,就會所有執行。一旦出現某個模塊被"循環加載",就只輸出已經執行的部分,還未執行的部分不會輸出。

ES6模塊

  • ES6模塊中的值屬於【動態只讀引用】。
  • 對於只讀來講,即不容許修改引入變量的值,import的變量是隻讀的,不管是基本數據類型仍是複雜數據類型。當模塊遇到import命令時,就會生成一個只讀引用。等到腳本真正執行時,再根據這個只讀引用,到被加載的那個模塊裏面去取值。
  • 對於動態來講,原始值發生變化,import加載的值也會發生變化。不管是基本數據類型仍是複雜數據類型。
  • 循環加載時,ES6模塊是動態引用。只要兩個模塊之間存在某個引用,代碼就可以執行。

最後:require/exports 是必要通用且必須的;由於事實上,目前你編寫的 import/export 最終都是編譯爲 require/exports 來執行的。

相關文章
相關標籤/搜索