接着看函數——這個具有魔幻色彩的對象。在上篇文章中說函數內部屬性時,還遺留了一個this內部屬性沒有解釋,不過在說this之前,我想先說一說執行環境和作用域的概念。
6、執行環境和作用域
(1)執行環境(execution context):所有的JavaScript代碼都運行在一個執行環境中,當控制權轉移至JavaScript的可執行代碼時,就進入了一個執行環境。活動的執行環境從邏輯上形成了一個棧,全局執行環境永遠是這個棧的棧底元素,棧頂元素就是當前正在運行的執行環境。每一個函數都有自己的執行環境,當執行流進入一個函數時,會將這個函數的執行環境壓入棧頂,函數執行完之后再將這個執行環境彈出,控制權返回給之前的執行環境。
(2)變量對象(variable object):每一個執行環境都有一個與之對應的變量對象,執行環境中定義的所有變量和函數就是保存在這個變量對象中。這個變量對象是后台實現中的一個對象,我們無法在代碼中訪問,但是這有助於我們理解執行環境和作用域相關概念。
(3)作用域鏈(scope chain):當代碼在一個執行環境中運行時,會創建由變量對象組成的一個作用域鏈。這個鏈的前端,就是當前代碼所在環境的變量對象,鏈的最末端,就是全局環境的變量對象。在一個執行環境中解析標識符時,會在當前執行環境相應的變量對象中搜索,找到就返回,沒有找到就沿着作用域鏈一級一級往上搜索直至全局環境的變量對象,如果一直未找到,就拋出引用異常。
(4)活動對象(activation object):如果一個執行環境是函數執行環境,也將變量對象稱為活動對象。活動對象在最開始只包含一個變量,即arguments對象(這個對象在全局環境的變量對象中不存在)。
這四個概念雖然有些抽象,但還是比較自然的,可以結合《JavaScript高級程序設計(第3版)》中的一個例子來細細體會一下:
// 進入到全局作用域,創建全局變量對象 var color = "blue"; function changeColor(){ // 進入到changeColor作用域,創建changeColor相應變量對象 var anotherColor = "red"; function swapColors(color1, color2){ // 進入到swapColors作用域,創建swapColors相應變量對象 var tempColor = anotherColor; anotherColor = color; color = tempColor; /* * swapColors作用域內可以訪問的對象有: * 全局變量對象的color,changeColor * changeColor函數相應變量對象的anotherColor、swapColors * swapColors函數相應變量對象的tempColor */ } swapColors('white'); /* * changeColor作用域內可以訪問的對象有: * 全局變量對象的color,changeColor * changeColor函數相應變量對象的anotherColor、swapColors */ } changeColor(); /* * 全局作用域內可以訪問的對象有: * 全局變量對象的color,changeColor */
這里的整個過程是:
(1)進入全局環境,創建全局變量對象,將全局環境壓入棧頂(這里也是棧底)。根據前面的關於聲明提升的結論,這里創建全局變量對象可能的一個過程是,先創建全局變量對象,然后處理函數聲明設置屬性changeColor為相應函數,再處理變量聲明設置屬性color為undefined。
(2)執行全局環境中的代碼。先執行color變量初始化,賦值為'blue',再調用changeColor()函數。
(3)調用changeColor()函數,進入到changeColor函數執行環境,創建這個環境相應的變量對象(也就是活動對象),將這個環境壓入棧頂。創建活動對象可能的一個過程是,先創建活動對象,處理內部函數聲明設置屬性swapColors為相應函數,處理函數參數創建活動對象的屬性arguments對象,處理內部變量聲明設置屬性anotherColor為undefined。
(4)執行changeColor()函數代碼。先執行anotherColor初始化為'red',再調用swapColors()函數。
(5)調用swapColors()函數,進入到swapColors函數執行環境,創建相應的變量對象(活動對象),將swapColors執行環境壓入棧頂。這里創建活動對象可能的一個過程是,先創建活動對象,處理函數參數,將形式參數作為活動對象的屬性並賦值為undefined,創建活動對象的屬性arguments對象,並根據實際參數初始化形式參數和arguments對應的值和屬性(將屬性color1和arguments[0]初始化為'white',由於沒有第二個實際參數,所以color2的值為undefined,而arguments的長度只為1了),處理完函數參數之后,再處理函數內部變量聲明,將tempColor作為活動對象的屬性並賦值為undefined。
(6)執行swapColors()函數代碼。先給tempColor初始化賦值,然后實現值交換功能(這里color和anotherColor的值都是沿着作用域鏈才讀取到的)。
(7)swapColors()函數代碼執行完之后,返回undefined,將相應的執行環境彈出棧並銷毀(注意,這里會銷毀執行環境,但是執行環境相應的活動對象並不一定會被銷毀),當前執行環境恢復成changeColor()函數的執行環境。隨着swapColor()函數執行完並返回,changeColor()也就執行完了,同樣返回undefined,並將changeColor()函數的執行環境彈出棧並銷毀,當前執行環境恢復成全局環境。整個處理過程結束,全局環境直至頁面退出再銷毀。
作用域鏈也解釋了為什么函數可以在內部遞歸調用自身:函數名是函數定義所在執行環境相應變量對象的一個屬性,然后在函數內部執行環境中,就可以沿着作用域鏈向外上溯一層訪問函數名指向的函數對象了。如果在函數內部將函數名指向了一個新函數,遞歸調用時就會不正確了:
function fn(num){ if(1 == num){ return 1; }else{ fn = function(){ return 0; }; return num * fn(num - 1); } } console.info(fn(5));//0
關於作用域和聲明提升,再看一個例子:
1 var name = 'linjisong'; 2 function fn(){ 3 console.info(name);//undefined 4 var name = 'oulinhai'; 5 console.info(name);//oulinhai 6 } 7 fn(); 8 console.info(name);//linjisong
這里最不直觀的可能是第3行輸出undefined,因為在全局中已經定義過name了,不過按照上面解析的步驟去解析一次,就可以得出正確的結果了。另外強調一下,在ECMAScript中只有全局執行環境和函數執行環境,相應的也只有全局作用域和函數作用域,沒有塊作用域——雖然有塊語句。
function fn(){ var fnScope = 'a'; { var blockScope = 'b'; blockScope += fnScope; } console.info(blockScope);//沒有塊作用域,所以可以在整個函數作用域內訪問blockScope console.info(fnScope); } fn();//ba,a console.info(blockScope);//ReferenceError,函數作用域外,不能訪問內部定義的變量 console.info(fnScope);//ReferenceError
對於作用域鏈,還可以使用with、try-catch語句的catch塊來延長:
- 使用with(obj){}語句時,將obj對象添加到當前作用域鏈的最前端。
- 使用try{}catch(error){}語句時,將error對象添加到當前作用域鏈的最前端。
插了一段較為抽象的概念,希望不至於影響整個閱讀的流暢,事實上,我在這里還悄悄的繞過了一個稱為“閉包”的概念,關於函數與閉包,在下篇文章中再詳細敘述。
7、函數內部對象與this
對於面向對象語言的使用者來說,this實在是再熟悉不過了,不就是指向構造函數新創建的對象嗎!不過,在ECMAScript中,且別掉以輕心,事情沒有那么簡單,雖然在使用new操作符調用函數的情況下,this也的確是指向新創建的對象,但這只是指定this對象值的一種方式而已,還有更多的方式可以指定this對象的值,換句話說,this是動態的,是可以由我們自己自由指定的。
(1)全局環境中的this
在全局環境中,this指向全局對象本身,在瀏覽器中也就是window,這里也可以把全局環境中的this理解為全局執行環境相應的變量對象,在全局環境中定義的變量和函數都是這個變量對象的屬性:
var vo = 'a'; vo2 = 'b'; function fn(){ return 'fn'; } console.info(this === window);//true console.info(this.vo);//a console.info(this.vo2);//b console.info(this.fn());//fn
如果在自定義函數中要引用全局對象,雖然可以直接使用window,但更好的方式則是將全局對象作為參數傳入函數,這是在JS庫中非常通用的一種方式:
(function(global){ console.info(global === window);//在內部可以使用global代替window了 })(this);
這種方式兼容性更好(ECMAScript的實現中全局對象未必都是window),在壓縮時,也可以將global簡化為g,而不用使用window了。
(2)函數內部屬性this
在函數環境中,this是一個內部屬性對象,可以理解成函數對應的活動對象的一個屬性,而這個內部屬性的值是動態的。那this值是怎么動態確定的呢?
- 使用new調用時,函數也稱為構造函數,這個時候函數內部的this被指定為新創建的對象。
function fn(){ var name = 'oulinhai';//函數對應的活動對象的屬性 this.name = 'linjisong';//當使用new調用函數時,將this指定為新創建對象,也就是給新創建對象添加屬性 } var person = new fn(); console.info(person.name);//linjisong var arr = [fn]; console.info(arr[0]());//undefined
需要注意區分一下函數執行環境中定義的屬性(也即活動對象的屬性)和this對象的屬性,在使用數組元素方式調用函數時,函數內部this指向數組本身,因此上例最后輸出undefined。
- 作為一般函數調用時,this指向全局對象。
- 作為對象的方法調用時,this指向調用這個方法的對象。
看下面的例子:
var name = 'oulinhai'; var person = { name:'linjisong', getName:function(){ return this.name; } }; console.info(person.getName());//linjisong var getName = person.getName; console.info(getName());//oulinhai
這里函數對象本身是匿名的,是作為person對象的一個屬性,當作為對象屬性調用時,this指向了對象,當把這個函數賦給另一個函數然后調用時,是作為一般函數調用的,this指向了全局對象。這個例子充分說明了“函數作為對象的方法調用時內部屬性this指向這個調用對象,函數作為一般函數調用時內部屬性this指向全局對象”,也說明了this的指定是動態的,是在調用時指定的,而不管函數是單獨定義的還是作為對象方法定義的。也正是因為函數作為對象的方法調用時this指向這個調用對象,所以在函數內部返回this時才能夠延續調用對象的下一個方法——也就是鏈式操作(jQuery的一大特色)。
- 使用apply()、call()或bind()調用函數時,this指向第一個參數對象。如果沒有傳入參數或傳入的是null和undefined,this指向全局對象(在ES5的嚴格模式下會設為null)。如果傳入的第一個參數是一個簡單類型,會將this設置為相應的簡單類型包裝對象。
var name = 'linjisong'; function fn(){ return this.name; } var person = { name:'oulinhai', getName:fn }; var person2 = {name:'hujinxing'}; var person3 = {name:'huanglanxue'}; console.info(fn());//linjisong,一般函數調用,內部屬性this指向全局對象,因此this.name返回linjisong console.info(person.getName());//oulinhai,作為對象方法調用,this指向這個對象,因此這里返回person.name console.info(fn.apply(person2));//hujinxing,使用apply、call或bind調用函數,執行傳入的第一個參數對象,因此返回person2.name console.info(fn.call(person2));//hujinxing var newFn = fn.bind(person3);//ES5中新增方法,會創建一個新函數實例返回,內部this值被指定為傳入的參數對象 console.info(newFn());//huanglanxue
上面示例中列出的都是一些常見情況,沒有列出第一個參數為null或undefined的情況,有興趣的朋友可以自行測試。關於this值的確定,在原書中還有一個例子:
var name = 'The Window'; var object = { name : 'My Object', getName:function(){ return this.name; }, getNameFunc:function(){ return function(){ return this.name; } } }; console.info(object.getName());//My Object console.info((object.getName)());//My Object console.info((object.getName = object.getName)());//The Window console.info(object.getNameFunc()());//The Window
第1個是正常輸出,第2個(object.getName)與object.getName的效果是相同的,而第3個(object.getName=object.getName)最終返回的是函數對象本身,也就是說第3個會作為一般函數來調用,第4個則先是調用getNameFunc這個方法,返回一個函數,然后再調用這個函數,也是作為一般函數來調用。
8、函數屬性和方法
函數是一個對象,因此也可以有自己的屬性和方法。不過函數屬性和方法與函數內部屬性很容易混淆,既然容易混淆,就把它們放一起對照着看,就好比一對雙胞胎,不對照着看,不熟悉的人是區分不了的。
先從概念上來區分一下:
(1)函數內部屬性:可以理解為函數相應的活動對象的屬性,是只能從函數體內部訪問的屬性,函數每一次被調用,都會被重新指定,具有動態性。
(2)函數屬性和方法:這是函數作為對象所具有的特性,只要函數一定義,函數對象就被創建,相應的屬性和方法就可以訪問,並且除非你在代碼中明確賦為另一個值,否則它們的值不會改變,因而具有靜態性。有一個例外屬性caller,表示調用當前函數的函數,也是在函數被調用時動態指定,在《JavaScript高級程序設計(第3版)》中也因此將caller屬性和函數內部屬性arguments、this一起講解,事實上,在ES5的嚴格模式下,不能對具有動態特性的函數屬性caller賦值。
光從概念上區分是非常抽象的,也不是那么容易理解,再把這些屬性列在一起比較一下(沒有列入一些非標准的屬性,如name):
類別 | 名稱 | 繼承性 | 說明 | 備注 |
函數內部屬性 | this | - | 函數據以執行的環境對象 | 和一般面向對象語言有很大區別 |
arguments | - | 表示函數實際參數的類數組對象 arguments本身也有自己的屬性:length、callee和caller |
1、length屬性表示實際接收到的參數個數 2、callee屬性指向函數對象本身,即有: fn.arguments.callee === fn 3、caller屬性主要和函數的caller相區分,值永遠都是undefined |
|
函數屬性 | caller | 否 | 調用當前函數的函數 | 雖然函數一定義就可訪問,但是不在函數體內訪問時永遠為null,在函數體內訪問時返回調用當前函數的函數,在全局作用域中調用函數也會返回null |
length | 否 | 函數形式參數的長度 | 就是定義函數時命名的參數個數 | |
prototype | 否 | 函數原型對象 | 原型對象是ECMAScript實現繼承的基礎 | |
constructor | 是 | 繼承自Object,表示創建函數實例的函數,也就是Function() | 值永遠是Function,也就是內置的函數Function() | |
函數方法 | apply | 否 | 調用函數自身,以(類)數組方式接受參數 | 這三個方法主要作用是動態綁定函數內部屬性this 1、apply和call在綁定之后會馬上執行 2、bind在綁定之后可以在需要的時候再調用執行 |
call | 否 | 調用函數自身,以列舉方式接受參數 | ||
bind | 否 | 綁定函數作用域,ES5中新增 | ||
toLocalString | 覆蓋 | 覆蓋了Object類型中的方法,返回函數體 不同瀏覽器實現返回可能不同,可能返回原始代碼,也可能返回去掉注釋后的代碼 |
||
toString | 覆蓋 | |||
valueOf | 覆蓋 | |||
hasOwnProperty | 是 | 直接繼承自Object類型的方法,用法同Object | ||
propertyIsEnumerable | 是 | |||
isPropertyOf | 是 |
函數屬性和方法,除了從Object繼承而來的屬性和方法,也包括函數本身特有的屬性和方法,用的最多的方法自然就是上一小節說的apply()、call(),這兩個方法都是用來設置函數內部屬性this從而擴展函數作用域的,只不過apply()擴展函數作用域時是以(類)數組方式接受函數的參數,而call()擴展函數作用域時需要將函數參數一一列舉出來傳遞,看下面的例子:
function sum(){ var total = 0, l = arguments.length ; for(; l; l--){ total += arguments[l-1]; } return total; } console.info(sum.apply(null,[1,2,3,4]));//10 console.info(sum.call(null,1,2,3,4));//10
不過需要強調的是:apply和call的主要作用還是在於擴展函數作用域。apply和call在擴展作用域時會馬上調用函數,這使得應用中有了很大限制,因此在ES5中新增加了一個bind()函數,這個函數也用於擴展作用域,但是可以不用馬上執行函數,它返回一個函數實例,將傳入給它的第一個參數作為原函數的作用域。它的一個可能的實現如下:
function bind(scope){ var that = this; return function(){ that.apply(scope, arguments); } }
Function.prototype.bind = bind;
這里涉及了一個閉包的概念,明天再繼續。