浅谈对JS面向对象的理解(二)

类——是面向对象语言中共有的一个标志,基于类可以创建任意多个具有相同属性和相同方法的对象。但ECMAScript却没有类的概念,所以它的对象与其它基于类的语言中的对象是有所不同的。

一、工厂模式

为了解决实例化对象产生大量重复的代码而采用的一种方法叫 工厂模式。js使用的是工厂模式的一种变体,由于ECMAScript无法创建类,所以用函数来封装以特定接口创建对象的细节。看下面示例:

function createPerson(name,age,job){
    var obj = new Object();
    obj.name = name;
    obj.age = age;
    obj.job = job;
    obj.say = function(){
        alert("welcome to qixige");
    };

    return obj;
}

var person1 = createPerson("qixige", 18, "engineer");
var person2 = createPerson("xiaosheng", 19, "manager");

采用工厂模式的好处在于很好的消除了对象间的耦合性,将所有实例化代码集中到一个位置防止代码的大量重复;但是却存在识别问题,因为很难搞清楚他们到底是哪个对象的实例。

那么,针对保留工厂模式的优点,解决难识别的问题,构造函数模式能很好的解决这个问题。

二、构造函数模式

重写一下上面的例子:

function Person(name, age, job){
    this.name = name;
    this.age = age;
    this.job = job;
    this.say = function(){
        alert("welcome to qixige");
    }
}

var person1 = new Person("qixige", 18, "engineer");
var person2 = new Person("xiaosheng", 19, "manager");

相较于上一个例子:本例没有显示的创建对象;同时直接将属性和方法赋值给了this对象;去掉了return语句。

而每次创建实例的时候,是通过new操作符创建。使用这种方法,即解决了创建实例代码重复问题,还解决了对象难识别的问题。

这里展开的说一下,使用new操作符创建新实例具体经历了哪些过程。

  • 1、首先,new 构造函数(),实际上就是在后台执行了 new Object()操作;此时创建了一个新的对象;
  • 2、new Object()创建出新对象后,构造函数中的this就代表了这个新的对象;
  • 3、构造函数接收的参数,也就是给这个新的对象添加属性;
  • 4、最后由后台直接将该对象返回。

尽管很好的解决了工厂模式存在的问题,但是这种模式也由一个问题:

**每个方法都要在每个实例中重新创建一遍**

也就是说实例person1 和 person2 中的say()方法并不是同一个Function的实例。因为每次创建一个对象,就会在堆内存中开辟一个新的内存空间,显然person1 和 person2 在内存中的空间是不一样的,那么在该空间中的方法自然也就不会是同一个。这其实也是一个代码重复的问题。

对待这个问题,最直接的方式就是将绑定在构造函数内部的方法进行解耦,将其转移到构造函数外部变成一个全局函数,而构造函数内部该属性则变成是包含一个指向函数的指针。如下:

function Person(name, age, job){
    this.name = name;
    this.age = age;
    this.job = job;
    this.say = say;
}

function say(){
    alert("welcome to qixige");
}

var person1 = new Person("qixige", 18, "engineer");
var person2 = new Person("xiaosheng", 19, "manager");

这种方式使得对象 person1 和 person2 可以共享全局作用域中的say()函数。

虽然问题得到解决,但是却出现了新的问题:这样定义的全局函数却只能被某个对象调用,而让这个全局作用域显得名不副实,而且当对象定义了多个方法时,就需要定义多个全局函数,这降低了引用类型的封装性。对此引出了下面的一个模式:原型模式

三、原型模式

在JS中,创建的每个函数都会相应的创建一个prototype属性,它是一个指针,指向函数的原型对象;也可以这么理解,prototype就是调用构造函数而创建的对象实例的原型对象。这个原型对象中的属性和方法是所有对象实例所共享的。你可以先大概知道有这么个东西,看下面实例:

function Person(){
}

Person.prototype.name = 'qixige';
Person.prototype.age = 18;
Person.prototype.job = 'engineer';
Person.prototype.say = function(){
    alert("welcome to qixige");
};

var person1 = new Person();
person1.say();

var person2 = new Person();
person2.say();

alert(person1.say() == person2.say());

这个例子中,对象的属性和方法并没有写在构造函数中,而是定义在了原型对象中;person1 和 person2 对象调用的函数say()是同一个,alert()返回的也是 true;

接下来,继续了解原型对象的工作原理。

1、原型对象

上面说过,只要创建函数,就会为该函数创建一个prototype属性;

"wjPrototype002.jpg"

这个属性是一个指针,指向一个原型对象。默认情况下,所有的原型对象都会 自动 获得一个 constructor 属性

"wjPrototype003.jpg"

这个属性,有指向 prototype 属性所在函数;它跟 prototype 属性一样也是一个指针;

"wjPrototype004.jpg"

在创建构造函数后,原型对象默认只会取得 constructor 属性;其它的属性和方法都是从Object继承而来。对应上面的例子,在原型对象中添加了 name、age、job属性 和 say()方法;

