面向对象的JavaScript编程初学者

94 阅读11分钟

大家好!在这篇文章中,我们将通过实际的JavaScript例子来回顾面向对象编程(OOP)的主要特点。

我们将讨论OOP的主要概念,为什么以及什么时候它可以发挥作用,我还会用JS代码给你提供大量的例子。

如果你对编程范式不熟悉,我建议你在深入研究这个范式之前,先看看我最近写的简单介绍

来吧!

面向对象的编程介绍

正如我在上一篇关于编程范式的文章中提到的,OOP的核心概念是将关注点和责任分离实体。

实体被编码为对象, ,每个实体都会将一组给定的信息**(属性**)和可以由实体执行的动作**(方法**)分组。

OOP在大规模的项目中非常有用,因为它有利于代码的模块化和组织。

通过实现实体的抽象化,我们能够以类似于我们的世界运作的方式来思考程序,不同的行动者执行某些行动并相互影响。

为了更好地理解我们如何实现OOP,我们将使用一个实际的例子,其中我们将编码一个小的视频游戏。我们将专注于角色的创建,看看OOP如何帮助我们完成这个任务。

如何创建对象--类

那么,任何视频游戏都需要角色,对吗?而所有的角色都有一定的特征(属性),如颜色、高度、名字等等,还有能力(方法),如跳跃、跑步、出拳等等。对象是用来存储这类信息的完美数据结构👌。

假设我们有3个不同的角色 "物种",我们想创建6个不同的角色,每个物种两个。

创建我们的字符的方法可以是直接使用对象字面意义手动创建对象这样做:

const alien1 = {
    name: "Ali",
    species: "alien",
    phrase: () => console.log("I'm Ali the alien!"),
    fly: () => console.log("Zzzzzziiiiiinnnnnggggg!!")
}
const alien2 = {
    name: "Lien",
    species: "alien",
    sayPhrase: () => console.log("Run for your lives!"),
    fly: () => console.log("Zzzzzziiiiiinnnnnggggg!!")
}
const bug1 = {
    name: "Buggy",
    species: "bug",
    sayPhrase: () => console.log("Your debugger doesn't work with me!"),
    hide: () => console.log("You can't catch me now!")
}
const bug2 = {
    name: "Erik",
    species: "bug",
    sayPhrase: () => console.log("I drink decaf!"),
    hide: () => console.log("You can't catch me now!")
}
const Robot1 = {
    name: "Tito",
    species: "robot",
    sayPhrase: () => console.log("I can cook, swim and dance!"),
    transform: () => console.log("Optimus prime!")
}
const Robot2 = {
    name: "Terminator",
    species: "robot",
    sayPhrase: () => console.log("Hasta la vista, baby!"),
    transform: () => console.log("Optimus prime!")
}

请看,所有的字符都有namespecies 属性,也有sayPhrase 方法。此外,每个物种都有一个只属于该物种的方法(例如,外星人有fly 方法)。

正如你所看到的,有些数据是所有字符共享的,有些数据是每个物种共享的,有些数据是每个单个字符独有的。

这种方法是有效的。看,我们完全可以像这样访问属性和方法:

console.log(alien1.name) // output: "Ali"
console.log(bug2.species) // output: "bug"
Robot1.sayPhrase() // output: "I can cook, swim and dance!"
Robot2.transform() // output: "Optimus prime!"

这样做的问题是,它的规模根本不大,而且容易出错。想象一下,我们的游戏可能有数百个角色。我们将需要为他们每个人手动设置属性和方法!为了解决这个问题,我们需要一个程序。

为了解决这个问题,我们需要一种程序化的方式来创建对象,并在一系列条件下设置不同的属性和方法。而这正是的好处所在。

类为创建具有预定义属性和方法的对象设定了一个蓝图。通过创建一个类,你以后可以从该类中实例化(创建)对象,这些对象将继承该类的所有属性和方法。

重构我们之前的代码,我们可以为我们的每个角色物种创建一个类,像这样:

class Alien { // Name of the class
    // The constructor method will take a number of parameters and assign those parameters as properties to the created object.
    constructor (name, phrase) {
        this.name = name
        this.phrase = phrase
        this.species = "alien"
    }
    // These will be the object's methods.
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
    sayPhrase = () => console.log(this.phrase)
}

class Bug {
    constructor (name, phrase) {
        this.name = name
        this.phrase = phrase
        this.species = "bug"
    }
    hide = () => console.log("You can't catch me now!")
    sayPhrase = () => console.log(this.phrase)
}

class Robot {
    constructor (name, phrase) {
        this.name = name
        this.phrase = phrase
        this.species = "robot"
    }
    transform = () => console.log("Optimus prime!")
    sayPhrase = () => console.log(this.phrase)
}

然后我们可以像这样从这些类中实例化我们的角色:

const alien1 = new Alien("Ali", "I'm Ali the alien!")
// We use the "new" keyword followed by the corresponding class name
// and pass it the corresponding parameters according to what was declared in the class constructor function

const alien2 = new Alien("Lien", "Run for your lives!")
const bug1 = new Bug("Buggy", "Your debugger doesn't work with me!")
const bug2 = new Bug("Erik", "I drink decaf!")
const Robot1 = new Robot("Tito", "I can cook, swim and dance!")
const Robot2 = new Robot("Terminator", "Hasta la vista, baby!")

然后,我们又可以像这样访问每个对象的属性和方法:

console.log(alien1.name) // output: "Ali"
console.log(bug2.species) // output: "bug"
Robot1.sayPhrase() // output: "I can cook, swim and dance!"
Robot2.transform() // output: "Optimus prime!"

这种方法和使用类的好处是,我们可以使用这些 "蓝图 "来创建新的对象,比我们 "手动 "创建更快、更安全。

另外,我们的代码组织得更好,因为我们可以清楚地识别每个对象的属性和方法的定义位置(在类中)。这使得未来的改变或调整更容易实现。

关于类,有一些事情需要记住。

按照这个定义,用更正式的术语来说:

"程序中的类是对自定义数据结构 "类型 "的定义,包括数据和对数据进行操作的行为。类定义了这种数据结构的工作方式,但类本身并不是具体的值。为了得到一个你可以在程序中使用的具体数值,一个类必须被实例化(用 "new "关键字)一次或多次。"

  • 记住,类并不是实际的实体或对象。类是蓝图或模具,我们要用它来创建实际的对象。
  • 按照惯例,类的名字是用大写的第一个字母和camelCase声明的。类的关键字会创建一个常量,所以它不能在事后被重新定义。
  • 类必须始终有一个构造函数方法,以后将用于实例化该类。JavaScript中的构造函数只是一个普通的函数,用来返回一个对象。它唯一特别的地方是,当用 "new "关键字调用时,它将其原型指定为返回对象的原型。
  • "this "关键字指向类本身,用于在构造函数方法中定义类的属性。
  • 方法可以通过简单地定义函数名和它的执行代码来添加。
  • JavaScript是一种基于原型的语言,在JavaScript内部,类只作为语法糖使用。这在这里并没有很大的区别,但知道并记住这一点是很好的。如果你想了解更多关于这个话题,可以阅读这篇文章

OOP的四个原则

OOP通常用4个关键原则来解释,这些原则决定了OOP程序如何工作。它们是继承、封装、抽象和多态性。让我们来回顾一下每一个原则。

继承

继承是在其他类的基础上创建类的能力。通过继承,我们可以定义一个父类 (具有某些属性和方法),然后是子类,它们将继承父类的所有属性和方法。

让我们通过一个例子来看看。想象一下,我们之前定义的所有角色都是我们主角的敌人。作为敌人,他们都将拥有 "力量 "属性和 "攻击 "方法。

有一种方法可以实现这一点,就是在我们所有的类中添加同样的属性和方法,就像这样:

...

class Bug {
    constructor (name, phrase, power) {
        this.name = name
        this.phrase = phrase
        this.power = power
        this.species = "bug"
    }
    hide = () => console.log("You can't catch me now!")
    sayPhrase = () => console.log(this.phrase)
    attack = () => console.log(`I'm attacking with a power of ${this.power}!`)
}

class Robot {
    constructor (name, phrase, power) {
        this.name = name
        this.phrase = phrase
        this.power = power
        this.species = "robot"
    }
    transform = () => console.log("Optimus prime!")
    sayPhrase = () => console.log(this.phrase)
    attack = () => console.log(`I'm attacking with a power of ${this.power}!`)
}

const bug1 = new Bug("Buggy", "Your debugger doesn't work with me!", 10)
const Robot1 = new Robot("Tito", "I can cook, swim and dance!", 15)

console.log(bug1.power) //output: 10
Robot1.attack() // output: "I'm attacking with a power of 15!"

但是你可以看到我们在重复代码,这并不是最佳的方法。一个更好的方法是声明一个父类 "敌人",然后由所有敌人物种扩展,像这样:

class Enemy {
    constructor(power) {
        this.power = power
    }

    attack = () => console.log(`I'm attacking with a power of ${this.power}!`)
}


