完全搞懂JavaScript中的做用域和閉包

1、做用域

  • 做用域是什麼

幾乎全部的編程語言都有一個基本功能,就是可以存儲變量的值,而且能在以後對這個值進行訪問和修改。ajax

那這些變量存儲在哪裏?怎麼找到它?由於只有找到它才能對它進行訪問和修改。編程

簡單來講,做用域就是一套規則,用於肯定在何處以及如何查找變量(標識符)。數組


那麼問題來了,究竟在哪裏設置這些做用域的規則呢?怎樣設置?閉包

首先,咱們要知道,一段代碼在執行以前會經歷三個步驟,統稱爲「編譯」。編程語言

  1. 分詞/詞法分析

這個過程會將字符串分解成有意義的代碼塊,這些代碼塊稱爲詞法單元函數

var a = 1;
// 這段代碼會被分解爲五個詞法單元:
var 、 a 、 = 、 1 、 ;
  1. 解析/語法分析

這個過程是將詞法單元流(數組)轉換成一個由元素逐級嵌套所組成的表明語法結構的樹。這個樹稱爲「抽象語法樹(AST)code

  1. 代碼生成

這個過程是將AST轉換爲可執行的代碼事件

簡單來講,用某種方法能夠將
var a = 2; 
的抽象語法樹(AST)轉化爲一組機器指令,
指令用來建立一個叫做a的變量,並將一個值2存在a中

在這個過程當中,有3個重要的角色:ip

  1. 引擎:從頭至尾負責整個JavaScript程序的編譯及執行過程
  2. 編譯器:負責語法分析及代碼生成
  3. 做用域(今天的主角):負責收集並維護由全部聲明的變量(標識符)註冊的一系列查詢,並實施一套嚴格的規則,肯定當前執行的代碼對這些變量的訪問權限。

因此,看似簡單的一段代碼 var a = 1; 編譯器是怎麼處理的呢?作用域

var a = 1;
  1. 首先,遇到 var a, 編譯器會詢問做用域是否已經有一個該名稱的變量存在於同一個做用域中。若是是,編譯器會忽略該聲明,繼續下一步。不然編譯器會要求做用域在當前做用域中聲明一個新變量,並命名爲a
  2. 其次,編譯器會爲引擎生成運行時所需的代碼,用來處理 a = 1 這個賦值操做。引擎運行時首先詢問做用域,當前做用域是否存在一個叫a的變量,若是是,引擎會使用這個變量,不然引擎會繼續查找該變量,若是找到了,就會將1賦值給它,不然引擎會拋出一個異常。

那麼,引擎是如何查找變量的?

引擎會爲變量 a 進行LHS查詢(左側)。另一個叫RHS查詢(右側)

簡單來講,LHS查詢就是試圖找到變量的容器自己(好比a);而RHS查詢就是查詢某個變量的值(好比1)

總結:做用域就是根據名稱查找變量的一套規則


  • 做用域嵌套

當一個塊或函數嵌套在另外一個塊或函數中時,就發生了做用域的嵌套。所以,在當前做用域中沒法找到某個變量時,引擎就會在外層嵌套的做用域中繼續查找,直到找到該變量,或抵達最外層的做用域(也就是全局做用域)爲止。

function add(a) {
  console.log(a + b)
}

var b = 2;

add(1) // 3

在add()內部對b進行RHS查詢,發現查詢不到,但能夠在上一級做用域(這裏是全局做用域)中查詢到。

怎麼區分LHS和RHS查詢?思考如下代碼

function add(a) {
  // 對b進行RHS查詢 沒法找到(未聲明)
  console.log(a + b) // 對變量b來講,取值操做
  b = a // 對變量b來講,賦值操做
}

add(1) // ReferenceError: b is not defined
function add(a) {
  // 對b進行LHS查詢,沒法找到,會自動建立一個全局變量window.b(非嚴格模式)
  b = a  // 對變量b來講,賦值操做
  console.log(a + b)// 對變量b來講,取值操做
}

add(1) // 2

總結:若是查找變量的目的是賦值,則進行LHS查詢;若是是取值,則進行RHS查詢


  • 詞法做用域

做用域有兩種主要的工做模型。第一種最爲廣泛,也是重點,叫做詞法做用域,另外一種叫做動態做用域(幾乎不用)

簡單來講,詞法做用域就是定義在詞法階段的做用域(通俗易懂的說,就是在寫代碼時變量或者函數聲明的位置)。

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

foo(2) // 2, 4, 12
  1. 全局做用域中有1個變量:foo
  2. foo做用域中有3個變量:a、b、bar
  3. bar做用域中有1個變量:c

變量查找的過程:首先從最內部的做用域(即bar函數)的做用域開始查找,引擎沒法找到變量a,所以會到上一級做用域(foo函數)中繼續查找,在這裏找到了變量a,所以引擎使用了這個引用。變量b同理,對於變量c來講,引擎在bar函數中的做用域就找到了它。

注意:做用域查找會在找到第一個匹配的變量(標識符)時中止查找


  • 函數做用域

簡單來講,函數做用域是指,屬於這個函數的所有變量均可以在這個函數範圍內使用及複用(複用:即在嵌套的其餘做用域中也可使用)。

var a = 1

// 定義一個函數包裹代碼塊,造成函數做用域
function foo() {
  var a = 2
  console.log(a) // 2
}

foo()
console.log(a) // 1

你會以爲,若是我要使用函數做用域,那麼我必須定義一個foo函數,這讓全局做用域多了個函數,污染了全局做用域,且必須執行一次該函數才能運行其中的代碼塊。

