继承是面向对象的,使用这种方式我们可以更好地复用以前的开发代码,缩短开发的周期、提升开发效率。
那么,请你先思考几个问题:
JS 的继承到底有多少种实现方式呢?
ES6 的 extends 关键字是用哪种继承方式实现的呢?
第一种:原型链继承
原型链继承是比较常见的继承方式之一,其中涉及的构造函数、原型和实例,三者之间存在着一定的关系,即每一个构造函数都有一个原型对象,原型对象又包含一个指向构造函数的指针,而实例则包含一个原型对象的指针。
重点:让新实例的原型等于父类的实例。
特点:实例可继承的属性有:实例的构造函数的属性,父类构造函数属性,父类原型的属性。(新实例不会继承父类实例的属性!)
缺点:1、新实例无法向父类构造函数传参。
2、继承单一。
3、所有新实例都会共享父类实例的属性。(原型上的属性是共享的,一个实例修改了原型属性,另一个实例的原型属性也会被修改!)
function Parent1() {
this.name = 'parent1';
this.play = [1, 2, 3]
}
function Child1() {
this.type = 'child2';
}
Child1.prototype = new Parent1();
console.log(new Child1());
上面的代码看似没有问题,虽然父类的方法和属性都能够访问,但其实有一个潜在的问题,我再举个例子来说明这个问题。
var s1 = new Child1();
var s2 = new Child1();
s1.play.push(4);
console.log(s1.play, s2.play);
[1,2,3,4],[1,2,3,4]
明明我只改变了 s1 的 play 属性,为什么 s2 也跟着变了呢?原因很简单,因为两个实例使用的是同一个原型对象。它们的内存空间是共享的,当一个发生变化的时候,另外一个也随之进行了变化,这就是使用原型链继承方式的一个缺点。
上述原因也是因为直接修改了原型上的属性,加入直接在对象上,对属性进行赋值,此时属性为实例对象自身的属性,所以不会改变一个其他实例对象跟着一起改变:
function Person() {
this.name = 'name'
this.list = [1,2,3,4]
}
function Child(type) {
this.type = type
}
Child.prototype = new Person();
var child1 = new Child('1');
var child2 = new Child('2');
child1.list = [1]
console.log(child1.list)
console.log(child2.list)
VM715:14 [1]
VM715:15 (4) [1, 2, 3, 4]
那么要解决这个问题的话,我们就得再看看其他的继承方式,下面我们看看能解决原型属性共享问题的第二种方法。
第二种:构造函数继承(借助 call)
重点:用.call()和.apply()将父类构造函数引入子类函数(在子类函数中做了父类函数的自执行(复制))
特点:1. 只继承了父类构造函数的属性,没有继承父类原型的属性。
2. 解决了原型链继承缺点1、2、3(无法像父类传参,继承单一,数据共享修改一个,其他实例也会被修改)
3. 可以继承多个构造函数属性(call多个)。
4.在子实例中可向父实例传参。
缺点:1、只能继承父类构造函数的属性。不能继承父类原型的属性和方法
2、无法实现构造函数的复用。(每次用每次都要重新调用)
3、每个新实例都有父类构造函数的副本,臃肿。
function Person(name) {
this.name = name
this.list = [1,2,3,4]
}
function Student(grade) {
Person.call(this,'学生姓名');
this.grade = grade;
}
var stu = new Student(100);
console.log(stu.name) // 学生姓名
console.log(stu.grade) //100
var sta = new Student(99)
console.log(sta)
Student {name: "学生姓名", list: Array(4), grade: 99}grade: 99list: (4) [1, 2, 3, 4]name: "学生姓名"__proto__: Object
console.log(stu)
Student {name: "学生姓名", list: Array(4), grade: 100}grade: 100list: (4) [1, 2, 3, 4]name: "学生姓名"__proto__: Object
1、只能继承父类构造函数的属性。不能继承父类原型的属性和方法,下面的例子:
Person.prototype.getName = function(){
return this.name
}
function Student(grade) {
Person.call(this,'111');
this.grade = grade;
}
var stu = new Student(100);
console.log(stu instanceof Person) // false
console.log(stu.getName())
VM1203:9 Uncaught TypeError: stu.getName is not a function
at <anonymous>:9:17
(anonymous) @ VM1203:9
因此,从上面的结果就可以看到构造函数实现继承的优缺点,它使父类的引用属性不会被共享,优化了第一种继承方式的弊端;但是随之而来的缺点也比较明显——只能继承父类的实例属性和方法,不能继承原型属性或者方法。
上面的两种继承方式各有优缺点,那么结合二者的优点,于是就产生了下面这种组合的继承方式。
第三种:组合继承(前两种组合)
需要手动挂上构造器,指向自己的构造函数
Child3.prototype.constructor = Child3;
重点:结合了两种模式的优点,传参和复用
特点:1、可以继承父类原型上的属性,可以传参,可复用。
2、每个新实例引入的构造函数属性是私有的。
缺点:调用了两次父类构造函数(耗内存),子类的构造函数会代替原型上的那个父类构造函数。
function Mama(name,color) {
Person.call(this,name) // 构造函数继承
this.color = color;
}
Mama.prototype = new Person(); // 原型链继承
Mama.prototype.constructor = Mama; // 手动挂载构造函数指向自己
var ma1 = new Mama('妈妈姓名1')
var ma2 = new Mama('妈妈姓名2')
console.log(ma1.getName())
console.log(ma2.getName())
ma1.list.push(9);
console.log(ma1.list)
console.log(ma2.list)
VM1436:10 妈妈姓名1
VM1436:11 妈妈姓名2
VM1436:13 (5) [1, 2, 3, 4, 9]
VM1436:14 (4) [1, 2, 3, 4]
function Parent3 () {
this.name = 'parent3';
this.play = [1, 2, 3];
}
Parent3.prototype.getName = function () {
return this.name;
}
function Child3() {
// 第二次调用 Parent3()
Parent3.call(this);
this.type = 'child3';
}
// 第一次调用 Parent3()
Child3.prototype = new Parent3();
// 手动挂上构造器,指向自己的构造函数
Child3.prototype.constructor = Child3;
var s3 = new Child3();
var s4 = new Child3();
s3.play.push(4);
console.log(s3.play, s4.play); // 不互相影响
console.log(s3.getName()); // 正常输出'parent3'
console.log(s4.getName()); // 正常输出'parent3'
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iK9hbm90-1620812027499)(https://s0.lgstatic.com/i/image/M00/8D/42/Ciqc1F_9B_uAHQtBAAAgMta5Vz8933.png)]
但是这里又增加了一个新问题:通过注释我们可以看到父类 Parent3 执行了两次,第一次是改变Child3 的 prototype 的时候,第二次是通过 call 方法调用 Parent3 的时候,那么 Parent3 多构造一次就多进行了一次性能开销,这是我们不愿看到的。
那么是否有更好的办法解决这个问题呢?请你再往下学习,下面的第六种继承方式可以更好地解决这里的问题。
上面介绍的更多是围绕着构造函数的方式,那么对于 JavaScript 的普通对象,怎么实现继承呢?
第四种:原型式继承
该方法的原理是创建一个构造函数,构造函数的原型指向父对象,然后调用new操作符创建构造函数的实例,最后返回这个实例,本质上是一个浅拷贝。所以父类中引用类型的对象被共享,被改变后,所有实例都会改变
重点:用一个函数包装一个对象,然后返回这个函数的调用,这个函数就变成了个可以随意增添属性的实例或对象。object.create()就是这个原理。
特点:类似于复制一个对象,用函数来包装。
缺点:1、所有实例都会继承原型上的属性。
2、无法实现复用。(新实例属性都是后面添加的)
3、无法向父类传参
我们通过一段代码,看看普通对象是怎么实现的继承。
先封装一个函数容器,用来输出对象和承载原型
function Baba(prototypeObj,color) {
function f() {
this.color = color
};
f.prototype = prototypeObj; // 继承了传入的obj
return new f(); // 返回函数对象
}
var per = new Person('baba'); // 创建父类对象
var ba1 = Baba(per,'2'); // 传入父类对象,将父类作为函数的原型,并返回函数;
var ba2 = Baba(per,'4'); // 传入父类对象,将父类作为函数的原型,并返回函数;
ba1.list.push(9)
console.log(ba1.name) // baba
console.log(ba1.color) // 2
console.log(ba1.list) // [1, 2, 3, 4, 9]
console.log(ba2.list) // [1, 2, 3, 4, 9]
console.log(ba2.getName()) // baba
这里不得不提到的就是 ES5 里面的 Object.create 方法,这个方法接收两个参数:一是用作新对象原型的对象、二是为新对象定义额外属性的对象(可选参数)。
let Parent={
name: 'parent',
list:[1,2,3,4],
getName(){
return this.name;
}
}
let per1 = Object.create(Parent);
let per2 = Object.create(Parent);
per1.name = 'per1'
per1.list.push(5);
per2.list.push(5);
console.log(per1.name) // per1
console.log(per2.name) // parent
console.log(per1.list) // [1, 2, 3, 4, 5, 5]
console.log(per2.list) // [1, 2, 3, 4, 5, 5]
console.log(per1.getName()) // per1
console.log(per2.getName()) // parent
console.log(per1.name == per1.getName()) // true
console.log(person5.friends);
从上面的代码中可以看到,通过 Object.create 这个方法可以实现普通对象的继承,不仅仅能继承属性,同样也可以继承 getName 的方法
第一个结果“per1”,比较容易理解,person4 继承了 parent4 的 name 属性,但是在这个基础上又进行了自定义。
第二个是继承过来的 getName 方法检查自己的 name 是否和属性里面的值一样,答案是 true。
第三个结果“parent”也比较容易理解,per2 继承了 parent 的 name 属性,没有进行覆盖,因此输出父对象的属性。
最后两个输出结果是一样的,讲到这里你应该可以联想到 02 讲中浅拷贝的知识点,关于引用数据类型“共享”的问题,其实 Object.create 方法是可以为一些对象实现浅拷贝的。
那么关于这种继承方式的缺点也很明显,多个实例的引用类型属性指向相同的内存,存在篡改的可能,接下来我们看一下在这个继承基础上进行优化之后的另一种继承方式——寄生式继承。
注: Object.create不是浅拷贝,下面是MDN对于Object.create的第一句解释:Object.create()方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__
第五种:寄生式继承
使用原型式继承可以获得一份目标对象的浅拷贝,然后利用这个浅拷贝的能力再进行增强,添加一些方法,这样的继承方式就叫作寄生式继承。
虽然其优缺点和原型式继承一样,但是对于普通对象的继承方式来说,寄生式继承相比于原型式继承,还是在父类基础上添加了更多的方法。那么我们看一下代码是怎么实现。和原型式继承对比,增加了一层函数嵌套,可以在函数内部增加自定义属性,这就是区别
function firends(obj) {
function f() {};
f.prototype = obj;
return new f();
}
var per4 = new Person();
function subFir(name) {
var sub = firends(per4);
sub.name = name
return sub
}
var subfir1 = subFir('subfir1');
var subfir2 = subFir('subfir2');
subfir1.list.push(3);
console.log(subfir1.getName())
console.log(subfir2.getName())
console.log(subfir1.list)
console.log(subfir2.list)
VM4128:15 subfir1
VM4128:16 subfir2
VM4128:17 (5) [1, 2, 3, 4, 3]
VM4128:18 (5) [1, 2, 3, 4, 3]
undefined
上面的结果能够发现,继承的属性被共享,改变一个,另外一个也会被改变;
类似的通过Obect.creat()实现:本质都是在原型式继承外套一层函数:
let parent5 = {
name: "parent5",
friends: ["p1", "p2", "p3"],
getName: function() {
return this.name;
}
};
function clone(original) {
let clone = Object.create(original);
clone.getFriends = function() {
return this.friends;
};
return clone;
}
let person5 = clone(parent5);
let person6 = clone(parent5);
person6.name = '111'
console.log(person5.getName());//parent5
console.log(person5.getName());//111
console.log(person5.getFriends());// ["p1", "p2", "p3"]
person5.friends.push('p4')
console.log(person6.getFriends()) // ["p1", "p2", "p3", "p4"]
通过上面这段代码,我们可以看到 person5 是通过寄生式继承生成的实例,它不仅仅有 getName 的方法,而且可以看到它最后也拥有了 getFriends 的方法
从最后的输出结果中可以看到,person5 通过 clone 的方法,增加了 getFriends 的方法,从而使 person5 这个普通对象在继承过程中又增加了一个方法,这样的继承方式就是寄生式继承。
我在上面第三种组合继承方式中提到了一些弊端,即两次调用父类的构造函数造成浪费,下面要介绍的寄生组合继承就可以解决这个问题。
第六种:寄生组合式继承
结合第四种中提及的继承方式,解决普通对象的继承问题的 Object.create 方法,我们在前面这几种继承方式的优缺点基础上进行改造,得出了寄生组合式的继承方式,这也是所有继承方式里面相对最优的继承方式,代码如下。
function clone (parent, child) {
// 这里改用 Object.create 就可以减少组合继承中多进行一次构造的过程
child.prototype = Object.create(parent.prototype);
child.prototype.constructor = child;
}
function Parent6() {
this.name = 'parent6';
this.play = [1, 2, 3];
}
Parent6.prototype.getName = function () {
return this.name;
}
function Child6() {
Parent6.call(this);
this.friends = 'child5';
}
clone(Parent6, Child6);
Child6.prototype.getFriends = function () {
return this.friends;
}
let person6 = new Child6();
console.log(person6);
console.log(person6.getName());
console.log(person6.getFriends());
通过这段代码可以看出来,这种寄生组合式继承方式,基本可以解决前几种继承方式的缺点,较好地实现了继承想要的结果,同时也减少了构造次数,减少了性能的开销,我们来看一下上面这一段代码的执行结果。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2lbrmWYD-1620812027504)(https://s0.lgstatic.com/i/image/M00/8D/4E/CgqCHl_9CBWATQbEAABszTJIdBQ249.png)]
可以看到 person6 打印出来的结果,属性都得到了继承,方法也没问题,可以输出预期的结果。
整体看下来,这六种继承方式中,寄生组合式继承是这六种里面最优的继承方式。另外,ES6 还提供了继承的关键字 extends,我们再看下 extends 的底层实现继承的逻辑。
ES6 的 extends 关键字实现逻辑
我们可以利用 ES6 里的 extends 的语法糖,使用关键词很容易直接实现 JavaScript 的继承,但是如果想深入了解 extends 语法糖是怎么实现的,就得深入研究 extends 的底层逻辑。
我们先看下用利用 extends 如何直接实现继承,代码如下:
class Person {
constructor(name) {
this.name = name
}
// 原型方法
// 即 Person.prototype.getName = function() { }
// 下面可以简写为 getName() {...}
getName = function () {
console.log('Person:', this.name)
}
}
class Gamer extends Person {
constructor(name, age) {
// 子类中存在构造函数,则需要在使用“this”之前首先调用 super()。
super(name)
this.age = age
}
}
const asuna = new Gamer('Asuna', 20)
asuna.getName() // 成功访问到父类的方法
因为浏览器的兼容性问题,如果遇到不支持 ES6 的浏览器,那么就得利用 babel 这个编译工具,将 ES6 的代码编译成 ES5,让一些不支持新语法的浏览器也能运行。
那么最后 extends 编译成了什么样子呢?我们看一下转译之后的代码片段。
function _possibleConstructorReturn (self, call) {
// ...
return call && (typeof call === 'object' || typeof call === 'function') ? call : self;
}
function _inherits (subClass, superClass) {
// 这里可以看到
subClass.prototype = Object.create(superClass && superClass.prototype, {
constructor: {
value: subClass,
enumerable: false,
writable: true,
configurable: true
}
});
if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass;
}
var Parent = function Parent () {
// 验证是否是 Parent 构造出来的 this
_classCallCheck(this, Parent);
};
var Child = (function (_Parent) {
_inherits(Child, _Parent);
function Child () {
_classCallCheck(this, Child);
return _possibleConstructorReturn(this, (Child.__proto__ || Object.getPrototypeOf(Child)).apply(this, arguments));
}
return Child;
}(Parent));
从上面编译完成的源码中可以看到,它采用的也是寄生组合继承方式,因此也证明了这种方式是较优的解决继承的方式。
到这里,JavaScript 中实现继承的方式也基本讲解差不多了,本课时也将告一段落。
总结
通过 Object.create 来划分不同的继承方式,最后的寄生式组合继承方式是通过组合继承改造之后的最优继承方式,而 extends 的语法糖和寄生组合继承的方式基本类似。
综上,我们可以看到不同的继承方式有不同的优缺点,我们需要深入了解各种方式的优缺点,这样才能在日常开发中,选择最适合当前场景的继承方式。
在日常的前端开发工作中,开发者往往会忽视对继承相关的系统性学习,但因为继承的方法比较多,每个实现的方法细节也比较零散,很多开发者很难有一个系统的、整体的认识,造成效率低下,以及代码能力难以进一步提升等问题。
因此我希望通过这一讲的学习,你能很好地掌握 JavaScript 的继承方式,以便在开发中规避我所说的这些问题。
在后续的课时中,我将继续带领你尝试一些和继承相关的 API 方法的实现,比如 call、apply 等。同时希望你多动手练习以熟练上面的代码,也欢迎你在下方留言讨论自己在学习过程中遇到的困惑,以及学习感悟等,让我们共同进步。