class Alien extends Enemy {
    constructor (name, phrase, power) {
        super(power)
        this.name = name
        this.phrase = phrase
        this.species = "alien"
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
    sayPhrase = () => console.log(this.phrase)
}

...

看,敌人类看起来就像其他的一样。我们使用构造函数方法来接收参数并将其分配为属性,方法的声明就像简单的函数。

在子类上,我们使用extends 关键字来声明我们要继承的父类。然后在构造方法上,我们必须声明 "power "参数,并使用super 函数来表示该属性是在父类上声明的。

当我们实例化新对象时,我们只需传递在相应的构造函数中声明的参数,就可以*了。*我们现在可以访问父类中声明的属性和方法:

const alien1 = new Alien("Ali", "I'm Ali the alien!", 10)
const alien2 = new Alien("Lien", "Run for your lives!", 15)

alien1.attack() // output: I'm attacking with a power of 10!
console.log(alien2.power) // output: 15

现在,让我们假设我们想添加一个新的父类,将我们所有的角色(无论他们是否是敌人)分组,并且我们想设置一个 "速度 "属性和一个 "移动 "方法。我们可以这样做:

class Character {
    constructor (speed) {
        this.speed = speed
    }

    move = () => console.log(`I'm moving at the speed of ${this.speed}!`)
}

class Enemy extends Character {
    constructor(power, speed) {
        super(speed)
        this.power = power
    }

    attack = () => console.log(`I'm attacking with a power of ${this.power}!`)
}


class Alien extends Enemy {
    constructor (name, phrase, power, speed) {
        super(power, speed)
        this.name = name
        this.phrase = phrase
        this.species = "alien"
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
    sayPhrase = () => console.log(this.phrase)
}

首先,我们声明一个新的 "角色 "父类。然后我们在 "敌人 "类上扩展它。最后我们将新的 "速度 "参数添加到我们的异形类中的constructorsuper 函数中。

我们在实例化时一如既往地传递参数,然后一次,我们可以从 "祖先 "类中访问属性和方法👴:

const alien1 = new Alien("Ali", "I'm Ali the alien!", 10, 50)
const alien2 = new Alien("Lien", "Run for your lives!", 15, 60)

alien1.move() // output: "I'm moving at the speed of 50!"
console.log(alien2.speed) // output: 60

现在我们对继承有了更多的了解,让我们重构我们的代码,以便尽可能地避免代码的重复:

class Character {
    constructor (speed) {
        this.speed = speed
    }
    move = () => console.log(`I'm moving at the speed of ${this.speed}!`)
}

class Enemy extends Character {
    constructor(name, phrase, power, speed) {
        super(speed)
        this.name = name
        this.phrase = phrase
        this.power = power
    }
    sayPhrase = () => console.log(this.phrase)
    attack = () => console.log(`I'm attacking with a power of ${this.power}!`)
}


class Alien extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "alien"
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
}

class Bug extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "bug"
    }
    hide = () => console.log("You can't catch me now!")
}

class Robot extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "robot"
    }
    transform = () => console.log("Optimus prime!")
}


const alien1 = new Alien("Ali", "I'm Ali the alien!", 10, 50)
const alien2 = new Alien("Lien", "Run for your lives!", 15, 60)
const bug1 = new Bug("Buggy", "Your debugger doesn't work with me!", 25, 100)
const bug2 = new Bug("Erik", "I drink decaf!", 5, 120)
const Robot1 = new Robot("Tito", "I can cook, swim and dance!", 125, 30)
const Robot2 = new Robot("Terminator", "Hasta la vista, baby!", 155, 40)

看,我们的物种类现在看起来小多了,这要归功于我们把所有共享的属性和方法移到了一个共同的父类中。这就是继承可以帮助我们提高的效率😉。

关于继承,有些事情要记住

  • 一个类只能有一个父类来继承。你不能扩展多个类,尽管有一些小技巧和方法可以解决这个问题。
  • 你可以随心所欲地扩展继承链,设置父类、祖类、曾祖类等等。
  • 如果一个子类继承了父类的任何属性,它必须先分配父类的属性,调用super() 函数,然后再分配自己的属性。

一个例子:

// This works:
class Alien extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "alien"
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
}

