深入學習作用域和閉包—全面(JS系列之二)

JefferyXZF發表於2020-03-14

作用域

在學習作用域之前,先了解兩個重要的概念:編譯器、引擎

編譯器:負責詞法分析及程式碼生成等編譯過程

引擎:負責整個 JavaScript 程式的編譯和執行

什麼是作用域

通俗的來講就是變數起作用的範圍。比較規範的解釋(引用《你不知道的 JavaScript 》上卷),負責收集並維護由所有宣告的識別符號(變數)組成的一系列查詢,並實施一套非常嚴格的規則,確定當前執行程式碼對這些識別符號的訪問許可權。

ES6之前,JavaScript只有全域性作用域函式作用域,與其他型別語言不同的是它沒有塊級作用域。

if(true){
	var a = 1;//全域性作用域
}
console.log(a); // 1

function foo(){
  var b = 1;//函式作用域
	console.log(a); //1
}
console.log(b); // ReferenceError 
複製程式碼

在上面的程式碼中,a 屬於全域性作用域,if 後的花括號並沒有形成塊級作用域,而 b 屬於 foo 函式的作用域,在JavaScript中函式外部作用域訪問不到函式內部作用域,所以在全域性作用域中訪問foo函式作用域變數b會報錯。

es6之後,JavaScript 擁有了塊級作用域

if (true) {
	let a = 1
}
console.log(a)   // ReferenceError 
複製程式碼

ifforwhiletry...catch 等在大括號中使用letconst 宣告的變數會形成塊級作用域,如果在外部訪問會報錯。

作用域如何工作

變數提升

剛開始接觸 JavaScript 的同學可能會對變數先宣告後使用的現象十分不解,要理解它我們得了解JavaScript編譯的兩個原則:①編譯時宣告 ②執行時賦值

var a = 2;

//相當於↓
var a; //編譯時
a = 2; //執行時

複製程式碼

上面這段程式碼 var a = 2只做一件事,對a進行賦值 ,不過瀏覽器引擎不這麼看, 它會被分為 var aa = 2 兩步進行,一個在編譯器編譯時宣告變數,另一個在引擎執行時賦值。

編譯器首先將上面這段程式分解為詞法單元,然後將詞法單元解析成一個樹結構(AST抽象語法樹)。在開始程式碼生成時,編譯器遇到var a,編譯器詢問作用域是否已經宣告瞭這個變數;如果是,編譯器忽略該宣告,否則在當前作用域集合宣告一個新的變數,命名為a

引擎執行a = 2首先詢問作用域,在當前的作用域集合中是否存在一個叫做a的變數。如果是,引擎就會使用這個變數,否則引擎會繼續延著作用域鏈查詢該變數。如果引擎最終找到了a變數,就會將 2 賦值給它,否則引擎會丟擲一個異常Uncaught ReferenceError: a is not defined

函式提升

a()  // aaa => 函式a被提升,所以在宣告前可以呼叫函式

var a
function a () {
console.log('aaa')
}

console.log(a) // ƒ a() {} 函式宣告優先順序比變數宣告高
複製程式碼

var宣告的變數會提升,function 宣告的函式也會被提升,並且函式宣告優先順序比變數宣告優先順序高,所以上面這段程式碼列印 a 是個函式,因為var a宣告的變數被function宣告的函式覆蓋了。

詞法作用域

詞法作用域就是定義在詞法階段的作用域,也就是說作用域是在書寫程式碼時函式宣告的位置來決定,與執行過程無關,JavaScript 採用的是詞法作用域。

相對詞法作用域另外一種叫做動態作用域,作用域是在執行階段確定的,比如Bash指令碼、Perl語言等。

看下面這段程式碼示例:

var a = 1

function foo () {
console.log(a)
}
function bar () {
	var a = 'local'
	foo ()
}

bar() // 詞法作用域是:1 ;動態作用域是:‘local複製程式碼

