JS设计模式深入理解—单例、工厂、构造函数、原型、组合构造原型、动态原型

了解并掌握各种JavaScript用于创建自定义类型对象的设计模式有利于帮助我们认识它们各自的优缺点和适用场景,这样我们在今后的开发过程中才能够做到有的放矢,在正确的场合使用正确的模式创建对象。

一、单例模式

var person = new Object();
person.name = "Nicholas";
person.age = 29;
person.job = "Software Engineer";

person.sayName = function() {
    alert(this.name);
};

单例模式是指通过创建一个Object对象,并为其设置各种属性和方法,以满足自定义对象的使用需求,如上所示;很明显,上面的多条语句显得十分分散,为了更好地将它们组合起来,更好的办法是使用对象字面量创建:

var person = {
    name: "Nicholas",
    age: 29,
    job: "Software Engineer",

    sayName: function() {
        alert(this.name);
    }    
};

模式评价:虽然Object构造函数或对象字面量都可以用来创建单个对象,但这些方式有两个明显的缺点:

  • 没有做到代码的复用,即要是使用同样的办法创建多个对象,会产生大量的重复代码。
  • 创建出的对象没有具体的类型,它们只是Object类型的一个实例。

二、工厂模式

为了解决单例模式的代码复用问题,更好的办法是采用工厂模式:

function createPerson(name, age, job) {
    var o = new Object();
    o.name = name;
    o.age = age;
    o.job = job;
    o.sayName = function() {
        alert(this.name);
    };
    return o;
}

var person1 = createPerson("Nicholas", 29, "Software Engineer");
var person2 = createPerson("Greg", 27, "Doctor");

由于JavaScript中无法创建类,所以人们就发明了一种函数,用函数来封装以特定接口创建对象的细节,这就是工厂模式的设计原理。

在上面的例子中,函数createPerson()能够根据接受的参数来构建一个包含所有必要信息的Person对象,并且可以无数次地调用这个函数,而每次调用都会返回一个包含三个属性和一个方法的对象。

模式评价:工厂模式虽然解决了创建多个相似对象造成的代码重复问题,但仍未解决对象类型的区分问题(怎样知道一个对象的具体类型);通过工厂模式创建出的对象,其类型都是Object,如果能把上例中创建出的对象标记为Person类型就好了。

同时,通过工厂模式创建出的对象还存在着一个很严重的问题,那就是内存浪费。每当调用一次createPerson()函数创建一个对象,就会在其内部创建一个函数实例。在前面的例子中,person1person2都有一个名为sayName()的方法,但person1.sayName()person2.sayName()并不是引用的同一个函数实例,而是不同的实例,因为

o.sayName = function() {
    alert(this.name);
};

o.sayName = new Function("alert(this.name)");

在逻辑上是完全等价的。为了证明person1.sayName()person2.sayName()引用的是不同的函数实例,有:

alert(person1.sayName == person2.sayName)      //false

因此,若一个自定义对象类型中要是有多个方法,那么通过工厂模式定义出多个对象造成的内存浪费就可想而知了。

三、构造函数模式

为了解决对象的类型问题,可以使用构造函数模式,JavaScript中的构造函数可以用来创建特定类型的对象:

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

var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");

首先需要明确什么样的函数称为构造函数,即通过new操作符+函数名的方式来创建对象的函数,就叫做构造函数。

构造函数创建对象实例的过程:

  1. 创建一个新对象;
  2. 把当前构造函数内的作用域赋给新创建的这个对象(此时构造函数内部的this指针就指向了这个新对象);
  3. 执行构造函数中的代码;
  4. 返回新对象

同时构造函数还有一个极其重要的特性:默认情况下,若构造函数内部没有通过return语句返回其它类型的变量或对象,则通过构造函数创建并返回的对象其类型由构造函数名指定

这个特性的意思就是,在上例中,通过名为Person的构造函数创建出的person1person2对象实例,其对象类型都是Person;也就是说,它们都是Person类型的对象实例(可以使用instanceof检验):

alert(person1 instanceof Person);      //true
alert(person2 instanceof Person);      //true

正因为这个特性,所以才为什么说构造函数可以用来创建特定类型的对象

这样一来,构造函数模式就很好理解了:

  1. 通过new Person()调用Person构造函数,创建出一个Person类型的对象。
  2. Person构造函数内部的作用域赋给该对象,即此时函数内部的this指向了该对象。
  3. 执行构造函数代码,通过this为该对象创建属性和方法。
  4. 由于Person构造函数内部没有用return语句返回其它变量和对象,所以Person构造函数返回该对象。

