JavaScript面向对象编程实践

面向对象的几个概念

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

  • 类:定义对象的特征。它是对象的属性和方法的模板定义。java

  • 对象(或称实例):类的一个实例。编程

  • 属性:对象的特征,好比颜色、尺寸等。浏览器

  • 方法:对象的行为,好比行走、说话等。框架

  • 构造函数:对象初始化的瞬间被调用的方法。函数

  • 继承:子类能够继承父类的特征。例如,猫继承了动物的通常特性。工具

  • 封装:一种把数据和相关的方法绑定在一块儿使用的方法。性能

  • 抽象:结合复杂的继承、方法、属性的对象可以模拟现实的模型。this

  • 多态:不一样的类能够定义相同的方法或属性。spa

在JavaScript的面向对象编程中大致也包括这些。不过在称呼上可能稍有不一样,例如,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);  //trueconsole.log(person2.constructor == Person);  //true

自定义对象的类型检测

咱们可使用instanceof操做符进行类型检测。咱们建立的全部对象既是Object的实例,同时也是Person的实例。 由于全部的对象都继承自Object

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

构造函数的问题

咱们不建议在构造函数中直接定义方法,若是这样作的话,每一个方法都要在每一个实例上从新建立一遍,这将很是损耗性能。 ——不要忘了,ECMAScript中的函数是对象,每定义一个函数,也就实例化了一个对象。

幸运的是,在ECMAScript中,咱们能够借助原型对象来解决这个问题。

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

咱们建立的每一个函数都有一个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); // trueperson1.sayName(); // Weiweiperson2.sayName(); // Lily

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

function Chinese (name) {
    this.name = name;}Chinese.prototype.country = 'China'; // 公共属性,全部实例共享

原型对象

如今咱们来深刻的理解一下什么是原型对象。

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

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

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

prototype graph

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

  • Person.prototype指向了原型对象

  • Person.prototype.constructor又指回了Person构造函数

  • Person的每一个实例person1person2都包含一个内部属性(一般为__proto__),person1.__proto__person2.__proto__指向了原型对象

查找对象属性

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

  1. 搜索首先从对象实例自己开始(实例person1sayName属性吗?——没有)

  2. 若是没找到,则继续搜索指针指向的原型对象person1.__proto__sayName属性吗?——有)

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

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

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)返回的对象实际就是这个对象的原型。 这个方法的兼容性请参考该连接

Object.keys()

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

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

此外,若是你想要获得全部实例属性,不管它是否可枚举,均可以使用Object.getOwnPropertyName()方法。

更简单的原型语法

在上面的代码中,若是咱们要添加原型属性和方法,就要重复的敲一遍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); // trueperson1.sayName();  // Weiweiperson2.sayName();  // Lily

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

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

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

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

继承

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

原型链继承

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

  • 每一个构造函数都有一个原型对象(prototype

  • 原型对象包含一个指向构造函数的指针(constructor

  • 实例都包含一个指向原型对象的内部指针([[Prototype]]

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

function Father () {
  this.fatherValue = true;}Father.prototype.getFatherValue = function () {
  console.log(this.fatherValue);};function Child () {
  this.childValue = false;}// 实现继承:继承自FatherChild.prototype = new Father();Child.prototype.getChildValue = function () {
  console.log(this.childValue);};var instance = new Child();instance.getFatherValue(); // trueinstance.getChildValue();  // false

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

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

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

prototype chain inheritance

在上面的代码中,咱们没有使用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

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

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

原型链继承的问题

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

其次,使用原型链实现继承时,不能使用对象字面量建立原型方法。由于这样作就会重写原型链,以下面的例子所示:

function Father () {
  this.fatherValue = true;}Father.prototype.getFatherValue = function () {
  console.log(this.fatherValue);};function Child () {
  this.childValue = false;}// 继承了Father// 此时的原型链为 Child -> Father -> ObjectChild.prototype = new Father();// 使用字面量添加新方法,会致使上一行代码无效// 此时咱们设想的原型链被切断,而是变成 Child -> ObjectChild.prototype = {
  getChildValue: function () {
    console.log(this.childValue);
  }};var instance = new Child();instance.getChildValue();  // falseinstance.getFatherValue(); // error!

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

最后,在建立子类型的实例时,不能向超类型的构造函数中传递参数。实际上,应该说是没有办法在不影响全部对象实例的状况下, 给超类型的构造函数传递参数。所以,咱们不多单独使用原型链。

借用构造函数继承

借用构造函数(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); // weiweivar instance2 = new Child("lily");console.log(instance2.colors); // [ 'red', 'blue', 'green' ]console.log(instance2.name); // lily

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

借用构造函数的缺点

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

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

一般,咱们会组合使用原型链继承和借用构造函数来实现继承。也就是说,使用原型链实现对原型属性和方法的继承, 而经过借用构造函数来实现对实例属性的继承。这样,既经过在原型上定义方法实现了函数复用,又可以保证每一个实例都有它本身的属性。 咱们改造最初的例子以下:

// 父类构造函数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); // trueperson1.sayName();  // Weiweistudent1.sayName(); // Lilystudent1.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 属性指向 StudentStudent.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仍然是基于原型的,这些新的关键字包括classconstructorstaticextends、 和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(); // weiweistu1.saySchool(); // Southeast Universitystu2.sayName(); // lilystu2.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中编写面向对象代码。

相关文章
相关标签/搜索