JS作用域和變數提升看這一篇就夠了
阿新 • • 發佈:2020-05-16
作用域是JS中一個很基礎但是很重要的概念,面試中也經常出現,本文會詳細深入的講解這個概念及其他相關的概念,包括宣告提升,塊級作用域,作用域鏈及作用域鏈延長等問題。
## 什麼是作用域
第一個問題就是我們要弄清楚什麼是作用域,這不是JS獨有的概念,而是程式設計領域中通用的一個概念。我們以下面這個語句為例:
```javascript
let x = 1;
```
這一個簡單的語句其實包含了幾個基本的概念:
> 1. **變數**(variable):這裡x就是一個變數,是用來指代一個值的符號。
> 2. **值**(value):就是具體的資料,可以是數字,字串,物件等。這裡`1`就是一個值。
> 3. **變數繫結**(name binding):就是變數和值之間建立對應關係,`x = 1`就是將變數`x`和`1`聯絡起來了。
> 4. **作用域**(scope):作用域就是**變數繫結**(name binding)的有效範圍。就是說在這個作用域中,這個**變數繫結**是有效的,出了這個作用域**變數繫結**就無效了。
就整個程式設計領域而言的話,作用域又分為靜態作用域和動態作用域兩類。
### 靜態作用域
靜態作用域又叫詞法作用域,JS就是靜態作用域,比如如下程式碼:
```javascript
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語言是這樣:
```perl
$x = 10;
sub f
{
return $x;
}
sub g
{
local $x = 20;
return f();
}
print g();
```
上述程式碼的輸出就是`20`,[大家可以用Perl跑下看看](https://c.runoob.com/compile/17),這就是動態作用域。所謂動態作用域就是我們呼叫一個函式時,如果這個函式的變數沒有在函式中定義,**就去呼叫該函式的地方查詢**。因為一個函式可能會在多個地方被呼叫,每次呼叫的時候變數的值可能都不一樣,所以叫動態作用域。動態作用域的變數值在執行前難以確定,複雜度更高,所以目前主流的都是靜態作用域,比如JS,C,C++,Java這些都是靜態作用域。
## 宣告提前
### 變數宣告提前
在ES6之前,我們申明變數都是使用`var`,使用`var`申明的變數都是**函式作用域**,即在函式體內可見,這會帶來的一個問題就是申明提前。
```javascript
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`,上面的程式碼其實等價於:
```javascript
var x = 1;
function f() {
var x
console.log(x);
x = 2;
}
f();
```
### 函式宣告提前
看下面這個程式碼:
```javascript
function f() {
x();
function x() {
console.log(1);
}
}
f();
```
上述程式碼`x()`呼叫是可以成功的,因為函式的宣告也會提前到當前函式的最前面,也就是說,上面函式`x`會提前到`f`的最頂部執行,上面程式碼等價於:
```javascript
function f() {
function x() {
console.log(1);
}
x();
}
f();
```
但是有一點需要注意,上面的`x`函式如果換成函式表示式就不行了:
```javascript
function f() {
x();
var x = function() {
console.log(1);
}
}
f();
```
這樣寫會報錯`Uncaught TypeError: x is not a function`。因為這裡的`x`其實就是一個普通變數,只是它的值是一個函式,它雖然會提前到當前函式的最頂部申明,但是就像前面講的,這時候他的值是`undefined`,將`undefined`當成函式呼叫,肯定就是`TypeError`。
### 變數申明和函式申明提前的優先順序
既然變數申明和函式申明都會提前,那誰的優先順序更高呢?答案是**函式申明的優先順序更高!**看如下程式碼:
```javascript
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`申明常量。看如下程式碼:
```javascript
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.
### 不允許重複申明
塊級作用域在同一個塊中是不允許重複申明的,比如:
```javascript
var a = 1;
let a = 2;
```
這個會直接報錯`Uncaught SyntaxError: Identifier 'a' has already been declared`。
但是如果你都用`var`申明就不會報錯:
```javascript
var a = 1;
var a = 2;
```
### 不會變數提升?
經常看到有文章說: 用`let`和`const`申明的變數不會提升。其實這種說法是不準確的,比如下面程式碼:
```javascript
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規範規定的。
### 迴圈語句中的應用
下面這種問題我們也經常遇到,在一個迴圈中呼叫非同步函式,期望是每次呼叫都拿到對應的迴圈變數,但是最終拿到的卻是最後的迴圈變數:
```javascript
for(var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i)
})
}
```
上述程式碼我們期望的是輸出`0,1,2`,但是最終輸出的卻是三個`3`,這是因為`setTimeout`是非同步程式碼,會在下次事件迴圈執行,而`i++`卻是同步程式碼,而全部執行完,等到`setTimeout`執行時,`i++`已經執行完了,此時`i`已經是3了。以前為了解決這個問題,我們一般採用自執行函式:
```javascript
for(var i = 0; i < 3; i++) {
(function(i) {
setTimeout(() => {
console.log(i)
})
})(i)
}
```
現在有了`let`我們直接將`var`改成`let`就可以了:
```javascript
for(let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i)
})
}
```
這種寫法也適用於`for...in`和`for...of`迴圈:
```javascript
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`宣告是沒問題的。
```javascript
let obj = {
x: 1,
y: 2,
z: 3
}
for(const k in obj){
setTimeout(() => {
console.log(obj[k])
})
}
```
### 不影響全域性物件
在最外層(全域性作用域)使用`var`申明變數,該變數會成為全域性物件的屬性,如果全域性物件剛好有同名屬性,就會被覆蓋。
```javascript
var JSON = 'json';
console.log(window.JSON); // JSON被覆蓋了,輸出'json'
```
而使用`let`申明變數則沒有這個問題:
```javascript
let JSON = 'json';
console.log(window.JSON); // JSON沒有被覆蓋,還是之前那個物件
```
上面這麼多點其實都是`let`和`const`對以前的`var`進行的改進,如果我們的開發環境支援ES6,我們就應該使用`let`和`const`,而不是`var`。
## 作用域鏈
作用域鏈其實是一個很簡單的概念,當我們使用一個變數時,先在當前作用域查詢,如果沒找到就去他外層作用域查詢,如果還沒有,就再繼續往外找,一直找到全域性作用域,如果最終都沒找到,就報錯。比如如下程式碼:
```javascript
let x = 1;
function f() {
function f1() {
console.log(x);
}
f1();
}
f();
```
這段程式碼在`f1`中輸出了`x`,所以他會在`f1`中查詢這個變數,當然沒找到,然後去`f`中找,還是沒找到,再往上去全域性作用域找,這下找到了。這個查詢鏈條就是作用域鏈。
## 作用域鏈延長
前面那個例子的作用域鏈上其實有三個物件:
```
f1作用域 -> f作用域 -> 全域性作用域
```
大部分情況都是這樣的,作用域鏈有多長主要看它當前巢狀的層數,但是有些語句可以在作用域鏈的前端臨時增加一個變數物件,這個變數物件在程式碼執行完後移除,這就是作用域延長了。能夠導致作用域延長的語句有兩種:`try...catch`的`catch`塊和`with`語句。
### try...catch
這其實是我們一直在用的一個特殊情況:
```javascript
let x = 1;
try {
x = x + y;
} catch(e) {
console.log(e);
}
```
上述程式碼`try`裡面我們用到了一個沒有申明的變數`y`,所以會報錯,然後走到`catch`,`catch`會往作用域鏈最前面新增一個變數`e`,這是當前的錯誤物件,我們可以通過這個變數來訪問到錯誤物件,這其實就相當於作用域鏈延長了。這個變數`e`會在`catch`塊執行完後被銷燬。
### with
`with`語句可以操作作用域鏈,可以手動將某個物件新增到作用域鏈最前面,查詢變數時,優先去這個物件查詢,`with`塊執行完後,作用域鏈會恢復到正常狀態。
```javascript
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. `let`和`const`是塊級作用域,有效範圍是一對`{}`。
7. 同一個塊級作用域裡面不能重複申明,會報錯。
8. 塊級作用域也有“變數提升”,但是行為跟`var`不一樣,塊級作用域裡面的“變數提升”會形成“暫時性死區”,在申明前訪問會直接報錯。
9. 使用`let`和`const`可以很方便的解決迴圈中非同步呼叫引數不對的問題。
10. `let`和`const`在全域性作用域申明的變數不會成為全域性物件的屬性,`var`會。
11. 訪問變數時,如果當前作用域沒有,會一級一級往上找,一直到全域性作用域,這就是作用域鏈。
12. `try...catch`的`catch`塊會延長作用域鏈,往最前面新增一個錯誤物件。
13. `with`語句可以手動往作用域鏈最前面新增一個物件,但是嚴格模式下不可用。
14. 如果開發環境支援ES6,就應該使用`let`和`const`,不要用`var`。
**文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。**
**作者博文GitHub專案地址: [https://github.com/dennis-jiang/Front-End-Knowledges](https://github.com/dennis-jiang/Front-End-Knowledges)**
**作者掘金文章彙總:[https://juejin.im/post/5e3ffc85518825494e2772fd](https://juejin.im/post/5e3ffc85518825494e2