彻底弄懂JS原型与继承
栏目: JavaScript · 发布时间: 6年前
内容简介:本文由浅到深,循序渐进的将原型与继承的抽象概念形象化,且每个知识点都搭配相应的例子,尽可能的将其通俗化,而且本文最大的优点就是:长(为了更详细嘛)。首先,我们先说说原型,但说到原型就得从函数说起,因为原型对象就是指函数所拥有的说到函数,我们得先有个概念:
本文由浅到深,循序渐进的将原型与继承的抽象概念形象化,且每个知识点都搭配相应的例子,尽可能的将其通俗化,而且本文最大的优点就是:长(为了更详细嘛)。
一、原型
首先,我们先说说原型,但说到原型就得从函数说起,因为原型对象就是指函数所拥有的 prototype
属性(所以下文有时说原型,有时说 prototype
,它们都是指原型)。
1.1 函数
说到函数,我们得先有个概念: 函数也是对象,和对象一样拥有属性 ,例如:
function F(a, b) { return a * b; } F.length // 2 指函数参数的个数 F.constructor // function Function() { [native code] } typeof F.prototype // "object" 复制代码
从上面我们可以看出函数和对象一样拥有属性,我们重点说的就是 prototype
这个原型属性。
prototype
也是一个对象,为了更形象的理解,我个人是把上述理解为这样的:
// F这个函数对象里有个prototype对象属性 F = { prototype: {} } 复制代码
下面我们就说说这个 prototype
对象属性。
1.2 prototype对象的属性
prototype
是一个对象,里面有个默认属性 constructor
,默认指向当前函数,我们依旧使用F这个函数来说明:
F = { prototype: { constructor: F // 指向当前函数 } } 复制代码
既然 prototype
是个对象,那我们也同样可以给它添加属性,例如:
F.prototype.name = 'BetterMan'; // 那F就变成如下: F = { prototype: { constructor: F, name: 'BetterMan' } } 复制代码
prototype
就先铺垫到这,下面我们来说说对象,然后再把它们串起来。
1.3 创建对象
创建对象有很多种方式,本文针对的是原型,所以就说说使用构造函数创建对象这种方式。上面的 F
函数其实就是一个构造函数(构造函数默认名称首字母大写便于区分),所以我们用它来创建对象。
let f = new F(); console.log(f) // {} 复制代码
这时得到了一个“空”对象,下面我们过一遍构造函数创建对象的过程:
- 创建一个新对象;
- 将构造函数的作用域赋给新对象,即把this指向新对象(同时还有一个过程,新对象的__proto__属性指向构造函数的ptototype属性,后面会解释这块)。
- 执行函数内代码,即为新对象添加属性。
- 返回新对象(不需要写,默认返回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.1 原型链
说到继承,肯定得说原型链,因为原型链是继承的主要方法。
我们先来简单的回顾一下构造函数、原型和实例的关系:每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针( constructor
),而实例包含一个指向原型对象的内部指针( __proto__
)。那么,假如我们让原型对象等于另一个实例对象,结果会怎么样呢?显然,此时的原型对象将包含一个指向 另一个原型 的指针( __proto__
),相应的, 另一个原型 中也包含着一个指向另一个构造函数的指针( constructor
)。那假如 另一个原型 又是另一个对象实例,那么上述关系依然成立,如此层层递进,就构成了实例与原型的链条。这就是所谓的原型链,如图:
到这里千万不要乱,一定要理解了这段话再往下看,其实就是把 别人的实例对象 赋值给了我们的构造函数的原型,这就是第一层,然后如果 别人的实例对象 的构造函数的原型又是另一个人的实例对象的话,那不是一样的道理吗?这就是第二层,那如果再出现个第三者,那又是一层了,这就构成了一个层层连起来的原型链。
好了,如果你看到了这里,说明已经理解了上述"链情",那我们就开始搞搞继承。
2.2 继承方式
继承有多重形式,我们一个个来,分别对比一下其中的优缺点。
注:因为多数继承都依赖于原型及原型链,所以当再依赖于其他方式时,我就以这个方式来命名这个继承方式,这样看起来就不会那么复杂。
1. 基于构造函数方式
我们先定义三个构造函数:
// 构造函数A function A() { this.name = 'A'; this.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对象赋值,这样就有了
name
、width
、height
、getArea
这四个自身属性; - ⑤返回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.prototype
和 new 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 c2 = new C(); console.log(c2.say()); // 'A' 复制代码
(⊙o⊙)…不是应该打印出 C
的吗?怎么和我内心的小完美不太一样?
想必大家应该都看出来了,上面的继承方式其实就相当于 A、B、C
全都共享了 A
的原型,那就造成了引用问题,要是 C
的原型属性修改了,那 A
和 B
的原型属性岂不是都被修改了?想想就委屈,小弟居然管起大哥来了。
有没有两全其美的办法,我又要效率,又不想委屈,啪!把这两个方法结合起来不就行了吗?!
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()); // A 复制代码
稳!这样我们既不是直接继承实例上的属性,而是继承原型所共享的属性,而且还能通过 X
和 Y
这两个"空"属性构造函数来把 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⊙)…,其实还有其它的继承方法,还是不写了,怕被打,但其实来来去去就是基于原型、构造函数、对象这几种方式搞来搞去,我个人就是这么给它们分类的,毕竟七秒记忆放不下,囧。
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。