JS面向对象编程-继承

注:本文基于ES5,TypeScript/ES6已经有了Class和extends。

随着浏览器性能的不断提升和前端技术的发展,JS能做的越来越多,已经远远超出了网页小工具的范畴。 随着功能的增多,代码也越来越复杂, 为了更加高效的编写JS代码,面向对象OOP的编程方式已经越来越多的应用于JS编程中。

继承是面向对象编程的最最基础的概念,本文主要讲解JS中继承的实现方式及原理。

实现原理

经典的面向对象的编程语言(比如Java),使用Class实现继承很简单,只需要extends就可以实现。

但是JS本身并没有Class的概念(ES6已经添加),其继承是通过prototype来实现的。

可以通过一个例子来看一下这两种实现方式的差异, 用两种方式描述下:zht是个程序员

  • class:zht is an instance of class Programmer.(zht是程序员类的一个对象)
  • prototype:zht is based on another object, called Programmer.(zht基于一个叫做程序员的对象)

原型链

先介绍一个概念"原型链"(Prototype Chaining), JS中的每个function都有一个prototype属性对象,当使用new来调用这个函数生成一个对象的时候,这个对象就有了一个隐藏的指向构造函数的prototype的link引用(通常叫做__proto__), 在浏览器调试器中可以看到这个属性

通过__proto__这个link,对象就可以像使用自身属性/方法一样调用prototype中的属性/方法, 而__proto__又是一个对象,它里面又会包含指向其构造函数的__proto__,这样就构成了原型链。

对象属性get/set

  • 读取对象属性/方法(get)

    当访问对象的属性/方法的时候(比如调用toString()),首先会查询对象自己有没有对应的属性,如果有就直接返回,如果没有找到就去prototype去找, 如果还没找到继续去prototype的prototype中去找,这样一直循环找下去,直到找到或者找到最顶层(Object)为止。这是prototype继承的基础。

  • 设置对象属性/方法(set)

    直接设置一个对象的属性/方法,是将属性设置到对象上,而不是prototype。

    举个例子obj.name = "new name",直接设置obj的name属性,对obj的prototype没有任何影响,可通过hasOwnProperty查看。

具体实现

本文主要介绍两种实现继承的方法,两种方法各有所长,也可以组合使用。

方法一

通过替换子类prototype的方式,实现方法

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

下面通过代码,以渐进的方式来介绍下这个方法是如何产生的。

1> 有三个有继承关系的构造函数Shape/TwoDShape/Rectangle。

                
                    function Shape() {
                        this.name = 'shape';
                        this.toString = function () {
                            return this.name;
                        };
                    }
                    function TwoDShape() {
                        this.name = '2d shape';
                    }
                    function Rectangle(width, height) {
                        this.name = 'Rectangle';
                        this.width = width;
                        this.height = height;
                        this.getArea = function () {
                            return this.width * this.height;
                        };
                    }
                
            

为了实现继承,可以将子类的prototype设置成父类的一个对象,这样通过这个prototype对象,就可以访问父类的相关属性/方法。

因为替换prototype会丢掉constructor,所以需要将其重新赋值。

                
                    TwoDShape.prototype = new Shape();
                    TwoDShape.prototype.constructor = TwoDShape;
                    Rectangle.prototype = new TwoDShape();
                    Rectangle.prototype.constructor = Rectangle;
                
            

2> 将共享方法抽象到函数prototype中。

一般对象的属性/方法分两种

  • 所有对象共享属性/方法
  • 比如Shape中的name,Rectangle中的getArea方法,所有对象都会共用,这些共享属性/方法才是需要继承的。
  • 每个对象特有属性/方法
  • 比如Rectangle的width/height属性,每个对象的width/height可能都不相同,这些特有属性/方法是不需要继承的。

对于共享属性,如果在构造函数中通过this直接设置到对象上,那么每生成一个对象,这个对象都会有一组对应的共享属性,而且每个对象中的属性都是重复一样的。

比如每次生成一个Shape对象,这个对象都会有一个name(="shape")属性, 这无疑增大了Shape对象的大小。

对于共享属性/方法,没必要每个对象都复制一份,只需要能引用到这个属性就可以了,而这与prototype的思路正好相同。

将name属性设置到Shape的prototype上,这样每个生成的Shape对象即不会包含name属性,又可以通过prototype随时访问,why

修改以后的代码

                
                    function Shape() {
                    }
                    //share
                    Shape.prototype.name = 'shape';//##
                    Shape.prototype.toString = function () {//##
                        return this.name;
                    };

                    function TwoDShape() {
                    }
                    TwoDShape.prototype = new Shape();
                    TwoDShape.prototype.constructor = TwoDShape;
                    // share
                    TwoDShape.prototype.name = '2d shape';//##

                    function Rectangle(width, height) {
                        this.width = width;
                        this.height = height;
                    }
                    Rectangle.prototype = new TwoDShape();
                    Rectangle.prototype.constructor = Rectangle;
                    //share
                    Rectangle.prototype.name = 'rectangle';//##
                    Rectangle.prototype.getArea = function () {//##
                        return this.width * this.height;
                    };
                
            

3> 直接继承父类的prototype。

既然需要继承的函数/属性就都加到了prototype中,那么子类只需要继承父类的prototype就可以了,就没必要通过生成父类对象的方式来实现继承了。 而且通过生成父类对象的方式可能会将很多不需要继承的特有属性也设置到子类的prototype中。

继续改进

                
                    function Shape() {
                    }
                    //share
                    Shape.prototype.name = 'shape';
                    Shape.prototype.toString = function () {
                        return this.name;
                    };

                    function TwoDShape() {
                    }
                    TwoDShape.prototype = Shape.prototype;//##
                    TwoDShape.prototype.constructor = TwoDShape;
                    //share
                    TwoDShape.prototype.name = '2d shape';

                    function Rectangle(width, height) {
                        this.width = width;
                        this.height = height;
                    }
                    Rectangle.prototype = TwoDShape.prototype;//##
                    Rectangle.prototype.constructor = Rectangle;
                    //share
                    Rectangle.prototype.name = 'Rectangle';
                    Rectangle.prototype.getArea = function () {
                        return this.width * this.height;
                    };
                
            

4> 避免父类/子类prototype相互干扰。

上述代码中,因为直接将父类的prototype赋给了子类,所有父类/子类的原型都指向了同一个对象。

这样就产生了另外一个问题,因为是父类/子类指向同一个对象,所以子类修改prototype中的值,会对父类造成影响。

在上述代码中

                
                    var shape = new Shape();
                    shape.toString();//"Rectangle"
                
            

而这并不是我们想要的结果,为了解决这个问题,可以借助一个空函数对象。

继续改进

                
                    function Shape() {
                    }
                    //share
                    Shape.prototype.name = 'shape';
                    Shape.prototype.toString = function () {
                        return this.name;
                    };

                    function TwoDShape() {
                    }
                    var F = function () {
                    };
                    F.prototype = Shape.prototype;
                    TwoDShape.prototype = new F();//##
                    TwoDShape.prototype.constructor = TwoDShape;
                    //share
                    TwoDShape.prototype.name = '2d shape';
                    function Rectangle(width, height) {
                        this.width = width;
                        this.height = height;
                    }

                    var F = function () {
                    };
                    F.prototype = TwoDShape.prototype;
                    Rectangle.prototype = new F();//##
                    Rectangle.prototype.constructor = Rectangle;
                    //share
                    Rectangle.prototype.name = 'rectangle';
                    Rectangle.prototype.getArea = function () {
                        return this.width * this.height;
                    };
                
            

子类的prototype都指向了这个空构造函数F的实例对象,因为F是个空构造函数,生成的对象不包含任何其它特有属性,所以不会有数据冗余。

这里的空函数对象有两个作用

  • 对父类的prototype的引用
  • 避免父类/子类prototype的相互干扰
  • 当子类修改prototype的属性时,其实修改的是F的实例对象的属性,并不会对父类的prototype有影响。 关于这里为什么可以避免父类/子类prototype相互干扰,可以参考上文中的,设置对象属性/方法(set)

(添加Child.superClass = Parent.prototype;这个逻辑是为了子类调用父类的构造函数/方法,关于这个,会另外再写文介绍。)

方法二

通过将父类prototype所有属性复制到子类prototype的方式,实现方法

                
                    function extend(Child, Parent) {
                        var p = Parent.prototype;
                        var c = Child.prototype;
                        deepCopy(p,c)
                    }
                    function deepCopy(p, c) {
                        var c = c || {};
                        for (var i in p) {
                            if (typeof p[i] === 'object') {
                                c[i] = (p[i].constructor === Array) ? [] : {};
                                deepCopy(p[i], c[i]);
                            } else {
                                c[i] = p[i];
                            }
                        }
                        return c;
                    }
                
            

通过这种方法,父类prototype中的属性/方法都会被copy到子类prototype中,从而实现了共享。

因为是将父类prototype都copy到了子类prototype中,所以这种方法相对于方法一,效率比较低。

但是为什么还要介绍这个方法呢? 这主要还牵扯到了面向对象中的另外一个常用概念:接口

在Java中可以通过implements的方式让类实现一个或多个接口, 为了在JS中实现类似接口功能,通过方法一的方式就不太合适了,主要原因有两个

  • 通过替换prototype的方式每次新的接口都会将其它接口的prototype替换掉,只剩最后一个。
  • 子类的superClass就变成接口了,而我们希望做到的只是实现接口,不希望改变父类。

这种情况下,就可以使用方法二来实现相关接口功能(这也实现多重继承的一个思路)。

我的实现

最后贴一个我自己的实现,除了继承还扩展了其他功能,代码片段如下

                
                    extend: function (Child, Parent, props) {
                        var F = function () {
                        };
                        F.prototype = Parent.prototype;
                        Child.prototype = new F();
                        Child.prototype.constructor = Child;
                        Child.superClass = Parent.prototype;
                        Child.__zsuperClass_ = Parent;
                        if (props) {
                            zUtil._eachObject(props, function (p, v) {
                                if (p.indexOf("___z") == 0) {
                                    if (p === "___zdefaults_") {
                                       ......
                                    } else {
                                        var gn = "";
                                        if (p === "___zsg") {
                                            gn = "get";
                                        } else if (p === "___zsi") {
                                            gn = "is";
                                        }
                                        ......
                                    }
                                } else {
                                    if (p == "__className_") {
                                        ClassUtil.__classNameMap[v] = Child;
                                        Child.__className_ = v;
                                    } else {
                                        Child.prototype[p] = v;
                                    }
                                }
                            });
                        }
                    }
                
            

使用用例

                
                    var Person = function (name) {
                        this._name = name;
                    };
                    z.util.extend(Person, Object, {//继承自Object
                        //将setName/getName设置到Person的prototype
                        getName: function () {
                            return this._name;
                        },
                        setName: function (name) {
                            return this._name = name;
                        }
                    });
                    var Programmer = function () {
                        Programmer.superClass.constructor.apply(this, arguments);//调用父类构造函数
                    };
                    z.util.extend(Programmer, Person, {//继承自Person
                        //将code设置到Programmer的prototype
                        code: function () {
                            var name = this.getName();
                            console.log("I'm "+name+", i'm coding");
                        }
                    });
                    var programmer = new Programmer("Zhang Tao");
                    programmer.getName();//"Zhang Tao"
                    programmer.code();//I'm Zhang Tao, i'm coding
                
            

关于这个的具体使用,在后续JS面向对象编程相关文章中会再做详细介绍。

注:本文部分内容参考Object-Oriented JavaScript。