在我的研究中,我发现 JavaScript 中有四种面向对象编程的方法
我应该使用哪些方法? 哪一种是“最佳”方法? 在这里,我将介绍我的发现以及可能有助于您做出正确选择的信息。
为了做出决定,我们不仅要查看不同的风格,还要比较它们之间的概念方面
让我们从 JavaScript 中 OOP 的基础开始。
什么是面向对象编程?
面向对象编程是一种编写代码的方式,它允许您从一个公共对象创建不同的对象。 公共对象通常称为蓝图,而创建的对象称为实例。
每个实例都具有与其他实例不共享的属性。 例如,如果您有一个 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 () { /* ... */ }
}
在面向对象编程中,有两种方法可以声明属性和方法
- 直接在实例上
- 在原型中
让我们学习如何做这两件事。
使用构造函数声明属性和方法
如果您想直接在实例上声明一个属性,可以在构造函数内部编写该属性。 确保将其设置为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 原型 的单独文章,如果你有兴趣了解更多。
初步结论
我们可以从上面写的代码中得到一些结论。这些观点是我的个人观点!
- 类比构造函数更好,因为在类上编写多个方法更容易。
- OLOO 因为
Object.create
部分而很奇怪。我尝试过 OLOO 一段时间,但我总是忘记写Object.create
。它对我来说太奇怪了,我不会使用它。 - 类和工厂函数最容易使用。问题是工厂函数不支持原型。但正如我所说,在生产中这并不重要。
我们只剩下两个选项。那么我们应该选择类还是工厂函数呢?让我们比较一下!
类 vs. 工厂函数——继承
要继续讨论类和工厂函数,我们需要理解三个与面向对象编程密切相关的概念。
- 继承
- 封装
this
让我们从继承开始。
什么是继承?
继承是一个很重的词。在我看来,许多业内人士错误地使用继承。在接收事物时会使用“继承”这个词。例如
- 如果你从父母那里继承了遗产,意味着你从他们那里获得了金钱和资产。
- 如果你从父母那里遗传了基因,意味着你从他们那里获得了基因。
- 如果你从老师那里继承了一个流程,意味着你从他们那里获得了那个流程。
相当直截了当。
在 JavaScript 中,继承可以指代相同的事物:从父级蓝图中获取属性和方法。
这意味着 所有 实例实际上都继承了它们的蓝图。它们通过两种方式继承属性和方法
- 在创建实例时直接创建属性或方法
- 通过原型链
我们讨论了如何在 上一篇文章 中进行这两种方法,如果你需要帮助查看代码中的这些过程,请参考它。
在 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
(也称为“父类”)类。它初始化 Human
的 constructor
。如果你不需要额外的初始化代码,可以完全省略 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)

用工厂函数进行子类化
使用工厂函数创建子类有四个步骤
- 创建一个新的工厂函数
- 创建一个父级蓝图的实例
- 创建一个新的副本
- 将属性和方法添加到这个新的副本中
这个过程看起来像这样
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)

注意:如果你使用 Getter 和 Setter,则不能使用 Object.assign
。你需要其他工具,比如 mix
。我在 这篇文章 中解释了原因。
覆盖父类的方法
有时你需要在子类中覆盖父类的方法。你可以通过以下方式来实现
- 创建一个同名方法
- 调用父类的方法(可选)
- 在子类方法中进行必要的修改
使用类,这个过程看起来像这样
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()

使用工厂函数,这个过程看起来像这样
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()

继承 vs. 组合
没有关于继承的讨论会不提到组合。像 Eric Elliot 这样的专家经常建议我们应该 优先考虑组合而不是继承。
“优先考虑对象组合而不是类继承”——四人帮,“设计模式:可复用面向对象软件的元素”
“在计算机科学中,复合数据类型或复合数据类型是任何可以在程序中使用编程语言的原始数据类型和其他复合类型构造的数据类型。[…] 构建复合类型的行为被称为组合。”~ 维基百科
所以让我们深入了解组合,并理解它是什么。
理解组合
组合是将两个事物合并为一个的过程。它是关于将事物融合在一起。合并对象最常见(也是最简单)的方式是使用 Object.assign
。
const one = { one: 'one' }
const two = { two: 'two' }
const combined = Object.assign({}, one, two)
组合的用法可以通过一个例子更好地解释。假设我们已经有两个子类,一个 Designer
和 Developer
。设计师可以设计,而开发人员可以编写代码。设计师和开发人员都继承自 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
,你喜欢哪个都行)。
你将如何创建第三个子类?
我们不能同时扩展 Designer
和 Developer
类。这是不可能的,因为我们无法决定哪个属性优先。这通常被称为“钻石问题”。

如果我们使用类似 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)

