手写实现 JavaScript 原型链继承:从构造函数到 class 语法的底层演进

各位同仁,各位技术爱好者,大家好。

今天,我们将深入探讨 JavaScript 中一个核心且引人入胜的话题:原型链继承。JavaScript 的继承机制,其独特之处在于它并非基于传统的类(class-based)而是基于原型(prototype-based)。然而,随着语言的发展,我们看到了从原始的构造函数模式到现代 ES6 class 语法的演进。这不仅仅是语法糖的变化,更是 JavaScript 社区在寻求更易用、更符合直觉的面向对象编程范式上的不懈努力。

本次讲座,我将带领大家穿越时空,从 JavaScript 最早的继承实现方式开始,一步步揭示原型链的奥秘,并最终理解 class 语法如何在底层复用并优化了这些机制。


一、原型链的基石:[[Prototype]]prototype__proto__

在深入继承模式之前,我们必须首先厘清 JavaScript 中三个至关重要的概念:[[Prototype]]prototype 属性以及 __proto__ 访问器。它们是理解原型链继承的基石。

1. [[Prototype]]:对象的内部秘密链接

每一个 JavaScript 对象都有一个内部属性,在 ECMAScript 规范中表示为 [[Prototype]]。这个内部属性指向另一个对象,也就是这个对象的原型。当您尝试访问一个对象的某个属性或方法时,如果该对象本身没有这个属性或方法,JavaScript 引擎就会沿着 [[Prototype]] 链向上查找,直到找到该属性或方法,或者查找到链的末端(null)。这就是原型链的本质——一个查找属性和方法的链接机制。

2. prototype 属性:函数的独有特性

只有函数(包括构造函数和类)才拥有 prototype 属性。这个属性是一个普通对象,它将被用作通过该函数创建的所有实例的 [[Prototype]]。换句话说,当您使用 new 关键字调用一个函数作为构造函数时,新创建的对象的 [[Prototype]] 就会被设置为该构造函数的 prototype 属性所指向的对象。

3. __proto__ 访问器:通往 [[Prototype]] 的桥梁

__proto__(双下划线 proto)是一个非标准的,但在大多数 JavaScript 环境中都实现了的访问器属性。它提供了一种便捷的方式来访问对象的 [[Prototype]]。尽管它在 ES6 之前是广泛使用的,但现在推荐使用 Object.getPrototypeOf()Object.setPrototypeOf() 来获取或设置对象的原型,因为 __proto__ 的性能和兼容性问题在某些旧环境中可能存在。

概念关系速览:

概念 描述 谁拥有它? 如何访问?
[[Prototype]] 一个对象的内部属性,指向其原型对象。这是原型链的实际链接。当查找属性或方法时,如果对象本身没有,就会沿着 [[Prototype]] 向上查找。 所有对象 Object.getPrototypeOf(obj)obj.__proto__ (不推荐直接使用 __proto__)
prototype 函数特有的属性,它是一个普通对象。当函数作为构造函数被 new 调用时,新创建实例的 [[Prototype]] 将指向这个 prototype 对象。 只有函数 Function.prototype
__proto__ 一个访问器属性,提供了对 [[Prototype]] 的便捷访问。虽然在实际开发中常用,但规范层面更推荐 Object.getPrototypeOf/setPrototypeOf 所有对象 obj.__proto__

示例:理解这些概念

// 1. 定义一个构造函数
function Person(name) {
    this.name = name;
}

// 2. 在构造函数的 prototype 上添加方法
Person.prototype.sayHello = function() {
    console.log(`Hello, my name is ${this.name}`);
};

// 3. 使用 new 关键字创建实例
const person1 = new Person("Alice");

// 4. 验证关系
console.log(person1.name); // Alice
person1.sayHello(); // Hello, my name is Alice

// person1 的 [[Prototype]] 指向 Person.prototype
console.log(Object.getPrototypeOf(person1) === Person.prototype); // true
console.log(person1.__proto__ === Person.prototype); // true (在多数环境中)

// Person.prototype 的 [[Prototype]] 指向 Object.prototype
console.log(Object.getPrototypeOf(Person.prototype) === Object.prototype); // true

