译文出处,包括对象的创建

作者: 前端  发布:2019-09-25

征服 JavaScript 面试:类继承和原型继承的区别

2017/01/30 · JavaScript · 继承

原文出处: Eric Elliott   译文出处:众成翻译   

图片 1

图-电子吉他-Feliciano Guimarães(CC BY 2.0)

“征服JavaScript面试”是我所写的一个系列文章,旨在帮助那些应聘中、高级JavaScript开发职位的读者们准备一些常见的面试问题。我自己在实际面试当中也经常会问到这类问题。系列的第一篇文章请参见“什么是闭包”

注:本文均以ES6标准做代码举例。如果想了解ES6,可以参阅“ES6学习指南”

原文链接:https://medium.com/javascript-scene/master-the-javascript-interview-what-s-the-difference-between-class-prototypal-inheritance-e4cd0a7562e9#.d84c324od

对象在JavaScript语言中使用十分广泛,学会如何有效地运用对象,有助于工作效率的提升。而不良的面向对象设计,可能会导致代码工程的失败,更严重的话还会引发整个公司悲剧

不同于其它大部分语言,JavaScript是基于原型的对象系统,而不是基于。遗憾的是,大多数JavaScript开发者对其对象系统理解不到位,或者难以良好地应用,总想按照类的方式使用,其结果将导致代码里的对象使用混乱不堪。所以JavaScript开发者最好对原型和类都能有所了解。

图片 2

摘要:本文本来是想自己写的,奈何花了好长时间写好之后忘记保存,还按了刷新键,一键回到解放前,索性不写了,所以本文是转载的。

面向对象编程是用抽象方式创建基于现实世界模型的一种编程模式,主要包括模块化、多态、和封装几种技术。 对JavaScript而言,其核心是支持面向对象的,同时它也提供了强大灵活的基于原型的面向对象编程能力。 本文将会深入的探讨有关使用JavaScript进行面向对象编程的一些核心基础知识,包括对象的创建,继承机制, 最后还会简要的介绍如何借助ES6提供的新的类机制重写传统的JavaScript面向对象代码。

类继承和原型继承有何区别?

这个问题比较复杂,大家有可能会在评论区各抒己见、莫衷一是。因此,列位看官需要打起十二分的精神学习个中差异,并将所学良好地运用到实践当中去。

类继承:可以把类比作一张蓝图,它描绘了被创建对象的属性及特征。

众所周知,使用new关键字调用构造函数可以创建类的实例。在ES6中,不用class关键字也可以实现类继承。像Java语言中类的概念,从技术上来说在JavaScript中并不存在。不过JavaScript借鉴了构造函数的思想。ES6中的class关键字,相当于是建立在构造函数之上的一种封装,其本质依旧是函数。

JavaScript

class Foo {} typeof Foo // 'function'

1
2
class Foo {}
typeof Foo // 'function'

虽然JavaScript中的类继承的实现建立在原型继承之上,但是并不意味二者具有相同的功能:

JavaScript的类继承使用原型链来连接子类和父类的 [[Prototype]],从而形成代理模式。通常情况下,super()_构造函数也会被调用。这种机制,形成了单一继承结构,以及面向对象设计中最紧密的耦合行为

“类之间的继承关系,导致了子类间的相互关联,从而形成了——基于层级的分类。”

原型继承: 原型是工作对象的实例。对象直接从其他对象继承属性。

原型继承模式下,对象实例可以由多个对象源所组成。这样就使得继承变得更加灵活且[[Prototype]]代理层级较浅。换言之,对于基于原型继承的面向对象设计,不会产生层级分类这样的副作用——这是区别于类继承的关键所在。

对象实例通常由工厂函数或者Object.create()来创建,也可以直接使用Object字面定义。

原型是工作对象的实例。对象直接从其他对象继承属性。”

JavaScript

 

面向对象的几个概念

在进入正题前,先了解传统的面向对象编程(例如Java)中常会涉及到的概念,大致可以包括:

  • 类:定义对象的特征。它是对象的属性和方法的模板定义。
  • 对象(或称实例):类的一个实例。
  • 属性:对象的特征,比如颜色、尺寸等。
  • 方法:对象的行为,比如行走、说话等。
  • 构造函数:对象初始化的瞬间被调用的方法。
  • 继承:子类可以继承父类的特征。例如,猫继承了动物的一般特性。
  • 封装:一种把数据和相关的方法绑定在一起使用的方法。
  • 抽象:结合复杂的继承、方法、属性的对象能够模拟现实的模型。
  • 多态:不同的类可以定义相同的方法或属性。

在JavaScript的面向对象编程中大体也包括这些。不过在称呼上可能稍有不同,例如,JavaScript中没有原生的“类”的概念, 而只有对象的概念。因此,随着你认识的深入,我们会混用对象、实例、构造函数等概念。

为什么搞清楚类继承和原型继承很重要?

继承,本质上讲是一种代码重用机制——各种对象可以借此来共享代码。如果代码共享的方式选择不当,将会引发很多问题,如:

使用类继承,会产生父-子对象分类的副作用

这种类继承的层次划分体系,对于新用例将不可避免地出现问题。而且基类的过度派生,也会导致脆弱基类问题,其错误将难以修复。事实上,类继承会引发面向对象程序设计领域的诸多问题:

  • 紧耦合问题(在面向对象设计中,类继承是耦合最严重的一种设计),紧耦合还会引发另一个问题:
  • 脆弱基类问题
  • 层级僵化问题(新用例的出现,最终会使所有涉及到的继承层次上都出现问题)
  • 必然重复性问题(因为层级僵化,为了适应新用例,往往只能复制,而不能修改已有代码)
  • 大猩猩-香蕉问题(你想要的是一个香蕉,但是最终到的却是一个拿着香蕉的大猩猩,还有整个丛林)

对于这些问题我曾做过深入探讨:“类继承已是明日黄花——探究基于原型的面向对象编程思想”

“优先选择对象组合而不是类继承。” ~先驱四人,《设计模式:可复用面向对象软件之道》

里面很好地总结了:

一. 重新认识面向对象

     面向对象编程是用抽象方式创建基于现实世界模型的一种编程模式,主要包括模块化、多态、和封装几种技术。 对JavaScript而言,其核心是支持面向对象的,同时它也提供了强大灵活的基于原型的面向对象编程能力。 本文将会深入的探讨有关使用JavaScript进行面向对象编程的一些核心基础知识,包括对象的创建,继承机制, 最后还会简要的介绍如何借助ES6提供的新的类机制重写传统的JavaScript面向对象代码。

对象(类)的创建

在JavaScript中,我们通常可以使用构造函数来创建特定类型的对象。诸如Object和Array这样的原生构造函数,在运行时会自动出现在执行环境中。 此外,我们也可以创建自定义的构造函数。例如:

function Person(name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job;
}

var person1 = new Person('Weiwei', 27, 'Student');
var person2 = new Person('Lily', 25, 'Doctor');

按照惯例,构造函数始终都应该以一个大写字母开头(和Java中定义的类一样),普通函数则小写字母开头。 要创建Person的新实例,必须使用new操作符。以这种方式调用构造函数实际上会经历以下4个步骤:

  1. 创建一个新对象(实例)
  2. 将构造函数的作用域赋给新对象(也就是重设了this的指向,this就指向了这个新对象)
  3. 执行构造函数中的代码(为这个新对象添加属性)
  4. 返回新对象

有关new操作符的更多内容请参考这篇文档。

在上面的例子中,我们创建了Person的两个实例person1person2。 这两个对象默认都有一个constructor属性,该属性指向它们的构造函数Person,也就是说:

console.log(person1.constructor == Person);  //true
console.log(person2.constructor == Person);  //true

是否所有的继承方式都有问题?

人们说“优先选择对象组合而不是继承”的时候,其实是要表达“优先选择对象组合而不是类继承”(引用自《设计模式》的原文)。该思想在面向对象设计领域属于普遍共识,因为类继承方式的先天缺陷,会导致很多问题。人们在谈到继承的时候,总是习惯性地省略这个字,给人的感觉像是在针对所有的继承方式,而事实上并非如此。

因为大部分的继承方式还是很棒的。

1. JavaScript是一门面向对象的语言

在说明JavaScript是一个面向对象的语言之前, 我们来探讨一下面向对象的三大基本特征: 封装, 继承, 多态

封装

把抽象出来的属性和对方法组合在一起, 且属性值被保护在内部, 只有通过特定的方法进行改变和读取称为封装

我们以代码举例, 首先我们构造一个Person构造函数, 它有nameid两个属性, 并有一个sayHi方法用于打招呼:

//定义Person构造函数
function Person(name, id) {
  this.name = name;
  this.id = id;
}

//在Person.prototype中加入方法
Person.prototype.sayHi = function() {
  console.log('你好, 我是' +  this.name);
}

现在我们生成一个实例对象p1, 并调用sayHi()方法

//实例化对象
let p1 = new Person('阿辉', 1234);

