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

JavaScript类与构造函数的关系

2022-01-165.1k 阅读

JavaScript 类与构造函数的关系

一、构造函数基础

在 JavaScript 早期,并没有类的概念,开发者使用构造函数来创建对象。构造函数本质上就是一个普通函数,但它遵循一种特定的约定来创建和初始化对象。

当使用 new 关键字调用一个函数时,这个函数就被当作构造函数来使用。例如:

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 john = new Person('John', 30);
john.sayHello(); 

在上述代码中,Person 函数就是一个构造函数。当使用 new Person('John', 30) 调用时,会发生以下几件事:

  1. 创建新对象:一个新的空对象被创建。
  2. 绑定作用域this 被绑定到新创建的对象上。这意味着在构造函数内部,this 指向新创建的对象。
  3. 执行构造函数:构造函数的代码被执行,对新对象进行属性和方法的初始化。
  4. 返回对象:如果构造函数没有显式返回一个对象,那么 new 表达式会自动返回新创建并初始化好的对象。

构造函数这种方式为对象创建提供了一种模式,但它也存在一些问题。比如,每个实例对象都会有自己的方法副本,这在内存使用上是不高效的。例如,如果创建多个 Person 实例,每个实例都有自己的 sayHello 方法副本,而这些方法逻辑是完全一样的。

二、原型与构造函数

为了解决构造函数创建对象时方法重复的问题,JavaScript 引入了原型(prototype)的概念。每个函数都有一个 prototype 属性,它是一个对象,这个对象包含了可以被构造函数创建的实例所共享的属性和方法。

继续以上面的 Person 构造函数为例,我们可以将 sayHello 方法放到原型上:

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

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

let mary = new Person('Mary', 25);
mary.sayHello(); 

当我们访问一个对象的属性或方法时,如果对象本身没有这个属性或方法,JavaScript 会沿着原型链向上查找。在这个例子中,mary 实例本身没有 sayHello 方法,但它的原型(Person.prototype)上有,所以可以通过原型链找到并调用该方法。

通过将方法定义在原型上,所有由 Person 构造函数创建的实例都共享这些方法,大大节省了内存。同时,构造函数通过 prototype 属性与原型对象建立联系,每个实例对象又通过 __proto__ 属性(非标准属性,但几乎所有浏览器都支持)与构造函数的原型对象建立联系。

三、JavaScript 类的出现

ES6 引入了类(class)的概念,它为创建对象提供了一种更简洁、更清晰的语法糖。类实际上是基于构造函数和原型的语法封装,并没有引入新的底层机制。

下面是用类重写上面 Person 的例子:

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 peter = new Person('Peter', 28);
peter.sayHello(); 

在这个 class 定义中,constructor 方法类似于构造函数,用于初始化对象的属性。而定义在类中的其他方法(如 sayHello)会被添加到类的原型上。

四、类与构造函数的关系本质

从本质上讲,JavaScript 类就是对构造函数和原型机制的一种语法封装。当我们定义一个类时,JavaScript 引擎在幕后做了很多与构造函数和原型相关的操作。

  1. 类的构造函数:类中的 constructor 方法就是对应的构造函数。当使用 new 关键字创建类的实例时,constructor 方法会被调用,就如同调用普通构造函数一样。
class Animal {
    constructor(name) {
        this.name = name;
    }
}

// 类的构造函数其实就是 constructor 方法
console.log(Animal === Animal.prototype.constructor); 

在上述代码中,Animal 类的构造函数其实就是 Animal.prototype.constructor,这与传统构造函数的机制是一致的。

  1. 类的原型:类中的方法会被添加到类的原型对象上。例如:
class Car {
    constructor(model) {
        this.model = model;
    }

    drive() {
        console.log(`Driving ${this.model}`);
    }
}

console.log('drive' in Car.prototype); 

这里 drive 方法被添加到了 Car.prototype 上,这与我们在传统构造函数中手动将方法添加到原型上是类似的。

  1. 实例与原型链:类创建的实例同样遵循原型链规则。实例的 __proto__ 指向类的原型对象,类的原型对象的 __proto__ 指向 Object.prototype
class Book {
    constructor(title) {
        this.title = title;
    }
}

let myBook = new Book('JavaScript Deep Dive');
console.log(myBook.__proto__ === Book.prototype); 
console.log(Book.prototype.__proto__ === Object.prototype); 

