JavaScript 面向對象編程
前言
面向對象編程(Object Oriented Programming,縮寫為 OOP)是目前主流的編程范式。它將真實世界各種復雜的關系,抽象為一個個對象,然后由對象之間的分工與合作,完成對真實世界的模擬。
每一個對象都是功能中心,具有明確分工,可以完成接受信息、處理數據、發出信息等任務。對象可以復用,通過繼承機制還可以定制。因此,面向對象編程具有靈活、代碼可復用、高度模塊化等特點,容易維護和開發,比起由一系列函數或指令組成的傳統的過程式編程(procedural programming),更適合多人合作的大型軟件項目。
那么,“對象”(object)到底是什么?我們從兩個層次來理解。
(1)對象是單個實物的抽象。
一本書、一輛汽車、一個人都可以是對象,一個數據庫、一張網頁、一個與遠程服務器的連接也可以是對象。當實物被抽象成對象,實物之間的關系就變成了對象之間的關系,從而就可以模擬現實情況,針對對象進行編程。
(2)對象是一個容器,封裝了屬性(property)和方法(method)。
屬性是對象的狀態,方法是對象的行為(完成某種任務)。比如,我們可以把動物抽象為animal對象,使用“屬性”記錄具體是那一種動物,使用“方法”表示動物的某種行為(奔跑、捕獵、休息等等)。
構造函數創建對象
想要了解對象,我們先來學習如何創建一個對象,首先是通過構造函數的形式來創建一個對象。
一般情況下,我們可以將現實生活當中的實物抽象成對象。而想要抽象成對象,我們通常情況下需要一個模板,這個模板當中具備這類
實物的公有特性,然后我們就可以通過這個模板來實現該類對象的創建。
在js中,我們就可以通過構造函數來創建這類模板。
構造函數是用new創建對象時調用的函數,與普通唯一的區別是構造函數名應該首字母大寫。
function Person(){
this.age = 30;
}
var person1 = new Person();
console.log(person1.age);//30
根據需要,構造函數可以接受參數:
function Person(age){
this.age = age;
}
var person1 = new Person(30);
console.log(person1.age);//30
如果沒有參數,可以省略括號
function Person(){
this.age = 30;
}
//等價於var person1 = new Person()
var person1 = new Person;
console.log(person1.age);//30
如果忘記使用new操作符,則this將代表全局對象window。通常這種情況下會容易發生很多錯誤。
一定要小心。
function Person(){
this.age = 30;
}
var person1 = Person();
//Uncaught TypeError: Cannot read property 'age' of undefined
console.log(person1.age);
instanceof
當我們通過構造函數創建了一個對象之后,我們就可以通過instanceof來判斷對象的類型以及當前對象是否是通過指定構造函數構建而成。
function Person(){
//
}
var person1 = new Person;
console.log(person1 instanceof Person);//true
constructor
每個對象在創建時都自動擁有一個構造函數屬性constructor,其中包含了一個指向其構造函數的引用。而這個constructor屬性實際上繼承自原型對象,而constructor也是原型對象唯一的自有屬性
function Person(){
//
}
var person1 = new Person;
console.log(person1.constructor === Person);//true
console.log(person1.__proto__.constructor === Person);//true
通過打印person1,你會發現,constructor 是一個繼承的屬性。
雖然對象實例及其構造函數之間存在這樣的關系,但是還是建議使用instanceof來檢查對象類型。這是因為構造函數屬性可以被覆蓋,並不一定完全准確
function Person(){
//
}
var person1 = new Person;
Person.prototype.constructor = 123;
console.log(person1.constructor);//123
console.log(person1.__proto__.constructor);//123
返回值
函數中的return語句用來返回函數調用后的返回值,而new構造函數的返回值有點特殊
如果構造函數使用return語句但沒有指定返回值,或者返回一個原始值,那么這時將忽略返回值,同時使用這個新對象作為調用結果
function fn(){
this.a = 2;
return;
}
var test = new fn();
console.log(test);//{a:2}
如果構造函數顯式地使用return語句返回一個對象,那么調用表達式的值就是這個對象
var obj = {a:1};
function fn(){
this.a = 2;
return obj;
}
var test = new fn();
console.log(test);//{a:1}
所以,針對丟失new的構造函數的解決辦法是在構造函數內部使用instanceof判斷是否使用new命令,如果發現沒有使用,則直接使用return語句返回一個實例對象
function Person(){
if(!(this instanceof Person)){
return new Person();
}
this.age = 30;
}
var person1 = Person();
console.log(person1.age);//30
var person2 = new Person();
console.log(person2.age);//30
使用構造函數的好處在於所有用同一個構造函數創建的對象都具有同樣的屬性和方法
function Person(name){
this.name = name;
this.sayName = function(){
console.log(this.name);
}
}
var person1 = new Person('bai');
var person2 = new Person('hu');
person1.sayName();//'bai'
構造函數允許給對象配置同樣的屬性,但是構造函數並沒有消除代碼冗余。使用構造函數的主要問題是每個方法都要在每個實例上重新創建一遍。在上面的例子中,每一個對象都有自己的sayName()方法。這意味着如果有100個對象實例,就有100個函數做相同的事情,只是使用的數據不同。
function Person(name){
this.name = name;
this.sayName = function(){
console.log(this.name);
}
}
var person1 = new Person('bai');
var person2 = new Person('hu');
console.log(person1.sayName === person2.sayName);//false
可以通過把函數定義轉換到構造函數外部來解決問題
function Person(name){
this.name = name;
this.sayName = sayName;
}
function sayName(){
console.log(this.name);
}
var person1 = new Person('bai');
var person2 = new Person('hu');
console.log(person1.sayName === person2.sayName);//true
但是,在全局作用域中定義的函數實際上只能被某個對象調用,這讓全局作用域有點名不副實。而且,如果對象需要定義很多方法,就要定義很多全局函數,嚴重污染全局空間,這個自定義的引用類型沒有封裝性可言了
如果所有的對象實例共享同一個方法會更有效率,這就需要用到下面所說的原型對象 。
原型對象
說起原型對象,就要說到原型對象、實例對象和構造函數的三角關系 。
例如:
function Foo(){};
var f1 = new Foo;
構造函數
用來初始化新創建的對象的函數是構造函數。在例子中,Foo()函數是構造函數
實例對象
通過構造函數的new操作創建的對象是實例對象,又常常被稱為對象實例。可以用一個構造函數,構造多個實例對象。下面的f1和f2就是實例對象
function Foo(){};
var f1 = new Foo;
var f2 = new Foo;
console.log(f1 === f2);//false
原型對象及prototype
通過構造函數的new操作創建實例對象后,會自動為構造函數創建prototype屬性,該屬性指向實例對象的原型對象。通過同一個構造函數實例化的多個對象具有相同的原型對象。下面的例子中,Foo.prototype是原型對象
function Foo(){};
Foo.prototype.a = 1;
var f1 = new Foo;
var f2 = new Foo;
console.log(Foo.prototype.a);//1
console.log(f1.a);//1
console.log(f2.a);//1
proto
實例對象內部包含一個proto屬性(IE10-瀏覽器不支持該屬性),指向該實例對象對應的原型對象
function Foo(){};
var f1 = new Foo;
console.log(f1.__proto__ === Foo.prototype);//true
isPrototypeOf
一般地,可以通過isPrototypeOf()方法來確定對象之間是否是實例對象和原型對象的關系
function Foo(){};
var f1 = new Foo;
console.log(f1.__proto__ === Foo.prototype);//true
console.log(Foo.prototype.isPrototypeOf(f1));//true
Object.getPrototypeOf()
ES5新增了Object.getPrototypeOf()方法,該方法返回實例對象對應的原型對象
function Foo(){};
var f1 = new Foo;
console.log(Object.getPrototypeOf(f1) === Foo.prototype);//true
實際上,Object.getPrototypeOf()方法和proto屬性是一回事,都指向原型對象
function Foo(){};
var f1 = new Foo;
console.log(Object.getPrototypeOf(f1) === f1.__proto__ );//true
關於對象的屬性查找
當讀取一個對象的屬性時,javascript引擎首先在該對象的自有屬性中查找屬性名字。如果找到則返回。如果自有屬性不包含該名字,則javascript會搜索proto中的對象。如果找到則返回。如果找不到,則返回undefined
var o = {};
console.log(o.toString());//'[object Object]'
o.toString = function(){
return 'o';
}
console.log(o.toString());//'o'
delete o.toString;
console.log(o.toString());//'[objet Object]'
in
in操作符可以判斷屬性在不在該對象上,但無法區別自有還是繼承屬性
var o = {a:1};
var obj = Object.create(o);
obj.b = 2;
console.log('a' in obj);//true
console.log('b' in obj);//true
console.log('b' in o);//false
//Object.create()是創建對象的一種方法,等價於
function Test(){};
var obj = new Test;
Test.prototype.a = 1;
obj.b = 2;
console.log('a' in obj);//true
console.log('b' in obj);//true
console.log('b' in Test.prototype);//false
hasOwnProperty()
通過hasOwnProperty()方法可以確定該屬性是自有屬性還是繼承屬性.
var o = {a:1};
var obj = Object.create(o);
obj.b = 2;
console.log(obj.hasOwnProperty('a'));//false
console.log(obj.hasOwnProperty('b'));//true
於是可以將hasOwnProperty方法和in運算符結合起來使用,用來鑒別原型屬性
function hasPrototypeProperty(object,name){
return name in object && !object.hasOwnProperty(name);
}
原型對象的共享機制使得它們成為一次性為所有對象定義方法的理想手段。
可以利用該機制實現完整的面向對象的寫法。
function Person(name){
this.name = name;
}
Person.prototype.sayName = function(){
console.log(this.name);
}
var person1 = new Person('bai');
var person2 = new Person('hu');
person1.sayName();//'bai'
雖然可以在原型對象上一一添加屬性,但是直接用一個對象字面形式替換原型對象更簡潔
function Person(name){
this.name = name;
}
Person.prototype = {
sayName: function(){
console.log(this.name);
},
toString : function(){
return '[person ' + this.name + ']'
}
};
var person1 = new Person('bai');
console.log(person1 instanceof Person);//true
console.log(person1.constructor === Person);//false
console.log(person1.constructor === Object);//true
構造函數、原型對象和實例對象之間的關系是實例對象和構造函數之間沒有直接聯系.
例如:
function Foo(){};
var f1 = new Foo;
以上代碼的原型對象是Foo.prototype,實例對象是f1,構造函數是Foo
JS當中實現繼承
學習如何創建對象是理解面向對象編程的第一步,第二步是理解繼承。開宗明義,繼承是指在原有對象的基礎上,略作修改,得到一個新的對象。javascript主要包括類式繼承、原型繼承和拷貝繼承這三種繼承方式。
類式繼承
大多數面向對象的編程語言都支持類和類繼承的特性,而JS卻不支持這些特性,只能通過其他方法定義並關聯多個相似的對象,如new和instanceof。不過在后來的ES6中新增了一些元素,比如class關鍵字,但這並不意味着javascript中是有類的,class只是構造函數的語法糖而已
類式繼承的主要思路是,通過構造函數實例化對象,通過原型鏈將實例對象關聯起來。
實現類式繼承 - 原型鏈繼承
javascript使用原型鏈作為實現繼承的主要方法,實現的本質是重寫原型對象,代之以一個新類型的實例。下面的代碼中,原來存在於SuperType的實例對象中的屬性和方法,現在也存在於SubType.prototype中了。
function Super(){
this.value = true;
}
Super.prototype.getValue = function(){
return this.value;
};
function Sub(){}
//Sub繼承了Super
Sub.prototype = new Super();
Sub.prototype.constructor = Sub;
var instance = new Sub();
console.log(instance.getValue());//true
原型鏈最主要的問題在於包含引用類型值的原型屬性會被所有實例共享,而這也正是為什么要在構造函數中,而不是在原型對象中定義屬性的原因。在通過原型來實現繼承時,原型實際上會變成另一個類型的實例。於是,原先的實例屬性也就順理成章地變成了現在的原型屬性了
function Super(){
this.colors = ['red','blue','green'];
}
function Sub(){};
//Sub繼承了Super
Sub.prototype = new Super();
var instance1 = new Sub();
instance1.colors.push('black');
console.log(instance1.colors);//'red,blue,green,black'
var instance2 = new Sub();
console.log(instance2.colors);//'red,blue,green,black'
原型鏈的第二個問題是,在創建子類型的實例時, 不能向超類型的構造函數中傳遞參數。實際上,應該說是沒有辦法在不影響所有對象實例的情況下,給超類型的構造函數傳遞參數。再加上包含引用類型值的原型屬性會被所有實例共享的問題,在實踐中很少會單獨使用原型鏈繼承
實現類式繼承 - 借用構造函數
借用構造函數(constructor stealing)的技術(有時候也叫做偽類繼承或經典繼承)。基本思想相當簡單,即在子類型構造函數的內部調用超類型構造函數,通過使用apply()和call()方法在新創建的對象上執行構造函數。
function Super(){
this.colors = ['red','blue','green'];
}
function Sub(){
//繼承了Super
Super.call(this);
}
var instance1 = new Sub();
instance1.colors.push('black');
console.log(instance1.colors);// ['red','blue','green','black']
var instance2 = new Sub();
console.log(instance2.colors);// ['red','blue','green']
相對於原型鏈而言,借用構造函數有一個很大的優勢,即可以在子類型構造函數中向超類型構造函數傳遞參數
function Super(name){
this.name = name;
}
function Sub(){
//繼承了Super,同時還傳遞了參數
Super.call(this,"bai");
//實例屬性
this.age = 29;
}
var instance = new Sub();
console.log(instance.name);//"bai"
console.log(instance.age);//29
但是,如果僅僅是借用構造函數,那么也將無法避免構造函數模式存在的問題——方法都在構造函數中定義,因此函數復用就無從談起了。
實現類式繼承 - 組合繼承
組合繼承(combination inheritance)有時也叫偽經典繼承,指的是將原型鏈和借用構造函數的技術組合到一塊,從而發揮二者之長的一種繼承模式。其背后的思路是使用原型鏈實現對原型屬性和方法的繼承,而通過借用構造函數來實現對實例屬性的繼承。這樣,既通過在原型上定義方法實現了函數復用,又能夠保證每個實例都有它自己的屬性
function Super(name){
this.name = name;
this.colors = ['red','blue','green'];
}
Super.prototype.sayName = function(){
console.log(this.name);
};
function Sub(name,age){
//繼承屬性
Super.call(this,name);
this.age = age;
}
//繼承方法
Sub.prototype = new Super();
Sub.prototype.constructor = Sub;
Sub.prototype.sayAge = function(){
console.log(this.age);
}
var instance1 = new Sub("bai",29);
instance1.colors.push("black");
console.log(instance1.colors);//['red','blue','green','black']
instance1.sayName();//"bai"
instance1.sayAge();//29
var instance2 = new Sub("hu",27);
console.log(instance2.colors);//['red','blue','green']
instance2.sayName();//"hu"
instance2.sayAge();//27
組合繼承有它自己的問題。那就是無論什么情況下,都會調用兩次父類型構造函數:一次是在創建子類型原型的時候,另一次是在子類型構造函數內部。子類型最終會包含父類型對象的全部實例屬性,但不得不在調用子類型構造函數時重寫這些屬性
function Super(name){
this.name = name;
this.colors = ["red","blue","green"];
}
Super.prototype.sayName = function(){
return this.name;
};
function Sub(name,age){
// 第二次調用Super(),Sub.prototype又得到了name和colors兩個屬性,並對上次得到的屬性值進行了覆蓋
Super.call(this,name);
this.age = age;
}
//第一次調用Super(),Sub.prototype得到了name和colors兩個屬性
Sub.prototype = new Super();
Sub.prototype.constructor = Sub;
Sub.prototype.sayAge = function(){
return this.age;
};
實現類式繼承 - 寄生組合繼承
解決兩次調用的方法是使用寄生組合式繼承。寄生組合式繼承與組合繼承相似,都是通過借用構造函數來繼承不可共享的屬性,通過原型鏈的混成形式來繼承方法和可共享的屬性。只不過把原型繼承的形式變成了寄生式繼承。使用寄生組合式繼承可以不必為了指定子類型的原型而調用父類型的構造函數,從而寄生式繼承只繼承了父類型的原型屬性,而父類型的實例屬性是通過借用構造函數的方式來得到的
function Super(name){
this.name = name;
this.colors = ["red","blue","green"];
}
Super.prototype.sayName = function(){
return this.name;
};
function Sub(name,age){
Super.call(this,name);
this.age = age;
}
if(!Object.create){
Object.create = function(proto){
function F(){};
F.prototype = proto;
return new F;
}
}
Sub.prototype = Object.create(Super.prototype);
Sub.prototype.constructor = Sub;
var instance1 = new Sub("bai",29);
instance1.colors.push("black");
console.log(instance1.colors);//['red','blue','green','black']
instance1.sayName();//"bai"
var instance2 = new Sub("hu",27);
console.log(instance2.colors);//['red','blue','green']
instance2.sayName();//"hu"
這個例子的高效率體現在它只調用了一次Super構造函數,並且因此避免了在Sub.prototype上面創建不必要的、多余的屬性。與此同時,原型鏈還保持不變
因此,開發人員普遍認為寄生組合式繼承是引用類型最理想的繼承范式,YUI的YAHOO.lang.extend()方法就采用了這種繼承模式
類式繼承 -- ES6 class
如果使用ES6中的class語法,則上面代碼修改如下
class Super {
constructor(name){
this.name = name;
this.colors = ["red","blue","green"];
}
sayName(){
return this.name;
}
}
class Sub extends Super{
constructor(name,age){
super(name);
this.age = age;
}
}
var instance1 = new Sub("bai",29);
instance1.colors.push("black");
console.log(instance1.colors);//['red','blue','green','black']
instance1.sayName();//"bai"
var instance2 = new Sub("hu",27);
console.log(instance2.colors);//['red','blue','green']
instance2.sayName();//"hu"
ES6的class語法糖隱藏了許多技術細節,在實現同樣功能的前提下,代碼卻優雅不少
使用原型繼承
原型繼承
原型繼承,在《你不知道的javascript》中被翻譯為委托繼承
道格拉斯·克羅克福德(Douglas Crockford)在2006年寫了一篇文章,《javascript中的原型式繼承》。在這篇文章中,他介紹了一種實現繼承的方式,這種方式並沒有使用嚴格意義上的構造函數。他的想法是借助原型可以基於已有的對象來創建新對象,同時不必因此創建自定義類型
原型繼承的基礎函數如下所示:
function object(o){
function F(){};
F.prototype = o;
return new F();
}
在object()函數內部,先創建了一個臨時性的構造函數,然后將傳入的對象作為這個構造函數的原型,最后返回了這個臨時類型的一個新實例。從本質上講,object()對傳入其中的對象執行了一次淺復制
例如:
var superObj = {
init: function(value){
this.value = value;
},
getValue: function(){
return this.value;
}
}
var subObj = object(superObj);
subObj.init('sub');
console.log(subObj.getValue());//'sub'
ES5通過新增Object.create()方法規范化了原型式繼承
var superObj = {
init: function(value){
this.value = value;
},
getValue: function(){
return this.value;
}
}
var subObj = Object.create(superObj);
subObj.init('sub');
console.log(subObj.getValue());//'sub'
與原型鏈繼承的關系
原型繼承雖然只是看上去將原型鏈繼承的一些程序性步驟包裹在函數里而已。但是,它們的一個重要區別是父類型的實例對象不再作為子類型的原型對象
1、使用原型鏈繼承
function Super(){
this.value = 1;
}
Super.prototype.value = 0;
function Sub(){};
//將父類型的實例對象作為子類型的原型對象
Sub.prototype = new Super();
Sub.prototype.constructor = Sub;
//創建子類型的實例對象
var instance = new Sub;
console.log(instance.value);//1
2、使用原型繼承
function Super(){
this.value = 1;
}
Super.prototype.value = 0;
function Sub(){};
Sub.prototype = Object.create(Super.prototype);
Sub.prototype.constructor = Sub;
//創建子類型的實例對象
var instance = new Sub;
console.log(instance.value);//0
上面的Object.create函數一行代碼Sub.prototype = Object.create(Super.prototype)可以分解為
function F(){};
F.prototype = Super.prototype;
Sub.prototype = new F();
由上面代碼看出,子類的原型對象是臨時類F的實例對象,而臨時類F的原型對象又指向父類的原型對象;所以,實際上,子類可以繼承父類的原型上的屬性,但不可以繼承父類的實例上的屬性
拷貝繼承
拷貝繼承在《javascript面向對象摘要》中翻譯為混入繼承,jQuery使用的就是拷貝繼承
拷貝繼承不需要改變原型鏈,通過拷貝函數將父例的屬性和方法拷貝到子例即可
拷貝函數
下面是一個深拷貝的拷貝函數
function extend(obj,cloneObj){
if(typeof obj != 'object'){
return false;
}
var cloneObj = cloneObj || {};
for(var i in obj){
if(typeof obj[i] === 'object'){
cloneObj[i] = (obj[i] instanceof Array) ? [] : {};
arguments.callee(obj[i],cloneObj[i]);
}else{
cloneObj[i] = obj[i];
}
}
return cloneObj;
}
var obj1={a:1,b:2,c:[1,2,3]};
var obj2=extend(obj1);
console.log(obj1.c); //[1,2,3]
console.log(obj2.c); //[1,2,3]
obj2.c.push(4);
console.log(obj2.c); //[1,2,3,4]
console.log(obj1.c); //[1,2,3]
對象間的拷貝繼承
由於拷貝繼承解決了引用類型值共享的問題,所以其完全可以脫離構造函數實現對象間的繼承
function extend(obj,cloneObj){
if(typeof obj != 'object'){
return false;
}
var cloneObj = cloneObj || {};
for(var i in obj){
if(typeof obj[i] === 'object'){
cloneObj[i] = (obj[i] instanceof Array) ? [] : {};
arguments.callee(obj[i],cloneObj[i]);
}else{
cloneObj[i] = obj[i];
}
}
return cloneObj;
}
var superObj = {
arrayValue:[1,2,3],
init: function(value){
this.value = value;
},
getValue: function(){
return this.value;
}
}
var subObj = extend(superObj);
subObj.arrayValue.push(4);
console.log(subObj.arrayValue);//[1,2,3,4]
console.log(superObj.arrayValue);//[1,2,3]
使用構造函數的拷貝組合繼承
如果要使用構造函數,則屬性可以使用借用構造函數的方法,而引用類型屬性和方法使用拷貝繼承。相當於不再通過原型鏈來建立對象之間的聯系,而通過復制來得到對象的屬性和方法
function extend(obj,cloneObj){
if(typeof obj != 'object'){
return false;
}
var cloneObj = cloneObj || {};
for(var i in obj){
if(typeof obj[i] === 'object'){
cloneObj[i] = (obj[i] instanceof Array) ? [] : {};
arguments.callee(obj[i],cloneObj[i]);
}else{
cloneObj[i] = obj[i];
}
}
return cloneObj;
}
function Super(name){
this.name = name;
this.colors = ["red","blue","green"];
}
Super.prototype.sayName = function(){
return this.name;
};
function Sub(name,age){
Super.call(this,name);
this.age = age;
}
Sub.prototype = extend(Super.prototype);
var instance1 = new Sub("bai",29);
instance1.colors.push("black");
console.log(instance1.colors);//['red','blue','green','black']
instance1.sayName();//"bai"
var instance2 = new Sub("hu",27);
console.log(instance2.colors);//['red','blue','green']
instance2.sayName();//"hu"
上面介紹了幾種繼承方式,其中最常見的是類式繼承。再加上ES6語法糖的緣故,所以導致更多的人使用。對於
一般開發來說,類式繼承也足以應付。