Javascript 面向对象程序设计

1,169 阅读11分钟

一、理解对象

两个属性类型

1、数据属性

数据属性有四个描述其行为的特性:

  • configurable:表示能否用delete关键字删除;能否修改属性的特性,或者修改为访问器属性。通过对象字面量定义的属性,该特性默认为true。
  • enumerable:能否通过for...in遍历到该属性。通过对象字面量定义的属性,该特性默认为true。
  • writable:能否修改属性的值。通过对象字面量定义的属性,该特性默认为true。
  • value:该属性的值。默认值为undefined。

2、访问器属性

访问器属性有四个描述其行为的特性:

  • configurable:表示能否用delete关键字删除;能否修改属性的特性,或者修改为访问器属性。通过对象字面量定义的属性,该特性默认为true。
  • enumerable:能否通过for...in遍历到该属性。通过对象字面量定义的属性,该特性默认为true。
  • get:读取属性时调用该函数,默认为undefined。
  • set:写入属性时调用该函数,默认为undefined。

定义属性以及属性类型

1、定义单个属性

var person = {};
Object.defineProperty(person,'name',{
    configurable:true,
    enumerable:true,
    writable:true,
    value:'Eallon',
})

值得注意的是,如果将configurable的值设置为false,那么该属性将永远不能再设置成true了。

2、定义多个属性

var hero = {};
Object.defineProperties(person,{
    //不可修改的属性
    name:{
        writable:false, 
        value:'VN'
    },
    // 可以修改的属性
    position:{
        writable:true,
        value:'ADC'
    },
    // 前面加"_"的属性,我们一般约定为内部访问的属性
    _attackSpeed:{
      writable:true,
      value:0.8
    },
    // 访问器属性
    attackSpeed:{
        get:function(){
            return this._attackSpeed;
        },
        set:function(newValue){
            this._attackSpeed = newValue > 2.5 ? 2.5 : newValue;
        }
    }
})

读取属性的特性

 var descriptor = Object.getOwnPropertyDescriptor(hero,'attackSpeed');
 console.log(descriptor);

二、创建对象

工厂模式

工厂模式,将创建对象的代码封装在一个简单的函数中,然后返回新创建的对象。

function createHero(name,position){
    var hero = new Object();
    hero.name = name;
    hero.position = position;
    hero.say = function(){
        console.log("I am " + this.name);
    };
    return hero;
}
var VN = createHero('VN','ADC');
var EZ = createHero('EZ','Mid');

优点:一套代码可以创建多个相似的对象。

缺点:创建的对象都是属于Object类,无法标识其具体类型。

构造函数模式

    function Hero(name,position){
        this.name = name;
        this.position = position;
        this.say = function () {
            console.log("I am " + this.name);
        }
    }
    var VN = new Hero("VN","ADC");
    var EZ = new Hero("EZ","Mid");

优点:使用构造函数创建的对象已经能区分对象类型了,如VN和EZ都是Hero的实例。

缺点:相同的方法,在多个实例上要重复创建,如say方法。

原型模式

    function Hero(){

    }
    Hero.prototype.name = "VN";
    Hero.prototype.position = "ADC";
    Hero.prototype.sup = ['蕾欧娜','布隆'];
    Hero.prototype.say = function () {
        console.log("我叫" + this.name + ",我的辅助们: "+ this.sup.toString() );
    };
    var VN = new Hero();
    var EZ = new Hero();

    EZ.sup.push('拉克丝');

    EZ.name = "EZ";

    VN.say();   // 我叫VN,我的辅助们: 蕾欧娜,布隆,拉克丝
    EZ.say();   // 我叫EZ,我的辅助们: 蕾欧娜,布隆,拉克丝

在构造函数的prototype上定义的属性是所有实例共用的,每个实例都有一个内部指针(__proto__)来指向构造函数的prototype对象。也就是说,构造函数的prototype与所有实例的__proto__是指向同一个内存地址的。

将共用的方法定义到prototype上是再合适不过了,这样就解决了构造函数中定义的方法在实例初始化时重复创建的问题,现在所有的实例都统一使用原型上的say方法,大家都不需要再自己单独创建say方法了。

但是,如果将一个引用类型的属性定义到prototype上会存在隐患,比如EZ将“拉克丝”加入到了辅助列表,结果VN获取辅助列表时发现自己也有“拉克丝”了,这是因为EZ将“拉克丝”加入到prototype的sup中了,所有的实例都会受到影响。

当然,如果只是基本类型的属性,倒是问题也不大,比如EZ定义了name="EZ",其实EZ只是在自己的实例上定义了一个新的name属性,而并没有更改prototype中的name属性,prototype的name属性还是VN。

组合使用构造函数 + 原型模式(最常用)

单独使用构造函数或原型模式都会有一些问题,但是将两者结合使用取长补短,则可以完美解决对象的创建问题,我们把上面的例子用组合方式再写一遍。

