《JavaScript 模式》读书笔记(7)— 设计模式1

  这些模式已经出现了相当长的一段时间,并被证明在许多情况下都非常有用。这也是为什么需要自己熟悉并谈论这些模式的原因。

  虽然这些设计模式是与语言和实现方式无关的,并且人们已经对此研究了多年,但都主要是从强类型的静态类语言的角度开展研究,比如C++和Java语言。

  JavaScript是一种弱类型、动态的、基于原型的语言,这种语言特性使得它非常容易、甚至是普通的方式实现其中的一些模式。

  让我们先从第一个例子开始,即单体模式,理解其与基于类的静态语言相比时,JavaScript中存在哪些区别。

 

一、单体模式

  单体(singleton)模式的思想在于保证一个特定类仅有一个实例。这意味着当您第二次使用同一个创建新对象的时候,应该得到与第一次创建的对象完全相同的对象。

  但是,如何将这种模式应用到JavaScript?在JavaScript中没有类,只有对象。当您创建一个对象时,实际上没有其他对象与其类似,因此新对象已经是单体了。使用对象字面量创建一个简单的对象也是一个单体的例子:

var obj = {
    myprop: 'my value'
}

  在JavaScript中,对象之间永远不会完全相等,除非它们是同一个对象,因此即使创建一个具有完全相同成员的同类对象,它也不会与第一个对象完全相同:

var obj2 = {
    myprop : 'my value'
};
console.log(obj === obj2);
console.log(obj == obj2);

  因此,可以认为每次在使用对象字面量创建对象的时候,实际上就正在创建一个单体,并且不涉及任何特殊语法。

  请注意,有时当人们在JavaScript上下文中谈论单体时,他们的意思是指第五章中所讨论的模块模式。

 

使用new操作符

  JavaScript中并没有类,因此对单体咬文嚼字的定义严格来说并没有意义。但是JavaScript中具有new语法可使用构造函数来创建对象,而且有时可能需要使用这种语法的单体实现。这种思想在于当使用同一个构造函数以new操作符来创建多个对象时,应该仅获得指向完全相同的对象的新指针。

  对于在一些基于类的语言(即静态的、强类型语言)中,其函数不是“第一类型对象”的那些语言来说,下面讨论的主题并不是那么有用,而是更多的作为一种理论上的模仿变通方法的运用。

  下面的代码片段显示了其与其行为(假定不认可多元宇宙的观点,并且接受外在世界只有一个宇宙的观点):

var uni = new Universe();
var uni2 = new Universe();
console.log(uni === uni2);

  在上面这个例子中,uni对象仅在第一次调用构造函数时被创建。在第二次(以及第二次以后的每一次)创建时都会返回头一个uni对象。这就是为什么uni === uni2,因为它们本质上是指向同一个对象的两个引用。那么如何在JavaScript中实现这种模式呢?

  需要Universe构造函数缓存该对象实例的this,以便当第二次调用该构造函数时能够创建并返回同一个对象。有多种选择可以实现这一目标:

  • 可以使用全局变量来存储该实例。但是并不推荐使用这种方法,因为在一般原则下,全局变量是有缺点的。此外,任何人都能够覆盖该全局变量,即使是意外事件。因此,让我们不要再进一步讨论这种方法。
  • 可以在构造函数的静态属性中缓存该实例。JavaScript中的函数也是对象,因此它们也可以有属性。您可以使用类似Universe.instance的属性并将实例缓存在该属性中。这是一种很好的实现方法,这种简介的解决方案唯一的缺点在于instance属性是公开可访问的属性,在外部代码中可能会修改该属性,以至于让您丢失了该实例。
  • 可以将该实例包装在闭包中。这样可以保证该实例的私有性并且保证该实例不会被构造函数之外的代码所修改,其代价是带来了额外的闭包开销。

  下面,我们来看下第二种和第三种方法的实现示例:

 

静态属性中的实例

  下面代码是一个在Universe构造函数的静态属性中缓存单个实例的例子:

