Skip to content

JavaScript 设计模式-面向对象

当前字数: 0 字 阅读时长: 0 分钟

概念

类(Class):对象的模板

类就像是一个模板,它定义了对象的属性和方法。通过类可以创建多个具有相同属性和方法的对象。

让我们用 Person 类作为例子来进一步解释面向对象编程中的“类”概念:

比如你正在开发一个管理系统,需要处理人员信息:

  • 这些人员都有名字、年龄(属性)。

  • 这些人员有一些共同的行为,比如 吃饭、讲话(方法)。

  • 这时定义一个 Person 类,它就像是一个“具有共同特征和行为的人员模具”。

js
class Person {
  // 构造函数,用于初始化对象的属性
  constructor(name, age) {
    this.name = name; // 名字属性
    this.age = age; // 年龄属性
  }

  // 吃的方法
  eat() {
    console.log(`${this.name} 正在吃饭。`);
  }

  // 讲话的方法
  speak() {
    console.log(`大家好,我叫${this.name},今年${this.age}岁。`);
  }
}

对象

对象(Object):类的实例

对象是类的实例(类是对象的模板-在这里具象了哦)。它是包含数据和功能的集合。可以通过类来实例化很多个对象。

现在继续,用 Person 类,创建不同的人员对象,并调用它们的方法来模拟这些行为:

js
// 创建一个叫“小明”的对象
let xiaoMing = new Person("小明", 25);

// 调用吃的方法
xiaoMing.eat();
// 输出: 小明 正在吃饭。

// 调用讲话的方法
xiaoMing.speak();
// 输出: 大家好,我叫小明,今年25岁。

// 创建另一个叫“小红”的对象
let xiaoHong = new Person("小红", 20);

// 调用吃的方法
xiaoHong.eat();
// 输出: 小红 正在吃饭。

// 调用讲话的方法
xiaoHong.speak();
// 输出: 大家好,我叫小红,今年20岁。

在上面例子中:

  • Person 类定义了两个方法 (eat、speak),分别用于模拟人吃饭、讲话的行为。
  • 当使用 new 关键字和 Person 类创建一个新对象时,这个对象就可以使用这些方法。
  • 然后,我们可以通过调用这些方法来实现特定的行为。

这种方式的好处是,我们只需要编写一份 Person 类的代码,就可以轻松地创建和管理大量的人员对象,并且每个对象都可以独立地执行这些行为。它允许我们定义通用的模板,并根据需要创建具有独特特征和行为的对象。

三要素

继承

继承(Inheritance),子类继承父类。

继承是面向对象编程中一种基本的代码复用机制。它允许一个类(称为子类或派生类)继承另一个类(称为父类或基类)的属性和方法。这样,子类就可以重用父类中已经定义好的代码,同时还可以添加或覆盖一些特定的功能。

父类:我们继续使用 Person 类作为 基类(父类):

  • 这里作为 “具有共同特征和行为的人员模具” 的 Person 类,包含了:
  • 属性:nameage
  • 方法:eatspeak
js
class Person {
  // 构造函数,用于初始化对象的属性
  constructor(name, age) {
    this.name = name; // 名字属性
    this.age = age; // 年龄属性
  }

  // 吃的方法
  eat() {
    console.log(`${this.name} 正在吃饭。`);
  }

  // 讲话的方法
  speak() {
    console.log(`大家好,我叫${this.name},今年${this.age}岁。`);
  }
}

子类继承父类: 创建 Student 类作为派生类(子类),继承自 Person

  • 这里除了继承 Person 父类的属性和方法, 我们要添加:
  • Student 子类 特有的属性,比如: 学号 studentId
  • Student 子类 特有的行为(方法),比如:学习 study
js
// 定义派生类(子类)Student,继承自Person
class Student extends Person {
  // 子类的构造函数需要调用 super() 来初始化从父类继承的属性
  constructor(name, age, studentId) {
    super(name, age); // 调用父类的构造函数
    this.studentId = studentId; // 子类特有的属性
  }

  // 子类可以添加新的方法
  study() {
    console.log(`${this.name} 正在学习。`);
  }
}

// 创建一个Student对象并测试
let zhangSan = new Student("张三", 14, "S12345");
zhangSan.eat(); // 输出: 张三 正在吃饭。
zhangSan.study(); // 输出: 张三 正在学习。

const liSi = new Student("李四", 16, "S54321");
liSi.eat(); // 输出: 李四 正在吃饭。
liSi.study(); // 输出: 李四 正在学习。

