完全弄懂JS原型与继承

本文由浅到深,按部就班的将原型与继承的抽象概念形象化,且每一个知识点都搭配相应的例子,尽量的将其通俗化,并且本文最大的优势就是:长(为了更详细嘛)。git

1、原型

首先,咱们先说说原型,但说到原型就得从函数提及,由于原型对象就是指函数所拥有的prototype属性(因此下文有时说原型,有时说prototype,它们都是指原型)。github

1.1 函数

说到函数,咱们得先有个概念:函数也是对象,和对象同样拥有属性,例如:浏览器

function F(a, b) {
    return a * b;
}

F.length   // 2 指函数参数的个数
F.constructor   // function Function() { [native code] }
typeof F.prototype  // "object"
复制代码

从上面咱们能够看出函数和对象同样拥有属性,咱们重点说的就是prototype这个原型属性。app

prototype也是一个对象,为了更形象的理解,我我的是把上述理解为这样的:函数

// F这个函数对象里有个prototype对象属性
F = {
    prototype: {}
}
复制代码

下面咱们就说说这个prototype对象属性。ui

1.2 prototype对象的属性

prototype是一个对象,里面有个默认属性constructor,默认指向当前函数,咱们依旧使用F这个函数来讲明:this

F = {
    prototype: {
        constructor: F    // 指向当前函数
    }
}
复制代码

既然prototype是个对象,那咱们也一样能够给它添加属性,例如:spa

F.prototype.name = 'BetterMan';

// 那F就变成以下:
F = {
    prototype: {
        constructor: F,
        name: 'BetterMan'
    }
}
复制代码

prototype就先铺垫到这,下面咱们来讲说对象,而后再把它们串起来。prototype

1.3 建立对象

建立对象有不少种方式,本文针对的是原型,因此就说说使用构造函数建立对象这种方式。上面的F函数其实就是一个构造函数(构造函数默认名称首字母大写便于区分),因此咱们用它来建立对象。指针

let f = new F();
console.log(f)  // {}
复制代码

这时获得了一个“空”对象,下面咱们过一遍构造函数建立对象的过程:

  1. 建立一个新对象;
  2. 将构造函数的做用域赋给新对象,即把this指向新对象(同时还有一个过程,新对象的__proto__属性指向构造函数的ptototype属性,后面会解释这块)。
  3. 执行函数内代码,即为新对象添加属性。
  4. 返回新对象(不须要写,默认返回this,this就是指新对象)。

下面咱们修改一下F构造函数:

function F(age) {
    this.age = age;
}
复制代码

再用F来建立一个实例对象:

let f1 = new F(18);  // 18岁,别来无恙
console.log(f1); // {age: 18}
复制代码

其实咱们就获得了一个f1对象,里面有一个age属性,但真的只有age属性吗?上面咱们讲到构造函数建立对象的过程,这里的新建对象,而后给对象添加属性,而后返回新对象,咱们都是看获得的,还有一个过程,就是新对象的__proto__属性指向构造函数的ptototype属性。

咱们打印一下看看:

console.log(f1.__proto__);  // {constructor: F}
复制代码

这不就是F构造函数的prototype对象吗?这个指向过程也就至关于f1.__proto__ === F.prototype,理解这个很重要!

__proto__咱们可称为隐式原型(不是全部浏览器都支持这个属性,因此谷歌搞起),这个就厉害了,既然它指向了构造函数的原型,那咱们获取到它也就能获取到构造函数的原型了(但通常咱们不用这个方法获取原型,后面会介绍其余方法)。

前面咱们说了构造函数的prototype对象中的constructor属性是指向自身函数的,那咱们用__proto__来验证一下:

console.log(f1.__proto__.constructor);  // F(age) {this.age = age;}
// 由于f1.__proto__ === F.prototype,因此上述就是指F.prototype.constructor
复制代码

嗯,不错不错,看来没毛病!

目前来讲应该仍是比较好理解的,那咱们再看看:

console.log(f1.constructor);  // F(age) {this.age = age;}
复制代码

额,这什么鬼?难道实例对象f1还有个constructor属性和构造函数原型的constructor同样都是指向构造函数?这就有点意思了。

其实不是,应该是说f1的神秘属性__proto__指向了F.prototype,这至关于一个指向引用,若是要形象点的话能够把它理解为把F.prototype的属性"共享"到了f1身上,但这是动态的"共享",若是后面F.prototype改变的话,f1所"共享"到的属性也会跟着改变。理解这个很重要!重要的事情说三遍!重要的事情说三遍!重要的事情说三遍!

那咱们再把代码"形象化":

F = {
    prototype: {
        constructor: F
    }
};

f1 = {
    age: 18,
    __proto__: {    // 既然咱们已经把这个形象化为"共享"属性了,那就再形象一点
        constructor: F
    }
}

// 更形象化:
f1 = {
    age: 18,  // 这个是f1对象自身属性
    constructor: F  // 这个是从原型上"共享"的属性
}
复制代码

既然咱们说的是动态"共享"属性,那咱们改一改构造函数的prototype属性看看f1会不会跟着改变:

// 没改以前
console.log(f1.name);  // undefined

// 修改以后
F.prototype.name = 'BetterMan';
console.log(f1);   // {age: 18}
console.log(f1.name);  // 'BetterMan'
复制代码

A(读A第二调)……,看来和想的一毛同样啊,可是f1上面没看到name属性,那就是说咱们只是能够从构造函数的原型上拿到name属性,而不是把name变为实例对象的自身属性。说到这里就得提提对象自身属性和原型属性(从原型上得来的属性)了。

1.4 对象自身属性和原型属性

咱们所建立的实例对象f1,有自身属性age,还有从原型上找到的属性name,咱们可使用hasOwnProperty方法检测一下:

console.log(f1.hasOwnProperty('age'));  // true 说明是自身属性
console.log(f1.hasOwnProperty('name')); // false 说明不是自身属性
复制代码

那既然是对象属性,应该就能够添加和删除吧?咱们试试:

delete f1.age;
console.log(f1.age); // undefined

delete f1.name;
console.log(f1.name); // 'BetterMan'
复制代码

额,age属性删除成功了,但好像name没什么反应,比较坚挺,这就说明了f1对象能够掌控自身的属性,爱删删爱加加,但name属性是从原型上获得的,是别人的属性,你可没有权利去修改。

其实咱们在访问对象的name属性时,js引擎会依次查询f1对象上的全部属性,可是找不到这个属性,而后就会去建立f1实例对象的构造函数的原型上找(这就归功于神秘属性__proto__了,是它把实例对象和构造函数的原型联系了起来),而后找到了(若是再找不到的话,还会往上找,这就涉及到原型链了,后面咱们会说到)。而找age属性时直接就在f1上找到了,就不用再去其余地方找了。

到如今你们应该对原型有了个大概的理解了吧,但它有什么用呢? 用处大大的,能够说咱们无时无刻都在使用它,下面咱们继续。

2、继承

讲了原型,那确定是离不开继承这个话题的,说到继承就很热闹了,什么原型模式继承、构造函数模式继承、对象模式继承、属性拷贝模式继承、多重继承、寄生式继承、组合继承、寄生组合式继承……这什么鬼?这么多,看着是否是很头疼?

我我的就把它们分为原型方式、构造函数方式、对象方式这三个方式,而后其余的继承方式都是基于这三个方式的组合,固然这只是我我的的理解哈,下面咱们开始。

2.1 原型链

说到继承,确定得说原型链,由于原型链是继承的主要方法。

咱们先来简单的回顾一下构造函数、原型和实例的关系:每一个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针(constructor),而实例包含一个指向原型对象的内部指针(__proto__)。那么,假如咱们让原型对象等于另外一个实例对象,结果会怎么样呢?显然,此时的原型对象将包含一个指向另外一个原型的指针(__proto__),相应的,另外一个原型中也包含着一个指向另外一个构造函数的指针(constructor)。那假如另外一个原型又是另外一个对象实例,那么上述关系依然成立,如此层层递进,就构成了实例与原型的链条。这就是所谓的原型链,如图:

到这里千万不要乱,必定要理解了这段话再往下看,其实就是把别人的实例对象赋值给了咱们的构造函数的原型,这就是第一层,而后若是别人的实例对象的构造函数的原型又是另外一我的的实例对象的话,那不是同样的道理吗?这就是第二层,那若是再出现个第三者,那又是一层了,这就构成了一个层层连起来的原型链。

好了,若是你看到了这里,说明已经理解了上述"链情",那咱们就开始搞搞继承。

2.2 继承方式

继承有多重形式,咱们一个个来,分别对比一下其中的优缺点。

注:由于多数继承都依赖于原型及原型链,因此当再依赖于其余方式时,我就以这个方式来命名这个继承方式,这样看起来就不会那么复杂。

1. 基于构造函数方式

咱们先定义三个构造函数:

// 构造函数A
function A() {
    this.name = 'A';
};
A.prototype.say = function() {
    return this.name;
};
// 构造函数B
function B() {
    this.name = 'B';
};
// 构造函数C
function C(width, height) {
    this.name = 'C';
    this.width = width;
    this.height = height;
    this.getArea = function() {
        return this.width * this.height;
    };
};
复制代码

下面咱们试试继承:

B.prototype = new A();
C.prototype = new B();
复制代码

上述是否是有点熟悉,是否是就是前面所提的原型链的概念:B构造函数的原型被赋上A构造函数的实例对象,而后C的原型又被赋上B构造函数的实例对象。

而后咱们用C构造函数来建立一个实例对象:

let c1 = new C(2, 6);
console.log(c1);   // {name: "C", width: 2, height: 6, getArea: ƒ}
console.log(c1.name);  // 'C'
console.log(c1.getArea()); // 12
console.log(c1.say());  // 'C'
复制代码

c1竟然有say方法了,可喜可贺,它是怎么作到的?让咱们来捋捋这个过程:

  • ①首先C新建了一个"空"对象;
  • ②而后this指向这个"空"对象;
  • ③c1.__proto__指向C.prototype;
  • ④给this对象赋值,这样就有了namewidthheightgetArea这四个自身属性;
  • ⑤返回this对象,此时咱们就获得了c1实例对象;
  • ⑥而后打印console.log(c1)console.log(c1.name)console.log(c1.getArea())都好理解;
  • ⑦接着console.log(c1.say()),这就得去找say方法了,js引擎先在c1身上找,没找到,而后c1.__proto__这个神秘连接是指向C构造函数的原型的,而后就去C.prototype上找,而后咱们是写有C.prototype = new B()的,也就是说是去B构造函数的实例对象上找,仍是没有,那继续,又经过new B().__proto__B的原型上找,而后咱们是写有B.prototype = new A();,那就是去A所建立的实例对象上找,没有,那就又跑去A构造函数的原型上找,OK!找到!

这个过程就至关于这样: c1 —→ C.prototype —→ new B() —→ B.prototype —→ new A() —→ A.prototype

这就是上述的一个基于构造函数方式的继承过程,其实就是一个查找过程,可是你们有没有发现什么?

上述方式存在两个问题:第一个问题就是constructor的指向。

原本B.prototype中的constructor指向好好的,是指向B的,但如今B.prototype彻底被new A()给替换了,那如今的B.prototype.constructor是指向谁的?咱们看看:

console.log(B.prototype.constructor);  // ƒ A() {}
let b1 = new B();
console.log(b1.constructor);   // ƒ A() {}
复制代码

此时咱们发现不只是B.prototype.constructor指向A,连b1也是如此,别忘了b1中的constructor属性也是由B.prototype所共享的,因此老大(B)改变了,小弟(b1)固然也会跟着动态改变。

但如今它们为何是指向A的呢?由于B.prototype被替换为了new A(),那new A()里有什么?咱们再把B.prototypenew A()形象化来表示一下:

A = {
    prototype:{
        constructor: A
    }
};

new A() = {
    name: 'A',
    say: function() {
        return this.name;
    },
    constructor: A       // 由__proto__的指向所共享获得的
}

B = {
    prototype:{
        constructor: B
    }
};

// 这时把B.prototype换为new A(),那就变成了这样:
B = {
    prototype:{
        name: 'A',
        say: function() {
            return this.name;
        },
        constructor: A   // 因此指向就变成了A
    }
};
复制代码

因此咱们要手动修正B.prototype.constructor的指向,同理C.prototype.constructor的指向也是如此:

B.prototype = new A();
B.prototype.constructor = B;
C.prototype = new B();
C.prototype.constructor = C;
复制代码

第一个问题解决了,到第二个问题:效率的问题。

当咱们用某一个构造函数建立对象时,其属性就会被添加到this中去。而且当别添加的属性其实是不会随着实例改变时,这种作法会显得没有效率。例如在上面的实例中,A构造函数是这样定义的:

function A() {
    this.name = 'A';
    this.say = function() {
        return this.name;
    };
};
复制代码

这种实现意味着咱们用new A()建立的每一个实例都会拥有一个全新的name属性和say属性,并在内存中拥有独立的存储空间。因此咱们应该考虑把这些属性放到原型上,让它们实现共享:

// 构造函数A
function A() {};
A.prototype.name = 'A';
A.prototype.say = function() {
    return this.name;
};

// 构造函数B
function B() {};
B.prototype.name = 'B';

// 构造函数C
function C(width, height) {  // 此处的width和height属性是随参数变化的,因此就不须要改成共享属性
    this.width = width;
    this.height = height;
};
C.prototype.name = 'C';
C.prototype.getArea = function() {
    return this.width * this.height;
};
复制代码

这样一来,构造函数所建立的实例中一些属性就再也不是私有属性了,而是在原型中能共享的属性,如今咱们来试试:

let test1 = new A();
let test2 = new A();
console.log(test1.say === test2.say);  // true 没改成共享属性前,它们是不相等的
复制代码

虽然这样作一般更有效率,但也只是针对实例中不可变属性而言的,因此在定义构造函数时咱们也要考虑哪些属性适合共享,哪些适合私有(且必定要继承后再对原prototype进行扩展和矫正constructor)。

2. 基于原型的方式

正如上面所作的,处于效率考虑,咱们应当尽量的将一些可重用的属性和方法添加到原型中去,这样的话咱们仅仅依靠原型就能够完成继承关系的构建了,因为原型上的属性都是可重用的,这也意味着从原型上继承比在实例上继承要好得多,并且既然须要继承的属性都放在了原型上,又何须生成实例下降效率,而后又从所生成的实例中继承不须要的私有属性呢?因此咱们直接抛弃实例,从原型上继承:

// 构造函数A
function A() {};
A.prototype.name = 'A';
A.prototype.say = function() {
    return this.name;
};

// 构造函数B
function B() {};
B.prototype = A.prototype;  // 先继承,再进行constructor矫正和B.prototype的扩展
B.prototype.constructor = B; 
B.prototype.name = 'B';

// 构造函数C
function C(width, height) {  // 此处的width和height属性是随参数变化的,因此就不须要改成共享属性
    this.width = width;
    this.height = height;
};
C.prototype = B.prototype;
C.prototype.constructor = C; // 先继承,再进行constructor矫正和C.prototype的扩展
C.prototype.name = 'C';
C.prototype.getArea = function() {
    return this.width * this.height;
};
复制代码

嗯,这样感受效率高多了,也比较养眼,而后咱们试试效果:

let b2 = new B();
console.log(b2.say());  // 'C'
复制代码

(⊙o⊙)…不是应该打印出B的吗?怎么和我心里的小完美不太同样?

想必你们应该都看出来了,上面的继承方式其实就至关于A、B、C全都共享了同一个原型,那就形成了引用问题,在后面对C原型上的name属性进行了修改,因此此时ABC的原型的name属性都为'C',此时真的是受制于人啊。

有没有一箭双鵰的办法,我又要效率,又不想受制于人,啪!把这两个方法结合起来不就好了吗?!

3. 结合构造函数方式和原型的方式

我既想快,又不想被别人管,搞个第三者来解决怎么样?(怎么感受听起来怪怪的)。咱们在它们中间使用一个临时构造函数(因此也可称为临时构造法)来作个桥梁,把小弟管大哥的关系断掉(腿打断),而后你们又能够高效率的合做:

// 构造函数A
function A() {};
A.prototype.name = 'A';
A.prototype.say = function() {
    return this.name;
};

// 构造函数B
function B() {};
let X = function() {};   // 新建一个"空"属性的构造函数
X.prototype = A.prototype;  // 将X的原型指向A的原型
B.prototype = new X();  // B的原型指向X建立的实例对象
B.prototype.constructor = B;  // 记得修正指向
B.prototype.name = 'B';       // 扩展

// 构造函数C
function C(width, height) {  // 此处的width和height属性是随参数变化的,因此就不须要改成共享属性
    this.width = width;
    this.height = height;
};
// 同上
let Y = function() {};  
Y.prototype = B.prototype;
C.prototype = new Y();
C.prototype.constructor = C;
C.prototype.name = 'C';
C.prototype.getArea = function() {
    return this.width * this.height;
};
复制代码

如今试试效果怎么样:

let c3 = new C;
console.log(c3.say());  // C
复制代码

稳!这样咱们既不是直接继承实例上的属性,而是继承原型所共享的属性,并且还能经过XY这两个"空"属性构造函数来把A和B上的非共享属性过滤掉(由于new X()比起new A()所生成的实例,由于X是空的,因此生成的对象不会存在私有属性,可是new A()可能会存在私有属性,既然是私有属性,因此也就是不须要被继承,因此new A()会存在效率问题和多出不须要的继承属性)。

4. 基于对象的方式

这种基于对象的方式其实包括几种方式,由于都和对象相关,因此我就统称为对象方式了,下面一一介绍:

①以接收对象的方式

function create(o) {  // o是所要继承的父对象
    function F() {};
    F.prototype = o;
    return new F();  // 返回一个实例对象
};
let a = {
    name: 'better'
};
console.log(create(a).name);  // 'better'
复制代码

这种方式是接受一个父对象后返回一个实例,进而达到继承的效果,有没有点似曾相识的感受?这不就是低配版的Object.create()吗?有兴趣的能够多去了解了解。因此这个方式其实也应该称为"原型继承法",由于也是以修改原型为基础的,但又和对象相关,因此我就把它归为对象方式了,这样比较好分类。

②以拷贝对象属性的方式

// 直接将父原型的属性拷贝过来,好处是Child.prototype.constructor没被重置,但这种方式仅适用于只包含基本数据类型的对象,且父对象会覆盖子对象的同名属性
function extend(Child, Parent) {   // Child, Parent都为构造函数
    let c = Child.prototype;
    let p = Parent.prototype;
    for (let i in p) {
        c[i] = p[i];
    }
};
复制代码
// 这种直接拷贝属性的方式简单粗暴,直接复制传入的对象属性,但仍是存在引用类型的问题
function extendCopy(p) {   // p是被继承的对象
    let c = {};
    for (let i in p) {
        c[i] = p[i];
    }
    return c;
};
复制代码
// 上面的extendCopy可称为浅拷贝,没有解决引用类型的问题,如今咱们使用深拷贝,这样就解决了引用类型属性的问题,由于无论你有多少引用类型,全都一个个拷过来
function deepCopy(p, c) {  // c和p都是对象
    c = c || {};
    for (let i in p) {
        if (p.hasOwnProperty[i]) {   // 排除继承属性
            if (typeof p[i] === 'object') {  // 解决引用类型
                c[i] = Array.isArray(p[i]) ? [] : {};
                deepCopy[p[i], c[i]];
            } else {
                c[i] = p[i];
            }
        }
    }
    return c;
}
复制代码

