Deep Dive into JavaScript (Part 2): Classes in JavaScript

发表于 2022-03-17 23:50 2507 字 13 min read

cos avatar

cos

FE / ACG / 手工 / 深色模式强迫症 / INFP / 兴趣广泛养两只猫的老宅女 / remote

文章介绍了 ES6 中类(class)的语法特性与底层机制,强调类是基于原型和构造函数的语法糖,具有构造函数、实例方法、静态方法和访问器等成员,支持单继承和类表达式。通过 extends 实现继承,支持 super 调用父类构造函数和方法,并引入 new.target 实现抽象基类和类混入,但建议优先使用“组合胜过继承”的设计原则。

This article has been machine-translated from Chinese. The translation may contain inaccuracies or awkward phrasing. If in doubt, please refer to the original Chinese version.

The previous section was all about using ES5 features to simulate class-like behavior. As we could see, each of these approaches has its own problems, and the inheritance code is quite verbose and confusing. Therefore, the class keyword introduced in ES6 provides the ability to formally define classes. It is essentially syntactic sugar, still using prototypes and constructors under the hood.

Class Definition

There are two ways to define classes — class declarations and class expressions — both using the class keyword:

// Class declaration
class Person {}

// Class expression
const Animal = class {};

Similar to function expressions, class expressions cannot be referenced before they are evaluated. However, the differences are:

  • Function declarations can be hoisted, while class definitions cannot be hoisted
  • Functions are limited by function scope, while classes are limited by block scope
// Function declarations can be hoisted, while class definitions cannot
console.log(FunctionExpression); // undefined
var FunctionExpression = function () {};
console.log(FunctionExpression); // function() {}

console.log(ClassExpression); // undefined
var ClassExpression = class {};
console.log(ClassExpression); // class {}
// Functions are limited by function scope, classes are limited by block scope
{
  function FunctionDeclaration() {}
  class ClassDeclaration {}
}
console.log(FunctionDeclaration); // FunctionDeclaration() {}
console.log(ClassDeclaration); // ReferenceError: ClassDeclaration is not defined

Class Composition

A class can include the following methods, none of which are required. An empty class definition is still valid.

  • Constructor
  • Getter and setter functions (get and set)
  • Static class methods (static)
  • Other instance methods

By default, code within a class definition executes in strict mode. Capitalizing the first letter goes without saying — it helps distinguish instances created from the class.

The name of a class expression is optional. After assigning a class expression to a variable, you can access the class expression’s name string via the name property. However, this identifier cannot be accessed outside the scope of the class expression.

let Person = class PersonName {
  identify() {
    console.log(Person.name, PersonName.name);
  }
};
let p = new Person();
p.identify(); // PersonName PersonName
console.log(Person.name); // PersonName
console.log(PersonName); // ReferenceError: PersonName is not defined

Class Constructor

The constructor keyword is used to create the class’s constructor function inside the class definition block.

  • constructor tells the interpreter: when creating a new instance of the class using the new operator, this function should be called.
  • Defining a constructor is not required. Not defining a constructor is equivalent to defining it as an empty function.

1. Instantiation

The constructor function is also syntactic sugar. It tells the JS interpreter that when defining an instance of a class with new, it should use the constructor function for instantiation.

Let’s review the operations performed when calling a constructor with new:

  • Create a new object in memory: let obj = new Object()
  • Assign the new object’s internal [[Prototype]] to the constructor’s prototype: obj.__proto__ = constructor.prototype;
  • Point this inside the constructor to this new object
  • Execute the code inside the constructor
    • The above two steps are equivalent to let res = contructor.apply(obj, args)
  • If the constructor returns a non-empty object, return that object res; otherwise, return the newly created object obj
    • return typeof res === 'object' ? res : obj;

Arguments passed during class instantiation serve as constructor arguments. If no arguments are needed, the parentheses after the class name are optional — you can simply write new Person.

The class constructor returns an object after execution, which is used as the instantiated object. Note: If the returned object res is a non-empty object and has no relationship with the new object obj created in the first step, the new object will be garbage collected. Also, instanceof checks will not detect any association with the class, since the prototype pointer was not modified.

2. What Is the Nature of a Class?

class Person {}
console.log(Person); // class Person {}
console.log(typeof Person); // function

It’s a function! As mentioned earlier, it’s essentially syntactic sugar, so it behaves like a function. It can be passed as an argument like any other object or function reference, and can also be immediately instantiated (similar to an immediately invoked function expression):

// Classes can be defined anywhere, like functions, such as in an array
let classList = [
  class {
    constructor(id) {
      this.id_ = id;
      console.log(`instance ${this.id_}`);
    }
  },
];
function createInstance(classDefinition, id) {
  return new classDefinition(id);
}
let foo = createInstance(classList[0], 3141); // instance 3141

// Immediately invoked
let p = new (class Foo {
  constructor(x) {
    console.log(x);
  }
})('bar'); // bar
console.log(p); // Foo {}

Instance, Prototype, and Class Members

Classes can conveniently define the following three types of members, similar to other languages:

  • Members on the instance
  • Members on the prototype
  • Members on the class itself (static class members)

