资讯专栏INFORMATION COLUMN

剖析JS的原型链和继承

darkerXi / 3056人阅读

摘要:接下来我们来聊一下的原型链继承和类。组合继承为了复用方法,我们使用组合继承的方式,即利用构造函数继承属性,利用原型链继承方法,融合它们的优点,避免缺陷,成为中最常用的继承。

JavaScript是一门面向对象的设计语言,在JS里除了nullundefined,其余一切皆为对象。其中Array/Function/Date/RegExp是Object对象的特殊实例实现,Boolean/Number/String也都有对应的基本包装类型的对象(具有内置的方法)。传统语言是依靠class类来完成面向对象的继承和多态等特性,而JS使用原型链和构造器来实现继承,依靠参数arguments.length来实现多态。并且在ES6里也引入了class关键字来实现类。
接下来我们来聊一下JS的原型链、继承和类。

函数与对象的关系

有时我们会好奇为什么能给一个函数添加属性,函数难道不应该就是一个执行过程的作用域吗?

var name = "Leon";
function Person(name) {
    this.name = name;
    this.sayName = function() {
        alert(this.name);
    }
}
Person.age = 10;
console.log(Person.age);    // 10
console.log(Person);
/* 输出函数体:
ƒ Person(name) {
    this.name = name;
}
*/

我们能够给函数赋一个属性值,当我们输出这个函数时这个属性却无影无踪了,这到底是怎么回事,这个属性又保存在哪里了呢?

其实,在JS里,函数就是一个对象,这些属性自然就跟对象的属性一样被保存起来,函数名称指向这个对象的存储空间。
函数调用过程没查到资料,个人理解为:这个对象内部拥有一个内部属性[[function]]保存有该函数体的字符串形式,当使用()来调用的时候,就会实时对其进行动态解析和执行,如同eval()一样。

上图是JS的具体内存分配方式,JS中分为值类型和引用类型,值类型的数据大小固定,我们将其分配在栈里,直接保存其数据。而引用类型是对象,会动态的增删属性,大小不固定,我们把它分配到内存堆里,并用一个指针指向这片地址,也就是Person其实保存的是一个指向这片地址的指针。这里的Person对象是个函数实例,所以拥有特殊的内部对象[[function]]用于调用。同时它也拥有内部属性arguments/this/name,因为不相关,这里我们没有绘出,而展示了我们为其添加的属性age。

函数与原型的关系

同时在JS里,我们创建的每一个函数都有一个prototype(原型)属性,这个属性是一个指针,指向一个用于包含该对象所有实例的共享属性和方法的对象。而这个对象同时包含一个指针指向这个这个函数,这个指针就是constructor,这个函数也被成为构造函数。这样我们就完成了构造函数和原型对象的双向引用。

而上面的代码实质也就是当我们创建了Person构造函数之后,同步开辟了一片空间创建了一个对象作为Person的原型对象,可以通过Person.prototype来访问这个对象,也可以通过Person.prototype.constructor来访问Person该构造函数。通过构造函数我们可以往实例对象里添加属性,如上面的例子里的name属性和sayName()方法。我们也可以通过prototype来添加原型属性,如:

Person.prototype.name = "Nicholas";
Person.prototype.age = 24;
Person.prototype.sayAge = function () {
    alert(this.age);
};

这些原型对象为实例赋予了默认值,现在我们可以看到它们的关系是:

要注意属性和原型属性不是同一个东西,也并不保存在同一个空间里:

Person.age; // 10
Person.prototype.age; // 24
原型和实例的关系

现在有了构造函数和原型对象,那我们接下来new一个实例出来,这样才能真正体现面向对象编程的思想,也就是继承

var person1 = new Person("Lee");
var person2 = new Person("Lucy");

我们新建了两个实例person1和person2,这些实例的内部都会包含一个指向其构造函数的原型对象的指针(内部属性),这个指针叫[[Prototype]],在ES5的标准上没有规定访问这个属性,但是大部分浏览器实现了__proto__的属性来访问它,成为了实际的通用属性,于是在ES6的附录里写进了该属性。__proto__前后的双下划线说明其本质上是一个内部属性,而不是对外访问的API,因此官方建议新的代码应当避免使用该属性,转而使用Object.setPrototypeOf()(写操作)、Object.getPrototypeOf()(读操作)、Object.create()(生成操作)代替。