"wjPrototype005.jpg"

此时,创建一个实例对象 Person1;同样的,在实例的内部也有一个指针 [[ Prototype ]] ,它指向构造函数的原型对象。

"wjPrototype006.jpg"

再创建一个实例对象 Person2; 内部的指针 [[ Prototype ]] 和Person1对象一样指向构造函数的原型对象。可以看到,实例跟构造函数其实没有直接的关系;创建的这两个实例都共享原型函数中的属性和方法。

"wjPrototype007.jpg"

实例对象调用原型属性的方法,是通过查找对象属性的过程来实现的;当通过对象调用属性和方法的时候,会先问实例对象有没有要调用的属性和方法;如果有,就返回;如果没有,这时才会去构造函数的原型对象里去找。

在实例对象中的 [[ Prototype ]]属性是无法访问到的。这里通过调用 isPrototypeOf() 方法来确认该实例对象是否指向原型对象。如果指向调用 isPrototype()方法的对象,则返回true;反之,返回false。

alert(Person.prototype.isPrototypeOf(person1));  //true
alert(Person.prototype.isPrototypeOf(person2));  //true

另外,需要注意的是:

实例对象可以访问保存在原型中的值,但是不能通过对象实例重写原型中的值

通过实例对象添加的属性和方法,只会保存在实例对象中;在添加与原型对象中同名的属性和方法时,实例对象中的属性就会屏蔽原型对象中保存的同名属性和方法。这样做,仅仅是阻止原型中的属性和方法,并不会修改它。

对此,如何检测,对象调用的方法是对象中的还是原型对象中的呢?

使用 hasOwnProperty() 可以解决。当检测的属性是实例属性,而不是原型属性,则返回true;反之,返回false。

function Person(){
}

Person.prototype.name = 'qixige';
Person.prototype.age = 18;
Person.prototype.job = 'engineer';
Person.prototype.say = function(){
    alert("welcome to qixige");
};

var person1 = new Person();
person1.name = "xiaosheng";
alert(person1.hasOwnProperty("name"));  //true

var person2 = new Person();
alert(person1.hasOwnProperty("name"));  //false

2、原型语法的简化

从上面的例子可以看到,在给原型对象添加属性和方法的时,会多次敲写Person.prototype;为了简化代码,采用以对象字面量的形式创建新对象,然后赋值给Person.prototype。如下实例:

function Person(){
}

Person.prototype = {
    name = 'qixige',
    age = 18
    job = 'engineer'
    say = function(){
        alert("welcome to qixige");
    }
};

这样写简化了不再重复的写Person.prototype;但是带来另一个问题,constructor属性不再指向Person; 因为采用这种写法,实际上时完全重写了默认的prototype对象,constructor属性也就成为了新对象(Object)的属性了。当然了,我们可以手动添加这个属性,让他指向Person。

function Person(){
}

Person.prototype = {
    constraintor:Person,
    name = 'qixige',
    age = 18
    job = 'engineer'
    say = function(){
        alert("welcome to qixige");
    }
};

手动添加constructor属性,虽然可以解决不指向 Person函数的问题,但是有引发了新的问题出来;

重设construcor属性,导致了它的 [[ Enumerable ]] 特性被设置成了true。默认情况下,constraintor属性是不可枚举的。解决这个问题,可以通过 Object.defineProperty()方法来解决。如下实例:

function Person(){
}

Person.prototype = {
    constraintor:Person,
    name = 'qixige',
    age = 18
    job = 'engineer'
    say = function(){
        alert("welcome to qixige");
    }
};

Object.defineProperty(Person.prototype, "constructor", {
    enumerable: false,
    value: Person
});

这种方法有一个局限性,就是它只适用于 ECMAScript5 兼容的浏览器。

3、存在的问题

1、省略了为构造函数传递初始化参数这一环节,结果所有实例再默认情况下都将取得相同的属性值。
2、对原型对象做任何修改都将反映在所有的实例上。

这两个问题,也正是所有实例都共享原型对象中的属性和方法所导致的。

四、组合使用构造函数模式和原型模式

这种方式是目前应用最为广泛的,也是用来定义引用类型的默认方式。构造函数模式用来定义实例属性,原型模式用来定义方法和共享属性。这样做既节省了内存,由解决了每个实例不能拥有自己特定的属性的问题。

function Person(name, age, job){
    this.name = name;
    this.age = age;
    this.job = job;
    this.family = ["father","mother"];
}

Person.prototype = {
    constructor: Person,
    say: function(){
        alert("welcome to qixige!");
    }
}    

var person1 = new Person("qixige", 18, "engineer");
var person2 = new Person("xiaosheng", 27, "manager");

person1.family.push("sister");
alert(person1.family);  //["father","mother","sister"]
alert(person2.family);  //["father","mother"]

五、参考链接

1、《JavaScript 高级程序设计 (第三版)》 人民邮电出版社

-------------本文结束感谢您的阅读-------------
Mr.wj wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!