JavaScript 面向对象

JavaScript 面向对象

说到 JavaScript 面向对象,我们就不得不说到构造函数。要说构造函数,就绕不开原型,以及基于原型实现的继承。


构造函数 constructor

JavaScript 的构造函数并不是作为类的一个特定方法存在的。当任意一个普通函数用于创建一类对象时,它就被称作构造函数。依照惯例,我们应该讲构造函数首字母大写,以便显著区的别于其他一般函数。

function Demo(){
    this.name = 'demo';
}
var demo1 = new Demo(); //通过构造函数创建对象

//上面在使用 new 操作符调用构造函数的时候,其实发生下面的四件事
1、var demo1 ={};  
2、demo1.prototype = Demo.prototype; //这是最关键的一步
3、Demo.call(demo1); //将构造函数的作用域赋给新对象,Demo 函数中的 this 指向新对象 demo1
4、return demo1;  

//如果不用 new 操作符,this 将指向全局对象,通常是 window。
var demo2 = Demo(); //undefined。由于函数没有返回值,所以其实际的返回值就是 undefined。

第二步中,将新对象的 prototype 属性赋值为构造函数的 prototype 属性,使得通过构造函数创建的所有对象共享相同的原型,因此它们都是同一个类的对象。 

按照面向对象的习惯性思维,我们可以将构造函数理解为“类”的定义,从而可以通过构造函数成员的访问级别,控制实例对象对类成员的访问权限。

//构造函数
function Demo(){
    //公有属性,实例化后可见
    this.userName = 'hq';
    this.gender= 'male';

    //私有属性
    var age= 18;
    var weight= '50kg';

    //公有方法
    this.getAge = function(){
        return age;
    }

    //私有方法
    function getWeight(){ return weight; }
}

//公有方法,类的 prototype 原型下的方法将会被实例继承
Demo.prototype.sayUserName = function(){
    return 'I am ' + this.userName;
}

//静态属性,是 Demo 类的属性,不是实例属性。但可以通过实例构造函数访问
Demo.country= 'china';

//静态方法,是 Demo 类的方法,不是实例方法。但可以通过实例构造函数访问
Demo.sayHello= function(){
    return 'Hello';
}

//实例化
var demo = new Demo();
demo.userName; //hq
demo.getAge(); //18
demo.age; //undefined
demo.getWeight(); //报错
demo.sayUserName(); //I am hq
//静态属性和方法,可以通过方法名直接访问,或者通过实例的构造函数访问
demo.constructor.sayHello(); //Hello
Demo.sayHello(); //Hello
demo.constructor.country; //china
Demo.country; //china


原型 prototype

每个函数中都有一个 prototype 属性,该属性所存储的就是原型对象。 

原型属性

通过构造函数新建对象,可以访问到对象的 this 值,这样就可以对新建对象添加属性和方法。

function Demo(userName){
    this.userName = 'hq';
}
Demo.prototype.age = 18; //通过原型给类添加属性

var demo = new Demo();
demo.userName; //'hq'
demo.age; //18

当我们访问 demo.age 时,JavaScript 引擎会遍历该对象的所有属性,并查找一个叫 age 的属性,如果未找到,接着去查找用于创建当前对象的构造器函数的原型(等价于直接访问:demo.constructor.age),如果仍未找到,接着去查找对象构造器原型的构造器的原型,直至找到要到的属性。每个对象都有一个构造器,而原型本身也是对象,所以原型也有构造器,而这个构造器又会有自己的原型。这就形成了原型链,并最终链接到 Object 对象。当调用 demo.toString() 是,返回的是 [object Object],是因为 demo 的原型链上,只有 Object 有 toString() 方法。

同名属性优先级:自身属性 > 原型1 > 原型2 > ······

枚举属性

for-in 可以枚举对象的所有属性,但有些细节值得留意:
(1).不是所有的属性都会在 for-in 中显示,如:length、constructor 等属性就不会显示。可以被显示的属性被称为可枚举的,可以通过对象的 propertyIsEnumerable() 方法来判断是否可枚举。
(2).原型链中的可枚举属性也会被展示出来。可以通过 hasOwnProperty() 方法来判断一个属性是对象属性还是原型属性。
(3).所有原型属性,propertyIsEnumerable() 都会返回 false,即使在 for-in 循环中可枚举的属性。大部分内建属性和方法也是不可枚举的。注意:如果 propertyIsEnumerable 的调用者是原型对象,则该原型对象上属性是可枚举的。

