banner
cos

cos

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

In-depth Journey of Learning JavaScript (1) Objects, Classes, and Object-Oriented Programming

Understanding objects and their creation process, ES6 syntactic sugar, prototype pattern, constructor functions

Understanding prototype chains, constructor theft, composite inheritance, and best practices, etc.

Understanding Objects#

ECMA-262 defines an object as an unordered collection of a set of properties, each property or method has a name to represent it, which can be imagined as a hash table, where values can be data/functions.

  • Example 1
// Created using new Object()
let person = new Object();
person.name = "cosine";
person.age = 29;
person.job = "Software Engineer"; 
person.sayName = function() { 
 console.log(this.name); 
}; 
// Created using object literal
let person = {
  name: 'cosine',
  age: 29, 
  job: "Software Engineer", 
  sayName() { 
    console.log(this.name); 
  }
}

The two objects above are equivalent, with the same properties and methods.
You might consider: What happens during the new process? This will be mentioned later.

Property Types#

ECMA-262 uses some internal features to describe the characteristics of properties. These features are defined by the specifications for the JavaScript implementation engine. Therefore, developers cannot directly access these features in JavaScript. To identify a feature as an internal feature, the specification encloses the feature's name in two square brackets, such as [[Enumerable]].
Properties are divided into two types: data properties and accessor properties.

Data Properties#

Contain a location to store data values. Values are read from this location and can also be written to this location. Data properties have 4 characteristics that describe their behavior.

  • [[Configurable]] Configurable
    • Indicates whether the property can be deleted using delete and redefined.
    • Whether its characteristics can be modified.
    • Whether it can be changed to an accessor property.
    • By default, this characteristic is true for all properties directly defined on the object.
  • [[Enumerable]] Enumerable
    • Indicates whether the property can be returned by a for-in loop.
    • Default: true.
  • [[Writable]] Writable
    • Indicates whether the property's value can be modified.
    • Default: true.
  • [[Value]] Writable
    • Contains the actual value of the property.
    • This is the location for reading and writing the property value mentioned earlier.
    • Default: undefined.

After explicitly adding properties to an object as in the previous example, [[Configurable]], [[Enumerable]], and [[Writable]] will be set to true, while the [[Value]] characteristic will be set to the specified value.

Object.defineProperty() Method#

To modify the default characteristics of a property, you must use the Object.defineProperty() method. This method takes 3 parameters:

  • obj The object to which the property is to be added.
  • prop The property name or Symbol to be defined or modified.
  • descriptor The property descriptor to be defined or modified.

The setting method is as follows:

// Object.defineProperty() sets the property
let person = {};
Object.defineProperty(person, "name", {
  writable: false,    // Look here! Cannot be modified
  value: "cosine"
});
console.log(person.name); // cosine
person.name = "NaHCOx";   // Attempt to modify 
console.log(person.name); // Modification ineffective print: cosine 

A property named name was created and assigned a read-only value, so this property's value cannot be modified.

  • In non-strict mode, attempting to reassign this property will be ignored.
  • In strict mode, attempting to modify the read-only property will throw an error.

Similar rules apply to creating non-configurable properties.
Setting configurable to false means that this property cannot be deleted from the object.

Note: Once a property is defined as non-configurable, it cannot be changed back to configurable!!

Calling Object.defineProperty() again and modifying any non-writable property will result in an error.

When calling Object.defineProperty(), if the values of configurable, enumerable, and writable are not specified, they all default to false.

Accessor Properties#

Accessor properties do not contain data values. Instead, they contain a getter (getter) function and a setter (setter) function, although these two functions are not required.

  • When reading an accessor property, the getter function is called, returning a valid value.
  • When writing to an accessor property, the setter function is called with the new value, and this function must decide what modifications to make to the data.

Accessor properties have 4 characteristics that describe their behavior:

  • [[Configurable]]: Indicates the property
    • Whether it can be deleted and redefined using delete.
    • Whether its characteristics can be modified.
    • Whether it can be changed to a data property.
    • Default: true.
  • [[Enumerable]]: Indicates whether the property can be returned by a for-in loop. Default: true.
  • [[Get]]: Getter function, called when reading the property. Default value is undefined.
  • [[Set]]: Setter function, called when writing to the property. Default value is undefined.

Note that the default values of the above properties indicate the defaults when directly defining on the object; if using Object.defineProperty(), then those not defined are undefined.

Here is an example:

// Accessor property definition
// Define an object containing a pseudo-private member year_ and a public member edition 
let book = { 
  year_: 2017, 
  edition: 1 
};
Object.defineProperty(book, "year", {
  get() {
    return this.year_;
  },
  set(newVal) {
    if(newVal > 2017) {
      this.year_ = newVal;
      this.edition += newVal - 2017;
    }
  }
});
book.year = 1999
console.log(book.year);   // 2017
console.log(book.edition);  // 1
book.year = 2018
console.log(book.year);   // 2018
console.log(book.edition);  // 2

The object book has two default properties: year_ and edition.
The underscore in year_ is commonly used to indicate that this property is not intended to be accessed from outside the object methods.
Another property year is defined as an accessor property, where:

  • The getter simply returns the value of year_.
  • The setter performs some calculations to determine the correct version (edition).

Thus, changing the year property to 2018 causes year_ to become 2018, and edition to become 2. Attempting to change it to 1999 will not result in any change, which is a typical use case for accessor properties, where setting a property value causes some other changes to occur.

The getter and setter functions do not necessarily have to be defined.

  • Defining only the getter function means the property is read-only, and attempts to modify the property will be ignored. In strict mode, attempting to write to a property that only has a getter function will throw an error.
  • Similarly, a property defined with only a setter is not readable; in non-strict mode, reading it will return undefined, while in strict mode, it will throw an error.

Other Property Definition Functions#

You can also define multiple properties using Object.defineProperties(), use Object.getOwnPropertyDescriptor() to obtain the property descriptor of a specified property, and Object.getOwnPropertyDescriptors() to obtain the property descriptors of each own property and return them in a new object.

Merging Objects#

Copy all properties from the source object to the target object; this operation is also known as "mixing in," as the target object is enhanced by mixing in properties from the source object.
The Object.assign() method is used to assign the values of all enumerable properties/own properties from one or more source objects to the target object, returning the target object.

const target = { a: 1, b: 2 };
const source = { b: 4, c: 5 };

const returnedTarget = Object.assign(target, source);

console.log(target);
// expected output: Object { a: 1, b: 4, c: 5 }

console.log(returnedTarget);
// expected output: Object { a: 1, b: 4, c: 5 }

Object.assign() actually performs a shallow copy, only copying the references of the objects.

  • If multiple source objects have the same property, the last copied value will be used.
  • Values obtained from source object accessor properties, such as getter functions, will be assigned to the target object as a static value. That is: getter and setter functions cannot be transferred between two objects.
  • If an error occurs during the assignment, the operation will stop and exit, throwing an error. There is no concept of "rollback" for previously assigned values, so it is a best-effort method that may only complete partial copying.

Object Identity and Equality Judgment#

Before ES6, there were some situations where using the === operator for judgment would fail, such as:

// These situations behave differently across different JavaScript engines but are still considered equal
console.log(+0 === -0); // true 
console.log(+0 === 0); // true 
console.log(-0 === 0); // true 
// To determine the equality of NaN, the extremely annoying isNaN() must be used 
console.log(NaN === NaN); // false 
console.log(isNaN(NaN)); // true 

The ES6 specification improved these situations by introducing the Object.is() method to determine whether two values are the same value.

// Correct equality/inequality judgment for 0, -0, +0
console.log(Object.is(+0, -0)); // false 
console.log(Object.is(+0, 0)); // true 
console.log(Object.is(-0, 0)); // false 
// Correct NaN equality judgment
console.log(Object.is(NaN, NaN)); // true

To check more than two values, you can recursively use equality transitivity.

function recursivelyCheckEqual(x, ...rest) {
  return Object.is(x, rest[0]) && 
  (rest.length < 2 || recursivelyCheckEqual(...rest));
}

ES6 Syntactic Sugar#

ECMAScript 6 introduced many extremely useful syntactic sugar features for defining and manipulating objects. These features do not change the behavior of existing engines but greatly enhance the convenience of handling objects.

Property Name Shorthand#

When adding variables to an object, you often find that the property names and variable names are the same. In this case, you can use the variable name without writing a colon; if no variable with the same name is found, a ReferenceError will be thrown.
For example:

let name = 'cosine'; 
let person = { name: name }; 
console.log(person); // { name: 'cosine' }
// Using syntactic sugar is equivalent to the above
let person = { name }; 
console.log(person); // { name: 'cosine' }

Code minifiers will retain property names across different scopes to prevent references from being lost.