// This throws an error:
class Alien extends Enemy {
    constructor (name, phrase, power, speed) {
        this.species = "alien" // ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor
        super(name, phrase, power, speed)
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
}
  • 继承时,所有父类的方法和属性都将被子类继承。我们不能决定从父类那里继承什么(就像我们不能选择从父母那里继承什么美德和缺陷一样。😅 我们会在讨论组成时再讨论这个问题)。
  • 子类可以重写父类的属性和方法。

举个例子,在我们之前的代码中,Alien类扩展了Enemy类,它继承了记录attack 方法,I'm attacking with a power of ${this.power}!

class Enemy extends Character {
    constructor(name, phrase, power, speed) {
        super(speed)
        this.name = name
        this.phrase = phrase
        this.power = power
    }
    sayPhrase = () => console.log(this.phrase)
    attack = () => console.log(`I'm attacking with a power of ${this.power}!`)
}


class Alien extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "alien"
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
}

const alien1 = new Alien("Ali", "I'm Ali the alien!", 10, 50)
alien1.attack() // output: I'm attacking with a power of 10!

假设我们想让attack 方法在我们的Alien类中做一件不同的事情。我们可以通过再次声明来覆盖它,像这样:

class Enemy extends Character {
    constructor(name, phrase, power, speed) {
        super(speed)
        this.name = name
        this.phrase = phrase
        this.power = power
    }
    sayPhrase = () => console.log(this.phrase)
    attack = () => console.log(`I'm attacking with a power of ${this.power}!`)
}


class Alien extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "alien"
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
    attack = () => console.log("Now I'm doing a different thing, HA!") // Override the parent method.
}

const alien1 = new Alien("Ali", "I'm Ali the alien!", 10, 50)
alien1.attack() // output: "Now I'm doing a different thing, HA!"

封装

封装是OOP的另一个关键概念,它代表了一个对象 "决定 "向 "外部 "暴露哪些信息和不暴露哪些信息的能力。封装是通过公有和私有属性和方法实现的。

在JavaScript中,所有对象的属性和方法默认都是公开的。"公有 "只是意味着我们可以从一个对象的主体之外访问它的属性/方法:

// Here's our class
class Alien extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "alien"
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
}

// Here's our object
const alien1 = new Alien("Ali", "I'm Ali the alien!", 10, 50)

// Here we're accessing our public properties and methods
console.log(alien1.name) // output: Ali
alien1.sayPhrase() // output: "I'm Ali the alien!"

为了更清楚地说明这一点,让我们看看私有属性和方法是什么样子的。

假设我们希望我们的Alien类有一个birthYear 属性,并使用该属性来执行一个howOld 方法,但我们不希望该属性可以从对象本身以外的其他地方被访问。我们可以像这样实现:

class Alien extends Enemy {
    #birthYear // We first need to declare the private property, always using the '#' symbol as the start of its name.

