深入理解 JavaScript, 從作用域與作用域鏈開始

1. 什麼是作用域

作用域是你的代碼在運行時,某些特定部分中的變量,函數和對象的可訪問性。換句話說,作用域決定了變量與函數的可訪問範圍,即作用域控制着變量與函數的可見性和生命週期

2. JavaScript中的作用域

在 JavaScript 中有兩種作用域

  • 全局作用域
  • 局部作用域

如果一個變量在函數外面或者大括號{}外聲明,那麼就定義了一個全局作用域,在ES6之前局部作用域只包含了函數作用域,ES6爲我們提供的塊級作用域,也屬於局部作用域

2.1 全局作用域

擁有全局作用域的對象可以在代碼的任何地方訪問到, 在js中一般有以下幾種情形擁有全局作用域:

  1. 最外層的函數以及最外層變量:
var globleVariable= 'global';  // 最外層變量
function globalFunc(){         // 最外層函數
    var childVariable = 'global_child';  //函數內變量
    function childFunc(){        // 內層函數
        console.log(childVariable);
    }
    console.log(globleVariable)
}
console.log(globleVariable);  // global
globalFunc();                 // global
console.log(childVariable)   // childVariable is not defined
console.log(childFunc)       // childFunc is not defined

從上面代碼中可以看到globleVariableglobalFunc在任何地方都可以訪問到, 反之不具有全局作用域特性的變量只能在其作用域內使用。

  1. 未定義直接賦值的變量(由於變量提升使之成爲全局變量)
function func1(){
    special = 'special_variable';
    var normal = 'normal_variable';
}
func1();
console.log(special);    //special_variable
console.log(normal)     // normal is not defined

雖然我們可以在全局作用域中聲明函數以及變量, 使之成爲全局變量, 但是不建議這麼做,因爲這可能會和其他的變量名衝突,一方面如果我們再使用const或者let聲明變量, 當命名發生衝突時會報錯。

// 變量衝突
var globleVariable = "person";
let globleVariable = "animal"; // Error, thing has already been declared

另一方面如果你使用var申明變量,第二個申明的同樣的變量將覆蓋前面的,這樣會使你的代碼很難調試。

var name = 'koala'
var name = 'xiaoxiao'
console.log(name);  // xiaoxiao

2.2 局部作用域

和全局作用於相反,局部作用域一般只能在固定代碼片段內可以訪問到。最常見的就是函數作用域

2.2.1 函數作用域

定義在函數中的變量就在函數作用域中。並且函數在每次調用時都有一個不同的作用域。這意味着同名變量可以用在不同的函數中。因爲這些變量綁定在不同的函數中,擁有不同作用域,彼此之間不能訪問。
//全局作用域
function test(){
    var num = 9;
    // 內部可以訪問
    console.log("test中:"+num);
}
//test外部不能訪問
console.log("test外部:"+num);

注意點:

  • 如果在函數中定義變量時,如果不添加var關鍵字,造成變量提升,這個變量成爲一個全局變量。
function doSomeThing(){
    // 在工作中一定避免這樣寫
    thing = 'writting';
    console.log('內部:'+thing);
}
console.log('外部:'+thing)
  • 任何一對花括號{...}中的語句集都屬於一個塊, 在es6之前,在塊語句中定義的變量將保留在它已經存在的作用域中:
var name = '程序員成長指北';
for(var i=0; i<5; i++){
    console.log(i)
}
console.log('{}外部:'+i);
// 0 1 2 3 4  {}外部:5

我們可以看到變量name和變量i是同級作用域。

2.2.2 在ES6塊級作用域未講解之前注意點

變量提升

變量提升英文名字hoisting,MDN中對它的解釋是變量申明是在任意代碼執行前處理的,在代碼區中任意地方申明變量和在最開始(最上面)的地方申明是一樣的。也就是說,看起來一個變量可以在申明之前被使用!這種行爲就是所謂的“hoisting”,也就是變量提升,看起來就像變量的申明被自動移動到了函數或全局代碼的最頂上。
看一段代碼:

var tmp = new Date();
function f() {
    console.log(tmp);
    if(false) {
        var tmp='hello';
    }
}