// Object.prototype 的 [[Prototype]] 是 null,原型链的顶端
console.log(Object.getPrototypeOf(Object.prototype)); // null

// Person 函数本身的 [[Prototype]] 指向 Function.prototype
console.log(Object.getPrototypeOf(Person) === Function.prototype); // true

这段代码清晰地展示了 person1 实例如何通过其 [[Prototype]] 链接到 Person.prototype,从而能够访问 sayHello 方法。而 Person.prototype 又链接到 Object.prototype,使得所有对象都能访问如 toString() 等方法。这就是原型链的基本运作方式。


二、构造函数模式与 new 关键字:原始的继承萌芽

在 ES6 class 语法出现之前,JavaScript 主要通过构造函数和 new 关键字来模拟类的行为和实现继承。这是一种非常灵活但也容易出错的方式。

1. 基本构造函数

我们从一个简单的 Person 构造函数开始。

// 定义一个构造函数 Person
function Person(name, age) {
    // 实例属性:在构造函数内部使用 this 定义的属性,每个实例都会有自己的副本
    this.name = name;
    this.age = age;
}

// 在 Person.prototype 上添加方法:这些方法会被所有 Person 实例共享
Person.prototype.greet = function() {
    console.log(`Hello, my name is ${this.name} and I am ${this.age} years old.`);
};

Person.prototype.getAge = function() {
    return this.age;
};

// 创建 Person 实例
const personA = new Person("Bob", 30);
const personB = new Person("Carol", 25);

personA.greet(); // Hello, my name is Bob and I am 30 years old.
console.log(personB.getAge()); // 25

// 验证方法共享性
console.log(personA.greet === personB.greet); // true

new 关键字的幕后魔法

当我们使用 new Person("Bob", 30) 时,JavaScript 引擎会执行以下几个关键步骤:

  1. 创建一个新对象: 一个全新的、空的普通 JavaScript 对象被创建。
  2. 链接原型: 这个新创建的对象的 [[Prototype]] 内部属性被设置为构造函数 Personprototype 属性所指向的对象 (Person.prototype)。
  3. 绑定 this 构造函数 Personthis 上下文被绑定到这个新创建的对象。
  4. 执行构造函数: 构造函数 Person 的代码被执行,通常用于初始化新对象的属性(例如 this.name = name;)。
  5. 返回新对象: 如果构造函数没有显式地返回一个非 null 的对象,那么 new 操作符会隐式地返回这个新创建并初始化的对象。如果构造函数显式地返回了一个对象,那么该对象将作为 new 表达式的结果。

正是第二步“链接原型”奠定了实例与构造函数原型之间的关系,使得实例能够通过原型链访问 Person.prototype 上的方法。

2. instanceof 操作符

instanceof 操作符用于检测一个对象是否是某个构造函数的实例。它的工作原理是沿着对象的原型链向上查找,看是否存在 Constructor.prototype

console.log(personA instanceof Person); // true
console.log(personA instanceof Object); // true (因为 Person.prototype 继承自 Object.prototype)
console.log(personA instanceof String); // false

三、构造函数模式下的继承实践:从繁琐到优雅

在理解了基本构造函数和原型链后,我们开始探索如何在构造函数之间实现继承。这部分是 JavaScript 继承历史中最具演进意义的阶段,它展示了社区如何逐步找到更健壮的模式。

目标:让 Student 继承 Person

这意味着 Student 实例应该拥有 Person 的属性(如 name, age)和方法(如 greet, getAge),同时 Student 还能有自己的特有属性(如 studentId)和方法。

1. 借用构造函数(Call Parent Constructor):继承属性

这是解决属性继承最直接的方法。在子构造函数中,使用 callapply 方法调用父构造函数,并将子构造函数的 this 传递给父构造函数。

function Student(name, age, studentId) {
    // 借用 Person 构造函数来初始化 name 和 age 属性
    // Person.call(this, name, age) 相当于在当前 Student 实例的上下文中执行 Person 构造函数
    Person.call(this, name, age);
    this.studentId = studentId;
}

const student1 = new Student("David", 20, "S12345");

console.log(student1.name);     // David
console.log(student1.age);      // 20
console.log(student1.studentId); // S12345

// 问题:student1 无法访问 Person.prototype 上的方法
// student1.greet(); // TypeError: student1.greet is not a function

优点:

  • 可以继承父类的实例属性。
  • 父类的构造函数可以接收参数。
  • 每个子类实例都有自己独立的父类属性副本,不会相互影响。

缺点:

  • 无法继承父类原型上的方法。
  • 方法必须在构造函数中定义(导致每个实例都有自己的方法副本,浪费内存)或使用其他方式来继承原型方法。

2. 原型链继承(Prototype Chaining):继承方法

为了继承父类原型上的方法,最直观的想法是将子类的 prototype 指向父类的实例。

// ... Person 构造函数及原型方法定义不变 ...

function Student(name, age, studentId) {
    Person.call(this, name, age); // 继承属性
    this.studentId = studentId;
}

// 核心:让 Student.prototype 的 [[Prototype]] 指向 Person.prototype
// 最直接但有缺陷的做法是:
Student.prototype = new Person(); // ⚠️ 缺陷:创建了一个不必要的 Person 实例

// 修正 constructor 指针,否则所有 Student 实例的 constructor 都是 Person
Student.prototype.constructor = Student;

Student.prototype.study = function() {
    console.log(`${this.name} is studying with ID ${this.studentId}.`);
};

const student2 = new Student("Eve", 22, "S67890");

student2.greet();  // Hello, my name is Eve and I am 22 years old. (成功访问 Person.prototype 方法)
student2.study();  // Eve is studying with ID S67890.
console.log(student2.name); // Eve

// 缺点演示:父类构造函数被调用了两次(一次是 Person.call(this, ...),另一次是 new Person())
// 并且,如果 Person 构造函数接收参数,new Person() 无法传递参数,导致 name 和 age 是 undefined。
// 更严重的是,如果 Person.prototype 上有引用类型属性,所有 Student 实例会共享它,修改一个会影响所有。

Student.prototype = new Person(); 的缺陷分析:

  • 父构造函数被调用两次: 一次在 Student 构造函数内部 Person.call(this, ...),另一次是在设置 Student.prototypenew Person()。这可能导致不必要的开销或副作用。
  • 无法向父构造函数传递参数: 当执行 Student.prototype = new Person(); 时,我们无法向 Person 构造函数传递初始化参数。这意味着 Student.prototype 上的 nameage 属性会是 undefined 或默认值。虽然实例上的 nameage 会在 Person.call(this, ...) 中正确设置,但原型上的这些属性是冗余且不正确的。
  • 共享引用类型属性: 这是最危险的缺陷。如果 Person.prototype 上有引用类型的属性(例如一个数组或对象),那么所有 Student 实例都会共享这个引用。一个实例修改了这个属性,会影响到所有其他实例。

3. 组合继承(Combination Inheritance):最常用模式(ES5前)

组合继承结合了“借用构造函数”和“原型链继承”的优点,是 ES5 之前最常用的继承模式。它通过借用构造函数来继承属性,通过原型链来继承方法。

// ... Person 构造函数及原型方法定义不变 ...

function Student(name, age, studentId) {
    Person.call(this, name, age); // 第一次调用:继承属性,确保实例属性独立
    this.studentId = studentId;
}

// 核心:创建一个 Person.prototype 的副本作为 Student.prototype 的原型
// 使用 Object.create() 是为了避免直接调用 new Person() 带来的副作用
// Object.create(Person.prototype) 创建一个空对象,其 [[Prototype]] 指向 Person.prototype
Student.prototype = Object.create(Person.prototype);

// 修正 constructor 指针
Student.prototype.constructor = Student;

Student.prototype.study = function() {
    console.log(`${this.name} is studying with ID ${this.studentId}.`);
};

const student3 = new Student("Frank", 21, "S98765");

student3.greet();  // Hello, my name is Frank and I am 21 years old.
student3.study();  // Frank is studying with ID S98765.
console.log(student3.name); // Frank
console.log(student3.age);  // 21
console.log(student3.studentId); // S98765

console.log(student3 instanceof Student); // true
console.log(student3 instanceof Person); // true
console.log(student3 instanceof Object); // true

Object.create() 的作用:

Object.create(proto) 方法创建一个新对象,并将其 [[Prototype]] 设置为 proto。这比 new Person() 更高效和安全,因为它:

  • 不需要调用父构造函数来创建原型对象,避免了父构造函数被调用两次的问题。
  • 父构造函数中的实例属性不会被添加到 Student.prototype 上,保持了原型的纯净。

优点:

  • 继承了父类的实例属性,且每个子类实例有自己的副本。
  • 继承了父类原型上的方法,且方法是共享的,节省内存。
  • 能够向父类构造函数传递参数。
  • 解决了原型链继承中引用类型属性共享的问题。

缺点(相对而言):

  • 父构造函数仍然会被调用两次(一次创建实例属性,一次创建 Object.create 的原型链,虽然 Object.create 不会实际运行父构造函数内部代码,但从概念上讲,仍然是针对父类原型的一种处理)。但这通常不是一个大问题。

4. 封装继承逻辑:一个通用的 inherits 函数

为了简化组合继承的实现,我们通常会封装一个 inheritsextend 辅助函数。

// 辅助函数:实现继承
function inherits(Child, Parent) {
    // 创建一个中间函数,避免直接修改 Child.prototype 的 [[Prototype]]
    // 同时也避免直接使用 Parent.prototype = new Parent() 的问题
    const F = function() {};
    F.prototype = Parent.prototype;
    Child.prototype = new F(); // 继承 Parent.prototype 的方法
    Child.prototype.constructor = Child; // 修正 constructor 指针
}

// 定义父类
function Animal(name) {
    this.name = name;
    this.species = "unknown";
}
Animal.prototype.sayName = function() {
    console.log(`My name is ${this.name}.`);
};

// 定义子类
function Dog(name, breed) {
    Animal.call(this, name); // 继承属性
    this.breed = breed;
}

// 继承原型方法
inherits(Dog, Animal);

Dog.prototype.bark = function() {
    console.log("Woof!");
};

const myDog = new Dog("Buddy", "Golden Retriever");
myDog.sayName(); // My name is Buddy.
myDog.bark();    // Woof!
console.log(myDog.name);  // Buddy
console.log(myDog.breed); // Golden Retriever
console.log(myDog.species); // unknown (因为 Animal.call 没设置,Dog 也没设置,会去原型链上找,但是原型链上也没定义,所以是undefined)

inherits 函数的演进:使用 Object.create() 优化

上述 inherits 函数使用了一个空函数 F 来作为中介,这在 ES5 之前很常见。ES5 引入 Object.create() 后,可以更简洁地实现。

function modernInherits(Child, Parent) {
    // 直接创建 Child.prototype,使其 [[Prototype]] 指向 Parent.prototype
    Child.prototype = Object.create(Parent.prototype);
    Child.prototype.constructor = Child;
}

// 使用 modernInherits
function Cat(name, color) {
    Animal.call(this, name);
    this.color = color;
}

modernInherits(Cat, Animal);

Cat.prototype.meow = function() {
    console.log("Meow!");
};

const myCat = new Cat("Whiskers", "black");
myCat.sayName(); // My name is Whiskers.
myCat.meow();    // Meow!
console.log(myCat.color); // black

这种 modernInherits 函数就是组合继承模式的精髓,也是 ES6 class 语法底层继承机制的直接前身。


四、ES6 class 语法:原型链继承的现代化封装

ES6 引入的 class 语法,为 JavaScript 带来了更清晰、更符合传统面向对象编程习惯的类定义方式。然而,需要强调的是,class 并非引入了一种全新的继承机制,它仅仅是基于现有原型链继承机制的“语法糖”(syntactic sugar)。它让开发者可以用更简洁、更语义化的方式来描述原型链的构建和继承关系。

1. 基本 class 定义

一个基本的 class 定义包括 constructor 方法和实例方法。

class Person {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }

    // 实例方法:自动添加到 Person.prototype 上
    greet() {
        console.log(`Hello, my name is ${this.name} and I am ${this.age} years old.`);
    }

    getAge() {
        return this.age;
    }

    // 静态方法:直接添加到类本身(Person)上,而不是原型上
    static describe() {
        console.log("This is a Person class.");
    }
}

const personC = new Person("Grace", 28);
personC.greet(); // Hello, my name is Grace and I am 28 years old.
console.log(personC.getAge()); // 28

