面向对象的语言有一个标志,那就是它们都有“类”的概念,通过类可以创建任意多个具有相同属性和方法的对象。JavaScript 中没有类的概念,因此它的面向对象与基于类的语言中的对象有所不同。

JavaScript 对对象的定义是:无序属性的集合,其属性可以包含基本值、对象或者函数。可以把 JavaScript 对象理解成散列表,即一组名值对,其中的值可以是数据或函数。

那么想要创建自定义对象,有以下几种常用方法:

1.使用 Object 构造函数:

var person = new Object();
person.name = "Lucy";
person.age = 24;
person.job = "nurse";
person.sayName = function() {
  console.log(this.name);
};

2.使用对象字面量:

var person = {
  name: "Lucy",
  age: 24,
  job: "nurse",
  sayName: function() {
    console.log(this.name);
  }
};

上面这两种方式虽然都能用来创建单个对象,但是这些方式有一个明显缺点:使用同一个接口创建很多对象时,会产生大量的重复代码,因此产生了工厂模式

3.工厂模式:

因为 JavaScript中不能创建类,因此出现了下面这种函数:

function createPerson(name, age, job) {
  var o = new Object();
  o.name = name;
  o.age= age;
  o.job = job;
  o.sayName = function() {
    console.log(this.name);
  };
  return o;
}
var person1 = function("Lucy", 24, "nurse");
var person2 = function("Tom", 22, "engineer");

不难看出,工厂模式很方便地解决了创建多个相似对象的问题。不过,通过工厂模式创建的对象并不能使用 instanceof 关键字明确知道新创建的对象的类型,随之而来的方案是采用构造函数模式

4.构造函数模式:

function Person(name, age, job) {
  this.name = name;
  this.age= age;
  this.job = job;
  this.sayName = function() {
    console.log(this.name);
  }
}
var person1 = new Person("Lucy", 24, "nurse");
var person2 = new Person("Tom", 22, "engineer");

对比工厂模式,构造函数模式没有显式地创建对象,直接将属性和方法赋给了 this 对象,并且没有 return 语句。另外,和其他 OO 语言相同,构造函数始终都应该以一个大写字母开头,使其能够和非构造函数区分开来。最后,使用构造函数模式需要用到 new 关键字,经历以下四步:

1)创建一个新对象;

2)将构造函数的作用域赋给新对象(即 this 指向这个新对象);

3)执行构造函数总的代码;

4)返回新对象。

通过构造函数创建的对象会有一个 constructor 属性,该属性指向其构造函数:

person1.constructor === Person //true
person2.constructor === Person //true

对象的 constructor 属性最初是用来标识对象类型的,不过,在检测对象类型时,使用 instanceof 关键字更可靠:

person1 instanceof Person //true
person2 instanceof Person //true

构造函数模式相对于工厂模式的一大步是构造函数模式解决了工厂模式无法解决的对象识别问题。

虽然构造函数模式相对于工厂模式来说已经是加强版,但也存在自身的缺点,即每个方法都要在每个实例上重新创建一遍。为什么呢?因为在 JavaScript 中,函数同样也是对象,因此每定义一个函数,其实就是实例化了一个对象。将上面的 Person 构造函数写成如下等价形式来方便理解:

function Person(name, age, job) {
  this.name = name;
  this.age= age;
  this.job = job;
  this.sayName = new Function("console.log(this.name)");
}

这样看来,每个 Person 实例都包含一个不同的 Function 实例。以这种方式创建函数,创建 Function 新实例的机制相同,但会导致不同的作用域链和标识符解析:

person1.sayName === person2.sayName //false

然而,创建两个完全同样任务的 Function 实例的确没有必要,况且有 this 对象在,根本不用在执行代码前就把函数绑定到特定对象上,可以把函数定义转移到构造函数外部来解决该问题:

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

这样做解决了两个函数做同一件事的问题,但问题是在全局作用域中定义的函数实际上只能被某个对象调用,而且如果对象需要定义很多方法,那么就要定义很多个全局函数,严重破坏自定义引用类型的封装性,这时,救世主来了——原型模式

5.原型模式:

function Person() {
}
Person.prototype.name = "Lucy";
Person.prototype.age= 24;
Person.prototype.job = "nurse";
Person.prototype.sayName = function() {
  console.log(this.name);
};
 
var person1 = new Person();
var person2 = new Person();
person1.sayName() //Lucy
person2.sayName() //Lucy

我们创建的每个函数都有一个 prototype 属性,该属性指向一个对象,可以称其为原型对象,使用该对象的好处是可以让所有对象实例共享它所包含的属性和方法。在默认情况下,所有原型对象都会自动获得一个 constructor 属性,这个属性包含一个指向 prototype 属性所在函数的指针。构造函数(Person)、原型对象(Person.prototype)和对象实例(person1, person2)间具体指向关系如图 1 所示:

JavaScript中的面向对象(一)——创建自定义对象_JavaScript

-1 指向关系