這道題應該很多小夥伴在面試中遇到過,有人會認爲輸出的是當前日期。但是正確的結果是undefined。這就是由於變量提升造成的,在這裏申明提升了,定義的內容並不會提升,提升後對應的代碼如下:

var tmp = new Date();
function f() {
    var tmp;
    console.log(tmp);
    if(false) {
        tmp='hello';
    }
}
f();

console在輸出的時候,tmp變量僅僅申明瞭但未定義。所以輸出undefined。雖然能夠輸出,但是並不推薦這種寫法推薦的做法是在申明變量的時候,將所用的變量都寫在作用域(全局作用域或函數作用域)的最頂上,這樣代碼看起來就會更清晰,更容易看出來哪個變量是來自函數作用域的,哪個又是來自作用域鏈

重複聲明

看一個例子:

// var
var name = 'koloa';
console.log(name); // koala
if(true){
    var name = '程序員成長指北';
    console.log(name); // 程序員成長指北
}
console.log(name); // 程序員成長指北

雖然看起來裏面name申明瞭兩次,但上面說了,js的var變量只有全局作用域和函數作用域兩種,且申明會被提升,因此實際上name只會在最頂上開始的地方申明一次,var name='程序員成長指北'的申明會被忽略,僅用於賦值。也就是說上面的代碼實際上跟下面是一致的。

// var
var name = 'koloa';
    console.log(name); // koala
if(true){
    name = '程序員成長指北';
    console.log(name); // 程序員成長指北
}
console.log(name); // 程序員成長指北
變量和函數同時出現的提升

如果有函數和變量同時聲明瞭,會出現什麼情況呢?看下面但代碼

console.log(foo);
var foo ='i am koala';
function foo(){}

輸出結果是function foo(){},也就是函數內容

如果是另外一種形式呢?

console.log(foo);
var foo ='i am koala';
var foo=function (){}

輸出結果是undefined

對兩種結果進行分析說明:

第一種:函數申明。就是上面第一種,function foo(){}這種形式

另一種:函數表達式。就是上面第二種,var foo=function(){}這種形式

第二種形式其實就是var變量的聲明定義,因此上面的第二種輸出結果爲undefined應該就能理解了。

而第一種函數申明的形式,在提升的時候,會被整個提升上去,包括函數定義的部分!因此第一種形式跟下面的這種方式是等價的!

var foo=function (){}
console.log(foo);
var foo ='i am koala';

原因是:

  1. 函數聲明被提升到最頂上;
  2. 申明只進行一次,因此後面var foo='i am koala'的申明會被忽略。
  3. 函數申明的優先級優於變量申明,且函數聲明會連帶定義一起被提升(這裏與變量不同)

接下來講,在ES6中引入的塊級作用域之後的事!

2.2.2 塊級作用域

ES6新增了letconst命令,可以用來創建塊級作用域變量,使用let命令聲明的變量只在let命令所在代碼塊內有效。

let 聲明的語法與 var 的語法一致。你基本上可以用 let 來代替 var 進行變量聲明,但會將變量的作用域限制在當前代碼塊中。塊級作用域有以下幾個特點:

  • 變量不會提升到代碼塊頂部且不允許從外部訪問塊級作用域內部變量
console.log(bar);//拋出`ReferenceErro`異常: 某變量 `is not defined`
let bar=2;
for (let i =0; i<10;i++){
    console.log(i)
}
console.log(i);//拋出`ReferenceErro`異常: 某變量 `is not defined`

其實這個特點帶來了許多好處,開發者需要檢查代碼時候,可以避免在作用域外意外但使用某些變量,而且保證了變量不會被混亂但複用,提升代碼的可維護性。就像代碼中的例子,一個只在for循環內部使用的變量i不會再去污染整個作用域。

  • 不允許反覆聲明

ES6的letconst不允許反覆聲明,與var不同

// var
function test(){
    var name = 'koloa';
    var name = '程序員成長指北';
    console.log(name); // 程序員成長指北
}

// let || const
function test2(){
    var name ='koloa';
    let name= '程序員成長指北'; 
    // Uncaught SyntaxError: Identifier 'count' has already been declared
}

看到這裏是不是感覺到了塊級作用域的出現還是很有必要的。