你也可以对 Developer
和 Designer
做同样的事情。
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
})

请随意使用你喜欢的代码结构。结果基本上是一样的。
使用工厂函数进行组合
使用工厂函数进行组合本质上是将共享方法添加到返回的对象中。
function DesignerDeveloper (firstName, lastName) {
return {
firstName,
lastName,
code: skills.code,
design: skills.design,
sayHello: skills.sayHello
}
}

同时使用继承和组合
没有人说我们不能同时使用继承和组合。我们可以!
使用我们迄今为止的例子,Designer
、Developer
和 DesignerDeveloper
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
})

以下是使用工厂函数的相同内容
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
}
}

现实世界中的子类化
关于子类化与组合的最后一点。尽管专家指出组合更灵活(因此更有用),但子类化仍然有其优点。我们今天使用的许多东西都是使用子类化策略构建的。
例如:我们所熟知和喜爱的 click
事件是一个 MouseEvent
。MouseEvent
是 UIEvent
的子类,而 UIEvent
又是 Event
的子类。

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

初步结论
类和工厂函数都可以使用继承和组合。组合在工厂函数中似乎更简洁,但这并不是相对于类而言的巨大优势。
接下来,我们将更详细地检查类和工厂函数。
类与工厂函数——封装
到目前为止,我们已经了解了四种不同的面向对象编程风格。其中两种——类和工厂函数——比其他两种更易于使用。
但问题仍然存在:你应该使用哪一个?为什么?
要继续讨论类和工厂函数,我们需要了解与面向对象编程密切相关的三个概念
- 继承
- 封装
this
我们刚刚谈到了继承。现在让我们谈谈封装。
封装
封装是一个大词,但它有一个简单的含义。封装是指将一个东西封闭在另一个东西中,这样里面的东西就不会泄露出来。想象一下将水储存在瓶子里。瓶子防止水泄漏。
在 JavaScript 中,我们感兴趣的是将变量(包括函数)封闭起来,这样这些变量就不会泄漏到外部作用域。这意味着你需要了解作用域才能理解封装。我们将进行解释,但你也可以使用 这篇文章 来加强你关于作用域的知识。
简单封装
封装最简单的形式是块级作用域。
{
// Variables declared here won't leak out
}
当你处于块级作用域中时,你可以访问在块级作用域外声明的变量。
const food = 'Hamburger'
{
console.log(food)
}

但当你处于块级作用域外时,你无法访问在块级作用域内声明的变量。
{
const food = 'Hamburger'
}
console.log(food)

注意:使用 var
声明的变量不尊重块级作用域。这就是为什么 我建议你使用 let
或 const
声明变量。
使用函数进行封装
函数的行为类似于块级作用域。当你在函数中声明一个变量时,它们无法泄漏到该函数之外。这对所有变量都有效,即使是使用 var
声明的变量。
function sayFood () {
const food = 'Hamburger'
}
sayFood()
console.log(food)

同样,当你在函数中时,你可以访问在该函数之外声明的变量。
const food = 'Hamburger'
function sayFood () {
console.log(food)
}
sayFood()

函数可以返回值。这个返回值可以在以后、在函数外部使用。
function sayFood () {
return 'Hamburger'
}
console.log(sayFood())

闭包
闭包是封装的一种高级形式。它们只是嵌套的函数。
// 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()

封装和面向对象编程
当你构建对象时,你希望将某些属性公开(以便人们可以使用它们)。但你也不希望将某些属性保持私有(以便其他人无法破坏你的实现)。
让我们通过一个例子来澄清这一点。假设我们有一个 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
- 约定私有
- 真正的私有成员
惯例私有
在 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
}
}
用户应该使用getFuel
和setFuel
方法来获取和设置燃料。
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
函数中使用#
。

您需要先在构造函数之外声明私有变量。
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)

您需要方法(如getFuel
或setFuel
)才能使用#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 而不是getFuel
和setFuel
。 语法更容易阅读。
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
时遇到任何问题。
这六种上下文是
- 在全局上下文中
- 在对象构造中
- 在对象属性/方法中
- 在简单函数中
- 在箭头函数中
- 在事件监听器中
我已经详细介绍了这六种上下文。如果您需要帮助理解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')

this
在构造函数中使用如果您在函数内部使用this
,并且使用new
来创建实例,那么this
将引用该实例。这就是构造函数的创建方式。
function Human (firstName, lastName) {
this.firstName = firstName
this.lastName = lastName
console.log(this)
}
const chris = new Human('Chris', 'Coyier')

我提到了构造函数,因为您可以在工厂函数中使用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')

