1. 引言

  继承(inheritance)、封装(encapsulation)和多态(polymorphism)是面向对象机制的主要特性。在JS中没有“class”的概念,自然也无法直接进行JAVA、C++常用到的extends、implements等操作。但从某种意义上来说,JS是纯粹的“面向对象”编程语言,因为JS中处处皆是对象(函数也是对象),而且作为函数式脚本语言,天生就是多态的。
  网上很多文章探讨JS中如何设计class和面向对象机制,这些文章的思路聚焦于如何严格按照JAVA、C++中面向对象的实现机制去在JS中实现同样机制。但在我看来,既然JS中抛去了“class”的定义,就应该充分享受JS的纯粹对象机制带来的便利。

2. 探索prototype

  面向对象中的Class是什么?其实本质上就是一个“模板”,就像做月饼一样,我们需要一个月饼模子,而使用月饼模子做出的月饼基本一致。
  那么在JS中,如何定义“月饼模子”? JS中提供了构造函数,构造函数就是JS中的“月饼模子”。考虑我厂生产月饼的如下代码:

var 序列号 = 0;
function 我厂月饼模子(厂名,日期){
    this.序列号 = 序列号++;
    this.厂家名字 = 厂名;
    this.生产日期 = 日期;
}

var 我厂月饼1 = new 我厂月饼模子("我厂","20180806");
var 我厂月饼2 = new 我厂月饼模子("我厂","20180807");

  我们在上面的代码中定义了“我厂月饼模子”这个构造函数(如果使用英文名请将首字母大写),通过“new”操作做出了两个月饼:月饼1和月饼2,还在月饼上打上了厂家名字和生产日期。除了没有出现“class”字样的关键字,这些代码和JAVA、C++的代码如此相似(需要注意,JS不支持函数重载,因此相同函数名的构造函数无法被JS重载)。
  我厂月饼生产线运转起来了。但好景不长,市场是残酷的,市面上有许多月饼厂家,他们生产的月饼各有特色。我们发现某友商A厂提供未经烘烤的月饼,可由消费者买回家后自己进行烘烤。我们买了一个A厂月饼,它是这样定义的:

var  A厂月饼1 = {
    月饼形状: "圆形",
    生产日期: "20180706",
    烘烤: function () {
        console.log("提供烘烤功能");
    }
}

  JS中构造函数具有prototype属性,当把构造函数“我厂月饼模子”的prototype属性设置为A厂月饼1后,生产出来的“我厂月饼1”可直接引用prototype对象的属性,如下代码所示:

我厂月饼模子.prototype = A厂月饼1;
var 我厂月饼1 = new 我厂月饼模子("我厂", "20180806");
我厂月饼1.烘烤();

  现在我厂生产的月饼也有了烘烤功能了,并且具有形状特征“圆形”,生产日期为“20180806”。“我厂月饼1”的对象属性如图1所示:

图1

  图1可看到对象之间的原型链为:我厂月饼1->A厂月饼1->Object->null。
  A厂生产的月饼形状是可以变化的,可以做成“方形”,也可以做成“圆形”,经过与A厂技术人员交流,我们得到了A厂生产月饼的构造函数如下:

function A厂月饼模子(形状, 日期) {
    this.月饼形状 = 形状;
    this.生产日期 = 日期;
    this.烘烤 = function () {
        console.log("提供烘烤功能");
    }
}

  很明显,A厂使用这个月饼模子可以做出多种形状的月饼,按照以前的方法,我们使用一个A厂生产的月饼作为“我厂月饼模子”的prototype,只能固定一个形状,现在我们也希望在使用“我厂月饼模子”时,可以做出不同形状的月饼。怎么办呢?办法就是在“我厂月饼模子”中引用“A厂月饼模子”,参考如下代码:

function 我厂月饼模子(形状,厂名, 日期) {
    A厂月饼模子.call(this,形状);
    this.序列号 = 序列号++;
    this.厂家名字 = 厂名;
    this.生产日期 = 日期;
}
var 我厂月饼1 = new 我厂月饼模子("方形","我厂", "20180806");

  再次查看“我厂月饼1”对象,发现已有“方形”这个属性了。如图2所示。
