作用域
在學習作用域之前,先了解兩個重要的概念:編譯器、引擎
編譯器:負責詞法分析及程式碼生成等編譯過程
引擎:負責整個 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
複製程式碼
在if
、for
、while
、try...catch
等在大括號中使用let
、const
宣告的變數會形成塊級作用域,如果在外部訪問會報錯。
作用域如何工作
變數提升
剛開始接觸 JavaScript
的同學可能會對變數先宣告後使用的現象十分不解,要理解它我們得了解JavaScript
編譯的兩個原則:①編譯時宣告 ②執行時賦值
var a = 2;
//相當於↓
var a; //編譯時
a = 2; //執行時
複製程式碼
上面這段程式碼 var a = 2
只做一件事,對a
進行賦值 ,不過瀏覽器引擎不這麼看, 它會被分為 var a
和 a = 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
採用的是詞法作用域
不過,this
在 JavaScript
中比較特殊,JavaScript
程式在執行的時候才會對this
進行賦值,在未執行時不能知道this
的作用域,所以比較準確的說在JavaScript
中this
採用的是動態作用域。
修改詞法作用域: 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
會修改引用中屬性的值,如果引用中沒有該屬性,在非嚴格模式下會在全域性作用域中建立一個全新的詞法作用域,欺騙了全域性詞法作用域
除此之外,使用 eval
和 with
還會帶來效能問題,因為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
函式引用著foo
的a
變數,所以執行完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、其次作用域工作時,使用var
和functioin
宣告會出現變數提升和函式提升;JavaScript
是詞法作用域,eval
和 with
會欺騙詞法作用域
3、最後講了作用域鏈的原理和閉包使用介紹
引用連結
推薦閱讀