从这些关系可以看出,虽然类的语法更简洁,但它底层依然依赖构造函数和原型的概念。

五、类与构造函数在继承中的体现

继承是面向对象编程的重要特性,在 JavaScript 中,无论是通过构造函数还是类,都可以实现继承。

  1. 构造函数的继承:通过 callapply 方法可以实现构造函数的继承。例如:
function Shape(color) {
    this.color = color;
}

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

function Rectangle(width, height, color) {
    Shape.call(this, color);
    this.width = width;
    this.height = height;
}

Rectangle.prototype = Object.create(Shape.prototype);
Rectangle.prototype.constructor = Rectangle;

Rectangle.prototype.getArea = function() {
    return this.width * this.height;
};

let rect = new Rectangle(5, 10, 'blue');
console.log(rect.getColor()); 
console.log(rect.getArea()); 

在上述代码中,Rectangle 构造函数通过 Shape.call(this, color) 调用 Shape 构造函数来继承其属性。同时,通过 Object.create(Shape.prototype) 创建新的原型对象,并设置 Rectangle.prototype.constructor 来修复构造函数指向。

  1. 类的继承:ES6 类通过 extends 关键字实现继承,语法更加简洁。例如:
class Shape {
    constructor(color) {
        this.color = color;
    }

    getColor() {
        return this.color;
    }
}

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

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

let rect2 = new Rectangle(3, 7, 'green');
console.log(rect2.getColor()); 
console.log(rect2.getArea()); 

在类的继承中,Rectangle 类通过 extends Shape 继承自 Shape 类。在 constructor 中,使用 super(color) 调用父类的构造函数进行初始化。类继承同样遵循原型链规则,Rectangle.prototypeShape.prototype 的后代,实例 rect2 的原型链也相应构建。

六、类与构造函数的使用场景

  1. 传统构造函数的场景:在一些需要兼容旧版 JavaScript 环境(如老旧浏览器)的项目中,由于对 ES6 类支持不完善,可能依然会使用传统构造函数。此外,在一些对性能要求极高且对代码简洁性要求相对较低的底层库开发中,构造函数和原型的手动操作可以提供更精细的控制,也可能会被使用。

  2. 类的场景:在现代 JavaScript 开发中,尤其是在前端框架(如 React、Vue 等)和后端 Node.js 应用开发中,类被广泛使用。类的语法更符合面向对象编程的习惯,代码更易读、维护和理解。特别是在大型项目中,类的清晰结构有助于团队协作开发。

七、类与构造函数的注意事项

  1. 构造函数的注意事项
    • 忘记使用 new:如果忘记使用 new 调用构造函数,this 指向的将是全局对象(在浏览器中是 window),可能会导致意外的全局变量声明和错误。
    • 原型对象修改:在修改构造函数的原型对象时,要注意可能影响到已经创建的实例。例如:
function Dog(name) {
    this.name = name;
}

let max = new Dog('Max');

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

// max 无法访问 bark 方法
max.bark(); 

这里修改 Dog.prototype 后,max 实例仍然指向旧的原型对象,无法访问新添加的 bark 方法。

  1. 类的注意事项
    • 严格模式:类的所有代码都在严格模式下执行,这意味着一些在非严格模式下允许的语法(如意外的全局变量声明)在类中会抛出错误。
    • 静态方法和属性:类可以有静态方法和属性,通过 static 关键字定义。静态方法和属性不能通过实例访问,只能通过类本身访问。例如:
class MathUtils {
    static add(a, b) {
        return a + b;
    }
}

// 正确访问静态方法
console.log(MathUtils.add(2, 3)); 

let utils = new MathUtils();
// 错误,不能通过实例访问静态方法
utils.add(4, 5); 

八、类与构造函数在内存管理上的差异

  1. 构造函数与内存:如前文所述,当方法定义在构造函数内部时,每个实例都会有自己的方法副本,占用更多内存。而将方法定义在原型上虽然可以节省内存,但在原型链查找过程中可能会有一定的性能开销,尤其是在原型链较长的情况下。

  2. 类与内存:类本质上也是基于原型机制,所以在内存管理方面与构造函数使用原型的情况类似。不过,类的语法封装使得代码结构更清晰,在一定程度上有助于开发者更好地理解和管理内存相关的操作。例如,在类中更容易识别哪些方法是共享的(定义在类体中),哪些是实例独有的(在 constructor 中初始化)。

