深入理解 JavaScript 类(一)———— 创建对象

86 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第 2 天,点击查看活动详情

在 JavaScript 中使用 class 时,可以很方便地创建类和继承,然而在 ES6 之前的规范中,是使用构造函数和基于原型的继承模式,而 class 仅仅是它们的语法糖。因此在了解 ES6 class 之前,有必要了解以前模拟类和继承的方法。

首先来看看在 JavaScript 中是如何创建一个对象的。

工厂模式

工厂模式是一种常见的设计模式,通过工厂方法返回想要的对象。

function createPerson(name, age) {
  let o = new Object()
  o.name = name
  o.age = age
  o.sayName = function() {
    console.log(this.name)
  }
  
  return o
}

let person = createPerson("wang", 18)

console.log(person) // { name: 'wang', age: 18, sayName: [Function (anonymous)] }
// }

工厂模式虽然可以解决创建多个类似对象的问题,但是它创建的对象没有标识。

构造函数模式

在 JavaScript 中,任何函数只要使用 new 操作符调用就是构造函数,而不使用 new 操作符调用的函数就是普通函数。

构造函数也称构造器(constructor),是创建对象时会调用的函数。

箭头函数不能使用 new 调用。

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

let person1 = new Person("wang", 18)
let person2 = new Person("hou", 18)

console.log(person1) // Person { name: 'wang', age: 18, sayName: [Function (anonymous)] }
console.log(person2) // Person { name: 'hou', age: 18, sayName: [Function (anonymous)] }

使用构造函数创建对象时,它打印出了对象标识,也就是说它解决了工厂模式没有对象标识的问题。

构造函数存在的问题:

首先让我们来看看 new 操作的过程:

  1. 首先创建一个空对象;
  2. 将空对象的原型赋值为构造函数的 prototype 对象;
  3. 让构造函数的 this 指向这个空对象,执行构造函数的代码;
  4. 判断构造函数返回值的类型,如果是基本类型,则返回创建的对象,如果是引用类型,则返回这个引用类型。

注意到第三步,构造函数的问题在于定义的方法会在每个实例上都创建一次。

要解决这个问题,可以把函数定义转移到构造函数外部:

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

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

let person1 = new Person("wang", 18)
let person2 = new Person("hou", 18)

但是如果这个对象需要多个方法,就要在全局作用域上定义多个函数,这会污染全局作用域。这个问题可以通过原型模型来解决。

原型模式

在了解原型模式的具体过程前,先来看看隐式原型和显示原型的概念。

隐式原型和显示原型

隐式原型

在通过对象访问属性时,会按照这个属性的名称开始搜索。搜索开始于对象实例本身。如果在这个实例上发现了给定的名称,则返回该名称对应的值。如果没有找到这个属性,则搜索会沿着**原型链(隐式原型)**继续查找。

每个对象都有一个特殊的内置属性 [[prototype]],这个特殊的对象指向另外一个对象。早期的 ECMA 没有规范如何去查看 [[prototype]],因此浏览器给对象提供一个 __proto__ 属性来查看这个属性。

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

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

let person1 = new Person("wang", 18)
// 查看对象隐式原型
console.log(person1.__proto__) // {}

ES5 之后提供了 Object.getPrototypeOf() 查看:

// 查看对象隐式原型
console.log(Object.getPrototypeOf(person1)) // {}

显式原型

函数作为对象,也拥有隐式原型 [[prototype]] 属性。

此外,每个函数都会创建一个 prototype 属性,称为函数的显式原型。这个属性指向原型对象,包含应该由特定引用类型的实例共享的属性和方法。所有原型对象自动获得一个名为 constructor 的属性,指回与之关联的构造函数。

function foo() {}

console.log(Object.getOwnPropertyDescriptors(foo.prototype))
// {
//   constructor: {
//     value: [Function: foo],
//     writable: true,
//     enumerable: false,
//     configurable: true
//   }
// }

原型模式具体实现过程

了解了隐式原型和显式原型后,我们就可以来看看原型模式具体是如何实现的了。

JavaScript 利用每个函数都有一个显式原型 prototype 属性(这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法),将需要共享的函数或属性放在这个原型对象上,而新创建的实例对象,就会通过它的隐式原型 __proto__ 顺着原型链往上找,就可以很好的解决构造函数模式污染全局作用域的问题。

让我们回忆一下前面 new 关键字的步骤时:

  1. 首先创建一个空对象;
  2. 将空对象的原型赋值为构造函数的 prototype 对象;
  3. 让构造函数的 this 指向这个空对象,执行构造函数的代码;
  4. 判断构造函数返回值的类型,如果是基本类型,则返回创建的对象,如果是引用类型,则返回这个引用类型。

其中第 2 步就是:构造函数的显示原型会被赋值给新对象的隐式原型

function Person() {}

Person.prototype.name = "wang"
Person.prototype.age = 18
Person.prototype.sayName = function () {
  console.log(this.name)
}

let person1 = new Person()
person1.sayName() // wang

上面定义原型时语法有点冗余,因此常用对象字面量直接重写函数原型。

function Person() {}

Person.prototype = {
  name: "wang",
  age: 18,
  sayName() {
    console.log(this.name)
  },
}

// 使 constructor 属性不可枚举
Object.defineProperty(Person.prototype, "constructor", {
  enumerable: false,
  value: Person,
})

原型模式存在的问题

原型模式是实现所有原生引用类型的模式,所有原生引用类型的构造函数(包括Object、Array、String 等)都在原型上定义了实例方法,因此我们才能调用那些方法。

但原型模式也有它的问题:

  • 弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值;
  • 原型上的引用属性是在实例间共享的,因此修改一个会影响其他实例。

来看一个例子:

function Person() {}

Person.prototype = {
  constructor: Person,
  name: "wang",
  age: 18,
  friends: ["neymar"],
  sayName() {
    console.log(this.name)
  },
}

// 使 constructor 属性不可枚举
Object.defineProperty(Person.prototype, "constructor", {
  enumerable: false,
  value: Person,
})

// 1. 无法向构造函数传参
let person1 = new Person()
let person2 = new Person()

person1.friends.push("messi")

// 2. 原型上的引用属性在实例间共享
console.log(person1.friends) // [ 'neymar', 'messi' ]
console.log(person2.friends) // [ 'neymar', 'messi' ]
console.log(person1.friends === person2.friends) // true

此时在 1 处无法向构造函数传参,而且在 2 处修改 person1.friends 也会影响到 person2 实例,因为他们指向的原型中的同一个地址。

为了解决这个问题,可以组合使用构造函数和原型模式

组合使用构造函数和原型模式

首先来回忆一下构造函数和原型模式各自的问题:

  • 构造函数模式:如果这个对象需要多个方法,就要在全局作用域上定义多个函数,会污染全局作用域;
  • 原型模式:弱化了向构造函数传递初始化参数的能力,引用属性会存在共享问题。

既然他们分开使用都会存在一些问题,因此可以组合使用这两种模式:通过构造函数初始化对象的属性,通过原型对象实现方法的复用

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

Person.prototype = {
  constructor: Person,
  sayName() {
    console.log(this.name)
  },
}

// 使 constructor 属性不可枚举
Object.defineProperty(Person.prototype, "constructor", {
  enumerable: false,
  value: Person,
})

let person1 = new Person("wang", 18, ["neymar"])
let person2 = new Person("hou", 18, ["messi"])

person1.friends.push('hou')
person2.friends.push('wang')

console.log(person1.friends) // [ 'neymar', 'hou' ]
console.log(person2.friends) // [ 'messi', 'wang' ]

此时不仅可以传递初始化参数,而且修改各自的 friends 属性都互不影响。

总结

以上就是 JavaScript 中创建对象的四种主要方式,包括工厂模式、构造函数模式、原型模式及组合使用构造函数和原型模式。让我们来回忆一下前面三种各自的缺点:

  • 工厂模式:无法解决对象标识问题;
  • 构造函数模式:对象需要多个方法,就要在全局作用域上定义多个函数,这会污染全局作用域;
  • 原型模式:弱化了向构造函数传递初始化参数的能力,引用属性会存在共享问题。