Instance Members

Within the constructor, you can add own properties to the newly created instance via this. These properties will not be shared between instances.

  • Note: Members written directly in the class block also become instance properties.
class Person {
  sex = '女';
  age = 21;
  constructor() {
    // This example first uses an object wrapper type to define a string
    // to test equality of the two objects below
    this.name = new String('Jack');
    this.sayName = () => console.log(this.name);
    this.nicknames = ['Jake', 'J-Dog'];
  }
}
let p1 = new Person(),
  p2 = new Person();
p1.sayName(); // Jack
p2.sayName(); // Jack
console.log(p1.name === p2.name); // false
console.log(p1.sayName === p2.sayName); // false
console.log(p1.nicknames === p2.nicknames); // false
console.log(p1.sex, p1.age); // 女 21

Prototype Members

Methods defined in the class block serve as prototype methods (shared across all instances):

class Person {
  constructor() {
    // Everything added to this exists on different instances
    this.locate = () => console.log('instance');
  }
  // Everything defined in the class block is defined on the class's prototype
  locate() {
    console.log('prototype');
  }
}
let p = new Person();
p.locate(); // instance
Person.prototype.locate(); // prototype

Class definitions also support getter and setter accessors. The syntax and behavior are the same as regular objects:

class Person {
  sex = '女';
  age = 21;
  constructor() {
    this.sayName = () => console.log(this.name);
    this.nicknames = ['Jake', 'J-Dog'];
  }
  set name(newName) {
    this.name_ = newName;
  }
  get name() {
    return this.name_;
  }
}
let p = new Person();
p.name = 'cosine';
console.log(p.name); // cosine

Static Class Methods

Static class members use the static keyword as a prefix in the class definition. In static members, this refers to the class itself. They can be accessed directly via ClassName.methodName.

Non-Function Prototype and Class Members

Although class definitions don’t explicitly support adding data members to the prototype or class, you can manually add them outside the class definition:

// Define data members on the class
Person.greeting = 'My name is';
// Define data members on the prototype
Person.prototype.name = 'Jake';
  • However, this is not recommended. Adding mutable data members to the prototype or class is an anti-pattern. Generally, object instances should independently own data referenced through this.

Iterator and Generator Methods

Class definition syntax supports defining generator methods on both the prototype and the class itself:

class Person {
  // Define generator method on the prototype
  *createNicknameIterator() {
    yield 'cosine1';
    yield 'cosine2';
    yield 'cosine3';
  }
  // Define generator method on the class
  static *createJobIterator() {
    yield 'bytedance';
    yield 'mydream';
    yield 'bytedance mydream!';
  }
}
let jobIter = Person.createJobIterator();
console.log(jobIter.next().value); // bytedance
console.log(jobIter.next().value); // mydream
console.log(jobIter.next().value); // bytedance mydream!
let p = new Person();
let nicknameIter = p.createNicknameIterator();
console.log(nicknameIter.next().value); // cosine1
console.log(nicknameIter.next().value); // cosine2
console.log(nicknameIter.next().value); // cosine3

Since generator methods are supported, you can make class instances iterable by adding a default iterator:

class People {
  constructor() {
    this.nicknames = ['cosine1', 'cosine2', 'cosine3'];
  }
  *[Symbol.iterator]() {
    yield* this.nicknames.entries();
  }
}
let workers = new People();
for (let [idx, nickname] of workers) {
  console.log(idx, nickname);
}
// 0 cosine1
// 1 cosine2
// 2 cosine3

Inheritance

One of the best aspects of ES6 is its native support for class inheritance mechanisms, which is syntactic sugar over the previous prototype chain approach.

Inheritance Basics

ES6 classes support single inheritance. Using the extends keyword, you can inherit from a class or a regular constructor function (maintaining backward compatibility).

  • Derived classes can access methods defined on the class and prototype through the prototype chain
  • The value of this reflects the instance or class calling the corresponding method
  • The extends keyword can also be used in class expressions

Constructor, HomeObject, and super()

  • Derived class methods can reference their prototypes via the super keyword
    • Can only be used in derived classes
    • Limited to class constructors, instance methods, and static methods
    • Using super in a class constructor calls the parent class constructor
  • [[HomeObject]]
    • ES6 adds the internal attribute [[HomeObject]] to class constructors and static methods
    • Points to the object where the method is defined. This pointer is automatically assigned and can only be accessed within the JavaScript engine.
  • super is always defined as the prototype of [[HomeObject]].

When using super, note the following:

  • super can only be used in derived class constructors and static methods
  • You cannot reference super alone — either call a constructor with it or use it to reference static methods
  • Calling super() calls the parent class constructor and assigns the returned instance to this, so you cannot reference this before calling super()
  • To pass arguments to the parent constructor, manually pass them to super
  • If no class constructor is defined, super() is called when instantiating the derived class, and all arguments passed to the derived class are forwarded
  • If a constructor is explicitly defined in a derived class, it must either call super() or explicitly return an object

Abstract Base Classes

