JavaScript基础入门12 - 面向对象编程

时间:2022-12-20 13:54:06

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语法糖的缘故,所以导致更多的人使用。对于
一般开发来说,类式继承也足以应付。