通过原型模式理解JavaScript原型链的本质

        在以类为中心的面向对象语言中(C++,Java,C#),我们都知道要想创建一个对象,首先我们要有声明一个类,这个类包含一些状态属性和它能够调用的方法行为。然后我们new一个对象时,操作系统会以这个类为模板在内存中创建这个对象。我们可以把类和对象的关系可以想象成铸模和铸件的关系。后来有人发现这种方式太过繁琐,类并不是必需的,对象未必需要从类中创建而来,一个对象可以通过克隆另外一个对象所得到,这就是今天我们要讲的原型编程思想。

        所谓原型模式就是使用特定原型实例来创建特定种类的对象,并通过拷贝原型来创建新的对象。类图如下:

通过原型模式理解JavaScript原型链的本质_第1张图片

 

        原型模式很简单,我们在声明原型类时,只需要提供一个Clone()方法,它在内部会利用当前实例对象的状态创建一个一样的实例化对象。当然原型模式的真正目的并非在于需要得到一个一模一样的对象,而是提供了一种便捷的方式去创建某个类型的对象,克隆只是创建这个对象的过程和手段。

        我们在使用C++等静态类型语言编写程序的时候,类型之间的解耦非常重要。依赖倒置原则提醒我们创建对象的时候要避免依赖具体类型,而用new XXX创建对象的方式显得很僵硬。工厂方法模式和抽象工厂模式可以帮助我们解决这个问题,但这两个模式会带来许多跟产品类平行的工厂类层次,也会增加很多额外的代码。原型模式提供了另外一种创建对象的方式,通过克隆对象,我们就不用再关心对象的具体类型名字。使用原型模式,我们只需要声明一种工厂类,传入不同的产品对象实例,就可以创建一个对应的工厂实例。在C++中我们还可以使用生成器函数和模板来构造我们的工厂实例。

        也许原型模式在我们日常的开发中很少用到,但是如果你使用过JavaScript话,一定会喜欢上它的简洁高效。Js是一门基于原型的面向对象语言,它是把原型模式天然融入其中的。其实我们需要设计模式,也是因为语言的先天不足。而后来诞生的一些语言是把一些设计模式融入自身的,使用者感受不到,但就是感觉开发过程很畅快。

        我们知道Js是基于原型链来实现对象之间的继承关系的。它遵循下面这些原型编程的基本规则:

1.所有的数据都是对象。

2.要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它。

3. 对象会记住它的原型。

4. 如果对象无法响应某个请求,它会把这个请求委托给它自己的原型。

        JavaScript中没有类的概念,它的函数既可以作为普通函数被调用,也可以作为构造器被调用。当使用new运算符来调用函数时,此时的函数就是一个构造器。

function Animal(name) {
    this.name = name;
};

// 此时Animal就是一个构造器
var animal = new Animal("dog");

 我们一般会在它的原型上面声明方法

Animal.prototype.getName = function() {
    return this.name;
};

引擎帮我们构造这个对象后,会有一个_proto_属性,然后自动把_proto_指向prototype。像上面这样在原型上声明方法,我们会给Animal创建一个原型。这符合面向对象继承的需要,当我们使用继承时,就是为了复用父类的行为。

就JavaScript的真正实现来说,其实并不能说对象有原型,而只能说对象的构造器有原型。对于“对象把请求委托给它自己的原型”这句话,更好的说法是对象把请求委托给它的构造器的原型。

虽然JavaScript的对象最初都是由Object.prototype对象克隆而来的,但对象构造器的原型并不仅限于Object.prototype上,而是可以动态指向其他对象。这样一来,当对象a需要借用对象b的能力时,可以有选择性地把对象a的构造器的原型指向对象b,从而达到继承的效果。下面的代码是我们最常用的原型继承方式:

var obj = {name: 'dabai'};

var A = function(){};
A.prototype = obj;

var a = new A();
console.log(a.name); // 输出:dabai

我们来看看执行这段代码的时候,引擎做了哪些事情。

1. 首先,尝试遍历对象a中的所有属性,但没有找到name这个属性。

2. 查找name属性的这个请求被委托给对象a的构造器的原型,它被a.__proto__ 记录着并且指向A.prototype,而A.prototype被设置为对象obj。

3.在对象obj中找到了name属性,并返回它的值。

当我们期望得到一个“类”继承自另外一个“类”的效果时,往往会用下面的代码来模拟实现:

var A = function(){};
A.prototype = {name: 'dabai'};

var B = function(){};
B.prototype = new A();

var b = new B();
console.log(b.name); // 输出:dabai

1. 首先,尝试遍历对象b中的所有属性,但没有找到name这个属性。

2. 查找name属性的请求被委托给对象b的构造器的原型,它被b.__proto__记录着并且指向B.prototype,而B.prototype被设置为一个通过new A()创建出来的对象。

3.在该对象中依然没有找到name属性,于是请求被继续委托给这个对象构造器的原型A.prototype。

4.在A.prototype中找到了name属性,并返回它的值。

        和把B.prototype直接指向一个字面量对象相比,通过B.prototype = new A()形成的原型链比之前多了一层。但二者之间没有本质上的区别,都是将对象构造器的原型指向另外一个对象,继承总是发生在对象和对象之间。最后还要留意一点,原型链并不是无限长的。现在我们尝试访问对象a的address属性。而对象b和它构造器的原型上都没有address属性,那么这个请求会被最终传递到哪里呢?

        实际上,当请求达到A.prototype,并且在A.prototype中也没有找到address属性的时候,请求会被传递给A.prototype的构造器原型Object.prototype,显然Object.prototype中也没有address属性,但Object.prototype的原型是null,说明这时候原型链的后面已经没有别的节点了。所以该次请求就到此打住,a.address返回undefined。

        现在ECMAScript 6带来了新的Class语法。这让JavaScript看起来像是一门基于类的语言,但其背后仍是通过原型机制来创建对象。通过Class创建对象的一段简单示例代码[插图]如下所示:

class Animal {
    constructor(name) {
        this.name = name;
    }

    getName() {
        return this.name;
    }
}

class Dog extends Animal {
    constructor(name) {
        super(name);
    }

    speak() {
        return "woof";
    }
}

备注:原型模式并不仅仅是一种编程模式,它更是一种泛型,是一种设计思想。在《Game Programming Patterns》一书中最后还举了一个原型数据建模的例子。游戏中怪物配置数据表,为了节省配置表的大小和避免重复数据,可以创建一个原型数据,后面其他数据对象可以通过引用指向这个原型数据来达到复用的目的。

参考:Prototype · Design Patterns Revisited · Game Programming Patterns

你可能感兴趣的:(游戏编程模式,游戏开发,设计模式,javascript,原型模式)