//调用sayHi方法
p1.sayHi();

在上述的代码中, p1这个对象并不知道sayHi()这个方法是如何实现的, 但是仍然可以使用这个方法. 这其实就是封装. 你也可以实现对象属性的私有和公有, 我们在构造函数中声明一个salary作为私有属性, 有且只有通过getSalary()方法查询到薪资.

function Person(name, id) {
  this.name = name;
  this.id = id;
  let salary = 20000;
  this.getSalary = function (pwd) {
    pwd === 123456 ? console.log(salary) : console.log('对不起, 你没有权限查看密码');
  }
}

继承

可以让某个类型的对象获得另一个类型的对象的属性和方法称为继承

以刚才的Person作为父类构造器, 我们来新建一个子类构造器Student, 这里我们使用call()方法实现继承

function Student(name, id, subject) {
  //使用call实现父类继承
  Person.call(this, name, id);
  //添加子类的属性
  this.subject = subject;
}

let s1 = new Student('阿辉', 1234, '前端开发');

多态

同一操作作用于不同的对象产生不同的执行结果, 这称为多态

JavaScript中函数没有重载, 所以JavaScript中的多态是靠函数覆盖实现的。

同样以刚才的Person构造函数为例, 我们为Person构造函数添加一个study方法

function Person(name, id) {
  this.name = name;
  this.id = id;
  this.study = function() {
    console.log(name + '在学习');
  }
}

同样, 我们新建一个StudentTeacher构造函数, 该构造函数继承Person, 并也添加study方法

function Student(subject) {
  this.subject = subject;
  this.study = function() {
    console.log(this.name + '在学习' + this.subject);
  }
}
Student.prototype = new Person('阿辉', 1234);
Student.prototype.constructor = Student;

function Teacher(subject) {
  this.subject = subject;
  this.study = function() {
    console.log(this.name + '为了教学而学习' + this.subject);
  }
}
Teacher.prototype = new Person("老夫子", 4567);
Teacher.prototype.constructor = Teacher;

测试我们新建一个函数doStudy

function doStudy(role) {
  if(role instanceof Person) {
    role.study();
  }
}

此时我们分别实例化StudentTeacher, 并调用doStudy方法

let student = new Student('前端开发');
let teacher = new Teacher('前端开发');

doStudy(student); //阿辉在学习前端开发
doStudy(teacher); //老夫子为了教学在学习前端开发

对于同一函数doStudy, 由于参数的不同, 导致不同的调用结果,这就实现了多态.

JavaScript的面向对象
从上面的分析可以论证出, JavaScript是一门面向对象的语言, 因为它实现了面向对象的所有特性. 其实, 面向对象仅仅是一个概念或者一个编程思想而已, 它不应该依赖于某个语言存在, 比如Java采用面向对象思想构造其语言, 它实现了类, 继承, 派生, 多态, 接口等机制. 但是这些机制,只是实现面向对象的一种手段, 而非必须。换言之, 一门语言可以根据自身特性选择合适的方式来实现面向对象。 由于大多数程序员首先学习的是Java, C++等高级编程语言, 因而先入为主的接受了“类”这个面向对象实际方式,所以习惯性的用类式面向对象语言中的概念来判断该语言是否是面向对象的语言。这也是很多有其他编程语言经验的人在学习JavaScript对象时,感觉到很困难的地方。

实际上, JavaScript是通过一种叫原型(prototype)的方式来实现面向对象编程的。下面我们就来讨论一下基于类(class-basesd)的面向对象基于原型(protoype-based)的面向对象这两者的差别。

 

自定义对象的类型检测

我们可以使用instanceof操作符进行类型检测。我们创建的所有对象既是Object的实例,同时也是Person的实例。 因为所有的对象都继承自Object

console.log(person1 instanceof Object);  //true
console.log(person1 instanceof Person);  //true
console.log(person2 instanceof Object);  //true
console.log(person2 instanceof Person);  //true

三种不同的原型继承方式

在深入探讨其他继承类型之前,还需要先仔细分析下我所说的类继承

你可以在Codepen上找到并测试下这段示例程序

BassAmp 继承自 GuitarAmp, ChannelStrip 继承自 BassAmpGuitarAmp。从这个例子我们可以看到面向对象设计发生问题的过程。ChannelStrip实际上并不是GuitarAmp的一种,而且它根本不需要一个cabinet的属性。一个比较好的解决办法是创建一个新的基类,供amps和strip来继承,但是这种方法依然有所局限。

到最后,采用新建基类的策略也会失效。

更好的办法就是通过类组合的方式,来继承那些真正需要的属性:

修改后的代码

认真看这段代码,你就会发现:通过对象组合,我们可以确切地保证对象可以按需继承。这一点是类继承模式不可能做到的。因为使用类继承的时候,子类会把需要的和不需要的属性统统继承过来。

这时候你可能会问:“唔,是那么回事。可是这里头怎么没提到原型啊?”

客官莫急,且听我一步步道来~首先你要知道,基于原型的面向对象设计方法总共有三种。

  1. 拼接继承: 是直接从一个对象拷贝属性到另一个对象的模式。被拷贝的原型通常被称为mixins。ES6为这个模式提供了一个方便的工具Object.assign()。在ES6之前,一般使用Underscore/Lodash提供的.extend(),或者 jQuery 中的$.extend(), 来实现。上面那个对象组合的例子,采用的就是拼接继承的方式。
  2. 原型代理:JavaScript中,一个对象可能包含一个指向原型的引用,该原型被称为代理。如果某个属性不存在于当前对象中,就会查找其代理原型。代理原型本身也会有自己的代理原型。这样就形成了一条原型链,沿着代理链向上查找,直到找到该属性,或者找到根代理Object.prototype为止。原型就是这样,通过使用new关键字来创建实例以及Constructor.prototype前后勾连成一条继承链。当然,也可以使用Object.create()来达到同样的目的,或者把它和拼接继承混用,从而可以把多个原型精简为单一代理,也可以做到在对象实例创建后继续扩展。
  3. 函数继承:在JavaScript中,任何函数都可以用来创建对象。如果一个函数既不是构造函数,也不是 class,它就被称为工厂函数。函数继承的工作原理是:由工厂函数创建对象,并向该对象直接添加属性,借此来扩展对象(使用拼接继承)。函数继承的概念最先由道格拉斯·克罗克福德提出,不过这种继承方式在JavaScript中却早已有之。

这时候你会发现,拼接继承是JavaScript能够实现对象组合的秘诀,也使得原型代理和函数继承更加丰富多彩。

多数人谈起JavaScript面向对象设计时,首先想到的都是原型代理。不过你看,可不仅仅只有原型代理。要取代类继承,原型代理还是得靠边站,对象组合才是主角

2. 基于类的面向对象和基于原型的面向对象的比较

基于类的面向对象

在基于的面向对象语言中(比如Java和C++), 是构建在类(class)实例(instance)上的。其中定义了所有用于具有某一特征对象的属性。是抽象的事物, 而不是其所描述的全部对象中的任何特定的个体。另一方面, 一个实例是一个的实例化,是其中的一个成员。

基于原型的面向对象
在基于原型的语言中(如JavaScript)并不存在这种区别:它只有对象!不论是构造函数(constructor),实例(instance),原型(prototype)本身都是对象。基于原型的语言具有所谓的原型对象的概念,新对象可以从中获得原始的属性。

所以,在JavaScript中有一个很有意思的__proto__属性(ES6以下是非标准属性)用于访问其原型对象, 你会发现,上面提到的构造函数,实例,原型本身都有__proto__指向原型对象。其最后顺着原型链都会指向Object这个构造函数,然而Object的原型对象的原型是null,不信, 你可以尝试一下Object.prototype.__proto__ === nulltrue。然而typeof null === 'object'true。到这里, 我相信你应该就能明白为什么JavaScript这类基于原型的语言中没有类和实例的区别, 而是万物皆对象!

差异总结

基于类的(Java) 基于原型的(JavaScript)
类和实例是不同的事物。 所有对象均为实例。
通过类定义来定义类;通过构造器方法来实例化类。 通过构造器函数来定义和创建一组对象。
通过 new 操作符创建单个对象。 相同
通过类定义来定义现存类的子类, 从而构建对象的层级结构 指定一个对象作为原型并且与构造函数一起构建对象的层级结构
遵循类链接继承属性 遵循原型链继承属性
类定义指定类的所有实例的所有属性。无法在运行时动态添加属性 构造器函数或原型指定初始的属性集。允许动态地向单个的对象或者整个对象集中添加或移除属性。

面向对象的几个概念

 

在进入正题前,先了解传统的面向对象编程(例如Java)中常会涉及到的概念,大致可以包括:

  • 类:定义对象的特征。它是对象的属性和方法的模板定义。(比如人类)
  • 对象(或称实例):类的一个实例。
  • 属性:对象的特征,比如颜色、尺寸等。
  • 方法:对象的行为,比如行走、说话等。
  • 构造函数:对象初始化的瞬间被调用的方法。
  • 继承:子类可以继承父类的特征。例如,猫继承了动物的一般特性。
  • 封装:一种把数据和相关的方法绑定在一起使用的方法。
  • 抽象:结合复杂的继承、方法、属性的对象能够模拟现实的模型。
  • 多态:不同的类可以定义相同的方法或属性。

