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


作用域是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小星星,你的支持是作者持續創作的動力。

歡迎關注我的公眾號進擊的大前端第一時間獲取高質量原創~

“前端進階知識”系列文章:https://juejin.im/post/5e3ffc85518825494e2772fd

“前端進階知識”系列文章源碼GitHub地址: https://github.com/dennis-jiang/Front-End-Knowledges

QR1270


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM