ES 面向对象 —— 创建对象

published

Object()

创建一个 Object 的实例,然后再为它添加属性和方法:

const person = new Object()
person.name = 'bill'
person.age = 25

person.sayName = function() {
  console.log(this.name)
}

早期的开发人员经常使用这个方法创建新对象,但是几年后,通过对象字面量创建对象成为了首选的方法。

对象字面量

const person = {
  name: 'bill',
  age: 25,
  sayHi: function () {
    console.log('Hi')
  }
}

ES2015 中添加了一种简写方式,用来简写属性和方法:

let name = 'bill'
let age = 25

const person = {
  name,
  age,
  sayHi() {
    console.log('Hi')
  }
}

如果某个方法是一个 Generator 函数,方法名前面得加上 *

let obj = {
  * m() {
    yield 'hello world'
  }
}

访问器属性的 setter 和 getter,也可以使用简写方式:

let cart = {
  _wheels: 4,

  get wheels () {
    return this._wheels
  },

  set wheels (value) {
    if (value < this._wheels) {
      throw new Error('数值太小了!')
    }
    this._wheels = value
  }
}

cart.wheels = 1  // Error: 数值太小了!
cart.wheels = 5
console.log(cart.wheels)  // 5

因为这种简洁写法的属性名其实是字符串,所以有时候会有些看起来很怪的现象:

let obj = {
  class() {
  }
}

这地方怎么用 class 当标识符? class 不是关键字吗?怎么不会发生语法解析错误。其实在这里 class 是个字符串。也就是说上面的代码等同于:

let obj = {
  'class': function() {
  }
}

虽然通过 Object() 和对象字面量都能创建对象,但是这两个方法有个缺点 —— 在创建很多相似对象的时候,会产生大量的重复代码。为了解决这个问题,开发人员开始使用工厂模式的一种变体。

工厂模式变体

考虑到在 ES 中无法创建类,开发人员就发明了一种函数,用它来封装以特定接口创建对象的细节:

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 = createPerson("Merlin", 25, "Nerd")
var person2 = createPerson("Billy", 29, "BigNerd")

工厂模式虽然解决了创建多个相似对象的问题,但是却没有解决对象识别的问题(辨别对象的类型)。 随着语言的发展,又一个新模式出现了。

构造函数模式

使用构造函数将前面的例子重写:

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("bill", 25, "Nerd")
var person2 = new Person("Billy", 29, "BigNerd")

按照惯例,构造函数名使用大写字母开头。这个做法借鉴于其他 OO 语言,主要是为了区别于 ES 中的其他函数。

Person() 函数取代了 createPerson() 函数,并且 Person() 构造函数还有以下特点:

  • 没有显式地创建对象
  • 直接将属性和方法赋给了 this
  • 没有 return 语句

要创建 Person 的实例,必需使用 new 运算符。以这种方式调用构造函数会执行以下几个步骤:

  • 创建一个新对象
  • this 指向新对象
  • 执行构造函数中的代码(为新对象添加属性和方法)
  • 返回新对象

通过这种方式创建出的对象,会有一个 constructor 属性,这个属性最初是用来标识对象类型的。比如,在这个例子中,该属性指向 Person 。但是,提到检测对象的类型,还是使用 instanceof 运算符更靠谱。

可以检测对象类型,是构造函数模式相对于工厂模式的一大优点。

构造函数也是函数

构造函数和其他函数的唯一区别,就是它们的调用方式不同 —— 通过 new 运算符来调用。 前面定义的 Person() 函数可以通过任意方式来调用:

// 1. 当作构造函数使用
// 这是构造函数的典型用法 —— 通过 new 运算符来创建一个对象
var person = new Person("bill", 25, "Nerd")
person.sayName()  // "bill"

// 2. 作为普通函数调用
// 在全局作用域中调用一个函数, this 指向了 Global 对象。
Person("bill", 25, "Nerd")
global.sayName()  // "bill"

// 3. 在另一个对象的作用域中调用
var o = new Object()
Person.call(o, "bill", 25, "Nerd")
o.sayName()  // "bill"

作用域安全的构造函数

构造函数就是一个使用 new 运算符调用的函数。当使用 new 调用时,构造函数内的 this 会指向新创建的对象,比如:

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

var person = new Person('bill', 25, 'Nerd')

看起来一切都好,但是如果没有使用 new 运算符呢来调用这个构造函数呢?由于 this 是在运行时绑定的,所以直接调用 Person() 的话, this 会指向全局对象,导致错误的属性添加。比如:

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

var person = Person('bill', 25, 'Nerd')
console.log(global.name)  // bill
console.log(global.age)  // 25
console.log(global.job)  // Nerd

解决这个问题的方法是创建一个作用域安全的构造函数 —— 在进行任何更改前,首先确定 this 指向的是不是正确类型的对象。如果不是,那么就创建新的实例并返回。比如:

function Person(name, age, job) {
  if (this instanceof Person) {
    this.name = name
    this.age = age
    this.job = job
  } else {
    return new Person(name, age, job);
  }
}

var person1 = Person('bill', 25, 'Nerd')
console.log(global.name)  // undefined
console.log(person1.name)  // 'bill'

var person2 = new Person('m5n', 25, 'Nerd')
console.log(person2.name)  // 'm5n'

这种方法确保了对象初始化的正确性,而不管是否使用了 new 运算符。 注意,一旦使用这个模式,也就锁定了调用构造函数的环境。如果使用借用构造函数模式创建继承关系而不使用原型链,那么继承可能被破坏,如:

function Polygon(sides) {
  if (this instanceof Polygon) {
    this.sides = sides
    this.getArea = function () {
      return 0
    }
  } else  {
    return new Polygon(sides)
  }
}

function Rectangle(width, height) {
  Polygon.call(this, 4)
  this.width = width
  this.height = height
  this.getArea = function () {
    return this.width * this.height
  }
}

var rect = new Rectangle(5, 10)
console.log(rect.sides)  // undefined

以上代码中,创建 Rectangle 实例时通过 Polygon.call() 来创建 sides 属性。但是,由于 Polygon.call(this, 4) 中的 this 指向 Rectangle 的实例,而且 Polygon 构造函数是作用域安全的,所以就会执行 return new Polygon(sides)。Rectangle 构造函数中的 this 指向的对象就没有得到 sides 属性。 如果使用组合继承或寄生组合继承就可以解决这个问题了。

function Polygon(sides) {
  if (this instanceof Polygon) {
    this.sides = sides
    this.getArea = function () {
      return 0
    }
  } else  {
    return new Polygon(sides)
  }
}

function Rectangle(width, height) {
  Polygon.call(this, 4)
  this.width = width
  this.height = height
  this.getArea = function () {
    return this.width * this.height
  }
}

Rectangle.prototype = new Polygon()

var rect = new Rectangle(5, 10)
console.log(rect.sides)  // 4

上述代码中,一个 Rectangle 的实例也是 Polygon 的实例,所以 Polygon.call() 会为 Rectangle 的实例 rect 添加 sides 属性。

多人协作时,对全局对象意外的更改可能会导致一些难以追踪的错误,作用域安全的构造函数在这时很有用。

构造函数的问题

构造函数虽然好用,但也不是没有缺点。它最主要的问题,就是每个方法都要在新的实例上重新创建一遍,无法被复用。 在前面的例子中, person1person2 都有一个名为 sayName() 的方法,但那两个方法并不是同一个对象。

ES 中的函数是对象,因此每定义一个函数,也就是实例化了一个对象。

为完成同样的任务创建了两个 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)
}

var person1 = new Person("bill", 25, "Nerd")
var person2 = new Person("Billy", 29, "BigNerd")

在上面的代码中:

  • sayName() 函数的定义转移到了构造函数的外部
  • 在构造函数内部,将 =sayName= 属性设置成了全局的 sayName() 函数

这样一来,由于 sayName 属性包含的是一个指向函数的指针,所以 person1person2 对象就共享了这个在全局作用域中定义的 sayName() 函数。 这样做确实解决了上述的问题,但是新的问题又来了:

  • 在全局作用域中定义的函数实际上只能被某个对象调用(因为这个函数就是为对象而生的,你在其他地方调用也没啥意义),所以这个函数放在全局作用域里确实不合适
  • 如果对象需要定义很多方法,那就要定义很多个全局函数,这就导致这个自定义的引用类型一点封装性都没有了

好在,这个问题可以通过原型模式得到解决。

原型模式

在 ES 中,每个对象都有 prototype 属性,函数也不例外。通过调用某一构造函数而创建的所有对象共享一个原型,这个原型可以让所有对象共享它所包含的属性和方法。

function Person() {
}

Person.prototype.name = "bill"
Person.prototype.age = 25
Person.prototype.job = "Nerd"
Person.prototype.sayName = function () {
  console.log(this.name)
}

var person1 = new Person()
person1.sayName()

var person2 = new Person()
person2.sayName()

console.log(person1.sayName === person2.sayName)  // true

在此,将所有属性和 sayName() 方法直接添加到了 Personprototype 属性中,构造函数变成了空函数。即便如此,也还是可以通过构造函数来创建新的对象。但是和构造函数模式不同的是,新对象的这些属性和方法被所有实例共享。

更简单的原型语法

前面的例子每当添加属性或方法时,就要敲一遍 Person.prototype 。为了减少不必要的输出,也为了从视觉上更好地封装原型的功能,更常见的用法是用一个包含所有属性和方法的对象字面量来重写整个原型对象:

function Person() {
}

Person.prototype = {
  name: 'bill',
  age: 29,
  job: 'Nerd',
  sayName: function () {
    console.log(this.name)
  }
}

上面的代码将 Person.prototype 设置成为一个以对象字面量形式创建的新对象。最终结果相同,但有一个例外 —— constructor 属性不再指向 Person 了。 这是为什么呢?前面介绍过,每创建一个函数,就会同时创建它的 prototype 对象,这个对象也会自动获得 constructor 属性。而这里使用的方法,本质上完全重写了默认的 prototype 对象,因此 constructor 属性也就变成了新对象的 constructor 属性(指向 Object 构造函数),不再指向 Person 函数。 尽管 instanceof 运算符还能返回正确的结果,但是通过 constructor 属性已经无法确定对象的类型了。

var friend = new Person()

console.log(friend instanceof Object)  // true
console.log(friend instanceof Person)  // true
console.log(friend.constructor === Person)  // false
console.log(friend.constructor === Object)  // true

如果 constructor 的确实很重要,可以像下面这样特意将它设置回适当的值:

function Person() {
}

Person.prototype = {
  constructor: Person,
  name: 'bill',
  age: 29,
  job: 'Nerd',
  sayName: function () {
    console.log(this.name)
  }
}

以这种方式重设 contructor 属性还有一个问题 —— 导致它的 [[Enumerable]] 特性被设置为 true。 默认情况下,原生的 constructor 属性是不可枚举的,如果要设置这个属性,可以使用 Object.defineProperty()

function Person() {
}

Person.prototype = {
  name: 'bill',
  age: 29,
  job: 'Nerd',
  sayName: function () {
    console.log(this.name)
  }
}

Object.defineProperty(Person.prototype, 'constructor', {
  enumerable: false,
  value: Person
})

ES2015 提供了更方便的方式:

function Person() {
}

Object.assign(Person.prototype, {
  name: 'bill',
  age: 29,
  job: 'Nerd',
  sayName: function () {
    console.log(this.name)
  }
})

原型模式的问题

原型模式也有缺点。 原型中所有属性和方法都是被实例共享的,这种共享对于方法非常合适,对于那些包含基本值的属性也说得过去(通过在实例上添加一个同名属性,就可以隐藏原型中对应的属性了),但对于包含引用类型值的属性来说,问题就来了:

function Person() {
}

Object.assign(Person.prototype, {
  name: 'bill',
  age: 29,
  job: 'Nerd',
  friends: ['billy', 'jilly'],
  sayName: function () {
    console.log(this.name)
  }
})

var person1 = new Person()
var person2 = new Person()

person1.friends.push('Van')

console.log(person1.friends)  // ['billy', 'jilly', 'van']
console.log(person2.friends)  // ['billy', 'jilly', 'van']
console.log(person1.friends === person2.friends)  // true

person1person2 共享了 Person.prototype.friends。可是,实例一般都要有属于自己的属性。因此,原型模式很少被单独使用。

组合构造函数模式和原型模式(常用)

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

  • 构造函数模式:用于定义实例属性
  • 原型模式:用于定义方法和共享的属性

这样,每个实例都会有属于自己的实例属性,同时有共享着方法的引用,最大限度地节省了内存。

function Person(name, age, job){
  this.name = name
  this.age = age
  this.job = job
  this.friends = ["billy", "jilly"]
}

Object.assign(Person.prototype, {
  sayName: function () {
    console.log(this.name)
  }
})

var person1 = new Person('bill', 25, 'Nerd')
var person2 = new Person('merlin', 20, 'nerd')

person1.friends.push('van')

console.log(person1.friends)  // ['jilly', 'billy', 'van']
console.log(person2.friends)  // ['jilly', 'billy']

console.log(person1.friends === person2.friends)  // false
console.log(person1.sayName === person2.sayName)  // true

这种构造函数模式与原型模式混合的模式,是目前在 ES 中使用最广泛、认同度最高的一种创建自定义类型的方法。

动态原型模式

有其他 OO 语言经验的开发人员在看到独立的构造函数和原型时,可能会觉得很困惑。 动态原型模式是致力于解决这个问题的一个方案,它把所有信息都封装在构造函数中,这包括在其中初始化原型。

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

  if (typeof this.sayName != 'function') {
    Person.prototype.sayName = function () {
      console.log(this.name)
    }
  }
}

var friend = new Person('bill', 25, 'Nerd')
friend.sayName()

其中,

Person.prototype.sayName = function (){
  console.log(this.name)
}

这段代码只会在原型中不存在 sayName 方法时才会执行。并且只在初次调用构造函数时执行(因为,调用一次之后,原型就已经完成初始化,相关的方法已经存在了,if 语句的判断总是 false

if 语句可以只检查一个应该存在的属性和方法,而不用检查每个属性和每个方法。

寄生构造函数模式

这种模式的基本思想是:创建一个函数,该函数的作用仅仅是封装创建对象的代码,然后返回新创建的对象。

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 friend = new Person('bill', 25, 'Nerd')
friend.sayName()  // 'bill'

除了使用 new 运算符并把使用的包装函数叫做构造函数之外,这个模式跟工厂函数模式其实是一模一样的。 构造函数在不明确指定返回值的情况下,默认会返回新对象实例(也就是 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")
console.log(colors.toPipedString())  // "red|blue|green"

关于寄生构造函数模式,有一点需要说明:

  • 返回的对象与构造函数以及构造函数的原型之间没有任何关系(也就是说,构造函数返回的对象和在构造函数外部创建的对象没什么不同)

因为这一点, instanceof 运算符也就没法被用来确定对象类型了。

在可以使用其他模式的情况下,不要使用这种模式。

稳妥构造函数模式

Douglas Crockford 发明了 JavaScript 中的稳妥对象(Durable Objects)这个概念。 稳妥对象:

  • 没有公共属性
  • 方法也不引用 this

稳妥对象最适合在一些安全的环境中(这些环境中禁止使用 thisnew ),或者防止数据被其他应用程序(如 Mashup 程序)改动时使用。 稳妥构造函数遵循与寄生构造函数类似的模式,但有两点不同:

  1. 不使用 new 运算符调用构造函数
  2. 新创建对象的实例方法不引用 this

稳妥构造函数模式本质是用闭包的局部变量保存数据。

按照稳妥构造函数的要求,可以将前面的 Person 构造函数重写:

function Person(name, age, job) {
  var o = new Object()

  // private members
  var nameUC = name.toUpperCase();

  // public members
  o.sayName = function() {
    console.log(name)
  }

  o.sayNameUC = function() {
    console.log(nameUC)
  }

  return o
}

var person = Person('bill', 25, 'Nerd')
person.sayName()  // 'bill'
person.sayNameUC()  // 'BILL'

console.log(person.name)  // undefined
console.log(person.nameUC)  // undefined

以这种模式创建的对象中,namenameUC 变成了私有的属性,除了使用 sayName()sayNameUC() 方法,没有其他方法可以访问到它们。 稳妥构造函数模式提供的这种安全性,使得它非常适合在某些安全执行环境下使用。 与寄生构造函数模式类似,使用稳妥构造函数模式创建的对象与构造函数以及构造函数的原型之间也没有什么关系,因此 instanceof 运算符对这种对象也没有意义。