Dark Dwarf Blog background

JavaScript 面向对象编程

JavaScript 面向对象编程

1. 面向对象编程范式

面向对象编程 (Object-Oriented Programming, OOP) 的核心目标是将复杂的问题分解为一个个独立的、可管理的单元(对象),从而提高代码的可重用性、可维护性和可扩展性。这种范式主要建立在四个核心原则之上:

  1. 封装 (Encapsulation)
  2. 继承 (Inheritance)
  3. 多态 (Polymorphism)
  4. 抽象 (Abstraction)

2. 封装

封装是指将数据(属性)和操作这些数据的方法捆绑在一个独立的单元(对象)中。更重要的是,它还意味着信息隐藏:对象应该隐藏其内部的复杂状态和实现细节,只对外暴露必要的、安全的接口(方法)来与外部世界交互。

把对象想象成一台“自动售货机”。你(外部代码)不需要知道机器内部的硬币如何存储、饮料如何制冷和传送。你只需要通过它提供的公共接口——投币口和按钮——来获取你想要的商品。机器的内部复杂性被完美地封装起来了。

在 ES6 之前,开发者通常使用闭包来模拟私有变量。但从 ES2022 开始,我们可以使用私有类字段(Private class fields),即在属性名前加上 # 号,来实现真正的封装。

class BankAccount {
  // #balance 是一个私有属性,外部无法直接访问
  #balance = 0;

  constructor(initialBalance) {
    if (initialBalance > 0) {
      this.#balance = initialBalance;
    }
  }

  // 公共方法,作为外部交互的接口
  deposit(amount) {
    if (amount > 0) {
      this.#balance += amount;
      console.log(`Deposited: $${amount}`);
    }
  }

  withdraw(amount) {
    if (amount > 0 && amount <= this.#balance) {
      this.#balance -= amount;
      console.log(`Withdrew: $${amount}`);
    } else {
      console.log("Insufficient funds or invalid amount.");
    }
  }

  // 公共的 getter 方法,用于安全地读取余额
  getBalance() {
    return this.#balance;
  }
}

const account = new BankAccount(100);

account.deposit(50); // "Deposited: $50"
console.log(account.getBalance()); // 150

// 尝试直接访问私有属性会导致语法错误
// console.log(account.#balance); // SyntaxError
// account.#balance = 9999; // SyntaxError

通过这种方式,我们保护了 balance 属性,防止了外部代码随意修改,只能通过我们提供的 depositwithdraw 方法来安全地操作,确保了数据的完整性。

3. 继承

继承允许我们创建一个新类(子类),它可以继承一个现有类(父类)的属性和方法。子类不仅可以使用父类的所有功能,还可以添加自己的新功能或重写父类的方法。

继承的主要目的是代码复用,它在类之间建立了一种“是一个”(is-a)的关系。

JavaScript 使用 extendssuper 关键字来实现类之间的继承。

// 父类 (Superclass)
class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    console.log(`${this.name} makes a noise.`);
  }
}

// 子类 (Subclass)
class Dog extends Animal {
  constructor(name, breed) {
    // 1. 调用父类的构造函数来设置 name
    super(name);
    this.breed = breed;
  }

  // 2. 重写 (Override) 父类的方法
  speak() {
    console.log(`${this.name} barks.`);
  }
}

const myDog = new Dog('Buddy', 'Golden Retriever');
myDog.speak(); // "Buddy barks." (调用的是 Dog 类重写后的方法)

// myDog 实例继承了 Animal 的 name 属性
console.log(myDog.name); // "Buddy"

4. 多态

多态同一个行为或接口,作用于不同的对象时,会产生不同的结果。它允许我们将不同类的对象视为同一个父类的实例来处理,而这些对象在实际执行时会展现出各自独特的行为。

想象一个 startEngine 的按钮。在汽车上按,会启动汽油发动机;在电动车上按,会接通电动机;在飞机上按,会启动涡轮发动机。按钮(接口)是同一个,但不同对象(汽车、飞机)的响应(实现)是不同的。

JavaScript 的动态类型特性使得多态的实现非常自然,这通常被称为“鸭子类型”(Duck Typing)——“如果它走起路来像鸭子,叫起来也像鸭子,那么它就是一只鸭子”。我们不关心对象的具体类型,只关心它是否具有我们需要的方法。

结合继承的例子,我们可以这样演示多态:

class Animal {
  constructor(name) { this.name = name; }
  speak() { console.log(`${this.name} makes a noise.`); }
}

class Dog extends Animal {
  speak() { console.log(`${this.name} barks.`); }
}

class Cat extends Animal {
  speak() { console.log(`${this.name} meows.`); }
}

// 这个函数不关心传入的是 Dog 还是 Cat,
// 它只知道 animal 对象有一个 speak 方法。
function makeAnimalSpeak(animal) {
  animal.speak();
}

const dog = new Dog('Buddy');
const cat = new Cat('Whiskers');

makeAnimalSpeak(dog); // "Buddy barks."
makeAnimalSpeak(cat); // "Whiskers meows."

makeAnimalSpeak 函数体现了多态。它接受任何“像”Animal 的对象(即有 speak 方法的对象),并调用该方法,而具体执行的是 Dogspeak 还是 Catspeak,则由传入的对象类型在运行时决定。

5. 抽象

抽象是指隐藏复杂的实现细节,只向外暴露最基本、最相关的功能。它帮助我们管理复杂性,让我们能够专注于对象“做什么”,而不是“怎么做”。

封装和抽象密切相关,但关注点不同:

  • 封装侧重于“隐藏”数据和实现,将它们捆绑在一起。
  • 抽象侧重于“简化”接口,为复杂的系统提供一个简单、高层次的模型。

驾驶汽车就是一个很好的抽象例子。我们只需要操作方向盘、油门和刹车(简化的接口),而无需关心发动机的点火时机、燃油喷射量或变速箱的齿轮比(复杂的实现细节)。

JavaScript 没有像 Java 或 C# 那样的 abstract class 或 interface 关键字。但是,我们可以通过编程约定来模拟这个概念。通常的做法是创建一个“抽象基类”,它本身不应被实例化,而是用来定义一个通用的模板,并强制子类去实现某些特定的方法。

// 我们可以认为 Shape 是一个“抽象类”
class Shape {
  constructor() {
    // new.target 指向被 new 关键字调用的构造函数。
    // 如果直接 new Shape(),new.target 就是 Shape 本身。
    if (new.target === Shape) {
      throw new Error("Cannot instantiate abstract class Shape directly.");
    }
  }

  // 这是一个“抽象方法”,它没有实现,并期望子类去重写。
  getArea() {
    throw new Error("Method 'getArea()' must be implemented.");
  }
}

class Rectangle extends Shape {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
  }

  // 子类实现了抽象方法
  getArea() {
    return this.width * this.height;
  }
}

class Circle extends Shape {
  constructor(radius) {
    super();
    this.radius = radius;
  }

  // 子类实现了抽象方法
  getArea() {
    return Math.PI * this.radius * this.radius;
  }
}

const rect = new Rectangle(10, 5);
console.log(rect.getArea()); // 50

// const shape = new Shape(); // Error: Cannot instantiate abstract class Shape directly.

在这个例子中,Shape 类就是一个抽象概念。它定义了一个契约:任何“形状”都必须能够计算其面积(拥有 getArea 方法)。通过在基类中抛出错误,我们强制子类必须提供自己的实现,从而达到了抽象的目的。