Javascript 零基础教程

JavaScript 对象构造函数与原型

在之前的章节中,你已经学会了如何使用对象字面量语法(例如 { name: "Alice", age: 30 })来创建单个对象。当你只需要一个或少数几个独特的对象时,这种方法非常完美。

然而,想象一下你正在构建一个应用程序,需要管理数百甚至数千个类似的实体,比如员工列表、产品目录或游戏角色集合。如果手动为每一个实体创建一个对象,那将是极度重复、容易出错且难以维护的。

如果所有这些对象都需要相同的属性集(如 name, age)和相同的行为(如 introduce, work)该怎么办?这就是 对象构造函数 (Object Constructors)原型 (Prototypes) 在 JavaScript 中变得至关重要的原因。它们允许我们为对象创建“蓝图”,并高效地共享功能。

1. 理解对象构造函数

对象构造函数 是一个特殊的函数,它充当创建具有相似属性和方法的多个对象的蓝图

你可以把它想象成一个饼干模具:你使用一个模具来制作许多形状相同的饼干。在 JavaScript 中,我们使用 new 关键字配合构造函数来基于该蓝图创建新的实例(对象)。

1.1 创建构造函数

构造函数本质上是一个普通的 JavaScript 函数,但有一个约定俗成的规则:它的首字母通常大写,以此来区分于普通函数。

在构造函数内部,this 关键字指的是新创建的对象(即我们蓝图的实例)。我们将属性和方法挂载到 this 上,以定义对象的特征和行为。

让我们看一个例子:

// 一个名为 'Person' 的简单构造函数
function Person(name, age) {
  this.name = name; // 'this.name' 指的是新 Person 对象的 'name' 属性
  this.age = age;   // 'this.age' 指的是新 Person 对象的 'age' 属性
  
  // 直接在构造函数内部定义的方法。
  // 我们稍后会看到一种处理方法的更高效方式。
  this.greet = function() {
    console.log(`你好,我的名字是 ${this.name},我今年 ${this.age} 岁。`);
  };
}

在这个 Person 构造函数中:

  1. 我们将其定义为 function Person(...)
  2. 它接收 nameage 作为参数,这些参数将用于初始化新对象的属性。
  3. this.name = name; 将传入的 name 值赋值给正在创建的对象的 name 属性。
  4. this.age = age;age 属性做同样的操作。
  5. this.greet = function() { ... }; 定义了一个 greet 方法,该方法对于此构造函数创建的每个对象都是独有的。

1.2 使用 new 创建对象

要从构造函数创建一个对象(一个实例),我们使用 new 关键字,后面跟上对构造函数的调用。

// 使用构造函数创建两个新的 Person 对象
const person1 = new Person("Alice", 30);
const person2 = new Person("Bob", 25);

// 访问属性
console.log(person1.name); // 输出: Alice
console.log(person2.age);  // 输出: 25

// 调用方法
person1.greet(); // 输出: 你好,我的名字是 Alice,我今年 30 岁。
person2.greet(); // 输出: 你好,我的名字是 Bob,我今年 25 岁。

当你使用 new 关键字时,幕后发生了以下几件事:

  1. 一个全新的、空的 JavaScript 对象被创建了。
  2. 构造函数内部的 this 关键字被自动绑定到这个新对象上。这意味着构造函数内的任何 this.property 或 this.method 都会被添加到这个新创建的空对象中。
  3. 构造函数的代码被执行,使用 this 为新对象填充属性和方法。
  4. 这个新对象被隐式返回(除非构造函数显式返回了一个不同的对象,但这很少见且通常不推荐)。

这使我们要能高效地创建许多具有相同基本结构的对象,与为每个实例重复编写对象字面量语法相比,代码更整洁、更易于管理。

2. 引入原型 (Prototypes):高效地共享方法

虽然直接在构造函数内部定义方法(如 this.greet = function() { ... };)是可行的,但在处理大量对象时,它有一个显著的缺点。

每次你创建一个新的 Person 对象时,都会创建一个全新的 greet 函数副本并将其直接存储在该特定对象上。如果你有 100 个 Person 对象,你的内存中实际上就会有 100 个完全相同的 greet 函数。这是低效且浪费的。

