原型和继承
原型和继承
原型
每个函数都有一个属性prototype
,它指向函数的原型,默认情况下它是一个普通Object
对象。
调用该构造函数所创建的实例的隐式原型指向该构造函数的原型对象。
JS同样存在由原型指向构造函数的属性:constructor
,即Func.prototype.constructor --> Func
JS中所有对象(除了null
)都具有一个__proto__
属性,该属性指向该对象的隐式原型。
JavaScript
所有的对象本质上都是通过new 函数
创建的,包括对象字面量的形式定义对象(相当于new Object()
的语法糖)。
所有的函数本质上都是通过new Function
创建的,包括Object
、Array
等。所有的函数都是对象。
实例对象在查找属性时,如果查找不到,就会沿着__proto__
去与对象关联的原型上查找,如果还查找不到,就去找原型的原型,直至查到最顶层,这也就是原型链的概念。
所有函数(包括
Function
)的__proto__
指向Function.prototype
自定义对象实例的
__proto__
指向构造函数的原型函数的
prototype
的__proto__
指向Object.prototype
Object.prototype.__proto__ --> null
继承
继承是一种代码复用的方式。在面向对象编程中,继承是一个很重要的点。
在JS中继承背后的原理是原型prototype
, 这种实现继承的方式,我们称之为原型继承。
全局对象
JS中一些全局内置函数,分别为Functon, Array, Object.
1 | console.log(Object); // -> ƒ Object() { [native code] } |
- 所有的数组对象,都是由全局内置函数Array创建的
- 所有的object对象,都是由全局内置函数Object创建的
- 所有的函数对象,都是由全局内置函数Function创建的
其他也是同理,比如:
1 | 1..__proto__ === Number.prototype; // true |
__proto__
__proto__
是一个内部属性,不建议对其进行直接操作。 而是建议通过prototype
来进行操作。
一个对象的__proto__
总是指向它的构造函数的prototype
。
构造函数指的是创建这个对象的函数, 比如 foo = new Foo(), 那么Foo就是foo的构造函数。
让我们来继续看一下上面的代码, 就不难理解了:
1 | 1..__proto__ === Number.prototype; // true |
除此我们需要注意一点,那就是Object.prototype.__proto__
值为 null
。 其实也就是继承链的终点。
原型链
为了能够明白原型链和继承,我们首先要知道“属性查找机制”。
当我们访问一个对象的属性的时候,引擎首先会在当前对象进行查找,如果找不到就会访问该对象的__proto__
, 如果__proto__
有了,就返回,如果没有则递归执行上述过程,直到__proto__
为 null
。
继承的过程,直接依靠的是__proto__
, 只不过就像我上面提到的__proto__
只是一个指向构造函数原型的引用, 因此开发人员修改了构造函数的原型,就会影响到__proto__
, 进而影响了对象的原型链。
当然你可以自己直接修改__proto__
,但是不推荐!
1 | var obj = {}; |
new
其实继承和原型这部分知识和new是强相关的。 我们有必要了解一下new的原理。
new 的原理很简单, 就是引擎内部新建一个空对象,然后将这个空对象的__proto__
指向构造函数的prototype
.然后调用构造函数,去填充我们创建的空对象(如果有必要)。 最后将this
指向我们刚刚创建的新对象。
new 运算符创建一个用户定义的对象类型的实例或具有构造函数的内置对象的实例。new 关键字会进行如下的操作:
- 创建一个空的简单JavaScript对象(即
{}
); - 将该对象的
__proto__
指向构造函数原型; - 将步骤1新创建的对象作为
this
的上下文 ; - 如果该函数没有返回对象,则返回
this
(新创建的对象)。
1 | function new_object() { |
__proto__
已废弃: 该特性已经从 Web 标准中删除,虽然一些浏览器目前仍然支持它,但也许会在未来的某个时间停止支持,请尽量不要使用该特性。
Object.setPrototypeOf(obj, proto);
直接修改已有对象的原型,非常耗时。警告: 通过现代浏览器的操作属性的便利性,可以改变一个对象的
[[Prototype]]
属性, 这种行为在每一个JavaScript引擎和浏览器中都是一个非常慢且影响性能的操作,使用这种方式来改变和继承属性是对性能影响非常严重的,并且性能消耗的时间也不是简单的花费在obj.__proto__ = ...
语句上, 它还会影响到所有继承来自该[[Prototype]]
的对象,如果你关心性能,你就不应该在一个对象中修改它的[[Prototype]]
。相反, 创建一个新的且可以继承[[Prototype]]
的对象,推荐使用Object.create()
1 | // 优化后 new 实现 |
补充:
shift/unshift
方法并不局限于数组:这个方法能够通过 call()
或 apply()
方法作用于类似数组的对象上。
instanceof
instanceof
判断对象的原型链上是否存在构造函数的原型。只能判断引用类型。instanceof
常用来判断A
是否为B
的实例
1 | // instanceof 的内部实现 |
Object.getPrototypeOf()
方法返回指定对象的原型(内部[[Prototype]]
属性的值)。
call/apply
Function.prototype.call()
call()
方法调用一个函数, 其具有一个指定的 this
值和多个参数(参数的列表)。
1 | func.call(thisArg, arg1, arg2, ...) |
它运行 func
,提供的第一个参数 thisArg
作为 this
,后面的作为参数。
看一个简单的例子:
1 | function sayWord() { |
所以,call
主要实现了以下两个功能:
call
改变了this
的指向bottle
执行了sayWord
函数
上面代码等效于
1 | var bottle = { |
这里把函数作为对象的属性存在,通过对象属性执行函数时,相当于对this进行了隐式绑定。
模拟实现 call
模拟实现 call
有三步:
- 将函数设置为绑定对象的属性
- 执行函数
- 删除对象的这个属性
1 | Function.prototype.newCall = function (context) { |
补充
封装实参的对象 arguments
- arguments是一个类数组对象,它也可以通过索引来操作数据,也可以获取长度。
- 在调用函数时,我们所传递的实参都会在arguments中保存
arguments.length
可以用来获取实参的长度arguments[0]
表示第一个实参,arguments[1]
表示第二个实参 。。。
展开语法、剩余参数
展开语法(扩展运算符)是将数组或者可迭代对象拆分成逗号分隔的参数序列。
剩余参数语法允许我们将一个不定数量的参数表示为一个数组。
剩余语法(Rest syntax) 看起来和展开语法完全相同,不同点在于, 剩余参数用于解构数组和对象。从某种意义上说,剩余语法与展开语法是相反的:展开语法将数组展开为其中的各个元素,而剩余语法则是将多个元素收集起来并“凝聚”为单个元素。
slice()
slice()
方法返回一个新的数组对象,这一对象是一个由begin
和end
决定的原数组的浅拷贝(包括begin
,不包括end
)。- 原始数组不会被改变。
Function.prototype.apply()
apply()
方法调用一个具有给定 this
值的函数,以及作为一个数组(或[类似数组对象)提供的参数。
1 | func.apply(thisArg, [argsArray]) |
它运行 func
设置 this = context
并使用类数组对象 args
作为参数列表。
call
和 apply
之间唯一的语法区别是 call
接受一个参数列表,而 apply
则接受带有一个类数组对象。
需要注意:Chrome 14 以及 Internet Explorer 9 仍然不接受类数组对象。如果传入类数组对象,它们会抛出异常。
模拟实现 apply
1 | Function.prototype.newApply = function (context, arr) { |
bind
Function.prototype.bind()
bind()
方法创建一个新的函数,在bind()
被调用时,这个新函数的this
被指定为bind()
的第一个参数,而其余参数将作为新函数的参数,供调用时使用。
bind
方法与 call / apply
最大的不同就是前者返回一个绑定上下文的函数,而后两者是直接执行了函数。
来个例子说明下:
1 | let value = 2; |
通过上述代码可以看出 bind
有如下特性:
- 指定
this
- 传入参数
- 返回一个函数
- 柯里化
bind
还有一个特性:
一个绑定函数也能使用 new 操作符创建对象:这种行为就像把原函数当成构造器,提供的 this 值被忽略,同时调用时的参数被提供给模拟函数。
来个例子说明下:
1 | let value = 2; |
上面例子中,运行结果 this.value
输出为 undefined
,这不是全局 value
也不是 foo
对象中的 value
,这说明 bind
的 this
对象失效了,new
的实现中生成一个新的对象,这个时候的 this
指向的是 obj
。
模拟实现bind
大体思路:
拷贝源函数:
通过变量储存源函数
const self = this;
this 指向源函数使用空对象或
Object.create
复制源函数的prototype给返回函数
返回拷贝的函数
调用拷贝的函数:
new调用判断:通过
instanceof
判断函数是否通过new
调用,来决定绑定的context
绑定this+传递参数
apply
返回源函数的执行结果
1 | Function.prototype.newBind = function (context) { |
至于为什么使用一个空对象 fNOP
作为中介,把 fBound.prototype
赋值为空对象的实例(原型式继承),这是因为直接 fBound.prototype = this.prototype
有一个缺点,修改 fBound.prototype
的时候,也会直接修改 this.prototype
;其实也可以直接使用ES5的 Object.create()
方法生成一个新对象,但 bind
和 Object.create()
都是ES5方法,部分IE浏览器(IE < 9)并不支持。
1 | if (self.prototype) { |
注意: bind()
函数在 ES5 才被加入,所以并不是所有浏览器都支持,IE8
及以下的版本中不被支持。
补充:
类数组->数组
slice
方法可以用来将一个类数组(Array-like)对象/集合转换成一个新数组。你只需将该方法绑定到这个对象上。
除了使用 Array.prototype.slice.call(arguments)
,你也可以简单的使用 [].slice.call(arguments)
来代替。
柯里化
在计算机科学中,柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。这个技术由 Christopher Strachey 以逻辑学家 Haskell Curry 命名的,尽管它是 Moses Schnfinkel 和 Gottlob Frege 发明的。
1 | var add = function(x) { |
这里定义了一个 add
函数,它接受一个参数并返回一个新的函数。调用 add
之后,返回的函数就通过闭包的方式记住了 add
的第一个参数。所以说 bind
本身也是闭包的一种使用场景。
柯里化是将 f(a,b,c)
可以被以 f(a)(b)(c)
的形式被调用的转化。JavaScript 实现版本通常保留函数被正常调用和在参数数量不够的情况下返回偏函数这两个特性。
继承方法
原型链继承
构造函数、原型和实例之间的关系:每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个原型对象的指针。
继承的本质就是复制,即重写原型对象,代之以一个新类型的实例。
1 | function SuperType() { |
原型链方案存在的缺点:多个实例对引用类型的操作会被篡改。
1 | function SuperType(){ |
借用构造函数
使用父类的构造函数来增强子类实例,等同于复制父类的实例给子类(不使用原型)
1 | function SuperType(){ |
核心代码是SuperType.call(this)
,创建子类实例时调用SuperType
构造函数,于是SubType
的每个实例都会将SuperType
中的属性复制一份。
缺点:
- 只能继承父类的实例属性和方法,不能继承原型属性/方法
- 无法实现复用,每个子类都有父类实例函数的副本,影响性能
组合继承
组合上述两种方法就是组合继承。用原型链实现对原型属性和方法的继承,用借用构造函数技术来实现实例属性的继承。
1 | function SuperType(name){ |
缺点:
- 第一次调用
SuperType()
:给SubType.prototype
写入两个属性name
,color
。 - 第二次调用
SuperType()
:给instance1
写入两个属性name
,color
。
实例对象instance1
上的两个属性就屏蔽了其原型对象SubType.prototype
的两个同名属性。所以,组合模式的缺点就是在使用子类创建实例对象时,其原型中会存在两份相同的属性/方法。
原型式继承
利用一个空对象作为中介,将某个对象直接赋值给空对象构造函数的原型。
1 | function object(obj){ |
object()对传入其中的对象执行了一次浅复制
,将构造函数F的原型直接指向传入的对象。
1 | var person = { |
缺点:
- 原型链继承多个实例的引用类型属性指向相同,存在篡改的可能。
- 无法传递参数
另外,ES5中存在Object.create()
的方法,能够代替上面的object方法。
寄生式继承
核心:在原型式继承的基础上,增强对象,返回构造函数
1 | function createAnother(original){ |
函数的主要作用是为构造函数新增属性和方法,以增强函数
1 | var person = { |
缺点(同原型式继承):
- 原型链继承多个实例的引用类型属性指向相同,存在篡改的可能。
- 无法传递参数
寄生组合式继承
结合借用构造函数传递参数和寄生模式实现继承
1 | function inheritPrototype(subType, superType){ |
这个例子的高效率体现在它只调用了一次SuperType
构造函数,并且因此避免了在SubType.prototype
上创建不必要的、多余的属性。于此同时,原型链还能保持不变;因此,还能够正常使用instanceof
和isPrototypeOf()
这是最成熟的方法,也是现在库实现的方法。
混入方式继承多个对象
1 | function MyClass() { |
Object.assign
会把 OtherSuperClass
原型上的函数拷贝到 MyClass
原型上,使 MyClass 的所有实例都可用 OtherSuperClass 的方法。
ES6类继承extends
extends
关键字主要用于类声明或者类表达式中,以创建一个类,该类是另一个类的子类。其中constructor
表示构造函数,一个类中只能有一个构造函数,有多个会报出SyntaxError
错误,如果没有显式指定构造方法,则会添加默认的 constructor
方法,使用例子如下。
1 | class Rectangle { |
extends
继承的核心代码如下,其实现和上述的寄生组合式继承方式一样
1 | function _inherits(subType, superType) { |
总结
1、函数声明和类声明的区别
函数声明会提升,类声明不会。首先需要声明你的类,然后访问它,否则会抛出一个ReferenceError
。
2、ES5继承和ES6继承的区别
- ES5的继承实质上是先创建子类的实例对象,然后再将父类的方法添加到this上(Parent.call(this))。
- ES6的继承有所不同,实质上是先创建父类的实例对象this,然后再用子类的构造函数修改this。因为子类没有自己的this对象,所以必须先调用父类的super()方法,否则新建实例报错。