这几天在掘金上阅读到了一篇关于原型的文章,角度较之前看到的几篇博客都不一样,顿时感觉我对于原型的知识点还没有完全吃透。鉴于本篇文章很可能会进行不定期的修订和拓展,故在此附上更新日志,以简单记录我在学习上的认知更新。

1.创建对象的方法

在了解原型链之前,首先先了解一下创建对象的几种方式,为后面做个铺垫。介绍以下三种。

代码:

// 第一种方式:字面量
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)

打印结果:

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.在构造函数的内部添加方法。这样做的话在每次用构造函数创建实例时都会大量产生方法的副本,这些方法副本功能一样,实际却是不同的。这会影响性能,且不利于代码复用;

这时,就该用上原型了。只要给构造函数的原型添加一个方法,那么构造函数的所有实例便都有了这个方法。接着上面的例子继续演示:

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.xxx 定义);如果没找到就去实例的原型中找,还没找到就再沿着原型链往上找,直到找到。当然,不止方法,属性也是可以继承自原型的。

那么怎么判断属性是实例本身具有的还是继承的?调用实例的 hasOwnProperty() 方法即可。那么实例为何有这个方法?同样是继承来的。由于所有的对象的原型链都会找到追溯到 Object.prototype,因此所有的对象都会有 Object.prototype 的方法,其中就包括 hasOwnProperty() 方法 。

2.4 访问原型

要访问实例对象的原型,可以用 obj.__proto__ 或者 Object.getPrototypeOf(obj)
__proto__ 属性在 ES6 时才被标准化,以确保 Web 浏览器的兼容性,但是不推荐使用,更不推荐通过这种方式修改实例的原型,除了标准化的原因之外还有性能问题。因此为了更好的支持,推荐使用 Object.getPrototypeOf(obj)

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__ —> …—> null 这条路线来找,如果构造函数的 prototype 在这条路线上出现过,那么就返回 true,否则返回 false。如下图,很显然 f1 实例的原型链上出现过 Foo 构造函数的 prototype,所以 f1 instanceof Object 返回 true。

注意:从 instanceof 的查找原理也可以看出,在实例的原型链上出现过的构造函数,都可以通过 instanceof 检测。

继续上面的代码

那怎么判断实例是由哪个构造函数生成的呢?这时候就要用到 constructor 了:

4.constructor 属性

4.1 定义

构造函数的 prototype 属性指向它的原型对象,在原型对象中则有一个 constructor 属性,指向该构造函数。值类型(除了 null 和undefined,这两者不具有这个属性)的 constructor 是只读的,不可修改,引用类型的 constructor 是可修改的,例如下文提到的修复指向。

4.2 修复 constructor 的指向:

为了实现从父类到子类方法的继承,一般会重写构造函数的原型,如:

function Person(){
    //.........
}
function Student(){
    //.........
}
Student.prototype = new Person()
var student = new Obj()

这将使得实例 student 具有构造函数 Person 的方法,但同时也会导致 constructor 的指向出现问题,造成继承链的紊乱,因此为了修复这个错误指向,需要显式指定 obj.prototype.constructor = obj 。拿下面例子说明:

function Animal { }
Animal.prototype.say = function(){
    console.log('wan');
}
var dog = new Animal()
Animal.prototype = {
    say: fucntion(){
    	console.log('miao')
    }
}
var cat = new Animal()

未重写原型对象之前,实例化了一个 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 方法,而cat 也可以调用 say 方法。

总结:
重写原型对象之后,会切断构造函数与最初原型之间的连接,使新构造的实例对象的原型指向重写的原型,而先前构造的实例对象的原型还是指向最初原型。在这种情况下,先前的实例对象可以使用最初原型的方法,新的实例对象可以使用重写的原型的方法。

5.new 和 Object.create()

这里,让我们回到文章开头提到的创建对象的三种方式。重点介绍后两种。

5.1 new

new一个构造函数时,实际发生的过程是:

var o = {};
o.__proto__ = M.prototype
M.call(o)
  • 第一步,创建一个空对象o;
  • 第二步,令空对象的 __proto__ 指向构造函数 M 的 prototype
  • 第三步,执行构造函数 ,且令构造函数中的 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();
  • 缺点:1、创建子类实例时,无法向父类构造函数传参;2、由于子类原型是单一实例,所以对一个子类实例的引用类型属性的操作将会影响其他子类实例,即引用属性共享
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;
    this.getName = function(){
        return this.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);
  • 优点:基本完美的继承方式,无任何缺点,也是目前库实现的方式。
  • PS:这里不使用 subType.prototype = new superType() 是为了不重复调用父类函数

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