JavaScript 做用域詳解

本文首發於貝殼社區FE專欄,歡迎關注!javascript

1、什麼是做用域

編譯原理

  • 分詞/詞法分析(Tokenizing/Lexing) 這個過程會將由字符組成的字符串分解成(對編程語言來講)有意義的代碼塊,這些代 碼塊被稱爲詞法單元(token)。例如,考慮程序var a = 2;。這段程序一般會被分解成 爲下面這些詞法單元:var、a、=、2 、;。空格是否會被看成詞法單元,取決於空格在 這門語言中是否具備意義。
  • 解析/語法分析(Parsing) 這個過程是將詞法單元流(數組)轉換成一個由元素逐級嵌套所組成的表明了程序語法 結構的樹。這個樹被稱爲「抽象語法樹」(Abstract Syntax Tree,AST)。 var a = 2; 的抽象語法樹中可能會有一個叫做 VariableDeclaration 的頂級節點,接下 來是一個叫做 Identifier(它的值是 a)的子節點,以及一個叫做 AssignmentExpression 的子節點。AssignmentExpression 節點有一個叫做 NumericLiteral(它的值是 2)的子 節點。
  • 代碼生成 將 AST 轉換爲可執行代碼的過程稱被稱爲代碼生成。這個過程與語言、目標平臺等息 息相關。 拋開具體細節,簡單來講就是有某種方法能夠將 var a = 2; 的 AST 轉化爲一組機器指 令,用來建立一個叫做 a 的變量(包括分配內存等),並將一個值儲存在 a 中。

簡而言之:java

  1. 將代碼以詞爲單位拆分紅一個個詞法單元。
  2. 解析詞法單元轉換成 AST 語法樹。
  3. 生成機器指令。

編譯過程

編譯過程

整個編譯過程有三個角色須要登場:node

  • 引擎 負責整個 JavaScript 程序的編譯及執行過程。
  • 編譯器 負責語法分析及既期代碼生成。
  • 做用域 負責收集並維護全部聲明的變量組成的一系列查詢。

那麼整個 var a = 2; 的編譯過程以下:es6

  • 編譯器拿到 var a = 2; 這段代碼,進行語法分析。
  • 編譯器分析到 var a,向做用域進行變量定義操做。
    • 若是做用域中已有 a 變量,直接通知編譯器
    • 若是做用域中沒有 a 變量,建立 a 變量並通知編譯器
  • 編譯器收到通知,繼續執行並將 a = 2 這段代碼編譯爲及其語言傳給引擎
  • 引擎拿到 a = 2做用域中去查找 a 變量,準備賦值操做。
    • 若是 a 所在做用域下有 a 變量,做用域直接通知引擎
    • 若是 a 所在做用域下沒有 a 變量,則不斷向外部做用域查找 a 變量。
      • 在外部做用域找到 a 變量,做用域通知引擎
      • 在外部做用域找 a 變量直到全局做用域下也沒有找到,做用域通知引擎未找到 a 變量。
  • 引擎收到通知
    • 若是找到 a 變量,引擎做用域內對變量 a 賦值。
    • 若是沒有找到 a 變量,引擎發出 Refence Error 錯誤。

流程圖

做用域的好處

  • 安全性 —— 變量和函數能夠定義在最小做用域下。
  • 減小命名衝突 —— 做用域幫咱們較少命名衝突發生的機率。
  • 代碼複用性 —— 好的局部做用域能夠提高代碼的複用性。

2、LHS 與 RHS

定義

我對於 LHS 和 RHS 的理解是:全部賦值操做都是 LHS,如 a = 2;;而全部的取值操做都是 RHS,如 console.log(a);面試

當變量出如今賦值操做的左側時進行 LHS 查詢,出如今右側時進行 RHS 查詢。 —— 《你不知道的 JavaScript》編程

差別

在非嚴格模式下,當變量 a 未被定義,像 console.log(a) 這樣的RHS 查找會報 ReferenceError 的錯誤,而像 b = 2 這樣的 LHS 查找會在全局做用域下建立變量並進行賦值。數組

console.log(a); // type: RHS, output: ReferenceError
b = 2; // type: LHS, output: 2
複製代碼

而在嚴格模式下,LHS 和 RHS 的效果是相同的,都會報 ReferenceError安全

示例

function foo(a){
  console.log(a);
}
foo(2);
複製代碼

在以上例子中有 3 次 RHS 和 1 次 LHSbash

  • RHS foo(2) 查找 foo 函數。
  • LHS foo(2) 隱藏着 a = 2 賦值行爲。
  • RHS console.log(a) 查找 console 對象
  • RHS console.log(a) 查找 a 變量
function foo(a) {
  var b = a;
  return a + b;
}
var c = foo(2);
複製代碼

