深入理解原型和继承
- 2019 年 11 月 7 日
- 筆記
这几天在掘金上阅读到了一篇关于原型的文章,角度较之前看到的几篇博客都不一样,顿时感觉我对于原型的知识点还没有完全吃透。鉴于本篇文章很可能会进行不定期的修订和拓展,故在此附上更新日志,以简单记录我在学习上的认知更新。
2019.2.24
- js继承的几种实现方式
2019.2.19
- 完善关于constructor属性的介绍
- 比对new和Object.create()的本质区别
- 纠正隐式原型的错误写法(之前没看仔细,一直写错,今天报错才发现)
1.创建对象的方法
在了解原型链之前,首先先了解一下创建对象的几种方式,为后面做个铺垫。介绍以下三种。

代码:
<script type="text/javascript"> // 第一种方式:字面量 var o1 = {name: 'o1'} var o2 = new Object({name: 'o2'}) // 第二种方式:构造函数 var M = function (name) { this.name = name; } var o3 = new M('o3') // 第三种方式:Object.create var p = {name: 'p'} var o4 = Object.create(p) console.log(o1) console.log(o2) console.log(o3) console.log(o4) </script>
打印结果:

2.构造函数、实例、原型、原型链
先来一张图简单了解一下

2.1 原型、实例、构造函数
首先是代码
var M = function (name) { this.name = name; } var o3 = new M('o3')
- 实例就是通过new一个构造函数生成的对象。在本例中o3就是实例,M就是构造函数。
- 每个函数都有prorotype属性,每个对象都有proto 属性(隐式原型,读作dunder prototype)
- 从上图中可以知道,实例的protpo指向原型对象。
- 从上图中可以知道,实例的构造函数的prototype也是指向原型对象。
- 原型对象的construor指向构造函数。 再来通过下面这个图来理解一下

2.2 原型链
简单理解就是原型组成的链,实例的proto就是原型,而原型也是一个对象,也有proto属性,它会指向另一个原型…………就这样可以一直通过proto向上找,这就是原型链,当向上找找到Object这个构造函数的原型(即null)时,这条原型链就算到头了。也就是说,原型链的尽头是null 。
2.3 原型的作用
原型的存在是为了帮助实现继承。我们先来思考一个问题:假如现在通过一个构造函数创建了多个实例,想要给它们添加同一个方法,该怎么做呢?
1.给每个实例去添加。太过麻烦,并不是一个明智的选择; 2.在构造函数的内部添加方法。这样做的话在每次用构造函数创建实例时都会大量产生方法的副本,这些方法副本功能一样,实际却是不同的。这会影响性能,且不利于代码复用; 3.这时,就该用上原型了。只要给构造函数的原型添加一个方法,那么构造函数的所有实例便都有了这个方法。接着上面的例子继续演示:
function M(name) {this.name = name;} var o3 = new M('o3') var o5 = new M('o5') M.prototype.say=furnction(){ console.log('hello world')} o3.say() o5.say() console.log(o3.say()==o5.say()); // true
打印结果

按照JS引擎的分析方式,在访问一个实例的方法时,首先在实例本身中找,如果找到了就说明其构造函数先前是有定义这个方法的(this);如果没找到就去实例的原型中找,还没找到就再沿着原型链往上找,直到找到。当然,不止方法,属性也是可以继承自原型的。
那么怎么判断属性是实例本身具有的还是继承的?对实例用 hasOwnProperty( )方法即可。那么实例为何有这个方法?同样是继承来的。 由于所有的对象的原型链都会找到追溯到Object.prototype,因此所有的对象都会有Object.prototype的方法,其中就包括 hasOwnProperty( )方法 。
2.4 访问原型
可以用obj.prototype
,obj.__proto__
,或者obj.getPrototypeOf()
。这里重点说后面两个。 __proto__
属性在 ES6 时才被标准化,以确保 Web 浏览器的兼容性,但是不推荐使用,更不推荐通过这种方式修改实例的原型,除了标准化的原因之外还有性能问题。 为了更好的支持,推荐使用 Object.getPrototypeOf()。
2.5 原型、构造函数、实例、Function、Object的关系
前面我们给出了一幅图简单梳理了一下关系,但想追本溯源,光靠那张图是不够的。下面我们给出另一张更详细的图。请先记住,Function和Object 是特殊的构造函数。

