面向对象编程的味道(JavaScript 中)

Avatar of Zell Liew
Zell Liew

DigitalOcean 为您的旅程的每个阶段提供云产品。 立即开始使用 200 美元的免费积分!

在我的研究中,我发现 JavaScript 中有四种面向对象编程的方法

我应该使用哪些方法? 哪一种是“最佳”方法? 在这里,我将介绍我的发现以及可能有助于您做出正确选择的信息。

为了做出决定,我们不仅要查看不同的风格,还要比较它们之间的概念方面

什么是面向对象编程?

面向对象编程是一种编写代码的方式,它允许您从一个公共对象创建不同的对象。 公共对象通常称为蓝图,而创建的对象称为实例

每个实例都具有与其他实例不共享的属性。 例如,如果您有一个 Human 蓝图,您可以创建具有不同名称的人类实例。

面向对象编程的第二个方面是关于在您有多级蓝图时构建代码。 这通常称为继承或子类化。

面向对象编程的第三个方面是关于封装,您在其中隐藏对象内的某些信息,因此它们不可访问。

如果您需要更多关于此简短介绍的信息,这里有一篇文章介绍了面向对象编程的这一方面,如果您需要帮助。

让我们从基础开始——介绍面向对象编程的四种风格。

面向对象编程的四种风格

在 JavaScript 中有四种编写面向对象编程的方式。 他们是

使用构造函数

构造函数是包含this关键字的函数。

function Human (firstName, lastName) {
  this.firstName = firstName
  this.lastName = lastName
}

this允许您存储(和访问)为每个实例创建的唯一值。 您可以使用new关键字创建一个实例。

const chris = new Human('Chris', 'Coyier')
console.log(chris.firstName) // Chris
console.log(chris.lastName) // Coyier

const zell = new Human('Zell', 'Liew')
console.log(zell.firstName) // Zell
console.log(zell.lastName) // Liew

类语法

据说类是构造函数的“语法糖”。 就像,类是编写构造函数的更简单方法。

关于类是否不好(如 )存在争议很大。 我们不会在这里深入探讨这些论点。 相反,我们只是要看看如何用类编写代码,并根据我们编写的代码来决定类是否比构造函数更好。

可以使用以下语法编写类

class Human {
  constructor(firstName, lastName) {
    this.firstName = firstName
    this.lastName = lastName
  }
}

注意到constructor函数包含与上面的构造函数语法相同的代码吗? 我们需要这样做,因为我们想要将值初始化到this中。 (如果我们不需要初始化值,可以跳过constructor。 稍后在继承下会详细介绍)。

乍一看,类似乎不如构造函数——需要编写更多代码! 等等,现在不要得出结论。 我们还有很多东西要讲。 类以后会开始发光。

和以前一样,您可以使用new关键字创建一个实例。

const chris = new Human('Chris', 'Coyier')

console.log(chris.firstName) // Chris
console.log(chris.lastName) // Coyier

对象链接到其他对象(OLOO)

OLOO 由 Kyle Simpson 创造和推广。 在 OLOO 中,您将蓝图定义为一个普通对象。 然后,您使用一个方法(通常命名为init,但不需要像constructor对类那样)来初始化实例。

const Human = {
  init (firstName, lastName ) {
    this.firstName = firstName
    this.lastName = lastName
  }
}

您使用Object.create来创建实例。 创建实例后,您需要运行您的init函数。

const chris = Object.create(Human)
chris.init('Chris', 'Coyier')

console.log(chris.firstName) // Chris
console.log(chris.lastName) // Coyier

如果您在init内部返回this,则可以在Object.create之后链接init

const Human = {
  init () {
    // ...
    return this 
  }
}

const chris = Object.create(Human).init('Chris', 'Coyier')
console.log(chris.firstName) // Chris
console.log(chris.lastName) // Coyier

工厂函数

工厂函数是返回对象的函数。 您可以返回任何对象。 您甚至可以返回类实例或 OLOO 实例——它仍然是一个有效的工厂函数。

以下是以最简单的方式创建工厂函数

function Human (firstName, lastName) {
  return {
    firstName,
    lastName
  }
}

您不需要new来使用工厂函数创建实例。 您只需调用函数即可。

const chris = Human('Chris', 'Coyier')

console.log(chris.firstName) // Chris
console.log(chris.lastName) // Coyier

既然我们已经看到了这四种 OOP 设置可能性,让我们看看如何在它们中的每一个上声明属性和方法,以便在进行更大的比较之前,我们能够更好地理解如何使用它们。


声明属性和方法

方法是声明为对象的属性的函数。

const someObject = {
  someMethod () { /* ... */ }
}

在面向对象编程中,有两种方法可以声明属性和方法

  1. 直接在实例上
  2. 在原型中

让我们学习如何做这两件事。

使用构造函数声明属性和方法

如果您想直接在实例上声明一个属性,可以在构造函数内部编写该属性。 确保将其设置为this的属性。

function Human (firstName, lastName) {
  // Declares properties
  this.firstName = firstName
  this.lastname = lastName

  // Declares methods
  this.sayHello = function () {
    console.log(`Hello, I'm ${firstName}`)
  }
}

const chris = new Human('Chris', 'Coyier')
console.log(chris)

方法通常在原型上声明,因为原型允许实例使用相同的方法。 它是更小的“代码占用空间”。

要在原型上声明属性,您需要使用prototype属性。

function Human (firstName, lastName) {
  this.firstName = firstName
  this.lastname = lastName
}

// Declaring method on a prototype
Human.prototype.sayHello = function () {
  console.log(`Hello, I'm ${this.firstName}`)
}

如果您想在原型中声明多个方法,可能会很麻烦。

// Declaring methods on a prototype
Human.prototype.method1 = function () { /*...*/ }
Human.prototype.method2 = function () { /*...*/ }
Human.prototype.method3 = function () { /*...*/ }

