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
關於範圍和聲明提升,再看一個例子: 複製代碼 代碼如下:var name = 'linjisong';
function fn(){
console.info(name);//undefined
var name = 'oulinhai';
console.info(name);//oulinhai
}
fn();
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的strict 模式下會設為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的strict 模式下,不能對具有動態特性的函數屬性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;
這裡涉及了一個閉包的概念,明天再繼續。