在这个例子中:

  • Student 类通过extends 关键字继承了 Person 类。

  • Person 是父类,是公共的,它不仅仅服务于 Student

  • Student 类的构造函数中,我们使用 super(name, age) 来调用父类的构造函数,以确保从父类继承的属性 (name、age) 被正确初始化。

  • 然后,我们添加了子类特有的属性 studentId。还添加了一个新的方法 study,这是父类中没有的。

通过使用继承,我们可以轻松地创建具有共享属性和方法的类,同时还可以在子类中添加特定的功能。在这里 People 不仅仅服务于 Student。继承可已将公共方法抽离出来,提高复用,减少冗余。

封装

封装(Encapsulation),数据的权限和保密。

封装是面向对象编程中一种保护数据的方式。它将类的属性和方法结合在一起,形成一个独立的单元,并对外部隐藏类的内部实现细节,只暴露必要的接口给外部使用。这样,类的内部状态只能通过类提供的方法进行访问和修改,从而保证了数据的安全性和完整性。

三个关键字 !!!:

封装呢,首先要提到这三个关键字:

  • public: 完全开放、private: 对自己开放、 protected: 对子类开放(自己也可以访问)

纯面向对象语言就是通过这个三个关键字来做封装。

但是 JS 中并没有这个三个关键字,体现不出封装的特性!