function Universe() {
    // 我们有一个现有的实例么?
    if(typeof Universe.instance === 'object') {
        return Universe.instance
    }

    // 正常进行
    this.start_time = 0;
    this.bang = 'Big';

    // 缓存
    Universe.instance = this;

    // 隐式返回
    return this;
}

  正如您所看到的,这是一个非常直接的解决方法,其唯一的缺点在于其instance属性是公开的。虽然其他代码不太可能会无意中修改该属性,但是仍然存在这种可能性。

 

闭包中的实例

  另一种实现类似于类的单体方法是采用闭包来保护该单个实例。可以通过使用在第五章中所讨论的私有静态成员模式实现这种单体模式。这里的秘诀就是重写构造函数:

function Universe() {
    // 缓存实例
    var instance = this;

    // 正常进行
    this.start_time = 0;
    this.bang = 'Big';

    // 重写该构造函数
    Universe = function () {
        return instance;
    }
}
// 测试
var uni = new Universe();
var uni2 = new Universe();
console.log(uni === uni2);

  在上述代码运行时,当第一次调用原始构造函数时,它像往常一样返回this。然后,在以后的每次调用时,将执行重写构造函数的部分。该部分通过闭包访问了私有instance变量,并且仅简单的返回了该instance。

  这个实现实际上来自于第四章的自定义函数模式的另一个例子。而这种方法的缺点我们已经在第四章中讨论过,主要在于重写构造函数(本例中也就是构造函数Universe)会丢失所有在初始定义和重定义时刻之间添加到它里面的属性。在这里的特定情况下,任何添加到Universe()原型中的对象都不会存在指向由原始实现所创建实例的活动链接。

  通过下面的一些测试,可以看到这个问题:

// 向原型添加属性
Universe.prototype.nothing = true;

var uni = new Universe();

// 在创建初始化对象之后,再次向该原型添加属性

Universe.prototype.everthing = true;

var uni2 = new Universe();

// 开始测试

// 仅有最初的原型链接到对象上

console.log(uni.nothing); // true
console.log(uni2.nothing); //true
console.log(uni.everthing); // undefined
console.log(uni2.everthing); // undefined

// 结果看上去是正确的
console.log(uni.constructor.name); //Universe
// 但是这个很奇怪:
console.log(uni.constructor === Universe); //false

  之所以uni.constructor不再与Universe()构造函数相同,是因为uni.constructor仍然指向了原始的构造函数,而不是重新定义的那个构造函数。

  从需求上来说,如果需要使原型和构造函数指针按照预期的那样运行,那么可以通过做一些调整来实现这个目标:

function Universe() {
    // 缓存实例
    var instance

    // 重写该构造函数
    Universe = function Universe() {
        return instance;
    }

    // 保留原型属性
    Universe.prototype = this;

    // 实例
    instance = new Universe();

    // 重置构造函数指针
    instance.constructor = Universe;

    // 所有功能
    instance.start_time = 0;
    instance.bang = 'Big';

    return instance;
}

// 更新原型并创建实例
Universe.prototype.nothing = true;

var uni = new Universe();

// 在创建初始化对象之后,再次向该原型添加属性

Universe.prototype.everthing = true;

var uni2 = new Universe();

// 它们是相同的实例
console.log(uni === uni2); //true
// 无论这些原型属性是何时定义的,所有原型属性都起作用。

console.log(uni.nothing && uni.everthing && uni2.nothing && uni2.everthing); //true

// 正常属性起作用
console.log(uni.bang); //'Big'

// 该构造函数指向正确
console.log(uni.constructor === Universe); //true

  另一种解决方案也是将构造函数和实例包装在即时函数中。在第一次调用构造函数时,他会创建一个对象,并且使得私有instance指向该对象。从第二次调用之后,该构造函数仅返回该私有变量。通过这个新的实现方式,前面所有代码片段的测试也都会按照预期运行。

var Universe;

