JS做用域和變量提高看這一篇就夠了

做用域是JS中一個很基礎可是很重要的概念,面試中也常常出現,本文會詳細深刻的講解這個概念及其餘相關的概念,包括聲明提高,塊級做用域,做用域鏈及做用域鏈延長等問題。javascript

什麼是做用域

第一個問題就是咱們要弄清楚什麼是做用域,這不是JS獨有的概念,而是編程領域中通用的一個概念。咱們如下面這個語句爲例:前端

let x = 1;

這一個簡單的語句其實包含了幾個基本的概念:java

  1. 變量(variable):這裏x就是一個變量,是用來指代一個值的符號。
  2. (value):就是具體的數據,能夠是數字,字符串,對象等。這裏1就是一個值。
  3. 變量綁定(name binding):就是變量和值之間創建對應關係,x = 1就是將變量x1聯繫起來了。
  4. 做用域(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,他們擁有一樣的名字。最終輸出來的typeofnumber,說明函數申明的優先級更高,x變量先被申明爲一個函數,而後被申明爲一個變量,由於名字同樣,後申明的覆蓋了先申明的,因此輸出是number

塊級做用域

前面的申明提早不太符合人們正常的思惟習慣,對JS不太熟悉的初學者若是不瞭解這個機制,可能會常常遇到各類TypeError,寫出來的代碼也可能隱含各類BUG。爲了解決這個問題,ES6引入了塊級做用域。塊級做用域就是指變量在指定的代碼塊裏面才能訪問,也就是一對{}中能夠訪問,在外面沒法訪問。爲了區分以前的var,塊級做用域使用letconst聲明,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;

不會變量提高?

常常看到有文章說: 用letconst申明的變量不會提高。其實這種說法是不許確的,好比下面代碼:

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...infor...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...infor...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沒有被覆蓋,仍是以前那個對象

上面這麼多點其實都是letconst對之前的var進行的改進,若是咱們的開發環境支持ES6,咱們就應該使用letconst,而不是var

做用域鏈

做用域鏈實際上是一個很簡單的概念,當咱們使用一個變量時,先在當前做用域查找,若是沒找到就去他外層做用域查找,若是尚未,就再繼續往外找,一直找到全局做用域,若是最終都沒找到,就報錯。好比以下代碼:

let x = 1;

function f() {
  function f1() {
    console.log(x);
  }
  
  f1();
}

f();

這段代碼在f1中輸出了x,因此他會在f1中查找這個變量,固然沒找到,而後去f中找,仍是沒找到,再往上去全局做用域找,這下找到了。這個查找鏈條就是做用域鏈。

做用域鏈延長

前面那個例子的做用域鏈上其實有三個對象:

f1做用域 -> f做用域 -> 全局做用域

大部分狀況都是這樣的,做用域鏈有多長主要看它當前嵌套的層數,可是有些語句能夠在做用域鏈的前端臨時增長一個變量對象,這個變量對象在代碼執行完後移除,這就是做用域延長了。可以致使做用域延長的語句有兩種:try...catchcatch塊和with語句。

try...catch

這實際上是咱們一直在用的一個特殊狀況:

let x = 1;
try {
  x = x + y;
} catch(e) {
  console.log(e);
}

上述代碼try裏面咱們用到了一個沒有申明的變量y,因此會報錯,而後走到catchcatch會往做用域鏈最前面添加一個變量e,這是當前的錯誤對象,咱們能夠經過這個變量來訪問到錯誤對象,這其實就至關於做用域鏈延長了。這個變量e會在catch塊執行完後被銷燬。

with

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的。

總結

  1. 做用域其實就是一個變量綁定的有效範圍。
  2. JS使用的是靜態做用域,即一個函數使用的變量若是沒在本身裏面,會去定義的地方查找,而不是去調用的地方查找。去調用的地方找到的是動態做用域。
  3. var變量會進行申明提早,在賦值前能夠訪問到這個變量,值是undefined
  4. 函數申明也會被提早,並且優先級比var高。
  5. 使用var的函數表達式其實就是一個var變量,在賦值前調用至關於undefined(),會直接報錯。
  6. letconst是塊級做用域,有效範圍是一對{}
  7. 同一個塊級做用域裏面不能重複申明,會報錯。
  8. 塊級做用域也有「變量提高」,可是行爲跟var不同,塊級做用域裏面的「變量提高」會造成「暫時性死區」,在申明前訪問會直接報錯。
  9. 使用letconst能夠很方便的解決循環中異步調用參數不對的問題。
  10. letconst在全局做用域申明的變量不會成爲全局對象的屬性,var會。
  11. 訪問變量時,若是當前做用域沒有,會一級一級往上找,一直到全局做用域,這就是做用域鏈。
  12. try...catchcatch塊會延長做用域鏈,往最前面添加一個錯誤對象。
  13. with語句能夠手動往做用域鏈最前面添加一個對象,可是嚴格模式下不可用。
  14. 若是開發環境支持ES6,就應該使用letconst,不要用var

文章的最後,感謝你花費寶貴的時間閱讀本文,若是本文給了你一點點幫助或者啓發,請不要吝嗇你的贊和GitHub小星星,你的支持是做者持續創做的動力。

做者博文GitHub項目地址: https://github.com/dennis-jiang/Front-End-Knowledges

做者掘金文章彙總:https://juejin.im/post/5e3ffc85518825494e2772fd

相關文章
相關標籤/搜索