JS作用域和變數提升看這一篇就夠了

_蔣鵬飛發表於2020-05-16

作用域是JS中一個很基礎但是很重要的概念,面試中也經常出現,本文會詳細深入的講解這個概念及其他相關的概念,包括宣告提升,塊級作用域,作用域鏈及作用域鏈延長等問題。

什麼是作用域

第一個問題就是我們要弄清楚什麼是作用域,這不是JS獨有的概念,而是程式設計領域中通用的一個概念。我們以下面這個語句為例:

let x = 1;

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

  1. 變數(variable):這裡x就是一個變數,是用來指代一個值的符號。
  2. (value):就是具體的資料,可以是數字,字串,物件等。這裡1就是一個值。
  3. 變數繫結(name binding):就是變數和值之間建立對應關係,x = 1就是將變數x1聯絡起來了。
  4. 作用域(scope):作用域就是變數繫結(name binding)的有效範圍。就是說在這個作用域中,這個變數繫結是有效的,出了這個作用域變數繫結就無效了。

就整個程式設計領域而言的話,作用域又分為靜態作用域和動態作用域兩類。

靜態作用域

靜態作用域又叫詞法作用域,JS就是靜態作用域,比如如下程式碼:

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這些都是靜態作用域。

宣告提前

變數宣告提前

在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

相關文章