下面全面揭示了javascript中的上下文和作用域的不同,以及各種設計模式如何使用他們,感興趣的朋友不要錯過
javascript中的作用域(scope)和上下文(context)是這門語言的獨到之處,這部分歸功於他們帶來的靈活性。每個函數有不同的變量上下文和作用域。這些概念是javascript中一些強大的設計模式的後盾。然而這也給開發人員帶來很大困惑。下面全面揭示了javascript中的上下文和作用域的不同,以及各種設計模式如何使用他們。 上下文 vs 作用域 首先需要澄清的問題是上下文和作用域是不同的概念。多年來我注意到許多開發者經常將這兩個術語混淆,錯誤的將一個描述為另一個。平心而論,這些術語變得非常混亂不堪。 每個函數調用都有與之相關的作用域和上下文。從根本上說,范圍是基於函數(function-based)而上下文是基於對象(object-based)。換句話說,作用域是和每次函數調用時變量的訪問有關,並且每次調用都是獨立的。上下文總是關鍵字 this 的值,是調用當前可執行代碼的對象的引用。 變量作用域 變量能夠被定義在局部或者全局作用域,這導致運行時變量的訪問來自不同的作用域。全局變量需被聲明在函數體外,在整個運行過程中都存在,能在任何作用域中訪問和修改。局部變量僅在函數體內定義,並且每次函數調用都有不同的作用域。這主題是僅在調用中的賦值,求值和對值的操作,不能訪問作用域之外的值。 目前javascript不支持塊級作用域,塊級作用域指在if語句,switch語句,循環語句等語句塊中定義變量,這意味著變量不能在語句塊之外被訪問。當前任何在語句塊中定義的變量都能在語句塊之外訪問。然而,這種情況很快會得到改變,let 關鍵字已經正式添加到ES6規范。用它來代替var關鍵字可以將局部變量聲明為塊級作用域。 "this" 上下文 上下文通常是取決於一個函數如何被調用。當函數作為對象的方法被調用時,this 被設置為調用方法的對象: 代碼如下: var object = { foo: function(){ alert(this === object); } }; object.foo(); // true 同樣的原理適用於當調用一個函數時通過new的操作符創建一個對象的實例。當以這種方式調用時,this 的值將被設置為新創建的實例: 代碼如下: function foo(){ alert(this); } foo() // window new foo() // foo 當調用一個未綁定函數,this 將被默認設置為 全局上下文(global context) 或window對象(如果在浏覽器中)。然而如果函數在嚴格模式下被執行("use strict"),this的值將被默認設置為undefined。 執行上下文和作用域鏈 javascript是一個單線程語言,這意味著在浏覽器中同時只能做一件事情。當javascript解釋器初始執行代碼,它首先默認竟如全局上下文。每次調用一個函數將會創建一個新的執行上下文。 這裡經常發生混淆,這術語”執行上下文(execution context)“在這裡的所要表達的意思是作用域,不是前面討論的上下文。這是槽糕的命名,然而這術語ECMAScript規范所定義的,無奈的遵守吧。 每次新創建一個執行上下文,會被添加到作用域鏈的頂部,又是也成為執行或調用棧。浏覽器總是運行在位於作用域鏈頂部當前執行上下文。一旦完成,它(當前執行上下文)將從棧頂被移除並且將控制權歸還給之前的執行上下文。例如: 代碼如下: function first(){ second(); function second(){ third(); function third(){ fourth(); function fourth(){ // do something } } } } first(); 運行前面的代碼將會導致嵌套的函數被從上倒下執行直到 fourth 函數,此時作用域鏈從上到下為: fourth, third, second, first, global。fourth 函數能夠訪問全局變量和任何在first,second和third函數中定義的變量,就如同訪問自己的變量一樣。一旦fourth函數執行完成,fourth暈高興上下文將被從作用域鏈頂端移除並且執行將返回到thrid函數。這一過程持續進行直到所有代碼已完成執行。 不同執行上下文之間的變量命名沖突通過攀爬作用域鏈解決,從局部直到全局。這意味著具有相同名稱的局部變量在作用域鏈中有更高的優先級。 簡單的說,每次你試圖訪問函數執行上下文中的變量時,查找進程總是從自己的變量對象開始。如果在自己的變量對象中沒發現要查找的變量,繼續搜索作用域鏈。它將攀爬作用域鏈檢查每一個執行上下文的變量對象去尋找和變量名稱匹配的值。 閉包 當一個嵌套的函數在定義(作用域)的外面被訪問,以至它可以在外部函數返回後被執行,此時一個閉包形成。它(閉包)維護(在內部函數中)對外部函數中局部變量,arguments和函數聲明的訪問。封裝允許我們從外部作用域中隱藏和保護執行上下文,而暴露公共接口,通過接口進一步操作。一個簡單的例子看起來如下: 復制代碼 代碼如下: function foo(){ var local = 'private variable'; return function bar(){ return local; } } var getLocalVariable = foo(); getLocalVariable() // private variable 其中最流行的閉包類型是廣為人知的模塊模式。它允許你模擬公共的,私有的和特權成員: 代碼如下: var Module = (function(){ var privateProperty = 'foo'; function privateMethod(args){ //do something } return { publicProperty: "", publicMethod: function(args){ //do something }, privilegedMethod: function(args){ privateMethod(args); } } })(); 模塊實際上有些類似於單例,在末尾添加一對括號,當解釋器解釋完後立即執行(立即執行函數)。閉包執行上下位的外部唯一可用的成員是返回對象中公用的方法和屬性(例如Module.publicMethod)。然而,所有的私有屬性和方法在整個程序的生命周期中都將存在,由於(閉包)使執行上下文收到保護,和變量的交互要通過公用的方法。 另一種類型的閉包叫做立即調用函數表達式(immediately-invoked function expression IIFE),無非是一個在window上下文中的自調用匿名函數(self-invoked anonymous function)。 代碼如下: function(window){ var a = 'foo', b = 'bar'; function private(){ // do something } window.Module = { public: function(){ // do something } }; })(this); 對保護全局命名空間,這種表達式非常有用,所有在函數體內聲明的變量都是局部變量,並通過閉包在整個運行環境保持存在。這種封裝源代碼的方式對程序和框架都是非常流行的,通常暴露單一全局接口與外界交互。 Call 和 Apply 這兩個簡單的方法,內建在所有的函數中,允許在自定義上下文中執行函數。call 函數需要參數列表而 apply 函數允許你傳遞參數為數組: 代碼如下: function user(first, last, age){ // do something } user.call(window, 'John', 'Doe', 30); user.apply(window, ['John', 'Doe', 30]); 執行的結果是相同的,user 函數在window上下文上被調用,並提供了相同的三個參數。 ECMAScript 5 (ES5)引入了Function.prototype.bind方法來控制上下文,它返回一個新函數,這函數(的上下文)被永久綁定到bind方法的第一個參數,無論函數被如何調用。它通過閉包修正函數的上下文,下面是為不支持的浏覽器提供的方案: 代碼如下: if(!('bind' in Function.prototype)){ Function.prototype.bind = function(){ var fn = this, context = arguments[0], args = Array.prototype.slice.call(arguments, 1); return function(){ return fn.apply(context, args); } } } 它常用在上下文丟失:面向對象和事件處理。這點有必要的因為 節點的addEventListener 方法總保持函數執行的上下文為事件處理被綁定的節點,這點很重要。然而如果你使用高級面向對象技術並且需要維護回調函數的上下文是方法的實例,你必須手動調整上下文。這就是bind 帶來的方便: 代碼如下: function MyClass(){ this.element = document.createElement('div'); this.element.addEventListener('click', this.onClick.bind(this), false); } MyClass.prototype.onClick = function(e){ // do something }; 當回顧bind函數的源代碼,你可能注意到下面這一行相對簡單的代碼,調用Array的一個方法: 代碼如下: Array.prototype.slice.call(arguments, 1); 有趣的是,這裡需要注意的是arguments對象實際上並不是一個數組,然而它經常被描述為類數組(array-like)對象,很向 nodelist(document.getElementsByTagName()方法返回的結果)。他們包含lenght屬性,值能夠被索引,但他們仍然不是數組,由於他們不支持原生的數組方法,比如slice和push。然而,由於他們有和數組類似的行為,數組的方法能被調用和劫持。如果你想這樣,在類數組的上下文中執行數組方法,可參照上面的例子。 這種調用其他對象方法的技術也被應用到面向對象中,當在javascript中模仿經典繼承(類繼承): 代碼如下: MyClass.prototype.init = function(){ // call the superclass init method in the context of the "MyClass" instance MySuperClass.prototype.init.apply(this, arguments); } 通過在子類(MyClass)的實例中調用超類(MySuperClass)的方法,我們能重現這種強大的設計模式。 結論 在你開始學習高級設計模式之前理解這些概念是非常重要的,由於作用域和上下文在現代javascript中扮演重要的和根本的角色。無論我們談論閉包,面向對象,和繼承或各種原生實現,上下文和作用域都扮演重要角色。如果你的目標是掌握javascript語言並深入了解它的組成,作用域和上下文應該是你的起點。 譯者補充 作者實現的bind函數是不完全的,調用bind返回的函數時不能傳遞參數,下面的代碼修復了這個問題: 代碼如下: if(!(‘bind' in Function.prototype)){ Function.prototype.bind = function(){ var fn = this, context = arguments[0], args = Array.prototype.slice.call(arguments, 1); return function(){ return fn.apply(context, args.concat(arguments));//fixed } } }