首先从构造函数Foo(或任意一个普通构造函数)出发,它创建了实例f1和f2等,而实例的__proto__
指向了Foo.prototype这个原型,该原型的__proto__
向上再次指向其他构造函数的原型,一直向上,最终指向Object这个构造函数的原型,即Object.prototype。而Object.prototype的 __proto__
指向了null,这时我们说到达了原型链的终点null。回过头看,该原型又被Object构造函数的实例的__proto__
指向,而函数的实例就是我们通常通过字面量创建的那些对象,也即是图中的o1,o2。那么,普通构造函数(这里指Foo)和特殊构造函数Object又来自于哪里?答案是,来自于另一个特殊构造函数Function。
实际上,所有的函数都是由Function函数创建的实例,而构造函数当然也是函数,所以也来自于Function。从图中可以看到,实例Foo的__proto__
和实例Object的__proto__
都指向了 Function的prototype,即Function.prototype 。
既然所有的函数都是由Function函数创建的实例,那么Function又是怎么来的?答案是,Function自己创造了自己。它既作为创造其他实例函数的构造函数而存在,也作为实例函数而存在,所以可以在图上看到作为实例的Function的__proto__
指向了作为构造函数的Function的prototype,
即Function.__proto__ ===Function.prototype
正如我们前面所说的,Function.prototype的__proto__
也像其他构造函数.prototype的__proto__
一样,最终指向Object.porototype,而Object.porototype的__proto__
最终指向null,原型链结束。
可以发现,经过简单梳理,这几者的关系没有我们想象的那么复杂。一句话,看懂这幅图就够了。
3.instanceof的原理

instanceof 沿着 实例—> proto —> …….. 这条线来找,同时沿着 实例的构造函数的prototype—>proto —> …….. 这条线来找,如果两条线能找到同一个引用,即同一个对象,那么就返回true。如果找到终点还未重合,则返回false。如下图,很显然 f1 instanceof Object 成立

注意:正因为 instanceof 的原理如上所述,因此实例的instanceof在比较的时候,与原型链上向上找的的构造函数相比都是true。
继续上面的代码

那怎么判断实例是由哪个构造函数生成的呢?这时候就要用到constructor了。 实例的原型的构造函数, obj.proto.constructor

4.constructor属性
4.1 定义:
构造函数的prototype属性指向它的原型对象,在原型对象中有一个constructor属性,指向该构造函数。值类型(除了null和undefined,这两者不具有这个属性)的constructor是只读的,不可修改,引用类型的constructor是可修改的,例如5.2提到的修复指向。
4.2 修复constructor的指向:
为了实现从父类到子类方法的继承,一般会重写构造函数的原型,如:
function Person(){ ......... } function Student(){ ......... } Student.prototype = new Person() var student = new Obj()
这将使得实例student具有构造函数Person的方法,但同时也会导致constructor的指向出现问题,造成继承链的紊乱,因此为了修复这个错误指向,需要显式指定obj.prototype.constructor = obj
。拿下面例子说明:

