banner
cos

cos

愿热情永存,愿热爱不灭,愿生活无憾
github
tg_channel
bilibili

In-depth Journey of Learning JavaScript (Part Two) Classes in JavaScript

The previous section simulated class-like behavior based on ES5 features. It is not difficult to see that these methods each have their own issues, and the code for implementing inheritance appears very lengthy and chaotic. Therefore, the newly introduced class keyword in ES6 has the ability to formally define classes; it is essentially syntactic sugar, and the underlying concepts still use prototypes and constructor functions.

Class Definition#

There are two ways to define a class: class declaration and class expression, 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 be hoisted*
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#

Classes can include the following methods, but none are required; an empty class definition is still valid.

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

By default, the code in class definitions is executed in strict mode.
The capitalized first letter is not worth mentioning; it can be used to distinguish instances created by it.

The name of a class expression is optional. After assigning a class expression to a variable, the class expression's name string can be obtained through the name property. However, this identifier cannot be accessed outside the class expression's scope.

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 within the class definition block.

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

1. Instantiation#

The constructor function is actually also syntactic sugar; it lets the JS interpreter know that when defining an instance of a class using new, the constructor function should be used for instantiation.

Let’s review the operations performed when calling the constructor function using new:

  • A new object is created in memory: let obj = new Object()
  • The new object's internal [[Prototype]] is assigned to the constructor's prototype: obj.__proto__ = constructor.prototype;
  • The this inside the constructor function points to this new object
  • The internal code of this constructor function is executed
    • The above two steps are equivalent to let res = constructor.apply(obj, args)
  • If the constructor function returns a non-null object, then that object is returned res; otherwise, the newly created object obj is returned
    • return typeof res === 'object' ? res : obj;

The parameters passed during class instantiation will serve as constructor function parameters. If no parameters are needed, the parentheses after the class name are also optional, allowing direct use of new Person.

The class constructor will return an object after execution, which will be used as the instantiated object.
Note: If the object returned by the constructor res is a non-null object and this object is unrelated to the newly created object obj in the first step of new, then the newly created object will be garbage collected, and it will not be detectable as related to the class through instanceof, since the prototype pointer has not been modified.

2. What is the essence of a class?#

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

It is a function! As mentioned earlier, it is essentially syntactic sugar, so it behaves like a function.
It can be passed as a parameter just like other object or function references, and can also be immediately instantiated (similar to immediately invoked function expressions).

// Classes can be defined anywhere like functions, for example 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.

  • Instance members
  • Prototype members
  • Class members (static class members)

Instance Members#

In the constructor, properties can be added to the newly created instance through this, and each instance will not share the properties here.

  • ps: Members written directly in the class block will also become instance properties.
class Person { 
	sex = 'female'
	age = 21
	constructor() { 
		// This example first uses an object wrapper type to define a string
		// to test the equality of 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) // female 21

Prototype Members#

Methods defined in the class block will be treated as prototype methods (shared among all instances).

class Person { 
    constructor() { 
        // Everything added to this will exist in different instances
        this.locate = () => console.log('instance'); 
    } 
    // Everything defined in the class block will be 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 ordinary objects:

class Person { 
	sex = 'female'
	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 are prefixed with the static keyword 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 do not explicitly support adding member data to prototypes or classes, it can be done manually 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, it is not recommended to do this; adding mutable data members to prototypes and classes is considered an anti-pattern. Generally, object instances should own data referenced by this.

Iterators and Generator Methods#

Class definition syntax supports defining generator methods on both prototypes 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, a default iterator can be added to make class instances iterable objects.

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 most outstanding features of ES6 is its native support for class inheritance mechanisms, which is syntactic sugar for the previous prototype chain.

Basics of Inheritance#

ES6 classes support single inheritance. Using the extends keyword, a class can inherit from another 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 will reflect the instance or class that calls the corresponding method.
  • The extends keyword can also be used in class expressions.

Constructor, HomeObject, and super()#

  • Methods in derived classes can reference their prototypes using 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 can call the parent class constructor.
  • [[HomeObject]]
    • ES6 adds an internal feature [[HomeObject]] to class constructors and static methods.
    • It points to the object that defines the method, and this pointer is automatically assigned and can only be accessed internally by the JavaScript engine.
  • super will always be defined as the prototype of [[HomeObject]].

When using super, several issues should be noted:

  • super can only be used in derived class constructors and static methods.
  • The super keyword cannot be referenced alone; it must either be used to call the constructor or reference a static method.
  • Calling super() will invoke the parent class constructor and assign the returned instance to this, so this cannot be referenced before calling super().
  • If parameters need to be passed to the parent class constructor, they must be manually passed to super.
  • If no class constructor is defined, super() will be called when instantiating the derived class, and all parameters passed to the derived class will be passed in.
  • If a constructor is explicitly defined in the derived class, it must either call super() within it or explicitly return an object.

Abstract Base Class#

An abstract base class is a class that can be inherited by other classes but will not be instantiated itself. This concept also exists in other languages. Although ECMAScript does not specifically support syntax for such classes, it can be easily implemented using new.target.

  • new.target holds the class or function called with the new keyword.
  • By checking if new.target is the abstract base class during instantiation, it 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 performing checks in the constructor of the abstract base class, it can require that derived classes must define a certain method. Since prototype methods already exist before calling the constructor of the prototype class, they can be checked using the this keyword to see if 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 a random number [0, i+1)
            [this[i], this[j]] = [this[j], this[i]];    // Swap two cards
        } 
    } 
} 
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); // Random new array
a.shuffle(); 
console.log(a); // Random new array

Some methods of built-in types return new instances. By default, the type of the returned instance is consistent with the original instance, but if you want to override this default behavior, you can override the Symbol.species accessor, which determines the class used when creating the returned instance.

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 (Simulated Implementation of Multiple Inheritance)#

Consolidating behaviors from different classes into one class is a common JavaScript pattern. Although ES6 does not explicitly support multiple class inheritance, it can be easily simulated using existing features.

First, note the following two points:

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

The extends keyword can be followed by a JavaScript expression. Any expression that can be resolved to a class or a constructor function is valid. This expression will be 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, a mechanism is needed to implement B inheriting A, C inheriting B, and then Person inheriting C to combine 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 classes introduced in ECMAScript 6 are largely syntactic sugar based on the existing prototype mechanism.
  • The class syntax allows developers to elegantly define backward-compatible classes.
  • Classes can inherit both built-in types and custom types.
  • Classes effectively bridge the gap between object instances, object prototypes, and object classes.
  • Through class mixins, a similar effect to multiple inheritance can be cleverly achieved, but it is not recommended, as “composition over inheritance” is preferred.
Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.