在JavaScript的面向对象编程中大体也包括这些。不过在称呼上可能稍有不同,例如,JavaScript中没有原生的“类”的概念, 而只有对象的概念。因此,随着你认识的深入,我们会混用对象、实例、构造函数等概念。

 

 

构造函数的问题

我们不建议在构造函数中直接定义方法,如果这样做的话,每个方法都要在每个实例上重新创建一遍,这将非常损耗性能。 ——不要忘了,ECMAScript中的函数是对象,每定义一个函数,也就实例化了一个对象。

幸运的是,在ECMAScript中,我们可以借助原型对象来解决这个问题。

*为什么说对象组合能够避免脆弱基类问题

要搞清楚这个问题,首先要知道脆弱基类是如何形成的:

  1. 假设有基类A
  2. B继承自基类A
  3. C继承自B
  4. D也继承自B

C中调用super方法,该方法将执行类B中的代码。同样,B也调用super方法,该方法会执行A中的代码。

CD需要从AB中继承一些无关联的特性。此时,D作为一个新用例,需要从A的初始化代码继承一些特性,这些特性与C的略有不同。为了应对以上需求,菜鸟开发人员会去调整A的初始化代码。于是乎,尽管D可以正常工作,但是C原本的特性被破坏了。

上面这个例子中,ABCD提供各种特性。可是,CD不需要来自AB的所有特性,它们只是需要继承某些属性。但是,通过继承和调用super方法,你无法选择性地继承,只能全部继承:

“面向对象语言的问题在于,子类会携带有父类所隐含的环境信息。你想要的是一个香蕉,但是最终到的却是一个拿着香蕉的大猩猩,以及整个丛林”——乔·阿姆斯特朗《编程人生》

如果是使用对象组合的方式 设想有如下几个特性:

JavaScript

feat1, feat2, feat3, feat4

1
feat1, feat2, feat3, feat4

C需要特性feat1feat3,而D 需要特性feat1, feat2, feat4

JavaScript

const C = compose(feat1, feat3); const D = compose(feat1, feat2, feat4);

1
2
const C = compose(feat1, feat3);
const D = compose(feat1, feat2, feat4);

假如你发现D需要的特性与feat1**略有出入。这时候无需改动feat1只要创建一个feat1的定制化版本*,就可以做到保持feat2feat4特性的同时,也不会影响到C*,如下:

JavaScript

const D = compose(custom1, feat2, feat4);

1
const D = compose(custom1, feat2, feat4);

像这样灵活的优点,是类继承方式所不具备的。因为子类在继承的时候,会连带着整个类继承结构

这种情况下,要适应新的用例,要么复制现有类层划分(必然重复性问题),要么在现有类层结构的基础上进行重构,就又会导致脆弱基类问题

而采用对象组合的话,这两个问题都将迎刃而解。

二. ES5中的面向对象

*这里的ES5并不特指ECMAScript 5, 而是代表ECMAScript 6 之前的ECMAScript!

对象(类)的创建

 

在JavaScript中,我们通常可以使用构造函数来创建特定类型的对象。诸如Object和Array这样的原生构造函数,在运行时会自动出现在执行环境中。 此外,我们也可以创建自定义的构造函数。例如:

function Person(name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job;
}

var person1 = new Person('Weiwei', 27, 'Student');
var person2 = new Person('Lily', 25, 'Doctor');

  

按照惯例,构造函数始终都应该以一个大写字母开头(和Java中定义的类一样),普通函数则小写字母开头。 要创建Person的新实例,必须使用new操作符。以这种方式调用构造函数实际上会经历以下4个步骤:

  1. 创建一个新对象(实例)
  2. 将构造函数的作用域赋给新对象(也就是重设了this的指向,this就指向了这个新对象)
  3. 执行构造函数中的代码(为这个新对象添加属性)
  4. 返回新对象

有关new操作符的更多内容请参考这篇文档。

在上面的例子中,我们创建了Person的两个实例person1person2。 这两个对象默认都有一个constructor属性,该属性指向它们的构造函数Person,也就是说:

console.log(person1.constructor == Person);  //true
console.log(person2.constructor == Person);  //true

 

借助原型模式定义对象的方法

我们创建的每个函数都有一个prototype属性,这个属性是一个指针,指向该函数的原型对象, 该对象包含了由特定类型的所有实例共享的属性和方法。也就是说,我们可以利用原型对象来让所有对象实例共享它所包含的属性和方法。

function Person(name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job;
}

// 通过原型模式来添加所有实例共享的方法
// sayName() 方法将会被Person的所有实例共享,而避免了重复创建
Person.prototype.sayName = function () {
  console.log(this.name);
};

var person1 = new Person('Weiwei', 27, 'Student');
var person2 = new Person('Lily', 25, 'Doctor');

console.log(person1.sayName === person2.sayName); // true

person1.sayName(); // Weiwei
person2.sayName(); // Lily

正如上面的代码所示,通过原型模式定义的方法sayName()为所有的实例所共享。也就是, person1person2访问的是同一个sayName()函数。同样的,公共属性也可以使用原型模式进行定义。例如:

function Chinese (name) {
    this.name = name;
}

Chinese.prototype.country = 'China'; // 公共属性,所有实例共享

你真的了解原型了吗?

采用先创建类和构造函数,然后再继承的方式,并不是正宗的原型继承,不过是使用原型来模拟类继承的方法罢了。这里有一些关于JavaScript中关于继承的常见误解,供君参考。

JavaScript中,类继承模式历史悠久,而且建立在灵活丰富的原型继承特性之上(ES6以上的版本亦然)。可是一旦使用了类继承,就再也享受不到原型灵活强大的特性了。类继承的所有问题都将始终如影随形无法摆脱

在JavaScript中使用类继承,是一种舍本逐末的行为。

(一) ES5中对象的创建

在ES5中创建对象有两种方式, 第一种是使用对象字面量的方式, 第二种是使用构造函数的方式。该两种方法在特定的使用场景分别有其优点和缺点, 下面我们来分别介绍这两种创建对象的方式。

自定义对象的类型检测

我们可以使用instanceof操作符进行类型检测。我们创建的所有对象既是Object的实例,同时也是Person的实例。 因为所有的对象都继承自Object

console.log(person1 instanceof Object);  //true
console.log(person1 instanceof Person);  //true
console.log(person2 instanceof Object);  //true
console.log(person2 instanceof Person);  //true

  

原型对象

现在我们来深入的理解一下什么是原型对象。

只要创建了一个新函数,就会根据一组特定的规则为该函数创建一个prototype属性,这个属性指向函数的原型对象。 在默认情况下,所有原型对象都会自动获得一个constructor属性,这个属性包含一个指向prototype属性所在函数的指针。 也就是说:Person.prototype.constructor指向Person构造函数。

创建了自定义的构造函数之后,其原型对象默认只会取得constructor属性;至于其他方法,则都是从Object继承而来的。 当调用构造函数创建一个新实例后,该实例内部将包含一个指针(内部属性),指向构造函数的原型对象。ES5中称这个指针为[[Prototype]], 在Firefox、Safari和Chrome在每个对象上都支持一个属性__proto__(目前已被废弃);而在其他实现中,这个属性对脚本则是完全不可见的。 要注意,这个链接存在于实例与构造函数的原型对象之间,而不是实例与构造函数之间。

这三者关系的示意图如下:

图片 3

上图展示了Person构造函数、Person的原型对象以及Person现有的两个实例之间的关系。

  • Person.prototype指向了原型对象
  • Person.prototype.constructor又指回了Person构造函数
  • Person的每个实例person1person2都包含一个内部属性(通常为__proto__),person1.__proto__person2.__proto__指向了原型对象

Stamps:可组合式工厂函数

多数情况下,对象组合是通过使用工厂函数来实现:工厂函数负责创建对象实例。如果工厂函数也可以组合呢?快查看Stamp文档找出答案吧。

(译者注:感觉原文表达有些不尽兴。于是我自作主张地画了2个图便于读者理解。不足之处还请谅解和指正) 图片 4图:类继承

说明:从图上可以直接看出单一继承关系、紧耦合以及层级分类的问题;其中,类8,只想继承五边形的属性,却得到了继承链上其它并不需要的属性——大猩猩/香蕉问题;类9只需要把五角星属性修改成四角形,导致需要修改基类1,从而影响整个继承树——脆弱基类/层级僵化问题;否则就需要为9新建基类——必然重复性问题。 图片 5图:原型继承/对象组合

说明:采用原型继承/对象组合,可以避免复杂纵深的层级关系。当1需要四角星特性的时候,只需要组合新的特性即可,不会影响到其他实例。

1 赞 8 收藏 评论