function Hero(name,position){
        this.name = name;
        this.position = position;
        this.sup = ['蕾欧娜','布隆'];
    }

    Hero.prototype.say = function () {
        console.log("我叫" + this.name + ",我的辅助们: "+ this.sup.toString() );
    };
    var VN = new Hero('VN','ADC');
    var EZ = new Hero('EZ','Mid');

    EZ.sup.push('拉克丝');

    VN.say();   // 我叫VN,我的辅助们: 蕾欧娜,布隆
    EZ.say();   // 我叫EZ,我的辅助们: 蕾欧娜,布隆,拉克丝

现在,我们在构造函数中定义实例特有的属性(name、position、sup),在构造函数的prototype上定义所有实例共用的方法(say)。VN和EZ自己维护自己的辅助们,互不干涉,但是他们可以使用共同的say方法。

动态原型模式

 function Hero(name,position){
        this.name = name;
        this.position = position;
        this.sup = ['蕾欧娜','布隆'];
        if(typeof this.say !== "function"){
            Hero.prototype.say = function () {
                console.log("我叫" + this.name + ",我的辅助们: "+ this.sup.toString() );
            };
        }
    }
    
    var VN = new Hero('VN','ADC');
    var EZ = new Hero('EZ','Mid');

    EZ.sup.push('拉克丝');

    VN.say();   // 我叫VN,我的辅助们: 蕾欧娜,布隆
    EZ.say();   // 我叫EZ,我的辅助们: 蕾欧娜,布隆,拉克丝

动态原型模式,将关于prototype的定义写到了构造函数中,这样的好处是看起来封装性和整体性更好一点。

在构造函数中,先判断say函数是否已经存在,也就是说say函数是在VN实例化的时候创建到prototype中的,等到EZ实例化的时候,prototype中已经存在say函数了,就不会再次创建了。

寄生构造函数模式

 function Hero(name,position){
        var hero = new Object();
        hero.name = name;
        hero.position = position;
        hero.sup = ['蕾欧娜','布隆'];
        hero.say = function () {
            console.log("我叫" + this.name + ",我的辅助们: "+ this.sup.toString() );
        };
        return hero;
    }

    var VN = new Hero('VN','ADC');
    var EZ = new Hero('EZ','Mid');

我们发现Hero函数里面的代码跟工厂模式差不多,唯一不同的是调用方式不同,该模式是用new关键字来调用Hero函数的。虽然是用构造函数的方式初始化的实例,但是得到的对象却与构造函数以及构造函数的原型没有任何关系,跟普通对象没有什么区别。所以,尽量不要用这种方式创建对象,因为完全没有必要。

稳妥构造函数模式

 function Hero(name,position){
        var hero = new Object();
        hero.say = function () {
            console.log("我叫" + name + ",我打" + position);
        };
        return hero;
    }

    var VN = Hero('VN','ADC');
    var EZ = Hero('EZ','Mid');
    VN.say();   // 我叫VN,我打ADC
    EZ.say();   // 我叫EZ,我打Mid

稳妥构造函数模式,不使用new和this关键字,直接调用Hero函数。调用Hero时传进去的参数,在返回的hero对象中无法直接访问,只能通过say方法来访问内部的成员变量。


三、继承

原型链

原型链是什么?
每个实例对象都有一个指向构造函数原型的内部指针(__proto__),实例可以使用原型中的属性和方法,如果我们把子类构造函数的prototype指向父类的一个实例,那么子类实例的__proto__的__proto__就是父类构造函数的prototype,那么子类实例也可以使用父类构造函数的prototype中定义的属性和方法了。像这样,实例与构造函数原型之间形成的这个链条就是原型链。

 function Hero(name,gender) {
        this.name = name;
        this.gender = gender;
        if(typeof this.say != 'function'){
            Hero.prototype.say = function () {
                console.log("I am " + this.name + ", I am " + this.gender);
            }
        }
    }
    function ADC(name,gender) {
        this.name = name;
        this.gender = gender;
        this.position = "ADC";  // 子类扩展的属性
    }
    ADC.prototype = new Hero();
    var VN = new ADC("VN",'female');

使用原型链的继承方式,我们实现了子类可以复用父类原型上的属性和方法。
但是,我们还是会面临以下几个问题:

  • 父类实例上的属性和方法被放到了子类原型上,ADC的原型上有父类的实例属性name和gender。
  • 初始化父类实例给子类原型赋值时,不知道怎么传参数。也因此,ADC原型的name和gender都是undefined。
  • 子类构造函数中重复写了一遍父类构造函数的代码

借用构造函数

function Hero(name,gender) {
        this.name = name;
        this.gender = gender;
        if(typeof this.say != 'function'){
            Hero.prototype.say = function () {
                console.log("I am " + this.name + ", I am " + this.gender);
            }
        }
    }
    function ADC(name,gender) {
        Hero.apply(this,arguments);
        this.position = 'ADC';
    }
    ADC.prototype = new Hero();
    var VN = new ADC("VN",'female');