这就是 原型 (Prototypes) 发挥作用的地方。原型提供了一种在对象之间共享方法和属性的机制,从而节省内存并提高性能。

2.1 什么是原型?

在 JavaScript 中,每个对象都有一个特殊的内部链接(通常称为 [[Prototype]]),指向另一个对象,称为它的原型

当你尝试访问对象上的属性或方法时,JavaScript 首先会在该对象本身上查找。如果找不到,它会去对象的原型上查找。如果还找不到,它会去原型的原型上查找,依此类推,直到到达链的末端(null)。这种链接链称为原型链 (Prototype Chain)

关键在于,JavaScript 中的每个函数(包括构造函数)自动拥有一个 prototype 属性(注意这里的 'p' 是小写的,以区别于内部的 [[Prototype]])。这个 prototype 属性是一个对象,你添加到它上面的任何东西,都可以通过原型链被该构造函数创建的所有对象访问。

2.2 将方法挂载到原型上

为了在构造函数创建的所有实例之间共享方法,我们将这些方法添加到构造函数的 prototype 属性上。

让我们重构 Person 构造函数,使用原型来处理 greet 方法:

// 我们的 Person 构造函数
function Person(name, age) {
  this.name = name;
  this.age = age;
  // 这里不再定义 greet 方法了!
  // 每个实例独有的属性(如 name, age)
  // 仍然直接用 'this' 定义。
}

// 将 'greet' 方法挂载到 Person 的原型上。
// 这个方法将被所有 Person 对象共享。
Person.prototype.greet = function() {
  console.log(`你好,我的名字是 ${this.name},我今年 ${this.age} 岁。`);
};

// 创建两个新的 Person 对象
const person3 = new Person("Charlie", 35);
const person4 = new Person("Diana", 40);

person3.greet(); // 输出: 你好,我的名字是 Charlie,我今年 35 岁。
person4.greet(); // 输出: 你好,我的名字是 Diana,我今年 40 岁。

在这个改进版本中:

  1. greet 函数现在只在 Person.prototype 上定义了一次。这意味着无论有多少个 Person 对象,内存中只有一个 greet 函数的副本。
  2. 当调用 person3.greet()person4.greet() 时,JavaScript 首先在 person3(或 person4)本身上查找 greet
  3. 它在 person3 上找不到 greet
  4. 于是,它顺着原型链向上查找 person3 的原型,即 Person.prototype
  5. 它在那里找到了 greet 并执行它。

重要的是,在 greet 方法内部,this 仍然正确地指向调用该方法的特定对象(person3person4)。这允许共享的 greet 方法访问各个 person3person4 对象的唯一 nameage 属性(this.namethis.age)。

这种方法确保了所有 Person 实例共享 greet 函数的单个副本,从而显著减少了内存消耗并提高了代码效率,特别是在许多对象共享共同行为的大型应用程序中。每个实例独有的属性(如 nameage)仍然直接在构造函数中使用 this 定义。

3. 实战示例与演示

让我们通过另一个实际示例来巩固理解:一个用于图书馆系统的 Book(书籍)构造函数。

3.1 示例 1:带有原型方法的 Book 构造函数

想象你正在构建一个图书馆管理系统。你需要创建许多 Book 对象。每本书都有标题、作者和页数。它还需要一个显示其信息的方法,以及一种将其标记为已读的方式。

// 1. 定义 Book 构造函数
function Book(title, author, pages) {
  this.title = title;
  this.author = author;
  this.pages = pages;
  this.isRead = false; // 所有新书初始状态都为未读 (每个实例独有的属性)
}

// 2. 将方法添加到 Book 的原型上以实现共享行为
Book.prototype.displayInfo = function() {
  console.log(`${this.title} 作者:${this.author}, 共 ${this.pages} 页。 ${this.isRead ? '已阅读。' : '尚未阅读。'}`);
};

Book.prototype.toggleReadStatus = function() {
  this.isRead = !this.isRead; // 反转这本特定书籍的阅读状态
  console.log(`"${this.title}" 的阅读状态已更新为:${this.isRead ? '已读' : '未读'}。`);
};

// 3. 创建 Book 的实例
const book1 = new Book("霍比特人", "J.R.R. 托尔金", 310);
const book2 = new Book("1984", "乔治·奥威尔", 328);
const book3 = new Book("杀死一只知更鸟", "哈珀·李", 281);

