刨析 JavaScript 模拟面向对象的内部机制
1. 构造对象的方法
1.1 通过字⾯量构造
let obj = { name: "jcLee95", birthday: "07-30" };
1.2 通过Object构造器构造
let obj = new Object(); obj.name = "jcLee95"; obj.birthday = "07-30";
1.3 通过原型构造
let obj = Object.create({ name: "jcLee95", birthday: "07-30" });
1.4 函数的构造调用
JavaScript 始终不是一个完全的面向对象编程语言,更多地像是在模拟面向对象地编程方式,原型链、构造调用等都是实现其面向对象地重要技术环节之一。构造调用 在形势上看,是使用 new
语法糖的函数调用,本质上是JavaScript函数的一种调用方式,用于实现对基于类面向对象编程语言中类的实例化过程。
这里说关键字 new是一个 语法糖 是因为,它其实完全就是一些列数据变换过程的简写,仅此而已。你完全可以 自己手动实现一个 new 函数,这在本章之后的内容中会介绍。实事上只要你愿意在其很多面向过程的语言里面也可以来模拟,比如 C语言,这就是至今有大佬认不为 JavaScript 能算面向对象的语言的原因,当然更不存在强面向对象语言中一切皆可对象的说法,因为它有很多东西压根就不是对象,如一些类型的字面量。
1.4.1 从ES6 class 构造调用说起
在ES6的class
语法糖中,构造函数名用constructor
表示,比如:
class Person { constructor(name, birthday) { this.name = name; this.birthday = birthday; } }
虽然本质不同,但用法和 Java 等语言中的类比较类似,如果你要创建 Person 类的实例,应该这样子:
let jack = new Person("jcLee95", "07-30"); console.log(jack);
Out[]
:
Person { name: 'jcLee95', birthday: '07-30' }
1.4.2 在 ES6规范 出现之前的 构造函数
更长的一段里 JavaScript 中是没有 class 语法糖 的,只能通过原始的 new 加 函数(构造函数)的方式来进行调用。例如:
// 相当于 class 语法中的 constructor 函数。 function Person(name, birthday) { this.name = name; this.birthday = birthday; }
这与基于 class 的写法创建了一个一样的对象,因此对他们进行相同的调用将产生一样的效果:
let jack = new Person("jcLee95", "07-30"); console.log(jack);
Out[]
:
Person { name: 'jcLee95', birthday: '07-30' }
我们的思路始终是很清晰的那就是 从模拟 class 来,到 class 去,这就是我们先对 ES6 中 class 写法进行介绍的原因。我们对构造函数进行大写的原因在于,这是借用了 Java 中 class 的习惯:构造方法 与 类 同名,比如:
// java 中的构造方法 public class Person{ String name; String birthday; // 构造方法与类同名 public Person(String name, String birthday){ this.name = name; this.birthday = birthday; } }
但事实上,在 JavaScript 中,你可以使用 任意合法的标识符 作为构造函数名,虽然我们不推荐这样做。
2. 原型 与 原型链 的概念
2.1 原型
2.1.1 原型的概念
原型(prototype) 是 JavaScript 对象 上的 一个 特殊 属性,用于 共享数据,它被称作 原型对象。原型 来源于 JavaScript 函数 ,我们每创建一个函数都有一个原型(prototype)属性 。因此,不论你使用将要把某个任意地 JavaScript 函数用作构造函数,比如以下地写法总是可以的:
function Person() {} Person.prototype.name = "jcLee95"; Person.prototype.birthday = "07-30"; console.log(Person.prototype);
Out[]
:
{ name: 'jcLee95', birthday: '07-30' }
2.1.2 关于 this 上下文
我们通过 函数名.prototype
的方式,可以对一个函数 原型对象 中的属性进行修改,但是 只有在使用 构造调用一个函数时,才能将函数体内部的 this
动态地绑定到新创建的一个对象的上下文中。
这里的区别在于:
(1)函数在没有发生构造调用的情况下
- 不会新创建一个对象
{}
作为被被构造出来的对象; - this 在全局执行环境中(在任何函数体外部)都指向 全局对象,如在浏览器中,
window 对象
同时也是全局对象;严格模式下将有所不同,函数内部的this
会若无赋值,将一直保持为undefined
。
例如以下代码:
function Person() { console.log("this 1 =",this) } Person(); console.log("this 2 = ",this);
在 nodeJS中结果为:
在浏览器中:
(2)函数在发生构造调用的情况下
这任然是JavaScript对面向对象语言的模拟的具体实现之一。一旦函数发生了构造调用,this
就应该像 Java 等语言的某个类中的this
一样,将指向本类。 JavaScript 中,构造调过程中被创建的一个新对象就是模拟 Java 中被声明的一个类所代表的对象,这就不难理解:JavaScript 构造调用中,新对象将作为构造函数中的this
的上下文,例如:
// 构造调用中的 this ,指向构造函数数所构造的类实例,它是一个在构造调用过程中创建的新的对象 function Person(name, birthday) { this.name = name; this.birthday = birthday; console.log(this); } new Person("jcLee95", "07-30");
也可以使用 class 语法糖:
// 使用 ES6 class 语法糖 的等效方式 class Person { constructor(name, birthday) { this.name = name; this.birthday = birthday; console.log(this); } } new Person("jcLee95", "07-30");
输出的结果都为:
Out[]
:
Person { name: 'jcLee95', birthday: '07-30' }
可以看到, this
已经不再是普通调用下的那个this
。
2.1.3 区分 prototype
、[[Prototype]]
和 __proto__
前面我们已经说过,prototype
是任意函数的一个属性,不一定是构造函数上的。
[[Prototype]]
可以认为是一种连接方式,用于链接实例和构造函数原型。 构造调用 过程中创建了一个新的对象,即实例对象。而[[Prototype]]
就是在这个新对象创建后,该对象上用于 指向发生构造调用的函数(构造函数)的原型(即构造函数的prototype
属性)的指针,称作 原型指针。
需要指出的是,一些资料不区分地将 prototype
和 [[Prototype]]
都称作原型,这不太准确,也容易导致初学者误解概念。
[[Prototype]]
来源于 ECMA-262规范中的定义,但实际在很多浏览器的实现中,为每个对象都提供了一个 __proto__
的指针,它就相当于 [[Prototype]]
,如最主流的chrome、Firefox、Safari浏览器。
2.1.4 使用函数的方式实现 new
(即所谓手写 new)
篇初已经指出构造调用中的new 不过是一个语法糖,用于模拟面向对象的类。我们介绍到这里已经陆续讲清楚了 JavaScript 中构造调用的各个环节的原理。现在只需要归纳 new 关键字在实际作用在一个函数时发送构造调用的具体步骤:
- (1)创建一个空的简单JavaScript对象(即
{}
); - (2)为步骤1新创建的对象添加属性
__proto__
,将该属性链接至构造函数的原型对象 ; - (3)将(1)中新创建的对象作为
this
的上下文 ; - (4)如果该函数没有返回对象,则返回
this
。
为了加深读者对于这几个过程的理解,我们可以通过函数的形式自己实现一个与关键字 new 有着相同功能的函数:
// `myNew(cstrct, ...params)` be equal to `new cstrct(...params)` function myNew(cstrct, ...params){ // 1. create a new JavaScript object obj const obj = {}; // 2. Add the attribute `__proto__` to the newly created object (obj), // And link this property to the prototype object of the constructor.; obj.__proto__ = cstrct.prototype; // 3. Take the new object (obj) as the context of this; // 3.1 Temporarily hang the constructor on obj.__proto__ obj.__proto__._func = cstrct; // 3.2 Execute this constructor to get "this" let _ = obj._func(...params); // 3.3 Delete the temporarily mounted attribute `_func` (otherwise this attribute will be added to the instance) delete obj.__proto__._func // 4. If the function does not return an object, that is, null or undefined, it returns this; otherwise, it returns the object. return _ instanceof Object ? _ : obj; }
你可以编写一个函数,使用自己编写的 new 函数调用试试:
// A function used as a constructor function Person(name, birthday) { this.name = name; this.birthday = birthday; console.log(this); } // 构造调用,与使用 new 返回的效果完全一样 myNew(Person, "jcLee95", "07-30")
Out[]
:
Person { name: 'jcLee95', birthday: '07-30' }
2.2 原型链
至此我们已经了解过什么是 原型。那么试想一下,如果我们让一个对象的原型对象(prototype)恰好为另一个类型的实例会怎么样呢?
JavaScript 中⼏乎所有对象都可以访问 其构造器上的原型对象,而 原型对象(prototype)⼜可以访问 它⾃身的构造器上的原型对象(prototype)。以此类推,就像通过原型在原本独立的对象之间手拉着手不再独立,形成了一个 链式结构,即 原型链。
在 JavaScript 中,原型链 是其实现 继承 的关键技术支撑,关于继承的相关内容,我们将在下一章进行介绍。
3. JavaScript 继承 的内部机制
继承 是面向对象编程的基本特点(抽象、封装、继承、多态)。上面讲了这么多,其实主要还是关于对象的创建于原型的。要称得上 面向对象编程,仅有这些是不够的,必须还要有 继承。早期的 JavaScript 的确从传统面向对象语言中借鉴很多,就比如继承。只是由于 JavaScript 自身本没有类的概念,只能通过已有的东西来 模拟继承。
3.1 从 Java 的继承说起
以Java为例,继承的本质作用是 允许创建分层次的类。面向对象编程中的继承表示的是生活中不同事物的所属关系,例如从生物学的意义上看人类是动物的一个子类别,那么可以以动物类所谓人类的父类。
// java 中的继承 // 父类:动物类 public class Animal { private String name; // 动物类(作为父类)的构造函数 public Animal(String myName) { name = myName; } public void eat(){ System.out.println(name+"在干饭"); } public void sleep(){ System.out.println(name+"正在睡觉"); } } // 子类:人类 public class Human extends Animal { private String name; // 人类(作为子类)的构造函数 public Human(name){ // 调用父类的构造函数 super(name); } }
Java 语言中的 super 和 super()
Java语言中:
super
关键字可以来实现对父类成员的访问,用来引用当前对象的父类。super
是一个函数,调用父类中构造器。- 子类是不继承父类的构造方法的,它只是调用(隐式或显式)。
- 如果父类的构造器带有参数,则必须在子类的构造器中显式地通过
super
关键字调用父类的构造器并配以适当的参数列表。 - 如果父类构造器没有参数,则在 子类的构造方法 中不需要使用
super
关键字调用 父类构造方法,系统会自动调用父类的无参构造方法。
在本例中, 人类(Human) 继承于 动物类(Animal)
3.2 ES6 class 语法中的继承
我们之所以要先讲 ES6 中基于 class 语法的继承,是因为该语法糖的用法已经更为接近 JavaScript 创造初要模拟继承的初忠。使用 ES6 的 class 改写上面的 Java 代码为 JavaScript 代码,你会发现形式上看起来是很接近的:
// JavaScript 中的继承(使用ES6 class语法) // 父类:动物类 class Animal { // 动物类(作为父类)的构造函数 Animal(name) { this.name = name; } eat(){ console.log(this.name+"在干饭"); } sleep(){ console.log(this.name+"正在睡觉"); } } // 子类:人类 class Human extends Animal { // 人类(作为子类)的构造函数 Human(String name){ // 调用父类的构造函数 super(name); } }
JavaScript(ES6) 中的 super 和 super()
在 ES6 语法规范中:
super
关键字可以来实现对父类成员的访问,用来引用当前对象的父类。super
是一个函数,调用父类中构造器。如果父类构造器中含有参数,应该按照父类构造器的参数规则传入super()
函数中。- 只能在派生类的构造函数中使用
super()
,如果尝试在非extends
关键字声明的类或函数中使用,则会导致程序抛出错误。 - 在构造函数中访问
this
之前一定要先调用super()
,它 负责初始化 this,因此如果你在调用super()
前访问this
则会导致程序序抛出错误。
例如将上面的程序中子了 Human 的构造函数改为:
class Human extends Animal { // 显示声明了子类(派生类)的构造函数,但没有调用 super() constructor(name){ } }
- 这将导致在你运行程序时引发一个 ReferenceError:
ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor
- 意思是在访问
this
或 从派生构造函数 返回之前,必须调用派生类中的 super() 构造函数。 - 如果你不想在一个派生类的构造函数中调用
super()
,则唯一的方法是让类的构造函数返回一个对象。 - 如果你不显式声明派生类的构造函数,派生类将 “继承” 其父类的构造函数作为自己的构造函数。(实际上不是真实意义上的继承,而是在构造时在其原型链上调用了父类的构造函数)因此如果子类不需要对父类的构造函数进行装饰时,你完全可以选择不写子类的构造函数,在使用时只要按照父类的构造格式进行构造调用即可。
虽然我们这里又要提醒,JavaScript 继承背后的机理与 Java 不同。但是可以看到,ES6 中的 class 继承 语法上还是挺像的。
3.3 ES6 之前 JvaScript 中的继承
3.3.1 JavaScript 继承的特点 与 原型链
继承 是面向对象编程中最最重要的特征之一,被认为是 面向对象的基石。 ES6 标准后新增的伪类(class
语法糖)已经和一般的面向对象语言写起来非常相似了。 但是毕竟 JavaScript 从一开始就是在模拟面向对象编程语言的行为特点,因此在其历史上也出现过很多种继承的实现方案。在 JavaScript 中提供了函数原型,这样我们可以使用原型链来完成实现继承,但这种继承和其它的语言的继承有内在的不同。
以 Java 为例,在Java的继承的过程中 子类 能够且仅能够从 父类 中获得 成员变量、方法(不包括构造方法)、内部类(包括接口、枚举)
例如:
// 动物类 public class Animal { private String name; public Animal(String myName) { name = myName; } public void eat(){ System.out.println(name+" is eating!"); } public void sleep(){ System.out.println(name+"is sleeping!"); } } // 人类 继承于 动物类,可以获得 Animal 类的成员变量、方法、内部类 public class Human extends Animal {; public Human(String name){ super(name); } } public class Run{ public static void main(String[] args) { Human a = new Human("Human"); // 调用人类从动物类继承过来的 eat 方法 a.eat(); } }
编译,运行:
可以看到,虽然Human
类没有定义eat
方法,但是从父类Animal
获得了该方法。
在 Java 的继承中,实际上子类中所获得的方法、成员等,都是父类中相应成员的复制。在子类中重写相应的方法能够覆盖父类中的方法。
那么 JavaScript 呢?还记得这段代码吗:
class Animal{ constructor(name){ this.name = name; } eat(){ console.log(this.name + " is eating!") } sleep(){ console.log(this.name + " is sleeping!") } } class Human extends Animal{ constructor(name,nationality){ super(name); this.nationality = nationality; } think(){ console.log(this.name + " is thinking!") } } let person = new Human("jcLee95", "China"); console.log(person); person.eat(); person.sleep(); person.think();
看起来简直不能和Java说很像,但是在继承的原理上看:
- 子类实例要访问父类的方法,实际上是通过子类实例对象 的 原型指针
__proto__
指向子类构造函数的原型对象(prototype
属性),这个原型对象中,也包含一个原型指针,指向了父类构造函数的原型。 - 事实上,在 JavaScript 中当代码读取某个对象的某个属性中,有一套这样的查询过程,是该语言比较有特点的地方:
- 从对象实例本身的属性开始搜索:
- 如果在实例中找到了具有给定名字的属性,则返回该属性的值;
- 如果在实例中找不到:则继续收缩 原型指针(
__proto__
)所指向的原型对象(prototype
),在该对象中继续查找具有给定名字的属性,如果找到,则返回该属性的值。
3.3.2 刨析原型链继承的过程:手写其详细步骤
3.3.2.1 父类的函数表示
(1)父类的构造函数
function Animal(name){ this.name= name; }
(2)父类的成员
let AnimalPrototypes = { eat:function() { console.log(this.name + " is eating!") }, sleep:function() { console.log(this.name + " is sleeping!") } } for(let i in AnimalPrototypes) { Animal.prototype[i] = AnimalPrototypes[i]; }
说明:
从基本功能上看,这种直接给 prototype
赋值的方法也可以实现,而且看起来更简单:
Animal.prototype = { eat:function() { console.log(this.name + " is eating!") }, sleep:function() { console.log(this.name + " is sleeping!") } }
但是这会覆盖Animal.prototype
原先包含的信息,使得使用Animal
作为构造函数创建实例时,看起来像普通对象:
可以看到这个例子构建的实例,在 NodeJS 中打印成了,{ name: 'animal_instance' }
,不能很好地表示该对象是通过 new
构建的 Animal
类的实例,这有时候在编程中不方便我们调试。
而使用Animal.prototype[i]
这种添加对象中的键值对的方式:
可以看到,打印的实例对象结果为:
Animal { name: 'animal_instance' }
很直观地显示出了这个对象是 Animal
构造函数经过构造调用而创建的 Animal
类的实例。
3.3.2.2 子类的函数表示
(1)子类的构造函数
子类的构造函数中是需要调用父类的构造函数的,使用 ES6的 class 语法时我们是这样写的:
constructor(name,nationality){ super(name); this.nationality = nationality; }
可见分为两个部分,第一个部分是父类的构造函数,它需要传入父类构造函数需要使用的参数,这个例子中是name
,一般来说父类构造时也需要绑定一些参数,两个构造函数中的 this 应该指向同一上下文,因此在子类中调用父类的构造函数一定有动态更改父类构造函数的 this 到之类构造函数当中,这个国产被隐含在 super(...)
之中了。
另外一个部分是子类特有的构造过程,相当于在构造方面子类对父类的扩展。比如这个例子中人类相对于动物类会更关注国籍,因此在完成动物类都有构造后,还要完成人类特有的国籍属性绑定。
本例中,子类(人类)的构造函数可以这样写:
function Human(super_instance, name, nationality){ super_instance._superConstructor.call(this,name); delete super_instance.__proto__._superConstructor; this.nationality = nationality; }
(2)子类的成员
这里我们仅仅是先声明好子类的成员到一个对象,需要在后续的继承函数中将这个对象中的属性逐个添加到子类实例。
let Human_PropMembers = { think : function(){ console.log(this.name + " is thinking!") } }
3.3.2.3 原型继承函数的实现
在这个函数中我们实现代码会更通用和原始一些,不会直接去使用 new
来创建对象的实例,因为直接使用 new
不仅不好显示继承的完整步骤,也不好处理子类父类都含有需要绑定的参数且参数不同的情况。
function extendsNew(superConstructor, subConstructor, superParams, subPatams, subPropMembers) { // Used as the super class instance object to be constructed const super_instance = {}; super_instance.__proto__ = superConstructor.prototype; // Temporarily mount the parent class constructor super_instance.__proto__._superConstructor = superConstructor; // The prototype of the subclass constructor is the parent class instance, that is, let the subclass inherit the parent class. // At this time, this instance of the parent class has not been constructed. // That is, the parent class constructor is called, and the parent class constructor will be called in the subclass constructor. for(let i in super_instance){ subConstructor.prototype[i] = super_instance[i]; } // An object used as an instance of the subclass to be constructed. const sub_instance = {}; sub_instance.__proto__ = subConstructor.prototype; // Add subclass members to the prototype of constructor. for (let i in subPropMembers) { subConstructor.prototype[i] = subPropMembers[i]; } // Constructor temporarily hung on subclasses on subclasses sub_instance.__proto__._subConstructor = subConstructor; // Execute subclass constructor to complete other things of subclass instance construction. // At this time, it should be called in the constructor of the subclass, and the constructor of the parent class, namely `super(...params)`, must be called first. // It is temporarily hung on the instance `super_instance` of the parent class, and should be deleted when it is used up. let _ = sub_instance._subConstructor(super_instance, ...superParams, ...subPatams); // After the subclass instance is constructed, the constructor is no longer needed on the instance, // so we need to delete the constructor on the subclass instance. delete sub_instance.__proto__._subConstructor; return _ instanceof Object ? _ : sub_instance; }
3.3.2.4 完整代码与测试
// by jcLee95 // https://blog.csdn.net/qq_28550263/article/details/126373011 // ----------------------------- Define Animal class ----------------------------- // class Animal's constructor // Which will be used as superclass's constructor function Animal(name){ this.name= name; } for(let i in AnimalPrototypes) { Animal.prototype[i] = AnimalPrototypes[i]; } // 不建议直接使用 Animal.prototype = {...} 的方式,因为这样会覆盖掉隐藏在 父类构造器上的 对象名 let AnimalPrototypes = { eat:function() { console.log(this.name + " is eating!") }, sleep:function() { console.log(this.name + " is sleeping!") } } for(let i in AnimalPrototypes) { Animal.prototype[i] = AnimalPrototypes[i]; } // ----------------------------- Define Human class ----------------------------- // class Human's constructor // Which will be used as subclass's constructor function Human(super_instance, name, nationality){ // Execute other operations required for parent class construction first: super(...params); // 【强调】:这里必须动态更改父类构造函数的 this 上下文与子类的 this 上下文一致 // 否则,在父类构造器中绑定到 this 的数据不会指向子类实例! // 你可以直接调用 JavaScript 函数的 .call 方法,该方法的第一个参数就是要动态改变的 this 上下文 super_instance._superConstructor.call(this,name); delete super_instance.__proto__._superConstructor; // Delete temporarily mounted superclass constructor. // Execute other operations of subclass construction. this.nationality = nationality; } let Human_PropMembers = { think : function(){ console.log(this.name + " is thinking!") } } // ----------------------------- Make Human extends Animal ----------------------------- // Prototype chain inheritance principle (完全手写继承原理) function extendsNew(superConstructor, subConstructor, superParams, subPatams, subPropMembers) { // 用作待构造的父类实例对象 const super_instance = {}; // 父类实例的原型指针指向父类构造函数的原型 super_instance.__proto__ = superConstructor.prototype; // 临时挂载父类构造函数 super_instance.__proto__._superConstructor = superConstructor; // 子类构造器原型 为 父类实例,也就是让子类继承父类,这时候父类的该实例也还没有完成构造,即调用父类构造函数,而父类构造函数将再子类构造函数中调用 // 不建议使用 subConstructor.prototype = super_instance;,因为这会覆盖掉隐藏在 subConstructor 对象名 for(let i in super_instance){ subConstructor.prototype[i] = super_instance[i]; } // 用作待构造的子类实例的对象 const sub_instance = {}; // 子类实例原型指针,指向子类构造器原型 sub_instance.__proto__ = subConstructor.prototype; // 添加子类成员到构造函数的原型 for (let i in subPropMembers) { subConstructor.prototype[i] = subPropMembers[i]; } // 子类实例上临时挂在子类的构造函数 sub_instance.__proto__._subConstructor = subConstructor; // 执行子类构造函数,完成子类实例构造的其它事情。这个时候,在子类的构造函数中应该调用,且必须先调用父类的构造函数,即 super(),它被临时挂在在父类的实例 super_instance 上,用完应该删除 let _ = sub_instance._subConstructor(super_instance, ...superParams, ...subPatams); // 构造子类实例后,实例上不再需要构造函数,因此删除子类实例上的构造函数 delete sub_instance.__proto__._subConstructor; return _ instanceof Object ? _ : sub_instance; } let person = extendsNew(Animal, Human, ["jcLee95"], ["China"], Human_PropMembers); console.log(person); person.eat(); person.sleep(); person.think();
Out[]
:
Human { name: 'jcLee95', nationality: 'China' } jcLee95 is eating! jcLee95 is sleeping! jcLee95 is thinking!