phenomLi / Blog

Comments, Thoughts, Conclusions, Ideas, and the progress.
219 stars 17 forks source link

JS的继承的总结 #15

Open phenomLi opened 6 years ago

phenomLi commented 6 years ago

Javascript中的继承一直是一个难点,因为js有别于其他面向对象的语言,js是基于 原型(prototype) 的。

prototype是一个很难琢磨得透也很难掌握的东西,当然也许有人会跳出来说现在都用ES6,typescript啦,谁还学prototype。这样想就错了,首先,ES6和typescript远远没有你想的这么普及,谁敢把不经过编译的es6和ts直接放到线上跑?编译后还不是一样回到prototype。其次,prototype能干的事情多的去了,不单单只是new或继承几个类,有兴趣可以看看Vue的响应式数组方法是怎么做的。

扯远了,今天写这篇东西是因为刚刚看到了一篇关于js继承的文章,想把一些思考和总结记下来。

如何实现继承

为什么在js中继承很麻烦?


一点一点来分析。

首先在ES6之前,js中是没有又甜又可爱的extends语法糖的,那要继承怎么办,只能自己在现有的js语法里面各种找方法实现(而且还不好找)。

其次,在一个js对象中,既有来自构造函数的属性,也能访问到其_proto_,也就是构造函数的prototype的属性(通常是方法)。这么理解这句话呢?看下面的例子:

const Foo = function() {
    this.count = 20;
};

Foo.prototype.getTotal = function() {
    return 400;
};

const foo = new Foo();

console.log(foo.count); //输出200
console.log(foo.getTotal()); //输出400
console.log(foo.__proto__.getTotal()); //输出400

/*
* 输出 { count: 20 }
* 可以看到在foo中并没有getTotal这个方法
*/
console.log(foo);  

/*
* 输出 { getTotal: [Function] }
* 而getTotal是在__proto__中 
*/
console.log(foo.__proto__);  

可以看到,getTotal是绑定在foo的构造函数的prototype中的一个方法,在实例化Foo后,foo既能访问它自身的属性count,也能访问getTotal方法。但是getTotal方法并没有在foo对象里面,所以很明显,当要访问一个对象的某个属性/方法时,js引擎首先在对象里面找,如果找不到,再顺着原型链往上找。foofoo.__proto__关系如下:

如果对proto和prototype的关系不了解,或者对原型链有疑惑的,建议先去了解一下,本篇文章不会细讲。


那么,也就是说,想要在js中继承一个类,就必须要做到两点:

  1. 子类要继承父类中的属性/方法

  2. 子类的prototype要继承父类的prototype

做不到第二点的都不是完整继承。
第一点的常规实现方法是:

//父类
const SuperClass = function() {
    this.a = 1;
};

//子类
const SubClass = function() {

    /*
    * 很巧妙的一直做法,因为父类的属性都定义在构造函数里面,
    * 所以只要在子类的构造函数里面用子类的上下文(this)调用一下父类的构造函数,
    * 父类的属性就都绑定到了子类的this上面去了
    */
    SuperClass.call(this);
}

//实例化子类
const sub = new SubClass();

console.log(sub.a);  //输出1

在子类的构造函数里面用一下call(当然也可以用apply,个人比较喜欢用call)调用父类的构造函数就行。

然后第二点,思路是这样子:

//简单粗暴地将父类的prototype指向子类的prototype
SubClass.prototype = SuperClass.prototype;

由于prototype是对象而不是函数,所以没法用call或者apply的方法了。

但是事情没有这么简单,仔细观察上面的代码:

SubClass.prototype = SuperClass.prototype;

发现问题了吗?js中的对象都是引用类型(对什么是引用类型不了解的可以看看这篇文章:JS中的深拷贝),对象的直接赋值都是改变指针指向的地址,也就是说:
子类的prototype和父类的prototype共享同一片内存空间了。
会造成什么问题?会造成当你想要往子类的prototype里添加属性/方法的时候,父类的prototype也会被修改:

//往子类的prototype添加了一个属性b
SubClass.prototype.b = 'phenom';
//父类的prototype也被加上了
console.log(SuperClass.prototype.b); //输出 phenom


这显然不好,但是怎么改进?不就是想要有独立分配的内存空间嘛,很简单,我们有父类SuperClass,我们直接让子类的 prototype 指向父类的实例:

//往父类的prototype里添加一个方法getA
SuperClass.prototype.getA = function() {
    return this.a;
};

//子类的prototype指向父类的一个实例
SubClass.prototype = new SuperClass();

//实例化一个子类
const sub = new SubClass();

console.log(sub.getA());  //输出1

为什么这种方法可以?其实父类的实例里面是没有getA这个方法的,getA在父类的prototype里面,但是前面已经说过了,js找一个对象的属性/方法是会顺着原型链往上找的:

/**
 * 输出 { a: 1 },没有getA方法
 */
console.log(SubClass.prototype);

/**
 * 输出 { getA: [Function] },getA方法在这里
 * 顺着原型链找到了这里
 */
console.log(SubClass.prototype.__proto__);


目前为止他们的恩怨情仇大概是这个样子:


看上图不难发现,有一个地方貌似不太合理(我故意画出来了),就是子类的prorotypeconstructor指针居然指向了父类的构造函数:

/**
 * 输出 [Function: SuperClass]
 * 原因是我们将父类实例赋值给子类的prototype时,把其constructor属性也一同覆盖了
 * 正常情况下子类的prototype的constructor应该指向子类的构造函数的
 */
console.log(SubClass.prototype.constructor);  

所以我们要做一个修正:

//修正
SubClass.prototype.constructor = SubClass;



寄生组合继承

我们把上面的所有实现都糅合起来,放进一个函数里面,并且命名为myExtends(不能直接用extends因为是保留字):

//父类
const SuperClass = function() {
    this.a = 1;
};
SuperClass.prototype.getA = function() {
    return this.a;
};

//子类
const SubClass = function() {
    SuperClass.call(this);
}

const myExtends = function(subClass, superClass) {
    //子类的prototype指向父类的一个实例
    subClass.prototype = new superClass();

    //修正
    subClass.prototype.constructor = subClass;

    return subClass;
};

myExtends(SubClass, SuperClass);

//实例化一个子类
const sub = new SubClass();


这样看起来清晰多了,但是还不完美。上面的代码中,父类的构造函数SuperClass一共被调用了两次,这不是好事,我们要想办法优化一下。

想一下,其实子类的prototype并不关心父类的构造函数中定义了哪些内容,因为父类构造函数中的内容已经在SuperClass.call(this);中继承给了子类的构造函数,我们要的只是父类prototype的内容,所以subClass.prototype = new superClass();是产生了一点冗余的,但是又不能直接赋值,因为父子两个类的prototype需要有独立的内存空间。所以,我们可以找一个能提供独立空间存放父类prototype的‘中间人’:

const myExtends = function(subClass, superClass) {
    //中间人函数
    const F = function() {};

    //存放父类的prototype
    F.prototype = superClass.prototype;

    /**
     * 子类的prototype指向中间人函数的一个实例,具有独立的内存空间,
     * 但是内存占用非常小,因为这个函数是没有内容的,而且这个中间人函数外界没法修改,
     * 所以也不会影响父类的prototype
     */
    subClass.prototype = new F();

    //修正
    subClass.prototype.constructor = subClass;

    return subClass;
};

这样就完美了。

下面贴上完整代码:

//父类
const SuperClass = function() {
    this.a = 1;
};
SuperClass.prototype.getA = function() {
    return this.a;
};

//子类
const SubClass = function() {
    SuperClass.call(this);
}

const myExtends = function(subClass, superClass) {
    //中间人函数
    const F = function() {};

    //存放父类的prototype
    F.prototype = superClass.prototype;

    /**
     * 子类的prototype指向中间人函数的一个实例,具有独立的内存空间,
     * 但是内存占用非常小,因为这个函数是没有内容的,而且这个中间人函数外界没法修改,
     * 所以也不会影响父类的prototype
     */
    subClass.prototype = new F();

    //修正
    subClass.prototype.constructor = subClass;

    return subClass;
};

myExtends(SubClass, SuperClass);

//实例化一个子类
const sub = new SubClass();

console.log(sub.getA());  //输出 1


其实上面这种实现,就是js中使用最普遍的寄生组合继承的实现。结合了各种继承的优点,而且实现起来也比较简单,容易理解。

--EOF--