(function (){
    var instance;

    Universe = function Universe() {
        if(instance) {
            return instance;
        }

        instance = this;

        // 所有功能
        this.start_time = 0;
        this.bang = 'Big';
    }
}());

 

二、工厂模式

  设计工厂模式的目的是为了创建对象。它通常在类或者类的静态方法中实现,具有下列目标:

  • 当创建相似对象时执行重复操作。
  • 在编译时不知道具体类型(类)的情况下,为工厂客户提供一种创建对象的接口。 

  其中,在静态类语言中第二点显得更为重要,因为静态语言创建类的实例是非常平凡的,即事先(在编译时)并不知道实例所属的类。而在JavaScript中,这部分目标实现起来相当容易。

  通过工厂方法(或类)创建的对象在设计上都继承了相同的父对象这个思想,它们都是实现专门功能的特定子类。有时候公共父类是一个包含了工厂方法的同一个类。

  让我们看一个实现示例:

  • 公共父构造函数CarMaker。
  • 一个名为factory()的CarMaker的静态方法,该方法创建car对象。
  • 从CarMaker继承的专门构造函数CarMaker.Compact、CarMaker.SUV和CarMaker.Convertible。所有这些构造函数都被定义为父类的静态属性,以保证全局命名空间免受污染,因此我们也知道了当需要这些构造函数的时候可以在哪找到它们。

  让我们先来看看如何使用这个已经完成的实现:

var corolla = CarMaker.factory('Compact');
var solstice = CarMaker.factory('Convertible');
var cherokee = CarMaker.factory('SUV');

corolla.drive(); // "Vroom, I Have 4 doors"
solstice.drive(); // "Vroom, I Have 2 doors"
cherokee.drive(); // "Vroom, I Have 24 doors"

  其中,这一部分:

var corolla = CarMaker.factory('Compact');

  可能是工厂模式中最易辨别的部分。现在看到工厂方法接受在运行时以字符串形式指定类型,然后创建并返回所请求类型的对象。代码中看不到任何具有new或对象字面量的构造函数,其中仅有一个函数根据字符串所指定类型来创建对象。

  下面是工厂模式的实现示例,这将会使得前面的代码片段正常运行:

// 父构造函数
function CarMaker (){}

// a method of the parent

CarMaker.prototype.drive = function () {
    return "Vroom,I Have " + this.doors + 'doors';
}

// 静态工厂方法
CarMaker.factory = function(type) {
    var constr = type,
        newcar;
    
    // 如果构造函数不存在,则发生错误
    if(typeof CarMaker[constr] !== 'function') {
        throw {
            name:"Error",
            message: constr + "doesn't exist "
        };
    }

    // 在这里,构造函数是已知存在的
    // 我们使得原型继承父类,但仅继承一次
    if(typeof CarMaker[constr].prototype.drive !== "function") {
        CarMaker[constr].prototype = new CarMaker();
    }

    // 创建一个新的实例
    newcar = new CarMaker[constr]();
    // 可选择性的调用一些方法,然后返回...
    return newcar;
};

// 定义特定的汽车制造商
CarMaker.Compact = function () {
    this.doors = 4;
}

CarMaker.Convertible = function () {
    this.doors = 2;
}

CarMaker.SUV = function () {
    this.doors = 24;
}

var corolla = CarMaker.factory('Compact');
var solstice = CarMaker.factory('Convertible');
var cherokee = CarMaker.factory('SUV');

console.log(corolla.drive()); // "Vroom, I Have 4 doors"
console.log(solstice.drive()); // "Vroom, I Have 2 doors"
console.log(cherokee.drive()); // "Vroom, I Have 24 doors"

  实现该工厂模式并没有特别的困难。所有需要做的就是寻找能够创建所需类型对象的构造函数。在这种情况下,简洁的命名习惯可用于将对象类型映射到创建该对象的构造函数中。继承部分仅是可以放进工厂方法的一个公用重复代码片段的范例,而不是对每中类型的构造函数的重复。

 