九、类与构造函数在代码调试中的特点

  1. 构造函数调试:调试构造函数时,由于其与原型的手动操作较多,可能会遇到一些复杂的问题。例如,原型链的错误构建可能导致属性和方法无法正确访问。在调试工具中,追踪 this 的指向也可能会因为忘记使用 new 关键字等原因而出现偏差。不过,一旦熟悉了构造函数和原型的机制,通过调试工具查看对象的原型链、属性和方法等信息,也能够快速定位问题。

  2. 类调试:类的语法更直观,在调试时更容易理解代码的结构。例如,通过调试工具可以很清晰地看到类的继承关系、实例的属性和方法等。但由于类是在严格模式下执行,一些在非严格模式下不报错的代码在类中可能会导致错误,这就需要开发者在调试时注意错误信息,理解严格模式带来的影响。

十、类与构造函数在不同应用场景下的性能对比

  1. 简单对象创建场景:在创建少量简单对象时,类和构造函数的性能差异不大。因为无论是类还是构造函数,创建对象的基本操作(如内存分配、属性初始化等)是相似的。例如:
// 构造函数创建简单对象
function Point(x, y) {
    this.x = x;
    this.y = y;
}

let point1 = new Point(1, 2);

// 类创建简单对象
class PointClass {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
}

let point2 = new PointClass(3, 4);

在这种情况下,两种方式在现代 JavaScript 引擎的优化下,性能表现相近。

  1. 大量对象创建场景:当需要创建大量对象时,将方法定义在原型上(无论是构造函数还是类基于的原型机制)可以显著提高性能。因为共享方法可以减少内存占用,从而降低内存分配和垃圾回收的开销。例如,如果有一个游戏场景需要创建大量的角色对象,使用原型共享方法的方式(无论是构造函数还是类)会更高效。

  2. 复杂继承场景:在复杂继承场景下,类的语法优势不仅体现在代码可读性上,在性能方面也有一定优势。因为类继承的语法更简洁,JavaScript 引擎可以更好地进行优化。例如,在多层继承的情况下,类的 super 关键字使得继承关系更清晰,引擎可以更准确地进行原型链的构建和优化,相比构造函数通过 call 和手动设置原型等复杂操作,性能可能更优。

十一、类与构造函数在模块开发中的应用

  1. 构造函数在模块中的应用:在一些 JavaScript 模块中,构造函数可以用于创建特定类型的对象实例。例如,在一个图形绘制的模块中,可以使用构造函数创建不同的图形对象。模块可以导出构造函数,其他模块通过引入该构造函数来创建对象实例。
// shape.js 模块
function Circle(radius, color) {
    this.radius = radius;
    this.color = color;
}

Circle.prototype.getArea = function() {
    return Math.PI * this.radius * this.radius;
};

export { Circle };

// main.js 模块
import { Circle } from './shape.js';

let myCircle = new Circle(5, 'red');
console.log(myCircle.getArea()); 
  1. 类在模块中的应用:在现代 JavaScript 模块开发中,类的使用更为普遍。类可以更好地组织模块内的代码结构,尤其是在需要实现复杂逻辑和继承关系时。例如,在一个电商系统的模块中,可以定义商品类、订单类等,通过类的继承和方法定义来实现业务逻辑。
// product.js 模块
class Product {
    constructor(id, name, price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }

    getDetails() {
        return `Name: ${this.name}, Price: ${this.price}`;
    }
}

export { Product };

// order.js 模块
import { Product } from './product.js';

class Order {
    constructor(customer) {
        this.customer = customer;
        this.products = [];
    }

    addProduct(product) {
        this.products.push(product);
    }

    getTotalPrice() {
        return this.products.reduce((total, product) => total + product.price, 0);
    }
}

export { Order };

// main.js 模块
import { Product, Order } from './product.js';
import { Order } from './order.js';

let product1 = new Product(1, 'Laptop', 1000);
let order1 = new Order('John');
order1.addProduct(product1);
console.log(order1.getTotalPrice()); 

通过以上对 JavaScript 类与构造函数关系的多方面深入探讨,我们可以看到它们虽然在语法上有所不同,但本质紧密相连,并且在不同的应用场景下各有优劣,开发者可以根据具体需求选择合适的方式来进行对象的创建和编程。