我們使用詞法作用域和動態作用域分析一下上面這段程式碼執行過程,bar 函式內部呼叫 foo 函式

如果是詞法作用域,呼叫 foo 查詢變數a會從foo函式程式碼定義的位置向外一層也就是全域性作用域訪問,此時var a = 1,結果是 1;

如果是動態作用域,呼叫foo查詢變數a會從當前呼叫函式位置開始嚮往搜尋,發現外部宣告var a = 'local',所以 a的值是local;

而在JS引擎中上面這段程式碼執行結果是 1,所以JavaScript採用的是詞法作用域

不過,thisJavaScript 中比較特殊,JavaScript 程式在執行的時候才會對this進行賦值,在未執行時不能知道this的作用域,所以比較準確的說在JavaScriptthis採用的是動態作用域。

修改詞法作用域: eval 和 with

eval 欺騙詞法作用域

eval 函式接收一個或多個宣告的程式碼,會修改其所處的詞法作用域。


var a = 2
function foo (str, b) {
	eval(str) // 欺騙
	console.log(a, b)
}
foo('var a = 3', 1) // 3, 1

複製程式碼

執行 eval 函式,傳入的字串會解析成指令碼執行,宣告一個變數 a 修改了 foo 函式的詞法作用域,遮蔽了外部(全域性)作用域中的同名變數訪問,欺騙了 foo 詞法作用域。另外,使用 eval 函式還容易受到xss攻擊。

with 欺騙詞法作用域

with 將一個物件的引用當作作用域來處理,將物件的屬性當作作用域中的識別符號來處理,如果物件中沒有該標識號,會在全域性建立一個新的詞法作用域

with 的用法

var obj = {
	a: 1,
	b: 2,
	c: 3
}
// 物件屬性賦值,多次使用obj
obj.a = 2
obj.b = 3
obj.c = 4

// 使用 with 寫法簡潔
with(obj) {
	a = 3;
	b = 4;
	c = 5;
}
複製程式碼

with 的缺陷

function foo(obj) {
	with(obj) {
		a = 2
	}
}
var obj1 = {
	a: 3
}
var obj2 = {
	b: 3
}
foo(obj1)
console.log(obj1.a) // 2

foo(obj2)
console.log(obj2.a) // undefined
console.log(a) // 2 —— a被洩露到了全域性作用域上
複製程式碼

with 會修改引用中屬性的值,如果引用中沒有該屬性,在非嚴格模式下會在全域性作用域中建立一個全新的詞法作用域,欺騙了全域性詞法作用域

除此之外,使用 evalwith 還會帶來效能問題,因為JS 引擎無法在編譯時對它們作用域進行查詢優化,這樣會導致程式碼執行效率變慢,所以建議不要使用它們。

作用域鏈

作用域鍊形成是由詞法作用域和編譯時詞法環境對外部環境引用的結果,關於詞法環境外部環境的引用可以參考這篇文章【深入瞭解JavaScript執行過程】

現在主要說說作用域鏈的構成過程,開始執行指令碼時建立全域性作用域,在全域性環境呼叫 foo函式 時,編譯foo 函式並建立foo函式作用域,foo 函式中宣告 bar函式,在呼叫 bar函式會建立 bar 函式作用域。JavaScript中,內部函式可以訪問外部函式的變數,這樣, bar 函式作用域 =》 foo 函式作用域 =》全域性作用域 構成了一條作用域鏈。


var a = 'global'
function foo () {
	var b = 'foo scoped'
	function bar () {
		var c = 'bar scoped'
		console.log(a, b, c)
		}
	bar()
	}
}


foo() // 'global'    'foo scoped'     'bar scoped'
	
複製程式碼

閉包

談起閉包,它可是JavaScript兩個核心技術之一(非同步和閉包),在面試以及實際應用當中,我們都離不開它們,甚至可以說它們是衡量js工程師實力的一個重要指標。下面我們就羅列閉包的幾個常見問題,從回答問題的角度來理解和定義閉包

