写在前面
本文只针对JavaScript实现OOP继承机制的方式进行阐述和验证,不过多介绍面对对象的相关细节内容,如有需要请移步两分钟让你明白面向对象
不是纯粹的面对对象(OOP)
JavaScript不是一个纯粹的面对对象的编程语言,这大概来源于它特殊的继承机制(原因之一)。不同于其他OOP范式的编程语言使用经典的类继承机制,JavaScript继承机制的原理来自于原型。基于原型使得实现继承的操作变得异常干净,毕竟只是在原有基础上构建,副作用是非常小的。
实现继承的方式
常见的JavaScript继承方式有以下几种:
原型链继承
构造函数继承(借助 call)
组合继承
原型式继承
寄⽣式继承
寄⽣组合式继承
原型链继承
原型链继承是比较常见的方式之一。借助于原型链的特性我们会发现,同属于一条链上的对象,子对象共享父对象的属性和方法的特性,那这何尝不是一种继承呢,继而,我们现在唯一需要解决的问题就是如何把对象的链挂上钩。
function Parent() {
this.name = 'parent1';
this.play = [1, 2, 3]
}
function Child() {
this.type = 'child2';
}
Child1.prototype = new Parent();// 原型对象变更为父对象
console.log(new Child())
在基于原型的特性中,我们知道创建一个函数或对象,它就会自己生成一条原型链,并且这条原型链最终会指向null,那么我们可以得知,Parent和Child这两个对象,其实是处于相似原型链上的,也就是除了它本身原型不同,他的上层链路是一致的,那么如果我们想让一个子对象Child继承一个父对象Parent,最简单的方法就是把Child的原型对象属性修改为父对象。
但是这种继承方式会存在引用问题:
var s1 = new Child();
var s2 = new Child();
s1.play.push(4);
console.log(s1.play, s2.play); // [1,2,3,4]
JavaScript数据类型分为原始类型和引用类型,原始类型是直接存储在栈上的,而引用类型在栈上只是存储了引用地址,真正的数据存储在堆上,因此,虽然s1和s2都通过new创建了对象,但是这是两个实例使⽤的是同⼀个原型对象,内存空间是共享的,通过修改s1必然影响所有引用这个原型的实例
构造函数继承
构造函数继承的本质是子对象通过父对象(函数)调用cell方法(this指定为子对象的this),进行子对象初始化父对象构造函数的过程,这样就可以在子对象中执行一次父对象构造函数,从而把继承父对象中的属性和方法
function Parent(){
this.name = 'parent1';
}
Parent.prototype.getName = function () {
return this.name;
}
function Child(){
Parent1.call(this);
this.type = 'child'
}
let child = new Child();
console.log(child); // 能访问到父对象name属性
console.log(child.getName()); // 报错:getName undefined
这种方式非常直观,相当于在子对象中执行了一次父对象构造函数,也不会有引用问题,但问题也非常明显,如果只是对构造函数进行初始化,那么子对象的继承范围是有限的,即只能继承到构造函数阶段定义的属性和方法,如果是在这个范围之外定义的属性和方法,是无法继承的
组合继承
前面提到的原型链继承和构造函数继承都存在相应的问题,但是不难发现,他们之间的问题有一定的互补性,因此通过这两种方式结合而成的组合继承就解决了引用问题和无法完全继承问题,成为更适合的一种继承实现方式
function Parent () {
this.name = 'parent';
this.play = [1, 2, 3];
}
Parent.prototype.getName = function () {
return this.name;
}
function Child() {
// 第⼆次调⽤ Parent()
Parent.call(this);
this.type = 'child';
}
// 第⼀次调⽤ Parent()
Child.prototype = new Parent();
// ⼿动挂上构造器,指向⾃⼰的构造函数
Child.prototype.constructor = Child;
var s1 = new Child3();
var s2 = new Child3();
s1.play.push(4);
console.log(s1.play, s2.play); // 不互相影响
console.log(s1.getName()); // 正常输出'parent'
console.log(s2.getName()); // 正常输出'parent'
这种方式即解决了引用指向的问题,又解决了继承不完整的问题,虽然看似是最优解,但实际上两次执行会带来多一次的性能损耗,因此不能称之为最优解
原型继承
原型继承即通过Object.create()方法创建一个浅拷贝父对象原型的对象的方式来实现继承,具体的通过Object.create(P.prototype)创建浅拷贝对象,然后将该对象赋值给子对象的原型属性
let parent4 = {
name: "parent4",
friends: ["p1", "p2", "p3"],
getName: function() {
return this.name;
}
};
let person4 = Object.create(parent4);
person4.name = "tom";
person4.friends.push("jerry");
let person5 = Object.create(parent4);
person5.friends.push("lucy");
console.log(person4.name); // tom
console.log(person4.name === person4.getName()); // true
console.log(person5.name); // parent4
console.log(person4.friends); // ["p1", "p2", "p3","jerry","lucy"]
console.log(person5.friends); // ["p1", "p2", "p3","jerry","lucy"]
由于是浅拷贝,问题也是十分明显,就是多个实例的引⽤类型属
性指向相同的内存,实例本身不独立。
歧义:原型继承和原型链继承的区别
其实这个问题就好像在问原型和原型链的区别,原型是一个对象,原型链是一种关系表示,因此二者区别在于,原型继承是通过复制对象来实现继承,而原型链继承是通过绑定子对象和父对象的关系来实现继承
寄生式继承
寄生式继承(Parasitic Inheritance)是一种在已有对象基础上创建新对象的继承方式,类似于原型式继承。寄生式继承的主要思想是在创建新对象的同时,在新对象上添加额外的属性或方法,以定制新对象的行为
// 已有对象(父对象)
var person = {
firstName: "John",
lastName: "Doe",
};
// 寄生式继承函数
function createPersonWithFullName(obj) {
// 创建一个新的对象,并复制已有对象的属性
var newPerson = Object.create(obj);
// 添加一个新的方法
newPerson.getFullName = function () {
return this.firstName + " " + this.lastName;
};
return newPerson;
}
// 使用寄生式继承创建新对象
var person1 = createPersonWithFullName(person);
console.log(person1.getFullName()); // 输出 "John Doe"
这种方式有点类似于原型继承的优化版本,这样做的目的是为了扩展继承的子对象的其他行为,不在像原型式继承的类型收到父对象的限制。
但是这种方式还是无法解决引用指向的问题,因为还是没有逃脱浅拷贝的问题
寄生组合式继承
寄生组合式继承是一种结合了寄生式继承和组合式继承的继承模式,旨在克服组合式继承的一些性能和原型污染问题。它通常被认为是实现继承的最佳实践之一。
这个继承模式的核心思想是:首先使用寄生式继承创建子类的原型对象,然后再将子类的构造函数指向正确的构造函数,以确保子类的实例既具有父类的属性和方法,又能正确识别为子类的实例
// 父类构造函数
function Parent(name) {
this.name = name;
}
// 父类原型方法
Parent.prototype.sayHello = function () {
console.log("Hello, " + this.name);
};
// 子类构造函数
function Child(name, age) {
// 调用父类构造函数以继承父类属性
Parent.call(this, name);
this.age = age;
}
// 使用寄生式继承创建子类的原型
// 重点:寄生组合式继承的中重点在于更改了组合式new创建的方式,
// 通过寄生式方式中的Object.create()方法来复制对象
Child.prototype = Object.create(Parent.prototype);
// 修复子类构造函数指向
// 组合式继承,将子对象的构造方法复制给原型的构造方法,区分创建
// 不同实例引用指向的方法
Child.prototype.constructor = Child;
// 子类特有的方法
Child.prototype.sayAge = function () {
console.log(this.name + " is " + this.age + " years old.");
};
// 创建子类实例
var child1 = new Child("Alice", 25);
// 调用父类方法
child1.sayHello(); // 输出 "Hello, Alice"
// 调用子类特有方法
child1.sayAge(); // 输出 "Alice is 25 years old."
寄生组合式继承,即拥有组合式继承的特性,即避免引用问题和继承不完整问题;又拥有寄生式继承的特性,即只进行一次调用,减少性能损耗,因此它被作为最合适的继承实现方式。
如果你将ES6类与继承的代码通过babel转换成ES5,你可以看到其中继承是这样实现的:
function _inherits(subClass, superClass) {
if (typeof superClass !== "function" && superClass !== null) {
throw new TypeError("Super expression must either be null or a function");
}
subClass.prototype = Object.create(superClass && superClass.prototype, {
constructor: { value: subClass, writable: true, configurable: true },
});
Object.defineProperty(subClass, "prototype", { writable: false });
if (superClass) _setPrototypeOf(subClass, superClass);
}
function _setPrototypeOf(o, p) {
_setPrototypeOf = Object.setPrototypeOf
? Object.setPrototypeOf.bind()
: function _setPrototypeOf(o, p) {
o.__proto__ = p;
return o;
};
return _setPrototypeOf(o, p);
}
可以看出,ES6也是通过类似的方式实现
总结:我们可以根据出现的问题反推导记忆几种继承实现方式:
1.继承存在对象引用指向问题的是原型链继承
2.继承存在继承不完整的是构造函数继承
3.解决对象引用指向和继承不完整问题的是组合继承,但同时它也存在性能问题
4.避免性能问题的是原型继承,但是它同时存在引用指向和继承不完整问题
5.避免性能问题同时解决继承不完整的是寄生式继承,但它也存在对象引用指向的问题
6.对象引用指向、继承不完整、性能问题都解决的是寄生组合式继承
评论区