通过图 1 我们可以看出,person1, person2 都包含一个内部属性,该属性仅仅指向了 Person.prototype,即对象实例与构造函数没有直接关系。虽然在所有实现中都无法访问到 `Prototype`,但可以通过 isPrototypeOf() 方法来确定对象之间是否存在这种关系:

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

ECMAScript 5 中增加了一个方法 Object.getPrototypeOf(),在所有支持的实现中,这个方法返回`Prototype`的值:

Object.getPrototypeOf(person1) === Person.prototype //true
Object.getPrototypeOf(person1).name //"Lucy"

该方法返回的对象实际就是对象实例所对应的原型对象。使用 Object.getPrototypeOf() 可以方便地取得一个对象的原型,这在利用原型实现继承的情况下很重要,我们会在之后的博客中提到 JavaScript 的继承。

在读取某个对象的某个属性时,都会执行一次搜索,顺序是对象实例(person1--> 原型对象(Person.prototype --> 构造函数(Person)。一旦找到相应属性,就停止向上寻找,并返回值。这是多个对象实例共享原型所保存的属性和方法的基本原理。(比如 person1 没有 constructor 属性,但是 person1.constructor 依然有值,该值就是找的原型对象 Person.prototype 中的 constructor 属性,即 person1.constructor --> Person.prototype.constructor)。

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

person1.name = "Tom";
person1.name //Tom (来自对象实例)
person2.name //Lucy (来自原型对象)

当为对象实例添加一个属性时,这个属性就会屏蔽原型对象中保存的同名属性。如果想去除该屏蔽,使用 delete 关键字

delete person1.name;
person1.name //Lucy (来自原型对象)

通过 hasOwnProperty() 方法可以确定什么时候访问的是对象实例属性,什么时候访问的是原型对象属性:

person1.hasOwnProperty("name"); //false
person1.name = "Tom";
person1.hasOwnProperty("name"); //true

in 关键字有两种用法,单独使用和在 for-in 循环中使用。单独使用时,作用类似于 hasOwnProperty() ,区别是只要在通过对象实例能访问给定属性时返回 true,无论该属性存在于实例中还是原型中:

person2.name; //Lucy (来自原型对象)
"name" in person2; //true
person2.hasOwnProperty("name"); //false
person2.name = "Jordan";
"name" in person2; //true
person2.hasOwnproperty("name"); //true

要取得对象上所有可枚举的实例属性,可以使用 ECMAScript 5 中的 Object.keys() 方法:

Object.keys(Person.prototype); //"name,age,job,sayName"
var person1 = new Person();
person1.name = "Duncan";
person1.age= 38;
Object.keys(person1); //"name,age"

刚才我们提到的原型模式在每添加一个属性或方法时都要敲一遍 Person.prototype,为减少不必要的输入,同时也从视觉上更好地封装原型的功能,更常见的是采用如下对象字面量的形式重写整个原型对象:

function Person() {
}
Person.prototype = {
  name: "Lucy",
  age: 24,
  job: "nurse",
  sayName: function() {
    console.log(this.name);
  }
};

注:采用对象字面量方式声明虽然最终结果与普通的原型模式相同,但有一个例外——constructor 属性不再指向 Person 了。这也是为什么我们在前面说通过 instanceof 关键字判断对象类型比通过 .constructor 属性要更精准:

var newperson = new Person();
newperson instanceof Object; //true
newperson instanceof Person; //true
newperson.constructor === Object; //true
newperson.constructor === Person; //false

如果 constructor 的值真的很重要,可以主动声明其值:

Person.prototype = {
  constructor: Person, //主动声明constructor
  name: "Lucy",
  age: 24,
  job: "nurse",
  sayName: function() {
    console.log(this.name);
  }
};

原型具有动态性,这里的动态性是指,我们对原型对象所做的任何修改都能够立即从实例上反映出来,即便我们是先创建的对象实例,之后再修改的原型对象:

var person1 = new Person();
Person.prototype.sayHello = function() {
  console.log("hello");
}
person1.sayHello() //"hello"

尽管如此,但如果是重写整个原型对象,那么情况就会有所不同:

function Person() {
}
var person1 = new Person();
Person.prototype = {
  constructor: Person,
  name: "Lucy",
  age: 24,
  job: "nurse",
  sayName: function() {
    console.log(this.name);
  }
};

person1.sayName(); //error,报错

这说明,重写原型对象切断了新原型对象与任何已经存在的对象之间的联系,它们引用的仍然是最初的原型对象。

原型模式不仅用来创建自定义类型的对象,同时,它也适用于原生的引用类型(Object, Array, String等)。例如,在 Array.prototype 中可以找到 sort() 方法,在 String.prototype 中可以找到 substring() 方法。可以通过修改原生原型对象定义新的方法:

String.prototype.startsWith = function (text) {
  return this.indexOf(text) === 0;
};
var msg = "Hello Wolrd";
msg.startsWith("Hello"); //true

尽管可以这样做,但并不推荐在产品化的过程中修改原生原型对象,因为这可能导致命名冲突,而且有可能意外地重写原生方法。

原型对象的最大问题是,对于引用类型值的属性很不合适,会引起不想要的对象共享:

Person.prototype = {
  constructor: Person,
  name: "Lucy",
  age: 24,
  job: "nurse",
  friends: ["Lily", "Tom"],
  sayName: function () {
    console.log(this.name);
  }
};
var person1 = new Person();
var person2 = new Person();
person1.friends.push("Jordan");
person2.friends //"Lily, Tom, Jordan"
person1.friends === person2.friends //true

6.构造函数模式和原型模式组合:

为了解决这一问题,可以组合使用构造函数模式和原型模式,这也是创建自定义类型最常见的方式,也是认同度最高的一种创建自定义类型的方法。其中,构造函数模式用于定义实例属性,原型模式用于定义方法和共享的属性:

function Person(name, age, job) {
  this.name = name;
  this.age= age;
  this.job = job;
  this.friends = ["Lily", "Tom"];
}
Person.prototype = {
  constructor: Person,
  sayName: function() {
    console.log(this.name);
  }
};
var person1 = new Person("Lucy", 24, "nurse");
var person2 = new Person("Tom", 22, "engineer");
person1.friends.push("Jordan");
person1.friends //"Lily, Tom, Jordan"
person2.friends //"Lily, Tom"
person1.friends === person2.friends //false
person1.sayName === person2.sayName //true
person1 instanceof Person //true
person2 instanceof Person //true

构造函数模式和原型模式组合可以使用 instanceof 关键字确定它的类型

如果你有其他 OO 语言经验,可能更加习惯于在一个构造方法中完成定义,而不是在构造函数之外再初始化原型完成共享属性的定义,这就需要用到动态原型模式

7.动态原型模式:

function Person(name, age, job) {
  this.name = name;
  this.age= age;
  this.job = job;
  this.friends = ["Lily","Tom"];
  if (typeof this.sayName != "function") {
    Person.prototype.sayName =function() {
      console.log(this.name);
    };
   }
}

只有在 sayName() 方法不存在的情况下,才会将它添加到原型中。这里对原型对象所做的修改能够立即在对象实例中得到反映。对于采用这种模式创建的对象,同样可以使用 instanceof 关键字确定它的类型。需要注意的是,使用动态原型模式时,不能使用对象字面量重写原型,因为这样会切断现有实例和新原型之间的联系。

如果上述几种模式都不适用的情况下,可以使用寄生构造函数模式

8.寄生构造函数模式:

function Person(name, age, job) {
 var o = new Object();
 o.name = name;
 o.age= age;
 o.job = job;
 o.sayName = function() {
   console.log(this.name);
 };
 return o;
}
var person1 = new Person("Lucy", 24, "nurse");
person1.sayName(); //"Lucy"

不难看出,除了使用 new 关键字并把使用的包装函数叫做构造函数之外,该模式跟工厂模式其实完全相同。(构造函数在不返回值的情况下,默认会返回新对象实例,通过在构造函数的末尾加上 return 语句,可以重写调用构造函数时返回的值)

寄生构造函数模式可以在特殊情况下创建构造函数,比如我们想创建一个具有额外方法的特殊数组,由于不能直接修改 Array 构造函数,因此可以使用该模式:

function SpecialArray() {
 var values = new Array();
 values.push.apply(values, arguments);
 values.toPipedString = function() {
   return this.join("|");
 };
 return values;
}
var colors = new SpecialArray("red", "blue", "green");
colors.toPipedString(); //"red|blue|green"

由于与工厂模式非常相似,因此寄生构造函数模式有着与工厂模式相同的缺点,即无法使用 instanceof 关键字确定对象类型。故在可以使用其他模式的情况下,不建议使用这种模式。

9.稳妥构造函数模式:

除了上述的几种创建自定义对象的模式,还有一种稳妥构造函数模式。这里有一个稳妥对象的概念,所谓稳妥对象,指的是没有公共属性,而且其方法也不引用 this 对象。稳妥对象最适合在一些安全环境中,或者防止数据被其他应用程序改动时使用

function Person(name, age, job) {
  var sayName = function() {
    console.log(name);
  },
  sayAge = function() {
    console.log(age);
  },
  sayJob = function() {
    console.log(job);
  };
  return {
    sayName: sayName,
    sayAge: sayAge,
    sayJob: sayJob
  };
}
var person1 = Person("Lucy", 24, "nurse");
person1.sayName(); //"Lucy"

在这种模式下,除了使用 sayName() 方法之外,没有其他方法访问 name 的值。稳妥构造函数模式提供的这种安全性,使得它非常适合在某些安全执行环境下使用。要注意的一点是,使用稳妥构造函数模式时同样无法用 instanceof 关键字判断自定义对象的类型


参考资料:

《JavaScript高级程序设计(第3版)》 作者: Nicholas C.Zakas  译者:李松峰 曹力