从本质上讲,当您创建一个工厂函数时,您不应该像使用构造函数一样使用this
。这是人们在使用this
时遇到的一个小障碍。我想突出显示这个问题,并使其变得清晰。
this
在工厂函数中使用在工厂函数中使用this
的正确方法是在“对象属性/方法中”的上下文中使用它。
function Human (firstName, lastName) {
return {
firstName,
lastName,
sayThis () {
console.log(this)
}
}
}
const chris = Human('Chris', 'Coyier')
chris.sayThis()

即使您可以在工厂函数中使用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.firstName
比this.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()

到目前为止,我们所介绍的内容都很简单。在创建足够复杂的示例之前,很难决定是否真的需要this
。所以让我们来做一下。
详细示例
这是设置。假设我们有一个Human
蓝图。这个Human
有firstName
和lastName
属性,以及一个sayHello
方法。
我们有一个Developer
蓝图,它派生自Human
。开发人员可以编写代码,因此他们将有一个code
方法。开发人员还想宣称他们是开发人员,因此我们需要覆盖sayHello
并将I'm a Developer
添加到控制台。
我们将使用类和工厂函数创建此示例。(我们将使用this
和不使用this
为工厂函数创建一个示例)。
使用类的示例
首先,我们有一个Human
蓝图。这个Human
有firstName
和lastName
属性,以及一个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
添加到控制台。我们通过调用Human
的sayHello
方法来做到这一点。我们可以使用super
来做到这一点。
class Developer extends Human {
code (thing) {
console.log(`${this.firstName} coded ${thing}`)
}
sayHello () {
super.sayHello()
console.log(`I'm a developer`)
}
}
this
)的示例
使用工厂函数(带 同样,首先,我们有一个 Human
蓝图。这个 Human
有 firstName
和 lastName
属性,以及一个 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
添加到控制台。
我们可以通过调用 Human
的 sayHello
方法来做到这一点。我们可以使用 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')
}
})
}
您是否注意到 firstName
在 Human
和 Developer
的词法范围内都可用?这意味着我们可以省略 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
,而工厂函数不需要。我在这里更喜欢工厂函数,因为
this
的上下文可能会改变(这可能会让人困惑)- 使用工厂函数编写的代码更短更简洁(因为我们可以使用封装变量,而无需编写
this.#variable
)。
接下来是最后一部分,我们将使用类和工厂函数一起构建一个简单的组件。您可以了解它们之间的区别以及如何在每种风味中使用事件监听器。
类与工厂函数——事件监听器
大多数面向对象编程文章都向您展示了没有事件监听器的示例。这些示例可能更容易理解,但它们不反映我们作为前端开发人员所做的事情。我们所做的事情需要事件监听器——原因很简单——因为我们需要构建依赖于用户输入的东西。
由于事件监听器会改变 this
的上下文,因此它们可能会使类难以处理。同时,它们使工厂函数更具吸引力。
但事实并非如此。
如果您知道如何在类和工厂函数中处理 this
,那么 this
的变化并不重要。很少有文章介绍这个主题,所以我认为用面向对象编程风格完成一个简单的组件来完成这篇文章会很好。
构建计数器
我们将在本文中构建一个简单的计数器。我们将使用您在本文中学到的所有内容——包括私有变量。
假设计数器包含两件事
- 计数本身
- 一个用于增加计数的按钮
这是计数器最简单的 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
类中获取两个元素
- 包含计数的
<span>
——当计数增加时,我们需要更新此元素 <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
}

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

