第五章:面向对象编程 (OOP):从语法到思想的结构化之道

第五章:面向对象编程 (OOP):从语法到思想的结构化之道

摘要: 在第四章,我们深入探索了 JavaScript 继承的底层基石——原型链,并一步步推导出了 ES5 时代最优雅的“寄生组合式继承”模式。然而,那个过程是复杂且易错的。本章,我们将学习 ES6 带来的革命性变革:class 关键字。我们将揭示 class 如何作为一层优雅的“语法糖”,将我们之前复杂的原型操作,封装成一种对后端开发者(特别是 Java/Spring Boot 背景)极其友好的、结构化的编程范式。本章的核心并非学习一套全新的体系,而是理解这套新语法是如何让我们更高效、更安全地去运用我们已经掌握的原型继承原理。

在本章,我们将完成一次从底层原理到上层建筑的思维跨越:

  1. 首先,我们将探讨 OOP 思想为何在大型 JavaScript 项目中至关重要,直面“代码随地乱飞”的开发痛点。
  2. 接着,我们将逐一学习 class 的核心语法 (constructor, static, #private),并时刻与第四章的原型实现进行对比,理解其“语法糖”的本质。
  3. 然后,我们将聚焦于 extendssuper,看看它们是如何将复杂的“寄生组合式继承”和“构造函数窃取”简化为单个关键字的。
  4. 最后,我们将把 OOP 思想置于 现代前端的生态 中,探讨它在 Angular/NestJS 中的核心地位,以及如何将其分层思想与 React/Vue 的函数式范式进行完美融合。

5.1. OOP 思想在 JavaScript 中的价值

注意: 这一章节适合有面向对象基础的人观看,如果您看不懂,没关系的,在日后的开发中以及现在的前端生态,面向对象的思想在前端体现的会比较少,更多的是组合式,组件化思想

承上启下: 在我们深入 class 语法之前,必须先回答一个根本问题:在一个以函数为一等公民、原型继承如此灵活的语言里,我们为什么还需要看似“刻板”的面向对象编程?

痛点背景:对于习惯了 Java/Spring Boot 这类强结构化框架的开发者而言,初入前端,特别是现代 React/Vue 的函数式组件世界,往往会感到一种“无序感”。数据状态、业务逻辑、API 请求散落在不同的自定义 Hooks 或 Composable 函数中,缺乏一个清晰的、高内聚的组织单元。总会感受到的“代码随地乱飞”。

OOP 思想为解决这一问题提供了强大的武器库:

  • 结构化与封装: 这是 OOP 的核心价值。class 如同一张蓝图,它强制性地将数据(属性)和操作这些数据的行为(方法)紧密地捆绑在一个独立的、高内聚的单元里。当需要处理“用户”逻辑时,我们去找 User 类;需要处理“订单”逻辑,就去找 Order 类。这与在 Spring Boot 中寻找 UserServiceOrderController 的思路是完全一致的。

  • 继承: 允许创建子类来复用和扩展父类的功能,是构建可维护、可扩展代码库的基础。例如,一个 BaseApiController 可以封装通用的错误处理和请求逻辑,所有具体的业务 API 客户端只需继承它即可。

  • 抽象与清晰的契约: class 通过其公共方法向外暴露一个清晰的“契约”,隐藏内部复杂的实现细节。这极大地降低了系统模块间的耦合度,使得大型项目得以协同开发和长期维护。

JavaScript 的 class 语法,正是为了将这些宝贵的 OOP 思想,用一种更简洁、更标准、对开发者更友好的方式引入到语言中。它并没有改变 JavaScript 原型继承的本质,而是为其提供了一个更强大的上层建筑。


5.2. ES6 class:现代 OOP 的语法基石

现在,让我们来逐一解析 class 的语法,并看看它们分别对应着我们在第四章学到的哪些原型概念。

5.2.1. classconstructor:定义蓝图与实例化

class 提供了一个定义“类”的清晰结构。constructor 方法是类的默认构造函数,通过 new 命令创建对象实例时,会自动调用该方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class User {
// constructor 负责初始化实例属性
constructor(name, age) {
this.name = name;
this.age = age;
}

// 实例方法
greet() {
console.log(`Hello, I am ${this.name}.`);
}
}

const user1 = new User('Alice', 25);
user1.greet();

与原型的联系 (Aha Moment!):

  • class User {...} 的声明,本质上等同于我们第四章写的 function User(...) {} 构造函数。
  • constructor 方法就是那个构造函数本身。
  • greet() 这个实例方法,class 语法会自动地、正确地将其定义在 User.prototype,而不是在构造函数里为每个实例重复创建。这正是对第四章“构造函数性能缺陷”的完美优化。
1
2
3
4
5
6
7
8
9
10
11
12
// 上述 class 写法的 ES5 原型等价实现
function User(name, age) {
this.name = name;
this.age = age;
}
User.prototype.greet = function() {
console.log(`Hello, I am ${this.name}.`);
};

const user1 = new User('Bob', 30);
// 验证一下
console.log(user1.greet === (new User()).greet); // true, 方法是共享的

5.2.2. 实例成员与 static 成员

  • 实例成员: 定义在 constructor 内部(通过 this)或类体顶层的属性,以及普通的方法。它们属于每一个对象实例。
  • static 成员: 使用 static 关键字修饰的属性和方法。它们不属于任何实例,而是直接属于 类本身
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class User {
// 实例属性
name = 'Anonymous';

// 静态属性
static platform = 'Prorise Blog';

constructor(name) {
this.name = name;
}

// 静态方法
static getPlatform() {
return `This user is from ${this.platform}.`; // 静态方法中的 this 指向类本身
}
}

const user = new User('Cathy');
console.log(user.name); // 'Cathy'
// console.log(user.platform); // undefined,实例无法访问静态成员

console.log(User.platform); // 'Prorise Blog'
console.log(User.getPlatform()); // "This user is from Prorise Blog."

与原型的联系:

  • static 成员就是直接定义在 构造函数 这个对象上的属性和方法。它等同于:
    1
    2
    3
    function User() {}
    User.platform = 'Prorise Blog';
    User.getPlatform = function() { /*...*/ };

5.2.3. 私有成员 (#)

历史痛点: 在原型时代,JavaScript 没有真正的私有成员。开发者只能通过“下划线命名约定”(如 _privateVar)来“提示”这是一个私有成员,但它在外部依然可以被随意访问和修改,封装性形同虚设。

解决方案: ES2022 引入了 # 前缀,用于定义 真正意义上的私有属性和方法。它们只能在类的内部被访问,外部访问会直接抛出语法错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class User {
#email; // 必须在类体顶层先声明私有字段

constructor(name, email) {
this.name = name;
this.#email = email;
}

getEmail() {
return this.#getFormattedEmail(); // 内部可以访问
}

#getFormattedEmail() { // 私有方法
return `Email: ${this.#email}`;
}
}