您可以使用合并函数(如Object.assign)使事情变得更容易。

Object.assign(Human.prototype, {
  method1 () { /*...*/ },
  method2 () { /*...*/ },
  method3 () { /*...*/ }
})

Object.assign不支持合并 Getter 和 Setter 函数。 您需要另一个工具。 这是原因。 并且这里是我创建的工具,用于合并具有 Getter 和 Setter 的对象。

使用类声明属性和方法

您可以在constructor函数内部为每个实例声明属性。

class Human {
  constructor (firstName, lastName) {
    this.firstName = firstName
      this.lastname = lastName

      this.sayHello = function () {
        console.log(`Hello, I'm ${firstName}`)
      }
  }
}

在原型上声明方法更容易。 您像普通函数一样在constructor之后编写方法。

class Human (firstName, lastName) {
  constructor (firstName, lastName) { /* ... */ }

  sayHello () {
    console.log(`Hello, I'm ${this.firstName}`)
  }
}

与构造函数相比,在类上声明多个方法更容易。 您不需要Object.assign语法。 您只需编写更多函数即可。

注意:在类中的方法声明之间没有,

class Human (firstName, lastName) {
  constructor (firstName, lastName) { /* ... */ }

  method1 () { /*...*/ }
  method2 () { /*...*/ }
  method3 () { /*...*/ }
}

使用 OLOO 声明属性和方法

在实例上声明属性和方法使用相同的流程。将它们分配为 this 的属性。

const Human = {
  init (firstName, lastName) {
    this.firstName = firstName
    this.lastName = lastName
    this.sayHello = function () {
      console.log(`Hello, I'm ${firstName}`)
    }

    return this
  }
}

const chris = Object.create(Human).init('Chris', 'Coyier')
console.log(chris)

要在原型上声明方法,像普通对象一样编写方法。

const Human = {
  init () { /*...*/ },
  sayHello () {
    console.log(`Hello, I'm ${this.firstName}`)
  }
}

使用工厂函数声明属性和方法

可以通过将属性和方法包含在返回的对象中,直接声明它们。

function Human (firstName, lastName) {
  return {
    firstName,
    lastName, 
    sayHello () {
      console.log(`Hello, I'm ${firstName}`)
    }
  }
}

使用工厂函数时,无法在原型上声明方法。如果你真的想要在原型上声明方法,你需要返回一个构造函数、类或 OLOO 实例。(不要这样做,因为没有意义。)

// Do not do this
function createHuman (...args) {
  return new Human(...args)
}

在哪里声明属性和方法

你应该直接在实例上声明属性和方法吗?还是应该尽可能使用 prototype

许多人以 JavaScript 是一种“原型语言”(这意味着它使用原型)而自豪。从这个说法,你可能会得出使用“原型”更好的假设。

真正的答案是:没有区别。

如果你在实例上声明属性和方法,每个实例将占用略多一些的内存。如果你在原型上声明方法,每个实例使用的内存会减少,但不会太多。对于当今的计算机处理能力来说,这种差异微不足道。相反,你应该关注代码编写是否容易——以及是否可以使用原型。

例如,如果你使用类或 OLOO,你最好使用原型,因为代码更容易编写。如果你使用工厂函数,你无法使用原型。你只能在实例上直接创建属性和方法。

我写了一篇关于 理解 JavaScript 原型 的单独文章,如果你有兴趣了解更多。

初步结论

我们可以从上面写的代码中得到一些结论。这些观点是我的个人观点!

  1. 类比构造函数更好,因为在类上编写多个方法更容易。
  2. OLOO 因为 Object.create 部分而很奇怪。我尝试过 OLOO 一段时间,但我总是忘记写 Object.create。它对我来说太奇怪了,我不会使用它。
  3. 类和工厂函数最容易使用。问题是工厂函数不支持原型。但正如我所说,在生产中这并不重要。

我们只剩下两个选项。那么我们应该选择类还是工厂函数呢?让我们比较一下!


类 vs. 工厂函数——继承

要继续讨论类和工厂函数,我们需要理解三个与面向对象编程密切相关的概念。

  1. 继承
  2. 封装
  3. this

让我们从继承开始。

什么是继承?

继承是一个很重的词。在我看来,许多业内人士错误地使用继承。在接收事物时会使用“继承”这个词。例如

  • 如果你从父母那里继承了遗产,意味着你从他们那里获得了金钱和资产。
  • 如果你从父母那里遗传了基因,意味着你从他们那里获得了基因。
  • 如果你从老师那里继承了一个流程,意味着你从他们那里获得了那个流程。

相当直截了当。

在 JavaScript 中,继承可以指代相同的事物:从父级蓝图中获取属性和方法。

这意味着 所有 实例实际上都继承了它们的蓝图。它们通过两种方式继承属性和方法

  1. 在创建实例时直接创建属性或方法
  2. 通过原型链

我们讨论了如何在 上一篇文章 中进行这两种方法,如果你需要帮助查看代码中的这些过程,请参考它。

在 JavaScript 中,继承还有一个 第二层 含义——从父级蓝图中创建一个衍生蓝图。这个过程更准确地称为 子类化,但人们有时也会将其称为继承。

理解子类化

子类化是关于从一个公共蓝图中创建一个衍生蓝图。可以使用任何面向对象编程风格来创建子类。

我们先用类语法来讨论这个,因为它更容易理解。

用类进行子类化

创建子类时,使用 extends 关键字。

class Child extends Parent {
  // ... Stuff goes here
}

例如,假设我们要从 Human 类创建一个 Developer 类。

// Human Class
class Human {
  constructor (firstName, lastName) {
    this.firstName = firstName
    this.lastName = lastName
  }

  sayHello () {
    console.log(`Hello, I'm ${this.firstName}`)
  }
}

Developer 类将扩展 Human,如下所示

