深入理解 JavaScript 中 class 的基本语法与继承机制

文章目录

  • 一、class 基本语法
  • 1.1 类的定义与实例化
  • 1.2 constructor 构造函数
  • 1.3 类方法与属性
  • 1.4 this 指向问题
  • 二、类的继承
  • 2.1 继承的基本概念与实现
  • 2.2 super 关键字的用途
  • 2.3 方法重写与扩展
  • 2.4 继承中的静态方法与属性
  • 三、实战应用与最佳实践
  • 3.1 实际案例分析
  • 3.2 代码优化与注意事项
  • 最后
  • 一、class 基本语法

    1.1 类的定义与实例化

    在JavaScript中,我们可以通过 class 关键字来定义类。例如,下面定义了一个简单的 Person 类:

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

    在上述代码中,Person 就是我们定义的类,类内部有一个 constructor 构造函数(下一小节会详细介绍它),用于初始化类的实例属性。
    而使用 new 运算符可以创建类的实例,就像这样:

    const person1 = new Person('张三', 20);
    const person2 = new Person('李四', 25);
    

    这里,person1 和 person2 就是 Person 类的两个不同实例,它们各自拥有独立的属性值。new 关键字在创建实例时,主要进行了以下几个操作:

  • 创建一个简单JavaScript空对象(即 {} );
  • 链接该对象到另一个对象 (即设置该对象的 proto 为构造函数的 prototype );
  • 执行构造函数,将构造函数内的 this 作用域指向步骤1中创建的空对象 {} ;
  • 如果构造函数有返回值,则 return 返回值,否则 return 空对象。
  • 1.2 constructor 构造函数

    constructor 方法是类的默认方法,通过 new 命令生成对象实例时,它会自动被调用。例如前面定义的 Person 类中的 constructor:

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

    当我们使用 new Person(‘张三’, 20) 创建实例时,constructor 里的代码就会执行,将传入的参数 ‘张三’ 和 20 分别赋值给实例的 name 和 age 属性。
    如果我们没有显式地定义 constructor 方法,JavaScript 会自动为类添加一个默认的空的 constructor 方法,就像这样:

    class SomeClass {
      constructor() {}
    }
    

    默认情况下,constructor 方法返回的就是实例对象 this ,不过也可以指定它返回其他对象。但需要注意的是,如果返回了一个非原始类型的值,它将成为 new 表达式的值,而原本的 this 值将会被丢弃。例如:

    class AnotherClass {
      constructor() {
        this.prop = '初始属性值';
        return { newProp: '新的属性值' };
      }
    }
    const instance = new AnotherClass();
    console.log(instance.newProp); // 输出 "新的属性值",原本的this里的属性就不存在了
    

    1.3 类方法与属性

    在类中,我们可以定义实例属性和方法,以及静态属性和方法。
    实例属性:是定义在实例对象(this )上的属性,每个实例都有自己独立的一份副本。比如在 Person 类中定义实例属性:

    class Person {
      constructor(name, age) {
        this.name = name;
        this.age = age;
        this.greeting = '你好呀'; // 这就是一个实例属性
      }
      sayHello() { // 这是一个实例方法
        console.log(`${this.greeting},我叫${this.name},今年${this.age}岁。`);
      }
    }
    const person1 = new Person('王五', 30);
    const person2 = new Person('赵六', 35);
    person1.sayHello(); // 输出 "你好呀,我叫王五,今年30岁。"
    person2.sayHello(); // 输出 "你好呀,我叫赵六,今年35岁。"
    

    这里的 greeting 、name 和 age 都是实例属性,每个 Person 类的实例都有自己独立的这些属性值。
    原型属性(方法):实例方法实际上是定义在类的原型(prototype )上的,多个实例可以共享这些方法,节省内存空间。从本质上来说,class 定义的类的方法都是通过原型来实现共享的。我们可以通过查看原型来理解:

    console.log(Person.prototype);
    // 可以看到上面定义的sayHello方法就在原型对象上
    

    静态属性和方法:是属于类本身的属性和方法,而不是实例的。通过 static 关键字来定义,直接通过类名来调用,实例对象无法访问。例如:

    class MathUtils {
      static PI = 3.1415926;
      static add(a, b) {
        return a + b;
      }
    }
    console.log(MathUtils.PI); // 输出 3.1415926
    console.log(MathUtils.add(2, 3)); // 输出 5
    const utilInstance = new MathUtils();
    console.log(utilInstance.PI); // 会报错,实例不能访问静态属性
    utilInstance.add(1, 2); // 会报错,实例不能调用静态方法
    

    1.4 this 指向问题

    在类的方法中,this 的默认指向是调用该方法的实例对象。但在一些复杂的场景下,容易出现 this 指向不符合预期的情况。
    比如在下面的代码中:

    class Counter {
      constructor() {
        this.count = 0;
        document.addEventListener('click', this.increment);
      }
      increment() {
        this.count++;
        console.log(this.count);
      }
    }
    const counter = new Counter();
    

    当点击页面时,会发现报错或者 count 的值没有按预期增加,这是因为在事件回调函数里,this 的指向发生了改变,不再指向 Counter 的实例对象了,而是指向了触发事件的元素(在浏览器环境下通常是 document )。
    为了解决这类 this 指向问题,我们可以使用一些方法来改变 this 的指向,常见的有 call 、apply 和 bind 。

  • call 方法:可以立即调用函数,并且指定函数内部 this 的指向以及传入参数。例如:
  • function showName() {
      console.log(this.name);
    }
    const person = { name: '小明' };
    showName.call(person); // 输出 "小明"
    
  • apply 方法:和 call 类似,不过参数需要以数组的形式传入,比如:
  • function sum(num1, num2) {
      return this.val + num1 + num2;
    }
    const obj = { val: 10 };
    const result = sum.apply(obj, [5, 3]);
    console.log(result); // 输出 18
    
  • bind 方法:它会返回一个新的函数,新函数内部的 this 已经绑定为指定的对象,我们可以在合适的时候再去调用这个新函数,比如:
  • class Counter {
      constructor() {
        this.count = 0;
        const boundIncrement = this.increment.bind(this);
        document.addEventListener('click', boundIncrement);
      }
      increment() {
        this.count++;
        console.log(this.count);
      }
    }
    const counter = new Counter();
    

    通过 bind 把 increment 方法里的 this 绑定为 Counter 的实例,这样在点击事件触发时,就能正确地操作实例的 count 属性了。
    不同的 this 绑定方式在使用场景和传参形式等方面有所区别,需要根据实际情况来合理选择使用哪种方式来处理 this 指向问题,确保类中的方法能正确地操作实例属性等内容。

    二、类的继承

    2.1 继承的基本概念与实现

    在JavaScript中,类继承是实现代码复用和扩展的重要概念,通过继承,我们可以基于现有类创建新类,并继承父类的属性和方法。使用 extends 关键字可以实现基本的类继承,例如我们有一个基础的 Animal 类:

    class Animal {
      constructor(name) {
        this.name = name;
      }
      speak() {
        console.log(`The ${this.name} makes a sound.`);
      }
    }
    

    现在我们要创建一个 Dog 类,让它继承自 Animal 类,可以这样写:

    class Dog extends Animal {
      bark() {
        console.log(`${this.name} barks loudly.`);
      }
    }
    let dog = new Dog('Fido');
    dog.speak(); // 输出: The Fido makes a sound.
    dog.bark(); // 输出: Fido barks loudly.
    

    在上述代码中,Dog 类就是 Animal 类的子类,通过 extends 关键字建立了继承关系,Dog 类自动继承了 Animal 类中定义的 name 属性以及 speak 方法,同时它还可以定义自己独有的 bark 方法,这就体现了继承带来的代码复用与扩展的优势。

    2.2 super 关键字的用途

    super 关键字在JavaScript的类继承中有非常重要的作用,主要体现在两个方面:一是在子类构造函数中调用父类构造函数,二是用于访问父类的方法。

  • 调用父类的构造函数:在派生类(子类)的构造函数中,必须在使用 this 关键字之前调用 super()。例如:
  • class Parent {
      constructor(name) {
        this.name = name;
      }
    }
    class Child extends Parent {
      constructor(name, age) {
        super(name); // 调用父类的构造函数
        this.age = age;
      }
    }
    

    在 Child 类的构造函数中,通过 super(name) 调用了父类 Parent 的构造函数,并传入了 name 参数,这样,子类 Child 就拥有了父类 Parent 的 name 属性,同时还能定义自己的 age 属性。

  • 访问父类的方法:可以使用 super.methodName() 来调用父类的方法。例如:
  • class Parent {
      sayHello() {
        return 'Hello from Parent';
      }
    }
    class Child extends Parent {
      sayHello() {
        return `${super.sayHello()} and Hello from Child`;
      }
    }
    const child = new Child();
    console.log(child.sayHello()); // 输出: Hello from Parent and Hello from Child
    

    在子类 Child 的 sayHello 方法中,通过 super.sayHello() 调用了父类 Parent 的 sayHello 方法,然后在此基础上添加了自己的内容进行返回,实现了在子类中复用父类方法逻辑并进行扩展的功能。

    2.3 方法重写与扩展

    当子类继承父类后,子类可以通过定义与父类同名的方法来重写父类的方法,从而改变其行为。以下是一个简单示例:

    class Animal {
      sound() {
        console.log('Animal makes a sound');
      }
    }
    class Dog extends Animal {
      sound() {
        console.log('Dog barks');
      }
    }
    const dog = new Dog();
    dog.sound(); // 输出:'Dog barks'
    

    在上述示例中,Animal 类有一个 sound 方法,它打印出 Animal makes a sound。然后,Dog 类继承自 Animal 类,并重写了 sound 方法,改为打印出 Dog barks。当我们创建一个 Dog 类的实例并调用 sound 方法时,就会执行子类中重写后的方法。
    而且,子类在重写父类方法时,还可以调用父类的方法,使用 super 关键字,以保留和扩展父类的功能。例如:

    class Dog extends Animal {
      sound() {
        super.sound(); // 调用父类的sound方法
        console.log('Dog barks');
      }
    }
    const dog = new Dog();
    dog.sound(); 
    // 输出:
    // 'Animal makes a sound'
    // 'Dog barks'
    

    这里子类 Dog 的 sound 方法首先调用了父类 Animal 的 sound 方法,然后再在此基础上添加了额外的行为,实现了在继承基础上对方法的合理扩展。

    2.4 继承中的静态方法与属性

    静态方法和属性是属于类本身的,而不是类的实例的,通过 static 关键字来定义,直接通过类名来调用,实例对象无法访问它们。在类继承关系中,静态方法和属性也有着特定的继承规则。
    例如,我们有一个 Animal 类定义了静态属性和方法:

    class Animal {
      static planet = 'Earth';
      constructor(name, speed) {
        this.speed = speed;
        this.name = name;
      }
      run(speed = 0) {
        this.speed += speed;
        console.log(`${this.name} runs with speed ${this.speed}.`);
      }
      static compare(animalA, animalB) {
        return animalA.speed - animalB.speed;
      }
    }
    

    然后创建一个 Rabbit 类继承自 Animal 类:

    class Rabbit extends Animal {
      hide() {
        console.log(`${this.name} hides!`);
      }
    }
    

    此时,子类 Rabbit 可以访问父类 Animal 的静态属性 planet,像这样:

    let rabbits = [new Rabbit('White Rabbit', 10), new Rabbit('Black Rabbit', 5)];
    rabbits.sort(Rabbit.compare);
    rabbits[0].run(); // Black Rabbit runs with speed 5.
    console.log(Rabbit.planet); // Earth
    

    从这个例子可以看出,对于静态方法,子类可以直接继承使用,而对于静态属性,子类同样能够继承并访问,就如同它们是子类本身所定义的静态成员一样,这在一些需要基于类层面进行操作和共享数据的场景中非常有用,比如在多个子类实例间进行比较、按照统一规则排序等操作时,静态方法就能发挥很好的复用性优势。

    三、实战应用与最佳实践

    3.1 实际案例分析

    假设我们正在开发一个简单的图形绘制应用程序,其中有一个基类 Shape,它具有一些通用的属性和方法,如颜色、位置等,以及一个绘制方法 draw。然后我们有具体的子类 Circle 和 Rectangle,它们继承自 Shape 类,并分别实现自己独特的绘制逻辑。
    以下是代码示例:

    // 基类Shape
    class Shape {
      constructor(color, x, y) {
        this.color = color;
        this.x = x;
        this.y = y;
      }
    
      setColor(color) {
        this.color = color;
      }
    
      draw() {
        console.log(`Drawing a shape at (${this.x}, ${this.y}) with color ${this.color}`);
      }
    }
    
    // 子类Circle
    class Circle extends Shape {
      constructor(color, x, y, radius) {
        super(color, x, y);
        this.radius = radius;
      }
    
      draw() {
        console.log(`Drawing a circle at (${this.x}, ${this.y}) with radius ${this.radius} and color ${this.color}`);
      }
    }
    
    // 子类Rectangle
    class Rectangle extends Shape {
      constructor(color, x, y, width, height) {
        super(color, x, y);
        this.width = width;
        this.height = height;
      }
    
      draw() {
        console.log(`Drawing a rectangle at (${this.x}, ${this.y}) with width ${this.width}, height ${this.height} and color ${this.color}`);
      }
    }
    
    // 使用示例
    const circle = new Circle('red', 10, 20, 5);
    circle.draw();
    
    const rectangle = new Rectangle('blue', 30, 40, 10, 20);
    rectangle.draw();
    

    在这个案例中,Shape 类作为基类,提供了通用的属性和方法。Circle 和 Rectangle 子类继承自 Shape 类,通过 super 关键字调用父类的构造函数来初始化继承的属性,并各自实现了独特的 draw 方法来绘制特定的图形。这样的设计使得代码结构清晰,易于扩展和维护。如果后续需要添加新的图形类型,只需创建一个新的子类并继承自 Shape 类,然后实现相应的绘制逻辑即可。

    3.2 代码优化与注意事项

  • 避免深度继承:虽然 JavaScript 支持多层继承,但过度的继承层次会使代码变得复杂且难以维护。当一个子类继承自另一个子类,再继承自父类时,代码的可读性和可维护性会大打折扣。例如,如果有一个 SubSubClass 继承自 SubClass,而 SubClass 又继承自 BaseClass,在 SubSubClass 中可能会难以确定某个属性或方法的来源,以及在修改代码时可能会影响到多个层次的继承关系。因此,应尽量保持继承层次的扁平化,避免深度继承。如果发现继承层次过深,可以考虑使用组合或接口等方式来替代部分继承关系。
  • 合理使用 super:在子类构造函数中,必须在使用 this 之前调用 super,否则会导致错误。而且,在使用 super 调用父类方法时,要注意方法的重写和扩展逻辑,确保代码的正确性和可维护性。例如,如果子类重写了父类的某个方法,但在重写方法中又错误地使用了 super 调用父类方法,可能会导致逻辑错误。另外,在使用 super 访问父类属性时,也要确保父类确实存在该属性,避免出现引用错误。
  • 常见错误与解决方法:
    忘记使用 new 关键字实例化类:这是一个常见的错误,会导致函数调用而不是类实例化。例如:const person = Person(‘John’, 30); 应该改为 const person = new Person(‘John’, 30);。
    在类的方法中使用箭头函数导致 this 指向错误:箭头函数会继承定义时的 this 上下文,而不是类实例的 this。如果在类方法中需要正确的 this 指向,应使用普通函数定义。例如:
  • class MyClass {
      constructor() {
        this.value = 1;
        // 错误示例
        this.clickHandler = () => {
          console.log(this.value);
        };
        // 正确示例
        this.clickHandler = function() {
          console.log(this.value);
        };
      }
    }
    

    方法重写时错误地覆盖了父类的重要逻辑:当子类重写父类方法时,有时可能会不小心完全覆盖父类的关键逻辑,而不是在其基础上进行扩展。为了避免这种情况,可以在子类方法中先调用 super 方法来执行父类的原有逻辑,然后再添加子类特有的逻辑。例如:

    class Parent {
      doSomething() {
        console.log('Parent doing something');
      }
    }
    
    class Child extends Parent {
      doSomething() {
        super.doSomething(); // 先执行父类的逻辑
        console.log('Child doing something more');
      }
    }
    

    最后

    推荐学习网站:ES6教程

    作者:前端没钱

    物联沃分享整理
    物联沃-IOTWORD物联网 » 深入理解 JavaScript 中 class 的基本语法与继承机制

    发表回复