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

作用域

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

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

引擎:負責整個 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 —— 這就是閉包的效果

函數是一等公民,可以當成數值來使用,它既可以作爲函數參數,也可以作爲函數返回值。調用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閉包

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