const user = new User('David', 'david@example.com');
console.log(user.getEmail()); // "Email: david@example.com"

try {
console.log(user.#email); // 外部访问,将抛出错误
} catch (e) {
console.error(e.message);
}

5.3. 继承 (extends) 与 super 关键字

这是 class 语法糖最强大的地方,它将我们第四章推导出的复杂继承模式,简化为了两个关键字。

5.3.1. extends:建立类之间的层级关系

extends 关键字用于让一个类(子类)继承另一个类(父类)。这意味着子类将自动获得父类所有非私有的属性和方法。

基础代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 1. 定义一个父类
class Parent {
constructor(name) {
this.name = name;
}

greet() {
console.log(`Hello from the Parent, my name is ${this.name}.`);
}
}

// 2. 子类 Child 使用 extends 继承 Parent
class Child extends Parent {
// 即使子类是空的,它也继承了 Parent 的所有东西
}

// 3. 创建子类的实例
const childInstance = new Child('Prorise');

// 4. 调用继承自父类的方法
childInstance.greet();
console.log(childInstance.name);

与原型的联系 (The Ultimate Aha Moment!):

  • class Child extends Parent {} 这一行代码,背后为你做了两件大事,完美对应了“寄生组合式继承”模式:
    1. 它将 Child.prototype[[Prototype]] 设置为了 Parent.prototype (等同于 Object.create(Parent.prototype)),从而实现了原型方法的继承。
    2. 它还将 Child 构造函数自身的 [[Prototype]] 设置为了 Parent 构造函数,从而实现了静态成员的继承。

5.3.2. super():在子类构造函数中调用父类

核心规则: 如果子类有 constructor,则 必须 在其中调用 super(),并且必须在使用 this 关键字之前调用。

与原型的联系:

  • super() 在这里作为函数调用,等同于我们之前在子类构造函数中使用的 Parent.call(this, ...args)。它执行父类的构造函数,并将 this 绑定到当前子类的实例上,从而实现 实例属性的继承(即“构造函数窃取”)。

5.3.3. super.method():在子类中调用父类的方法

super 作为对象使用时,它指向父类的原型。这允许我们在子类中调用被覆盖的父类方法,以实现功能的扩展而非完全重写。

综合代码示例 (extends, super(), super.method()):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Vehicle {
constructor(name) {
this.name = name;
}
move() {
console.log(`${this.name} is moving.`);
}
}

class Car extends Vehicle {
constructor(name, brand) {
// 1. 使用 super() 调用父类构造函数,继承 name 属性
super(name); // 等同于 Vehicle.call(this, name)
this.brand = brand;
}

// 2. 子类重写 move 方法
move() {
// 3. 使用 super.move() 调用父类的同名方法
super.move(); // 等同于 Vehicle.prototype.move.call(this)
console.log(`It's a ${this.brand} car.`);
}
}

const myCar = new Car('Sedan', 'Toyota');
myCar.move();

5.4. instanceof 类型检查

5.4.1. instanceof

instanceof 运算符的工作原理没有改变。它依然是通过检查一个对象的 原型链 上是否存在某个构造函数的 prototype 对象来判断。class 语法只是让这个判断的对象来源更清晰。

1
2
3
4
const myCar = new Car('Sedan', 'Toyota');
console.log(myCar instanceof Car); // true
console.log(myCar instanceof Vehicle); // true,因为 Car.prototype 在 myCar 的原型链上
console.log(myCar instanceof Object); // true

5.5. OOP 在现代前端生态中的位置与实践

核心讨论: 既然 class 如此强大且结构清晰,为何在现代主流的 React/Vue 框架中,我们看到更多的是函数式组件?

  • 全面拥抱 OOP 的框架: Angular 是最彻底贯彻 OOP 和依赖注入思想的框架。其组件、服务、指令等一切皆为 class。对于有 Spring Boot 经验的开发者,Angular 的开发模式会感到非常亲切。在后端,NestJS 更是被誉为“Node.js 版的 Spring Boot”,它完全基于 TypeScript 和 OOP,其架构思想(模块、控制器、服务)与 Spring 高度相似,是 Java/C# 开发者进入 Node.js 世界的最佳桥梁。

  • 函数式范式的兴起: React Hooks 和 Vue 组合式 API 代表了另一种编程哲学。它们追求 极致的灵活性和逻辑复用,通过将逻辑拆分成可组合的函数单元,而不是封装在类中。

  • 优点: 逻辑复用粒度更细,代码更灵活。
    * 缺点: 如果没有良好的架构约束,容易导致逻辑分散、分层不清,即“代码随地乱飞”。

最佳实践:融合两种思想

我们不必陷入“非此即彼”的二元对立。最成熟的工程实践,是 结合两种范式的优点:使用 OOP 的 class 来构建清晰的、分层的业务逻辑(服务层),然后让函数式的 UI 组件(视图层)保持轻薄,只负责消费这些服务并渲染界面。

场景: 我们用 React Hooks 和一个业务逻辑 Class 来重构一个用户列表的加载功能。

1. 创建一个业务逻辑类 (服务层 - The “Service Layer”)
这个文件里没有任何 UI 框架的东西,是纯粹的、可独立测试的业务逻辑。
文件路径: services/UserService.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 这个类负责所有与用户数据相关的逻辑
class UserService {
async fetchUsers() {
try {
// 模拟 API 请求
const response = await new Promise(resolve => setTimeout(() => resolve([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
]), 1000));
return response;
} catch (error) {
console.error("Failed to fetch users", error);
return []; // 返回空数组作为降级处理
}
}
}

// 导出一个单例,整个应用共享这一个实例
export const userService = new UserService();

2. 在 UI 组件中使用这个 Service (视图层 - The “View Layer”)
这个 React 组件变得非常“薄”,只负责调用 Service 的方法,并根据返回的数据更新视图。
文件路径: components/UserList.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import React, { useState, useEffect } from 'react';
import { userService } from '../services/UserService'; // 导入我们的服务实例

function UserList() {
const [users, setUsers] = useState([]);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
// 调用 service 的方法获取数据
userService.fetchUsers().then(data => {
setUsers(data);
setIsLoading(false);
});
}, []); // 空依赖数组确保只在组件挂载时执行一次