那有沒有一種辦法,可讓我不污染全局做用域(即不定義新的具名函數),且函數能夠自動執行呢?

你必定想到了,IIFE(當即執行函數)

var a = 1;
(function foo() {
  var a = 2
  console.log(a) // 2
})()
console.log(a) // 1

這種寫法,實際上不是一個函數聲明,而是一個函數表達式。要區分這二者,最簡單的方法就是看function關鍵字是否出如今第一個位置(第一個詞),若是是,那麼是函數聲明,不然是一個函數表達式。

  • 塊做用域

儘管你可能沒寫過塊做用域的代碼,但你必定對下面的代碼塊很熟悉:

for(var i = 0; i < 5; i++) {
  console.log(i)
}

咱們在for循環的頭部定義了變量i,是由於想在for循環內部的上下文中使用i,而忽略了最重要的一點:i會被綁定在外部做用域(即全局做用域中)。

ES6改變了這種狀況,引入let關鍵字,提供另外一種聲明變量的方式。

{
  let a = 2;
  console.log(a) // 2
}
console.log(a) // ReferenceError: a is not defined

討論一下以前的for循環

for(let i = 0; i < 5; i++) {
  console.log(i)
}
console.log(i) // ReferenceError: i is not defined

這裏,for循環頭部的i綁定在循環內部,其實它在每一次循環中,對i進行了從新賦值。

{
  let j;
  for(let j = 0; j < 5; j++) {
    let i = j // 每次循環從新賦值
    console.log(i)
  }
  j++
}
console.log(i) // ReferenceError: i is not defined

小知識:其實在ES6以前,使用try/catch結構(在catch分句中)也有塊做用域


  • 提高

先有雞(聲明)仍是先有蛋(賦值)?

簡單來講,一個做用域中,包括變量和函數在內的全部聲明都會在任何代碼被執行前首先被 「移動」 到做用域的最頂端,這個過程就叫做提高。

a = 2
var a
console.log(a) // 2

// 引擎解析:
var a
a = 2
console.log(a) // 2
console.log(a) // undefined
var a = 2

//引擎解析:
var a
console.log(a) // undefined
a = 2

能夠發現,當JavaScript看到 var a = 2; 時,會分紅兩個階段,編譯階段執行階段

編譯階段:定義聲明,var a

執行階段: 賦值聲明,a = 2

結論:先有蛋(聲明),後有雞(賦值)。

  • 函數優先

函數和變量都會提高,但函數會首先被提高,而後是變量。

foo() // 2

var foo = 1

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

foo = function() {
  console.log(3)
}

// 引擎解析:
function foo() {...}
foo()
foo = function() {...}

多個同名函數,後面的會覆蓋前面的函數

foo() // 3

var foo = 1

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

function foo() {
  console.log(3)
}

提高不受條件判斷控制

foo() // 2

if (true) {
  function foo() {
    console.log(1)
  }
} else {
  function foo() {
    console.log(2)
  }
}

注意:儘可能避免普通的var聲明和函數聲明混合在一塊兒使用。

2、閉包

  • 定義:當函數能夠記住並訪問所在的詞法做用域時,就產生了閉包,即便函數是在當前詞法做用域以外執行。

祕訣:JavaScript中閉包無處不在,你只須要可以識別並擁抱它。

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

var baz = foo()
baz() // 2 快看啊,這就是閉包!!!

函數bar()的詞法做用域可以訪問foo()的內部做用域,而後將bar()自己看成一個值類型進行傳遞。

正常狀況下,當foo()執行後,foo()內部的做用域都會被銷燬(引擎的垃圾回收機制),而閉包的「神奇」之處就是能夠阻止這件事請的發生。事實上foo()內部的做用域依然存在,否則bar()裏面沒法訪問到foo()做用域內的變量a

foo()執行後,bar()依然持有該做用域的引用,而這個引用就叫做閉包

總結:不管什麼時候何地,若是將函數看成值類型進行傳遞,你就會看到閉包在這些函數中的應用(定時器,ajax請求,事件監聽器...)。

我相信你懂了!

回顧一下以前提到的for循環

for(var i = 0; i < 10; i++) {
  setTimeout(function timer() {
    console.log(i)
  }, i * 1000)
}

指望:每秒依次打印一、二、三、四、5...9

結果:每秒打印的都是10

稍稍改進一下代碼(利用IIFE)

for(var i = 0; i < 10; i++) {
  (function(i) {
    setTimeout(function timer() {
        console.log(i)
    }, i * 1000)
  })(i)
}

問題解決!對了,咱們差點忘了let關鍵字

for(var i = 0; i < 10; i++) {
  let j = i // 閉包的塊做用域
  setTimeout(function timer() {
    console.log(j)
  }, j * 1000)
}

還記得嗎?以前有提到,for循環頭部的let聲明在每次迭代都會從新聲明賦值,並且每一個迭代都會使用上一個迭代結束的值來進行此次值的初始化。

最終版:

for(let i = 0; i < 10; i++) {
  setTimeout(function timer() {
    console.log(i)
  }, i * 1000)
}

好了,如今你確定懂了!

總結:當函數能夠記住並訪問所在的詞法做用域,即便函數是在當前的詞法做用域以外執行,就產生了閉包

若是你堅持看到了這裏,我替你感到高興,由於你已經掌握了JavaScript中的做用域和閉包,這些知識都是進階必備的,若是有不理解的,花時間多看幾遍,相信你必定能夠掌握其中的精髓。

都到這兒了!

點個關注再走唄!!

相關文章
相關標籤/搜索