首发于2018年5月7日,最后更新于2019年4月9日。
记录关于JS面向对象的相关细节。
初学者建议先去看JS基础教程ES6手册。部分内容来自网络和MDN。

类(class)与对象(object)

ES5以及之前的面向对象实现方式

没有ES6类class关键字写法的时候,一般这样实现类:

1
2
3
4
5
6
7
8
//定义构造函数
function Person(_name, _age){
this.name = _name;
this.age = _age;
}

//创建实例
let Jack = new Person('Jack', 18);

这里对象的构造流程可以理解为:
Person构造函数把它自身的.prototype拿出来当做this,它在这个this上加了.name.age等一堆属性。这个构造函数没有返回值,使用new指令,即表示用它自身的this来作为返回值。
也因此该构造函数必须使用new,不使用new直接调用得到的是一个undefined,而且在这个例子里会导致nameage泄露成为全局变量。

如果想给这个Person类添加一个名为halo的打招呼的方法,用于输出自己的名字,有几种实现方式:
最简单易懂的:

1
2
3
4
5
6
7
function Person(_name, _age){
this.name = _name;
this.age = _age;
this.halo = function(){
console.log(this.name);
}
}

上面的代码直接在类内定义方法,因为这里使用了匿名函数,这会导致每个对象都独享一个单独的halo方法。
一般来说,类的方法应该是所有对象共享一个的,不应该是每个对象单独持有,因此这种方式不合理。

让类的成员共享同一个方法、或是共享同一个属性,可以这样做:
将需要共享的属性抽离出来,放置在类外:

1
2
3
4
5
6
7
8
function halo(){
console.log(this.name);
}
function Person(_name, _age){
this.name = _name;
this.age = _age;
this.halo = halo;
}

或者是将需要共享的属性定义在构造函数的原型上,因为构造函数初始的this就是用的它的.prototype原型,所以所有构造出的对象都共享这一个方法:

1
2
3
4
5
6
7
function Person(_name, _age){
this.name = _name;
this.age = _age;
}
Person.prototype.halo = function(){
console.log(this.name);
}

如果想要实现继承,则需要分别实现属性的继承(对象上)以及方法(构造函数原型)的继承。
代码可以这样写:

1
2
3
4
5
6
7
8
9
//属性继承,步骤1
function Student(_name, _age, _school){
Person.call(this, _name, _age);
this.school = _school;
}
//原型继承,用来继承方法,步骤2
Student.prototype = Object.create(Person.prototype);
//构造器可不能跟着变成Person了,要改回来,步骤3
Student.prototype.constructor = Student;

这种使用构造函数来实现继承的方式,各个步骤的原理如下:
1、首先调用把自身的this交给Person这个构造函数,这样来继承Person的属性;
2、然后把Person.prototype对象复制一份拿过来当做自己的.prototype,这样来继承Person的方法;
3、因为上一步操作的把prototype上的.constructor也一起改了,这个属性表示该原型的构造函数是哪个。Student类的构造器肯定是Student构造函数本身,因此将它设置为Student,如果省略这一步,别人对一个Student对象使用Object.create()构造出来的对象会是一个Person对象。

这些面向对象的实现方式都非常复杂,并不好理解,而且大多涉及到了prototypeconstructor等概念。如果对JS面向对象和JS原型不甚了解,很容易被搞糊涂。

ES6新增的类、继承语法

ES6带来了新的class语法:

1
2
3
4
5
6
7
8
9
class Person{
constructor(_name, _age){
this.name = _name;
this.age = _age;
}
halo(){
console.log(this.name);
}
}

ES6的类必须使用new构建对象,这会调用它的构造器constructor方法,并返回其中的this.
注意,这里的Person是类名,但是JS里没有类这个概念,只有方法,因此Person实际上是构造函数名,typeof(Person)的结果也是function

这种class语法,在类里面定义的所有方法其实都是定义在Person.prototype这个原型上的,不只有.halo (),也包括了.constructor(),因此它构造的对象都共享相同的方法。
上面的代码其实可以理解为:

1
2
3
4
5
6
7
8
function Person() {}
Person.prototype.constructor = function(_name, _age){
this.name = _name;
this.age = _age;
}
Person.prototype.halo = function() {
console.log(this.name);
}