这里的prototype我们称为显示原型,__proto__我们称为隐式原型

同时由于现代 JavaScript 引擎优化属性访问所带来的特性的关系,更改对象的 [[Prototype]]在各个浏览器和 JavaScript 引擎上都是一个很慢的操作。其在更改继承的性能上的影响是微妙而又广泛的,这不仅仅限于 obj.__proto__ = ... 语句上的时间花费,而且可能会延伸到任何代码,那些可以访问任何[[Prototype]]已被更改的对象的代码。如果你关心性能,你应该避免设置一个对象的 [[Prototype]]。相反,你应该使用 Object.create()来创建带有你想要的[[Prototype]]的新对象。

此时它们的关系是(为了清晰,忽略函数属性的指向,用(function)代指):

在这里我们可以看到两个实例指向了同一个原型对象,而在new的过程中调用了Person()方法,对每个实例分别初始化了name属性和sayName方法,属性值分别被保存,而方法作为引用对象也指向了不同的内存空间。

我们可以用几种方法来验证实例的原型指针到底指向的是不是构造函数的原型对象:

person1.__proto__ === Person.prototype // true
Person.prototype.isPrototypeOf(person1); // true
Object.getPrototypeOf(person2) === Person.prototype; // true
person1 instanceof Person; // true
原型链

现在我们访问实例person1的属性和方法了:

person1.name; // Lee
person1.age; // 24
person1.toString(); // [object Object]

想下这个问题,我们的name值来自于person1的属性,那么age值来自于哪?toString( )方法又在哪定义的呢?

这就是我们要说的原型链,原型链是实现继承的主要方法,其思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。如果我们让一个原型对象等于另一个类型的实例,那么该原型对象就会包含一个指向另一个原型的指针,而如果另一个原型对象又是另一个原型的实例,那么上述关系依然成立,层层递进,就构成了实例与原型的链条,这就是原型链的概念

上面代码的name来自于自身属性,age来自于原型属性,toString( )方法来自于Person原型对象的原型Object。当我们访问一个实例属性的时候,如果没有找到,我们就会继续搜索实例的原型,如果还没有找到,就递归搜索原型链直到原型链末端。我们可以来验证一下原型链的关系:

Person.prototype.__proto__ === Object.prototype // true

同时让我们更加深入的验证一些东西:

Person.__proto__ === Function.prototype // true
Function.prototype.__proto__ === Object.prototype // true

我们会发现Person是Function对象的实例,Function是Object对象的实例,Person原型是Object对象的实例。这证明了我们开篇的观点:JavaScript是一门面向对象的设计语言,在JS里除了null和undefined,其余一切皆为对象

下面祭出我们的原型链图:

根据我们上面讲述的关于prototype/constructor/__proto__的内容,我相信你可以完全看懂这张图的内容。需要注意两点:

构造函数和对象原型一一对应,他们与实例一起作为三要素构成了三面这幅图。最左侧是实例,中间是构造函数,最右侧是对象原型。

最最右侧的null告诉我们:Object.prototype.__proto__ = null,也就是Object.prototype是JS中一切对象的根源。其余的对象继承于它,并拥有自己的方法和属性。

继承 原型链继承

通过原型链我们已经实现了对象的继承,我们具体的实现下:

function Super(name) {
    this.name = name;
    this.colors = ["red", "blue"];
};
function Sub(age) {
    this.age = age;
}
Sub.prototype = new Super("Lee");
var instance = new Sub(20);

instance.name; // Lee
instance.age; // 20

我们通过让Sub类的原型指向Super类的实例,实现了继承,可以在instance上访问name和colors属性。但是,其最大的问题来自于共享数据,如果实例1修改了colors属性,那么实例2的colors属性也会变化。另外,此时我们在子类上并不能传递父类的参数,限制性很大。

构造函数继承

为了解决对象引用的问题,我们调用构造函数来实现继承,保证每个实例拥有相同的父类属性,但值之间互不影响。实质