图片 6

1. 使用对象字面量的方式

我们通过对象字面量的方式创建两个student对象,分别是student1student2

var student1 = {
  name: '阿辉',
  age: 22,
  subject: '前端开发'
};

var student2 = {
  name: '阿傻',
  age: 22,
  subject: '大数据开发'
};

上面的代码就是使用对象字面量的方式创建实例对象, 使用对象字面量的方式在创建单一简单对象的时候是非常方便的。但是,它也有其缺点:

  • 在生成多个实例对象时, 我们需要每次重复写name,age,subject属性,写起来特别的麻烦
  • 虽然都是学生的对象, 但是看不出student1student2之间有什么联系。

为了解决以上两个问题, JavaScript提供了构造函数创建对象的方式。

构造函数的问题

 

我们不建议在构造函数中直接定义方法,如果这样做的话,每个方法都要在每个实例上重新创建一遍,这将非常损耗性能。 ——不要忘了,ECMAScript中的函数是对象,每定义一个函数,也就实例化了一个对象。

幸运的是,在ECMAScript中,我们可以借助原型对象来解决这个问题。

查找对象属性

从上图我们发现,虽然Person的两个实例都不包含属性和方法,但我们却可以调用person1.sayName()。 这是通过查找对象属性的过程来实现的。

  1. 搜索首先从对象实例本身开始(实例person1sayName属性吗?——没有)
  2. 如果没找到,则继续搜索指针指向的原型对象(person1.__proto__sayName属性吗?——有)

这也是多个对象实例共享原型所保存的属性和方法的基本原理。

注意,如果我们在对象的实例中重写了某个原型中已存在的属性,则该实例属性会屏蔽原型中的那个属性。 此时,可以使用delete操作符删除实例上的属性。

2. 使用构造函数的方式

构造函数就其实就是一个普通的函数,当对构造函数使用new进行实例化时,会将其内部this的指向绑定实例对象上,下面我们来创建一个Student构造函数(构造函数约定使用大写开头,和普通函数做区分)。

function Student (name, age, subject) {
  this.name = name;
  this.age = age; 
  this.subject = subject;
  console.log(this);
}

我特意在构造函数中打印出this的指向。上面我们提到,构造函数其实就是一个普通的函数, 那么我们使用普通函数的调用方式尝试调用Student

Student('阿辉', 22, '前端开发'); //window{}

采用普通方式调用Student时, this的指向是window。下面使用new来实例化该构造函数, 生成一个实例对象student1

let student1 = new Student('阿辉', 22, '前端开发'); //Student {name: "阿辉", age: 22, subject: "前端开发"}

当我们采用new生成实例化对象student1时, this不再指向window, 而是指向的实例对象本身。这些, 都是new帮我们做的。上面的就是采用构造函数的方式生成实例对象的方式, 并且当我们生成其他实例对象时,由于都是采用Student这个构造函数实例化而来的, 我们能够清楚的知道各实例对象之间的联系。

let student1 = new Student('阿辉', 22, '前端开发');
let student2 = new Student('阿傻', 22, '大数据开发');
let student3 = new Student('阿呆', 22, 'Python');
let student4 = new Student('阿笨', 22, 'Java');

 

Object.getPrototypeOf()

根据ECMAScript标准,someObject.[[Prototype]] 符号是用于指派 someObject 的原型。 这个等同于 JavaScript 的 __proto__ 属性(现已弃用)。 从ECMAScript 5开始, [[Prototype]] 可以用Object.getPrototypeOf()Object.setPrototypeOf()访问器来访问。

其中Object.getPrototypeOf()在所有支持的实现中,这个方法返回[[Prototype]]的值。例如:

person1.__proto__ === Object.getPrototypeOf(person1); // true
Object.getPrototypeOf(person1) === Person.prototype; // true

也就是说,Object.getPrototypeOf(p1)返回的对象实际就是这个对象的原型。 这个方法的兼容性请参考该链接)。

(二) ES5中对象的继承

借助原型模式定义对象的方法

我们创建的每个函数都有一个prototype属性,这个属性是一个指针,指向该函数的原型对象, 该对象包含了由特定类型的所有实例共享的属性和方法。也就是说,我们可以利用原型对象来让所有对象实例共享它所包含的属性和方法。

function Person(name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job;
}

 

// 通过原型模式来添加所有实例共享的方法
// sayName() 方法将会被Person的所有实例共享,而避免了重复创建
Person.prototype.sayName = function () {
  console.log(this.name);
};

var person1 = new Person('Weiwei', 27, 'Student');
var person2 = new Person('Lily', 25, 'Doctor');

console.log(person1.sayName === person2.sayName); // true  person1和person2访问的是同一个sayName()函数

person1.sayName(); // Weiwei
person2.sayName(); // Lily

 

正如上面的代码所示,通过原型模式定义的方法sayName()为所有的实例所共享。也就是, person1person2访问的是同一个sayName()函数。同样的,公共属性也可以使用原型模式进行定义。例如:

function Chinese (name) {
    this.name = name;
}

Chinese.prototype.country = 'China'; // 公共属性,所有实例共享

 

当我们new Person()时,返回的Person实例会结合构造函数中定义的属性、行为和原型中定义的属性、行为, 生成最终属于Person实例的属性和行为。

构造函数中定义的属性和行为的优先级要比原型中定义的属性和行为的优先级高,如果构造函数和原型中定义了同名的属性或行为, 构造函数中的属性或行为会覆盖原型中的同名的属性或行为。

 

Object.keys()

要取得对象上所有可枚举的实例属性,可以使用ES5中的Object.keys()方法。例如:

Object.keys(p1); // ["name", "age", "job"]

此外,如果你想要得到所有实例属性,无论它是否可枚举,都可以使用Object.getOwnPropertyName()方法。

1. prototype的原型继承

prototype是JavaScript这类基于原型继承的核心, 只要弄明白了原型和原型链, 就基本上完全理解了JavaScript中对象的继承。下面我将着重的讲解为什么要使用prototype和使用prototype实现继承的方式。

为什么要使用prototype

我们给之前的Student构造函数新增一个study方法

function Student (name, age, subject) {
  this.name = name;
  this.age = age; 
  this.subject = subject;
  this.study = function() {
    console.log('我在学习' + this.subject);
  }
}