找出 3 次 LHS 4 次 RHS。閉包

  • RHS: foo(2) 查找 foo 函數。
  • LHS: foo(2) 隱藏有 a = 2 賦值行爲。
  • LHS: var c = foo(2) 是賦值行爲。
  • RHS: var b = a 查找 a 變量。
  • LHS: var b = a 是賦值行爲。
  • RHS: return a + b 查找 a 變量。
  • RHS: return a + b 查找 b 變量。

3、詞法做用域及欺騙詞法

詞法做用域

詞法做用域就是指咱們代碼詞法所表示的做用域。看下以下代碼:

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

這段代碼的詞法做用域如圖:

詞法做用域

其實就是咱們在代碼編寫時所定義的做用域即詞法做用域。

欺騙詞法

固然也有不按詞法規則來的寫法,稱爲欺騙詞法。

eval

相似於 eval() 方法會將字符串解析成 JS 語言的執行。它將破壞詞法做用域的規則。如

function foo() {
  eval('var a = 3')
  console.log(a) // 3
}

var a = 2;
foo();
複製代碼

with

with 這個冷門的關鍵詞一般被看成重複引用同一個對象中的多個屬性的快捷方式,能夠不須要重複引用對象自己。

var obj = { 
  a: 1,
  b: 2,
  c: 3
};
// 單調乏味的重複 "obj" 
obj.a = 2;
obj.b = 3;
obj.c = 4;
// 簡單的快捷方式 
with (obj) {
  a = 3;
  b = 4;
  c = 5;
}
複製代碼

兩種賦值方式看似等價。但若是賦值目標是 obj 對象中沒有的變量,兩種賦值效果是不一樣的。

var obj = { 
  a: 1,
  b: 2,
  c: 3
};

obj.d = 11;  

console.log(obj) // { a: 1, b:2, c:3, d: 11 } 
console.log(d) // ReferenceError
複製代碼
var obj = { 
  a: 1,
  b: 2,
  c: 3
};

with (obj) {
  d = 11;
}

console.log(obj) // { a: 1, b:2, c:3 } 
console.log(d) // 11

複製代碼

能夠看到在 with 函數中的對於變量 d 的賦值行爲(LHS)是定義在了 window 對象上的。

4、函數做用域和塊做用域

函數做用域

一般狀況下,函數內的變量沒法在函數外調用。即變量存在於函數做用域下,因此函數做用域起到了局部變量或者變量隱藏的做用。以下例子

var a = 2;

function foo() {
  var a = 3;
  console.log(a); // 3
} 
foo();
console.log(a); // 2
複製代碼

以上寫法將 foo 方法中的 a 變量隱藏了起來。不過也產生了一個問題 —— 全局做用域下多了一個 foo 函數變量。解決這種污染的方式是當即執行函數(IIFE),咱們將上面的代碼進行改造:

var a = 2;
(function foo() {
  var a = 3;
  console.log(a); // 3
})();
console.log(a); // 2
console.log(foo) // ReferenceError
複製代碼

這種寫法就能夠將 foo 函數變量也隱藏起來,避免對全局做用域的濡染。

塊做用域

定義

塊級做用域存在於 if, for, while, {} 等語法中,這些做用域中使用 var 定義的變量是不在這個做用域內的。

塊做用域和函數定義域的區別在於:函數定義域隱藏函數內的變量,而塊做用域隱藏塊中的變量。舉個栗子:

// 函數做用域,隱藏變量a
function test() {
  var a = 2
}
console.log(a) // ReferenceError
複製代碼
// 塊做用域,隱藏變量 i
// 不隱藏變量 a (不是函數做用域)
for (let i = 0; i < 10; i++) {
  var a = 2;
}
console.log(i) // ReferenceError
複製代碼

with 與 try/catch

with 和 catch 關鍵字都會建立塊級做用域,由於他們建立的做用域在外部做用域中無效。

var obj = {
  a: 1
}

with(obj) {
  a = 2
}

console.log(obj) // { a: 2 }
console.log(a) // ReferenceError
複製代碼
try {
  undefined(); // 執行一個非法操做來強制製造一個異常
} catch (err) {
  console.log(err); // 可以正常執行! 
}
console.log(err); // ReferenceError
複製代碼

let 與 const

let 和 const 關鍵字能夠將變量綁定到所在的任意做用域中。換句話說,let 和 const 爲其聲明的變量隱式地了所在的塊做用域。

{
  let a = 2;
}
console.log(a) // ReferenceError
複製代碼

可見 const 和 let 可以保證變量隱藏在所在做用域中。

var 與 let 的差別

因爲 ES5 只有全局做用域和函數做用域,沒有塊級做用域,這帶來不少不合理的場景。

而 ES6 所提出的 let 和 const 爲 JavaScript 帶來了塊做用域解決了這個問題。

下面列出4點 var 與 let 的差別之處:

  1. let 不存在變量提高。(var 的變量提高下文有說起)
console.log(foo); // undefined
var foo = 2;