    constructor (name, phrase, power, speed, birthYear) {
        super(name, phrase, power, speed)
        this.species = "alien"
        this.#birthYear = birthYear // Then we assign its value within the constructor function
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
    howOld = () => console.log(`I was born in ${this.#birthYear}`) // and use it in the corresponding method.
}
    
// We instantiate the same way we always do
const alien1 = new Alien("Ali", "I'm Ali the alien!", 10, 50, 10000)

然后我们可以访问howOld 方法,像这样:

alien1.howOld() // output: "I was born in 10000"

但是如果我们试图直接访问该属性,我们会得到一个错误。而且,如果我们记录这个对象,这个私有属性也不会显示出来:

console.log(alien1.#birthYear) // This throws an error
console.log(alien1) 
// output:
// Alien {
//     move: [Function: move],
//     speed: 50,
//     sayPhrase: [Function: sayPhrase],
//     attack: [Function: attack],
//     name: 'Ali',
//     phrase: "I'm Ali the alien!",
//     power: 10,
//     fly: [Function: fly],
//     howOld: [Function: howOld],
//     species: 'alien'
//   }

当我们需要某些属性或方法用于对象的内部工作时,封装是很有用的,但我们不想将其暴露在外部。拥有私有属性/方法可以确保我们不会 "意外地 "暴露出我们不想要的信息。

抽象性

抽象是一个原则,即一个类应该只代表与问题背景相关的信息。通俗地说,只向外界公开你要使用的属性和方法。如果不需要,就不要暴露出来。

这一原则与封装密切相关,因为我们可以使用公共和私有属性/方法来决定什么被暴露,什么不被暴露。

多态性

然后是多态性(听起来很复杂,不是吗? OOP的名字是最酷的...🙃)。 多态性的意思是 "多种形式",实际上是一个简单的概念。它是指一个方法能够根据某些条件返回不同的值。

例如,我们看到,Enemy类有sayPhrase 方法。而我们所有的物种类都继承自Enemy类,这意味着它们也都有sayPhrase 方法。

但是我们可以看到,当我们在不同的物种上调用这个方法时,我们得到的结果是不同的:

const alien2 = new Alien("Lien", "Run for your lives!", 15, 60)
const bug1 = new Bug("Buggy", "Your debugger doesn't work with me!", 25, 100)

alien2.sayPhrase() // output: "Run for your lives!"
bug1.sayPhrase() // output: "Your debugger doesn't work with me!"

这是因为我们在实例化时给每个类传递了一个不同的参数。这就是多态性的一种,基于参数的多态性。👌

另一种多态性是基于继承的,指的是当我们有一个父类设置了一个方法,而子类重写了这个方法,以某种方式修改它。我们之前看到的那个例子在这里也完全适用:

class Enemy extends Character {
    constructor(name, phrase, power, speed) {
        super(speed)
        this.name = name
        this.phrase = phrase
        this.power = power
    }
    sayPhrase = () => console.log(this.phrase)
    attack = () => console.log(`I'm attacking with a power of ${this.power}!`)
}


class Alien extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "alien"
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
    attack = () => console.log("Now I'm doing a different thing, HA!") // Override the parent method.
}

const alien1 = new Alien("Ali", "I'm Ali the alien!", 10, 50)
alien1.attack() // output: "Now I'm doing a different thing, HA!"

这个实现是多态的,因为如果我们注释掉Alien类中的attack 方法,我们仍然能够在对象上调用它:

alien1.attack() // output: "I'm attacking with a power of 10!"

我们得到了同一个方法,它可以做一件事或另一件事,取决于它是否被重载。多态性。👌👌

对象组合

对象组合是一种可以替代继承的技术。

当我们谈到继承时,我们提到子类总是继承父类的所有方法和属性。那么,通过使用组合,我们可以以一种比继承更灵活的方式将属性和方法分配给对象,所以对象只得到它们需要的东西,而不是其他。

我们可以很简单地实现这一点,通过使用接收对象作为参数的函数,为其分配所需的属性/方法。让我们在一个例子中看到它。

假设现在我们想给我们的虫子角色添加飞行能力。正如我们在代码中看到的,只有外星人有fly 方法。因此,一种选择是在Bug 类中复制完全相同的方法:

class Alien extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "alien"
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
}

class Bug extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "bug"
    }
    hide = () => console.log("You can't catch me now!")
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!") // We're duplicating code =(
}

另一个选择是将fly 方法移到Enemy 类中,这样它就可以被AlienBug 类继承。但是这也使得那些不需要这个方法的类可以使用这个方法,比如Robot

class Enemy extends Character {
    constructor(name, phrase, power, speed) {
        super(speed)
        this.name = name
        this.phrase = phrase
        this.power = power
    }
    sayPhrase = () => console.log(this.phrase)
    attack = () => console.log(`I'm attacking with a power of ${this.power}!`)
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
}


class Alien extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "alien"
    }
}

class Bug extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "bug"
    }
    hide = () => console.log("You can't catch me now!")
}

class Robot extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "robot"
    }
    transform = () => console.log("Optimus prime!")
	// I don't need the fly method =(
}

正如你所看到的,当我们的类的起始计划发生变化时(在现实世界中几乎总是如此),继承会导致问题。对象组合提出了一种方法,对象只有在需要时才会被分配属性和方法。

在我们的例子中,我们可以创建一个函数,它的唯一职责是为任何接收到参数的对象添加飞行方法:

const bug1 = new Bug("Buggy", "Your debugger doesn't work with me!", 25, 100)

const addFlyingAbility = obj => {
    obj.fly = () => console.log(`Now ${obj.name} can fly!`)
}

addFlyingAbility(bug1)
bug1.fly() // output: "Now Buggy can fly!"

我们还可以为我们可能希望我们的怪物拥有的每一种力量或能力建立非常类似的函数。

你肯定可以看到,这种方法比拥有固定属性和方法的父类要灵活得多。每当一个对象需要一个方法时,我们只要调用相应的函数就可以了。

这里有一个很好的视频,比较了继承和组合

综述

OOP是一种非常强大的编程范式,它可以通过创建实体的抽象来帮助我们解决巨大的项目。每个实体都将负责某些信息和行动,实体之间也能进行交互,这与现实世界的工作方式很相似。

在这篇文章中,我们了解了类、继承、封装、抽象、多态性和组合。这些都是OOP世界中的关键概念。而且我们还看到了各种例子,说明OOP是如何在JavaScript中实现的。

像往常一样,我希望你喜欢这篇文章,并学到一些新东西。如果你愿意,你也可以在LinkedInTwitter上关注我。

祝贺你,下一篇见✌️