js不同于传统的面向对象的语言,它并没有类的概念(即使ES6中已经有class
关键字)。所以js的面向对象编程采用了一种特殊的方式,这种方式就是原型链。原型和原型链是js的核心,保证了函数或对象中的方法或属性能够被子类复用。
原型链
我们先从一个简单的例子看看什么是原型链,我们在浏览器直接输出对象a
(假设此时a = {name: "xxx"}
):
可以看到对象a
有一个__proto__
属性,该属性所指向的对象具有一些如hasOwnProperty
等属性。所以当我们使用a.hasOwnProperty("name")
能正确运行。这是因为虽然a
在自己的属性中找不到hasOwnProperty
属性,但是它能通过__proto__
属性继续查找,最后就能找到hasOwnProperty
属性。
这种通过__proto__
所连接起来的搜索轨迹就是原型链。
__proto__
是很多浏览器实现但非标准的属性,我们通常不会直接操作这个属性,而是通过js的一些内置方法来操作,后续会讲到这些方法。
get操作
当在获取对象某个属性时,如果无法在对象本身找到该属性,会访问对象的原型链继续查找,直到找到匹配的属性并返回;或者查找完整的原型链还未找到,此时会返回undefined
。这就是原型链的get
操作。
const obj1 = { a: 1, b: 2 };
const obj2 = Object.create(obj1);
obj2.c = 3;
console.log(obj2.a, obj2.b, obj2.c); // 1, 2, 3
其中Object.create
是用于创建一个新对象,并将该对象的原型链关联到obj1
。此时obj2
的结构如下:
当obj2
获取a
或者b
属性时,显然它自身并没有这些属性,所以会继续查找它的原型链,首先找原型链关联的最近一层对象,即obj1
,显然在obj1
中能够找到这些属性。
for...in...
循环会遍历完整的原型链输出所有的属性,所以如果只想要遍历对象本身的属性,需要使用hasOwnProperty
过滤。in
操作符同样也会检测某个属性是否存在于对象中或者对象的原型链中:
// 接上述例子
for (const key in obj2) {
console.log(key); // a, b, c
}
for (const key in obj2) {
if (obj2.hasOwnProperty(key)) {
console.log(key); // c
}
}
console.log("c" in obj2); // true
console.log("a" in obj2); // true
与作用域链类型,js在原型链上也只会找第一个匹配到的属性;如果原型链后续层级中还有同名属性,会被屏蔽。
const obj1 = { a: 1, b: 2 };
const obj2 = Object.create(obj1);
const obj3 = Object.create(obj1);
obj2.a = 3;
obj3.c = 3;
console.log(obj2.a, obj3.a); // 3, 1
使用isPrototypeof
可以判断两个对象之间的关系,即一个对象是否出现在另一个对象的原型链中:
console.log(obj1.isPrototypeOf(obj2)); // true
set操作
当我们为对象属性设置值时,如obj.a = 10
。首先会有一个get
操作过程获取该属性所在位置。其过程大致如下:
- 如果对象本身具有该属性
- 判断该属性是否是访问描述符(即存在
getter
或者setter
)- 如果存在
setter
,则调用setter
方法, - 否则在非严格模式会静默失败,在严格模式会报错
- 如果存在
- 否则,判断该属性是否可写(即
writable = true
)- 如果是,则修改该属性
- 否则在非严格模式会静默失败,在严格模式会报错
- 判断该属性是否是访问描述符(即存在
- 如果对象原型链中具有该属性
- 判断该属性是否是访问描述符(即存在
getter
或者setter
)- 如果存在
setter
,则调用setter
方法 - 否则在非严格模式会静默失败,在严格模式会报错
- 如果存在
- 否则,判断该属性是否可写
- 如果是,则直接在对象本身创建一个新的属性
a
,并赋值为10
,屏蔽原型链中的同名属性 - 否则在非严格模式会静默失败,在严格模式会报错
- 如果是,则直接在对象本身创建一个新的属性
- 判断该属性是否是访问描述符(即存在
- 如果该属性不存在于对象及对象原型链中
- 直接在对象本身创建一个新的属性
a
,并赋值为10
- 直接在对象本身创建一个新的属性
const obj1 = { a: 1 };
const obj2 = Object.create(obj1);
console.log(obj2.hasOwnProperty("a")); // false
obj2.a = 10;
console.log(obj2.hasOwnProperty("a")); // true, 在obj2中创建一个属性,屏蔽原型链中同名属性
函数原型
在js中,函数也是对象,所以函数允许拥有其他属性,每个函数都有一个特殊的属性prototype
。
这里讨论的函数是除箭头函数外的函数
如果我们自定义一个函数,那么它的prototype
是一个只包含constructor
属性的对象(忽略原型链的前提下),且constructor
的值就是函数本身。
function Foo() {}
console.log(Foo.prototype); // {constructor: Foo}
js内置函数的prototype
包含很多其他属性和方法,如Object.prototype
就包含了诸如hasOwnProperty
等属性或者方法。
原型链的尽头
我们现在已经知道了js对原型链的操作流程,那么原型链的尽头是什么呢?所有原型链最终都会指向内置的Object.prototype
,并且Object.prototype.__proto__
值为null
。所以js遍历原型链时(即遍历__proto__
)会一直到它为null
为止,此时js就会认为已经到了尽头。正因为如此它包含了许多js中的通用功能。
原生函数的原型链关系
当我们创建一个字符串类型变量后,在调用它的一些方法时,会自动将它转换为它对应String
对象(类似Java的装箱),此时能使用String.prototype
中的方法(具体细节查看new到底干了啥)。
类似的,当我们声明其他字面量类型变量(number
,boolean
)时,当我们在调用某些属性时,js会自动进行封装对象操作。所以此时对象实例的原型链能够关联上构造函数的prototype
属性。当我们声明对象类型变量时,也会在声明后自动将其原型链和对应构造函数的prototype
属性关联上。
下图就展示常用内置对象的原型链关系(结合继承小节使用更佳):
其中Function.prototype
是一个空函数,Array.prototype
是一个空数组,RegExp.prototype
是一个空的正则表达式。
面向对象和继承
虽然在js并没有类的概念,但是我们能以一种奇怪的方式模仿类:
// 约定构造函数的首字母需要大写(这不是必须,只是让它看起来更像一个类)
function Foo() {
this.a = 1;
this.b = 2;
}
Foo.prototype.getValue = function () {
return [this.a, this.b];
};
const foo = new Foo();
console.log(foo.getValue()); // [1, 2]
在上述代码中,Foo
就相当于传统面向对象语言中的类,而foo
就是类的实例。所以foo
就能访问到类中一些方法。
new到底干了啥
在继续讲继承之前,我们先来看看new
做了什么,使得实例能够访问到构造函数的prototype
属性。
new
大概做了这几件事:
- 创建一个空的临时对象
tmp
- 将
tmp
的原型链关联到构造函数的prototype
属性 - 绑定构造函数的
this
指向为tmp
,并运行函数 - 判断构造函数返回值,如果返回值是对象,则返回这个对象;否则返回
tmp
function mockNew(fn, ...args) {
const tmp = Object.create(fn.prototype);
const result = fn.call(tmp, ...args);
return typeof result === "object" ? result : tmp;
}
function Foo(a, b) {
this.a = a;
this.b = b;
}
Foo.prototype.getValue = function () {
return [this.a, this.b];
};
const foo1 = new Foo(1, 2);
const foo2 = mockNew(Foo, 11, 22);
console.log(foo1.getValue()); // [1, 2]
console.log(foo2.getValue()); // [11, 22]
现在我们知道了,当使用了new Foo()
后,foo1
,foo2
和Foo
的关系是:
注意:实例foo1
,foo2
的原型链其实是和构造函数的prototype
关联上的,而不是关联构造函数本身。
构造函数中prototype.constructor
是一个很迷惑人的属性,给人的感受是:该属性是指“由......构造”。但这个属性可被修改,所以请把它就当做一个普通的属性看待。
将上述例子稍加修改:
function Foo(a, b) {
this.a = a;
this.b = b;
}
Foo.prototype = {
getValue() {
return [this.a, this.b];
},
};
const foo1 = new Foo(1, 2);
console.log(foo1.getValue()); // [1, 2]
console.log(foo1.constructor); // ???
此时我们为Foo.prototype
显示赋值一个新的对象,这个新的对象里面没有constructor
属性。那么此时foo1.constructor
输出的是什么?我们第一直觉可能是undefined
,但实际结果是function Object
(即Object
构造函数)。虽然新的对象里面不存在constructor
属性,但该对象的原型链是关联了Object.prototype
,而Object.prototype
中具有constructor
属性,它指向Object
构造函数本身。
甚至,我们还可以修改prototype.constructor
的指向:
function Other() {}
function Foo(a, b) {
this.a = a;
this.b = b;
}
Foo.prototype.constructor = Other;
Foo.prototype.getValue = function () {
return [this.a, this.b];
};
const foo1 = new Foo(1, 2);
console.log(foo1.getValue()); // [1, 2]
console.log(foo1.constructor); // function Other
可以看出prototype.constructor
是一个非常不可靠并且不安全的引用,虽然它在默认情况下确实有“由......构造”的意思。不要因为它的名字为它赋予过多意义。
继承
我们现在来看看使用原型链如何实现继承,废话不多说,先上代码:
function Foo(a, b) {
this.a = a;
this.b = b;
}
Foo.prototype.getValue = function () {
return [this.a, this.b];
};
function Bar(a, b, c) {
Foo.call(this, a, b);
this.c = c;
}
// 创建一个新的Bar.prototype,并将它的原型链关联到Foo.prototype
Bar.prototype = Object.create(Foo.prototype);
Bar.prototype.getMoreValue = function () {
return [this.a, this.b, this.c];
};
const bar = new Bar(1, 2, 3);
console.log(bar.getValue()); // [1, 2]
console.log(bar.getMoreValue()); // [1, 2, 3]
这段代码的关键有两处:
- 在调用
new Bar(1, 2, 3)
时,运行了Foo.call(this, a, b)
。保证a
,b
两个变量顺利初始化 - 通过
Bar.prototype = Object.create(Foo.prototype)
将Bar.prototype
和Foo.prototype
关联上,保证bar
的原型链关联上Bar.prototype
和Foo.prototype
。虽然这种方式会丢失默认的Bar.prototype
(主要影响是新建的Bar.prototype
不具备constructor
属性)- 如果希望保留原始
prototype
属性,可使用Object.setPrototypeOf(Bar.prototype,Foo.prototype)
,它会把现有的Bar.prototype
和Foo.prototype
关联起来 - 一定不要使用
Bar.prototype = Foo.prototype
,这和我们想要的结果是不一样的,它只是让Bar.prototype
直接引用Foo.prototype
,结果就是后续Bar.prototype.getMoreValue=......
会直接修改Foo.prototype
本身
- 如果希望保留原始
此时的关系图为:
构造函数与实例
我们可以使用instanceof
操作符判断一个对象实例和构造函数之间的关系。instanceof
的作用是判断在对象的整个原型链中是否包含指向构造函数prototype
的对象。
instanceof
的左边操作数是一个对象,右边是一个函数。以继承的例子为例:
console.log(bar instanceof Bar); // true
console.log(bar instanceof Foo); // true
console.log(bar instanceof Object); // true
而使用Object.getPrototypeOf
时只会获取对象原型链的第一层所指向的对象:
console.log(Object.getPrototypeOf(bar) === Bar.prototype); // true
console.log(Object.getPrototypeOf(bar) === Foo.prototype); // false
console.log(Object.getPrototypeOf(bar) === Object.prototype); // false
来自es6时代的光
可以看到使用原型链的方式实现类和继承十分别扭,所幸es6为我们提供了class
关键字(它的本质是原型链版本的语法糖),让我们能够以更接近传统面向对象语言的方式编写类。
关于
class
的详细的使用不是本章的重点,详解请查阅阮一峰大佬的文章。
通过class
关键字改写上述例子:
class Foo {
constructor(a, b) {
this.a = a;
this.b = b;
}
getValue() {
return [this.a, this.b];
}
}
class Bar extends Foo {
constructor(a, b, c) {
super(a, b); // 必须放在第一行
this.c = c;
}
getMoreValue() {
return [this.a, this.b, this.c];
}
}
const bar = new Bar(1, 2, 3);
console.log(bar.getValue()); // [1, 2]
console.log(bar.getMoreValue()); // [1, 2, 3]
可以看出使用class
代码语义性更强,也不需要我们显式的操作原型链。
es6class
和es5的原型链在继承时最大的不同:
- es6
class
的contructor
中,必须先调用super
方法。这是因为子类的this
对象必须先通过父类构造函数完成塑造,得到父类同样的实例属性和方法,然后在对其加工,加上子类的实例属性和方法。如果不调用super
方法,子类就得不到this
对象,所以在子类的构造函数中,只有调用super
之后,才可以使用this
关键字 - 而在es5的原型链版本中则没有这个限制,所以子类
Bar
中Foo.call(this, a, b)
没有位置限制。
我们看一下class
所编译的es5代码就能明白原因:
function _inheritsLoose(subClass, superClass) {
subClass.prototype = Object.create(superClass.prototype);
subClass.prototype.constructor = subClass;
subClass.__proto__ = superClass;
}
var Foo = /*#__PURE__*/ (function () {
function Foo(a, b) {
this.a = a;
this.b = b;
}
var _proto = Foo.prototype;
_proto.getValue = function getValue() {
return [this.a, this.b];
};
return Foo;
})();
var Bar = /*#__PURE__*/ (function (_Foo) {
_inheritsLoose(Bar, _Foo);
function Bar(a, b, c) {
var _this;
// 下面这一行就是super(a, b)的编译结果,试想如果它不是第一行,那么_this就不能被正确的初始化
_this = _Foo.call(this, a, b) || this;
_this.c = c;
return _this;
}
var _proto2 = Bar.prototype;
_proto2.getMoreValue = function getMoreValue() {
return [this.a, this.b, this.c];
};
return Bar;
})(Foo);
var bar = new Bar(1, 2, 3);
console.log(bar.getValue()); // [1, 2]
console.log(bar.getMoreValue()); // [1, 2, 3]