现在我们来实例化Student构造函数, 生成student1和``student2, 并分别调用其study`方法。

let student1 = new Student('阿辉', 22, '前端开发');
let student2 = new Student('阿傻', 22, '大数据开发');

student1.study(); //我在学习前端开发
student2.study(); //我在学习大数据开发

这样生成的实例对象表面上看没有任何问题, 但是其实是有很大的性能问题!我们来看下面一段代码:

console.log(student1.study === student2.study); //false

其实对于每一个实例对象studentx,其study方法的函数体是一模一样的,方法的执行结果只根据其实例对象决定(这就是多态),然而生成的每个实例都需要生成一个study方法去占用一份内存。这样是非常不经济的做法。新手可能会认为, 上面的代码中也就多生成了一个study方法, 对于内存的占用可以忽略不计。

那么我们在MDN中看一下在JavaScript中我们使用的String实例对象有多少方法?

图片 7

String中的方法

上面的方法只是String实例对象中的一部分方法(我一个屏幕截取不完!), 这也就是为什么我们的字符串能够使用如此多便利的原生方法的原因。设想一下, 如果这些方法不是挂载在String.prototype上, 而是像上面Student一样写在String构造函数上呢?那么我们项目中的每一个字符串,都会去生成这几十种方法去占用内存,这还没考虑Math,Array,Number,Object等对象!

现在我们应该知道应该将study方法挂载到Student.prototype原型对象上才是正确的写法,所有的studentx实例都能继承该方法。

function Student (name, age, subject) {
  this.name = name;
  this.age = age; 
  this.subject = subject;
}
Student.prototype.study = function() {
  console.log('我在学习' + this.subject);
}

现在我们实例化student1student2

let student1 = new Student('阿辉', 22, '前端开发');
let student2 = new Student('阿傻', 22, '大数据开发');

student1.study(); //我在学习前端开发
student2.study(); //我在学习大数据开发

console.log(student1.study === student2.study); //true

从上面的代码我们可以看出, student1student2study方法执行结果没有发生变化,但是study本身指向了一个内存地址。这就是为什么我们要使用prototype进行挂载方法的原因。接下来我们来讲解一下如何使用prototype来实现继承。

原型对象

现在我们来深入的理解一下什么是原型对象。

只要创建了一个新函数,就会根据一组特定的规则为该函数创建一个prototype属性,这个属性指向函数的原型对象。 在默认情况下,所有原型对象都会自动获得一个constructor属性,这个属性包含一个指向prototype属性所在函数的指针。 也就是说:Person.prototype.constructor指向Person构造函数。

创建了自定义的构造函数之后,其原型对象默认只会取得constructor属性;至于其他方法,则都是从Object继承而来的。 当调用构造函数创建一个新实例后,该实例内部将包含一个指针(内部属性),指向构造函数的原型对象。ES5中称这个指针为[[Prototype]], 在Firefox、Safari和Chrome在每个对象上都支持一个属性__proto__(目前已被废弃);而在其他实现中,这个属性对脚本则是完全不可见的。 要注意,这个链接存在于实例与构造函数的原型对象之间,而不是实例与构造函数之间

这三者关系的示意图如下:

图片 8

上图展示了Person构造函数、Person的原型对象以及Person现有的两个实例之间的关系。

  • Person.prototype指向了原型对象
  • Person.prototype.constructor又指回了Person构造函数
  • Person的每个实例person1person2都包含一个内部属性(通常为__proto__),person1.__proto__person2.__proto__指向了原型对象
  • 原型对象相当于是实例和构造函数中的一个桥梁,相当于两者的一个共同部分

更简单的原型语法

在上面的代码中,如果我们要添加原型属性和方法,就要重复的敲一遍Person.prototype。为了减少这个重复的过程, 更常见的做法是用一个包含所有属性和方法的对象字面量来重写整个原型对象。 参考资料。

function Person(name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job;
}

Person.prototype = {

  // 这里务必要重新将构造函数指回Person构造函数,否则会指向这个新创建的对象
  constructor: Person, // Attention!

  sayName: function () {
    console.log(this.name);
  }
};

var person1 = new Person('Weiwei', 27, 'Student');
var person2 = new Person('Lily', 25, 'Doctor');

console.log(person1.sayName === person2.sayName); // true

person1.sayName();  // Weiwei
person2.sayName();  // Lily

在上面的代码中特意包含了一个constructor属性,并将它的值设置为Person,从而确保了通过该属性能够访问到适当的值。 注意,以这种方式重设constructor属性会导致它的[[Enumerable]]特性设置为true。默认情况下,原生的constructor属性是不可枚举的。 你可以使用Object.defineProperty()

// 重设构造函数,只适用于ES5兼容的浏览器
Object.defineProperty(Person.prototype, "constructor", {
  enumerable: false,
  value: Person
});
如何使用prototype实现继承?

“学生”这个对象可以分为小学生, 中学生和大学生等。我们现在新建一个小学生的构造函数Pupil

function Pupil(school) {
  this.school = school;
}

那么如何让Pupil使用prototype继承Student呢? 其实我们只要将Pupilprototype指向Student的一个实例即可。

Pupil.prototype = new Student('小辉', 8, '小学义务教育课程');
Pupil.prototype.constructor = Pupil;

let pupil1 = new Pupil('北大附小');

代码的第一行, 我们将Pupil的原型对象(Pupil.prototype)指向了Student的实例对象。

Pupil.prototype = new Student('小辉', 8, '小学义务教育课程');

代码的第二行也许有的读者会不能理解是什么意思。

Pupil.prototype.constructor = Pupil;

Pupil作为构造函数有一个protoype属性指向原型对象Pupil.prototype,而原型对象Pupil.prototype也有一个constructor属性指回它的构造函数Pupil。如下图所示:

图片 9

prototype和constructor的指向

然而, 当我们使用实例化Student去覆盖Pupil.prototype后, 如果没有第二行代码的情况下, Pupil.prototype.constructor指向了Student构造函数, 如下图所示:

图片 10

prototype和constructor的指向错误

而且, pupil1.constructor会默认调用Pupil.prototype.constructor, 这个时候pupil1.constructor指向了Student

Pupil.prototype = new Student('小辉', 8, '小学义务教育课程');
let pupil1 = new Pupil('北大附小');

console.log(pupil1.constructor === Student); //true

这明显是错误的, pupil1明明是用Pupil构造函数实例化出来的, 怎么其constructor指向了Student构造函数呢。所以, 我们就需要加入第二行, 修正其错误:

Pupil.prototype = new Student('小辉', 8, '小学义务教育课程');

//修正constructor的指向错误
Pupil.prototype.constructor = Pupil;

let pupil1 = new Pupil('北大附小');

console.log(pupil1.constructor === Student); //false
console.log(pupil1.constructor === Pupil); //ture

上面就是我们的如何使用prototype实现继承的例子, 需要特别注意的: 如果替换了prototype对象, 必须手动将prototype.constructor重新指向其构造函数。

 

组合使用构造函数模式和原型模式

创建自定义类型的最常见方式,就是组合使用构造函数模式与原型模式。构造函数模式用于定义实例属性, 而原型模式用于定义方法和共享的属性。结果,每个实例都会有自己的一份实例属性的副本,但同时又共享着对方的引用, 最大限度的节省了内存。

2. 使用callapply方法实现继承

使用callapply是我个人比较喜欢的继承方式, 因为只需要一行代码就可以实现继承。但是该方法也有其局限性,callapply不能继承原型上的属性和方法, 下面会有详细说明。

使用call实现继承

同样对于上面的Student构造函数, 我们使用call实现Pupil继承Student的全部属性和方法:

//父类构造函数
function Student (name, age, subject) {
  this.name = name;
  this.age = age; 
  this.subject = subject;
}

//子类构造函数
function Pupil(name, age, subject, school) {
  //使用call实现继承
  Student.call(this, name, age, subject);
  this.school = school;
}

//实例化Pupil
let pupil2 = new Pupil('小辉', 8, '小学义务教育课程', '北大附小');

需要注意的是, callapply只能继承本地属性和方法, 而不能继承原型上的属性和方法,如下面的代码所示, 我们给Student挂载study方法,Pupil使用call继承Student后, 调用pupil2.study()会报错:

//父类构造函数
function Student (name, age, subject) {
  this.name = name;
  this.age = age; 
  this.subject = subject;
}
//原型上挂载study方法
Student.prototype.study = function() {
  console.log('我在学习' + this.subject);
}

//子类构造函数
function Pupil(name, age, subject, school) {
  //使用call实现继承
  Student.call(this, name, age, subject);
  this.school = school;
}

let pupil2 = new Pupil('小辉', 8, '小学义务教育课程', '北大附小');

//报错
pupil2.study(); //Uncaught TypeError: pupil2.study is not a function

使用apply实现继承
使用apply实现继承的方式和call类似, 唯一的不同只是参数需要使用数组的方法。下面我们使用apply来实现上面Pupil继承Student的例子。

//父类构造函数
function Student (name, age, subject) {
  this.name = name;
  this.age = age; 
  this.subject = subject;
}

//子类构造函数
function Pupil(name, age, subject, school) {
  //使用applay实现继承
  Student.apply(this, [name, age, subject]);
  this.school = school;
}

//实例化Pupil
let pupil2 = new Pupil('小辉', 8, '小学义务教育课程', '北大附小');

查找对象属性

从上图我们发现,虽然Person的两个实例都不包含属性和方法,但我们却可以调用person1.sayName()。 这是通过查找对象属性的过程来实现的。

  1. 搜索首先从对象实例本身开始(实例person1sayName属性吗?——没有)
  2. 如果没找到,则继续搜索指针指向的原型对象person1.__proto__sayName属性吗?——有)

  (搜索开始,首先搜索实例person1,你有sayName属性吗,没有啊,那你的原型对象有吗,有啊,废话少说,有就拿来用嘛)

这也是多个对象实例共享原型所保存的属性和方法的基本原理。

注意,如果我们在对象的实例中重写了某个原型中已存在的属性,则该实例属性会屏蔽原型中的那个属性。 此时,可以使用delete操作符删除实例上的属性。

代码如下:

    function Person(name, age, job) {
      this.name = name;
      this.age = age;
      this.job = job;
    }
    Person.prototype.sayName = function () {
      alert(this.name);
    };
    var person1 = new Person('Weiwei', 27, 'Student');
    var person2 = new Person('Lily', 25, 'Doctor');

    person2.sayName=function(){
        alert(1);
    };
    person2.sayNam() // 1
    delete person2.sayName;
    person2.sayName(); //Lily

 

继承

大多的面向对象语言都支持两种继承方式:接口继承和实现继承。ECMAScript只支持实现继承,而且其实现继承主要依靠原型链来实现。

3. 其他继承方式

JavaScript中的继承方式不仅仅只有上面提到的几种方法, 在《JavaScript高级程序设计》中, 还有实例继承,拷贝继承,组合继承,寄生组合继承等众多继承方式。在寄生组合继承中, 就很好的弥补了callapply无法继承原型属性和方法的缺陷,是最完美的继承方法。这里就不详细的展开论述,感兴趣的可以自行阅读《JavaScript高级程序设计》。

Object.getPrototypeOf()

根据ECMAScript标准,someObject.[[Prototype]] 符号是用于指派 someObject 的原型。 这个等同于 JavaScript 的 __proto__ 属性(现已弃用,因为它不是标准)。 从ECMAScript 5开始, [[Prototype]] 可以用Object.getPrototypeOf()Object.setPrototypeOf()访问器来访问。

其中Object.getPrototypeOf()在所有支持的实现中,这个方法返回[[Prototype]]的值。例如:

person1.__proto__ === Object.getPrototypeOf(person1); // true
Object.getPrototypeOf(person1) === Person.prototype; // true

 

也就是说,Object.getPrototypeOf(p1)返回的对象实际就是这个对象的原型。 这个方法的兼容性请参考该链接)。

 

原型链继承

使用原型链作为实现继承的基本思想是:利用原型让一个引用类型继承另一个引用类型的属性和方法。首先我们先回顾一些基本概念:

  • 每个构造函数都有一个原型对象(prototype
  • 原型对象包含一个指向构造函数的指针(constructor
  • 实例都包含一个指向原型对象的内部指针([[Prototype]]

如果我们让原型对象等于另一个类型的实现,结果会怎么样?显然,此时的原型对象将包含一个指向另一个原型的指针, 相应的,另一个原型中也包含着一个指向另一个构造函数的指针。假如另一个原型又是另一个类型的实例,那么上述关系依然成立, 如此层层递进,就构成了实例与原型的链条。 更详细的内容可以参考这个链接。 先看一个简单的例子,它演示了使用原型链实现继承的基本框架:

function Father () {
  this.fatherValue = true;
}

Father.prototype.getFatherValue = function () {
  console.log(this.fatherValue);
};

function Child () {
  this.childValue = false;
}

// 实现继承:继承自Father
Child.prototype = new Father();

Child.prototype.getChildValue = function () {
  console.log(this.childValue);
};

var instance = new Child();
instance.getFatherValue(); // true
instance.getChildValue();  // false

在上面的代码中,原型链继承的核心语句是Child.prototype = new Father(),它实现了ChildFather的继承, 而继承是通过创建Father的实例,并将该实例赋给Child.prototype实现的。

实现的本质是重写原型对象,代之以一个新类型的实例。也就是说,原来存在于Father的实例中的所有属性和方法, 现在也存在于Child.prototype中了。

这个例子中的实例以及构造函数和原型之间的关系如下图所示:

图片 11

在上面的代码中,我们没有使用Child默认提供的原型,而是给它换了一个新原型;这个新原型就是Father的实例。 于是,新原型不仅具有了作为一个Father的实例所拥有的全部属性和方法。而且其内部还有一个指针[[Prototype]],指向了Father的原型。

  • instance指向Child的原型对象
  • Child的原型对象指向Father的原型对象
  • getFatherValue()方法仍然还在Father.prototype
  • 但是,fatherValue则位于Child.prototype
  • instance.constructor现在指向的是Father

因为fatherValue是一个实例属性,而getFatherValue()则是一个原型方法。既然Child.prototype现在是Father的实例, 那么fatherValue当然就位于该实例中。

通过实现原型链,本质上扩展了本章前面介绍的原型搜索机制。例如,instance.getFatherValue()会经历三个搜索步骤:

  1. 搜索实例
  2. 搜索Child.prototype
  3. 搜索Father.prototype

三. ES6中的面向对象

基于原型的继承方式,虽然实现了代码复用,但是行文松散且不够流畅,可阅读性差,不利于实现扩展和对源代码进行有效的组织管理。不得不承认,基于类的继承方式在语言实现上更健壮,且在构建可服用代码和组织架构程序方面具有明显的优势。所以,ES6中提供了基于类class的语法。但class本质上是ES6提供的一颗语法糖,正如我们前面提到的,JavaScript是一门基于原型的面向对象语言

Object.keys()

要取得对象上所有可枚举的实例属性,可以使用ES5中的Object.keys()方法。例如:

Object.keys(p1); // ["name", "age", "job"]

此外,如果你想要得到所有实例属性,无论它是否可枚举,都可以使用Object.getOwnPropertyName()方法。

 

别忘了Object

所有的函数都默认原型都是Object的实例,因此默认原型都会包含一个内部指针[[Prototype]],指向Object.prototype。 这也正是所有自定义类型都会继承toString()valueOf()等默认方法的根本原因。所以, 我们说上面例子展示的原型链中还应该包括另外一个继承层次。关于Object的更多内容,可以参考这篇博客。

也就是说,Child继承了Father,而Father继承了Object。当调用了instance.toString()时, 实际上调用的是保存在Object.prototype中的那个方法。

(一) ES6中对象的创建

我们使用ES6的class来创建Student

//定义类
class Student {
  //构造方法
  constructor(name, age, subject) {
    this.name = name;
    this.age = age;
    this.subject = subject;
  }

  //类中的方法
  study(){
    console.log('我在学习' + this.subject);
  }
}

//实例化类
let student3 = new Student('阿辉', 24, '前端开发');
student3.study(); //我在学习前端开发

上面的代码定义了一个Student类, 可以看到里面有一个constructor方法, 这就是构造方法,而this关键字则代表实例对象。也就是说,ES5中的构造函数Student, 对应的是E6中Student类中的constructor方法。

Student类除了构造函数方法,还定义了一个study方法。需要特别注意的是,在ES6中定义类中的方法的时候,前面不需要加上function关键字,直接把函数定义进去就可以了。另外,方法之间不要用逗号分隔,加了会报错。而且,类中的方法全部是定义在原型上的,我们可以用下面的代码进行验证。

console.log(student3.__proto__.study === Student.prototype.study); //true
console.log(student3.hasOwnProperty('study')); // false

上面的第一行的代码中, student3.__proto__是指向的原型对象,其中Student.prototype也是指向的原型的对象,结果为true就能很好的说明上面的结论: 类中的方法全部是定义在原型上的。第二行代码是验证student3实例中是否有study方法,结果为false, 表明实例中没有study方法,这也更好的说明了上面的结论。其实,只要理解了ES5中的构造函数对应的是类中的constructor方法,就能推断出上面的结论。

更简单的原型语法

在上面的代码中,如果我们要添加原型属性和方法,就要重复的敲一遍Person.prototype。为了减少这个重复的过程, 更常见的做法是用一个包含所有属性和方法的对象字面量来重写整个原型对象。 参考资料。

// 重写整个原型对象
Person.prototype = {

  // 这里务必要重新将构造函数指回Person构造函数,否则会指向这个新创建的对象
  constructor: Person, // Attention!

  sayName: function () {
    console.log(this.name);
  }
};

var person1 = new Person('Weiwei', 27, 'Student');
var person2 = new Person('Lily', 25, 'Doctor');

console.log(person1.sayName === person2.sayName); // true

person1.sayName();  // Weiwei
person2.sayName();  // Lily

  

在上面的代码中特意包含了一个constructor属性,并将它的值设置为Person,从而确保了通过该属性能够访问到适当的值。 注意,以这种方式重设constructor属性会导致它的[[Enumerable]]特性设置为true。默认情况下,原生的constructor属性是不可枚举的。 你可以使用Object.defineProperty()

// 重设构造函数,只适用于ES5兼容的浏览器
Object.defineProperty(Person.prototype, "constructor", {
  enumerable: false,
  value: Person
});

 

原型链继承的问题

首先是顺序,一定要先继承父类,然后为子类添加新方法。

其次,使用原型链实现继承时,不能使用对象字面量创建原型方法。因为这样做就会重写原型链,如下面的例子所示:

function Father () {
  this.fatherValue = true;
}

Father.prototype.getFatherValue = function () {
  console.log(this.fatherValue);
};

function Child () {
  this.childValue = false;
}

// 继承了Father
// 此时的原型链为 Child -> Father -> Object
Child.prototype = new Father();

// 使用字面量添加新方法,会导致上一行代码无效
// 此时我们设想的原型链被切断,而是变成 Child -> Object
Child.prototype = {
  getChildValue: function () {
    console.log(this.childValue);
  }
};

var instance = new Child();
instance.getChildValue();  // false
instance.getFatherValue(); // error!

在上面的代码中,我们连续两次修改了Child.prototype的值。由于现在的原型包含的是一个Object的实例, 而非Father的实例,因此我们设想中的原型链已经被切断——ChildFather之间已经没有关系了。

最后,在创建子类型的实例时,不能向超类型的构造函数中传递参数。实际上,应该说是没有办法在不影响所有对象实例的情况下, 给超类型的构造函数传递参数。因此,我们很少单独使用原型链。

(二) ES6中对象的继承

E6中class可以通过extends关键字来实现继承, 这比前面提到的ES5中使用原型链来实现继承, 要清晰和方便很多。下面我们使用ES6的语法来实现Pupil

//子类
class Pupil extends Student{
  constructor(name, age, subject, school) {
    //调用父类的constructor
    super(name, age, subject); 
    this.school = school;
  }
}

let pupil = new Pupil('小辉', 8, '小学义务教育课程', '北大附小');
pupil.study(); //我在学习小学义务教育课程

上面代码代码中, 我们通过了extends实现Pupil子类继承Student父类。需要特别注意的是,子类必须在constructor方法中首先调用super方法,否则实例化时会报错。这是因为子类没有自己的this对象, 而是继承父类的this对象,然后对其加工。如果不调用super方法,子类就得不到this对象。

组合使用构造函数模式和原型模式

   创建自定义类型的最常见方式,就是组合使用构造函数模式与原型模式。构造函数模式用于定义实例属性, 而原型模式用于定义方法和共享的属性。结果,每个实例都会有自己的一份实例属性的副本,但同时又共享着对方的引用, 最大限度的节省了内存。

 

 

借用构造函数继承

借用构造函数(constructor stealing)的基本思想如下:即在子类构造函数的内部调用超类型构造函数。

function Father (name) {
  this.name = name;
  this.colors = ['red', 'blue', 'green'];
}

function Child (name) {
  // 继承了Father,同时传递了参数
  Father.call(this, name);
}

var instance1 = new Child("weiwei");
instance1.colors.push('black');
console.log(instance1.colors); // [ 'red', 'blue', 'green', 'black' ]
console.log(instance1.name); // weiwei

var instance2 = new Child("lily");
console.log(instance2.colors); // [ 'red', 'blue', 'green' ]
console.log(instance2.name); // lily

为了确保Father构造函数不会重写子类型的属性,可以在调用超类型构造函数后,再添加应该在子类型中定义的属性。

四.结束语

JavaScript 被认为是世界上最受误解的编程语言,因为它身披 c 语言家族的外衣,表现的却是 LISP 风格的函数式语言特性;没有类,却实也彻底实现了面向对象。要对这门语言有透彻的理解,就必须扒开其 c 语言的外衣,从新回到函数式编程的角度,同时摒弃原有类的面向对象概念去学习领悟它(摘自参考目录1)。现在的前端中不仅普遍的使用了ES6的新语法,而且在JavaScript的基础上还出现了TypeScript、CoffeeScript这样的超集。可以预见的是,目前在前端生态圈一片繁荣的情况下,对JSer的需求也会越来越多,但同时也对前端开发者的JavaScript的水平提出了更加严苛的要求。使用面向对象的思想去开发前端项目也是未来对JSer的基本要求之一!

继承

 

   大多的面向对象语言都支持两种继承方式:接口继承和实现继承。ECMAScript只支持实现继承,而且其实现继承主要依靠原型链来实现。

前面我们知道,JavaScript中实例的属性和行为是由构造函数和原型两部分共同组成的。如果我们想让Child继承Father, 那么我们就需要Father构造函数和原型中属性和行为全部传给Child的构造函数和原型。

借用构造函数的缺点

同构造函数一样,无法实现方法的复用。

五.参考文章

  1. IBM: 全面理解面向对象的JavaScript
  2. MDN: 对象模型的细节
  3. 阮一峰: Javascript面向对象编程系列
  4. 阮一峰: ECMASciprt6入门

 

组合使用原型链和借用构造函数

通常,我们会组合使用原型链继承和借用构造函数来实现继承。也就是说,使用原型链实现对原型属性和方法的继承, 而通过借用构造函数来实现对实例属性的继承。这样,既通过在原型上定义方法实现了函数复用,又能够保证每个实例都有它自己的属性。 我们改造最初的例子如下:

// 父类构造函数
function Person (name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job;
}

// 父类方法
Person.prototype.sayName = function () {
  console.log(this.name);
};

// --------------

// 子类构造函数
function Student (name, age, job, school) {
  // 继承父类的所有实例属性
  Person.call(this, name, age, job);
  this.school = school; // 添加新的子类属性
}

// 继承父类的原型方法
Student.prototype = new Person();

// 新增的子类方法
Student.prototype.saySchool = function () {
  console.log(this.school);
};

var person1 = new Person('Weiwei', 27, 'Student');
var student1 = new Student('Lily', 25, 'Doctor', "Southeast University");

console.log(person1.sayName === student1.sayName); // true

person1.sayName();  // Weiwei
student1.sayName(); // Lily
student1.saySchool(); // Southeast University

组合集成避免了原型链和借用构造函数的缺陷,融合了它们的优点,成为了JavaScript中最常用的继承模式。 而且,instanceofisPropertyOf()也能够用于识别基于组合继承创建的对象。

原型链继承

使用原型链作为实现继承的基本思想是:利用原型让一个引用类型继承另一个引用类型的属性和方法。首先我们先回顾一些基本概念:

  • 每个构造函数都有一个原型对象(prototype
  • 原型对象包含一个指向构造函数的指针(constructor
  • 实例都包含一个指向原型对象的内部指针([[Prototype]]

   如果我们让原型对象等于另一个类型的实例,结果会怎么样?显然,此时的原型对象将包含一个指向另一个原型的指针, 相应的,另一个原型中也包含着一个指向另一个构造函数的指针。假如另一个原型又是另一个类型的实例,那么上述关系依然成立, 如此层层递进,就构成了实例与原型的链条。 更详细的内容可以参考这个链接。 先看一个简单的例子,它演示了使用原型链实现继承的基本框架:

function Father () {
  this.fatherValue = true;
}

Father.prototype.getFatherValue = function () {
  console.log(this.fatherValue);
};

function Child () {
  this.childValue = false;
}

// 实现继承:继承自Father
Child.prototype = new Father();

Child.prototype.getChildValue = function () {
  console.log(this.childValue);
};

var instance = new Child();
instance.getFatherValue(); // true
instance.getChildValue();  // false

 

在上面的代码中,原型链继承的核心语句是Child.prototype = new Father(),它实现了ChildFather的继承, 而继承是通过创建Father的实例,并将该实例赋给Child.prototype实现的。

实现的本质是重写原型对象,代之以一个新类型的实例。也就是说,原来存在于Father的实例中的所有属性和方法, 现在也存在于Child.prototype中了。

这个例子中的实例以及构造函数和原型之间的关系如下图所示:

图片 12

在上面的代码中,我们没有使用Child默认提供的原型,而是给它换了一个新原型;这个新原型就是Father的实例。 于是,新原型不仅具有了作为一个Father的实例所拥有的全部属性和方法。而且其内部还有一个指针[[Prototype]],指向了Father的原型。

  • instance指向Child的原型对象
  • Child的原型对象指向Father的原型对象
  • getFatherValue()方法仍然还在Father.prototype
  • 但是,fatherValue则位于Child.prototype
  • instance.constructor现在指向的是Father

因为fatherValue是一个实例属性,而getFatherValue()则是一个原型方法。既然Child.prototype现在是Father的实例, 那么fatherValue当然就位于该实例中。

通过实现原型链,本质上扩展了本章前面介绍的原型搜索机制。例如,instance.getFatherValue()会经历三个搜索步骤:

  1. 搜索实例
  2. 搜索Child.prototype
  3. 搜索Father.prototype

组合继承的改进版:使用Object.create()

在上面,我们继承父类的原型方法使用的是Student.prototype = new Person()。 这样做有很多的问题。 改进方法是使用ES5中新增的Object.create()。可以调用这个方法来创建一个新对象。新对象的原型就是调用create()方法传入的第一个参数:

Student.prototype = Object.create(Person.prototype);

console.log(Student.prototype.constructor); // [Function: Person]

// 设置 constructor 属性指向 Student
Student.prototype.constructor = Student;

详细用法可以参考文档。 关于Object.create()的实现,我们可以参考一个简单的polyfill:

function createObject(proto) {
    function F() { }
    F.prototype = proto;
    return new F();
}

// Usage:
Student.prototype = createObject(Person.prototype);

从本质上讲,createObject()对传入其中的对象执行了一次浅复制。

 

ES6中的面向对象语法

ES6中引入了一套新的关键字用来实现class。 JavaScript仍然是基于原型的,这些新的关键字包括class、 constructor、 static、 extends、 和super。

对前面的代码修改如下:

'use strict';

class Person {

  constructor (name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
  }

  sayName () {
    console.log(this.name);
  }

}

class Student extends Person {

  constructor (name, age, school) {
    super(name, age, 'Student');
    this.school = school;
  }

  saySchool () {
    console.log(this.school);
  }

}

var stu1 = new Student('weiwei', 20, 'Southeast University');
var stu2 = new Student('lily', 22, 'Nanjing University');

stu1.sayName(); // weiwei
stu1.saySchool(); // Southeast University

stu2.sayName(); // lily
stu2.saySchool(); // Nanjing University

别忘了Object

所有的函数都默认原型都是Object的实例,因此默认原型都会包含一个内部指针[[Prototype]],指向Object.prototype。 这也正是所有自定义类型都会继承toString()valueOf()等默认方法的根本原因。所以, 我们说上面例子展示的原型链中还应该包括另外一个继承层次。关于Object的更多内容,可以参考这篇博客。

也就是说,Child继承了Father,而Father继承了Object。当调用了instance.toString()时, 实际上调用的是保存在Object.prototype中的那个方法。

类:class

是JavaScript中现有基于原型的继承的语法糖。ES6中的类并不是一种新的创建对象的方法,只不过是一种“特殊的函数”, 因此也包括类表达式和类声明, 但需要注意的是,与函数声明不同的是,类声明不会被提升。 参考链接

 

类构造器:constructor

constructor()方法是有一种特殊的和class一起用于创建和初始化对象的方法。注意,在ES6类中只能有一个名称为constructor的方法, 否则会报错。在constructor()方法中可以调用super关键字调用父类构造器。如果你没有指定一个构造器方法, 类会自动使用一个默认的构造器。参考链接

原型链继承的问题

首先是顺序,一定要先继承父类,然后为子类添加新方法。

其次,使用原型链实现继承时,不能使用对象字面量创建原型方法。因为这样做就会重写原型链,如下面的例子所示:

function Father () {
  this.fatherValue = true;
}

Father.prototype.getFatherValue = function () {
  console.log(this.fatherValue);
};

function Child () {
  this.childValue = false;
}

// 继承了Father
// 此时的原型链为 Child -> Father -> Object
Child.prototype = new Father();

// 使用字面量添加新方法,会导致上一行代码无效
// 此时我们设想的原型链被切断,而是变成 Child -> Object
// 所以我们不推荐这么写了
Child.prototype = {
  getChildValue: function () {
    console.log(this.childValue);
  }
};

var instance = new Child();
instance.getChildValue();  // false
instance.getFatherValue(); // error!

 

在上面的代码中,我们连续两次修改了Child.prototype的值。由于现在的原型包含的是一个Object的实例, 而非Father的实例,因此我们设想中的原型链已经被切断——ChildFather之间已经没有关系了。

最后,在创建子类型的实例时,不能向超类型的构造函数中传递参数。实际上,应该说是没有办法在不影响所有对象实例的情况下, 给超类型的构造函数传递参数。因此,我们很少单独使用原型链。

类的静态方法:static

静态方法就是可以直接使用类名调用的方法,而无需对类进行实例化,当然实例化后的类也无法调用静态方法。 静态方法常被用于创建应用的工具函数。参考链接

 

继承父类:extends

extends关键字可以用于继承父类。使用extends可以扩展一个内置的对象(如Date),也可以是自定义对象,或者是null

借用构造函数继承

借用构造函数(constructor stealing)的基本思想如下:即在子类构造函数的内部调用超类型构造函数。

function Father (name) {
  this.name = name;
  this.colors = ['red', 'blue', 'green'];
}

function Child (name) {
  // 继承了Father,同时传递了参数
  // 之所以这么做,是为了获得Father构造函数中的所有属性和方法
  // 之所以用call,是为了修正Father内部this的指向
  Father.call(this, name);
}

var instance1 = new Child("weiwei");
instance1.colors.push('black');
console.log(instance1.colors); // [ 'red', 'blue', 'green', 'black' ]
console.log(instance1.name); // weiwei

var instance2 = new Child("lily");
console.log(instance2.colors); // [ 'red', 'blue', 'green' ]
console.log(instance2.name); // lily

 

为了确保Father构造函数不会重写子类型的属性,可以在调用超类型构造函数后,再添加应该在子类型中定义的属性。

关键字:super

super关键字用于调用父对象上的函数。 super.propsuper[expr]表达式在类和对象字面量中的任何方法定义中都有效。

super([arguments]); // 调用父类构造器
super.functionOnParent([arguments]); // 调用父类中的方法

如果是在类的构造器中,需要在this关键字之前使用。参考链接

 

小结

本文对JavaScript的面向对象机制进行了较为深入的解读,尤其是构造函数和原型链方式实现对象的创建、继承、以及实例化。 此外,本文还简要介绍了如在ES6中编写面向对象代码。

借用构造函数的缺点

同构造函数一样,无法实现方法的复用(所有的方法会被重复创建一份)。

References

  1. 详解Javascript中的Object对象
  2. new操作符
  3. JavaScript面向对象简介
  4. Object.create()
  5. 继承与原型链         

转自 

 

组合使用原型链和借用构造函数

通常,我们会组合使用原型链继承和借用构造函数来实现继承。也就是说,使用原型链实现对原型属性和方法的继承, 而通过借用构造函数来实现对实例属性的继承。这样,既通过在原型上定义方法实现了函数复用,又能够保证每个实例都有它自己的属性。 我们改造最初的例子如下:

// 父类构造函数
function Person (name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job;
}

// 父类方法
Person.prototype.sayName = function () {
  console.log(this.name);
};

// --------------

// 子类构造函数
function Student (name, age, job, school) {
  // 继承父类的所有实例属性(获得父类构造函数中的属性)
  Person.call(this, name, age, job);
  this.school = school; // 添加新的子类属性
}

// 继承父类的原型方法(获得父类原型链上的属性和方法)
Student.prototype = new Person();

// 新增的子类方法
Student.prototype.saySchool = function () {
  console.log(this.school);
};

var person1 = new Person('Weiwei', 27, 'Student');
var student1 = new Student('Lily', 25, 'Doctor', "Southeast University");

console.log(person1.sayName === student1.sayName); // true

person1.sayName();  // Weiwei
student1.sayName(); // Lily
student1.saySchool(); // Southeast University

 

组合集成避免了原型链和借用构造函数的缺陷,融合了它们的优点,成为了JavaScript中最常用的继承模式。 而且,instanceofisPropertyOf()也能够用于识别基于组合继承创建的对象。

 

组合继承的改进版:使用Object.create()

在上面,我们继承父类的原型方法使用的是Student.prototype = new Person()。 这样做有很多的问题。 改进方法是使用ES5中新增的Object.create()。可以调用这个方法来创建一个新对象。新对象的原型就是调用create()方法传入的第一个参数:

Student.prototype = Object.create(Person.prototype);

console.log(Student.prototype.constructor); // [Function: Person]

// 设置 constructor 属性指向 Student
Student.prototype.constructor = Student;

 

 

详细用法可以参考文档。 关于Object.create()的实现,我们可以参考一个简单的polyfill:

function createObject(proto) {
    function F() { }
    F.prototype = proto;
    return new F();
}

// Usage:
Student.prototype = createObject(Person.prototype);

 

从本质上讲,createObject()对传入其中的对象执行了一次浅复制。

 

ES6中的面向对象语法

 

ES6中引入了一套新的关键字用来实现class。 但它并不是映入了一种新的面向对象继承模式。JavaScript仍然是基于原型的,这些新的关键字包括class、 constructor、 static、 extends、 和super。

class关键字不过是提供了一种在本文中所讨论的基于原型模式和构造器模式的面向对象的继承方式的语法糖(syntactic sugar)

对前面的代码修改如下:

'use strict';

class Person {

  constructor (name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
  }

  sayName () {
    console.log(this.name);
  }

}

class Student extends Person {

  constructor (name, age, school) {
    super(name, age, 'Student');
    this.school = school;
  }

  saySchool () {
    console.log(this.school);
  }

}

var stu1 = new Student('weiwei', 20, 'Southeast University');
var stu2 = new Student('lily', 22, 'Nanjing University');

stu1.sayName(); // weiwei
stu1.saySchool(); // Southeast University

stu2.sayName(); // lily
stu2.saySchool(); // Nanjing University

 

类:class

是JavaScript中现有基于原型的继承的语法糖。ES6中的并不是一种新的创建对象的方法,只不过是一种“特殊的函数”, 因此也包括类表达式和类声明, 但需要注意的是,与函数声明不同的是,类声明不会被提升。 参考链接

 

类构造器:constructor

constructor()方法是有一种特殊的和class一起用于创建和初始化对象的方法。注意,在ES6类中只能有一个名称为constructor的方法, 否则会报错。在constructor()方法中可以调用super关键字调用父类构造器。如果你没有指定一个构造器方法, 类会自动使用一个默认的构造器。参考链接

 

类的静态方法:static

静态方法就是可以直接使用类名调用的方法,而无需对类进行实例化,当然实例化后的类也无法调用静态方法。 静态方法常被用于创建应用的工具函数。参考链接

 

继承父类:extends

extends关键字可以用于继承父类。使用extends可以扩展一个内置的对象(如Date),也可以是自定义对象,或者是null

 

关键字:super

super关键字用于调用父对象上的函数。 super.propsuper[expr]表达式在类和对象字面量中的任何方法定义中都有效。

super([arguments]); // 调用父类构造器
super.functionOnParent([arguments]); // 调用父类中的方法

 

如果是在类的构造器中,需要在this关键字之前使用。参考链接

 

小结

本文对JavaScript的面向对象机制进行了较为深入的解读,尤其是构造函数和原型链方式实现对象的创建、继承、以及实例化。 此外,本文还简要介绍了如在ES6中编写面向对象代码。

 

References

  1. 详解Javascript中的Object对象
  2. new操作符
  3. JavaScript面向对象简介
  4. Object.create()
  5. 继承与原型链
  6. Understanding the prototype property in JavaScript

 

 本文转载自:

 

<iframe src="//www.googletagmanager.com/ns.html?id=GTM-KJHNXB" height="0" width="0" style="display:none;visibility:hidden"></iframe>

本文由9159.com发布于前端,转载请注明出处:   译文出处,包括对象的创建

关键词: