JS进阶(3):人人都能懂的继承

文章2020-07-25848 人已阅来源:Zero & One

在上一篇文章中,我们主要介绍了 JavaScript 中原型对象的概念。这篇文章我们来聊一聊 JavaScript 中的继承。

一、继承的基本概念

相对于 JavaScript 来说,在其他一些面向对象的编程语言中,继承主要指的是父类和子类的一些关系。而在 JavaScript 中,继承主要是基于原型链来实现的。更简单地说,在 JavaScript 中,某个对象可以访问到另一个对象中的属性和方法,我们就可以认为它们之间存在继承关系

通过上篇讲到的原型对象的知识来举个例子:

function Fruit() {
    // code...
}
var apple = new Fruit();
apple.__proto__ === Fruit.prototype;

此时,实例 apple 不仅可以访问到自身的属性和方法,同时也可以访问到 Fruit.prototype 中的属性和方法,所以我们说 apple 继承自 Fruit.prototype

二、 JavaScript 中主流的继承方式

1、基于原型链实现继承

要了解基于原型链的继承,先要搞清楚什么是原型链。

原型对象就是某个构造函数的 prototype 属性所指向的那个对象,也就是构造函数的实例的 __proto__ 属性所指向的那个对象。而原型对象上其实也有一个 __proto__ 属性指向另外一个对象。听起来比较绕?那么我们用控制台来打印一下试试。

JS进阶(3):人人都能懂的继承

通过上图我们发现,在构造函数的原型对象 Fruit.prototype 上有一个 __proto__ 属性指向另一个对象'A',同时在 Fruit.prototype.__proto__ 这个对象'A'上,依然有一个 __proto__ 属性,指向对象'B'......

像这样,已知一个对象,通过这个对象上的 __proto__ 属性找到构造函数的原型对象,再通过原型对象上的 __proto__ 属性找到这个原型的构造函数的原型,最终找到某个不含有 __proto__ 属性的对象终止(原型链的顶端)的链式结构,我们称之为原型链(说的比较绕,其实自己理解了就好)。

搞清楚了原型链,我们就来说说基于原型链的继承。

基于原型链的继承,实际上就是在原型对象中扩展方法。实现方式我们也可以再分成两种:(1) 扩展原型对象(2) 替换原型对象

(1) 扩展原型对象

function Person() {}
var p1 = new Person();

当一个函数创建好之后,就会有一个默认的原型对象。在给这个原型对象添加属性和方法时,就用到了扩展原型对象实现继承

举例来说:

Person.prototype.run = function() {
    console.log("I'm running");
};
console.log(p1.run);

此时,p1 是可以访问到 run 方法的,我们就说 p1 继承自 Person.prototype

(2) 替换原型对象

扩展原型对象的方法虽然很好,但是它也有一些弊端。比如我们要给原型对象中扩展多个属性和方法时,就会出现以下情形:

Person.prototype.run = function() {
    console.log("I'm running");
};

Person.prototype.say = function() {
    console.log("I'm saying");
};

Person.prototype.sing = function() {
    console.log("I'm singing");
};

Person.prototype.walk = function() {
    console.log("I'm walking");
};

此时,我们发现使用扩展原型对象的方式又会出现一些重复的代码。而当出现重复代码的时,作为程序猿的我们自然会想到将这些重复封装起来。所以,替换原型对象实现继承的方式就出现了。

function Person() {}

// 替换原型对象
Person.prototype = {
    constructor: Person,  // 重点
    run: function() {},
    say: function() {},
    sing: function() {},
    walk: function() {}
};

// 实例可以访问
var p1 = new Person();
p1.run;
p1.say;
...

用图来表示一下这个过程。

JS进阶(3):人人都能懂的继承

实际上在 Person 函数创建好以后,会自动创建一个 Person.prototype (old) 。而当我们新创建一个 Person.prototype (new) 对象,并且把其中的 constructor 属性值设为 Person 后,Person 函数中的 prototype 属性就会指向我们新创建的这个 Person.prototype (new) 对象。

最后,我们通过一个比较经典的面试题再来理解一下其中的过程:

function Person() {}

Person.prototype.run = function() {
    // code...
};

var p1 = new Person();   // p1.__proto__ 指向默认的原型对象

Person.prototype = {
    constructor: Person,
    say: function() {
        // code...
    }
};

var p2 = new Person();   // p2.__proto__ 指向新的原型对象

console.log(typeof p1.say); // undefined
console.log(typeof p2.say); // "function"

2. 混入继承(又称拷贝继承)

