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 thenew
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'sprototype
: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)
- The above two steps are equivalent to
- If the constructor function returns a non-null object, then that object is returned
res
; otherwise, the newly created objectobj
is returnedreturn 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.
- ES6 adds an internal feature
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 tothis
, sothis
cannot be referenced before callingsuper()
. - 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 thenew
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.
- The
- 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.