内置对象工厂

  而对于“自然工厂”的例子,可以考虑内置的全局Object()构造函数。他也表现出工厂的行为,因为它根据输入类型而创建不同的对象。如果传递一个原始数字,那么它能够在后台以Number()构造函数创建一个对象。对于字符串和布尔值也同样成立。对于任何其他值,甚至包括无输入的值,他都会创建一个常规的对象。

  下面是该行为的一些例子和测试。请注意,无论使用new操作符与否,都可以调用Object():

var o = new Object(),
    n= new Object(1),
    s = new Object('1'),
    b = new Object(true);

// test
console.log(o.constructor === Object); //true
console.log(n.constructor === Number); //true
console.log(s.constructor === String); //true
console.log(b.constructor === Boolean); //true

  事实上,Object()也是一个实际用途不大的工厂,值得将它作为例子而提及的原因在于它是我们身边常见的工厂模式。

 

三、迭代器模式

  在迭代器模式中,通常有一个包含某种数据集合的对象。该数据可能存储在一个复杂数据结构内部,而要提供一种简单的方法能够访问数据结构中的每个元素。对象的消费者并不需要知道如何组织数据,所有需要做的就是取出单个数据进行工作。

  在迭代器模式中,我们需要提供一个next()方法。一次调用next()必须返回下一个连续的元素。当然,在特定的数据结构中,“下一个”所代表的意义是由您来决定的。

  假定对象名为agg,可以在类似下面这样的一个循环中通过简单调用next()即可访问每个数据元素:

var element;
while(element = agg.next()) {
    // 处理该元素...
    console.log(element);
}

  在迭代器模式中,聚合对象通常还提供了一个较为方便的hasNext()方法,因此,该对象的用户可以使用该方法来确定是否已经到达了数据的末尾。此外,还有另一种顺序访问所有元素的方法,这次是使用hasNext(),其用法如下所示:

while (agg.hasNext()) {
    // 处理下一个元素..
    console.log(agg.next());
}

  现在已经有了用例,让我们看看如何实现这样的聚合对象。

  当实现迭代器模式时,私下的存储数据和指向下一个可用元素的指针是很有意义的,为了演示一个实现示例,让我们假定数据只是普通数组,而“特殊”的检索下一个连续元素的逻辑为返回每隔一个的数组元素。

var agg = (function () {
    var index = 0,
        data = [1,2,3,4,5],
        length = data.length;

        return {
            next: function () {
                var element;
                if(!this.hasNext()) {
                    return null;
                }

                element = data[index];
                index = index + 2;
                return element;
            },
            hasNext:function () {
                return index < length;
            }
        };
}());

  为了提供更简单的访问方式以及多次迭代数据的能力,您的对象可以提供额外的便利方法:

  rewind():重置指针到初始位置。

  current():返回当前元素,因为不可能在不前进指针的情况下使用next()执行该操作。

  实现这些方法不存在任何困难,我们来看加上这两个方法的完整示例:

var agg = (function () {
    var index = 0,
        data = [1,2,3,4,5],
        length = data.length;

        return {
            next: function () {
                var element;
                if(!this.hasNext()) {
                    return null;
                }

                element = data[index];
                index = index + 2;
                return element;
            },
            hasNext:function () {
                return index < length;
            },
            rewind:function () {
                index = 0;
            },
            current: function () {
                return data[index];
            }
        };
}());

// 测试迭代器
while (agg.hasNext()) {
    // 处理下一个元素..
    console.log(agg.next());
}

// 回退
agg.rewind();
console.log(agg.current());

  输出结果将记录在控制台中:即依次输出1,3,5(从循环中),并且最后输出1(在回绕以后)。

  好了,我们这篇学了三个设计模式,分别是单体模式、工厂模式以及迭代器模式。这三个模式比较简单,也更容易理解。下一篇,我们来学习一下更为复杂的设计模式。

 

posted @ 2020-09-01 17:31  Zaking  阅读(176)  评论(0编辑  收藏  举报