深入理解 Javascript 之 作用域

meils發表於2019-02-24

深入理解 Javascript 之 作用域

所有的程式語言都有一個功能,那就是儲存變數中的值。並且在這個變數宣告之後可以進行訪問和修改,而這個變數存在哪裡?如何進行訪問?這就需要用到作用域了。 作用域就是變數與函式的可訪問範圍,即作用域控制著變數與函式的可見性和生命週期。在JavaScript中,變數的作用域有全域性作用域和區域性作用域兩種,區域性作用域又稱為函式作用域。

一、認識作用域

全域性作用域

  • 情況一: 程式最外層定義的函式或者變數
var a = "tsrot";
function hello(){
	alert(a);
}
function sayHello(){
	hello();
}
alert(a);     //能訪問到tsrot
hello();      //能訪問到tsrot
sayHello();   //能訪問到hello函式,然後也能訪問到tsrot

複製程式碼
  • 情況二: 所有末定義直接賦值的變數(不推薦)
function hello(){
	a = "tsrot";
	var b = "hello tsrot";
}
alert(a);  //能訪問到tsrot
alert(b);  //error 不能訪問
複製程式碼
  • 情況三: window物件的屬性和方法

一般情況下,window物件的內建屬性都擁有全域性作用域,例如window.name、window.location、window.top等等。

區域性作用域(函式作用域)

// 區域性作用域在函式內建立,在函式內可訪問,函式外不可訪問。

function hello(){
	var a = "tsrot";
	alert(a);
}
hello(); //函式內可訪問到tsrot
alert(a); //error not defined

複製程式碼

二、作用域鏈

瞭解作用域鏈之前我們要知道一下幾個概念:

- 變數和函式的宣告
- 函式的生命週期
- Activetion Object(AO)、Variable Object(VO)
複製程式碼

(1) 變數的宣告

在js引擎處理程式碼的時候,首先要把變數和函式的宣告提前進行預解析。然後再去執行別的程式碼。至於具體如何進行預處理的,我們之後進行深入學習。

變數宣告:變數的宣告只有一種方式,那就是用var關鍵字宣告,直接賦值不是一種宣告方式。這僅僅是在全域性物件上建立了新的屬性(而不是變數)。它們有一下區別:

  • (1)因為它只是一種賦值,所以不會宣告提前
alert(a); // undefined
alert(b); // error "b" is not defined
b = 10; // 這裡不會提前
var a = 20;
複製程式碼
  • (2)直接賦值形式是在執行階段建立
alert(a); // undefined, 這個大家都知道
b = 10;
alert(b); // 10, 程式碼執行階段建立
 
var a = 20;
alert(a); // 20, 程式碼執行階段修改
複製程式碼
  • (3)變數不能刪除(delete),屬性可以刪除
a = 10;
alert(window.a); // 10
 
alert(delete a); // true
 
alert(window.a); // undefined
 
var b = 20;
alert(window.b); // 20
 
alert(delete b); // false
 
alert(window.b); // 仍然為 20,因為變數是不能夠刪除的。

複製程式碼

(2) 函式宣告:函式的宣告有三種方式

  • (1)function name( ){ }直接建立方式
function add(a,b){
	return a+b;
}
add(5,4);
複製程式碼
  • (2)new Funtion構建函式建立
var add=new Function("a", "b", "return a+b;");
add(4,5);
複製程式碼
  • (3)給變數賦值匿名函式方法建立
var add = function(a,b){
	return a+b;
}
add(4,5);
複製程式碼

後面兩種方法,在宣告前訪問時,返回的都是一個undefined的變數。當然,在宣告後訪問它們都是一個function的函式。

注意:如果變數名和函式名宣告時相同,函式優先宣告。

alert(x); // function
var x = 10;
alert(x); // 10
 
x = 20;
function x() {};
 
alert(x); // 20
複製程式碼

(3) 函式的生命週期

  • 在函式建立階段,JS解析引擎進行預解析,會將函式宣告提前,同時將該函式放到全域性作用域中或當前函式的上一級函式的區域性作用域中。

  • 在函式執行階段,JS引擎會將當前函式的區域性變數和內部函式進行宣告提前,然後再執行業務程式碼,當函式執行完退出時,釋放該函式的執行上下文,並登出該函式的區域性變數。

(4) 什麼是AO、VO

為了表示不同的執行環境,JavaScript中有一個執行上下文(Execution context,EC)的概念。也就是說,當JavaScript程式碼執行的時候,會進入不同的執行上下文,這些執行上下文就構成了一個執行上下文棧(Execution context stack,ECS)。

var a = "global var";

function foo(){
    console.log(a);
}

function outerFunc(){
    var b = "var in outerFunc";
    console.log(b);
    
    function innerFunc(){
        var c = "var in innerFunc";
        console.log(c);
        foo();
    }
    
    innerFunc();
}


outerFunc()
複製程式碼

程式碼首先進入Global Execution Context,然後依次進入outerFunc,innerFunc和foo的執行上下文,執行上下文棧就可以表示為:

深入理解 Javascript 之 作用域

對於每個Execution Context都有三個重要的屬性,變數物件(Variable object,VO)作用域鏈(Scope chain)this。這三個屬性跟程式碼執行的行為有很重要的關係,下面會一一介紹。

變數物件(Variable object)

變數物件是與執行上下文相關的資料作用域。它是一個與上下文相關的特殊物件,其中儲存了在上下文中定義的變數和函式宣告。也就是說,一般VO中會包含以下資訊:

變數 (var, Variable Declaration);
函式宣告 (Function Declaration, FD);
函式的形參
複製程式碼
VO = {
    a: 'global var',
    foo: <function>
    outerFunc: <function>
}
複製程式碼

注意,假如上面的例子程式碼中有下面兩個語句,Global VO仍將不變

(function bar(){}) // function expression, FE
baz = "property of global object"
複製程式碼

也就是說,對於VO,是有下面兩種特殊情況的:

函式表示式(與函式宣告相對)不包含在VO之中
沒有使用var宣告的變數(這種變數是,"全域性"的宣告方式,只是給Global新增了一個屬性,並不在VO中)
複製程式碼

活動物件(Activation object)

只有全域性上下文的變數物件允許通過VO的屬性名稱間接訪問;在函式執行上下文中,VO是不能直接訪問的,此時由啟用物件(Activation Object,縮寫為AO)扮演VO的角色。啟用物件 是在進入函式上下文時刻被建立的,它通過函式的arguments屬性初始化。

對於VO和AO的關係可以理解為,VO在不同的Execution Context中會有不同的表現:當在Global Execution Context中,可以直接使用VO;但是,在函式Execution Context中,AO就會被建立。

深入理解 Javascript 之 作用域

當上面的例子開始執行outerFunc的時候,就會有一個outerFunc的AO被建立:

深入理解 Javascript 之 作用域

AO = {
    arguments: {},
    b : 'var in outerFunc',
    innerFunc: <function>
}
複製程式碼

(5) 細看Execution Context

當一段JavaScript程式碼執行的時候,JavaScript直譯器會建立Execution Context,其實這裡會有兩個階段:

  • 建立階段(當函式被呼叫,但是開始執行函式內部程式碼之前)
    • 建立Scope chain
    • 建立VO/AO(variables, functions and arguments)
    • 設定this的值
  • 啟用/程式碼執行階段
    • 設定變數的值、函式的引用,然後解釋/執行程式碼
function foo(i) {
    var a = 'hello';
    var b = function privateB() {

    };
    function c() {

    }
}

foo(22);
複製程式碼

對於上面的程式碼,在"建立階段",可以得到下面的Execution Context object:

fooExecutionContext = {
    scopeChain: { ... }, // 作用域鏈
    variableObject: {
        arguments: {
            0: 22,
            length: 1
        },
        i: 22,
        c: pointer to function c()
        a: undefined,
        b: undefined
    },
    this: { ... } // this指向
}
複製程式碼

在"啟用/程式碼執行階段",Execution Context object就被更新為:

fooExecutionContext = {
    scopeChain: { ... },
    variableObject: {
        arguments: {
            0: 22,
            length: 1
        },
        i: 22,
        c: pointer to function c()
        a: 'hello',
        b: pointer to function privateB()
    },
    this: { ... }
}
複製程式碼

案例分析:

案例一

(function(){
    console.log(bar);
    console.log(baz);
    
    var bar = 20;
    
    function baz(){
        console.log("baz");
    }
    
})()
複製程式碼

因為是匿名函式且處於全域性,函式預處理階段只是將函式的作用域提到了全域性,在函式的執行階段,首先進行變數、函式的提升,此時在執行上下文中的AO物件如下,然後再執行業務程式碼。

深入理解 Javascript 之 作用域

最後執行結果如下:

深入理解 Javascript 之 作用域

案例2

(function(){
    console.log(bar); // VM60:2 Uncaught ReferenceError: bar is not defined
    console.log(baz); // function baz
    
    bar = 20;
    console.log(window.bar);  // 20
    console.log(bar);   // 20
    
    function baz(){
        console.log("baz");
    }
    
})()
複製程式碼

執行這段程式碼會得到"bar is not defined(…)"錯誤。當程式碼執行到"console.log(bar);"的時候,會去AO中查詢"bar"。但是,根據前面的解釋,函式中的"bar"並沒有通過var關鍵字宣告,所有不會被存放在AO中,也就有了這個錯誤。

深入理解 Javascript 之 作用域

案例3

(function(){
    console.log(foo); // undefined
    console.log(bar); // function
    console.log(baz); // function 
    
    var foo = function(){};
    
    function bar(){
        console.log("bar");
    }
    
    var bar = 20;  
    console.log(bar);  // 20
    
    function baz(){
        console.log("baz");
    }
    
})()


AO = {
    arguments: {},
    foo: undefined,
    bar: <function>,
    baz: <function>
}
複製程式碼

程式碼中,最"奇怪"的地方應該就是"bar"的輸出了,第一次是一個函式,第二次是"20"。

其實也很好解釋,回到前面對"建立VO/AO"的介紹,在建立VO/AO過程中,直譯器會先掃描函式宣告,然後"foo: "就被儲存在了AO中;但直譯器掃描變數宣告的時候,雖然發現"var bar = 20;",但是因為"foo"在AO中已經存在,所以就沒有任何操作了。

但是,當程式碼執行到第二句"console.log(bar);"的時候,"啟用/程式碼執行階段"已經把AO中的"bar"重新設定了

深入理解 Javascript 之 作用域

(5) JavaScript作用域鏈

當程式碼在一個環境中執行時,會建立變數物件的一個作用域鏈(scope chain)來保證對執行環境有權訪問的變數和函式的有序訪問。作用域第一個物件始終是當前執行程式碼所在環境的變數物件(VO)。

function add(a,b){
	var sum = a + b;
	return sum;
}
複製程式碼

假設函式是在全域性作用域中建立的,在函式a建立的時候,它的作用域鏈填入全域性物件,全域性物件中有所有全域性變數,此時的全域性變數就是VO。此時的作用域鏈就是:

	scope(add) -> Global Object(VO)
	
	VO = {
		this : window,
		add : <reference to function>
	}
複製程式碼

如果是函式執行階段,那麼將其activation object(AO)作為作用域鏈第一個物件,第二個物件是上級函式的執行上下文AO,下一個物件依次類推。

add(4,5);
複製程式碼

例如,呼叫add後的作用域鏈是:


此時作用域鏈(Scope Chain)有兩級,第一級為AO,然後Global Object(VO)
	scope(add) -> AO -> VO
	AO = {
		this : window,
		arguments : [4,5],
		a : 4,
		b : 5,
		sum : undefined
	}
	
	VO = {
		this : window,
		add : <reference to function>
	}
複製程式碼

在函式執行過程中識別符號的解析是沿著作用域鏈一級一級搜尋的過程,從第一個物件開始,逐級向後回溯,直到找到同名識別符號為止,找到後不再繼續遍歷,找不到就報錯。

看過上面的內容後,可能還有人不懂,我再通熟易懂的解釋一遍,先舉個例子:

var x = 10;
 
function foo() {
    var y = 20;
 
    function bar() {
        var z = 30;
 
        console.log(x + y + z);
    };
 
    bar()
};
 
foo();

複製程式碼

上面程式碼的輸出結果為”60″,函式bar可以直接訪問”z”,然後通過作用域鏈訪問上層的”x”和”y”。此時的作用域鏈為:

此時作用域鏈(Scope Chain)有三級,第一級為bar AO,第二級為foo AO,然後Global Object(VO)
	scope -> bar.AO -> foo.AO -> Global Object
	bar.AO = {
		z : 30,
		__parent__ : foo.AO
	}
	foo.AO = {
		y : 20,
		bar : <reference to function>,
		__parent__ : <Global Object>
	}
	
	Global Object = {
		x : 10,
		foo : <reference to function>,
		__parent__ : null
	}
複製程式碼

三、刨析整個解析過程

深入理解 Javascript 之 作用域

四、面試題

第一題:

console.log(a());// 2
var a = function b(){
    console.log(1);
}
console.log(a());// 1
function a(){
    console.log(2);
}
console.log(a());// 1
console.log(b());// reference error
複製程式碼

複製程式碼程式碼編譯後,變數提升,函式優先,賦值語句中b為右值,非變數宣告,所以程式碼等價於

第三題:

"use strict";
function test() {
    console.log(a);// undefined
    console.log(b);// reference error
    console.log(c);// reference error
    var a = b =1;// 直接丟擲語法錯誤
    let c = 1;
}
test();
console.log(b);// reference error
console.log(a);// reference
error

複製程式碼

複製程式碼進入嚴格模式後,b=1這種語法會直接出錯,不會變成全域性變數

第四題:4.1題

for(var i=0;i<5;i++){
  setTimeout(function(){console.log(i)},0); // 5 5 5 5 5 
}

複製程式碼

複製程式碼i 依附函式作用域,執行過程只有一個i,而setTimeout是非同步函式,需要等棧中的程式碼執行完後再執行,此時i已經變為5

4.2題

for(let i=0;i<5;i++){
  setTimeout(function(){console.log(i)},0); // 1 2  3  4
}

複製程式碼

複製程式碼let 依附for的塊級作用域,程式碼等價於

for(let i=0;i<5;i++){
  let j = i;
  setTimeout(function(){console.log(j)},0); // 1 2  3  4
}

複製程式碼

複製程式碼可以看出每次迴圈都產生一個新的記憶體單元,非同步函式執行時,取到的值為當時保持的快照值。

相關文章