關於JavaScript作用域的理解
作用域是JavaScript中一個基本的知識點,但是如果要比較全面的瞭解它,不免需要涉及到很多知識點。
首先,我們需要明確作用域的定義或者描述。作用域,也就是規定如何儲存變數,在需要的時候如何去訪問這些變數的規則。作用域分為兩類:動態作用域,靜態作用域(詞法作用域)。一動一靜,從字面上理解也能看出他們的區別,動態作用域是在執行時確定的(JavaScript中的this符合這一點,先挖個坑),而詞法作用域是在寫程式碼的時候確定的,或者說變數定義的位置。很明顯,JavaScript是詞法作用域(假設你寫過JavaScript程式碼)。
先看一段程式碼:
var arr = [1,2,3,4,5]; var average = countAverage(arr); for(var i = 0;i<arr.length;i++){ if(arr[i] < average){ arr.splice(i,1); i--; } } function countAverage(arr) { var sum = 0; var len = arr.length; if(len){ for(var j = 0;j<len;j++){ sum += arr[j]; } } return sum/len; }
首先,我們找一下通過var宣告的變數一共有哪些:arr,average,i,countAverage,sum,len,j。上述程式碼段執行完畢之後,我們去訪問下這些變數,看一下會輸出什麼:
arr //[3,4,5]
average //3
i //3
countAverage //f countAverage(){...}
sum //sum is not defined
len //len is not defined
j //j is not defined
第一個關注點在變數‘i’,‘i’的定義是在for語句塊中,但是在後續訪問的時候任然可以訪問。我們建立變數‘i’的初衷是希望它只存在於迴圈體中,但是現在它汙染到了全域性。也就是說,js中似乎沒有塊作用域。是否能通過一些手段達到塊作用域的效果呢,先挖個坑。
第二個關注點在變數‘sum’,‘len’,‘j’,這三個變數的定義是在函式體內,後續訪問時報錯未定義,同時我們可以在函式內部訪問到函式外部變數‘arr’。也就是說,函式會建立一個作用域。綜合上述兩點,可以得出一個結論:Js是基於函式的作用域,其他結構絕大多數不會建立作用域。為方便理解,敘述,給出下面集合:
直觀的:橢圓的房子裡面有三個人‘arr’,‘average’和‘i’,以及一個圓形的房間‘countAverage’,圓形房間裡面有三個居民‘sum’,‘len’和‘j’。現在站在橢圓的房子裡叫‘arr’,會有人迴應,但是叫‘sum’,沒有人答應,因為圓形房間此時是關閉的。如果走進圓形房間,再去做同樣的事情,就都能得到迴應。也就是說,我們只能由內向外去訪問居民。這裡提到的“走進”的實施者,其實就是JavaScript的“執行流”。
由此,我們拓展開來,在圓形集合裡面可以有一個方形集合,方形集合裡面也可以有個三角形集合,甚至可以無限的往下做巢狀。我們可以在最後一個x形集合中向外訪問居民,反之則不行。
在JavaScript中,這些橢圓區域,圓形區域...,每個區域都是一個執行環境,每個環境都有一個與之對應的變數物件,環境中定義的變數和函式都儲存在這個物件中。‘最外圍’的環境就是我們常說的全域性執行環境,在web瀏覽器中,全域性環境對應的變數物件是window,例如我們也可以通過window.arr去訪問變數‘arr’。
暫時先不去考慮塊作用域的事情。現在知道,每個函式都有自己的執行環境,那js是怎麼知道這些環境的巢狀關係的呢?在程式執行時,程式碼執行到某個函式,對應函式的執行環境就會被推入環境棧中,很明顯,棧底儲存的就是全域性執行環境,而棧頂元素到棧底所形成的就是當前函式物件可訪問的作用域,也就是當前函式物件的作用域鏈。作用域鏈的最前端是棧頂元素對應的變數物件,最末端就是棧底元素全域性環境的變數物件。當函式執行完畢,環境棧就會將當前函式執行環境彈出。例如,給出以下示例:
function groupA(){
var str = 'groupA';
......//程式碼塊
function groupB(){
var str = 'groupB';
......//程式碼塊
function groupC(){
var str = 'groupC';
......//程式碼塊
}
}
}
在環境中,變數查詢是沿著作用域鏈進行線性的,有序的搜尋的。例如在上述程式碼中,當執行到函式groupC時,程式碼塊中訪問變數“str”,groupC中定義了“str”,因此返回“groupC”;如果訪問的變數在groupC中未定義,就會沿著作用域鏈往上搜索,依次查詢groupB,groupA,全域性環境,如果找到就終止搜尋,如果在全域性環境中任然沒有找到,就會報錯未定義(嚴格模式下,非嚴格模式下就會建立一個全域性變數)。
現在來說說塊作用域。在es5中實現塊作用域的方法,以下幾種:
- with
- try/catch
- 立即執行函式IIFE
其中with不推薦使用,而且在嚴格模式下是被禁止的。catch分句會建立一個塊作用域,瞭解下就好。然後說以下立即執行函式。一般常見的函式寫法是這樣的:
//函式宣告
function foo(){
......//程式碼塊
}
//函式表示式
var another = function(){
......//程式碼塊
}
//匿名函式
function(){
......//程式碼塊
}
如果你看過jQuery原始碼,你會熟悉這樣的結構:
(function(global,factory){
......//程式碼塊
})(..........)
將函式包含在()中使其變為一個函式表示式,然後再用一個()去執行它。看一個簡單的對比
(function IIFE(){
var str = 'IIFE';
console.log(str);
})();
function fun(){
var str = 'fun';
console.log(str);
}
將程式碼放到控制檯執行,然後分別訪問IIFE和fun,結果顯示為IIFE未定義,fun會返回函式。可以這樣理解,變數fun儲存的是指向函式的指標,並且fun作為全域性執行環境變數物件window的屬性被儲存。而IIFE整體全都被封印在‘()’中。IIFE--立即執行函式很強大,可以作為一個專題來寫了,這裡就不深入了。
在es6中,加入了let,const,解決了以往的一個var宣告一切的尷尬。對let,const,var的對比網上有很多文章寫的都很好,之後我也會做一個總結,這裡不多說。
說到作用域,閉包這個話題是逃不開的。剛接觸js時就聽說閉包很難,感覺很神祕,似乎大多人不太願意談論這個話題。事實上,你已經寫過很多‘閉包’了,只是自己沒有意識到,例如各種回撥。常見的講解閉包的程式碼類似下面的:
function fun(name){
var str = 'hello,'+name;
return function(){
console.log(str);
};
}
var test = fun('world!');
test(); //hello,world!
首先函式fun內部定義了一個變數str,函式返回的是另一個匿名函式,匿名函式做的事情就是打印出fun定義的內部變數str。然後我們在函式fun外部呼叫fun,並把結果賦值給變數test。這個test指向就是匿名函式。按照之前說的詞法作用域,在執行test的時候應該報錯:str未定義才對。但實際上,test能夠正常執行,並輸出'hello,world!'。
我們在函式定義的詞法作用域外部對函式進行呼叫,它仍然可以訪問其定義所在的詞法作用域,這個時候就產生了閉包。再想想程式碼中寫過的各種回撥,我們實際上就是將內部函式傳遞到了其定義所在的詞法作用域之外,自然都用到了閉包。
這篇文章跨度時間有點久,一來自己不滿意,感覺想寫的東西多,不知道怎麼合理的串起來但不至於文章看起來雜亂多,二是工作內容多時間不充裕。算是匆匆結個尾。回頭看了一遍,作用域和閉包的基本知識應該是交待清楚了。文中提到的立即執行函式,塊作用域,es6的let,const等等這些,都需要都值得去深入學習。之後找時間再去針對這些點寫總結。
期待您的指正交流。