ES2019 引入的私有字段语法(#前缀),这是 JavaScript 中实现私有属性的官方方式。在之前的 JavaScript 版本中,通常使用约定(如下划线前缀_)或闭包来模拟私有属性。不是简单和固用的方式来做封装。

有关可以阅读 MDN-类-私有属性

下面使用 TypeScript 实现! 下面使用 TypeScript 实现! 下面使用 TypeScript 实现!

这里我们继续使用 Person 类作为 基类(父类),用 TS 来改造一下:

  • Person 类增加一个 protected 属性 personId
  • 它可以在 Person 类及其子类(如 Student)中被访问和修改。
ts
class Person {
  // 公共属性
  name: string;
  age: number;

  // 受保护的属性,只能在Person类及其子类中访问
  protected personId: string;

  // 构造函数,用于初始化对象的属性
  constructor(name: string, age: number, personId: string) {
    this.name = name; // 名字属性
    this.age = age; // 年龄属性
    this.personId = personId; // 受保护的属性,通常用于标识人
  }

  // 吃的方法
  eat(): void {
    console.log(`${this.name} 正在吃饭。`);
  }

  // 讲话的方法
  speak(): void {
    console.log(`大家好,我叫${this.name},今年${this.age}岁。`);
  }

  // 获取personId的方法
  // 虽然是受保护的,但为了演示,这里提供一个公共方法来访问它
  getPersonId(): string {
    return this.personId;
  }
}

定义派生类(子类)Student,继承自 Person:

  • Student 子类特有的属性,studentId 改为私有属性 private
  • studentId 只能在 Student 类内部被访问和修改。
ts
class Student extends Person {
  // 私有的属性,只能在Student类内部访问
  private studentId: string;

  // 子类的构造函数需要调用 super() 来初始化从父类继承的属性
  constructor(name: string, age: number, personId: string, studentId: string) {
    super(name, age, personId); // 调用父类的构造函数
    this.studentId = studentId; // 子类特有的私有属性
  }

  // 子类可以添加新的方法
  study(): void {
    console.log(`${this.name} 正在学习。`);
  }

  // 获取学生编号的方法
  // 虽然是私有的,这里提供一个受保护或公共方法来访问它,但在实际封装中应保持私有
  // 注意:通常我们不会从外部直接访问私有属性,这里仅为了演示目的
  getStudentIdForDemo(): string {
    // 在实际项目中,应该避免这种直接暴露私有属性的做法
    // 这里仅作为演示,展示如何定义和使用私有属性
    return this.studentId;
  }
}
ts
const wangWu = new Person("王五", 30, "P12345");
console.log(wangWu.getPersonId()); // 输出: P12345
console.log(wangWu.personId); // 错误:属性 “personId” 受保护,只能在类“Person”及其子类中访问。

const xiaoLi = new Student("小丽", 20, "S54321", "20230001");
console.log(xiaoLi.getPersonId()); // 输出: S54321
console.log(xiaoLi.studentId); // 错误:属性“studentId”为私有属性,只能在类“Student”中访问。
console.log(xiaoLi.getStudentIdForDemo()); // 输出: 20230001(仅为了演示,实际项目中应避免这样做)
xiaoLi.study(); // 输出: 小丽 正在学习。

在这个例子中:

  • Person 类有一个 protected 属性 personId,它在 Person 类及子类 Student 中被访问和修改。
  • Student 类有一个 private 属性 studentId,它只能在 Student 类内部被访问和修改。
  • 隐藏对象的内部状态和行为,仅通过公共方法暴露必要的接口。减少耦合,不该外漏的不外漏。

多态

多态(Polymorphism),同一接口不同表现。

多态是面向对象编程中一种实现接口重用的机制。它允许一个接口在不同的实例中以不同的方式实现。多态通常通过继承和方法重写来实现。在运行时,系统可以根据对象的实际类型来决定调用哪个方法,从而实现动态绑定。

TIP

多态需要结合 Java 等语言的接口、重写、重载等功能。这些 JS 中都没有。

面向接口编程中,有时不关心子类中是怎么实现的,只需要关心父类有多少接口,是什么样的接口。但这些在 JS 中体现并不明显。

JS 多态的体现并不像在一些静态类型语言(如 JavaC++)中那样显式或强制,但多态的概念和效果在 JS 中仍然是存在的,并且非常重要。

由于 JS 的灵活性,有时候开发者可能会不自觉地绕过多态性(例如,通过直接检查对象的类型或属性来决定行为)。

实现一个多态的列子:

  • 有一个基类 Animal,以及两个继承自 Animal 的子类 DogCat
  • 每个类都有一个 Speak 方法,但它们的实现是不同的。
js
// 定义基类 Animal
class Animal {
  constructor(name) {
    this.name = name;
  }

  // 提供一个默认实现,但通常基类中的方法会被子类重写
  speak() {}
}

// 定义 Dog 类,继承自 Animal
class Dog extends Animal {
  constructor(name) {
    super(name);
  }

  // 重写 speak 方法
  speak() {
    console.log(`${this.name} says: Woof!`);
  }
}

// 定义 Cat 类,继承自 Animal
class Cat extends Animal {
  constructor(name) {
    super(name);
  }

  // 重写 speak 方法
  speak() {
    console.log(`${this.name} says: Meow!`);
  }
}

创建实例并展示多态行为:

js
// 创建一个 Dog 实例和一个 Cat 实例
const dog = new Dog("Buddy");
const cat = new Cat("Whiskers");

// 调用 speak 方法,展示多态行为
function makeAnimalSpeak(animal) {
  // 这里不需要知道 animal 是 Dog 还是 Cat,它会自动调用正确的 speak 方法
  animal.speak();
}

makeAnimalSpeak(dog); // 输出: Buddy says: Woof!
makeAnimalSpeak(cat); // 输出: Whiskers says: Meow!

在这个例子中

  • 我们使用了 classextends 关键字来定义类和继承关系。每个子类都重写了 speak 方法,以提供自己的实现。

  • makeAnimalSpeak 函数接受一个 Animal 类型的参数(实际上是 Animal 的实例或其子类的实例),并调用其 speak 方法。

  • 由于 JavaScript 的动态类型特性,它会在运行时根据对象的实际类型调用正确的方法,这就是多态的体现。

  • 以后如果添加更多的动物类型,只需定义新的子类并重写 speak 方法即可,无需修改现有的 makeAnimalSpeak 函数或其他相关代码。

应用

使用 ES6class 语法来模拟 jQuery 的一个小片段:

  • 定义一个基础类,用于封装 DOM 操作:
js
class DOMManipulator {
  constructor(selector) {
    this.elements = document.querySelectorAll(selector);
  }

  // 私有方法,用于遍历元素并应用操作
  _applyToElements(callback) {
    this.elements.forEach((element) => callback(element));
  }

  // 公共方法,添加CSS类
  addClass(className) {
    this._applyToElements((element) => {
      if (element.classList) {
        element.classList.add(className);
      } else {
        element.className += " " + className;
      }
    });
    return this; // 支持链式调用
  }

  // ... 其他DOM操作方法(如css, removeClass等)可以类似地实现
}

请注意

虽然 JavaScript 没有正式的私有方法或属性(直到 ES2019 的私有类字段提案),但我们可以通过约定(如下划线前缀)和闭包来模拟私有成员。在上面的例子中,_applyToElements 方法被视为私有方法,因为它不应该被类的外部直接调用。

  • 定义一个继承自 DOMManipulator 的类,用于更具体的操作:
js
class DemoJQuery extends DOMManipulator {
  // 可以添加一些特定的方法或重写父类的方法
  // 例如,重写addClass方法以添加日志记录功能
  addClass(className) {
    console.log(
      `Adding class '${className}' to ${this.elements.length} elements.`
    );
    super.addClass(className); // 调用父类的方法
    return this; // 支持链式调用
  }

  // 添加一个特定于DemoJQuery的方法,比如绑定事件
  on(event, handler) {
    this._applyToElements((element) => {
      element.addEventListener(event, handler);
    });
    return this; // 支持链式调用
  }

  // ... 可以添加更多特定于DemoJQuery的方法
}
  • 最后挂载一个 $window:
js
window.$ = function (selector) {
  // 工厂模式
  return new DemoJQuery(selector);
};
  • 使用 demo 示例:
html
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>

  <p>继承继承继承继承继承继承继承继承继承</p>
  <p>封装封装封装封装封装封装封装封装封装</p>
  <p>多态多态多态多态多态多态多态多态多态</p>

</body>
<script src="./demo-jquery.js"></script>

<script>
  $('p').addClass('active') // 这将调用重写后的addClass方法,并打印日志
    .on('click', () => {
      alert('Element clicked!');
    })
</script>

</html>
js
// region DOMManipulator
class DOMManipulator {
  constructor(selector) {
    this.elements = document.querySelectorAll(selector);
  }

  // 私有方法,用于遍历元素并应用操作
  _applyToElements(callback) {
    this.elements.forEach((element) => callback(element));
  }

  // 公共方法,添加CSS类
  addClass(className) {
    this._applyToElements((element) => {
      if (element.classList) {
        element.classList.add(className);
      } else {
        element.className += " " + className;
      }
    });
    return this; // 支持链式调用
  }

  // ... 其他DOM操作方法(如css, removeClass等)可以类似地实现
}

// endregion DOMManipulator

// region DemoJQuery
class DemoJQuery extends DOMManipulator {
  // 可以添加一些特定的方法或重写父类的方法
  // 例如,重写addClass方法以添加日志记录功能
  addClass(className) {
    console.log(
      `Adding class '${className}' to ${this.elements.length} elements.`
    );
    super.addClass(className); // 调用父类的方法
    return this; // 支持链式调用
  }

  // 添加一个特定于DemoJQuery的方法,比如绑定事件
  on(event, handler) {
    this._applyToElements((element) => {
      element.addEventListener(event, handler);
    });
    return this; // 支持链式调用
  }

  // ... 可以添加更多特定于DemoJQuery的方法
}

// endregion DemoJQuery

// region window
window.$ = function (selector) {
  // 工厂模式
  return new DemoJQuery(selector);
};
// endregion window

在这个例子中:

封装:

  • DOMManipulator 类封装了 DOM 操作的基本逻辑,而 DemoJQuery 类则进一步封装了特定的功能。
  • _applyToElements 方法是一个私有方法(通过约定使用下划线前缀来表示),它封装了遍历元素并应用操作的逻辑。

继承:

  • DemoJQuery 类继承 DOMManipulator 类,从而能够重用其 DOM 操作的基本逻辑。
  • 同时,DemoJQuery 类还可以添加或重写方法以提供额外的功能。

多态:

  • 在这个例子中,多态性可能不那么明显,因为我们没有创建多个子类来展示不同的行为。
  • 然而,DemoJQuery 类本身可以视为一个多态的实体,它提供了与 DOMManipulator 类相似的接口(方法集),但具有额外的功能(如事件绑定和日志记录)。

体现面向对象思想的关键在于封装、继承和多态这三个基本原则。

总结

我们所有的教程文档都在说面向对象可以表达万物,比如上面我们演示的 PersonAnimalxiaoMingxiaoHong 这些,都是和我们日常生活对应的。这只是为了让我们好学习而已。面向对象的意义,根本不在于此。(所以不要纠结那些 吃、睡、猫叫、狗叫!!!)

我们日常使用的编程语言,所有程序的执行都是通过: 顺序、判断、循环 这三种方式来搞定。我们限定了一种规范,这三种可以满足所有的需求。对程序来说这就叫结构化了。

面向对象的意义是将零散的数据进行结构化。结构化的东西才是最简单的。编程也应该是简单和抽象的。

所以面向对象就是:数据结构化。

《松本行弘的程序世界》

Released under the MIT License.