JavaScript中的函数继承

3,897 阅读9分钟

面向对象和基于对象

几乎每个开发人员都有面向对象语言(比如C++、C#、Java)的开发经验。在传统面向对象的语言中,有两个非常重要的概念——类和实例。类定义了一些事物公共的行为和方法;而实例则是类的一个具体实现。我们还知道,面向对象编程有三个重要的概念——封装、继承和多态。 但是在Javascript的世界中,所有的这一切特性似乎都不存在。因为Javascript本身不是面向对象的语言,而是基于对象的语言。Javascript中所有事物都是对象,包括字符串、数组、日期,甚至是函数,请看一个有趣的实例:

//定义一个函数 
function add(a,b){
	add.invokeTimes++;
	return a+b;
}
//因为函数本身也是对象,在这里我们为add定义一个属性,用来记录次函数被调用的次数
add.invokeTimes = 0;
add(1,1);
add(2,2);
console.log(add.invokeTimes);//2

模拟Javascript中类和继承

在面向对象的语言中,我们使用类来创建一个自定义对象。然而Javascript中所有事物都是对象,那么用什么方法来创建自定义对象呢? 在这里我们引入一个新概念——原型(prototype),我们可以简单的把prototype看做是一个模板,新创建的自定义对象都是这个模板(prototye)的一个拷贝(实际上不是拷贝而是链接,只不过这种链接是不可见,给人的感觉好像是拷贝)。 使用prototype创建自定义对象的一个例子:

//构造函数
function Person(name,gender){
	this.name = name;
	this.gender = gender;
}
//定义Person的原型,原型中的属性可以被自定义对象引用
Person.prototype = {
	getName:function(){
		return this.name;
	},
	getGender:function() {
		return this.gender;
	}
}

这里我们把函数Person称为构造函数,也就是创建自定义对象的函数。可以看出,Javascript通过结构函数和原型的方式模拟实现了类的功能。 创建自定义对象(实例化类):

var Person1 = new Person("张三","男");
console.log(Person1.getName());//张三
var Person2 = new Person("娜娜","女");
console.log(Person2.getName());//娜娜

当代码var Person1 = new Person("张三","男")执行时,其实内部做了如下几件事情:

创建一个空白对象(new Object())。 拷贝Person.prototype中的属性(键值对)到这个空对象中(我们前面提到,内部实现时不是拷贝而是一个隐藏的链接)。 将这个对象通过this关键字传递到构造函数中并执行构造函数。 将这个对象赋值给变量Person1。

为了证明prototype模板并不是被拷贝到实例化的对象中,而是一种链接的方式,请看如下实例:

function Person(name,gender){
	this.name = name;
	this.gender= gender;
}
Person.prototype.age = 20;
var Person1 = new Person('娜娜','女');
console.log(Person1.age);

//覆盖prototype中的age属性
Person1.age = 25;
console.log(Person1.age);//25
delete Person1.age;
//在删除实例属性age后,此属性值又从prototype中获取
console.log(Person1.age);//20

Javascript继承的几种方式

为了阐述Javascript继承的几种方式,首先我们提前约定共同语言:

//约定
function Fun(){
	//私有属性
	var val = 1;        //私有基本属性
	var arr = [1];      //私有引用属性
	function fun() {}   //私有函数(引用属性)
	
	//实例属性
	this.val = 1;              //公有基本属性
	this.arr = [1];            //公有引用属性
	this.fun = function(){};   //公有函数(引用属性)
}
//原型属性
Fun.prototype.val = 1;             //原型基本属性
Fun.prototype.arr = [1];           //原型引用属性
Fun.prototype.fun = function(){};  //原型函数(引用属性)

一、简单原型链实现继承

这是实现继承最简单的方式了。 如果“猫”的prototype对象,指向一个Animal的示例,那么所有“猫”的实例,就能继承Animal了。

具体实现

function Animal(){
	this.species = "动物";
	this.classes = ['脊椎动物','爬行动物'];
}
function Cat(name,color){
	this.name = name;
	this.color = color;
}
//将Cat的prototype对象指向一个Animal的实例
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat;

var cat1 = new Cat("大毛","黄色");
var cat2 = new Cat("二毛","白色");
cat1.classes.push('哺乳动物');
cat1.species = '哺乳动物';
console.log(cat1.species);//哺乳动物
console.log(cat2.species);//动物
console.log(cat1.classes);//["脊椎动物", "爬行动物", "哺乳动物"]
console.log(cat2.classes);//["脊椎动物", "爬行动物", "哺乳动物"]

我们将Cat的prototype对象指向一个Animal的示例。

Cat.prototype = new Animal();

它相当于完全删除了prototype对象原先的值,然后赋予一个新值。

Cat.prototype.constructor = Cat;

任何一个prototype对象都有一个constructor属性,指向它的构造函数。如果没有“Cat.prototype = new Animal(); ”这一行,Cat.prototype.constructor是指向Cat的;加了这一行以后,Cat.prototype.constructor指向Animal。

console.log(Cat.prototype.constructor == Animal);//true

更重要的是,每一个实例也有一个constructor属性,默认调用prototype对象的constructor属性。

console.log(cat1.constructor = Cat.prototype.constructor);//true

因此,在运行“Cat.prototype = new Animal();”这一行之后,cat1.constructor也指向了Animal!

console.log(cat1.constructor == Animal);//true

这显然会导致继承链的紊乱(cat1明明是构造函数Cat生成的),因此我们必须手动纠正,将Cat.prototype对象的constructor值改为Cat。 这一点很重要,编程时务必遵守。如果替换里prototype对象

o.prototype = {};

那么,下一步必然是为新的prototype对象加上contructor属性,并将这个属性指回原来的构造函数。

o.prototype.constructor = o;

存在的问题

1.修改cat1.classes后cat2.classes也发生了变化,因为来自原型对象的引用属性是所有实例共享的。 可以这样理解:执行cat1.classes.push('哺乳动物');先对cat1进行属性查找,找遍了实例属性(在本例中没有实例属性),没找到,就开始顺着原型链向上找,拿到了cat1的原型对象,一查找,发现有classes属性。于是给classes末尾插入了‘哺乳动物’,所哟cat2.classes也发生了变化。

2.创建子类实例时,无法向父类构造函数传递参数。

二、借用构造函数和call或者apply方法

简单原型链真够简单,可是存在两个致命的缺点简直无法使用,于是上世纪末的Jsers就想办法修复了这两个缺陷,然后就出现了借用构造函数这种方式。

具体实现

function Animal(species){
	this.species = species;
	this.classes = ['脊椎动物','爬行动物'];
}
function Cat(name,color,species){
	Animal.call(this,species);//核心
	this.name = name;
	this.color = color;
}

var cat1 = new Cat("大毛","黄色",'动物');
var cat2 = new Cat("二毛","白色",'哺乳动物');

cat1.classes.push('哺乳动物');
console.log(cat1.species);//动物
console.log(cat2.species);//哺乳动物

console.log(cat1.classes);//["脊椎动物", "爬行动物", "哺乳动物"]
console.log(cat2.classes);//["脊椎动物", "爬行动物"]

核心

借父类的构造函数来增强子类实例,等于是把父类的实例属性复制了一份给子类实例装上了(完全没有用到原型)。

优缺点

优点: 1.解决了子类实例共享父类引用属性的问题; 2.创建子类实例时,可以向父类构造函数传参。 缺点: 无法实现函数复用,每个子类实例都持有一个新的fun函数,太多了就会影响性能,内存爆炸。

三、组合继承(最常用)

目前我们借用构造函数方式还是有问题(无法实现函数复用),没关系,接着修复,于是出现了组合继承。

具体实现

function Animal(species){
	//只在此处声明基本属性和引用属性
	this.species = species;
	this.classes = ['脊椎动物','爬行动物'];
}
//在此处声明函数
Animal.prototype.eat = function(){
	console.log('动物必须吃东西获取能量');
}
Animal.prototype.run = function(){
	console.log('动物正在跑动');
}
function Cat(name,color,species){
	Animal.call(this,species);//核心
	this.name = name;
	this.color = color;
}
Cat.prototype = new Animal();

var cat1 = new Cat("大毛","黄色",'动物');
var cat2 = new Cat("二毛","白色",'哺乳动物');

cat1.classes.push('哺乳动物');
console.log(cat1.species);//动物
console.log(cat2.species);//哺乳动物

console.log(cat1.classes);//["脊椎动物", "爬行动物", "哺乳动物"]
console.log(cat2.classes);//["脊椎动物", "爬行动物"]
console.log(cat1.eat === cat2.eat);//true

具体实现

把实例函数都放在原型对象上,以实现函数复用。同时还要保留借用构造函数方式的优点,通过Animal.call(this,species)继承父类的基本属性和引用属性并保留能传参的优点;通过Cat.prototype = new Animal(),继承父类函数,实现函数复用。

优缺点

优点: 1.不存在引用属性共享的问题 2.可传参 3.函数可以复用 缺点: (一点小瑕疵)子类原型上有一份多余的父类实例属性,因为父类构造函数被调用了两次,生成了两份,而子类实例上的那一份屏蔽了子类原型上的。又是内存浪费,不过已经改进了很多。

四、直接继承prototype(改进简单原型链继承)

第四种方法是对第二种方法的改进。由于Animal对象中,不变的属性都可以直接写入Animal.prototype。所以,我们也可以让Cat()跳过Animal(),直接继承Animal.prototype。

具体实现

function Animal(){}
Animal.prototype.species = '动物';
function Cat(name,color){
	this.name = name;
	this.color = color;
}
//将Cat的prototype对象指向Animal的prototype对象,这样就实现了继承
Cat.prototype = Animal.prototype;
Cat.prototype.constructor = Cat;

var cat1 = new Cat('大毛','黄色');
console.log(cat1.species);//动物

优缺点

优点: 与第一种方法相比,这样做的优点是效率比较高(不用执行和建立Animal的示例了),比较省内存。 缺点: Cat.prototype和Animal.prototype现在指向了同一个对象,那么任何对Cat.prototype的修改,都会反映到Animal.prototype。 Cat.prototype.constructor = Cat,把Animal.prototype对象的constructor属性也改掉了

console.log(Animal.prototype.constructor);//Cat

五、利用空对象作为中介(寄生组合继承)

由于“直接继承prototype”存在上述的缺点,所以就有了以下方法,利用一个空对象作为中介。

function Animal(){}
Animal.prototype.species = '动物';
function Cat(name,color){
	Animal.call(this);
	this.name = name;
	this.color = color;
}

//利用空对象作为中介,核心
var F = function(){};
F.prototype = Animal.prototype;
Cat.prototype = new F();
Cat.prototype.constructor = Cat;

F是空对象,所以几乎不占内存。这时,修改Cat的prototype对象,就不会影响到Animal的prototype对象。

console.log(Animal.prototype.constructor);//Animal

将上述方法封装成一个函数,便于使用

function extend(Child,Parent){
	var F = function(){};
	F.prototype = Parent.prototype;
	Child.prototype = new F();
	Child.prototype.constructor = Child;
	Child.uber = Parent.prototype;
}

使用方法如下:

function Animal(){}
Animal.prototype.species = '动物';
function Cat(name,color){
	this.name = name;
	this.color = color;
}
extend(Cat,Animal);
var cat1 = new Cat('大毛','黄色');
console.log(cat1.species);//动物

函数的最后一行

Child.uber = Parent.prototype;

为子对象设一个uber属性,这个属性直接指向父对象的prototype属性。(uber是一个德语词,意思是"向上"、"上一层"。)这等于在子对象上打开一条通道,可以直接调用父对象的方法。这一行放在这里,只是为了实现继承的完备性,纯属备用性质。

六、拷贝继承

上面是采用prototype对象,实现继承。我们也可以换一种思路,纯粹采用“拷贝”方法实现继承。简单说,就是把父对象的所有属性和方法,拷贝进子对象。 定义一个函数,实现属性拷贝的目的:

function extend(Child,Parent){
	var p = Parent.prototype;
	var c = Child.prototype;
	for(var i in p){
		c[i] = p[i];
	}
	c.uber = p;
}

这个函数的作用就是将父对象的prototype对象中的属性,一一拷贝给Child对象的prototype对象。 继承的具体实现如下:

function Animal(){}
Animal.prototype.species = '动物';
function Cat(name,color){
	this.name = name;
	this.color = color;
}
extend(Cat,Animall);
var cat = new Cat('大毛','黄色');
console.log(cat.species);//动物