图2
  与图1所不同的是,“我厂月饼1”对象的原型链已经发生了变化,因为这次,我们没有使用“我厂月饼模子”的prototype。
  到此为止,似乎一切都已经尘埃落定,我厂不仅保留了原来的月饼特色,还包含了A厂的月饼特色,一切似乎都是那么的美好。但是不久,我们发现--又出状况了。A厂生产的月饼提供了DIY配色的功能,用户能够根据月饼提供的配色包对月饼进行配色,这一功能颇受部分特定人群的欢迎。
  联系A厂技术人员,发现他们对“A厂月饼模子”做了修改,在prototype里增加了配色函数,代码如下:

A厂月饼模子.prototype.配色 = function(){
    console.log("提供配色功能");
}

  如果我们想继续共享“A厂月饼模子”的“配色”功能,还是得从prototype来想办法,这次我们在原来的代码上将“A厂月饼模子”的prototype设置为一个通用的“A厂月饼模子”生成的“A厂月饼”(构造函数调用时不带参数),完整代码如下:

function A厂月饼模子(形状, 日期) {
    this.月饼形状 = 形状;
    this.生产日期 = 日期;
    this.烘烤 = function () {
        console.log("提供烘烤功能");
    }
}

A厂月饼模子.prototype.配色 = function(){
    console.log("提供配色功能");
}

var 序列号 = 0;

function 我厂月饼模子(形状,厂名, 日期) {
    A厂月饼模子.call(this,形状);
    this.序列号 = 序列号++;
    this.厂家名字 = 厂名;
    this.生产日期 = 日期;
}

我厂月饼模子.prototype = new A厂月饼模子();
var 我厂月饼1 = new 我厂月饼模子("方形","我厂", "20180806");

  再次查看“我厂月饼1”对象,如图3所示,已经具有配色的功能(请注意原型链已有变化)。
图3
  到了现在,终于可以嘘一口气了,A厂再在prototype中增加新功能,我们的代码不用改了。

3. 使用prototype

  在上节月饼模子的例子中,我们探索了prototype,那么prototype是一个怎样的存在,我们来总结一下:
  1. prototype专属于构造函数,在使用构造函数new出来的对象中,使用__proto__表示。
  2. prototype对象中包含的属性(包括函数属性)被使用构造函数构建的对象所共享。从某种意义上来说,prototype对象就是父对象。
  下面我们根据上节的示例归纳一下不同应用场景下如何使用prototype。为便于描述,我们将需要共享其它对象属性的对象称为子对象,生成子对象所使用的构造函数称为子构造函数,提供共享属性的对象称为父对象,生成父对象使用的构造函数称为父构造函数。
  我们归纳出如下规则:
  1. 若子对象只想共享父构造函数中定义的属性,在子构造函数调用父构造函数即可,需要注意的是子构造函数的参数可能需要调整。
  2. 若子对象想共享父构造函数和prototype中的所有属性:当父构造函数无参数时,只需要赋值子构造函数的prototype为使用父构造函数new出来的一个父对象即可;当父构造函数有参数时,不仅要赋值子构造函数的prototype为使用父构造函数new出来的一个父对象(构造函数不带参数),还需要在子构造函数调用父构造函数(初始化父构造函数中的参数)。
  当我们使用DDD(领域驱动设计)思想来设计软件时,在建模时我们会设计领域中的实体、值对象和聚合。
  领域中数量最多的应该是实体,这些实体也即编程语言中的对象。设想我们使用JS来编程领域模型,当我们使用“对象共享属性”的观点来看待原来的“对象继承”关系时,也能实现使用JAVA、C++等编程语言达到的效能。

4. 小结

  prototype是JS中常令人迷惑的一个概念,之所以令人迷惑是因为大家总是想把它与面向对象的经典框架结合起来,反而束缚了自己的思维。JS是一个纯粹的面向对象系统,使用构造函数的prototype实现了对象属性间的共享,本文探索了prototype的本质并归纳总结了prototype的使用规则。