Skip to content

对 JS 面向对象(OO)编程的理解

面向对象编程(OOP)是种具有对象概念的编程方法。JS 中可以通过多种模式创建对象,比如工厂模式构造函数模式原型模式等。而在 JS 中又利用继承来达到代码重用和可扩展性的特性,也符合在面向对象编程中“封装”的哲学。这是说不应该访问对象的底层实现,而是使用抽象方法来与之交互。JS 主要通过原型链实现继承,原型链的构建是通过将一个类型的实例赋值给另一个构造函数的原型实现的。这样,子类型就能够访问父类的所有属性和方法。

js
function SuperType() {
  this.property = true
}

SuperType.prototype.getSuperType = function () {
  return this.property
}

function SubType() {
  this.subProperty = false
}

// 继承SuperType
SubType.prototype = new SuperType() 
SubType.prototype.getSubType = function () {
  return this.subProperty
}
let instance = new SubType()
console.log(instance.getSuperType()) // true
原型链的问题
  1. 主要问题出现在原型中包含引用值的时候。

原型中包含的引用值会在所有实例间共享,这也是为什么属性通常会 在构造函数中定义而不会定义在原型上的原因。在使用原型实现继承时,原型实际上变成了另一个类型 的实例。这意味着原先的实例属性变成了原型属性。

js
function SuperType() {
  this.colors = ['red', 'blue', 'green']
}
function SubType() {}
// 继承SuperType
SubType.prototype = new SuperType()
let instance1 = new SubType()
instance1.colors.push('black')
console.log(instance1.colors) // "red,blue,green,black"
let instance2 = new SubType()
console.log(instance2.colors) // "red,blue,green,black"

在这个例子中,SuperType 构造函数定义了一个 colors 属性,其中包含一个数组(引用值)。每 个 SuperType 的实例都会有自己的 colors 属性,包含自己的数组。但是,当 SubType 通过原型继承 SuperType 后,SubType.prototype 变成了 SuperType 的一个实例,因而也获得了自己的 colors 属性。这类似于创建了 SubType.prototype.colors 属性。最终结果是,SubType 的所有实例都会 共享这个 colors 属性。

  1. 在创建子类型的实例时,不能向父类型的构造函数中传递参数。

组合继承

可以使用组合继承解决原型中包含引用类型值所带来的问题,它将原型链和借用构造函数的技术组合到一起。

js
function SuperType(name) {
  this.name = name
  this.colors = ['red', 'blue', 'green']
}
SuperType.prototype.sayName = function () {
  console.log(this.name)
}
function SubType(name, age) {
  // 继承属性
  SuperType.call(this, name) 
  this.age = age
}
// 继承方法
SubType.prototype = new SuperType() 
SubType.prototype.sayAge = function () {
  console.log(this.age)
}
let instance1 = new SubType('Nicholas', 29)
instance1.colors.push('black')
console.log(instance1.colors) // "red,blue,green,black"
instance1.sayName() // "Nicholas";
instance1.sayAge() // 29
let instance2 = new SubType('Greg', 27)
console.log(instance2.colors) // "red,blue,green"
instance2.sayName() // "Greg";
instance2.sayAge() // 27

在这个例子中,SuperType 构造函数定义了两个属性,name 和 colors,而它的原型上也定义了一个方法叫 sayName()。SubType 构造函数调用了 SuperType 构造函数,传入了 name 参数,然后又定义了自己的属性 age。此外,SubType.prototype 也被赋值为 SuperType 的实例。原型赋值之后,又在这个原型上添加了新方法 sayAge()。这样,就可以创建两个 SubType 实例,让这两个实例都有自己的属性,包括 colors,同时还共享相同的方法。

类(class)

ES6 中引入了 class 关键字具有正式定义类的能力。在 JS 中,类可以看作是已有的原型继承机制的一种抽象。

js
// 在类中,实例的创建是通过构造函数来完成的。
class Color {
  constructor(r, g, b) {
    this.values = [r, g, b]
  }
}
// 构造函数的语法与普通函数完全相同,这意味着可以使用其他语法,例如剩余参数:
class Color {
  constructor(...values) {
    this.values = values
  }
}

const red = new Color(255, 0, 0)
console.log(red)
js
// 实例方法被定义在所有实例的原型上,即 Color.prototype。
class Color {
  constructor(r, g, b) {
    this.values = [r, g, b]
  }
  getRed() {
    return this.values[0]
  }
}

const red = new Color(255, 0, 0)
console.log(red.getRed()) // 255
js
// 在面向对象编程中,有一个叫做“封装”的哲学:不应该访问对象的底层实现,而是使用抽象方法来与之交互。
class Color {
  // 每个 Color 实例都有一个名为 #values 的私有字段。
  #values
  constructor(r, g, b) {
    this.#values = [r, g, b]
  }
  getRed() {
    return this.#values[0]
  }
  setRed(value) {
    this.#values[0] = value
  }
}