if (isLoading) {
return <div>Loading...</div>;
}

return (
<ul>
{users.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}

这样做的好处:

  • 关注点分离 (SoC): UserService 只关心业务逻辑,UserList 只关心 UI 展示。
  • 代码定位清晰: 业务问题去 services 目录找,UI 问题去 components 目录找。
  • 可测试性: UserService 可以被独立进行单元测试,无需渲染任何 UI。
  • 结构化: 您从 Spring Boot 中欣赏的清晰分层,通过这种方式在前端项目中得到了实现。

5.6. 本章核心原理与高频面试题

核心原理速查

class 语法对应的原型原理 (ES5 等价实现)核心价值
constructor(..)function ClassName(..)定义实例属性
method()ClassName.prototype.method = function() {}性能优化:在原型上定义共享方法
static method()ClassName.method = function() {}定义类级别的方法
class Child extends Parent寄生组合式继承代码简化:用单个关键字实现最复杂的继承模式
super()Parent.call(this, ...)继承父类实例属性
super.method()Parent.prototype.method.call(this)调用父类原型上的方法

高频面试题与陷阱

面试官深度追问
2025-08-28

请解释一下 JavaScript 的 class 和传统面向对象语言(比如 Java)的 class 有什么本质区别?

最本质的区别在于底层实现。Java 的 class 是一个严格的、编译时的实体,其实例化和继承都基于类。而 JavaScript 的 class 本质上是原型继承的语法糖。它的底层机制仍然是我们第四章讨论的原型链。class 并没有为 JavaScript 引入新的对象继承模型,只是提供了一套更清晰、更结构化的语法来操作已有的原型模型。

很好。那么,在子类的 constructor 中,为什么必须在使用 this 之前调用 super()

因为在 ES6 的类继承模型中,子类的实例是通过父类的构造函数来创建的。super() 的作用就是执行父类的构造函数,并让其返回的实例作为子类的 this。如果在调用 super() 之前就尝试访问 this,那么这个 this 实际上还不存在,因此 JavaScript 规定这是一种引用错误,以保证继承模型的正确性。