Person.describe(); // This is a Person class.
// personC.describe(); // TypeError: personC.describe is not a function

// 验证底层机制
console.log(typeof Person); // function (类本质上仍然是函数)
console.log(Person.prototype.greet === personC.greet); // true
console.log(Object.getPrototypeOf(personC) === Person.prototype); // true
console.log(Person.describe === Person.__proto__.describe); // false, 静态方法直接在类上
console.log(Object.getPrototypeOf(Person) === Function.prototype); // true

class 语法与构造函数的对比:

特性 构造函数模式 class 语法
定义 function ConstructorName(...) { ... } class ClassName { constructor(...) { ... } }
实例属性 this.prop = value; 在构造函数内部定义 this.prop = value;constructor 内部定义
实例方法 ConstructorName.prototype.method = function() { ... }; 直接在 class 体内定义方法,自动添加到 prototype
静态方法 ConstructorName.staticMethod = function() { ... }; static staticMethod() { ... }
继承 复杂的 Object.create()inherits 辅助函数 extends 关键字,super 关键字
this 绑定 需手动 bind 或使用箭头函数解决 类方法默认不绑定 this,但有更好的方式(如箭头函数属性或 bind
严格模式 默认非严格模式,除非文件顶部声明 use strict 类体内部自动处于严格模式
无法提升(Hoisting) 函数声明会被提升,类声明不会 类声明不会被提升,必须先定义后使用

2. 使用 extendssuper 实现继承

class 语法通过 extends 关键字和 super 关键字,极大地简化了继承的实现。

class Student extends Person { // extends 建立了 Student 与 Person 的原型链关系
    constructor(name, age, studentId) {
        super(name, age); // 调用父类的构造函数,初始化 name 和 age
        this.studentId = studentId;
    }

    study() {
        console.log(`${this.name} is studying with ID ${this.studentId}.`);
    }

    // 覆盖父类方法
    greet() {
        console.log(`Hi everyone, I'm ${this.name}, a student.`);
        super.greet(); // 调用父类的 greet 方法
    }

    // 静态方法也会被继承
    static getStudentCount() {
        // 假设这里有一些逻辑来获取学生数量
        return 100;
    }
}

const student4 = new Student("Harry", 19, "S001");
student4.greet();
// Hi everyone, I'm Harry, a student.
// Hello, my name is Harry and I am 19 years old.
student4.study(); // Harry is studying with ID S001.

console.log(student4.name); // Harry
console.log(student4.age);  // 19
console.log(student4.studentId); // S001

console.log(student4 instanceof Student); // true
console.log(student4 instanceof Person);  // true
console.log(student4 instanceof Object);  // true

// 静态方法继承
console.log(Student.getStudentCount()); // 100
Person.describe(); // This is a Person class.
Student.describe(); // This is a Person class. (Student 继承了 Person 的静态方法)

extends 的底层机制解析:

当您写 class Student extends Person 时,JavaScript 引擎在底层做了以下几件事:

  1. 设置 Student.prototype[[Prototype]] Object.setPrototypeOf(Student.prototype, Person.prototype);。这确保了 Student 的实例能够通过原型链访问 Person.prototype 上的方法。
  2. 设置 Student 函数本身的 [[Prototype]] Object.setPrototypeOf(Student, Person);。这使得 Student 类能够继承 Person 类的静态方法。例如,Student.describe() 能够工作正是因为 Student 的原型链上找到了 Person.describe()
  3. 处理 this 的特殊行为: 在派生类(子类)的构造函数中,this 在调用 super() 之前是不能被访问的。这是因为在传统构造函数中,new 操作符会先创建一个实例对象,然后绑定 this。但在派生类中,这个实例对象的创建是由父类的构造函数负责的。super() 调用会触发父类构造函数的执行,并返回一个实例对象,然后这个实例对象才会被绑定到子类的 this 上。这就是为什么派生类构造函数必须先调用 super()

    • 非派生类构造函数 (Base Constructor): new Base() -> 先创建 this 对象 -> 执行 Base 构造函数。
    • 派生类构造函数 (Derived Constructor): new Derived() -> super() 被调用 -> super 负责创建 this 对象并执行父类构造函数 -> Derived 构造函数执行,可以使用 this

super 关键字的用途:

  • super() (作为函数调用): 在派生类的 constructor 中,用于调用父类的构造函数。它必须是 constructor 中的第一个操作,因为它负责初始化 this
  • super.propertyName (作为对象访问): 用于访问父类原型上的方法或属性。例如 super.greet() 会调用 Person.prototype.greet,并正确地绑定 this 到当前的 Student 实例。

3. superthis 绑定

super 调用时,this 的绑定是一个常见疑问。在类方法中,super 并不是简单地指向 Parent.prototype。实际上,super 内部有一个特殊的 [[HomeObject]] 绑定,它指向当前方法所属的对象。当您调用 super.method() 时,JavaScript 引擎会查找 [[HomeObject]] 的原型(即 Object.getPrototypeOf([[HomeObject]])),然后在这个原型上查找 method,并以当前 this 的值来调用它。

class Parent {
    constructor() {
        this.value = 1;
    }
    getValue() {
        return this.value;
    }
}

class Child extends Parent {
    constructor() {
        super();
        this.value = 2; // 覆盖父类的 this.value
    }
    getSuperValue() {
        return super.getValue(); // 调用父类的 getValue 方法,但 this 仍然是 Child 实例
    }
}

const childInstance = new Child();
console.log(childInstance.getValue());     // 2 (Child 实例上的 getValue 方法被继承,但 this.value 是 Child 自己的)
console.log(childInstance.getSuperValue()); // 2 (super.getValue() 依然是以 childInstance 作为 this 来调用的)

// 如果要在 super.getValue() 中获取 Parent 构造函数中设置的 value,则需要修改 Child 类的实现,
// 例如在 Child 的 constructor 中不覆盖 value,或者使用其他属性名。

这个例子说明 super 调用的方法仍然运行在当前实例 this 的上下文中,而不是父类实例的上下文中。super 只是决定了方法查找的起点。


五、高级概念与现代实践

随着 JavaScript 的不断演进,一些新的特性和模式也浮现出来,进一步增强了面向对象编程的能力。

1. Mixin 模式

继承是一种强耦合关系,有时我们只想复用某些行为而不希望形成严格的“is-a”关系。Mixin 模式允许我们通过组合来“混合”多个对象的行为,而不是通过继承。

// 定义一个 Mixin:可飞行的行为
const Flyable = {
    fly() {
        console.log(`${this.name} is flying!`);
    }
};

// 定义另一个 Mixin:可游泳的行为
const Swimmable = {
    swim() {
        console.log(`${this.name} is swimming!`);
    }
};

class Bird {
    constructor(name) {
        this.name = name;
    }
    sing() {
        console.log(`${this.name} is singing.`);
    }
}

// 通过 Object.assign 将 Mixin 混入到类的原型中
Object.assign(Bird.prototype, Flyable);

const sparrow = new Bird("Sparrow");
sparrow.sing(); // Sparrow is singing.
sparrow.fly();  // Sparrow is flying!
// sparrow.swim(); // TypeError

// 也可以创建一个结合多个 Mixin 的类
class Duck extends Bird {
    constructor(name) {
        super(name);
    }
}
Object.assign(Duck.prototype, Flyable, Swimmable);

const donald = new Duck("Donald");
donald.sing(); // Donald is singing.
donald.fly();  // Donald is flying!
donald.swim(); // Donald is swimming!

Mixin 模式提供了一种灵活的代码复用方式,避免了单继承的限制,尤其适用于 JavaScript 这种多继承受限的语言。

2. 私有类成员(ES2022+)

JavaScript 长期以来缺乏真正的私有成员。ES2022 引入了 # 语法来实现私有实例字段和方法。这些私有成员在类的外部是完全不可访问的。

class Account {
    #balance; // 私有字段
    #password; // 私有字段

    constructor(initialBalance, password) {
        this.#balance = initialBalance;
        this.#password = password;
    }

    deposit(amount) {
        if (amount > 0) {
            this.#balance += amount;
            console.log(`Deposited ${amount}. New balance: ${this.#balance}`);
        }
    }

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

    getBalance(password) {
        if (password === this.#password) {
            return this.#balance;
        }
        console.log("Invalid password for balance inquiry.");
        return null;
    }
}

class SavingsAccount extends Account {
    #interestRate;

    constructor(initialBalance, password, interestRate) {
        super(initialBalance, password);
        this.#interestRate = interestRate;
    }

    applyInterest() {
        // 无法直接访问父类的 #balance
        // console.log(this.#balance); // SyntaxError
        // 必须通过父类提供的公共方法间接操作,或者父类提供受保护的访问器
        // 这是一个设计上的选择:私有成员不被继承,每个类有自己的私有作用域
        console.log(`Applying ${this.#interestRate * 100}% interest.`);
        // 假设 deposit 方法可以被调用
        // this.deposit(this.getBalance("correctPassword") * this.#interestRate);
        // 上述代码也无法直接工作,因为 getBalance 需要密码,且 #balance 无法直接访问
        // 私有字段不参与原型继承,它们是类本身的私有状态
    }
}

const myAccount = new Account(1000, "secret123");
myAccount.deposit(200);
// console.log(myAccount.#balance); // SyntaxError: Private field '#balance' must be declared in an enclosing class
myAccount.withdraw(100, "secret123");
console.log(myAccount.getBalance("secret123")); // 1100

const mySavings = new SavingsAccount(500, "savepass", 0.02);
mySavings.applyInterest();
// mySavings.deposit(50); // 这会调用父类的 deposit 方法

私有成员的引入改变了传统意义上的继承行为:私有字段和方法不会被子类直接继承。每个类都有自己独立的私有字段集合。如果子类需要与父类的私有状态交互,父类必须通过公共或受保护的方法来暴露这种交互。这强化了封装性。


六、对 class 的再审视:不仅仅是语法糖

尽管我们一再强调 class 只是语法糖,但它带来的影响远不止于此。

1. 强制严格模式

class 内部的代码默认就在严格模式下执行,这意味着一些不安全的 JavaScript 行为(如隐式全局变量)会被禁止,有助于编写更健壮的代码。

2. 更好的错误处理

派生类构造函数强制要求调用 super(),并且必须在访问 this 之前调用。这解决了传统组合继承中容易忘记调用父构造函数或调用顺序不当导致 this 问题。

3. 更清晰的语义

class 语法更符合人类对“类”和“继承”的直观理解,使得代码更易读、易写、易维护,尤其是对于有其他 OOP 语言背景的开发者。

4. new.target 的作用

class 构造函数中,new.target 指向被 new 关键字直接调用的构造函数。这允许您在父类构造函数中判断当前实例是否由子类创建,从而实现一些高级的工厂模式或抽象类模拟。

class Shape {
    constructor() {
        if (new.target === Shape) {
            throw new Error("Cannot instantiate abstract class Shape directly.");
        }
        console.log("Shape constructor called.");
    }
}

class Circle extends Shape {
    constructor(radius) {
        super();
        this.radius = radius;
        console.log("Circle constructor called.");
    }
}

// const s = new Shape(); // Error: Cannot instantiate abstract class Shape directly.
const c = new Circle(10);
// Output:
// Shape constructor called.
// Circle constructor called.

class 并非简单地将旧代码包裹起来,它在设计上引入了一些新的行为和限制,旨在提供更安全、更可预测的编程模型。它将 JavaScript 的原型链的强大能力以一种更易于管理和理解的方式呈现给开发者。


七、抽象的历程与深层理解的价值

从最初通过构造函数和 new 关键字模拟继承,到 Object.create() 带来的优化,再到 ES6 class 语法的现代化封装,JavaScript 的继承机制经历了一段从底层机制暴露到高层抽象的演进。

这一演进的核心始终是原型链。无论您使用哪种语法,最终的实例属性和方法查找都将遵循 [[Prototype]] 链条。理解这一底层机制的运作方式,对于编写高效、可维护且无意外的 JavaScript 代码至关重要。即使在日常开发中,我们可能更多地依赖 class 语法,但当遇到复杂的继承问题、性能瓶颈或是需要与其他库框架集成时,对原型链的深刻理解将成为您解决问题的关键。

掌握 JavaScript 的原型链,就是掌握了其面向对象编程的灵魂。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注