// 4. 使用对象及其共享方法
console.log("--- 初始书籍信息 ---");
book1.displayInfo(); // 输出: 霍比特人 作者:J.R.R. 托尔金, 共 310 页。 尚未阅读。
book2.displayInfo(); // 输出: 1984 作者:乔治·奥威尔, 共 328 页。 尚未阅读。

console.log("\n--- 更新一本书的状态 ---");
book1.toggleReadStatus(); // 输出: "霍比特人" 的阅读状态已更新为:已读。
book1.displayInfo();      // 输出: 霍比特人 作者:J.R.R. 托尔金, 共 310 页。 已阅读。

console.log("\n--- 另一本书的信息 ---");
book3.displayInfo();      // 输出: 杀死一只知更鸟 作者:哈珀·李, 共 281 页。 尚未阅读。

在这个例子中,displayInfotoggleReadStatus 仅在 Book.prototype 上定义了一次,但 book1book2book3 都可以使用它们。每本书对象仍然保留其自己独特的 titleauthorpagesisRead 属性,但它们利用原型链共享了相同的 displayInfotoggleReadStatus 函数。

3.2 示例 2:用于车行库存的 Car 构造函数

让我们考虑一个用于汽车经销商应用程序的 Car(汽车)构造函数。每辆车都有 make(品牌)、model(型号)、year(年份)和 price(价格)。它还需要获取详细信息的方法和应用折扣的方法(折扣只应影响那辆特定汽车的价格)。

// Car 构造函数
function Car(make, model, year, price) {
  this.make = make;
  this.model = model;
  this.year = year;
  this.price = price;
  // 每个汽车实例独有的属性
  this.isSold = false;
}

// 挂载到原型上的方法,用于共享行为
Car.prototype.getDetails = function() {
  // 使用 toLocaleString() 来更好地格式化价格
  return `${this.year} ${this.make} ${this.model} - $${this.price.toLocaleString()} ${this.isSold ? '(已售)' : '(有货)'}`;
};

Car.prototype.applyDiscount = function(percentage) {
  if (percentage > 0 && percentage < 100) {
    const discountAmount = this.price * (percentage / 100);
    this.price -= discountAmount; // 更新此实例独有的 price 属性
    console.log(`应用了 ${percentage}% 的折扣。${this.make} ${this.model} 的新价格:$${this.price.toLocaleString()}`);
  } else {
    console.log("无效的折扣百分比。请输入 1 到 99 之间的值。");
  }
};

Car.prototype.markAsSold = function() {
  this.isSold = true;
  console.log(`${this.make} ${this.model} 已被标记为已售。`);
};

// 创建汽车实例
const car1 = new Car("Toyota", "Camry", 2022, 28000);
const car2 = new Car("Honda", "Civic", 2023, 26500);
const car3 = new Car("Ford", "F-150", 2021, 45000);

console.log("--- 初始汽车详情 ---");
console.log(car1.getDetails()); // 输出: 2022 Toyota Camry - $28,000 (有货)
console.log(car2.getDetails()); // 输出: 2023 Honda Civic - $26,500 (有货)

console.log("\n--- 应用折扣并标记为已售 ---");
car1.applyDiscount(5);  // 输出: 应用了 5% 的折扣。Toyota Camry 的新价格:$26,600
console.log(car1.getDetails()); // 输出: 2022 Toyota Camry - $26,600 (有货)

car3.applyDiscount(10); // 输出: 应用了 10% 的折扣。Ford F-150 的新价格:$40,500
car3.markAsSold();      // 输出: Ford F-150 已被标记为已售。
console.log(car3.getDetails()); // 输出: 2021 Ford F-150 - $40,500 (已售)

car2.applyDiscount(120); // 输出: 无效的折扣百分比。请输入 1 到 99 之间的值。

这个例子进一步演示了原型上的方法如何与每个对象实例的独特数据(如 this.pricethis.isSold)进行交互和修改。getDetailsapplyDiscountmarkAsSold 方法是高效的,因为无论你创建多少个 Car 对象,它们在内存中只存储一次。然而,每个对象都维护其在构造函数中定义的属性的状态。