做用域是JS中一個很基礎可是很重要的概念,面試中也常常出現,本文會詳細深刻的講解這個概念及其餘相關的概念,包括聲明提高,塊級做用域,做用域鏈及做用域鏈延長等問題。javascript
第一個問題就是咱們要弄清楚什麼是做用域,這不是JS獨有的概念,而是編程領域中通用的一個概念。咱們如下面這個語句爲例:前端
let x = 1;
這一個簡單的語句其實包含了幾個基本的概念:java
- 變量(variable):這裏x就是一個變量,是用來指代一個值的符號。
- 值(value):就是具體的數據,能夠是數字,字符串,對象等。這裏
1
就是一個值。- 變量綁定(name binding):就是變量和值之間創建對應關係,
x = 1
就是將變量x
和1
聯繫起來了。- 做用域(scope):做用域就是變量綁定(name binding)的有效範圍。就是說在這個做用域中,這個變量綁定是有效的,出了這個做用域變量綁定就無效了。
就整個編程領域而言的話,做用域又分爲靜態做用域和動態做用域兩類。git
靜態做用域又叫詞法做用域,JS就是靜態做用域,好比以下代碼:github
let x = 10; function f() { return x; } function g() { let x = 20; return f(); } console.log(g()); // 10
上述代碼中,函數f
返回的x
是外層定義的x
,也就是10
,咱們調用g
的時候,雖然g
裏面也有個變量x
,可是在這裏咱們並無用它,用的是f
裏面的x
。也就是說咱們調用一個函數時,若是這個函數的變量沒有在函數中定義,就去定義該函數的地方查找,這種查找關係在咱們代碼寫出來的時候其實就肯定了,因此叫靜態做用域。這是一段很簡單的代碼,你們都知道輸出是10
,難道還能輸出20
?還真有輸出20的,那就是動態做用域了!面試
Perl語言就採用的動態做用域,仍是上面那個代碼邏輯,換成Perl語言是這樣:編程
$x = 10; sub f { return $x; } sub g { local $x = 20; return f(); } print g();
上述代碼的輸出就是20
,你們能夠用Perl跑下看看,這就是動態做用域。所謂動態做用域就是咱們調用一個函數時,若是這個函數的變量沒有在函數中定義,就去調用該函數的地方查找。由於一個函數可能會在多個地方被調用,每次調用的時候變量的值可能都不同,因此叫動態做用域。動態做用域的變量值在運行前難以肯定,複雜度更高,因此目前主流的都是靜態做用域,好比JS,C,C++,Java這些都是靜態做用域。json
在ES6以前,咱們申明變量都是使用var
,使用var
申明的變量都是函數做用域,即在函數體內可見,這會帶來的一個問題就是申明提早。異步
var x = 1; function f() { console.log(x); var x = 2; } f();
上述代碼的輸出是undefined
,由於函數f
裏面的變量x
使用var
申明,因此他其實在整個函數f
可見,也就是說,他的聲明至關於提早到了f
的最頂部,可是賦值仍是在運行的x = 2
時進行,因此在var x = 2;
上面打印x
就是undefined
,上面的代碼其實等價於:函數
var x = 1; function f() { var x console.log(x); x = 2; } f();
看下面這個代碼:
function f() { x(); function x() { console.log(1); } } f();
上述代碼x()
調用是能夠成功的,由於函數的聲明也會提早到當前函數的最前面,也就是說,上面函數x
會提早到f
的最頂部執行,上面代碼等價於:
function f() { function x() { console.log(1); } x(); } f();
可是有一點須要注意,上面的x
函數若是換成函數表達式就不行了:
function f() { x(); var x = function() { console.log(1); } } f();
這樣寫會報錯Uncaught TypeError: x is not a function
。由於這裏的x
其實就是一個普通變量,只是它的值是一個函數,它雖然會提早到當前函數的最頂部申明,可是就像前面講的,這時候他的值是undefined
,將undefined
當成函數調用,確定就是TypeError
。
既然變量申明和函數申明都會提早,那誰的優先級更高呢?答案是函數申明的優先級更高!看以下代碼:
var x = 1; function x() {} console.log(typeof x); // number
上述代碼咱們申明瞭一個變量x
和一個函數x
,他們擁有一樣的名字。最終輸出來的typeof
是number
,說明函數申明的優先級更高,x
變量先被申明爲一個函數,而後被申明爲一個變量,由於名字同樣,後申明的覆蓋了先申明的,因此輸出是number
。
前面的申明提早不太符合人們正常的思惟習慣,對JS不太熟悉的初學者若是不瞭解這個機制,可能會常常遇到各類TypeError
,寫出來的代碼也可能隱含各類BUG。爲了解決這個問題,ES6引入了塊級做用域。塊級做用域就是指變量在指定的代碼塊裏面才能訪問,也就是一對{}
中能夠訪問,在外面沒法訪問。爲了區分以前的var
,塊級做用域使用let
和const
聲明,let
申明變量,const
申明常量。看以下代碼:
function f() { let y = 1; if(true) { var x = 2; let y = 2; } console.log(x); // 2 console.log(y); // 1 } f();
上述代碼咱們在函數體裏面用let
申明瞭一個y
,這時候他的做用域就是整個函數,而後又有了一個if
,這個if
裏面用var
申明瞭一個x
,用let
又申明瞭一個y
,由於var
是函數做用域,因此在if
外面也能夠訪問到這個x
,打印出來就是2,if
裏面的那個y
由於是let
申明的,因此他是塊級做用域,也就是隻在if
裏面生效,若是在外面打印y
,會拿到最開始那個y
,也就是1.
塊級做用域在同一個塊中是不容許重複申明的,好比:
var a = 1; let a = 2;
這個會直接報錯Uncaught SyntaxError: Identifier 'a' has already been declared
。
可是若是你都用var
申明就不會報錯:
var a = 1; var a = 2;
常常看到有文章說: 用let
和const
申明的變量不會提高。其實這種說法是不許確的,好比下面代碼:
var x = 1; if(true) { console.log(x); let x = 2; }
上述代碼會報錯Uncaught ReferenceError: Cannot access 'x' before initialization
。若是let
申明的x
沒有變量提高,那咱們在他前面console
應該拿到外層var
定義的x
纔對。可是如今卻報錯了,說明執行器在if
這個塊裏面實際上是提早知道了下面有一個let
申明的x
的,因此說變量徹底不提高是不許確的。只是提高後的行爲跟var
不同,var
是讀到一個undefined
,而塊級做用域的提高行爲是會製造一個暫時性死區(temporal dead zone, TDZ)。暫時性死區的現象就是在塊級頂部到變量正式申明這塊區域去訪問這個變量的話,直接報錯,這個是ES6規範規定的。
下面這種問題咱們也常常遇到,在一個循環中調用異步函數,指望是每次調用都拿到對應的循環變量,可是最終拿到的倒是最後的循環變量:
for(var i = 0; i < 3; i++) { setTimeout(() => { console.log(i) }) }
上述代碼咱們指望的是輸出0,1,2
,可是最終輸出的倒是三個3
,這是由於setTimeout
是異步代碼,會在下次事件循環執行,而i++
倒是同步代碼,而所有執行完,等到setTimeout
執行時,i++
已經執行完了,此時i
已是3了。之前爲了解決這個問題,咱們通常採用自執行函數:
for(var i = 0; i < 3; i++) { (function(i) { setTimeout(() => { console.log(i) }) })(i) }
如今有了let
咱們直接將var
改爲let
就能夠了:
for(let i = 0; i < 3; i++) { setTimeout(() => { console.log(i) }) }
這種寫法也適用於for...in
和for...of
循環:
let obj = { x: 1, y: 2, z: 3 } for(let k in obj){ setTimeout(() => { console.log(obj[k]) }) }
那能不能使用const
來申明循環變量呢?對於for(const i = 0; i < 3; i++)
來講,const i = 0
是沒問題的,可是i++
確定就報錯了,因此這個循環會運行一次,而後就報錯了。對於for...in
和for...of
循環,使用const
聲明是沒問題的。
let obj = { x: 1, y: 2, z: 3 } for(const k in obj){ setTimeout(() => { console.log(obj[k]) }) }
在最外層(全局做用域)使用var
申明變量,該變量會成爲全局對象的屬性,若是全局對象恰好有同名屬性,就會被覆蓋。
var JSON = 'json'; console.log(window.JSON); // JSON被覆蓋了,輸出'json'
而使用let
申明變量則沒有這個問題:
let JSON = 'json'; console.log(window.JSON); // JSON沒有被覆蓋,仍是以前那個對象
上面這麼多點其實都是let
和const
對之前的var
進行的改進,若是咱們的開發環境支持ES6,咱們就應該使用let
和const
,而不是var
。
做用域鏈實際上是一個很簡單的概念,當咱們使用一個變量時,先在當前做用域查找,若是沒找到就去他外層做用域查找,若是尚未,就再繼續往外找,一直找到全局做用域,若是最終都沒找到,就報錯。好比以下代碼:
let x = 1; function f() { function f1() { console.log(x); } f1(); } f();
這段代碼在f1
中輸出了x
,因此他會在f1
中查找這個變量,固然沒找到,而後去f
中找,仍是沒找到,再往上去全局做用域找,這下找到了。這個查找鏈條就是做用域鏈。
前面那個例子的做用域鏈上其實有三個對象:
f1做用域 -> f做用域 -> 全局做用域
大部分狀況都是這樣的,做用域鏈有多長主要看它當前嵌套的層數,可是有些語句能夠在做用域鏈的前端臨時增長一個變量對象,這個變量對象在代碼執行完後移除,這就是做用域延長了。可以致使做用域延長的語句有兩種:try...catch
的catch
塊和with
語句。
這實際上是咱們一直在用的一個特殊狀況:
let x = 1; try { x = x + y; } catch(e) { console.log(e); }
上述代碼try
裏面咱們用到了一個沒有申明的變量y
,因此會報錯,而後走到catch
,catch
會往做用域鏈最前面添加一個變量e
,這是當前的錯誤對象,咱們能夠經過這個變量來訪問到錯誤對象,這其實就至關於做用域鏈延長了。這個變量e
會在catch
塊執行完後被銷燬。
with
語句能夠操做做用域鏈,能夠手動將某個對象添加到做用域鏈最前面,查找變量時,優先去這個對象查找,with
塊執行完後,做用域鏈會恢復到正常狀態。
function f(obj, x) { with(obj) { console.log(x); // 1 } console.log(x); // 2 } f({x: 1}, 2);
上述代碼,with
裏面輸出的x
優先去obj
找,至關於手動在做用域鏈最前面添加了obj
這個對象,因此輸出的x
是1。with
外面仍是正常的做用域鏈,因此輸出的x
仍然是2。須要注意的是with
語句裏面的做用域鏈要執行時才能肯定,引擎沒辦法優化,因此嚴格模式下是禁止使用with
的。
var
變量會進行申明提早,在賦值前能夠訪問到這個變量,值是undefined
。var
高。var
的函數表達式其實就是一個var
變量,在賦值前調用至關於undefined
(),會直接報錯。let
和const
是塊級做用域,有效範圍是一對{}
。var
不同,塊級做用域裏面的「變量提高」會造成「暫時性死區」,在申明前訪問會直接報錯。let
和const
能夠很方便的解決循環中異步調用參數不對的問題。let
和const
在全局做用域申明的變量不會成爲全局對象的屬性,var
會。try...catch
的catch
塊會延長做用域鏈,往最前面添加一個錯誤對象。with
語句能夠手動往做用域鏈最前面添加一個對象,可是嚴格模式下不可用。let
和const
,不要用var
。文章的最後,感謝你花費寶貴的時間閱讀本文,若是本文給了你一點點幫助或者啓發,請不要吝嗇你的贊和GitHub小星星,你的支持是做者持續創做的動力。
做者博文GitHub項目地址: https://github.com/dennis-jiang/Front-End-Knowledges