Computed Properties#

You can directly name properties dynamically in object literals:

const nameKey = 'name'; 
const ageKey = 'age'; 
const jobKey = 'job'; 
let uniqueToken = 0; 
function getUniqueKey(key) { 
 return `${key}_${uniqueToken++}`; 
} 
let person = { 
 [nameKey]: 'cosine', 
 [ageKey]: 21, 
 [jobKey]: 'Software engineer',
 // Can also be an expression!
 [getUniqueKey(jobKey+ageKey)]: 'test'
}; 
console.log(person); 
// { name: 'cosine', age: 21, job: 'Software engineer', jobage_0: 'test' }

Shorthand Method Names#

Just look:

let person = { 
let person = { 
    // sayName: function(name) { // Old
    //     console.log(`My name is ${name}`); 
    // } 
    sayName(name) { // New
        console.log(`My name is ${name}`); 
    } 
}; 
person.sayName('Matt'); // My name is Matt 

Shorthand method names also apply to getter and setter functions, and shorthand method names are compatible with computed property keys, laying the groundwork for classes discussed later.

Object Destructuring#

// Object destructuring
let person = { 
    name: 'cosine', 
    age: 21 
}; 
let { name: personName, age: personAge } = person; 
console.log(personName, personAge); // cosine 21 
// Allow variables to directly use property names, define default values; if not defined, the default value will be undefined
let { name, age, job = 'test', score } = person; 
console.log(name, age, job, score); // cosine 21 test undefined

Destructuring internally uses the function ToObject() (which cannot be accessed directly in the runtime environment) to convert the source data structure into an object.
This means that in the context of object destructuring, primitive values are treated as objects. That is: null and undefined cannot be destructured, or it will throw an error.

let { length } = 'foobar'; 
console.log(length); // 6 
let { constructor: c } = 4; 
console.log(c === Number); // true 
let { _ } = null; // TypeError 
let { _ } = undefined; // TypeError

To destructure and assign values to pre-declared variables, the assignment expression must be enclosed in parentheses.

let personName, personAge; 
let person = { 
 name: 'cosine', 
 age: 21
}; 
({name: personName, age: personAge} = person); 
console.log(personName, personAge); // cosine 21

1. Nested Destructuring#

Destructuring has no restrictions on referencing nested properties or assignment targets. Therefore, you can use destructuring to copy object properties (shallow copy).

let person = { 
    name: 'cosine', 
    age: 21, 
    job: { 
        title: 'Software engineer' 
    } 
}; 
// Declare a title variable and assign the value of person.job.title to it
let { job: { title } } = person; 
console.log(title); // Software engineer 

Nested destructuring cannot be used if the outer property is not defined. This applies to both the source object and the target object.

2. Partial Destructuring#

Destructuring assignment involving multiple properties is an output-agnostic sequential operation. If a destructuring expression involves multiple assignments, if the first assignment succeeds and the subsequent assignment fails (attempting to destructure undefined || null), then the entire destructuring assignment will only complete partially.

3. Parameter Context Matching#

Destructuring assignment can also be performed in function parameter lists. Destructuring assignment for parameters does not affect the arguments object but allows declaring local variables to be used within the function body:

let person = { 
    name: 'cosine', 
    age: 21
}; 
function printPerson(foo, {name, age}, bar) { 
    console.log(arguments); 
    console.log(name, age); 
} 
function printPerson2(foo, {name: personName, age: personAge}, bar) { 
    console.log(arguments); 
    console.log(personName, personAge); 
} 
printPerson('1st', person, '2nd'); 
// ['1st', { name: 'cosine', age: 21 }, '2nd'] 
// 'cosine' 21 
printPerson2('1st', person, '2nd'); 
// ['1st', { name: 'cosine', age: 21 }, '2nd'] 
// 'cosine' 21 

Creating Objects#

Starting with ES6, classes and inheritance are officially supported, but this support is essentially syntactic sugar that encapsulates ES5.1 constructor functions and prototype inheritance.

Factory Pattern#

Some design patterns were mentioned in the blog on design patterns (Frontend Design Patterns Application Notes), and the factory pattern is also a widely used design pattern that provides an optimal way to create objects. In the factory pattern, we do not expose the creation logic to the client when creating objects, and we point to the newly created object using a common interface.

function createPerson(name, age, job) { 
    let o = new Object(); 
    o.name = name; 
    o.age = age; 
    o.job = job; 
    o.sayName = function() { 
        console.log(this.name); 
    }; 
    return o; 
} 
let person1 = createPerson("cosine", 21, "Software Engineer"); 
let person2 = createPerson("Greg", 27, "Doctor"); 
console.log(person1);   // { name: 'cosine', age: 21, job: 'Software Engineer', sayName: [Function (anonymous)] }
person1.sayName();  // cosine
console.log(person2); // { name: 'Greg', age: 27, job: 'Doctor', sayName: [Function (anonymous)] }
person2.sayName();  // Greg

This pattern can solve the problem of creating multiple similar objects but does not solve the object identity problem (i.e., what type the newly created object is).

Constructor Function Pattern#

Custom constructor functions define properties and methods for their own object types in the form of functions.

function Person(name, age, job){ 
    this.name = name; 
    this.age = age; 
    this.job = job; 
    this.sayName = function() { 
        console.log(this.name); 
    }; 
} 
let person1 = new Person("cosine", 21, "Software Engineer"); 
person1.sayName(); // cosine
  • No explicit object creation.
  • Properties and methods are directly assigned to this.
  • No return.
  • To create an instance of Person, the new operator should be used.

What Happens During the new Process?#

Key point: Using new to call a constructor function performs the following operations:

  1. A new object is created in memory.
  2. The new object's internal [[Prototype]] is assigned to the constructor function's prototype property.
  3. The this inside the constructor function points to this new object.
  4. The code inside the constructor function is executed (adding properties to the object).
  5. If the constructor function returns a non-null object, that object is returned. Otherwise, the newly created object is returned!

At the end of the previous example, person1 has a constructor property pointing to Person.

console.log(person1.constructor)    // [Function: Person]
console.log(person1.constructor === Person)    // true
console.log(person1 instanceof Object); // true 
console.log(person1 instanceof Person); // true 

Defining a custom constructor function can ensure that instances are identified as a specific type, which is a significant advantage over the factory pattern.
person1 is also considered an instance of Object because all custom objects inherit from Object (which will be discussed later).

Note the following points:

  1. Constructor functions are also functions: Any function called with the new operator is a constructor function, while a function called without the new operator is a regular function.
  2. The main issue with constructor functions: The methods defined will be created anew on each instance, so although the functions have the same name on different instances, they are not equal, and since they do the same thing, there is no need to define two different Function instances.

The this object can defer the binding of functions to objects until runtime, so the function definitions can be moved outside the constructor function.
While this solves the problem of redundant function definitions with the same logic, it also muddles the global scope, as that function can only be called on one object. If this object needs multiple methods, then multiple functions must be defined in the global scope. This leads to the new problem that custom type reference code cannot be well grouped together. This new problem can be solved through the prototype pattern.

Prototype Pattern#

  • Every function creates a prototype property pointing to the prototype object.
  • Properties and methods defined on the prototype object can be shared by all object instances.
  • Values originally assigned to object instances in the constructor function can be directly assigned to their prototype.

1. Understanding Prototypes#

  • Whenever a function is created, a prototype property is created for that function according to specific rules, pointing to the prototype object.
  • All prototype objects will have a property named constructor, pointing back to the associated constructor function.
    • For example, Person.prototype.constructor points to Person.
  • Then, additional properties and methods can be added to the prototype object through the constructor function.
  • By default, the prototype object will only have the constructor property, and all other methods inherit from Object.
  • Each time a new instance is created by calling the constructor function, its internal [[Prototype]] pointer will be assigned to the constructor function's prototype object.
  • There is no standard way to access this [[Prototype]] feature in scripts, but Firefox, Safari, and Chrome expose the __proto__ property on each object, allowing access to the object's prototype.

A normal prototype chain will terminate at the Object prototype object, and the prototype of Object is null.

console.log(Person.prototype.__proto__ === Object.prototype); // true 
console.log(Person.prototype.__proto__.constructor === Object); // true 
console.log(Person.prototype.__proto__.__proto__ === null); // true 

Constructor functions, prototype objects, and instances are 3 completely different objects:

console.log(person1 !== Person); // true 
console.log(person1 !== Person.prototype); // true 
console.log(Person.prototype !== Person); // true

Instances link to the prototype object through __proto__, and constructor functions link to the prototype object through the prototype property:

console.log(person1.__proto__ === Person.prototype); // true 
console.log(person1.__proto__.constructor === Person); // true 

Instances created by the same constructor function share the same prototype object, and instanceof checks whether the prototype of the specified constructor function appears in the instance's prototype chain:

console.log(person1.__proto__ === person2.__proto__); // true 
console.log(person1 instanceof Person); // true 
console.log(person1 instanceof Object); // true 
  • The isPrototypeOf() method is used to test whether an object exists in another object's prototype chain.
    • Unlike the instanceof operator. In the expression object instanceof AFunction, the prototype chain of object is checked against AFunction.prototype, not AFunction itself.
  • The getPrototypeOf() method returns the value of the internal [[Prototype]] property of the parameter.
    • It can be conveniently used to obtain an object's prototype, which is particularly important when implementing inheritance through prototypes.
  • The setPrototypeOf() method can write a new value to the instance's private [[Prototype]] property.
    • It can rewrite an object's prototype inheritance relationship.
console.log(Person.prototype.isPrototypeOf(person1)); // true 
console.log(Person.prototype.isPrototypeOf(person2)); // true
console.log(Object.getPrototypeOf(person1) == Person.prototype); // true 
console.log(Object.getPrototypeOf(person1).name); // "cosine"

Object.setPrototypeOf() can severely impact code performance. Mozilla documentation states clearly: "The impact of modifying inheritance relationships is subtle and profound across all browsers and JavaScript engines. This impact is not just about executing the Object.setPrototypeOf() statement, but it will involve all code that accesses those objects that modified [[Prototype]].

To avoid the performance degradation that may result from using Object.setPrototypeOf(), you can create a new object using Object.create() while specifying its prototype:

let biped = { 
 numLegs: 1
}; 
let person = Object.create(biped); 
person.name = 'cosine'; 
console.log(person.name); // cosine
console.log(person.numLegs); // 1
console.log(Object.getPrototypeOf(person) === biped); // true 

2. Prototype Hierarchy#

  • When accessing properties through an object, the property name is searched for.
  • If this property is found on the instance, the corresponding value is returned.
  • If not found on the instance, it will go to the prototype object, and if the property is found on the prototype object, the corresponding value is returned.
  • If not found on the prototype object, it will continue to search up the prototype chain... and so on, until found.
  • This is the principle by which prototypes are used to share properties and methods among multiple object instances.

Note the following points:

  • Although you can read values from the prototype object through instances, you cannot overwrite values on the prototype object through instances.
  • If a property with the same name is added to the instance, this property will be created on the instance, and it will shadow the property on the prototype object.
  • Using the delete operator can completely remove this property from the instance, allowing the identifier resolution process to continue searching the prototype object.

hasOwnProperty()#

The hasOwnProperty() method is used to determine whether a property exists on the instance or on the prototype object. This method returns true when the property exists on the object instance that calls it.

function Person() {} 
Person.prototype.name = "cosine"; 
Person.prototype.age = 21; 
Person.prototype.job = "Software Engineer"; 
Person.prototype.sayName = function() { 
	console.log(this.name); 
}; 
let person1 = new Person(); 
let person2 = new Person(); 

console.log(person1.hasOwnProperty("name")); // false 
person1.name = "Khat"; 		// Added name to the instance, shadowing the prototype's name

console.log(person1.name); // "Khat", from the instance
console.log(person1.hasOwnProperty("name")); // true 

console.log(person2.name); // "cosine", from the prototype
console.log(person2.hasOwnProperty("name")); // false 

delete person1.name; 
console.log(person1.name); // "cosine", from the prototype
console.log(person1.hasOwnProperty("name")); // false

3. Prototypes and the in Operator#

The in operator can be used in two ways:

  • Using the in operator alone.
  • Using it in a for-in loop.

When used alone, it returns true when accessing a specified property through an object, regardless of whether the property is on the instance or on the prototype.

console.log(person1.hasOwnProperty("name")); // false 
console.log("name" in person1); // true 

person1.name = "cosine"; 
console.log(person1.name); // "Khat", from the instance
console.log(person1.hasOwnProperty("name")); // true 
console.log("name" in person1); // true 

console.log(person2.name); // "cosine", from the prototype
console.log(person2.hasOwnProperty("name")); // false 
console.log("name" in person2); // true 

delete person1.name; 
console.log(person1.name); // "cosine", from the prototype
console.log(person1.hasOwnProperty("name")); // false 
console.log("name" in person1); // true 

To determine whether a property exists on the prototype, you can use both hasOwnProperty() and in operator together.

function hasPrototypeProperty(object, name){ 
	return !object.hasOwnProperty(name) && (name in object); 
} ;

When using the in operator in a for-in loop, all properties that can be accessed and enumerated will be returned, including enumerable instance properties and prototype properties (excluding shadowed prototype properties and non-enumerable properties). Additionally, the Object.keys() method can obtain all enumerable instance properties of an object, returning an array of strings that includes all enumerable property names of that object.

If you want to obtain all instance properties (including non-enumerable ones), you can use Object.getOwnPropertyNames().

4. Property Enumeration Order#

  • Enumeration order is uncertain:
    • for-in loop
    • Object.keys()
    • Depends on the JavaScript engine, which may vary by browser.
  • Enumeration order is certain:
    • Object.getOwnPropertyNames()
    • Object.getOwnPropertySymbols()
    • Object.assign()
    • First enumerates numeric keys in ascending order, then enumerates string and symbol keys in insertion order.

Object Iteration#

ECMAScript 2017 introduced two static methods for converting object content into serialized (iterable) formats. These two static methods, Object.values() and Object.entries(), take an object and return an array of their contents.

  • Object.values() returns an array of the object's values.
  • Object.entries() returns an array of key-value pairs.
  • Non-string properties will be converted to strings for output, while symbol properties will be ignored. These two methods perform a shallow copy of the object.

1. Other Prototype Syntax#

To reduce code redundancy and visually better encapsulate prototype functionality, directly rewriting the prototype with an object literal containing all properties and methods has become a common practice.

function Person() {} 
Person.prototype = {
	name: "cosine", 
	age: 21, 
	job: "Software Engineer", 
	sayName() { 
	console.log(this.name); 
	} 
}; 

However, there is a problem: after rewriting in this way, the constructor property of Person.prototype no longer points to Person. When creating a function, its prototype object is also created, and the constructor property of this prototype is automatically assigned. Therefore, we need to explicitly set the value of the constructor.

Person.prototype = { 
	constructor: Person, // Assign value
	name: "cosine", 
	age: 21, 
	job: "Software Engineer", 
	sayName() { 
		console.log(this.name); 
	} 
}; 

Restoring the constructor property in this way will create a property with [[Enumerable]] set to true. However, the native constructor property is by default non-enumerable. Therefore, if you are using a JavaScript engine compatible with ECMAScript, you may need to use the Object.defineProperty() method to define the constructor property.

2. The Dynamism of Prototypes#

Because the process of searching for values on the prototype is dynamic, any modifications made to the prototype object will reflect on instances even if the instances existed before the modifications:

function Person() {} 
let friend = new Person(); 
Person.prototype = { 
	constructor: Person, 
	name: "cosine", 
	age: 21, 
	job: "Software Engineer", 
	sayName() { 
		console.log(this.name); 
	} 
}; 
friend.sayName(); // Error

3. Prototypes of Native Objects#

  • The prototype pattern is the model for implementing all native reference types.
  • All native reference type constructors (including Object, Array, String, etc.) define instance methods on their prototypes.
    • Methods like sort() for array instances are defined on Array.prototype.
    • Methods like substring() for string wrapper objects are defined on String.prototype.
  • You can obtain references to all default methods through the prototypes of native objects, and you can define new methods for instances of native types (though this is not recommended).

4. Issues with Prototypes#

  • Weakens the ability to pass initialization parameters to the constructor function, leading to all instances obtaining the same property values by default.
  • The main issue arises from its shared characteristics; generally, different instances should have their own copies of properties, while properties added to the prototype will reflect across different instances.

Inheritance Implementation#

Prototype Chain#

  • ECMA-262 defines the prototype chain as the primary inheritance method in ECMAScript.
  • The basic idea is to inherit properties and methods from multiple reference types through prototypes.

To recap: each constructor function has a prototype object, which points to the prototype object through prototype, the prototype has a constructor property pointing back to the constructor function, and instances have an internal pointer __proto__ pointing to the prototype.

What if the prototype is an instance of another type? This means that this prototype itself has an internal pointer __proto__ pointing to another prototype object. Correspondingly, the other prototype also has another pointer constructor pointing to another constructor function, thus constructing a prototype chain between instances and prototypes.

The prototype chain extends the prototype search mechanism described earlier. We know that when reading properties on an instance, the property is first searched on the instance. If not found, the prototype of the instance is searched. After implementing inheritance through the prototype chain, the search can go up, searching the prototype of the prototype, until the end of the prototype chain.

1. Default Prototype#

By default, all reference types inherit from Object, which is also achieved through the prototype chain. The default prototype of any function is an instance of Object, meaning this instance has an internal pointer pointing to Object.prototype. This is why custom types can inherit all default methods, including toString(), valueOf(), etc.

2. Prototypes and Inheritance Relationships#

The relationship between prototypes and instances can be determined in two ways.

  • Using the instanceof operator; if an instance's prototype chain contains the corresponding constructor function, instanceof returns true.
console.log(instance instanceof Object); // true 
console.log(instance instanceof SuperType); // true 
console.log(instance instanceof SubType); // true
  • Using the isPrototypeOf() method. As long as the prototype is included in the instance's prototype chain, this method returns true.
console.log(Object.prototype.isPrototypeOf(instance)); // true 
console.log(SuperType.prototype.isPrototypeOf(instance)); // true 
console.log(SubType.prototype.isPrototypeOf(instance)); // true

3. Issues with Prototype Chains#

  • As mentioned when discussing prototype issues, the reference values contained in the prototype will be shared among all instances, which is why properties are typically defined in the constructor function rather than on the prototype.
  • When using prototypes to implement inheritance, the prototype effectively becomes an instance of another type. This means the original instance properties.
function SuperType() { 
	this.colors = ["red", "blue", "green"]; 
} 

function SubType() {} 

// Inherit SuperType 
SubType.prototype = new SuperType(); 

let instance1 = new SubType(); 
instance1.colors.push("black"); 
console.log(instance1.colors); // "red,blue,green,black" 

let instance2 = new SubType(); 
console.log(instance2.colors); // "red,blue,green,black" 

When SubType inherits from SuperType through the prototype, SubType.prototype becomes an instance of SuperType, thus acquiring its own colors property. This is akin to creating a SubType.prototype.colors property. The final result is that all instances of SubType will share this colors property, and modifications to instance1.colors will also reflect on instance2.colors.

  • The second issue is that the subclass cannot pass parameters to the constructor function of the parent type during instantiation. In fact, we cannot pass parameters to the parent class's constructor function without affecting all object instances. Coupled with the previously mentioned issue of reference values in the prototype, this leads to the conclusion that the prototype chain is generally not used alone.

Constructor Theft#

The basic idea is to call the parent class's constructor function within the subclass constructor function. Functions are simple objects that execute code in a specific context, so we can use the apply() and call() methods to execute the constructor function in the context of the newly created object.

function SuperType() { 
	this.colors = ["red", "blue", "green"]; 
} 
function SubType() { 
	// Inherit SuperType !!
	SuperType.call(this); 
} 
let instance1 = new SubType(); 
instance1.colors.push("black"); 
console.log(instance1.colors); // "red,blue,green,black" 

let instance2 = new SubType(); 
console.log(instance2.colors); // "red,blue,green"

By using the call() (or apply()) method, the SuperType constructor function is executed in the context of the new object being created for SubType. This is equivalent to running all the initialization code in the SuperType() function on the new SubType object! Thus, each instance has its own properties.

1. Passing Parameters#

Using constructor theft allows passing parameters from the subclass constructor function to the parent class constructor function.

function SuperType(name){ 
	this.name = name; 
} 
function SubType() { 
	// Inherit SuperType and pass parameters
	SuperType.call(this, "cosine"); 
	// Instance property
	this.age = 21; 
} 
let instance = new SubType(); 
console.log(instance.name); // "cosine"; 
console.log(instance.age); // 21

2. Main Issues#

The main drawback of constructor theft, as well as the issue with using constructor function patterns to define types:

  • Methods must be defined within the constructor function. Therefore, functions cannot be reused.
  • Subclasses cannot access methods defined on the parent class's prototype, meaning all types can only use the constructor function pattern.

Due to these issues, constructor theft is generally not used alone.

Composite Inheritance#

Composite inheritance combines the advantages of prototype chains and constructor theft.

  • The basic idea is to inherit properties and methods from the prototype using the prototype chain, while inheriting instance properties through constructor theft.
  • This allows methods to be defined on the prototype for reuse while ensuring that each instance has its own properties.
function SuperType(name){ 
	this.name = name; 
	this.colors = ["red", "blue", "green"]; 
} 
SuperType.prototype.sayName = function() { 
	console.log(this.name); 
}; 
function SubType(name, age){ 
	// Inherit properties
	SuperType.call(this, name); 
	this.age = age; 
} 
// Inherit methods
SubType.prototype = new SuperType(); 
SubType.prototype.sayAge = function() { 
	console.log(this.age); 
}; 
let instance1 = new SubType("cosine", 21); 
instance1.colors.push("black"); 
console.log(instance1.colors); // "red,blue,green,black" 
instance1.sayName(); // "cosine"; 
instance1.sayAge(); // 21 
let instance2 = new SubType("NaHCOx", 22); 
console.log(instance2.colors); // "red,blue,green" 
instance2.sayName(); // "NaHCOx"; 
instance2.sayAge(); // 22

Prototype-based Inheritance#

Applicable scenarios: When you already have an object and want to create a new object based on it. You need to pass this object to Object.create(), and then modify the returned object as appropriate.

let person = { 
	name: "Nicholas", 
	friends: ["Shelby", "Court", "Van"] 
}; 
let anotherPerson = Object.create(person, { 
	name: { 
		value: "Greg" 
	} 
}); 
console.log(anotherPerson.name); // "Greg"

This is particularly suitable for the following situations:

  • No need to create a constructor function separately.
  • Need to share information between objects.
  • Note: Properties containing reference values will always be shared among related objects.

Parasitic Inheritance#

A type of inheritance that is quite similar to prototype-based inheritance is parasitic inheritance.
Create a function that implements inheritance, enhances the object in some way, and then returns this object.
The basic parasitic inheritance pattern is as follows:

function createAnother(original) {
	let clone = Object.create(original);   // Call the constructor function to create a new object
	clone.sayHi = function() {
        console.log(`Hi! I am ${this.name}`);
    };
    return clone;   // Return this object
}
let person = {
    name: "cosine",
    friends: ['NaHCOx', 'Khat']
};
let person2 = createAnother(person);
person2.name = 'CHxCOOH';
person2.sayHi();    // Hi! I am CHxCOOH

This example uses person as the source object and returns a new object with an added sayHi function (which is an enhancement), mainly suitable for scenarios where the focus is on the object and the constructor function and type are not important.

One important point to note is:

  • Adding functions to objects through parasitic inheritance makes the functions difficult to reuse, similar to the constructor function pattern.

Parasitic Composite Inheritance#

Composite inheritance also has efficiency issues, such as the parent class constructor function being called twice.

  • Once when creating the subclass prototype.
  • Once when calling the subclass constructor function.
function SuperType(name){ 
	this.name = name; 
	this.colors = ["red", "blue", "green"]; 
} 
SuperType.prototype.sayName = function() { 
	console.log(this.name); 
}; 
function SubType(name, age){ 
	// Inherit properties
	SuperType.call(this, name); // Second call to the parent class constructor!
	this.age = age; 
} 
// Inherit methods
SubType.prototype = new SuperType(); // First call to the parent class constructor!
SubType.prototype.sayAge = function() { 
	console.log(this.age); 
}; 

Essentially, the subclass's prototype ultimately needs to contain all instance properties of the parent class object, so the subclass constructor function only needs to rewrite its own prototype during execution.

The main idea of parasitic composite inheritance is as follows:

  • Inherit properties through constructor theft.
  • Inherit methods through mixed prototype chains.
    That is, take the parent class prototype and obscure the original constructor with one pointing to the subclass.
function inheritPrototype(subType, superType) {
    let prototype = Object.create(superType.prototype); // Create a copy of the parent class prototype
    prototype.constructor = subType;    // Restore the constructor lost due to prototype rewriting
    subType.prototype = prototype;      // Assign the object
}
function SuperType(name){ 
	this.name = name; 
	this.colors = ["red", "blue", "green"]; 
} 
SuperType.prototype.sayName = function() { 
	console.log(this.name); 
}; 
function SubType(name, age){ 
	// Inherit properties
	SuperType.call(this, name); // Second call to the parent class constructor!
	this.age = age; 
} 
// Inherit methods
- SubType.prototype = new SuperType(); // First call to the parent class constructor!
+ inheritPrototype(SubType, SuperType);	// Change to calling this function
SubType.prototype.sayAge = function() { 
	console.log(this.age); 
}; 

This avoids unnecessary multiple calls to the parent class constructor function while ensuring that the prototype chain remains unchanged, making it the best pattern for reference type inheritance.

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.