JavaScript 面向对象编程
1. 面向对象编程范式
面向对象编程 (Object-Oriented Programming, OOP) 的核心目标是将复杂的问题分解为一个个独立的、可管理的单元(对象),从而提高代码的可重用性、可维护性和可扩展性。这种范式主要建立在四个核心原则之上:
- 封装 (Encapsulation)
- 继承 (Inheritance)
- 多态 (Polymorphism)
- 抽象 (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
属性,防止了外部代码随意修改,只能通过我们提供的 deposit
和 withdraw
方法来安全地操作,确保了数据的完整性。
3. 继承
继承允许我们创建一个新类(子类),它可以继承一个现有类(父类)的属性和方法。子类不仅可以使用父类的所有功能,还可以添加自己的新功能或重写父类的方法。
继承的主要目的是代码复用,它在类之间建立了一种“是一个”(is-a)的关系。
JavaScript 使用 extends
和 super
关键字来实现类之间的继承。
// 父类 (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
方法的对象),并调用该方法,而具体执行的是 Dog
的 speak
还是 Cat
的 speak
,则由传入的对象类型在运行时决定。
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
方法)。通过在基类中抛出错误,我们强制子类必须提供自己的实现,从而达到了抽象的目的。