ES6同样提供了类的继承机制语法:

1
2
3
4
5
6
class Student extends Person{
constructor(_name, _age, _school){
super(_name, _age);
this.school = _school;
}
}

constructor中调用super()来使用基类的构造函数,必须首先调用super(),这是强制的,因为ES6实现的原理是先用super调用父类的构造器,在this上添加父类的属性,然后再在this上添加子类的属性。
如果想要在子类中访问父类的成员,则要使用super.prop这种形式。

JS中的原型概念

显示原型prototype

1、它是一个JS引擎内部实现的对象,只有函数(也就是类名)具备.prototype,而原始值、对象均不具备这个属性;
2、它用于实现属性的继承:当一个方法开始执行的时候,他会把自己的.prototype这个对象复制一份拿过来当做this,所以说在一个函数的.prototype对象上添加新的属性、添加函数,所有由该函数构造出的对象都会具备这些新添的属性或函数。

例如,我们已经知道ES6类中所有方法均是定义在其类构造函数的原型上的,包括constructor也是,所以用ES6类构造出的对象也具备constructor方法:

1
2
3
4
5
6
7
class A {
constructor() {}
}

let a = new A();
a.constructor === A.prototype.constructor;
a.toString === Object.prototype.toString;

隐式原型__proto__

1、几乎任何JS对象、方法、值都具有的属性,当它被创建出来时,JS引擎会令它的.__proto__指向构造它的那个原型;
2、它用于实现原型链,如果在一个对象上找不到某个属性,JS引擎就会去它的原型链上依次向上寻找,同时instanceof关键字也会沿着原型链依次向上搜寻。

例如,以下代码均为true

1
2
3
4
5
6
7
8
9
class A {}
let a = new A();

a.__proto__ === A.prototype;
(1).__proto__ === Number.prototype;
"~".__proto__ === String.prototype;
({}).__proto__ === Object.prototype;
(function(){}).__proto__ === Function.prototype;
A.__proto__ === Function.prototype;

原型的应用

原型链

以下代码构造一个名为stuA对象,并调用其.halo()方法发送问候:

1
2
3
let stuA = new Student('Jack', 17, 'SunSchool');

stuA.halo();

我们知道sutA本身具备了.name.age.shcool三个属性,这是构造函数赋予它的;
但是它本身不具备.halo()方法,因此JS引擎会先从Student的原型开始搜寻,因此stuA.__proto__指向了需要搜寻的作用域Student.prototype

1
stuA.__proto__ === Student.prototype;

而实际上,Student.prototype上也没有具备.halo()方法,因此JS引擎会继续搜寻,这一次搜寻的范围便是Person的原型,因此Student.prototype.__proto__指向了需要搜寻的作用域Person.prototype

1
Student.prototype.__proto__ === Person.prototype;

.halo()函数确实是定义在Person.prototype上的,因此找到了.halo(),JS引擎便会执行之。

假设我们调用的不是.halo()而是.toString()方法,此时Person.prototype上依然找不到这个方法,JS引擎还会继续沿着原型链去寻找。
Person本身没有继承别的类,但是JS和Java、C#等编程语言的行为类似:如果一个对象不继承其他类,那么它默认继承了Object类,因此Person.prototype.__proto__指向了下一步要搜索的作用域:Object.prototype

1
Person.prototype.__proto__ === Object.prototype;

因为.toString()定义在这里,所以.toString()可以成功调用。

如果到了Object.prototype这里还是没有找到想要的属性,JS引擎会再沿着原型链向上查找:

1
Object.prototype.__proto__ === null;

因为Objcect是几乎所有对象的基类,而它本身是没有基类的,它的原型链再往上就没有了,值为null
JS引擎判断到这里,发现.__proto__null了,就知道到顶了。如果此时还没有找到属性,就会返回一个undefined

同样,a instanceof A运算符也是依次沿着运算符a.__proto__往上遍历,直到找到任何一个对象与A.prototype这个原型对象相等为止,返回true
如果遍历到原型链最顶层的null还没有找到,返回false

综合上面的举例来看,我们的搜索路径是:

1
2
3
4
stuA.__proto__ === Student.prototype;
Student.prototype.__proto__ === Person.prototype;
Person.prototype.__proto__ === Object.prototype;
Object.prototype.__proto__ === null;

由此可见,任何对象都默认具备Object.prototype上的属性,例如.toString().valueOf()这些属性都很常用。
如果一个类没有继承任何其他类,那么它的prototype原型上的.__proto__便是Object.prototype。所以任何对象都有.toString()等这些方法。
如果它继承了某一个类,它的prototype原型上的.__proto__便是它继承的类的.prototype。所以子类可以使用父类的方法。

同理,实际上JS中的任何函数都都默认具备Function.prototype上的属性,例如.call().apply()这些,它们也都是经常能用到的。
如果一个类没有继承任何其他的类,那么它的构造函数(类名)的.__proto__便是Function.prototype。这是JS中所有函数都具备的行为。
如果它继承了某一个类,它的.__proto__便是它继承的类的构造函数本身。

上面这些话,用代码来描述就是这样:

1
2
3
4
5
6
//普通的类,不继承任何类
class Person {}

//以下结果均为true
Person.__proto__ === Function.prototype;
Person.prototype.__proto__ === Object.prototype;

普通的类,构造方法来自于Function.prototype,属性来自于Object.prototype

1
2
3
4
5
6
//类实现了继承
class Student extends Person {}

//以下结果均为true
Student.__proto__ === Person;
Student.prototype.__proto__ === Person.prototype;

继承的类,构造方法和属性都来自于父类。

ES6的extends关键字只接受一个具备prototype属性的对象,或者null
如果是前者他会将子类的原型的构造器指向为父类的原型;
如果是null,则子类的原型上不具有__proto__属性,代码如下:

1
2
3
4
5
class A extends null {}

//以下结果均为true
A.__proto__ === Function.prototype;
A.prototype.__proto__ === undefined;

因为调用子类的constructor之前必须调用父类的构造器,而这个class A的父类没有构造器,因此这个构造函数无法使用new来创建任何对象。尽量避免使用这种类的定义方式。

也有一些特殊的对象和方法,他们可能没有原型,例如:

1
2
3
4
5
let obj = Object.create(null);
//obj是真正的空对象,没有任何成员,包括__proto__

let func = (function() {}).bind({});
//使用bind()创建的函数没有prototype对象

在开发中使用原型

慎用__proto__

__proto__并不是一个标准属性。
ES6规定浏览器环境,必须部署__proto__这个属性,但是实际上还是不推荐使用这种用法,因为JS代码经常要运行在Node.js端或者其他虚拟机上,无法保证所有运行环境都有这个属性。

标准化的写法应该是:

1
2
3
4
5
Object.getPrototypeOf(obj);
//视同使用obj.__proto__

Object.setPrototypeOf(obj, prop);
//视同使用obj.__proto__ = prop

对原生类的扩展

可以使用extends关键字来扩展JS原生对象的构造函数,并且使用super(...args)来生成同样的实例,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
class MyArray extends Array {
//构造函数中必须先调用super()
constructor(...args) {
super(...args);
//...
}

//自己定义一个给数组求和的函数
sum() {
return this.reduce((a, b) => a + b );
}
//...
}

这样便实现了对原生类型的扩展。
可以这样使用这个原生对象:

1
2
3
4
5
6
7
8
9
10
11
12
//构造实例
let myArray = new MyArray();

//构造实例使用
let myArray= new MyArray();
//调用Array类的方法,放入数组成员
myArray.push(100);
myArray.push(88);
//调用Array类的方法,返回2
myArray.length;
//调用自己定义的方法,返回188
myArray.sum();

使用对象原型来构造对象

有了构造函数我们可以直接构造对象。而使用实例,同样也可以构造对象。
这是因为,任何构造函数都具备.prototype原型对象,而该原型对象具备一个.constructor表示它原本的构造函数。而一个对象的.__proto__即表示它的.prototype就是它的原型对象。

例如:

1
2
//使用已有的person对象创造一个新的newPerson
let newPerson = new person.__proto__.constructor();

Object.create()的用法

Objcet.create(obj)返回一个新对象,它的原型链.__proto__指向这里的obj参数。
可以理解为,它创建了一个以obj作为原型链上一级的对象。
Object.create(null)会创造一个真正意义上的空对象,甚至没有.toString()等方法。