//Demo 为上面定义的对象
var demo = new Demo();
for(var e in demo){
    console.log(e + ' : ' + demo[e] + ' , ' + demo.propertyIsEnumerable(e));
}
//结果:
userName : hq , true
age : 18 , false

demo.propertyIsEnumerable('age'); //false,实例对象调用
demo.constructor.prototype.propertyIsEnumerable('age'); //true,原型对象调用

isPrototypeOf()

每个对象都有一个 isPrototypeOf() 方法,该方法可以判断当前对象是否是另一个对象的原型。

var animal = { color : 'black' };
var Cat = function(name){ this.name = name; };
Cat.prototype = animal;

var tomcat = new Cat('Tomcat');
animal.isPrototypeOf(tomcat); //true

__proto__

在 JavaScript 标准中,并没有 __proto__ 这个属性,不过目前主流浏览器都包含这个属性,用于指向构造函数的原型。例如在 IE10 及以前版本的 IE 浏览器都没有 __proto__ 属性,所以如果代码中调用 __proto__ 是不能跨平台的。
需要注意的是,__proto__ 与 prototype 并不等价。__proto__ 是某个实体对象的属性,prototype 是属于构造器函数的属性。

扩展内建对象

在 JavaScript 中,内建对象的构造器函数都是可以通过其原型来进行扩展的。

//利用占位符格式化字符串。
String.prototype.format = function () {
    var s = this, i = arguments.length;

    while (i-- >= 0) {
        s = s.replace(new RegExp('\\{' + i + '\\}', 'gm'), arguments[i]);
    }
    return s;
};
//根据内容删除元素(第一个)
Array.prototype.remove = function (val) {
    var index = this.indexOf(val);
    if (index >= 0) {
        this.splice(index, 1);
    }
};

'{0},你好!我是{1}。'.format('Tom', 'Tim'); //Tom,你好!我是Tim。
var arr = ['aa', 'bb', 'cc'];
arr.remove('bb'); //arr = ['aa','cc'];

随着 JavaScript 的更新,其支持的内容会越来越多,有可能现在通过原型来扩展的功能,下个版本就会出现在内建方法中。所以在扩展某个方法时可以先检查一下方法是否已存在。

