MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript类与构造函数的协同运作

2022-02-072.5k 阅读

JavaScript类与构造函数的协同运作

1. JavaScript中的构造函数

在JavaScript中,构造函数是一种特殊的函数,用于创建对象。它的命名通常遵循首字母大写的约定,以便与普通函数区分开来。构造函数通过 new 关键字调用,在调用时,它会创建一个新的空对象,并将 this 关键字指向这个新对象。

function Person(name, age) {
    this.name = name;
    this.age = age;
    this.sayHello = function() {
        console.log(`Hello, my name is ${this.name} and I'm ${this.age} years old.`);
    };
}

let person1 = new Person('John', 30);
person1.sayHello();

在上述代码中,Person 就是一个构造函数。当使用 new Person('John', 30) 调用时,会创建一个新的对象 person1,这个对象具有 nameage 属性以及 sayHello 方法。

1.1 构造函数的内部机制

当使用 new 关键字调用构造函数时,会发生以下几个步骤:

  1. 创建新对象:JavaScript引擎会创建一个新的空对象。
  2. 设置原型链:新创建的对象的 __proto__ 属性会被设置为构造函数的 prototype 属性。这使得新对象可以访问构造函数原型对象上的属性和方法。
  3. 绑定 this:构造函数内部的 this 关键字会被绑定到新创建的对象上。所以在构造函数中使用 this 来定义对象的属性和方法,实际上是在为新创建的对象添加这些内容。
  4. 执行构造函数:执行构造函数内部的代码,为新对象添加属性和方法。
  5. 返回对象:如果构造函数没有显式返回一个对象(除了 return undefined 这种情况),那么会默认返回新创建的对象。
function Animal(species) {
    // this 指向新创建的对象
    this.species = species;
    // 没有显式返回对象,默认返回新创建的对象
}

let dog = new Animal('Dog');
console.log(dog.species);

1.2 构造函数的问题

虽然构造函数为创建对象提供了一种便捷的方式,但它也存在一些问题。例如,每个通过构造函数创建的对象,其方法都是独立的实例。这意味着如果有多个对象实例,每个实例都有自己的一套相同的方法,会浪费内存。

function Car(make, model) {
    this.make = make;
    this.model = model;
    this.getDetails = function() {
        return `This is a ${this.make} ${this.model}`;
    };
}

let car1 = new Car('Toyota', 'Corolla');
let car2 = new Car('Honda', 'Civic');

console.log(car1.getDetails === car2.getDetails);

在上述代码中,car1.getDetailscar2.getDetails 是不同的函数实例,尽管它们的功能完全相同。这在对象数量较多时,会导致内存浪费。

2. JavaScript中的类

ES6引入了类的概念,它是一种基于原型的面向对象编程的语法糖。类实际上是对构造函数和原型链的一种封装,使得代码更加简洁和易于理解。

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

    sayHello() {
        console.log(`Hello, my name is ${this.name} and I'm ${this.age} years old.`);
    }
}

let person1 = new Person('Jane', 25);
person1.sayHello();

在上述代码中,Person 是一个类。constructor 方法是类的构造函数,用于初始化对象的属性。sayHello 方法定义在类的原型上,所有通过 Person 类创建的对象都可以共享这个方法。

2.1 类的内部机制

类在底层依然是基于构造函数和原型链来实现的。当定义一个类时,JavaScript引擎会创建一个构造函数,其名称与类名相同。类中的 constructor 方法会成为构造函数的主体。类中的其他方法会被添加到构造函数的 prototype 对象上。

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

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

// 实际上,类定义后会生成类似这样的构造函数
function AnimalConstructor(species) {
    this.species = species;
}

AnimalConstructor.prototype.speak = function() {
    console.log(`${this.species} makes a sound.`);
};

let cat = new Animal('Cat');
cat.speak();

2.2 类的继承

类的一个重要特性是继承,它允许一个类从另一个类继承属性和方法。通过 extends 关键字实现继承。