为了解决子类构造函数中重复写父类构造函数代码的问题,我们在子类ADC的构造函数中用apply或者call方法调用父类构造函数Hero,然后再声明子类ADC自己的属性position。
此处应注意,为了防止调用父类构造函数时覆盖子类自己的属性,我们应该像上面一样,先调用父类构造函数,再声明子类自己的属性。

组合继承

上面的例子其实就已经是组合继承的例子了。我们在子类的构造函数中调用父类的构造函数来使子类也有父类一样的实例属性,然后将父类的一个实例赋值给子类的原型,从而使子类实例与父类原型之间产生了一条原型链。这样就使得子类既继承了父类原型的属性,也继承了父类实例的属性。
这种组合继承的方式是使用最广泛的,但也还是没有解决子类原型中出现父类实例属性的问题,虽然这个问题不影响正常使用,但总觉得不太完美,下面会有更加完美的解决方案。O(∩_∩)O

原型式继承

    function object(o) {
        function F() {}
        F.prototype = o;
        return new F();
    }

    var ADC = {
        position:'ADC',
        say:function () {
            console.log("I am " + this.position);
        }
    };
    var VN = object(ADC);
    VN.name = 'VN';

原型式继承的思路是,以一个已知的对象为原型创建一个新的对象,然后在新的对象上可以扩展新的属性。

ECMAscript5 新增的Object.create()方法规范化了原型化继承。该方法接受两个参数,第二个参数是扩展在实例上的属性,第二个参数的格式与Object.defineProperties的第二个参数的格式一样。如果只传第一个参数,则与上面的object函数的行为一样。
上面的object例子,可以这样写:

    var ADC = {
        position: 'ADC',
        say: function () {
            console.log("I am " + this.position);
        }
    };
    var VN = Object.create(ADC, {
        name: {
            writable: false,
            value: 'VN'
        }
    });

寄生式继承

function createHero(obj) {
    var clone = Object.create(obj);
    clone.attack = function () {
        console.log("走位,A")
    };
    return clone;
}
var hero = {
    name:'hero',
    say: function () {
        console.log(this.name);
    }
};
var vn = createHero(hero);

寄生式继承与原型式继承的区别是,在函数内部还扩展了实例属性。

寄生组合式继承

重头戏来了!!!寄生组合式继承是目前最理想的继承方式。个人认为,该方式也是最完美的继承方式,因为该方式基本上解决了之前我们遇到的所有问题。

 // 继承prototype
    function inheritPrototype(Sub, Super) {
        var prototype = Object.create(Super.prototype);
        prototype.constructor = Sub;
        Sub.prototype = prototype;
    }
    
    function Hero(name, gender) {
        this.name = name;
        this.gender = gender;
        if (typeof this.say != 'function') {
            Hero.prototype.say = function () {
                console.log(this.name);
            }
        }
    }
    
    function ADC(name, gender) {
        Hero.apply(this, arguments);
        this.position = "ADC";
    }

    inheritPrototype(ADC, Hero);
    
    ADC.prototype.attack = function () {
        console.log("ADC要走A");
    };
    
    var vn = new ADC("vn", 25);

该模式与组合式继承相比的精髓之处体现在inheritPrototype函数中,此处我们不再是实例化一个Hero实例直接赋值给ADC的prototype,因为这样做的话,ADC的prototype中会出现Hero实例的属性,这不是我们想要的。我们是这样做的,我们以寄生的方式得到一个以Hero的prototype对象为原型的实例对象,该对象没有实例方法,内部指针指向Hero的prototype。然后我们将该实例对象的constructor属性指向ADC,再然后赋值给ADC的prototype,这样就完美实现了原型的继承。

上面代码中的Object.create方法的调用是关键一环,那它做了什么?
首先它以Hero的prototype对象为原型创建了一个构造函数,然后用新创建的构造函数实例化一个对象,那么该实例对象的内部指针__proto__是指向Hero的prototype的(这是我们的目的),然后把该对象返回。我们拿到该对象后,要把该对象的constructor指向子类ADC,因为此时该对象的constructor是指向父类Hero的。再然后,就是把该实例对象赋值给ADC的prototype即实现了原型的继承。(此处很重要,所以啰嗦了好几遍O(∩_∩)O)

总结下寄生组合继承的思路:

  1. 通过在子类构造函数中调用父类构造函数来继承实例属性,使用apply或者call方法实现。
  2. 获取一个以父类prototype对象为原型的实例对象(使用Object.create方法实现),将该对象的constructor属性指向子类构造函数(因为此时该属性的constructor还是指向父类构造函数的)。
  3. 将第2步得到的实例对象赋值给子类的prototype,实现原型的继承。