③拷贝多对象属性的方式

// 这种方式就能够一次拷贝多个对象属性,也称为多重继承
function multi() {
    let n = {},
    stuff,
    j = 0,
    len = arguments.length;
    for (j = 0; j < len; j++) {
        stuff = arguments[j];
        for (let i in stuff) {
            if (stuff.hasOwnProperty(i)) {
                n[i] = stuff[i];
            }
        }
    }
    return n
};
复制代码

④吸取对象属性并扩展的方式

这种方式其实应该叫作"寄生式继承",这名字乍看很抽象,其实也就那么回事,因此也把它分到对象方式里:

// 其实也就是在建立对象的函数中吸取了其它对象的属性(寄生兽把别人的xx吸走),而后对其扩展并返回
let parent = {
    name: 'parent',
    toString: function() {
        return this.name;
    }
};
function raise() {
    let that = create(parent);  // 使用前面咱们写过的create函数
    that.other = 'Once in a blue moon!'; // 今天学的,丑显呗一下
    return that;
}
复制代码

和对象相关的方式是否是有点多?但其实也都是围绕着对象属性的,理解这点就好理解了,下面继续。

5. 构造函数借用法

这个方式其实也可归为构造函数方式,但比较溜,因此单独拎出来溜溜(这是最后一个了,我保证)。

咱们再把以前定义的老函数A拿出来炒炒:

// 构造函数A
function A() {
    this.name = 'A';
};
A.prototype.say = function() {
    return this.name;
};

// 构造函数D
function D() {
    A.apply(this, arguments);  // 这里就至关于借用A构造函数把A中属性建立给了D,即name和say属性
};
D.prototype = new A();  // 这里负责拿到A原型上的属性
D.prototype.name = 'D';  // 继承后再进行扩展
复制代码

这样两个步骤是否是就把A的自身属性和原型属性都搞定了?简单完美!

等等,看起来好像有点不对,A.apply(this, arguments)已经完美的把A自身属性变为了D的自身属性,可是D.prototype = new A()又把A的自身属性继承了一次,真是画蛇添足,既然咱们只是单纯的想要原型上的属性,那直接拷贝不就完事了吗?

// 构造函数A
function A() {
    this.name = 'A';
};
A.prototype.say = function() {
    return this.name;
};

// 以前定义的属性拷贝函数
function extend2(Child, Parent) {
    let c = Child.prototype;
    let p = Parent.prototype;
    for (let i in p) {
        c[i] = p[i];
    }
};

// 构造函数D
function D() {
    A.apply(this, arguments);  // 这里就至关于借用A构造函数把A中属性建立给了D,即name和say属性
};
extend2(D, A);  // 这里就直接把A原型的属性拷贝给了D原型
D.prototype.name = 'D';  // 继承后在进行扩展

let d1 = new D();
console.log(d1.name);  // 'A'
console.log(d1.__proto__.name)  // undefined 这就说明了name属性是新建的,而不是继承获得的
复制代码

(⊙o⊙)…,其实还有其它的继承方法,仍是不写了,怕被打,但其实来来去去就是基于原型、构造函数、对象这几种方式搞来搞去,我我的就是这么给它们分类的,毕竟七秒记忆放不下,囧。

最后

写到这里,终于咽下了最后一口气,呸,松了一口气。也感谢你看到了最后,但愿对你有所帮助,有写得不对的地方还请多多指教,喜欢的就关注一波吧,后续会持续更新。

github源码

相关文章
相关标签/搜索