class Developer extends Human {
  constructor(firstName, lastName) {
    super(firstName, lastName)
  }

    // Add other methods
}

注意:super 调用 Human(也称为“父类”)类。它初始化 Humanconstructor。如果你不需要额外的初始化代码,可以完全省略 constructor

class Developer extends Human {
  // Add other methods
}

假设 Developer 可以编写代码。我们可以将 code 方法直接添加到 Developer 中。

class Developer extends Human {
  code (thing) {
    console.log(`${this.firstName} coded ${thing}`)
  }
}

下面是 Developer 实例的示例

const chris = new Developer('Chris', 'Coyier')
console.log(chris)
Instance of a Developer class.

用工厂函数进行子类化

使用工厂函数创建子类有四个步骤

  1. 创建一个新的工厂函数
  2. 创建一个父级蓝图的实例
  3. 创建一个新的副本
  4. 将属性和方法添加到这个新的副本中

这个过程看起来像这样

function Subclass (...args) {
  const instance = ParentClass(...args)
  return Object.assign({}, instance, {
    // Properties and methods go here
  })
}

我们将使用相同的示例——创建一个 Developer 子类——来说明这个过程。下面是 Human 工厂函数

function Human (firstName, lastName) {
  return {
    firstName,
    lastName,
    sayHello () {
      console.log(`Hello, I'm ${firstName}`)
    }
  }
}

我们可以像这样创建 Developer

function Developer (firstName, lastName) {
  const human = Human(firstName, lastName)
  return Object.assign({}, human, {
    // Properties and methods go here
  })
}

然后我们像这样添加 code 方法

function Developer (firstName, lastName) {
  const human = Human(firstName, lastName)
  return Object.assign({}, human, {
    code (thing) {
      console.log(`${this.firstName} coded ${thing}`)
    }
  })
}

下面是 Developer 实例的示例

const chris = Developer('Chris', 'Coyier')
console.log(chris)
Example of a Developer instance with Factory functions.

注意:如果你使用 Getter 和 Setter,则不能使用 Object.assign。你需要其他工具,比如 mix。我在 这篇文章 中解释了原因。

覆盖父类的方法

有时你需要在子类中覆盖父类的方法。你可以通过以下方式来实现

  1. 创建一个同名方法
  2. 调用父类的方法(可选)
  3. 在子类方法中进行必要的修改

使用类,这个过程看起来像这样

class Developer extends Human {
  sayHello () {
    // Calls the parent method
    super.sayHello() 

    // Additional stuff to run
    console.log(`I'm a developer.`)
  }
}

const chris = new Developer('Chris', 'Coyier')
chris.sayHello()
Overwriting a parent's method.

使用工厂函数,这个过程看起来像这样

function Developer (firstName, lastName) {
  const human = Human(firstName, lastName)

  return Object.assign({}, human, {
      sayHello () {
        // Calls the parent method
        human.sayHello() 

        // Additional stuff to run
        console.log(`I'm a developer.`)
      }
  })
}

const chris = new Developer('Chris', 'Coyier')
chris.sayHello()
Overwriting a parent's method.

继承 vs. 组合

没有关于继承的讨论会不提到组合。像 Eric Elliot 这样的专家经常建议我们应该 优先考虑组合而不是继承

“优先考虑对象组合而不是类继承”——四人帮,“设计模式:可复用面向对象软件的元素”

“在计算机科学中,复合数据类型或复合数据类型是任何可以在程序中使用编程语言的原始数据类型和其他复合类型构造的数据类型。[…] 构建复合类型的行为被称为组合。”~ 维基百科

所以让我们深入了解组合,并理解它是什么。

理解组合

组合是将两个事物合并为一个的过程。它是关于将事物融合在一起。合并对象最常见(也是最简单)的方式是使用 Object.assign

const one = { one: 'one' }
const two = { two: 'two' }
const combined = Object.assign({}, one, two)

组合的用法可以通过一个例子更好地解释。假设我们已经有两个子类,一个 DesignerDeveloper。设计师可以设计,而开发人员可以编写代码。设计师和开发人员都继承自 Human 类。

到目前为止的代码如下

class Human {
  constructor(firstName, lastName) {
    this.firstName = firstName
    this.lastName = lastName
  }

  sayHello () {
    console.log(`Hello, I'm ${this.firstName}`)
  }
}

class Designer extends Human {
  design (thing) {
    console.log(`${this.firstName} designed ${thing}`)
  }
}

class Developer extends Designer {
  code (thing) {
    console.log(`${this.firstName} coded ${thing}`)
  }
}

现在假设你想要创建一个第三个子类。这个子类是设计师和开发人员的混合——他们可以设计和编码。我们不妨称之为 DesignerDeveloper(或 DeveloperDesigner,你喜欢哪个都行)。

你将如何创建第三个子类?

我们不能同时扩展 DesignerDeveloper 类。这是不可能的,因为我们无法决定哪个属性优先。这通常被称为“钻石问题”。

Diamond problem.

如果我们使用类似 Object.assign 的东西——我们可以优先考虑一个对象而不是另一个——可以轻松地解决钻石问题。如果我们使用 Object.assign 方法,我们可能能够像这样扩展类。但这在 JavaScript 中不受支持。

// Doesn't work
class DesignerDeveloper extends Developer, Designer {
  // ...
}

因此,我们需要依赖组合。

组合说:与其尝试通过子类化创建 DesignerDeveloper,不如创建一个存储共同特征的新对象。然后,我们可以在需要时包含这些特征。

实际上,它可能看起来像这样

const skills = {
  code (thing) { /* ... */ },
  design (thing) { /* ... */ },
  sayHello () { /* ... */ }
}

然后,我们可以完全跳过 Human,并根据他们的技能创建三个不同的类。

以下是 DesignerDeveloper 的代码

