深入解读JavaScript面向对象编程实践

Mar 9, 2016

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

面向对象的几个概念

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

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

在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);  //true
console.log(person2.constructor == Person); //true

自定义对象的类型检测

我们可以使用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

构造函数的问题

我们不建议在构造函数中直接定义方法,如果这样做的话,每个方法都要在每个实例上重新创建一遍,这将非常损耗性能。 ——不要忘了,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); // true person1.sayName(); // Weiwei
person2.sayName(); // Lily

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

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

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

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

原型对象

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

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

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

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

上图展示了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); // 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
});

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

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

继承

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

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

原型链继承

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

  • 每个构造函数都有一个原型对象(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中了。

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

在上面的代码中,我们没有使用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 -> 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之间已经没有关系了。

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

借用构造函数继承

借用构造函数(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构造函数不会重写子类型的属性,可以在调用超类型构造函数后,再添加应该在子类型中定义的属性。

借用构造函数的缺点

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

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

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

// 父类构造函数
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

js面向对象(构造函数与继承)的更多相关文章

  1. Js面向对象构造函数继承

    构造函数继承 <!-- 创建构造函数 --> function Animal(){ this.species= '动物'; } function Dog(name,color){ this ...

  2. js面向对象--类式继承

    //待研究//类式继承 //js中模拟类式继承的3个函数 //简单的辅助函数,让你可以将新函数绑定到对象的 prototype 上 Function.prototype.method = functi ...

  3. JavaScript 对象的原型扩展(JS面向对象中的继承)

    <script type="text/javascript"> function person(name, age) { this._name = name; this ...

  4. 从零开始的全栈工程师——JS面向对象( 六大继承 )

    一.对象克隆 var obj = { name:'li', age:23 } var obj2 = obj; // 这不是对象克隆 只是把obj的内存地址给obj2 1.for in克隆(浅拷贝)  ...

  5. js面向对象程序设计之继承

    在面向对象语言中继承分成两种:接口继承和实现继承.解释一下,接口继承只继承方法的签名,而实现继承则是继承实际的方法.但是ECMAScript中的函数没有签名所以无法进行接口继承,只能是实现实现继承.而 ...

  6. 深入理解js——非构造函数的继承

    原文来自阮一峰日志(http://www.ruanyifeng.com/blog/2010/05/object-oriented_javascript_inheritance_continued.ht ...

  7. node.js面向对象实现(二)继承

    http://blog.sina.com.cn/s/blog_b5a53f2e0101nrdi.html 继承是面向对象中非常重要的一个概念,那么在Node.js中如何实现继承呢? node.js在u ...

  8. 第九课:js的类与继承

    因为本书是js的框架设计,因此观看本书的必须有js基础,看不懂,请不要觉得自己差.我也是看了5遍js高级程序设计,才能看懂这本书的. 有关js的构造函数,继承的方法大家可以去看js的高级程序设计,我这 ...

  9. js面向对象编程(三)非构造函数的继承(转载)

    Javascript面向对象编程(三):非构造函数的继承 今天是最后一个部分,介绍不使用构造函数实现"继承". 一.什么是"非构造函数"的继承? 比如,现在有一 ...

  10. js面向对象编程(二)构造函数的继承(转载)

    Javascript面向对象编程(二):构造函数的继承 这个系列的第一部分,主要介绍了如何"封装"数据和方法,以及如何从原型对象生成实例. 今天要介绍的是,对象之间的"继 ...

随机推荐

  1. UIStepper

    @在IOS5中增加了一个UIStepper的新控件,UIStepper可以连续增加或减少一个数值.控件的外观是两个水平并排的按钮构成,一个显示为“+”,一个显示为“-”. 1. 初始化控件 UISte ...

  2. Eclipse 编译错误 Access restriction: The type 'JPEGCodec' is not API (restriction on required library 'C:\Program Files\Java\jre7\lib\rt.jar')

    解决方案:          Project -> Properties ->Java Build Path -> libraries,         先 remove 掉 JRE ...

  3. Scrum团队成立,阅读《构建之法》第6~7章,并参考以下链接,发布读后感、提出问题、并简要说明你对Scrum的理解

    Scrum团队成立:  团队名称:神的孩子 团队目标:短期目标,完成O2O模式的第一个平台 团队口号:我们都不是神的孩子 团队照: 角色分配 产品负责人: 许佳仪.决定开发内容和优先级排序,最大化产品 ...

  4. 快速集成iOS基于RTMP的视频推流

    前言 这篇blog是iOS视频直播初窥:<喵播APP>的一个补充. 因为之前传到github上的项目中没有集成视频的推流.有很多朋友简信和微博上问我推流这部分怎么实现的. 所以, 我重新集 ...

  5. IDEA配置maven

    步骤:Setting....或Ctrl+Alt+S

  6. ABAP简单表维护的制作

    为了知识的积累,特作了个简单的表维护. 因为自己之前做dynpro程序的时候建了一个Tree node的表,所以就不在此重复.(在表的交付和维护页签中标的属性要是‘允许标准表维护的’) 直接Alt+U ...

  7. Python环境的安装

    参考官方文档 http://www.runoob.com/python/python-install.html Python已经被移植在许多平台上(经过改动使它能够工作在不同平台上). 您需要下载适用 ...

  8. js事件知识整理

    鼠标事件 鼠标移动到目标元素上的那一刻,首先触发mouseover 之后如果光标继续在元素上移动,则不断触发mousemove 如果按下鼠标上的设备(左键,右键,滚轮……),则触发mousedown ...

  9. backbonejs中的集合篇(一)

    一:集合概念 集合是多个模型,如果把模型model理解为表结构中的行,那么集合collection就是一张表,由多个行组成.我们经常需要用集合来组织和管理多个模型. 二:创建集合 1:扩展Backbo ...

  10. BZOJ3928 [Cerc2014] Outer space invaders

    第一眼,我勒个去...然后看到n ≤ 300的时候就2333了 首先把时间离散化,则对于一个时间的区间,可以知道中间最大的那个一定要被选出来,然后把区间分成左右两份 于是区间DP就好了,注意用左开右开 ...