function Super(name) {
    this.name = name;
    this.colors = ["red", "blue"];
    this.sayName = function() {
        return this.name;
    }
}
function Sub() {
    Super.call(this, "Nicholas");
}
var instance1 = new Sub();
var instance2 = new Sub();
instance1.colors.push("black");

instance1.colors; // ["red", "blue", "black"]
instance2.colors; // ["red", "blue"]

此时我们通过改变父类构造函数的作用域就解决了引用对象的问题,同时我们也可以向父类传递参数了。但是,只用构造函数就很难在定义方法时复用,现在我们创建所有实例时都要声明一个sayName()的方法,而且此时,子类中看不到父类的方法。

组合继承

为了复用方法,我们使用组合继承的方式,即利用构造函数继承属性,利用原型链继承方法,融合它们的优点,避免缺陷,成为JS中最常用的继承。

function Super(name) {
    this.name = name;
    this.colors = ["red", "blue"];
};
function Sub(name, age) {
    // 第二次调用
    Super.call(this, name);
    this.age = age;
}
Super.prototype.sayName = function () {
    return this.name;
};
// 第一次调用
Sub.prototype = new Super();
Sub.prototype.constructor = Sub;
Sub.prototype.sayAge = function () {
    return this.age;
}

var instance = new Sub("lee", 40);
instance.sayName(); // lee
instance.sayAge(); // 40

这时我们全局只有一个函数,不用再给每一个实例新建一个,并且每个实例拥有相同的属性,达到了我们想要的继承。此时instanceof和isPrototypeOf()也能够识别继承创建的对象。
但是依然有一个不理想的地方是,我们会调用两次父类的构造函数,第一次在Sub的原型上设置了name和colors属性,此时name的值是undefined;第二次调用在Sub的实例上新建了name和colors属性,而这个实例属性会屏蔽原型的同名属性。所以这种继承会出现两组属性,这并不是理想的方式,我们试图来解决这个问题。

原型式继承

我们先来看一个后面会用到的继承,它根据已有的对象创建一个新对象。

function create(obj) {
    function F(){};
    F.prototype = obj;
    return new F();
}

var person = {
    name: "Nicholas",
    friends: ["Lee", "Luvy"]
};
var anotherPerson = create(person);

anotherPerson.name; // Nicholas
anotherPerson.friends.push("Rob");
person.friends; // ["Lee", "Luvy", "Rob"]

也就是说我们根据一个对象作为原型,直接生成了一个新的对象,其中的引用对象依然共用,但你同时也可以给其赋予新的属性。

ES5规范化了这个原型继承,新增了Object.create()方法,接收两个参数,第一个为原型对象,第二个为要混合进新对象的属性,格式与Object.defineProperties()相同。

Object.create(null, {name: {value: "Greg", enumerable: true}});
寄生组合式继承
function Super(name) {
    this.name = name;
    this.colors = ["red", "blue"];
};
function Sub(name, age) {
    Super.call(this, name);
    this.age = age;
}
Super.prototype.sayName = function () {
    return this.name;
};

// 我们封装其继承过程
function inheritPrototype(Sub, Super) {
    // 以该对象为原型创建一个新对象
    var prototype = Object.create(Super.prototype);
    prototype.constructor = Sub;
    Sub.prototype = prototype;
}

inheritPrototype(Sub, Super);

Sub.prototype.sayAge = function () {
    return this.age;
}

var instance = new Sub("lee", 40);
instance.sayName(); // lee
instance.sayAge(); // 40

这种方式只调用了一次父类构造函数,只在子类上创建一次对象,同时保持原型链,还可以使用instanceof和isPrototypeOf()来判断原型,是我们最理想的继承方式。

Class类

ES6引进了class关键字,用于创建类,这里的类是作为ES5构造函数和原型对象的语法糖存在的,其功能大部分都可以被ES5实现,不过在语言层面上ES6也提供了部分支持。新的写法不过让对象原型看起来更加清晰,更像面向对象的语法而已。
我们先看一个具体的class写法:

//定义类
class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  toString() {
    return "(" + this.x + ", " + this.y + ")";
  }
}

var point = new Point(10, 10);