class DesignerDeveloper {
  constructor (firstName, lastName) {
    this.firstName = firstName
    this.lastName = lastName

    Object.assign(this, {
      code: skills.code,
      design: skills.design,
      sayHello: skills.sayHello
    })
  }
}

const chris = new DesignerDeveloper('Chris', 'Coyier')
console.log(chris)
Composing methods into a class

你也可以对 DeveloperDesigner 做同样的事情。

class Designer {
  constructor (firstName, lastName) {
    this.firstName = firstName
    this.lastName = lastName 

    Object.assign(this, {
      design: skills.design,
      sayHello: skills.sayHello
    }) 
  }
}

class Developer {
  constructor (firstName, lastName) {
    this.firstName = firstName
    this.lastName = lastName 

    Object.assign(this, {
      code: skills.code,
      sayHello: skills.sayHello
    }) 
  }
}

你注意到我们直接在实例上创建方法了吗?这只是一个选择。我们仍然可以将方法放入原型中,但我认为代码看起来很笨拙。(就好像我们又在写构造函数一样。)

class DesignerDeveloper {
  constructor (firstName, lastName) {
    this.firstName = firstName
    this.lastName = lastName
  }
}

Object.assign(DesignerDeveloper.prototype, {
  code: skills.code,
  design: skills.design,
  sayHello: skills.sayHello
})
Composition via Classes by putting methods into the Prototype.

请随意使用你喜欢的代码结构。结果基本上是一样的。

使用工厂函数进行组合

使用工厂函数进行组合本质上是将共享方法添加到返回的对象中。

function DesignerDeveloper (firstName, lastName) {
  return {
    firstName,
    lastName,    
    code: skills.code,
    design: skills.design,
    sayHello: skills.sayHello
  }
}
Composing methods into a factory function

同时使用继承和组合

没有人说我们不能同时使用继承和组合。我们可以!

使用我们迄今为止的例子,DesignerDeveloperDesignerDeveloper Humans 仍然是人类。他们可以扩展 Human 对象。

以下是一个使用类语法同时使用继承和组合的示例。

class Human {
  constructor (firstName, lastName) {
    this.firstName = firstName
    this.lastName = lastName
  }

  sayHello () {
    console.log(`Hello, I'm ${this.firstName}`)
  }
}

class DesignerDeveloper extends Human {}
Object.assign(DesignerDeveloper.prototype, {
  code: skills.code,
  design: skills.design
})
Subclassing and Composition at the same time.

以下是使用工厂函数的相同内容

function Human (firstName, lastName) {
  return {
    firstName,
    lastName,
    sayHello () { 
      console.log(`Hello, I'm ${this.firstName}`)
    }
  }
}