console.log(bar); // ReferenceError
let bar = 2;
複製代碼
  1. let 在塊做用域內定義了變量後不受外部做用域變量影響。
var a = 3
{
  console.log(a) // ReferenceError
  let a
}
console.log(a) // 3
複製代碼
  1. 不容許重複申明。
  2. 最大的不一樣是在於 let 做用域塊做用域,而 var 只做用域函數做用域和全局做用域。

5、變量提高

在使用 var 定義變量和使用 function 定義函數時,會出現變量提高的狀況。

編譯順序

看幾個例子來理解下變量提高:

var a = 2; 
console.log( a );
// JavaScript 的處理邏輯
var a;
a = 2;
console.log(a); // 2
複製代碼
console.log( a );
var a = 2; 
// JavaScript 的處理邏輯
var a;
console.log(a); // undefined
a = 2;
複製代碼
foo();

function foo() {
  console.log(a); // undefined 
  var a = 2;
}
// JavaScript 的處理邏輯
function foo() {
  var a;
  console.log(a); // undefined 
  a = 2;
}
foo();
複製代碼

**爲何呢?**回憶一下上文說到的編譯過程就能理解了。看圖!

流程圖

能夠看到編譯器會將變量都定義到做用域中,而後再編譯代碼給引擎去執行代碼命令。var a = 2; 是被拆開執行的且 var a 變量會提早被定義。

再來看一個不靠譜的函數定義方法:

foo(); // "b"
var a = true;
if (a) {
  function foo() {
    console.log("a");
  }
} else {
  function foo() {
    console.log("b");
  }
}
複製代碼

輸出結果與《你不知道的 JavaScript》中的有所不一樣,在 node v10.5.0 中輸出的是 TypeError 而非 b。這個差別有待考證。

函數優先

雖然函數和變量都會提高,可是編譯器會先提高函數,再是變量。看以下例子:

foo(); // 1
var foo;

function foo() {
  console.log(1);
}
foo = function () {
  console.log(2);
};
複製代碼

同時是函數定義,可是第二種是定義變量的形式,因此聽從函數優先原則,以上代碼會變爲:

function foo() {
  console.log(1);
}
var foo; // 無心義

foo(); // 1

foo = function () {
  console.log(2);
};
複製代碼

6、閉包

下面是人見人怕的閉包。

定義

函數能夠記住並訪問所在的詞法做用域時,就產生了閉包。 當函數能夠記住並訪問所在的詞法做用域時,就產生了閉包。 當函數能夠記住並訪問所在的詞法做用域時,就產生了閉包。 重要的定義說三遍!

function foo() {
  var a = 2;

  function bar() {
    return a;
  }
  return bar;
}
var baz = foo();
console.log(baz()); // 2 <-- 這就是閉包
複製代碼

按照咱們對於函數做用域的理解,函數做用域外是沒法獲取函數做用域內的變量的。

可是經過閉包,函數做用域被持久保存,而且閉包函數能夠訪問到做用域下的變量。

下面再展現幾個閉包便於理解:

var fn;

function foo() {
  var a = 2;

  function baz() {
    console.log(a);
  }
  fn = baz; // 將 baz 分配給全局變量 
}

function bar() {
  fn(); // <-- 閉包!
}
foo();
bar(); // 2
複製代碼
function foo() {
  var a = 2;

  function baz() {
    console.log(a); // 2
  }
  bar(baz);
}

function bar(fn) {
  fn(); // <-- 閉包!
}
複製代碼
function wait(message) {
  setTimeout(function timer() {
    console.log(message);
  }, 1000);
}
wait("Hello, closure!");
// timer 持有 wait 函數做用域,因此是閉包。
複製代碼

上面幾個例子能夠概括下閉包的特性:

  1. 閉包一定是函數。
  2. 函數能夠在當前詞法做用域外持有並訪問詞法做用域。

就這麼簡單!按照這個定義其實全部的回調函數都屬因而閉包。

經典的循環面試題解析

for (var i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}
複製代碼

看看以上寫法最終輸出的是什麼呢?因爲 var i = 0 是在全局做用域下,且沒有任何地方存 i 的變化值,因此最終輸出是 5 個 6

解決方案有兩種:

  1. 使用閉包的持有做用域特性,爲每個 timer 函數封閉一個做用域保存當前的 i。
  2. 使用 let 塊做用域封閉 for 循環中的做用域,保存當前的 i 值。
// 閉包寫法
for (var i = 1; i <= 5; i++) {
  (function (j) {
    setTimeout(function timer() {
      console.log(j);
    }, j * 1000);
  })(i);
}
複製代碼
// 塊做用域寫法
for (let i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}
複製代碼

參考文檔

最後

本文旨在更方便和全面的理解做用域的相關知識,但願能對你有所幫助 JavaScript 的做用域知識不論是在面試中仍是在實際工做中都是很是重要的。

相關文章
相關標籤/搜索