if(!String.prototype.format){
    String.prototype.format = function(){ //... }
}

原型引用注意事项

对象的原型是通过传引用的方式来传递的,所以每个新建对象并没有属于自己的原型副本,而仅有指向原型的指针。这就意味着,修改原型对象的值,会影响到与之相关的对象,即使对象是在修改前创建。

function Demo(userName){
    this.userName = userName;
}
Demo.prototype.age = 18;

var demo1 = new Demo('demo1');
console.log(demo1.age); //18
Demo.prototype.age = 20;
console.log(demo1.age); //20

是不是感觉很奇怪?是不是感觉不很不靠谱?没办法,人家就是这么玩的,其实这仅仅是个开始,还有更毁三观的机制。同样是上面的例子。

function Demo(userName){
    this.userName = userName;
}
Demo.prototype.age = 18;

var demo1 = new Demo('demo1');
Demo.prototype = { face : 'handsome' }; //重写 Demo 原型
var demo2 = new Demo('hq');

demo1.age; //18
demo1.face; //undefined
demo1.constructor; //Demo(userName){this.userName = userName;}

demo2.age; //undefined
demo2.face; //handsome
demo2.constructor; //Object() { [native code] }

为何同一个构造函数创建的对象指向了两个完全不同的原型呢,结合下图谈谈我的理解:
 (1).原型是引用类型对象,所以在声明 Demo 时,堆内存中是有为其原型开辟有存储空间的(地址1)。
 (2).新建 demo1 时,Demo 原型指向的是地址1(红色箭头表示),所以 demo1.prototype 指向地址1。
 (3).重写 Demo 原型,内存又开辟新空间(地址2)存储新的原型对象,而不是覆盖地址1。
 (4).新建 demo2 时,Demo 原型指向的是地址2(紫色箭头表示),所以 demo2.prototype 指向地址2。
可以看出,demo1 和 demo2 原型指针指向的并不是同一个内存地址,所以导致取值并不相同。所谓共享原型,前提是指向原型的指针相同。

               170956_y4V5_2563695.png

细心猿可能发现了,重写原型后的 demo2.constructor 指向的是 Object,其实这本身也没什么问题,因为重写的原型确实是 Object 类型。当重写对象的 prototype 时,可以重置相应的 constructor,避免出现构造函数乱指向的问题。如:demo2.constructor = Demo。
 

继承

JavaScript 中的继承是通过原型来实现的。原型链是 ECMAScript 标准指定的默认继承方式。

原型链示例

function Animal(){
    this.name = 'animal';
    this.hello = function(){ return 'Hello, I am ' + this.name };
}

function Cat(){
    this.name = 'cat';
    this.age = 2;
}

function Tomcat(color){
    this.name = 'tomcat';
    this.color = color;
}

Cat.prototype = new Animal(); //用 Animal 实例赋值 Cat 原型
Tomcat.prototype = new Cat(); //用 Cat 实例赋值 Tomcat 原型

var animal = new Animal();
var cat = new Cat();
var tomcat = new Tomcat('gray');

console.log(animal.name); //animal
console.log(animal.hello()); //Hello, I am animal
console.log(cat.name);  //cat
console.log(cat.age);  //2
console.log(cat.hello()); //Hello, I am cat
console.log(tomcat.name);  //tomcat
console.log(tomcat.color);  //gray
console.log(tomcat.age);  //2
console.log(tomcat.hello()); //Hello, I am tomcat

JavaScript 中没有类的概念,因此我们需要直接 new 一个实体,然后通过该实体的属性完成相关的继承工作,而不是直接继承自构造器。
如下面代码,完成继承关系后,重置构造函数是一个很好的习惯,这样可以消除继承给 constructor 带来的负面影响。通过 instanceof 可以发现,animal 对象同时是 Tomcat、Cat、Animal 的实例。通过 isPrototypeOf 可知,Tomcat、Cat、Animal 的原型都存在于 animal 实例的原型链上。

console.log(cat.constructor); //Animal
console.log(tomcat.constructor); //Animal

Cat.prototype.constructor = Cat; //重置构造函数
Tomcat.prototype.constructor = Tomcat;

console.log(tomcat.constructor); //Tomcat
console.log(cat.constructor); //Cat

//判断构造器
console.log(tomcat instanceof Animal); //true
console.log(tomcat instanceof Cat); //true
console.log(tomcat instanceof Tomcat); //true
console.log(tomcat instanceof String); //false

//判断原型
console.log(Animal.prototype.isPrototypeOf(tomcat)); //true
console.log(Cat.prototype.isPrototypeOf(tomcat)); //true
console.log(Tomcat.prototype.isPrototypeOf(tomcat)); //true
console.log(String.prototype.isPrototypeOf(tomcat)); //false

将共享属性迁移到原型链中

当我们 new 构造器时,其属性就被添加到 this 中去。这会使某些不能通过实体改变的属性出现一些效率低下的情况。如上例中的 Tomcat 构造函数的 name 属性,当我们 new Tomcat() 新建对象时,每个实体都会有一个全新的属性,并在内存中拥有自己独立的存储空间。其实我们可以把 name 属性添加到所有实体所共享的原型对象中去。修改后的 Tomcat 就成了下面这个样子。

//将共享属性添加到原型中,如果有共享方法也是这样
function Tomcat(color){
    this.color = color;
}
Animal.prototype.name = 'tomcat';

现在我们 new Tomcat() 时,新建对象在内存中就不含有自己的 name 了,而是拥有了指向原型中 name 的指针。这样既提高新建对象的效率,同时也节省内存空间。当然这只针对对象实体中不可改变的属性而言的,如 color 属性就是实例特有可改变的。

原型继承最佳实践

正如上面所说,把可重用属性和方法添加到原型中,那么仅仅依靠原型就能完成继承关系的构建了。所以,当共享属性添加到原型后,继承方法可做如下修改。

//通过实例继承
Cat.prototype = new Animal();
Tomcat.prototype = new Cat(); 
//通过原型继承
Cat.prototype = Animal.prototype;
Tomcat.prototype = Cat.prototype; 

看着还不错吧,但其实这样是有问题的。修改后,父类与子类共享原型,所以一旦子类对原型进行修改,父类对象的原型也随即被改变。我们只需要借用一个空函数就能打破这种连锁关系。即我们创建一个空函数 F(),将其原型设置成父类构造器。然后既可以用 new F() 来创建一些不包含父对象属性的对象,同时又可以从父对象 prototype 属性中继承共享属性。这样,就在保持原型链的基础上使父对象摆脱子对象的影响了。

//继承
function extendClass(childClass, superClass) {
    var F = function () {};
    F.prototype = superClass.prototype;
    childClass.prototype = new F();
    childClass.prototype.constructor = childClass;
    childClass.superclass = superClass.prototype; //指向父原型的引用
}

如果还有猿没弄明白的话,我们再通过一个例子来举例说明。

//Tomcat 继承自 Animal
extendClass(Tomcat, Animal);
		
var tomcat = new Tomcat();
var animal = new Animal();
console.log(animal.hello()); //Hello, I am animal
console.log(tomcat.hello()); //Hello, I am Tomcat
		
Tomcat.prototype.hello = function(){ return 'I am demo' }; //扩展 Tomcat 原型
console.log(animal.hello()); //Hello, I am animal 发现没有,父类对象完全没受影响
console.log(tomcat.hello()); //I am demo

如果我们打开控制台 debug 一下就全明白了(下图是 chrome 浏览器截图)。可以用一句话概括:通过 new F() 的方式实现继承后,父类原型与子类原型不在原型链的同一级上。

                             继承中父子类原型关系

通过属性拷贝实现继承

除了原型外,也可以通过属性拷贝的方式来实现继承。将父类的属性深拷贝的到子类属性中。当然,如果父类只包含基本类型数据类型,浅拷贝就可以。

//对象深拷贝
function deepCopy(parent, child) {
    var child = child || {};
    for (var i in parent) {
        if(typeof parent[i] === 'object'){
            child[i] = (parent[i].constructor === Array) ? [] : {};
            deepCopy(parent[i], child[i]);
        }else{
            child[i] = parent[i];
        }
    } 
    return child; 
}

var tomcat = deepCopy(new Animal()); //通过深拷贝实现继承
console.log(tomcat.hello()); //Hello, I am Tomcat

多继承

面向对象语言中,有些支持多继承,有些不支持。JavaScript 中实现多继承是十分简单的,利用上面说的属性拷贝便可。

function multiExtend(){
    var child = {},superClass = null,argLen = arguments.length;
	for(var i = 0; i < argLen; i++){
	    superClass = arguments[i];
		for(var j in superClass){
            //此处可以实现其他逻辑,如同名属性覆盖等问题,深浅拷贝问题
		    child[j] = superClass[j];
		}
	}
	return child;
}

Object.create 函数

ES5 中引入了 Object.create() 函数,创建具有指定原型的新对象。

var demo1 = Object.create(null);	//相当于{}
var demo2 = Object.create(Demo.prototype);	//demo2 具有 Demo 原型链,类似于继承自 Demo

不过在 IE8 及以下 IE 版本不支持。


总结

至此,JavaScript 面向对象相关的知识点就讲完了,只要涉及到构造函数、原型、继承。
JavaScript 中所有函数都有 prototype 属性,初始值为空对象。可以对其进行扩展或重写。当通过构造器新建对象时,这些对象都会拥有一个指向 prototype 属性的指针,通过这个指针可以访问到对象的原型链。JavaScript 中继承方式比较多,但基本都是原型继承和属性拷贝的不同演变版本。

转载于:https://my.oschina.net/u/2563695/blog/1593764

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值