class Mammal extends Animal {
    constructor(species, furColor) {
        super(species);
        this.furColor = furColor;
    }

    describe() {
        console.log(`${this.species} with ${this.furColor} fur.`);
    }
}

let rabbit = new Mammal('Rabbit', 'White');
rabbit.speak();
rabbit.describe();

在上述代码中,Mammal 类继承自 Animal 类。super(species) 调用了父类的构造函数,以初始化从父类继承的属性。Mammal 类还定义了自己的 describe 方法。

3. 类与构造函数的协同运作

3.1 类作为构造函数的封装

类可以看作是对构造函数的一种更高级的封装形式。它提供了一种更清晰、更符合面向对象编程习惯的语法。通过类定义的构造函数,其内部逻辑更加规整。

// 传统构造函数方式
function Rectangle1(width, height) {
    this.width = width;
    this.height = height;
    this.getArea = function() {
        return this.width * this.height;
    };
}

// 类的方式
class Rectangle2 {
    constructor(width, height) {
        this.width = width;
        this.height = height;
    }

    getArea() {
        return this.width * this.height;
    }
}

let rect1 = new Rectangle1(5, 10);
let rect2 = new Rectangle2(5, 10);

console.log(rect1.getArea());
console.log(rect2.getArea());

在上述代码中,Rectangle2 类通过更简洁的语法实现了与 Rectangle1 构造函数相同的功能。类的定义方式使得代码结构更加清晰,属性和方法的定义也更加集中。

3.2 原型链的统一

无论是通过构造函数还是类创建的对象,都基于原型链来实现属性和方法的继承。当使用类时,JavaScript引擎会自动处理原型链的设置,确保对象能够正确地继承属性和方法。

function Shape1() {
    this.color = 'black';
}

Shape1.prototype.getColor = function() {
    return this.color;
};

class Shape2 {
    constructor() {
        this.color = 'black';
    }

    getColor() {
        return this.color;
    }
}

let shape1 = new Shape1();
let shape2 = new Shape2();

console.log(shape1.getColor());
console.log(shape2.getColor());

在上述代码中,Shape1 是通过构造函数定义的,Shape2 是通过类定义的。尽管定义方式不同,但它们创建的对象都能通过原型链访问到 getColor 方法。

3.3 相互转换

在某些情况下,可能需要在类和构造函数之间进行转换。例如,将一个现有的构造函数转换为类,或者在需要使用构造函数的地方使用类。

// 构造函数转换为类
function Circle1(radius) {
    this.radius = radius;
    this.getArea = function() {
        return Math.PI * this.radius * this.radius;
    };
}

class Circle2 {
    constructor(radius) {
        this.radius = radius;
    }

    getArea() {
        return Math.PI * this.radius * this.radius;
    }
}

let circle1 = new Circle1(5);
let circle2 = new Circle2(5);

console.log(circle1.getArea());
console.log(circle2.getArea());

在上述代码中,Circle1 是构造函数,Circle2 是类,它们实现了相同的功能,可以相互转换。

3.4 使用场景

在实际开发中,类通常用于更复杂、结构化的面向对象编程场景。它提供了更清晰的代码结构和更好的封装性,适合用于大型项目的代码组织。而构造函数则在一些简单的对象创建场景中仍然有用,特别是在与旧代码兼容或者对性能要求极高(在某些极端情况下,构造函数的性能略优于类,因为类有一些额外的语法开销)的情况下。

// 简单场景使用构造函数
function Point(x, y) {
    this.x = x;
    this.y = y;
}

// 复杂场景使用类
class GraphicObject {
    constructor() {
        this.children = [];
    }

    add(child) {
        this.children.push(child);
    }

    remove(child) {
        this.children = this.children.filter(c => c!== child);
    }
}

class Rectangle extends GraphicObject {
    constructor(width, height) {
        super();
        this.width = width;
        this.height = height;
    }
}