const red = new Color(255, 0, 0)
console.log(red.getRed()) // 255
js
/**
 * 就像是对象有了一个 red 属性,但实际上,实例上并没有这样的属性。实例只有两个方法,分别以 get 和 set 为前缀,而这使得我们可以像操作属性一样操作它们。
 * 如果一个字段仅有一个 getter 而没有 setter,它将是只读的。
 */
class Color {
  constructor(r, g, b) {
    this.values = [r, g, b]
  }
  get red() {
    return this.values[0]
  }
  set red(value) {
    this.values[0] = value
  }
}

const red = new Color(255, 0, 0)
red.red = 0
console.log(red.red) // 0
js
class MyClass {
  luckyNumber = Math.random()
}
console.log(new MyClass().luckyNumber) // 0.5
console.log(new MyClass().luckyNumber) // 0.3

// 公共字段几乎等价于将一个属性赋值给 this。例如,上面的例子也可以转换为:
class MyClass {
  constructor() {
    this.luckyNumber = Math.random()
  }
}
js
/**
 * 静态属性是一组在类本身上定义的特性,而不是在类的实例上定义的特性。这些特性包括:
 * ·静态方法
 * ·静态字段
 * ·静态 getter 与 setter
 */
class Color {
  static isValid(r, g, b) {
    return r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255
  }
}

Color.isValid(255, 0, 0) // true
Color.isValid(1000, 0, 0) // false

继承:类的一个关键特性(除了私有字段)是继承,这意味着一个对象可以“借用”另一个对象的大部分行为,同时覆盖或增强某些部分的逻辑。

例如,假定我们需要为 Color 类引入透明度支持。我们可能会尝试添加一个新的字段来表示它的透明度:

js
class Color {
  #values
  constructor(r, g, b, a = 1) {
    this.#values = [r, g, b, a]
  }
  get alpha() {
    return this.#values[3]
  }
  set alpha(value) {
    if (value < 0 || value > 1) {
      throw new RangeError('Alpha 值必须在 0 与 1 之间')
    }
    this.#values[3] = value
  }
}

然而,这意味着每个实例——即使是大多数不透明的实例(那些 alpha 值为 1 的实例)——都必须有额外的 alpha 值,这并不是很优雅。此外,如果特性继续增长,我们的 Color 类将变得非常臃肿且难以维护。

所以,在面向对象编程中,我们更愿意创建一个派生类。派生类可以访问父类的所有公共属性。在 JavaScript 中,派生类是通过 extends 子句声明的,它指示它扩展自哪个类。

js
/**
 * 在访问 this 之前,必须调用 super(),这是 JavaScript 的要求。
 * super() 调用父类的构造函数来初始化 this,这里大致相当于 this = new Color(r, g, b)。
 * super() 之前也可以有代码,但不能在 super() 之前访问 this。
 */
class ColorWithAlpha extends Color {
  #alpha
  constructor(r, g, b, a) {
    super(r, g, b)
    this.#alpha = a
  }
  get alpha() {
    return this.#alpha
  }
  set alpha(value) {
    if (value < 0 || value > 1) {
      throw new RangeError('Alpha 值必须在 0 与 1 之间')
    }
    this.#alpha = value
  }
}

名词解释

原型链

每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型对象的内部指针。那么,假如让原型对象等于另一个类型的实例,结果会怎么样呢?显然,此时的原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函数的指针。假如另一个原型又是另一个类型的实例,那么上述关系依然成立,如此层层递进,就构成了实例与原型的链条。这就是所谓原型链的基本概念。

上面例子中的实例以及构造函数和原型之间的关系如图所示。 原型链

在上面的代码中,没有使用 SubType 默认提供的原型,而是给它换了一个新原型;这个新原型就是 SuperType 的实例。于是,新原型不仅具有作为一个 SuperType 的实例所拥有的全部属性和方法,而且其内部还有一个指针,指向了 SuperType 的原型。最终结果就是这样的:instance 指向 SubType 的原型,SubType 的原型又指向 SuperType 的原型。getSupervalue()方法仍然还在 SuperType.prototype 中,但 property 则位于 SubType.prototype 中。这是因为 property 是一个实例属性,而 getSuperValue()则是一个原型方法。既然 Subrype.prototype 现在是 SuperType 的实例,那么 property 当然就位于该实例中了。此外,要注意 instance.constructor 现在指向的是 SuperType,这是因为原来 SubType.prototype 中的 constructor 被重写了的缘故。