作用域是JavaScript最重要的概念之一,想要學好JavaScript就需要理解JavaScript作用域和作用域鏈的工作原理。今天這篇文章對JavaScript作用域和作用域鏈作簡單的介紹,希望能幫助大家更好的學習JavaScript。
JavaScript作用域
任何程式設計語言都有作用域的概念,簡單的說,作用域就是變數與函式的可訪問範圍,即作用域控制著變數與函式的可見性和生命週期。在JavaScript中,變數的作用域有全域性作用域和區域性作用域兩種。
1. 全域性作用域(Global Scope)
在程式碼中任何地方都能訪問到的物件擁有全域性作用域,一般來說以下幾種情形擁有全域性作用域:
(1)最外層函式和在最外層函式外面定義的變數擁有全域性作用域,例如:
1 2 3 4 5 6 7 8 9 10 11 12 | var authorName= "山邊小溪" ; function doSomething(){ var blogName= "夢想天空" ; function innerSay(){ alert(blogName); } innerSay(); } alert(authorName); //山邊小溪 alert(blogName); //指令碼錯誤 doSomething(); //夢想天空 innerSay() //指令碼錯誤 |
(2)所有末定義直接賦值的變數自動宣告為擁有全域性作用域,例如:
1 2 3 4 5 6 7 8 | function doSomething(){ var authorName= "山邊小溪" ; blogName= "夢想天空" ; alert(authorName); } doSomething(); //山邊小溪 alert(blogName); //夢想天空 alert(authorName); //指令碼錯誤 |
變數blogName擁有全域性作用域,而authorName在函式外部無法訪問到。
(3)所有window物件的屬性擁有全域性作用域
一般情況下,window物件的內建屬性都擁有全域性作用域,例如window.name、window.location、window.top等等。
1. 區域性作用域(Local Scope)
和全域性作用域相反,區域性作用域一般只在固定的程式碼片段內可訪問到,最常見的例如函式內部,所有在一些地方也會看到有人把這種作用域稱為函式作用域,例如下列程式碼中的blogName和函式innerSay都只擁有區域性作用域。
1 2 3 4 5 6 7 8 9 | function doSomething(){ var blogName= "夢想天空" ; function innerSay(){ alert(blogName); } innerSay(); } alert(blogName); //指令碼錯誤 innerSay(); //指令碼錯誤 |
作用域鏈(Scope Chain)
在JavaScript中,函式也是物件,實際上,JavaScript裡一切都是物件。函式物件和其它物件一樣,擁有可以通過程式碼訪問的屬性和一系列僅供JavaScript引擎訪問的內部屬性。其中一個內部屬性是[[Scope]],由ECMA-262標準第三版定義,該內部屬性包含了函式被建立的作用域中物件的集合,這個集合被稱為函式的作用域鏈,它決定了哪些資料能被函式訪問。
當一個函式建立後,它的作用域鏈會被建立此函式的作用域中可訪問的資料物件填充。例如定義下面這樣一個函式:
1 2 3 4 | function add(num1,num2) { var sum = num1 + num2; return sum; } |
在函式add建立時,它的作用域鏈中會填入一個全域性物件,該全域性物件包含了所有全域性變數,如下圖所示(注意:圖片只例舉了全部變數中的一部分):
函式add的作用域將會在執行時用到。例如執行如下程式碼:
1 | var total = add(5,10); |
執行此函式時會建立一個稱為“執行期上下文(execution context)”的內部物件,執行期上下文定義了函式執行時的環境。每個執行期上下文都有自己的作用域鏈,用於識別符號解析,當執行期上下文被建立時,而它的作用域鏈初始化為當前執行函式的[[Scope]]所包含的物件。
這些值按照它們出現在函式中的順序被複制到執行期上下文的作用域鏈中。它們共同組成了一個新的物件,叫“活動物件(activation object)”,該物件包含了函式的所有區域性變數、命名引數、引數集合以及this,然後此物件會被推入作用域鏈的前端,當執行期上下文被銷燬,活動物件也隨之銷燬。新的作用域鏈如下圖所示:
在函式執行過程中,沒遇到一個變數,都會經歷一次識別符號解析過程以決定從哪裡獲取和儲存資料。該過程從作用域鏈頭部,也就是從活動物件開始搜尋,查詢同名的識別符號,如果找到了就使用這個識別符號對應的變數,如果沒找到繼續搜尋作用域鏈中的下一個物件,如果搜尋完所有物件都未找到,則認為該識別符號未定義。函式執行過程中,每個識別符號都要經歷這樣的搜尋過程。
作用域鏈和程式碼優化
從作用域鏈的結構可以看出,在執行期上下文的作用域鏈中,識別符號所在的位置越深,讀寫速度就會越慢。如上圖所示,因為全域性變數總是存在於執行期上下文作用域鏈的最末端,因此在識別符號解析的時候,查詢全域性變數是最慢的。所以,在編寫程式碼的時候應儘量少使用全域性變數,儘可能使用區域性變數。一個好的經驗法則是:如果一個跨作用域的物件被引用了一次以上,則先把它儲存到區域性變數裡再使用。例如下面的程式碼:
1 2 3 4 5 | function changeColor(){ document.getElementById( "btnChange" ).onclick= function (){ document.getElementById( "targetCanvas" ).style.backgroundColor= "red" ; }; } |
這個函式引用了兩次全域性變數document,查詢該變數必須遍歷整個作用域鏈,直到最後在全域性物件中才能找到。這段程式碼可以重寫如下:
1 2 3 4 5 6 | function changeColor(){ var doc=document; doc.getElementById( "btnChange" ).onclick= function (){ doc.getElementById( "targetCanvas" ).style.backgroundColor= "red" ; }; } |
這段程式碼比較簡單,重寫後不會顯示出巨大的效能提升,但是如果程式中有大量的全域性變數被從反覆訪問,那麼重寫後的程式碼效能會有顯著改善。
改變作用域鏈
函式每次執行時對應的執行期上下文都是獨一無二的,所以多次呼叫同一個函式就會導致建立多個執行期上下文,當函式執行完畢,執行上下文會被銷燬。每一個執行期上下文都和一個作用域鏈關聯。一般情況下,在執行期上下文執行的過程中,其作用域鏈只會被 with 語句和 catch 語句影響。
with語句是物件的快捷應用方式,用來避免書寫重複程式碼。例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | function initUI(){ with (document){ var bd=body, links=getElementsByTagName( "a" ), i=0, len=links.length; while (i < len){ update(links[i++]); } getElementById( "btnInit" ).onclick= function (){ doSomething(); }; } } |
這裡使用width語句來避免多次書寫document,看上去更高效,實際上產生了效能問題。
當程式碼執行到with語句時,執行期上下文的作用域鏈臨時被改變了。一個新的可變物件被建立,它包含了引數指定的物件的所有屬性。這個物件將被推入作用域鏈的頭部,這意味著函式的所有區域性變數現在處於第二個作用域鏈物件中,因此訪問代價更高了。如下圖所示:
因此在程式中應避免使用with語句,在這個例子中,只要簡單的把document儲存在一個區域性變數中就可以提升效能。
另外一個會改變作用域鏈的是try-catch語句中的catch語句。當try程式碼塊中發生錯誤時,執行過程會跳轉到catch語句,然後把異常物件推入一個可變物件並置於作用域的頭部。在catch程式碼塊內部,函式的所有區域性變數將會被放在第二個作用域鏈物件中。示例程式碼:
1 2 3 4 5 | try { doSomething(); } catch (ex){ alert(ex.message); //作用域鏈在此處改變 } |
請注意,一旦catch語句執行完畢,作用域鏈機會返回到之前的狀態。try-catch語句在程式碼除錯和異常處理中非常有用,因此不建議完全避免。你可以通過優化程式碼來減少catch語句對效能的影響。一個很好的模式是將錯誤委託給一個函式處理,例如:
1 2 3 4 5 | try { doSomething(); } catch (ex){ handleError(ex); //委託給處理器方法 } |
優化後的程式碼,handleError方法是catch子句中唯一執行的程式碼。該函式接收異常物件作為引數,這樣你可以更加靈活和統一的處理錯誤。由於只執行一條語句,且沒有區域性變數的訪問,作用域鏈的臨時改變就不會影響程式碼效能了。