在日常工作中,经常遇到给一个函数传递多个参数的情况。比如说,我们需要得到用户的详细地址信息。

function getAddress(country, name, city, street, code,  province, tel) {
    // code ...
}

// 当传递参数时,我们需要非常小心
getAddress('China', 'zs', 'Beijing', 'xxx', '102611', 'Beijing', '13888889999');

在上面的例子中,由于参数非常多,我们在传递参数时就必须非常小心,一旦传错,整个信息就会错乱。于是我们找到了一个很好的解决办法,可以把这些参数当成一个对象来传递。

function getAddress(obj) {
    this.country = obj.country;
    this.name = obj.name;
    this.city = obj.city;
    this.street = obj.street;
    this.code = obj.code;
    this.province = obj.province;
    this.tel = obj.tel;
}

getAddress({
    street: 'xxx',
    country: 'China',
    name: 'zs',
    city: 'Beijing',
    code: '102611',
    tel: '13888889999',
    province: 'Beijing',
});

此时我们发现,当函数的参数是一个对象时,出错的几率大大降低,因为我们的参数可以调整顺序。对应的变量接收对应的参数。但是我们又发现,函数内部这一大坨东西依然很恶心,如果信息再多点,那岂不是......

这个时候,混入继承出现了。用代码表示就是:

function getAddress(obj) {
    for (var key in obj) { // key 保存了 obj 中每一个属性的属性名
        // 获取指定属性的值
        this[key] = obj[key]; // this["street"] = obj["street"]
    }
}

getAddress({
    street: 'xxx',
    country: 'China',
    name: 'zs',
    city: 'Beijing',
    code: '102611',
    tel: '13888889999',
    province: 'Beijing',
});

这样下来,代码是不是简化了很多?

有的同学可能会问了,这仅仅是传递参数的情况,那我如何把一个对象中的属性和方法拷贝到另一个对象中呢?其实封装一下就好。

function mixin(target, source) {
    for (var key in source) {
        target[key] = source[key];
    }
    
    return target;
}

var obj1 = { name: 'zs', age: 18 };
var obj2 = {};

mixin(obj2, obj1);

简单来说,就是利用 for...in 循环,将源对象中的属性和方法拷贝到目标对象中,从而实现继承。

以上,就是关于混入继承,也称拷贝继承的实现方式。

3. 原型式继承(也叫经典继承)

混入继承很牛x,但是它也有一些问题,比如说:

var obj3 = { name:'ls', age: 18 };
var obj4 = { name: 'ww', gender: '女' };

mixin(obj4, obj3);
// obj4 = { name: "ls", gender: "女", age: 18 };

我们想要让 obj4 继承 obj3 的年龄,但是 obj4name 也被覆盖了。有时候,我们仅仅想继承自身没有的属性,而保留自身已有的属性。这个时候,混入继承是做不到了,但原型式继承却可以。

原型式继承的大致思路就是让 obj3obj4 之间产生继承关系,比如:

// 让 obj4 继承自 obj3
// obj4.__proto__ == obj3;

但是在这里我们不能直接使用 obj4.__proto__ == obj3 ,因为 __proto__ 属性时非标准属性,有浏览器兼容问题。此时,我们可以想到之前提到的继承方式:

function Person() {}
var p1 = new Person();
p1.__proto__ === Person.prototype;

为了实现上面的关系,我们可以进行相关转换,也就是通过某个构造函数,创建一个实例 obj4 ,然后让构造函数的原型对象指向 obj3 。下面我们用代码来实现一下:

var obj3 = { name:'ls', age: 18 };

function F() {}

F.prototype = obj3;  // 让 F 的实例可以访问到 obj3 的属性和方法

var obj4 = new F();

obj4.name = 'ww';

obj4.gender = '女';

console.log(obj4.name);  // obj4 有自己的 name,打印 'ww'

console.log(obj4.gender); // obj4 有自己的 gender,打印 '女'

console.log(obj4.age);  // obj4 没有自己的 age,访问 obj3 中的 age,打印 18

最后我们来总结一下原型式继承的功能:创建一个新的对象,让新的对象可以继承自指定的对象,从而新的对象可以访问到自己的属性和方法,也可以访问到指定对象的属性和方法。

其实,除了上面三种继承方法,还有比较经典的借用构造函数实现继承的方式,我会把它放在之后的文章中讲解。而基于原型链的继承,可能是我们会比较常用的继承方式,混入继承与经典继承,大家多理解就好,也许哪天面试就遇到了呢?