我们看到其中的constructor方法就是之前的构造函数,this就是之前的原型对象,toString()就是定义在原型上的方法,只能使用new关键字来新建实例。语法差别在于我们不需要function关键字和逗号分割符。其中,所有的方法都直接定义在原型上,注意所有的方法都不可枚举。类的内部使用严格模式,并且不存在变量提升,其中的this指向类的实例。

new是从构造函数生成实例的命令。ES6 为new命令引入了一个new.target属性,该属性一般用在构造函数之中,返回new命令作用于的那个构造函数。如果构造函数不是通过new命令调用的,new.target会返回undefined,因此这个属性可以用来确定构造函数是怎么调用的。

类存在静态方法,使用static关键字表示,其只能类和继承的子类来进行调用,不能被实例调用,也就是不能被实例继承,所以我们称它为静态方法。类不存在内部方法和内部属性。

class Foo {
  static classMethod() {
    return "hello";
  }
}

Foo.classMethod() // "hello"

var foo = new Foo();
foo.classMethod()
// TypeError: foo.classMethod is not a function

类通过extends关键字来实现继承,在继承的子类的构造函数里我们使用super关键字来表示对父类构造函数的引用;在静态方法里,super指向父类;在其它函数体内,super表示对父类原型属性的引用。其中super必须在子类的构造函数体内调用一次,因为我们需要调用时来绑定子类的元素对象,否则会报错。

class ColorPoint extends Point {
  constructor(x, y, color) {
    super(x, y); // 调用父类的constructor(x, y)
    this.color = color;
  }

  toString() {
    return this.color + " " + super.toString(); // 调用父类的toString()
  }
}
参考资料

阮一峰 ES6 - class: http://es6.ruanyifeng.com/#do...

MDN文档 - Object.create(): https://developer.mozilla.org...

深入理解原型对象和继承: https://github.com/norfish/bl...

知乎 prototype和__proto__的区别: https://www.zhihu.com/questio...

Javascript高级程序设计: 第四章(变量、作用域和内存问题)、第五章(引用类型)、第六章(面向对象的程序设计)

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/88756.html

相关文章

  • JS原型链和继承

    摘要:是对象或者实例中内置的,其指向的是产生该对象的对象的在浏览器中提供了让我们可以访问,通过的指向形成的一个链条,就称做原型链,原型链的整个链路是实例对象构造函数的的。 原型和原型链 原型prototype,在创建新函数的时候,会自动生成,而prototype中也会有一个constructor,回指创建该prototype的函数对象。 __proto__是对象或者实例中内置的[[proto...

    anRui 评论0 收藏0
  • JS面向对象程序设计之继承实现-组合继承

    摘要:实现思路使用原型链实现对原型方法和方法的继承,而通过借用构造函数来实现对实例属性的继承。继承属性继承方法以上代码,构造函数定义了两个属性和。 JS面向对象的程序设计之继承的实现-组合继承 前言:最近在细读Javascript高级程序设计,对于我而言,中文版,书中很多地方翻译的差强人意,所以用自己所理解的,尝试解读下。如有纰漏或错误,会非常感谢您的指出。文中绝大部分内容引用自《Java...

    antz 评论0 收藏0
  • 深入浅出面向对象和原型【概念篇3】—— 原型链和继承

    摘要:由一个问题引发的思考这个方法是从哪儿蹦出来的首先我们要清楚数组也是对象,而且是对象的实例也就是说,下面两种形式是完全等价的只不过是一种字面量的写法,在深入浅出面向对象和原型概念篇文章里,我们提到过类会有一个属性,而这个类的实例可以通过属性访 1.由一个问题引发的思考 let arr1 = [1, 2, 3] let arr2 = [4, 5, 6] arr1.c...

    levinit 评论0 收藏0
  • JS高程笔记 - 继承

    摘要:下面来看一个例子继承属性继承方法在这个例子中构造函数定义了两个属性和。组合继承最大的问题就是无论什么情况下都会调用两次超类型构造函数一次是在创建子类型原型的时候另一次是在子类型构造函数内部。 组合继承 组合继承(combination inheritance),有时候也叫做伪经典继承,指的是将原型链和借用构造函数的技术组合到一块,从而发挥二者之长的一种继承模式。其背后的思路是使用原型链...

    fsmStudy 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<