問題如下:

- 什麼是閉包

- 閉包的原理是什麼

- 閉包是如何使用的

- 閉包的應用場景有哪些
複製程式碼

如果你能回答上面這些問題,說明你對閉包非常熟悉了;如果腦子裡比較模糊回答不上來也不用擔心,繼續往下讀,相信你會找到答案的。

什麼是閉包

網上有很多種對閉包解釋的說法:

1、閉包是由函式以及建立該函式的詞法環境組合而成

2、閉包是能夠讀取其他函式內部變數的函式

讀起來比較抽象和拗口,用程式碼來理解閉包。

function foo() {
	var a = 2
	function bar () {
		console.log(a)
	}
	return bar
}
var baz = foo()

baz() // 2 —— 這就是閉包的效果

複製程式碼

深入學習作用域和閉包—全面(JS系列之二)

函式是一等公民,可以當成數值來使用,它既可以作為函式引數,也可以作為函式返回值。呼叫foo函式返回bar,理論上來說foo函式執行完之後會被銷燬,不過bar函式引用著fooa變數,所以執行完foo,函式體會被銷燬,但是a被引用著不能被回收仍然儲存在記憶體當中,所以在外部呼叫bar函式可以訪到foo內部函式的a變數。這時我們給foo起了另外一個名字叫閉包函式。

我們知道根據作用域鏈函式內部可以訪問函式外部的變數,反過來是不行的,但是閉包可以做到,這就是閉包的神奇之處

總結一下,閉包本質上是一個函式,它返回另一個函式,可以使外部函式可以訪問其他函式內部的變數。

閉包原理

細心的朋友可能知道答案了,閉包的原理就是詞法作用域和作用域鍊形成的結果。

如何使用閉包

為了能讓我們的程式更健壯,我們往往需要將實現細節隱藏起來,只對外提供暴露介面,這也是物件導向三大特性之一封裝性

私有變數

function foo () {
	var num = 0
	function bar () {
		++num
		return num
	}
	return bar
}
var add1 = foo ()
add1() // 1
add1() // 2
add1() // 3
var add2 = foo ()
add2() // 1
add2() // 2
add2() // 3
複製程式碼

每次執行foo都得到相同的值,不會相互汙染

function Person() {
	var age = 20
	var sex = 'man'
	getAge () {
		return age
	}
	setAge(value) {
		age = value
	}
	getSex () {
		return sex
	}
	setSex(value) {
		sex = value
	}
	return {
		getAge,
		setAge,
		getSex,
		setSex
	}
}

var zhangsan = Person()
zhangsan.getAge() // 20
zhangsan.getSex() // 男

複製程式碼

隱藏實現細節,對外暴露介面。模擬實現了物件導向的思想,程式碼也顯得健壯、易理解、可擴充套件可維護。

閉包的應用場景

定時器、事件監聽器、Ajax 請求、跨視窗通訊、Web Workers 或者任何其他的非同步(或者同步)任務中,只要使用了回撥函式,實際上就是使用閉包

閉包使用注意事項

1、閉包會使得函式中的變數都被儲存在記憶體中,記憶體消耗很大,處理不當,容易造成記憶體洩漏

2、如果不是某些特定任務需要使用閉包,在其它函式中建立函式是不明智的,因為閉包在處理速度和記憶體消耗方面對指令碼效能具有負面影響。

總結

寫的內容有點多,梳理一下

1、首先講了什麼是作用域,作用域型別分為全域性作用域、函式作用域、函式作用域

2、其次作用域工作時,使用varfunctioin宣告會出現變數提升和函式提升;JavaScript 是詞法作用域,evalwith 會欺騙詞法作用域

3、最後講了作用域鏈的原理和閉包使用介紹

引用連結

深入javascript——作用域和閉包

JavaScript中的作用域和閉包

從作用域鏈談閉包

【第863期】深入學習JavaScript閉包

推薦閱讀

相關文章