在上述代码中,Point 使用构造函数定义,适用于简单的坐标点对象创建。而 GraphicObjectRectangle 使用类定义,用于更复杂的图形对象的层次结构和功能实现。

4. 深入理解类与构造函数的协同

4.1 函数的内部属性

无论是构造函数还是类中的方法,本质上都是函数。函数有一些内部属性,如 prototype__proto__,它们在类与构造函数的协同运作中起着关键作用。

function MyFunction() {}

let instance = new MyFunction();

console.log(MyFunction.prototype);
console.log(instance.__proto__);

在上述代码中,MyFunction.prototype 是构造函数的原型对象,instance.__proto__ 是实例对象的原型,它们在正常情况下是相等的,这是实现属性和方法继承的基础。

4.2 类的静态方法与构造函数的静态属性

类可以有静态方法,这些方法属于类本身,而不是类的实例。构造函数也可以有静态属性和方法。

class MathUtils {
    static add(a, b) {
        return a + b;
    }
}

function MathUtils2() {}

MathUtils2.add = function(a, b) {
    return a + b;
};

console.log(MathUtils.add(2, 3));
console.log(MathUtils2.add(2, 3));

在上述代码中,MathUtils 类的 add 方法是静态方法,MathUtils2 构造函数通过直接添加属性的方式也实现了类似的静态方法功能。

4.3 性能考虑

在性能方面,虽然类在大多数情况下表现良好,但由于其语法糖的性质,会有一些额外的开销。在性能敏感的场景中,如高性能游戏开发或者频繁创建大量对象的场景,构造函数可能是更好的选择。不过,现代JavaScript引擎对类的优化也在不断提升,这种性能差异在大多数实际应用中并不明显。

// 性能测试示例
console.time('Constructor');
for (let i = 0; i < 1000000; i++) {
    new Rectangle1(5, 10);
}
console.timeEnd('Constructor');

console.time('Class');
for (let i = 0; i < 1000000; i++) {
    new Rectangle2(5, 10);
}
console.timeEnd('Class');

在上述代码中,可以通过 console.timeconsole.timeEnd 来测试构造函数和类在创建大量对象时的性能差异。

5. 最佳实践与常见错误

5.1 最佳实践

  1. 使用类进行面向对象编程:在新的项目开发中,优先使用类来实现面向对象的结构,因为它提供了更清晰的语法和更好的代码组织。
  2. 合理使用静态方法和属性:对于与类相关但不依赖于实例的功能,使用静态方法和属性,这样可以提高代码的可读性和可维护性。
  3. 理解原型链:深入理解原型链的工作原理,无论是在使用类还是构造函数时,这有助于正确地实现属性和方法的继承。

5.2 常见错误

  1. 忘记 new 关键字:在调用构造函数时,如果忘记使用 new 关键字,this 关键字将不会指向新创建的对象,而是指向全局对象(在浏览器中是 window),这会导致意想不到的结果。
  2. 在类中错误使用 this:在类的方法中,确保 this 关键字的正确使用。如果在方法内部使用箭头函数,箭头函数没有自己的 this,它会继承外部作用域的 this,这可能导致 this 指向错误的对象。
// 忘记new关键字的错误示例
function Person(name) {
    this.name = name;
}

let person = Person('Bob');
console.log(window.name);

// 类中箭头函数this指向错误示例
class Example {
    constructor() {
        this.value = 10;
        this.getWrongValue = () => {
            return this.value;
        };
        this.getCorrectValue = function() {
            return this.value;
        };
    }
}

let example = new Example();
let wrongValue = example.getWrongValue();
let correctValue = example.getCorrectValue();

在上述代码中,第一个示例展示了忘记 new 关键字的问题,第二个示例展示了类中箭头函数 this 指向错误的情况。

通过深入理解JavaScript中类与构造函数的协同运作,开发者可以根据不同的需求选择合适的方式来创建对象和组织代码,提高代码的质量和可维护性。无论是在小型项目还是大型企业级应用中,正确运用类和构造函数都是非常重要的。