function DesignerDeveloper (firstName, lastName) {
  const human = Human(firstName, lastName)
  return Object.assign({}, human, {
    code: skills.code,
    design: skills.design
  }
}
Subclassing and Composition in Factory functions

现实世界中的子类化

关于子类化与组合的最后一点。尽管专家指出组合更灵活(因此更有用),但子类化仍然有其优点。我们今天使用的许多东西都是使用子类化策略构建的。

例如:我们所熟知和喜爱的 click 事件是一个 MouseEventMouseEventUIEvent 的子类,而 UIEvent 又是 Event 的子类。

MouseEvent is a subclass of UIEvent.

另一个例子:HTML 元素是节点的子类。这就是为什么它们可以使用节点的所有属性和方法的原因。

HTMLElement is a subclass of Node.

初步结论

类和工厂函数都可以使用继承和组合。组合在工厂函数中似乎更简洁,但这并不是相对于类而言的巨大优势。

接下来,我们将更详细地检查类和工厂函数。


类与工厂函数——封装

到目前为止,我们已经了解了四种不同的面向对象编程风格。其中两种——类和工厂函数——比其他两种更易于使用。

但问题仍然存在:你应该使用哪一个?为什么?

要继续讨论类和工厂函数,我们需要了解与面向对象编程密切相关的三个概念

  1. 继承
  2. 封装
  3. this

我们刚刚谈到了继承。现在让我们谈谈封装。

封装

封装是一个词,但它有一个简单的含义。封装是指将一个东西封闭在另一个东西中,这样里面的东西就不会泄露出来。想象一下将水储存在瓶子里。瓶子防止水泄漏。

在 JavaScript 中,我们感兴趣的是将变量(包括函数)封闭起来,这样这些变量就不会泄漏到外部作用域。这意味着你需要了解作用域才能理解封装。我们将进行解释,但你也可以使用 这篇文章 来加强你关于作用域的知识。

简单封装

封装最简单的形式是块级作用域。

{
  // Variables declared here won't leak out
}

当你处于块级作用域中时,你可以访问在块级作用域外声明的变量。

const food = 'Hamburger'

{
  console.log(food)
}
Logs food from inside the blog. Result: Hamburger.

但当你处于块级作用域外时,你无法访问在块级作用域内声明的变量。

{
  const food = 'Hamburger'
}

console.log(food)
Logs food from outside the blog. Results: Error.

注意:使用 var 声明的变量不尊重块级作用域。这就是为什么 我建议你使用 letconst 声明变量。

使用函数进行封装

函数的行为类似于块级作用域。当你在函数中声明一个变量时,它们无法泄漏到该函数之外。这对所有变量都有效,即使是使用 var 声明的变量。

function sayFood () {
  const food = 'Hamburger'
}

sayFood()
console.log(food)
Logs food from outside the function. Results: Error.

同样,当你在函数中时,你可以访问在该函数之外声明的变量。

const food = 'Hamburger'

function sayFood () {
  console.log(food)
}


sayFood()
Logs food from inside the function. Result: Hamburger.

函数可以返回值。这个返回值可以在以后、在函数外部使用。

function sayFood () {
  return 'Hamburger'
}

console.log(sayFood())
Logs return value from function. Result: Hamburger.

闭包

闭包是封装的一种高级形式。它们只是嵌套的函数。

// Here's a closure
function outsideFunction () {
  function insideFunction () { /* ...*/ }
}

outsideFunction 中声明的变量可以在 insideFunction 中使用。

function outsideFunction () {
  const food = 'Hamburger'
  console.log('Called outside')

  return function insideFunction () {
    console.log('Called inside')
    console.log(food)
  }
}

// Calls `outsideFunction`, which returns `insideFunction`
// Stores `insideFunction` as variable `fn`
const fn = outsideFunction() 

// Calls `insideFunction`
fn()
Closure logs.

封装和面向对象编程

当你构建对象时,你希望将某些属性公开(以便人们可以使用它们)。但你也不希望将某些属性保持私有(以便其他人无法破坏你的实现)。

让我们通过一个例子来澄清这一点。假设我们有一个 Car 蓝图。当我们生产新车时,我们会给每辆车装满 50 升的燃料。

class Car {
  constructor () {
    this.fuel = 50
  }
}

在这里,我们公开了 fuel 属性。用户可以使用 fuel 来获取他们汽车中剩余的燃料量。

const car = new Car()
console.log(car.fuel) // 50

用户还可以使用 fuel 属性来设置任何数量的燃料。

const car = new Car()
car.fuel = 3000
console.log(car.fuel) // 3000

让我们添加一个条件,说每辆车的最大容量为 100 升。有了这个条件,我们不希望用户随意设置 fuel 属性,因为他们可能会损坏汽车。

有两种方法可以阻止用户设置 fuel

  1. 约定私有
  2. 真正的私有成员

惯例私有

在 JavaScript 中,有一种做法是在变量名前加下划线。这表示该变量是私有的,不应使用。

class Car {
  constructor () {
    // Denotes that `_fuel` is private. Don't use it!
    this._fuel = 50
  }
}

我们经常创建方法来获取和设置这个“私有”的_fuel变量。

class Car {
  constructor () { 
    // Denotes that `_fuel` is private. Don't use it!
    this._fuel = 50
  }

  getFuel () {
    return this._fuel
  }

  setFuel (value) {
    this._fuel = value
    // Caps fuel at 100 liters
    if (value > 100) this._fuel = 100
  }
}

用户应该使用getFuelsetFuel方法来获取和设置燃料。

const car = new Car() 
console.log(car.getFuel()) // 50 

car.setFuel(3000)
console.log(car.getFuel()) // 100 

但是_fuel实际上并不是私有的。它仍然是一个公共变量。您仍然可以访问它,您仍然可以使用它,您仍然可以滥用它(即使滥用部分是意外的)。

const car = new Car() 
console.log(car.getFuel()) // 50 

car._fuel = 3000
console.log(car.getFuel()) // 3000

如果我们想完全阻止用户访问它们,我们需要使用真正的私有变量。

真正的私有成员

这里的成员指的是变量、函数和方法。它是一个集合术语。

使用类创建私有成员

类允许您通过在变量前加上#来创建私有成员。

class Car {
  constructor () {
    this.#fuel = 50
  }
}

不幸的是,您不能直接在constructor函数中使用#

Error when declaring <code>#</code> directly in constructor function.

您需要先在构造函数之外声明私有变量。

class Car {
  // Declares private variable
  #fuel 
  constructor () {
    // Use private variable
    this.#fuel = 50
  }
}

在这种情况下,我们可以使用简写并在前面声明#fuel,因为我们将燃料设置为50

class Car {
  #fuel = 50
}

您不能在Car之外访问#fuel。您将收到错误。

const car = new Car()
console.log(car.#fuel)
Cannot access #fuel.

您需要方法(如getFuelsetFuel)才能使用#fuel变量。

class Car {
  #fuel = 50

  getFuel () {
    return this.#fuel
  }

  setFuel (value) {
    this.#fuel = value
    if (value > 100) this.#fuel = 100
  }
}

const car = new Car()
console.log(car.getFuel()) // 50

car.setFuel(3000)
console.log(car.getFuel()) // 100

注意:我更喜欢使用 Getter 和 Setter 而不是getFuelsetFuel语法更容易阅读。

class Car {
  #fuel = 50

  get fuel () {
    return this.#fuel
  }

  set fuel (value) {
    this.#fuel = value
    if (value > 100) this.#fuel = 100
  }
}

const car = new Car()
console.log(car.fuel) // 50

car.fuel = 3000
console.log(car.fuel) // 100

使用工厂函数创建私有成员

工厂函数会自动创建私有成员。您只需像平常一样声明一个变量。用户将无法在任何其他地方获取该变量。这是因为变量是函数作用域的,因此默认情况下是封装的。

function Car () {
  const fuel = 50 
}

const car = new Car() 
console.log(car.fuel) // undefined 
console.log(fuel) // Error: `fuel` is not defined

我们可以创建 getter 和 setter 函数来使用这个私有的fuel变量。

function Car () {
  const fuel = 50 

  return {
    get fuel () { 
      return fuel 
    },

    set fuel (value) {
      fuel = value 
      if (value > 100) fuel = 100
    }
  }
}

const car = new Car()
console.log(car.fuel) // 50

car.fuel = 3000
console.log(car.fuel) // 100

就这样!简单而容易!

封装的结论

使用工厂函数进行封装更简单,更容易理解。它们依赖于作用域,作用域是 JavaScript 语言的重要组成部分。

另一方面,使用类进行封装需要在私有变量前加上#。这可能会让事情变得笨拙。

我们将在下一节中介绍最后一个概念——this,以完成类和工厂函数之间的比较。


类与工厂函数——this变量

this(哈哈!)是反对使用类进行面向对象编程的主要论据之一。为什么?因为this的值会根据其使用方式而改变。对于许多开发人员(无论是新手还是有经验的)来说,这可能会令人困惑。

但实际上,this的概念相对简单。您只能在六种上下文中使用this。如果您掌握了这六种上下文,您将不会在使用this时遇到任何问题。

这六种上下文是

  1. 在全局上下文中
  2. 在对象构造中
  3. 在对象属性/方法中
  4. 在简单函数中
  5. 在箭头函数中
  6. 在事件监听器中

我已经详细介绍了这六种上下文。如果您需要帮助理解this,请阅读它。

注意:不要回避学习使用this。如果您打算精通 JavaScript,这是一个需要理解的重要概念。

在您巩固了对this的理解之后,请回到这篇文章。我们将更深入地讨论在类和工厂函数中使用this

回来了吗?很好。让我们开始吧!

在类中使用this

在类中使用时,this指的是实例。(它使用“在对象属性/方法中”的上下文。)这就是为什么您可以在constructor函数中在实例上设置属性和方法。

class Human {
  constructor (firstName, lastName) {
    this.firstName = firstName
    this.lastName = lastName
    console.log(this)
  }
}

const chris = new Human('Chris', 'Coyier')
<code>this</code> points to the instance

在构造函数中使用this

如果您在函数内部使用this,并且使用new来创建实例,那么this将引用该实例。这就是构造函数的创建方式。

function Human (firstName, lastName) {
  this.firstName = firstName 
  this.lastName = lastName
  console.log(this)  
}

const chris = new Human('Chris', 'Coyier')
<code>this</code> points to the instance.

我提到了构造函数,因为您可以在工厂函数中使用this。但是this指向 Window(或者如果您使用 ES6 模块,或像 webpack 这样的捆绑器,则指向undefined)。

// NOT a Constructor function because we did not create instances with the `new` keyword
function Human (firstName, lastName) {
  this.firstName = firstName 
  this.lastName = lastName
  console.log(this)  
}

const chris = Human('Chris', 'Coyier')
<code>this</code> points to Window.

从本质上讲,当您创建一个工厂函数时,您不应该像使用构造函数一样使用this。这是人们在使用this时遇到的一个小障碍。我想突出显示这个问题,并使其变得清晰。

在工厂函数中使用this

在工厂函数中使用this的正确方法是在“对象属性/方法中”的上下文中使用它。

function Human (firstName, lastName) {
  return {
    firstName,
    lastName,
    sayThis () {
      console.log(this)
    }
  }
}

const chris = Human('Chris', 'Coyier')
chris.sayThis()
<code>this</code> points to the instance.

即使您可以在工厂函数中使用this,您也不需要使用它们。您可以创建一个指向实例的变量。完成此操作后,您可以使用该变量而不是this。以下是一个正在使用的示例。

function Human (firstName, lastName) {
  const human = {
    firstName,
    lastName,
    sayHello() {
      console.log(`Hi, I'm ${human.firstName}`)
    }
  }

  return human
}

const chris = Human('Chris', 'Coyier')
chris.sayHello()

human.firstNamethis.firstName更清晰,因为human肯定指向该实例。您在看到代码时就知道。

如果您习惯使用 JavaScript,您可能还会注意到,根本不需要编写human.firstName!只需firstName就足够了,因为firstName在词法作用域中。(如果您需要帮助理解作用域,请阅读这篇文章)。

function Human (firstName, lastName) {
  const human = {
    firstName,
    lastName,
    sayHello() {
      console.log(`Hi, I'm ${firstName}`)
    }
  }

  return human
}

const chris = Human('Chris', 'Coyier')
chris.sayHello()
Runs <code>chris.sayHello</code>

到目前为止,我们所介绍的内容都很简单。在创建足够复杂的示例之前,很难决定是否真的需要this。所以让我们来做一下。

详细示例

这是设置。假设我们有一个Human蓝图。这个HumanfirstNamelastName属性,以及一个sayHello方法。

我们有一个Developer蓝图,它派生自Human。开发人员可以编写代码,因此他们将有一个code方法。开发人员还想宣称他们是开发人员,因此我们需要覆盖sayHello并将I'm a Developer添加到控制台。

我们将使用类和工厂函数创建此示例。(我们将使用this和不使用this为工厂函数创建一个示例)。

使用类的示例

首先,我们有一个Human蓝图。这个HumanfirstNamelastName属性,以及一个sayHello方法。

class Human {
  constructor (firstName, lastName) {
    this.firstName = firstName
    this.lastname = lastName 
  }

  sayHello () {
    console.log(`Hello, I'm ${this.firstName}`)
  }
}

我们有一个Developer蓝图,它派生自Human。开发人员可以编写代码,因此他们将有一个code方法。

class Developer extends Human {
  code (thing) {
    console.log(`${this.firstName} coded ${thing}`)
  }
}

开发人员还想宣称他们是开发人员。我们需要覆盖sayHello并将I'm a Developer添加到控制台。我们通过调用HumansayHello方法来做到这一点。我们可以使用super来做到这一点。

class Developer extends Human {
  code (thing) {
    console.log(`${this.firstName} coded ${thing}`)
  }

  sayHello () {
    super.sayHello()
    console.log(`I'm a developer`)
  }
}

使用工厂函数(带 this)的示例

同样,首先,我们有一个 Human 蓝图。这个 HumanfirstNamelastName 属性,以及一个 sayHello 方法。

function Human () {
  return {
    firstName,
    lastName,
    sayHello () {
      console.log(`Hello, I'm ${this.firstName}`)
    }
  }
}

接下来,我们有一个 Developer 蓝图,它派生自 Human。开发人员可以编写代码,因此他们将有一个 code 方法。

function Developer (firstName, lastName) {
  const human = Human(firstName, lastName)
  return Object.assign({}, human, {
    code (thing) {
      console.log(`${this.firstName} coded ${thing}`)
    }
  })
}

开发人员还想宣称自己是开发人员。我们需要覆盖 sayHello 并将 I'm a Developer 添加到控制台。
我们可以通过调用 HumansayHello 方法来做到这一点。我们可以使用 human 实例来做到这一点。

function Developer (firstName, lastName) {
  const human = Human(firstName, lastName)
  return Object.assign({}, human, {
    code (thing) {
      console.log(`${this.firstName} coded ${thing}`)
    },

    sayHello () {
      human.sayHello()
      console.log('I\'m a developer')
    }
  })
}

使用工厂函数(不带 this)的示例

这是使用工厂函数(带 this)的完整代码

function Human (firstName, lastName) {
  return {
    firstName,
    lastName,
    sayHello () {
      console.log(`Hello, I'm ${this.firstName}`)
    }
  }
}

function Developer (firstName, lastName) {
  const human = Human(firstName, lastName)
  return Object.assign({}, human, {
    code (thing) {
      console.log(`${this.firstName} coded ${thing}`)
    },

    sayHello () {
      human.sayHello()
      console.log('I\'m a developer')
    }
  })
}

您是否注意到 firstNameHumanDeveloper 的词法范围内都可用?这意味着我们可以省略 this 并直接在两个蓝图中使用 firstName

function Human (firstName, lastName) {
  return {
    // ...
    sayHello () {
      console.log(`Hello, I'm ${firstName}`)
    }
  }
}

function Developer (firstName, lastName) {
  // ...
  return Object.assign({}, human, {
    code (thing) {
      console.log(`${firstName} coded ${thing}`)
    },

    sayHello () { /* ... */ }
  })
}

看到了吗?这意味着当您使用工厂函数时,可以安全地从代码中省略 this

关于 this的结论

简单来说,类需要 this,而工厂函数不需要。我在这里更喜欢工厂函数,因为

  1. this 的上下文可能会改变(这可能会让人困惑)
  2. 使用工厂函数编写的代码更短更简洁(因为我们可以使用封装变量,而无需编写 this.#variable)。

接下来是最后一部分,我们将使用类和工厂函数一起构建一个简单的组件。您可以了解它们之间的区别以及如何在每种风味中使用事件监听器。

类与工厂函数——事件监听器

大多数面向对象编程文章都向您展示了没有事件监听器的示例。这些示例可能更容易理解,但它们不反映我们作为前端开发人员所做的事情。我们所做的事情需要事件监听器——原因很简单——因为我们需要构建依赖于用户输入的东西。

由于事件监听器会改变 this 的上下文,因此它们可能会使类难以处理。同时,它们使工厂函数更具吸引力。

但事实并非如此。

如果您知道如何在类和工厂函数中处理 this,那么 this 的变化并不重要。很少有文章介绍这个主题,所以我认为用面向对象编程风格完成一个简单的组件来完成这篇文章会很好。

构建计数器

我们将在本文中构建一个简单的计数器。我们将使用您在本文中学到的所有内容——包括私有变量。

假设计数器包含两件事

  1. 计数本身
  2. 一个用于增加计数的按钮

这是计数器最简单的 HTML 代码

<div class="counter">
  <p>Count: <span>0</span>
  <button>Increase Count</button>
</div>

使用类构建计数器

为了简单起见,我们将要求用户找到计数器的 HTML 并将其传递给 Counter 类。

class Counter () {
  constructor (counter) {
    // Do stuff 
  } 
}

// Usage 
const counter = new Counter(document.querySelector('.counter'))

我们需要在 Counter 类中获取两个元素

  1. 包含计数的 <span>——当计数增加时,我们需要更新此元素
  2. <button>——我们需要在此元素类中添加一个事件监听器
Counter () {
  constructor (counter) {
    this.countElement = counter.querySelector('span')
    this.buttonElement = counter.querySelector('button')
  }
}

我们将初始化一个 count 变量并将其设置为 countElement 所显示的内容。我们将使用一个私有 #count 变量,因为计数不应该在其他地方公开。

class Counter () {
  #count
  constructor (counter) {
    // ...

    this.#count = parseInt(countElement.textContent)
  } 
}

当用户点击 <button> 时,我们希望增加 #count。我们可以用另一个方法来做到这一点。我们将此方法命名为 increaseCount

class Counter () {
  #count
  constructor (counter) { /* ... */ }

  increaseCount () {
    this.#count = this.#count + 1
  }
}

接下来,我们需要使用新的 #count 更新 DOM。让我们创建一个名为 updateCount 的方法来执行此操作。我们将从 increaseCount 调用 updateCount

class Counter () {
  #count
  constructor (counter) { /* ... */ }

  increaseCount () {
    this.#count = this.#count + 1
    this.updateCount()
  }

  updateCount () {
    this.countElement.textContent = this.#count
  }
}

我们现在可以添加事件监听器了。

添加事件监听器

我们将事件监听器添加到 this.buttonElement 中。不幸的是,我们不能直接使用 increaseCount 作为回调。如果您尝试这样做,您将收到错误。

class Counter () {
  // ...

  constructor (counter) {
    // ...
    this.buttonElement.addEventListener('click', this.increaseCount)
  }

  // Methods
}
Error accessing #count because this doesn't point to the instance

您会收到错误是因为 this 指向 buttonElement。(这是事件监听器上下文。)如果您将 this 记录到控制台,您将看到 buttonElement

this points to the button element

我们需要将 this 的值更改回 increaseCount 的实例才能使其正常工作。有两种方法可以做到这一点

  1. 使用 bind
  2. 使用箭头函数

大多数人使用第一种方法(但第二种方法更容易)。

使用 bind 添加事件监听器

bind 返回一个新函数。它允许您将 this 更改为传递的第一个参数。人们通常通过调用 bind(this) 来创建事件监听器。

class Counter () {
  // ...

  constructor (counter) {
    // ...
    this.buttonElement.addEventListener('click', this.increaseCount.bind(this))
  }

  // ...
}

这有效,但阅读起来并不愉快。它也不适合初学者,因为 bind 被认为是高级的 JavaScript 函数。

箭头函数

第二种方法是使用箭头函数。箭头函数有效是因为它将 this 的值保留到词法上下文中。

大多数人将方法写入箭头函数回调中,如下所示

class Counter () {
  // ...

  constructor (counter) {
    // ...
    this.buttonElement.addEventListener('click', _ => {
      this.increaseCount()
    })
  }

  // Methods
}

这有效,但这是绕了一圈。实际上有一个捷径。

您可以使用箭头函数创建 increaseCount。如果您这样做,increaseCountthis 值将立即绑定到实例的值。

所以这是您需要的代码

class Counter () {
  // ...

  constructor (counter) {
    // ...
    this.buttonElement.addEventListener('click', this.increaseCount)
  }

  increaseCount = () => {
    this.#count = this.#count + 1
    this.updateCounter()
  }

  // ...
}

代码

这是基于类的代码的完整版本(使用箭头函数)。

使用工厂函数创建计数器

我们在这里也会做同样的事情。我们将要求用户将计数器的 HTML 传递给 Counter 工厂。

function Counter (counter) {
  // ...
}

const counter = Counter(document.querySelector('.counter'))

我们需要从 counter 中获取两个元素——<span><button>。我们可以在这里使用普通变量(没有 this),因为它们已经是私有变量了。我们不会公开它们。

function Counter (counter) {
  const countElement = counter.querySelector('span')
  const buttonElement = counter.querySelector('button')
}

我们将初始化一个 count 变量,使其等于 HTML 中存在的那个值。

function Counter (counter) {
  const countElement = counter.querySelector('span')
  const buttonElement = counter.querySelector('button')

  let count = parseInt(countElement.textContext)
}

我们将使用 increaseCount 方法增加这个 count 变量。您可以选择在这里使用普通函数,但我喜欢创建一个方法来保持整洁。

function Counter (counter) {
  // ... 
  const counter = {
    increaseCount () {
      count = count + 1
    }
  }
}

最后,我们将使用 updateCount 方法更新计数。我们也会从 increaseCount 调用 updateCount

function Counter (counter) {
  // ... 
  const counter = {
    increaseCount () {
      count = count + 1
      counter.updateCount()
    }

    updateCount () {
      increaseCount()
    }
  }
}

注意我使用了 counter.updateCount 而不是 this.updateCount?我喜欢这样,因为与 this 相比,counter 更清晰。我也这样做是因为初学者也可能会在工厂函数中犯 this 的错误(我将在后面介绍)。

添加事件监听器

我们可以为buttonElement添加事件监听器。当我们这样做时,可以直接使用counter.increaseCount作为回调函数。

我们可以这样做,因为我们没有使用this,所以即使事件监听器改变了this的值也无关紧要。

function Counter (counterElement) {
  // Variables 

  // Methods
  const counter = { /* ... */ }

  // Event Listeners
  buttonElement.addEventListener('click', counter.increaseCount)
}

The this 陷阱

你可以在工厂函数中使用this。但你需要在方法的上下文中使用this

在下面的例子中,如果你调用counter.increaseCount,JavaScript也会调用counter.updateCount。这是因为this指向counter变量。

function Counter (counterElement) {
  // Variables 

  // Methods
  const counter = {
    increaseCount() {
      count = count + 1
      this.updateCount()
    }
  }

  // Event Listeners
  buttonElement.addEventListener('click', counter.increaseCount)
}

不幸的是,事件监听器不会工作,因为this的值被改变了。你需要和类一样的处理方式——使用bind或箭头函数——来让事件监听器再次工作。

这引出了第二个陷阱。

第二个 this 陷阱

如果你使用工厂函数语法,你不能用箭头函数创建方法。这是因为方法是在简单的function上下文中创建的.

function Counter (counterElement) {
  // ...
  const counter = {
    // Do not do this. 
    // Doesn't work because `this` is `Window`
    increaseCount: () => {
      count = count + 1
      this.updateCount()
    }
  }
  // ...
}

所以,我强烈建议如果你使用工厂函数,就完全跳过this。这样会更容易。

代码

事件监听器结论

事件监听器会改变this的值,因此我们必须非常小心地使用this值。如果你使用类,我建议用箭头函数创建事件监听器回调函数,这样你就不必使用bind

如果你使用工厂函数,我建议完全跳过this,因为它可能会让你感到困惑。这就是全部内容!


结论

我们讨论了面向对象编程的四种方式。它们是

  1. 构造函数
  2. OLOO
  3. 工厂函数

首先,我们得出结论,从代码的角度来看,类和工厂函数更容易使用。

其次,我们比较了如何在类和工厂函数中使用子类。在这里,我们发现用类创建子类更容易,但用工厂函数创建组合更容易。

第三,我们比较了类和工厂函数的封装方式。在这里,我们发现用工厂函数进行封装是自然的——就像JavaScript——而用类进行封装需要你在变量之前添加一个#

第四,我们比较了类和工厂函数中this的使用方式。我认为工厂函数在这里胜出,因为this可能很模棱两可。编写this.#privateVariable也会比使用privateVariable本身产生更长的代码。

最后,在这篇文章中,我们用类和工厂函数都构建了一个简单的计数器。你学习了如何为这两种面向对象编程风格添加事件监听器。在这里,两种风格都有效。你只需要小心是否使用this

这就是全部内容!

我希望这能让你对JavaScript中的面向对象编程有所了解。如果你喜欢这篇文章,你可能会喜欢我的JavaScript课程,学习JavaScript,在那里我以清晰简洁的方式解释了(几乎)所有你需要了解的JavaScript知识。

如果你对JavaScript或前端开发有任何问题,请随时联系我。我会看看我怎样才能帮助你!