An abstract base class is a class that can be inherited by other classes but is not intended to be instantiated itself. This concept exists in other languages too. Although ECMAScript doesn’t have dedicated syntax for this, it can be easily achieved using new.target:

  • new.target holds the class or function called with the new keyword
  • By checking whether new.target is the abstract base class during instantiation, you can prevent instantiation of the abstract base class
// Abstract base class
class Vehicle {
  constructor() {
    console.log(new.target);
    if (new.target === Vehicle) {
      throw new Error('Vehicle cannot be directly instantiated');
    }
  }
}
// Derived class
class Bus extends Vehicle {}
new Bus(); // class Bus {}
new Vehicle(); // class Vehicle {}
// Error: Vehicle cannot be directly instantiated

By checking in the abstract base class’s constructor, you can require derived classes to define certain methods. Since prototype methods already exist before the prototype class’s constructor is called, you can use the this keyword to check whether the corresponding method is defined:

// Abstract base class
class Vehicle {
  constructor() {
    if (new.target === Vehicle) throw new Error('Vehicle cannot be directly instantiated');
    if (!this.foo) throw new Error('Inheriting class must define foo()');
    console.log('success!');
  }
}
// Derived class
class Bus extends Vehicle {
  foo() {}
}
// Derived class
class Van extends Vehicle {}
new Bus(); // success!
new Van(); // Error: Inheriting class must define foo()

Inheriting Built-in Types

ES6 classes provide a smooth mechanism for inheriting built-in reference types, allowing developers to easily extend built-in types. For example, adding a shuffle algorithm method to Array:

class SuperArray extends Array {
  shuffle() {
    // Add a shuffle algorithm
    for (let i = this.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1)); // Generate random number in [0, i+1)
      [this[i], this[j]] = [this[j], this[i]]; // Swap two elements
    }
  }
}
let a = new SuperArray(1, 2, 3, 4, 5);
console.log(a instanceof Array); // true
console.log(a instanceof SuperArray); // true
console.log(a); // [1, 2, 3, 4, 5]
a.shuffle();
console.log(a); // Randomly shuffled array
a.shuffle();
console.log(a); // Randomly shuffled array

Some built-in type methods return new instances. By default, the returned instance type matches the original instance type. If you want to override this behavior, you can override the Symbol.species accessor, which determines the class used when creating returned instances:

class SuperArray extends Array {
  static get [Symbol.species]() {
    return Array;
  }
}
let a1 = new SuperArray(1, 2, 3, 4, 5);
let a2 = a1.filter((x) => !!(x % 2));
console.log(a1); // [1, 2, 3, 4, 5]
console.log(a2); // [1, 3, 5]
console.log(a1 instanceof SuperArray, a1 instanceof Array); // true true
console.log(a2 instanceof SuperArray, a2 instanceof Array); // false true

Class Mixins (Simulating Multiple Inheritance)

Combining behaviors from different classes into one class is a common JavaScript pattern. Although ES6 doesn’t explicitly support multiple class inheritance, this behavior can be easily simulated using existing features.

First, note the following two points:

  • If you only need to mix in properties from multiple objects, Object.assign() will do.
    • Object.assign() was specifically designed for mixing object behaviors. It’s only necessary to implement your own mixin expressions when you need to mix in class behaviors.
  • Many JavaScript frameworks (especially React) have abandoned the mixin pattern in favor of the composition pattern
    • Composition means extracting methods into independent classes and helper objects, then composing them without using inheritance.
    • The well-known software design principle: “Composition over inheritance.”

The extends keyword can be followed by any JavaScript expression. Any expression that resolves to a class or constructor is valid. This expression is evaluated when the class definition is evaluated, which is the principle behind class mixins.

For example, if the Person class needs to combine classes A, B, and C, some mechanism is needed so that B inherits A, C inherits B, and then Person inherits C, thereby composing A, B, and C into this superclass:

class Vehicle {}
let AMixin = (Superclass) =>
  class extends Superclass {
    afunc() {
      console.log('A Mixin');
    }
  };
let BMixin = (Superclass) =>
  class extends Superclass {
    bfunc() {
      console.log('B Mixin');
    }
  };
let CMixin = (Superclass) =>
  class extends Superclass {
    cfunc() {
      console.log('C Mixin');
    }
  };
function mixin(BaseClass, ...Mixins) {
  return Mixins.reduce((accumulator, current) => current(accumulator), BaseClass);
}
class Bus extends mixin(Vehicle, AMixin, BMixin, CMixin) {}
let b = new Bus();
b.afunc(); // A Mixin
b.bfunc(); // B Mixin
b.cfunc(); // C Mixin

Summary

  • The new class feature in ECMAScript 6 is largely syntactic sugar based on the existing prototype mechanism
  • Class syntax allows developers to elegantly define backward-compatible classes
  • Classes can inherit from both built-in types and custom types
  • Classes effectively bridge the gap between object instances, object prototypes, and object classes
  • Through class mixins, you can cleverly achieve effects similar to multiple inheritance, but this is not recommended because “composition over inheritance”

喜欢的话,留下你的评论吧~

© 2020 - 2026 cos @cosine
Powered by theme astro-koharu · Inspired by Shoka