进一步优化
为了解决工厂模式提到的内存浪费问题,我们发现创建两个完成同样任务的Function实例的确没有必要;况且有this对象在,根本不用在执行代码前就把函数绑定到特定对象上。因此,可以把公共函数的定义转移到构造函数外部:

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

function sayName() {
    alert(this.name);
};

var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");

模式评价
构造函数模式有效地解决了对象的类型问题,是比工厂模式更佳的解决方案。同时为了解决工厂模式中遇到的内存浪费问题,选择将公共函数的定义转移到构造函数的外部,看样子解决了目前遇到的所有问题。然而新的问题又来了:把公共函数定义在全局作用域中,而仅仅只是为了供对象调用,看起来似乎有些小题大做了。而更让人感到违和的是,如果一个对象有很多公共函数,或者把所有对象的公共函数定义都放在全局作用域中,暂且不说我们这个自定义的引用类型毫无封装性可言,更有可能会与全局函数的定义混在一起难以区分,从而增加了代码的维护成本。因此我们仍需要找一个具有更好封装性的解决方案。

四、原型模式

我们创建的每个函数都有一个prototype(原型)属性,这个属性是一个指针,指向一个对象(原型对象),而这个对象的用途是可以包含由特定类型的所有实例共享的属性和方法。因此产生了原型模式:

function Person() {
}

Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() {
    alert(this.name);
};

var person1 = new Person();
person1.sayName();        //Nicholas

var person2 = new Person();
person2.sayName();        //Nicholas

alert(person1.sayName == person2.sayName)    //true

由于原型对象的存在,我们可以在构造函数中什么都不写,只要是通过构造函数创建的对象实例(new操作符),都能够与函数的原型对象相关联,从而访问原型对象中的属性和方法。这也是为什么person1.sayName == person2.sayName,因为它们访问的属性和方法都是位于原型对象中的同一份。

每当创建一个函数时,原型对象会自动获得一个属性:constructor,该属性指向prototype所在函数,即在本例中,Person.prototype.constructor == Person当利用构造函数创建一个对象实例时,创建出的对象会自动获得一个指向当前构造函数原型对象的内部指针[[prototype]](虽然无法访问但却是真实存在的),这也解释了为什么所有通过构造函数创建的实例能够访问同一个原型对象中的属性和方法。

再看JavaScript引擎是如何搜索某个对象的某个属性的:每当代码读取某个对象的某个属性时,都会执行一次搜索,目标是具有给定名字的属性。搜索先从对象实例本身开始。如果在实例中找到了具有给定名字的属性,则返回该属性的值;否则继续搜索
[[prototype]]内部指针指向的原型对象,在原型对象中查找具有给定名字的属性。如果在原型对象中找到了该属性,则返回原型对象中的该属性值。

原型模式利用了所有对象实例访问同一个原型对象的机制来解决内存浪费问题,不过在前面的例子中,每添加一个属性和方法都要敲一遍Person.prototype。为减少冗余代码,也为视觉上更好的封装性,更常见的做法是用一个包含所有属性和方法的对象字面量来重写整个原型对象:

function Person() {
}

Person.prototype = {
    name: "Nicholas",
    age: 29,
    job: "Software Engineer",
    sayName : function () {
        alert(this.name);
    }
};

然而这样又会带来一个问题,那就是利用对象字面量来重写Person.prototype后,Person.prototype.constructor已经不再指向Person了;这是因为利用对象字面量创建出的对象是Object()构造函数创建出的实例,其prototype.constructor指向的是Object()构造函数;因此,如果constructor十分重要的话,还需要将其显式设置回原来的值:

function Person() {
}

Person.prototype = {
    construct: Person,
    name: "Nicholas",
    age: 29,
    job: "Software Engineer",
    sayName : function () {
        alert(this.name);
    }
};

模式评价:基于原型对象的特点,原型模式可以将自定义类型的方法定义在原型对象内部,而不用再将其放到全局作用域中,因此从这一点考虑,它是比构造函数模式更优的解决方案;然而原型模式也并非没有缺点,那就是它省略了为构造函数传递初始化参数的这一环节,结果使得所有实例在默认情况下都取得相同的属性值,每个对象无法拥有自己独特的属性内容,这显然是与“利用相同模板创建不同对象”的理念背道而驰的。因此这也正是很少看到有人单独使用原型模式创建对象的原因所在。

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

为了解决原型模式所遇到的困境,自然而然会想到利用原型对象定义公共方法,而把共享的属性放到构造函数中的方式来创建对象,而这正是组合使用构造函数模式和原型模式的思路:

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

Person.prototype = {
    constructor: Person,
    sayName: function() {
        alert(this.name);
    }
};

var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");