我们需要将 this
的值更改回 increaseCount
的实例才能使其正常工作。有两种方法可以做到这一点
- 使用
bind
- 使用箭头函数
大多数人使用第一种方法(但第二种方法更容易)。
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
。如果您这样做,increaseCount
的 this
值将立即绑定到实例的值。
所以这是您需要的代码
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)
}
this
陷阱
The 你可以在工厂函数中使用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
,因为它可能会让你感到困惑。这就是全部内容!
结论
我们讨论了面向对象编程的四种方式。它们是
- 构造函数
- 类
- OLOO
- 工厂函数
首先,我们得出结论,从代码的角度来看,类和工厂函数更容易使用。
其次,我们比较了如何在类和工厂函数中使用子类。在这里,我们发现用类创建子类更容易,但用工厂函数创建组合更容易。
第三,我们比较了类和工厂函数的封装方式。在这里,我们发现用工厂函数进行封装是自然的——就像JavaScript——而用类进行封装需要你在变量之前添加一个#
。
第四,我们比较了类和工厂函数中this
的使用方式。我认为工厂函数在这里胜出,因为this
可能很模棱两可。编写this.#privateVariable
也会比使用privateVariable
本身产生更长的代码。
最后,在这篇文章中,我们用类和工厂函数都构建了一个简单的计数器。你学习了如何为这两种面向对象编程风格添加事件监听器。在这里,两种风格都有效。你只需要小心是否使用this
。
这就是全部内容!
我希望这能让你对JavaScript中的面向对象编程有所了解。如果你喜欢这篇文章,你可能会喜欢我的JavaScript课程,学习JavaScript,在那里我以清晰简洁的方式解释了(几乎)所有你需要了解的JavaScript知识。
如果你对JavaScript或前端开发有任何问题,请随时联系我。我会看看我怎样才能帮助你!
太棒了!
非常详细!
但是它没有提到邮票。只要滚动这个页面——stampit.js.org
这是一篇非常棒的文章。对不同方式实现相同结果的清醒解释。我喜欢把意见排除在外,只关注代码。
很棒的文章,内容构建得很好,信息量大,充满了奉献精神。
它解释了用不同的技术和方法实现相同结果的方式非常棒。
我通过阅读学到了很多东西。
这篇文章解释得非常透彻,组织得很好。我建议把它添加到CSS Tricks的“指南”部分。我相信,它不仅可以作为教程,还可以作为参考。
很棒的文章!我还没有读完,但好像在“理解组合”部分发现了一个印刷错误。开发者扩展人类(而不是设计师)是正确的。
:)
我并不是唯一一个!看到带有分支图的下节内容时,我在想有什么意义。
哇,这真的是一篇很好的参考文章。在实践中接触了很多实际例子之后,它可能看起来很明显,但对于那些想要获益并习惯于只有年龄才记得的东西的人来说,它更具价值。
太棒了!这简直是内容支柱!写得真好!
乍一看,类似乎比构造函数差。非常感谢您提供的信息。
我首先要承认这篇文章的质量,正如其他评论中提到的那样。除此之外,我不得不说我认为它带有主观意见。
从一个公共对象创建对象是一种使用面向对象编程设计程序的流行方式,但面向对象编程仅仅意味着创建对象并使它们相互交互,例如通过组合,这意味着一个对象获得对另一个对象的引用,这可以在创建该对象之后通过其方法之一进行。
我相信,“设计模式:可复用面向对象软件元素”中没有一个模式使用继承或从一个公共对象创建对象。它更倾向于使用组合(如上定义)而不是继承,或所有其衍生形式,如mixin。
关于
this
和引用Kyle Simpson,这篇文章也提到了他,我认为用以下定义更容易理解。class Human (firstName, lastName) { 会给我一个语法错误。
很棒!
精彩的文章!这是我读过关于类、构造函数、对象和继承最好的解释之一。这篇文章从头到尾都很清晰明了……
我认为作者在这篇文章中没有说哪种方法“优于”另一种,关于构造函数与类,这一点非常明智……他只是说他认为其中一种方式对他来说更有意义……这也是我的观点……我会选择适合你和你团队理解代码含义的方式……
我想知道类私有成员语法在原生浏览器中的支持情况……有人知道吗?这似乎是原生(不使用转译器)JavaScript的一个相当新的功能……
最后,我讨厌指出细微的错误……所以各位版主,请随意删除下面的几行……总之……代码有小错误(我认为)
在“使用工厂函数的私有成员”部分……你在设置私有成员时
我认为你的意思是
否则,当你尝试使用设置函数将燃料设置为新值时,你会收到“对常量变量的赋值”错误……
“这种差异在当今的计算机处理能力下微不足道。”
任何以这种方式编程的人根本不在乎他们的用户,并且在纵容无知。处理能力不是无限的资源,用户也不会将自己局限于一次只进行一个操作/程序。
不负责任不是一种可接受的编程风格。
JavaScript在过去几年中获得了一些不错的OOP技能。但只要JS是HTML页面的一部分,就存在一个普遍问题:HTML标识符通常是全局变量(这个词不好)。在大多数浏览器中,甚至不需要使用document.getElementById,因为ID会在浏览器加载时立即定义为全局变量。
我设计了一个很好的框架来解决这个问题 https://github.com/efpage/dml。这绕过了HTML,使纯JS的全页面设计成为可能。在这种情况下,JavaScript对象直接绑定到DOM元素,从而使OOP Web设计成为可能。但是对于标准网页来说,真正的OOP将是一个幻想。
致敬
埃克哈德
感谢你的杰作。
使用工厂函数,是否可以像这样在原型上声明方法
`function Human(name, lastName) {
return Object.assign(Object.create({name, lastName}), {})
}
let target = Human(‘john’, ‘wick’)`
更漂亮的代码
function Human(name, lastName) {
return Object.assign(Object.create({name, lastName}), {})
}
let target = Human(‘john’, ‘wick’)
在
function Car
(getter & setter 演示)中,如果你使用const fuel = 50
,那么setter无法改变值。你会收到TypeError: Assignment to constant
错误