深入分析 `JavaScript` `Class` 语法糖背后基于 `Prototype Chain` 的继承机制和 `new.target` 的作用。

各位观众老爷们,大家好!今天咱们不聊风花雪月,来聊聊 JavaScript 这位老朋友新穿的“糖果色外套”—— Class 语法。

别看 Class 关键字让 JavaScript 看起来像个正经的面向对象语言了,但扒开这层糖衣,你会发现里面还是那个熟悉的 Prototype Chain (原型链)在默默支撑。 咱们还要顺便看看 new.target 这个小家伙,它在 Class 的世界里扮演着什么角色。

第一幕:Class 登场,糖衣炮弹来袭

JavaScript 早期并没有 Class 关键字,创建对象和实现继承的方式略显繁琐。比如下面这样:

function Person(name) {
  this.name = name;
}

Person.prototype.sayHello = function() {
  console.log(`Hello, my name is ${this.name}`);
};

function Student(name, major) {
  Person.call(this, name); // 借用构造函数继承属性
  this.major = major;
}

Student.prototype = Object.create(Person.prototype); // 关键:原型链继承
Student.prototype.constructor = Student; // 修正 constructor 指向

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

const john = new Student("John", "Computer Science");
john.sayHello(); // Hello, my name is John
john.study();   // John is studying Computer Science

看到没? 为了实现继承,又是 call,又是 Object.create,又是修正 constructor,简直像在做一套广播体操,步骤多到让人怀疑人生。

然后,ES6 带来了 Class 语法,让代码瞬间变得优雅起来:

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

  sayHello() {
    console.log(`Hello, my name is ${this.name}`);
  }
}

class Student extends Person {
  constructor(name, major) {
    super(name); // 调用父类的 constructor
    this.major = major;
  }

  study() {
    console.log(`${this.name} is studying ${this.major}`);
  }
}

const jane = new Student("Jane", "Biology");
jane.sayHello(); // Hello, my name is Jane
jane.study();   // Jane is studying Biology

这简洁的代码,这清晰的结构,是不是感觉世界都美好了? extends 关键字一出,继承关系一目了然,super 关键字也让调用父类方法变得轻松愉快。

但是! 别被表象迷惑了! Class 仅仅是 JavaScript 原型继承机制的语法糖。它并没有改变 JavaScript 的继承本质,只是让代码更容易阅读和编写。

第二幕:原型链的秘密,挖地三尺也要找到你

要理解 Class 背后的原型链,我们先要搞清楚几个概念:

  • 构造函数 (Constructor):new 关键字调用的函数,用于创建对象。
  • 原型对象 (Prototype): 每个函数都有一个 prototype 属性,它指向一个对象。这个对象就是通过该函数创建的实例的原型。
  • 实例对象 (Instance): 通过 new 关键字创建的对象。
  • __proto__ 属性: 每个对象(除了 null)都有一个 __proto__ 属性,指向创建该对象的构造函数的原型对象。

有了这些概念,我们就可以画出原型链的关系图:

实例对象 --> __proto__ --> 构造函数的原型对象 --> __proto__ --> Object.prototype --> __proto__ --> null

简单来说,当我们访问一个对象的属性或方法时,JavaScript 引擎会按照以下顺序查找:

  1. 对象自身是否有该属性或方法。
  2. 如果没有,就沿着 __proto__ 属性指向的原型对象查找。
  3. 如果原型对象也没有,就继续沿着原型对象的 __proto__ 属性向上查找,直到找到 Object.prototype
  4. 如果 Object.prototype 也没有,就沿着 Object.prototype.__proto__ 查找到 null,查找结束,返回 undefined

现在,让我们回到 Class 语法。 Class 声明实际上做了什么?

class MyClass {
  constructor(value) {
    this.value = value;
  }

  getValue() {
    return this.value;
  }
}

console.log(typeof MyClass); // function
console.log(MyClass.prototype.constructor === MyClass); // true
console.log(MyClass.prototype); // {constructor: f, getValue: f, __proto__: Object}

从上面的代码可以看出:

  • Class 声明本质上还是创建了一个函数。
  • Classprototype 属性指向一个对象,该对象包含了 Class 中定义的方法。
  • Classprototype 属性的 constructor 属性指向 Class 本身。

也就是说,Class 只是把构造函数和原型对象的操作封装起来了,让代码更易读。 extends 关键字也只是简化了原型链继承的写法。

让我们再看一个 extends 的例子,并用原型链的视角来分析:

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

  speak() {
    console.log(`${this.name} makes a sound.`);
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);
    this.breed = breed;
  }

  speak() {
    console.log(`${this.name} barks.`);
  }
}

const dog = new Dog("Buddy", "Golden Retriever");
dog.speak(); // Buddy barks.

这段代码背后发生了什么?

  1. Dog extends Animal 实际上是设置了 Dog.prototype.__proto__ = Animal.prototype
  2. super(name) 调用了父类 Animal 的构造函数,相当于 Animal.call(this, name)
  3. Dog 类中的 speak 方法覆盖了 Animal 类中的 speak 方法(方法重写)。

我们可以用下面的表格来总结 Class 语法和原型链的关系:

Class 语法 原型链实现
class MyClass {} function MyClass() {}
constructor() 构造函数
method() MyClass.prototype.method = function() {}
extends ParentClass MyClass.prototype.__proto__ = ParentClass.prototype
super() ParentClass.call(this)

第三幕:new.target 的逆袭,谁在呼唤我?

new.target 是一个 ES6 新增的属性,它指向使用 new 关键字调用的构造函数或 Class。 简单来说,它告诉你“我是谁用 new 调用的”。

new.target 主要有以下几个用途:

  1. 判断函数是否被 new 调用: 如果函数不是通过 new 关键字调用的,new.target 的值为 undefined

    function MyFunction() {
      if (!new.target) {
        throw new Error("MyFunction must be called with new");
      }
      this.value = 10;
    }
    
    const obj = new MyFunction(); // 正确
    //MyFunction(); // 抛出错误
  2. 在抽象类中防止直接实例化: 抽象类通常只用于继承,不应该被直接实例化。我们可以使用 new.target 来防止这种情况。

    class AbstractClass {
      constructor() {
        if (new.target === AbstractClass) {
          throw new Error("AbstractClass cannot be instantiated directly");
        }
      }
    
      abstractMethod() {
        throw new Error("Abstract method must be implemented in subclass");
      }
    }
    
    class ConcreteClass extends AbstractClass {
      abstractMethod() {
        console.log("Implemented method");
      }
    }
    
    //const abstractObj = new AbstractClass(); // 抛出错误
    const concreteObj = new ConcreteClass(); // 正确
    concreteObj.abstractMethod(); // Implemented method
  3. 确定继承链中的哪个类被 new 调用: 在继承链中,new.target 指向的是直接被 new 调用的类,而不是父类。

    class Base {
      constructor() {
        console.log("Base constructor", new.target.name);
      }
    }
    
    class Derived extends Base {
      constructor() {
        super();
        console.log("Derived constructor", new.target.name);
      }
    }
    
    const baseObj = new Base(); // Base constructor Base
    const derivedObj = new Derived(); // Base constructor Derived  Derived constructor Derived

    在这个例子中,即使 Base 类的构造函数被 Derived 类调用,new.target 仍然指向 Derived 类。

new.target 的作用在于提供了更灵活的控制,让我们可以在运行时判断函数的调用方式和类的实例化行为。 它就像一个“身份识别器”,帮助我们更好地理解和管理 JavaScript 的面向对象编程。

第四幕:总结与升华,拨开云雾见青天

总而言之,Class 语法是 JavaScript 原型继承机制的语法糖。 它让代码更易读、易写,但并没有改变 JavaScript 的继承本质。 理解原型链是理解 Class 语法的关键。

new.target 则是一个强大的工具,可以帮助我们判断函数的调用方式和类的实例化行为,从而实现更灵活的控制。

希望今天的讲座能够帮助大家更好地理解 JavaScript 的 Class 语法和原型链机制。记住,不要被表面的糖衣迷惑,要深入理解背后的原理,才能真正掌握 JavaScript 这门语言。

下课!

发表回复

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