这样一来,每个对象都有自己的属性,但同时又能共享一份相同的方法,最大限度地节约了内存。
模式评价:这种混成模式集构造函数模式和原型模式各自之长,是目前JavaScript中使用最广泛、认同度最高的一种创建自定义类型的方法。可以说,这是用来定义引用类型的一种默认模式

六、动态原型模式

即便是已经拥有了如此好的用来定义引用类型的设计模式,但习惯于使用Java/C++等语言的开发人员可能仍会认为:组合使用构造函数模式和原型模式的设计模式感觉还是和单独使用构造函数模式差不多,前者虽然比后者好那么一些,但还是没法做到彻底地封装,毕竟构造函数和原型对象依然是分开定义的,从这一点来说,两者并没有多大差别。为了做到将两者结合到一起,实现彻底的封装,于是就有了动态原型模式:

function Person(name, age, job) {
    //属性
    this.name = name;
    this.age = age;
    this.job = job;
    
    //方法
    if(typeof this.sayName != "function") {
        //所有的公有方法都在这里定义
        Person.prototype.sayName = function() {
            alert(this.name);
        };

        Person.prototype.sayJob = function() {
            alert(this.job);
        };


        Person.prototype.sayAge = function() {
            alert(this.age);
        };
    }
}

var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");

person1.sayName();        //Nicholas
person2.sayName();        //Greg

动态原型模式把所有信息都封装到了构造函数中,既通过在构造函数中初始化原型(只会在第一次调用构造函数创建对象时进行),也保持了同时使用构造函数和原型的优点。

我们可以分析一下该模式创建对象实例的过程:

  1. 首先通过new Person()调用构造函数,此时立刻创建了一个Person类型的对象。
  2. 新创建的对象获得了指向构造函数原型对象的指针,此时的原型对象中只有constructor属性,且指向Person函数。
  3. Person构造函数内部的作用域赋给该对象,即此时函数内部的this指向了该对象。
  4. 进入构造函数执行内部语句,首先设置新对象的name,age,job属性
  5. 随后搜索并判断新对象中是否已存在有效的sayName()函数,如果不存在,说明原型对象中还没有添加该方法,此时创建原型对象中的对应方法。
  6. 由于新对象中保存的是指向原型对象的指针,所以在原型对象中添加的方法能被新对象动态访问到
  7. 返回新对象并退出构造函数

构造函数中通过检查某个应该存在的方法是否有效,来决定是否需要初始化原型;这是一个非常巧妙的策略。若把所有需要初始化的公共方法和公共属性都放在一起,仅仅只需要检查其中一个即可知道是否已执行过这段代码。因此在上例中,只有第一次创建对象person1时才会初始化原型,而当创建对象person2时,由于原型已经初始化了,所以将不再重复此过程。

不过需要特别注意的是,该模式下不能使用对象字面量构造原型,如下面这样:

function Person(name, age, job) {
    //属性
    this.name = name;
    this.age = age;
    this.job = job;
    
    //方法
    if(typeof this.sayName != "function") {
        //不能用这样的方法初始化原型
        Person.prototype = {
            sayName: function() {
                alert(this.name);
            },

            sayJob: function() {
                alert(this.job);
            },

            sayAge: function() {
                alert(this.age);
            }
        };
    }
}

var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");

person1.sayName();        //出错
person2.sayName();        //出错

这是因为,若把原型对象的初始化过程放到构造函数中,那么当调用构造函数创建对象时,对象的创建总是先于原型对象的初始化的(在执行构造函数内部的代码前对象就已经完成了创建),由于对象获得指向原型对象的指针发生在对象创建时刻,所以在这种情况下,创建的对象早已指向了原型对象,对于之前的情况,由于Person.prototype和新对象的[[prototype]]指向同一个对象,所以通过Person.prototype修改原型对象能被新对象的[[prototype]]访问到;然而使用对象字面量时,Person.prototype通过赋值的方式指向了另一个对象,但此时[[prototype]]仍指向初始的原型对象,这也是为什么person1.sayName()出错的原因。

总结:

通过对各个模式的分析发现,组合使用构造函数模式和原型模式动态原型模式是所有介绍过的模式中最适合用来创建自定义类型对象的两个模式。这两者虽然从本质上看是相同的,但是由于实现细节的不同使得它们互有优势。对于前者来说,它能够使用对象字面量的方式构造原型对象,适用于定义具有较多公共方法的对象类型,这样可以简化代码,但其构造函数与原型对象是分开定义的,封装性一般。而后者正好与之相反,后者虽不能使用对象字面量创建原型对象,但却做到了将原型对象的初始化封装到了构造函数中从而形成一个整体,这样更加符合OO的思想。