?
本文檔使用 php中文網手冊 發(fā)布
簡介
對象和面向對象編程
構造函數
new命令
new命令的原理
instanceof運算符
this關鍵字
涵義
使用場合
使用注意點
固定this的方法
call方法
apply方法
bind方法
參考鏈接
“面向對象編程”(Object Oriented Programming,縮寫為OOP)是目前主流的編程范式。它的核心思想是將真實世界中各種復雜的關系,抽象為一個個對象,然后由對象之間的分工與合作,完成對真實世界的模擬。
傳統的計算機程序由一系列函數或一系列指令組成,而面向對象編程的程序由一系列對象組成。每一個對象都是功能中心,具有明確分工,可以完成接受信息、處理數據、發(fā)出信息等任務。因此,面向對象編程具有靈活性、代碼的可重用性、模塊性等特點,容易維護和開發(fā),非常適合多人合作的大型軟件項目。
那么,“對象”(object)到底是什么?
我們從兩個層次來理解。
(1)“對象”是單個實物的抽象。
一本書、一輛汽車、一個人都可以是“對象”,一個數據庫、一張網頁、一個與遠程服務器的連接也可以是“對象”。當實物被抽象成“對象”,實物之間的關系就變成了“對象”之間的關系,從而就可以模擬現實情況,針對“對象”進行編程。
(2)“對象”是一個容器,封裝了“屬性”(property)和“方法”(method)。
所謂“屬性”,就是對象的狀態(tài);所謂“方法”,就是對象的行為(完成某種任務)。比如,我們可以把動物抽象為animal對象,“屬性”記錄具體是那一種動物,“方法”表示動物的某種行為(奔跑、捕獵、休息等等)。
雖然不同于傳統的面向對象編程語言,但是JavaScript具有很強的面向對象編程能力。本章介紹JavaScript如何進行“面向對象編程”。
“面向對象編程”的第一步,就是要生成對象。
前面說過,“對象”是單個實物的抽象。所以,通常需要一個模板,表示某一類實物的共同特征,然后“對象”根據這個模板生成。
典型的面向對象編程語言(比如C++和Java),存在“類”(class)這樣一個概念。所謂“類”就是對象的模板,對象就是“類”的實例。JavaScript語言沒有“類”,而改用構造函數(constructor)作為對象的模板。
所謂“構造函數”,就是專門用來生成“對象”的函數。它提供模板,作為對象的基本結構。一個構造函數,可以生成多個對象,這些對象都有相同的結構。
構造函數是一個正常的函數,但是它的特征和用法與普通函數不一樣。下面就是一個構造函數:
var Vehicle = function() { this.price = 1000; };
上面代碼中,Vehicle就是構造函數,它提供模板,用來生成車輛對象。
構造函數的最大特點就是,函數體內部使用了this關鍵字,代表了所要生成的對象實例。生成對象的時候,必需用new命令,調用Vehicle函數。
new命令的作用,就是執(zhí)行構造函數,返回一個實例對象。
var Vehicle = function (){ this.price = 1000; }; var v = new Vehicle(); v.price // 1000
上面代碼通過new命令,讓構造函數Vehicle生成一個實例對象,保存在變量v中。這個新生成的實例對象,從構造函數Vehicle繼承了price屬性。在new命令執(zhí)行時,構造函數內部的this,就代表了新生成的實例對象,this.price表示實例對象有一個price屬性,它的值是1000。
使用new命令時,根據需要,構造函數也可以接受參數。
var Vehicle = function (p){ this.price = p; }; var v = new Vehicle(500);
new命令本身就可以執(zhí)行構造函數,所以后面的構造函數可以帶括號,也可以不帶括號。下面兩行代碼是等價的。
var v = new Vehicle(); var v = new Vehicle;
一個很自然的問題是,如果忘了使用new命令,直接調用構造函數會發(fā)生什么事?
這種情況下,構造函數就變成了普通函數,并不會生成實例對象。而且由于下面會說到的原因,this這時代表全局對象,將造成一些意想不到的結果。
var Vehicle = function (){ this.price = 1000; }; var v = Vehicle(); v.price // Uncaught TypeError: Cannot read property 'price' of undefined price // 1000
上面代碼中,調用Vehicle構造函數時,忘了加上new命令。結果,price屬性變成了全局變量,而變量v變成了undefined。
因此,應該非常小心,避免出現不使用new命令、直接調用構造函數的情況。為了保證構造函數必須與new命令一起使用,一個解決辦法是,在構造函數內部使用嚴格模式,即第一行加上use strict
。
function Fubar(foo, bar){ "use strict"; this._foo = foo; this._bar = bar; } Fubar() / TypeError: Cannot set property '_foo' of undefined
上面代碼的Fubar為構造函數,use strict命令保證了該函數在嚴格模式下運行。由于在嚴格模式中,函數內部的this不能指向全局對象,默認等于undefined,導致不加new調用會報錯(JavaScript不允許對undefined添加屬性)。
另一個解決辦法,是在構造函數內部判斷是否使用new命令,如果發(fā)現沒有使用,則直接返回一個實例對象。
function Fubar(foo, bar){ if (!(this instanceof Fubar)) { return new Fubar(foo, bar); } this._foo = foo; this._bar = bar; } Fubar(1, 2)._foo // 1 (new Fubar(1, 2))._foo // 1
上面代碼中的構造函數,不管加不加new命令,都會得到同樣的結果。
使用new命令時,它后面的函數調用就不是正常的調用,而是被new命令控制了。內部的流程是,先創(chuàng)造一個空對象,作為上下文對象,賦值給函數內部的this關鍵字。也就是說,this指的是一個新生成的空對象,所有針對this的操作,都會發(fā)生在這個空對象上。
構造函數之所以叫“構造函數”,就是說這個函數的目的,就是操作上下文對象(即this對象),將其“構造”為需要的樣子。如果構造函數的return語句返回的是對象,new命令會返回return語句指定的對象;否則,就會不管return語句,返回構造后的上下文對象。
var Vehicle = function (){ this.price = 1000; return 1000; }; (new Vehicle()) === 1000 // false
上面代碼中,Vehicle是一個構造函數,它的return語句返回一個數值。這時,new命令就會忽略這個return語句,返回“構造”后的this對象。
但是,如果return語句返回的是一個跟this無關的新對象,new命令會返回這個新對象,而不是this對象。這一點需要特別引起注意。
var Vehicle = function (){ this.price = 1000; return { price: 2000 }; }; (new Vehicle()).price // 2000
上面代碼中,構造函數Vehicle的return語句,返回的是一個新對象。new命令會返回這個對象,而不是this對象。
new命令簡化的內部流程,可以用下面的代碼表示。
function _new(/* constructor, param, ... */) { var args = [].slice.call(arguments); var constructor = args.shift(); var context = Object.create(constructor.prototype); var result = constructor.apply(context, args); return (typeof result === 'object' && result != null) ? result : context; } var actor = _new(Person, "張三", 28);
instanceof運算符用來確定一個對象是否為某個構造函數的實例。
var v = new Vehicle(); v instanceof Vehicle // true
instanceof運算符的左邊放置對象,右邊放置構造函數。在JavaScript之中,只要是對象,就有對應的構造函數。因此,instanceof運算符可以用來判斷值的類型。
[1, 2, 3] instanceof Array // true ({}) instanceof Object // true
上面代碼表示數組和對象則分別是Array對象和Object對象的實例。最后那一行的空對象外面,之所以要加括號,是因為如果不加,JavaScript引擎會把一對大括號解釋為一個代碼塊,而不是一個對象,從而導致這一行代碼被解釋為“{}; instanceof Object”,引擎就會報錯。
需要注意的是,由于原始類型的值不是對象,所以不能使用instanceof運算符判斷類型。
"" instanceof String // false 1 instanceof Number // false
上面代碼中,字符串不是String對象的實例(因為字符串不是對象),數值1也不是Number對象的實例(因為數值1不是對象)。
如果存在繼承關系,也就是某個對象可能是多個構造函數的實例,那么instanceof運算符對這些構造函數都返回true。
var a = []; a instanceof Array // true a instanceof Object // true
上面代碼表示,a是一個數組,所以它是Array的實例;同時,a也是一個對象,所以它也是Object的實例。
利用instanceof運算符,還可以巧妙地解決,調用構造函數時,忘了加new命令的問題。
function Fubar (foo, bar) { if (this instanceof Fubar) { this._foo = foo; this._bar = bar; } else return new Fubar(foo, bar); }
上面代碼使用instanceof運算符,在函數體內部判斷this關鍵字是否為構造函數Fubar的實例。如果不是,就表明忘了加new命令。
構造函數內部需要用到this關鍵字。那么,this關鍵字到底是什么意思呢?
簡單說,this就是指函數當前的運行環(huán)境。在JavaScript語言之中,所有函數都是在某個運行環(huán)境之中運行,this就是這個環(huán)境。對于JavaScipt語言來說,一切皆對象,運行環(huán)境也是對象,所以可以理解成,所有函數總是在某個對象之中運行,this就指向這個對象。這本來并不會讓用戶糊涂,但是JavaScript支持運行環(huán)境動態(tài)切換,也就是說,this的指向是動態(tài)的,沒有辦法事先確定到底指向哪個對象,這才是最讓初學者感到困惑的地方。
舉例來說,有一個函數f,它同時充當a對象和b對象的方法。JavaScript允許函數f的運行環(huán)境動態(tài)切換,即一會屬于a對象,一會屬于b對象,這就要靠this關鍵字來辦到。
function f(){ console.log(this.x); }; var a = {x:'a'}; a.m = f; var b = {x:'b'}; b.m = f; a.m() // a b.m() // b
上面代碼中,函數f可以打印出當前運行環(huán)境中x變量的值。當f屬于a對象時,this指向a;當f屬于b對象時,this指向b,因此打印出了不同的值。由于this的指向可變,所以可以手動切換運行環(huán)境,以達到某種特定的目的。
前面說過,所謂“運行環(huán)境”就是對象,this指函數運行時所在的那個對象。如果一個函數在全局環(huán)境中運行,this就是指頂層對象(瀏覽器中為window對象);如果一個函數作為某個對象的方法運行,this就是指那個對象。
可以近似地認為,this是所有函數運行時的一個隱藏參數,決定了函數的運行環(huán)境。
this的使用可以分成以下幾個場合。
(1)全局環(huán)境
在全局環(huán)境使用this,它指的就是頂層對象window。
this === window // true function f() { console.log(this === window); // true }
上面代碼說明,不管是不是在函數內部,只要是在全局環(huán)境下運行,this就是指全局對象window。
(2)構造函數
構造函數中的this,指的是實例對象。
var O = function(p) { this.p = p; }; O.prototype.m = function() { return this.p; };
上面代碼定義了一個構造函數O。由于this指向實例對象,所以在構造函數內部定義this.p,就相當于定義實例對象有一個p屬性;然后m方法可以返回這個p屬性。
var o = new O("Hello World!"); o.p // "Hello World!" o.m() // "Hello World!"
(3)對象的方法
當a對象的方法被賦予b對象,該方法就變成了普通函數,其中的this就從指向a對象變成了指向b對象。這就是this取決于運行時所在的對象的含義,所以要特別小心。如果將某個對象的方法賦值給另一個對象,會改變this的指向。
var o1 = new Object(); o1.m = 1; o1.f = function (){ console.log(this.m);}; o1.f() // 1 var o2 = new Object(); o2.m = 2; o2.f = o1.f o2.f() // 2
從上面代碼可以看到,f是o1的方法,但是如果在o2上面調用這個方法,f方法中的this就會指向o2。這就說明JavaScript函數的運行環(huán)境完全是動態(tài)綁定的,可以在運行時切換。
如果不想改變this的指向,可以將o2.f改寫成下面這樣。
o2.f = function (){ o1.f() }; o2.f() // 1
上面代碼表示,由于f方法這時是在o1下面運行,所以this就指向o1。
有時,某個方法位于多層對象的內部,這時如果為了簡化書寫,把該方法賦值給一個變量,往往會得到意想不到的結果。
var a = { b : { m : function() { console.log(this.p); }, p : 'Hello' } }; var hello = a.b.m; hello() // undefined
上面代碼表示,m屬于多層對象內部的一個方法。為求簡寫,將其賦值給hello變量,結果調用時,this指向了全局對象。為了避免這個問題,可以只將m所在的對象賦值給hello,這樣調用時,this的指向就不會變。
var hello = a.b; hello.m() // Hello
(4)Node.js
在Node.js中,this的指向又分成兩種情況。全局環(huán)境中,this指向全局對象global;模塊環(huán)境中,this指向module.exports。
// 全局環(huán)境this === global // true // 模塊環(huán)境this === module.exports // true
(1)避免多層this
由于this的指向是不確定的,所以切勿在函數中包含多層的this。
var o = { f1: function() { console.log(this); var f2 = function() { console.log(this); }(); } } o.f1() // Object // Window
上面代碼包含兩層this,結果運行后,第一層指向該對象,第二層指向全局對象。一個解決方法是在第二層改用一個指向外層this的變量。
var o = { f1: function() { console.log(this); var that = this; var f2 = function() { console.log(that); }(); } } o.f1() // Object // Object
上面代碼定義了變量that,固定指向外層的this,然后在內層使用that,就不會發(fā)生this指向的改變。
(2)避免數組處理方法中的this
數組的map和foreach方法,允許提供一個函數作為參數。這個函數內部不應該使用this。
var o = { v: 'hello', p: [ 'a1', 'a2' ], f: function f() { this.p.forEach(function (item) { console.log(this.v+' '+item); }); } } o.f() // undefined a1 // undefined a2
上面代碼中,foreach方法的參數函數中的this,其實是指向window對象,因此取不到o.v的值。
解決這個問題的一種方法,是使用中間變量。
var o = { v: 'hello', p: [ 'a1', 'a2' ], f: function f() { var that = this; this.p.forEach(function (item) { console.log(that.v+' '+item); }); } } o.f() // hello a1 // hello a2
另一種方法是將this當作foreach方法的第二個參數,固定它的運行環(huán)境。
var o = { v: 'hello', p: [ 'a1', 'a2' ], f: function f() { this.p.forEach(function (item) { console.log(this.v+' '+item); }, this); } } o.f() // hello a1 // hello a2
(3)避免回調函數中的this
回調函數中的this往往會改變指向,最好避免使用。
var o = new Object(); o.f = function (){ console.log(this === o); } o.f() // true
上面代碼表示,如果調用o對象的f方法,其中的this就是指向o對象。
但是,如果將f方法指定給某個按鈕的click事件,this的指向就變了。
$("#button").on("click", o.f);
點擊按鈕以后,控制臺會顯示false。原因是此時this不再指向o對象,而是指向按鈕的DOM對象,因為f方法是在按鈕對象的環(huán)境中被調用的。這種細微的差別,很容易在編程中忽視,導致難以察覺的錯誤。
為了解決這個問題,可以采用下面的一些方法對this進行綁定,也就是使得this固定指向某個對象,減少不確定性。
this的動態(tài)切換,固然為JavaScript創(chuàng)造了巨大的靈活性,但也使得編程變得困難和模糊。有時,需要把this固定下來,避免出現意想不到的情況。JavaScript提供了call、apply、bind這三個方法,來切換/固定this的指向。
函數的call方法,可以指定該函數內部this的指向(即函數執(zhí)行時所在的作用域),然后在所指定的作用域中,調用該函數。
var o = {}; var f = function (){ return this; }; f() === this // true f.call(o) === o // true
上面代碼中,在全局環(huán)境運行函數f時,this指向全局環(huán)境;call方法可以改變this的指向,指定this指向對象o,然后在對象o的作用域中運行函數f。
再看一個例子。
var n = 123; var o = { n : 456 }; function a() { console.log(this.n); } a.call() // 123 a.call(null) // 123 a.call(undefined) // 123 a.call(window) // 123 a.call(o) // 456
上面代碼中,a函數中的this關鍵字,如果指向全局對象,返回結果為123。如果使用call方法將this關鍵字指向o對象,返回結果為456??梢钥吹?,如果call方法沒有參數,或者參數為null或undefined,則等同于指向全局對象。
call方法的完整使用格式如下。
func.call(thisValue, arg1, arg2, ...)
它的第一個參數就是this所要指向的那個對象,后面的參數則是函數調用時所需的參數。
function add(a,b) { return a+b; } add.call(this,1,2) // 3
上面代碼中,call方法指定函數add在當前環(huán)境(對象)中運行,并且參數為1和2,因此函數add運行后得到3。
call方法的一個應用是調用對象的原生方法。
var obj = {}; obj.hasOwnProperty('toString') // false obj.hasOwnProperty = function (){ return true; }; obj.hasOwnProperty('toString') // true Object.prototype.hasOwnProperty.call(obj, 'toString') // false
上面代碼中,hasOwnProperty是obj對象繼承的方法,如果這個方法一旦被覆蓋,就不會得到正確結果。call方法可以解決這個方法,它將hasOwnProperty方法的原始定義放到obj對象上執(zhí)行,這樣無論obj上有沒有同名方法,都不會影響結果。
apply方法的作用與call方法類似,也是改變this指向,然后再調用該函數。唯一的區(qū)別就是,它接收一個數組作為函數執(zhí)行時的參數,使用格式如下。
func.apply(thisValue, [arg1, arg2, ...])
apply方法的第一個參數也是this所要指向的那個對象,如果設為null或undefined,則等同于指定全局對象。第二個參數則是一個數組,該數組的所有成員依次作為參數,傳入原函數。原函數的參數,在call方法中必須一個個添加,但是在apply方法中,必須以數組形式添加。
請看下面的例子。
function f(x,y){ console.log(x+y); } f.call(null,1,1) // 2 f.apply(null,[1,1]) // 2
上面的f函數本來接受兩個參數,使用apply方法以后,就變成可以接受一個數組作為參數。
利用這一點,可以做一些有趣的應用。
(1)找出數組最大元素
JavaScript不提供找出數組最大元素的函數。結合使用apply方法和Math.max方法,就可以返回數組的最大元素。
var a = [10, 2, 4, 15, 9]; Math.max.apply(null, a) // 15
(2)將數組的空元素變?yōu)閡ndefined
通過apply方法,利用Array構造函數將數組的空元素變成undefined。
Array.apply(null, ["a",,"b"]) // [ 'a', undefined, 'b' ]
空元素與undefined的差別在于,數組的foreach方法會跳過空元素,但是不會跳過undefined。因此,遍歷內部元素的時候,會得到不同的結果。
var a = ["a",,"b"]; function print(i) { console.log(i); } a.forEach(print) // a // b Array.apply(null,a).forEach(print) // a // undefined // b
(3)轉換類似數組的對象
另外,利用數組對象的slice方法,可以將一個類似數組的對象(比如arguments對象)轉為真正的數組。
Array.prototype.slice.apply({0:1,length:1}) // [1] Array.prototype.slice.apply({0:1}) // [] Array.prototype.slice.apply({0:1,length:2}) // [1, undefined] Array.prototype.slice.apply({length:1}) // [undefined]
上面代碼的apply方法的參數都是對象,但是返回結果都是數組,這就起到了將對象轉成數組的目的。從上面代碼可以看到,這個方法起作用的前提是,被處理的對象必須有l(wèi)ength屬性,以及相對應的數字鍵。
(4)綁定回調函數的對象
上一節(jié)按鈕點擊事件的例子,可以改寫成
var o = new Object(); o.f = function (){ console.log(this === o); } var f = function (){ o.f.apply(o); // 或者 o.f.call(o); }; $("#button").on("click", f);
點擊按鈕以后,控制臺將會顯示true。由于apply方法(或者call方法)不僅綁定函數執(zhí)行時所在的對象,還會立即執(zhí)行函數,因此不得不把綁定語句寫在一個函數體內。更簡潔的寫法是采用下面介紹的bind方法。
bind方法用于將函數體內的this綁定到某個對象,然后返回一個新函數。它的使用格式如下。
func.bind(thisValue, arg1, arg2,...)
下面是一個例子。
var o1 = new Object(); o1.p = 123; o1.m = function (){ console.log(this.p); }; o1.m() // 123 var o2 = new Object(); o2.p = 456; o2.m = o1.m; o2.m() // 456 o2.m = o1.m.bind(o1); o2.m() // 123
上面代碼使用bind方法將o1.m方法綁定到o1以后,在o2對象上調用o1.m的時候,o1.m函數體內部的this.p就不再到o2對象去尋找p屬性的值了。
bind比call方法和apply方法更進一步的是,除了綁定this以外,還可以綁定原函數的參數。
var add = function (x,y) { return x*this.m + y*this.n; } var obj = { m: 2, n: 2 }; var newAdd = add.bind(obj, 5); newAdd(5) // 20
上面代碼中,bind方法除了綁定this對象,還綁定了add函數的第一個參數,結果newAdd函數只要一個參數就能運行了。
如果bind方法的第一個參數是null或undefined,等于將this綁定到全局對象,函數運行時this指向全局對象(在瀏覽器中為window)。
function add(x,y) { return x+y; } var plus5 = add.bind(null, 5); plus5(10) // 15
上面代碼除了將add函數的運行環(huán)境綁定為全局對象,還將add函數的第一個參數綁定為5,然后返回一個新函數。以后,每次運行這個新函數,就只需要提供另一個參數就夠了。
bind方法有一些使用注意點。
(1)每一次返回一個新函數
bind方法每運行一次,就返回一個新函數,這會產生一些問題。比如,監(jiān)聽事件的時候,不能寫成下面這樣。
element.addEventListener('click', o.m.bind(o));
上面代碼表示,click事件綁定bind方法生成的一個匿名函數。這樣會導致無法取消綁定,所以,下面的代碼是無效的。
element.removeEventListener('click', o.m.bind(o));
正確的方法是寫成下面這樣:
var listener = o.m.bind(o); element.addEventListener('click', listener); // ... element.removeEventListener('click', listener);
(2)bind方法的自定義代碼
對于那些不支持bind方法的老式瀏覽器,可以自行定義bind方法。
if(!('bind' in Function.prototype)){ Function.prototype.bind = function(){ var fn = this; var context = arguments[0]; var args = Array.prototype.slice.call(arguments, 1); return function(){ return fn.apply(context, args); } } }
(3)jQuery的proxy方法
除了用bind方法綁定函數運行時所在的對象,還可以使用jQuery的$.proxy方法,它與bind方法的作用基本相同。
$("#button").on("click", $.proxy(o.f, o));
上面代碼表示,$.proxy方法將o.f方法綁定到o對象。
(4)結合call方法使用
利用bind方法,可以改寫一些JavaScript原生方法的使用形式,以數組的slice方法為例。
[1,2,3].slice(0,1) // [1] // 等同于 Array.prototype.slice.call([1,2,3], 0, 1) // [1]
上面的代碼中,數組的slice方法從[1, 2, 3]里面,按照指定位置和長度切分出另一個數組。這樣做的本質是在[1, 2, 3]上面調用Array.prototype.slice方法,因此可以用call方法表達這個過程,得到同樣的結果。
call方法實質上是調用Function.prototype.call方法,因此上面的表達式可以用bind方法改寫。
var slice = Function.prototype.call.bind(Array.prototype.slice); slice([1, 2, 3], 0, 1) // [1]
可以看到,利用bind方法,將[1, 2, 3].slice(0, 1)變成了slice([1, 2, 3], 0, 1)的形式。這種形式的改變還可以用于其他數組方法。
var push = Function.prototype.call.bind(Array.prototype.push); var pop = Function.prototype.call.bind(Array.prototype.pop); var a = [1 ,2 ,3]; push(a, 4) a // [1, 2, 3, 4] pop(a) a // [1, 2, 3]
如果再進一步,將Function.prototype.call方法綁定到Function.prototype.bind對象,就意味著bind的調用形式也可以被改寫。
function f(){ console.log(this.v); } var o = { v: 123 }; var bind = Function.prototype.call.bind(Function.prototype.bind); bind(f,o)() // 123
上面代碼表示,將Function.prototype.call方法綁定Function.prototype.bind以后,bind方法的使用形式從f.bind(o),變成了bind(f, o)。
Jonathan Creamer,
Erik Kronberg,
Axel Rauschmayer,