3. 作用域鏈

在講解作用域鏈之前先說一下,先了解一下 JavaScript是如何執行的?

3.1 JavaScript是如何執行的?


JavaScript代碼執行分爲兩個階段:

3.1.1 分析階段

javascript編譯器編譯完成,生成代碼後進行分析

  • 分析函數參數
  • 分析變量聲明
  • 分析函數聲明

分析階段的核心,在分析完成後(也就是接下來函數執行階段的瞬間)會創建一個AO(Active Object 活動對象)

3.1.2 執行階段

分析階段分析成功後,會把給AO(Active Object 活動對象)給執行階段

  • 引擎詢問作用域,作用域中是否有這個叫X的變量
  • 如果作用域有X變量,引擎會使用這個變量
  • 如果作用域中沒有,引擎會繼續尋找(向上層作用域),如果到了最後都沒有找到這個變量,引擎會拋出錯誤。

執行階段的核心就是,具體怎麼,後面會講解LHS查詢RHS查詢

3.1.3 JavaScript執行舉例說明

看一段代碼:

function a(age) {
    console.log(age);
    var age = 20
    console.log(age);
    function age() {
    }
    console.log(age);
}
a(18);
首先進入分析階段

前面已經提到了,函數運行的瞬間,創建一個AO (Active Object 活動對象)

AO = {}

第一步:分析函數參數:

形式參數:AO.age = undefined
實參:AO.age = 18

第二步,分析變量聲明:

// 第3行代碼有var age
// 但此前第一步中已有AO.age = 18, 有同名屬性,不做任何事
即AO.age = 18

第三步,分析函數聲明:

// 第5行代碼有函數age
// 則將function age(){}付給AO.age
AO.age = function age() {}

函數聲明注意點:AO上如果有與函數名同名的屬性,則會被此函數覆蓋。但是一下面這種情況

var age = function () {
            console.log('25');
        }

聲明的函數並不會覆蓋AO鏈中同名的屬性

進入執行階段

分析階段分析成功後,會把給AO(Active Object 活動對象)給執行階段,引擎會詢問作用域,的過程。所以上面那段代碼AO鏈中最初應該是

AO.age = function age() {}
//之後
AO.age=20
//之後
AO.age=20

所以最後的輸出結果是:

function age(){
    
}
20
20

3.2 作用域鏈概念

看了前面一個完整的javascript函數執行過程,讓我們來說下作用域鏈的概念吧。JavaScript上每一個函數執行時,會先在自己創建的AO上找對應屬性值。若找不到則往父函數的AO上找,再找不到則再上一層的AO,直到找到大boss:window(全局作用域)。 而這一條形成的“AO鏈” 就是JavaScript中的作用域鏈。

3.3 過程LHS和RHS查詢特殊說明

LHS,RHS 這兩個術語就是出現在引擎對變量進行查詢的時候。在《你不知道的Javascript(上)》也有很清楚的描述。在這裏,我想引用freecodecamp 上面的回答來解釋:

LHS = 變量賦值或寫入內存。想象爲將文本文件保存到硬盤中。 RHS = 變量查找或從內存中讀取。想象爲從硬盤打開文本文件。 Learning Javascript, LHS RHS

3.3.1 LHS和RHS特性

  • 都會在所有作用域中查詢
  • 嚴格模式下,找不到所需的變量時,引擎都會拋出ReferenceError異常。
  • 非嚴格模式下,LHR稍微比較特殊: 會自動創建一個全局變量
  • 查詢成功時,如果對變量的值進行不合理的操作,比如:對一個非函數類型的值進行函數調用,引擎會拋出TypeError異常

3.3.2 LHS和RHS舉例說明

例子來自於《你不知道的Javascript(上)》

function foo(a) {
    var b = a;
    return a + b;
}
var c = foo( 2 );

直接看引擎在作用域這個過程:
LSH(寫入內存):

c=, a=2(隱式變量分配), b=

RHS(讀取內存)

讀foo(2), = a, a ,b
(return a + b 時需要查找a和b)

3.4 作用域鏈總結

最後對作用域鏈做一個總結,引用《你不知道的Javascript(上)》中的一張圖解釋

加入我們一起學習吧!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章