未重写原型对象之前,实例化了一个dog;第6行重写了原型对象,使其指向另一个实例(等式右边是字面量,因此可以看作是由Object构造函数实例化出来的一个对象),之后实例化了一个cat。
查看dog和cat的constructor:
console.log(dog.constructor); //function Animal() console.log(cat.constructor); //function Object()
dog.say(); //wan cat.say(); //miao
首先,构造函数没有constructor属性,这导致了它构造的实例也没有constructor属性,所以,实例将沿着原型链(注意,构造函数不算在原型链里)向上追溯对应的原型对象的constructor属性。dog.constructor可以指向原来的构造函数,说明原来的原型对象还存在;而cat.constructor 指向另一个构造函数,是因为Animal( )的原型被重写,并且作为Object( )构造函数的一个实例而存在,那么由cat实例出发,向上进行constructor属性追溯的时候,最终会找到Object( ) 构造函数。同样的,正因为原型重写前后创建的实例分别对应了初始原型和新的原型,所以我们可以对旧实例调用初始原型的方法、对新实例调用新的原型的方法,放在本例子中,就表现为dog依然可以调用say( )方法发出wan,而cat也可以调用say( )方法发出miao 。
总结: 重写原型对象之后,会切断构造函数与最初原型之间的连接,使新构造的实例对象的原型指向重写的原型,而先前构造的实例对象的原型还是指向最初原型。在这种情况下,先前的实例对象可以使用最初原型的方法,新的实例对象可以使用重写的原型的方法。
5. new 和 Object.create():
这里,让我们回到文章开头提到的创建对象的三种方式。重点介绍后两种。
5.1 new
new一个构造函数时,实际发生的过程是:
var o={}; o.__proto__=M.prototype M.call(o)
- 第一步,创建一个空对象o;
- 第二步,令空对象的proto指向构造函数M的prototype;
- 第三步,令构造函数M中的this指针指向o,使得o具有M的属性或方法,如果M无返回值或返回的不是对象,则最后会返回o 。
在这里要注意下面这个坑:
var Base = function(){ this.a = 2; }; console.log(Base.a);
构造函数就好比印钞机,而它创建的实例就好比钞票。构造函数中的this.xxxx都是为了实例而准备的属性和方法,这些this在构造函数内,但并不指向构造函数,而是在new构造函数执行的时候转而指向新实例。构造函数自身没有这些属性和方法,像上面那样调用Base的a属性是会报错的,Base根本没有a属性。
手动实现new(方法一):
下面根据new的工作原理通过代码手动实现一下new运算符
var new2 = function (func) { //创建一个空对象,并链接到原型 var o = Object.create(func.prototype); //改变func中的this指向,把func的结果赋给k var k = func.call(o); //判断func是否显式返回对象 return typeof k === 'object' ? k : o;
验证

不难看出,我们手动编写的new2和new运算符的作用是一样的。
手动实现new(方法二):
考虑到构造函数本身需要传参,这里提供第二种手写new的方法
function new3(){ // 获得构造函数func(arguments的第一个参数) var func = [].shift.call(arguments); // 创建一个空对象,并链接到原型 var o = Object.create(func.prototype); // 改变func中的this指向,把func的结果赋给k var k = func.call(o,arguments); // 判断func是否显式返回对象 return k instanceof Object ? k : o; }; function M(){....} // 使用内置new var m = new M(....) // 使用手写new var m = new3(M,.....)
这里要注意数组的shift()
方法,它可以删去数组的第一个元素并返回该元素。但是arguments是类数组的对象,无法直接使用这个方法,所以我们使用[].shift.call(arguments)
,意思是从参数列表(包括构造函数、构造函数的参数)中删去并返回第一个参数(构造函数),将其赋给func,之后的arguments将只包含构造函数func的参数。
5.2 Object.create()
Object.create()方法创建一个新对象(实例),并使用现有的对象(参数)作为新创建的对象的proto 也就是说,这个方法可以起到指定原型的作用。
执行Object.create() 时,实际发生的过程是:
Object.create = function (o) { var F = function () {}; F.prototype = o; return new F(); };
- 第一步,创建空的构造函数;
- 第二步,令构造函数的prototype指向传入的对象; 实际上也相当于 令新实例的proto指向传入的对象
- 第三步,实例化一个对象并返回
这里,如果Object.create()接受的参数是null,即var obj = Object.create(null)
,则obj是真正意义上的空对象,不具有hasOwnProperty(),toString()等方法或属性。
6 继承的7种方式
6.1.原型链继承
- 核心:重写子类原型,代之以父类的实例
function Person(){ this.age=[6,12,24]; } function Worker(){} Worker.prototype = new Person();
- 缺点: 创建子类实例时,无法向父类构造函数传参; 对一个子类实例的引用类型属性的操作将会影响其他子类实例,即引用属性共享
var worker1 = new Worker() var worker2 = new Worker() worker1.age.push(48) alert(worker1.age) //[6,12,24,48] alert(worker2.age) //[6,12,24,48]
6.2.借用构造函数继承
又称为冒充继承、经典继承、伪造对象继承
- 核心:使用父类的构造函数来增强子类实例,等同于复制父类的实例属性给子类(不使用原型)
function Person(name){ this.age=[6,12,24]; this.name=name; } function Worker(name){ Person.call(this,name); } var worker1 = new Worker() var worker2 = new Worker() worker1.age.push(48) alert(worker1.age) //[6,12,24,48] alert(worker2.age) //[6,12,24]
- 缺点:虽然消除了原型链继承的缺点,但是不利于实现函数复用,每个子类都有父类实例函数的副本,影响性能。
6.3.组合继承
- 核心:原型链继承+借用构造函数继承。即使用原型链实现对原型属性和方法的继承,通过借用构造函数来实现对实例属性的继承.
function Person(){ this.age=[6,12,24]; } Person.prototype.shout=function(){ alert("Ahhhhhh"); } function Worker(){ Person.call(this); ...其余新增属性。。。 } Worker.prototype=new Person() Worker.prototype.constructor=Worker //别忘记修正constructor的指向 var worker1 = new Worker()
- 缺点:很常用的继承方式,但也有缺点,就是代码第11、13行合计调用了两次父类函数,造成了不必要的消耗。
6.4.原型式继承
用到了object(),规范化之后即为Object.create()
- 核心:利用Object.create()对传入其中的对象进行浅拷贝
var Person = { age: [6,12,24] } var worker1 = Object.create(Person) var worker2 = Object.create(Person)
- 缺点:和原型链继承一样,存在引用属性共享的问题。
worker1.age.push(48) alert(worker1.age) //[6,12,24,48] alert(worker2.age) //[6,12,24,48]
原因很好解释,因为worker1无age属性,因此向它的原型查找,它的原型恰好就是Person对象。因此实际上是在改动Person的age属性。
6.5.寄生继承
- 核心:创建一个函数用于封装继承的过程,在函数内部增强对象,最后将其返回
var Person = { age: [6,12,24] } function createAnother(Person){ var worker0 = Object.create(Person); worker0.shout = function(){ alert("Ahhhhh"); }; return worker0; } var worker1 = createAnother(Person) worker1.shout()
- 缺点:和原型链继承一样,存在引用属性共享的问题;和经典继承一样,无法实现函数复用
6.6.寄生组合继承
- 核心:结合寄生式继承和组合继承的优点,避免为了指定子类的原型而二次调用父类的构造函数
//封装函数。功能:在避免二次调用父类函数的前提下令子类原型指向父类实例 function inheritPrototype(subType, superType){ var obj = Object.create(superType.prototype); subType.prototype = obj; subType.prototype.constructor = subType; //修正constructor的指向 } // 父类初始化实例属性和原型属性 function Person(){ this.age=[6,12,24] } Person.prototype.shout = function(){ alert("Ahhhhhh"); }; // 借用构造函数传递增强子类实例属性(支持传参和避免篡改) function Worker(){ Person.call(this); } // 调用函数,令子类原型指向父类实例 inheritPrototype(Worker, Person);
- 优点:基本完美的继承方式,无任何缺点,也是目前库实现的方式。
6.7.extends类继承
// 父类 class Person { constructor(name,age) { this.name = name; this.age = age; } shout() { alert("Ahhhhhh"); } } //子类继承父类 class Worker extends Person{ constructor(name,age,job){ super(name,age); this.job = job; } work() { alert("I am working"); } }
- 解释:可以看作是ES6新增的语法糖,使得js中继承的写法更趋向于传统的面向对象语言。super是关键字,代表父类构造函数,只有在子类的构造函数中调用super()函数,才能让父类构造出this给子类去丰富。
参考: http://www.cnblogs.com/wangfupeng1988/p/3978131.html https://www.cnblogs.com/chengzp/p/prototype.html https://juejin.im/post/5c6a9c10f265da2db87b98f3 https://www.cnblogs.com/94pm/p/9113434.html https://segmentfault.com/a/1190000016891009