对象、原型链、类、继承【上】

2,307 阅读21分钟

概述

JavaScript,或者说ECMAScript 具有面向对象语言的一些特点,但它不是一门纯粹的面向对象语言,因为它也包含着函数式编程的一些东西。事实上,现在很多的面向对象的语言,比如Java,也开始实现一些函数式的新特性。总之,所有的编程语言都在随着应用场景的变化而不断进化。

这篇文章尽可能的将ECMAScript这门语言中关于面向对象的实现表述完全。好了,我们先从对象开始吧!

对象

对象的定义:无序属性的集合,其属性可以包含基本值、对象、或者函数。可以看做一个散列。

对象的创建:每个对象都是基于一个引用类型创建的,这个引用类型可以是原生类型,也可以是自定义的类型。

对象的属性类型

对象有属性(property),属性有特性(attribute),特性一般表示形式为双方括号(如[[attribute]])。

对象有两种属性(property):数据属性访问器属性

类型:数据属性

数据属性包含一个数据值的位置。在这个位置可以读/写值。数据属性有四个特性([[attribute]]):

  • [[Configurable]] 表示能否通过delete删除属性,能否修改属性的特性值,能否把属性修改为访问器属性。直接在对象上定义的属性的默认值为true
  • [[Enumerable]] 表示能否通过for-in循环返回属性。直接在对象上定义的属性的默认值为true
  • [[Writable]] 表示能否修改属性的值。直接在对象上定义的属性的默认值为true
  • [[Value]] 属性的数据值。此处用来存储。默认值为undefined

defineProperty方法可以配置属性的特性。(IE9+)

var obj = {}
Object.defineProperty(obj, 'x', {
    configurable: true,
    enumerable: true,
    writable: true,
    value: 123
})
obj; // {x: 123}

类型:访问器属性

访问器属性没有数据值([[Value]]),所以也没有([[Writable]]),但是多了[[Get]][[Set]]。也叫gettersetter。用于读、写对应属性的值。

  • [[Configurable]] 表示能否通过delete删除属性,能否修改属性的特性值,能否把属性修改为访问器属性。直接在对象上定义的属性的默认值为true
  • [[Enumerable]] 表示能否通过for-in循环返回属性。直接在对象上定义的属性的默认值为true
  • [[Get]] 读取属性时调用的函数。默认值是undefined
  • [[Set]] 写入属性时调用的函数,参数为写入值,默认值是undefined

访问器属性不能直接定义,必须使用defineProperty来定义。

var book = {
    _page: 2
}
Object.defineProperty(book, 'page', {
    get: function () {
        console.log('你调用了get方法')
        return this._page
    },
    set: function (val) {
    console.log('你调用了set方法')
        this._page = val
    }
})
book.page; // 你调用了get方法
book.page = 3 // 你调用了set方法

defineProperty是ES5新加的方法,在此之前,对于gettersetter,浏览器内部有自己的实现。

var book = {
    _page: 2
}
book.__defineGetter__('page', function () {
    return this._page
})
book.__defineSetter__('page', function (val) {
    this._page = val
})

定义多个属性

defineProperties()(ES5,IE9+)可以同时定义多个属性。

var book = {}
Object.defineProperties(book, {
    _page: {
        value: 2
    },
    author: {
        value: 'JiaHeSheng'
    },
    page: {
        get: function () {
            return this._page
        },
        set: function (val) {
            this._page = val
        }
    }
})
book;
/*
{
  author: "JiaHeSheng"
  page: 2
  _page: 2
  get page: ƒ ()
  set page: ƒ (val)
}
*/

读取属性的特性

如何查看对象某个属性的特性呢?ES5提供了getOwnPropertyDescriptor方法, 它返回一个属性所拥有的特性组成的对象。

var obj = { x: 456 }
Object.getOwnPropertyDescriptor(obj, 'x')
/*
{
  configurable: true
  enumerable: true
  value: 456
  writable: true
}
*/

创建对象

创建对象最简单的模式就是通过对象字面量进行创建,如var obj = {}。也可以通过Object构造函数,配合new命令进行创建,如var obj = new Object()。但这些都适用于创建单个对象,如果我要批量创建一些「具有某些相同属性」的对象呢?

工厂模式

通过参数传入工厂函数,每次都可已生成一个包含特有信息和相同信息的新对象。但缺点是,我们并不能通过其生成的实例找到与对应的工厂函数的关联。

function factoryFoo (name, age) {
    var o = {}
    o.name = name;
    o.age = age;
    o.say = function () {
        console.log(this.name)
    }
    return o
}
var p1 = factoryFoo('Tom', 23)
var p2 = factoryFoo('Jack', 24)
p1.constructor // Object
p1 instanceof factoryFoo // false

我们可以看到,实例的constructor指向了Object,并且也不能证明p1是工厂函数的实例。

构造函数模式

为了解决工厂函数带来了问题,我们试着使用构造函数+new来生成实例。

function Person (name, age) {
    this.name = name;
    this.age = age;
    this.say = function () {
        console.log(this.name)
    }
}

var p1 = new Person('Tom', 23)
var p2 = new Person('Jack', 24)

p1.constructor // Person
p2.constructor // Person

p1 instanceof Person // true
p1 instanceof Object // true

我们可以看到,使用构造函数模式构造出来的对象实例,可以通过其constructor属性找到它的构造函数。(解决了工厂函数的问题)

另外,与工厂函数相比,少了显式地创建对象,少了return语句。这是因为使用new操作符,隐式地做了这些事情。

什么是构造函数

构造函数与普通函数的区别就是,它被new命令用来创建了实例。换言之,没有被new操作的构造函数就是普通函数。

构造函数的缺点

构造函数也有它的缺点:每个方法都会在实例化的时候被重新创造一遍,即使它们一模一样。 上例中的say方法就被创造了两次。

// 创建实例时
this.say = new Function('console.log(this.name)')

// 创建后
p1.say === p2.say // false

为了解决这个问题,我们可以这样:

function say () {
    console.log(this.name)
}
function Person (name, age) {
    this.name = name;
    this.age = age;
    this.say = say
}

但是这又引出了一个新问题:总不能每个方法都这样全局定义吧?

new 运算符

new运算符用来创建一个用户定义的对象类型的实例或具有构造函数的内置对象的实例。

当代码 new Foo(...) 执行时,会发生以下事情:

  1. 一个继承自 Foo.prototype 的新对象被创建。
  2. 使用指定的参数调用构造函数 Foo ,并将 this 绑定到新创建的对象。new Foo 等同于 new Foo(),也就是没有指定参数列表,Foo 不带任何参数调用的情况。
  3. 由构造函数返回的对象就是 new 表达式的结果。如果构造函数没有显式返回一个对象,则使用步骤1创建的对象。(一般情况下,构造函数不返回值,但是用户可以选择主动返回对象,来覆盖正常的对象创建步骤)

原型模式

为了解决上面的问题,ECMAScript语言中有了原型(prototype)和原型链的概念。

每一个函数上都有一个prototype属性,这个属性是一个指针,指向一个对象,这个对象包含了一些属性和方法,这些属性和方法可以被所有由这个函数创建的实例所共享。

举例来说,任意一个函数Personprototype属性指向对象prototypeObject对象,所有由new Person()创建的实例(p1p2...pn),都会共享prototypeObject的属性和方法。

function Person (){
}
Person.prototype.age = 34;
Person.prototype.getAge = function () {
    return this.age
}
var p1 = new Person()
var p2 = new Person()
p1.age === p2.age // true
p1.age // 34
p1.getAge === p2.getAge // true
p2.getAge() // 34

构造函数、实例、原型对象

无论什么时候,创建一个新函数,新函数就会有prototype属性,它指向该函数的原型对象。 默认情况下,每个原型对象都有一个属性constructor,它指向原型所在的函数。 当调用这个函数生成出一个实例之后,生成的实例有个隐藏的属性(不可见,也无法访问)[[prototype]],它指向原型对象。 幸好,浏览器实现了这个属性:__proto__,通过这个属性可以访问原型对象。不过这不是标准实现,不建议在生产环境中使用。

通过上面的示例和描述,我制作了一张图片,说明「构造函数、实例、原型对象」的关系:

构造函数、实例、原型对象的关系

知道他们的关系之后,我们看下通过哪些方法可以查看他们关系。

// 证明 p1是 Person 的实例
p1.constructor === Person // true
p1 instanceof Person // true

// 证明 Person.prototype 是 p1 的原型对象
Person.prototype === p1.__proto__ // true
Person.prototype.isPrototypeOf(p1) // true
Object.getPrototypeOf(p1) === Person.prototype // true

点击查看Object.prototype.isPrototypeOf()Object.getPrototypeOf的详细用法。

实例、原型对象上的属性和方法

如果要读取实例上的属性或者方法,就会现在实例对象上搜索,如果有就返回搜到的值;如果没有,继续在实例的原型对象上搜索,如果搜到,就返回搜到的值,如果没搜到,就返回undefined。 下面是示例:

function Person () {}
var p1 = new Person();
p1.x // undefined

Person.prototype.x = 'hello'
p1.x // hello 来自原型对象

p1.x = 'world'
p1.x // world 来自实例
Person.prototype.x // hello

这是抽象出来的搜索流程图:

寻找属性值

我们在代码示例中看到,给示例的属性赋值,并没有覆盖原型上对应的属性值,只是在搜索时,屏蔽掉了而已。 而这就是使用原型对象的好处:生成的实例,可以共享原型对象的属性和方法,也可以在自身自定义属性和方法,即使同名也互不影响,并且优先使用实例上的定义。

继续看下面的代码:

p1.x = null
p1.x // null 来自实例
delete p1.x
p1.x // hello 来自原型对象

设置属性值为null,获取属性的时候,并不会跳过实例,如果要重新建立与原型对象的链接,可以使用delete删除实例上的属性。

那么如何知道当前获取的属性值是在实例还是在原型对象上面定义的呢?ECMAScript提供了hasOwnProperty方法,该方法会忽略掉那些从原型链上继承到的属性。

p1.x = 'world'
p1.hasOwnProperty('x') // true
delete p1.x
p1.hasOwnProperty('x') // false
Person.prototype.x = 'hello'
p1.hasOwnProperty('x') // false
Person.prototype.hasOwnProperty('x') // true

原型与in操作符

in操作符会在通过对象能够访问给定属性时返回true,无论该属性存在于实例还是原型中。

Person.prototype.x = 'hello'
'x' in Person.prototype // true
'x' in p1 // true
p1.x = 'world'
'x' in p1 // true

组合使用in操作符和hasOwnProperty即可判断取到的属性值,是不是存于原型中的。

function hasPrototypeProperty (obj, name) {
    return !obj.hasOwnProperty(name) && (name in obj)
}
Person.prototype.x = 'hello'
hasPrototypeProperty(p1, 'x') // true
p1.x = 'world'
hasPrototypeProperty(p1, 'x') // false

那么,如何获取对象上所有自身的属性和方法呢?

  • Object.keys。可以获取对象上所有自身的可枚举属性和方法名,返回一个名称列表。

  • Object.getOwnPropertyNames。可以获取对象上自身的所有属性和方法名,包括不可枚举的,也返回一个名称列表。

更简单的原型语法

上面示例中,我们添加原型属性,是一个一个在Person.prototype上添加。为了减少不必要的输入,视觉上也更易读,我们可以把要添加的属性和方法,直接封装成对象,然后改变Person.prototype指向的位置。

function Person () {}
Person.prototype = {
    age: 34,
    getAge: function () {
        return this.age
    }
}

但是,如果这样做,Person.prototype.constructor也被重写,指向了封装对象的构造函数,也就是Object

Person.prototype.constructor === Object // true

这时,我们已经无法通过constructor知道原型对象的构造类型了。如果你还记得,工厂模式也存在这个问题。我们可以这样做:

function Person () {}
Person.prototype = {
    constructor: Person,
    age: 34,
    getAge: function () {
        return this.age
    }
}

但是,这样也会有问题。默认的constructor是不可枚举的,这样显式的赋值之后,就会变成可枚举的了。

Person.hasOwnProperty('constructor') // true

如果你很在意这个,可以使用defineProperty,修改constructor属性为不可枚举。

Object.defineProperty(Person.prototype, 'constructor', {
    enumerable: false,
    value: Person
})

原型的动态性

因为联系原型对象和实例的只是一个指针,而不是一个原型对象的副本,所以原型对象上属性的任何修改都会在实例上反应出来,无论实例创建是在改动之前或者之后。

function Person () {}
Person.prototype.age = 12
var p1 = new Person()
p1.age // 12
Person.prototype.age = 24
p1.age // 24
var p2 = new Person()
p2.age // 24

但如果修改了整个原型对象,那情况不一样了。因为重写原型对象会切断构造函数与原先原型对象的联系,而实例的指针指向的却是原来的原型对象。

function Person () {}
Person.prototype = {
    age: 12
}
var p1 = new Person()
p1.age // 12
p1.__proto__ === Person.prototype // true

Person.prototype = {
    age: 24
}
p1.age // 12
Person.prototype.age // 24 
p1.__proto__ === Person.prototype // false

所以,修改原型对象是把双刃剑,用得好可以解决问题,用不好就会带来问题。

原生对象的原型

原生对象(ObjectArrayString等)其实也是构造函数

typeof Object // function
typeof Array // function
typeof String // function

它们自身拥有一些属性和方法,它们的原型对象也拥有一些,而原型对象上面的属性和方法,都会被它们构造的实例所共享。

Object.getOwnPropertyNames(Object).join(',') // "length,name,prototype,assign,getOwnPropertyDescriptor,getOwnPropertyDescriptors,getOwnPropertyNames,getOwnPropertySymbols,is,preventExtensions,seal,create,defineProperties,defineProperty,freeze,getPrototypeOf,setPrototypeOf,isExtensible,isFrozen,isSealed,keys,entries,values"

Object.getOwnPropertyNames(Object.prototype).join(',') // "constructor,__defineGetter__,__defineSetter__,hasOwnProperty,__lookupGetter__,__lookupSetter__,isPrototypeOf,propertyIsEnumerable,toString,valueOf,__proto__,toLocaleString"

Object.getOwnPropertyNames(Object.getPrototypeOf({})).join(',') // "constructor,__defineGetter__,__defineSetter__,hasOwnProperty,__lookupGetter__,__lookupSetter__,isPrototypeOf,propertyIsEnumerable,toString,valueOf,__proto__,toLocaleString"

既然可以共享,当然也可以修改和添加。

Object.prototype.toString = function () {
    return 'hello world'
}
var a = {}
a.toString() // hello world

虽然这样很方便,但是,我们并不推荐这么做。因为每个原生对象的属性和方法,都是有规范可寻的,并且这个规范是所有开发人员都认可的。那么,如果「自定义」了这些属性和方法,可能在多人协作的项目中引起不必要冲突。并且如果规范更新,也会带来问题。

原型对象的问题

上面说了使用原型对象的诸多优点,但是原型模式也是有问题的。原型模特的优点是因为它的共享特性,缺点也是。比如,我们在原型对象上定义了一个引用类型的属性。

function Person () {}
Person.prototype.family = ['father','mother']
var p1 = new Person()
var p2 = new Person()
p1.family.push('girlFriend')

p1.family // ["father", "mother", "girlFriend"]
p2.family // ["father", "mother", "girlFriend"]

我们在p1family属性中添加了girlFriend,但是p2.family也添加了,因为他们指向的是同一个数组。而这,是我们不希望看到的。实例之间需要共享的属性和方法,自然,也需要自有的属性和方法。

实例属性(OwnProperty) 该属性在实例上,而不是原型上。可以在构造函数内部或者原型方法内部创建。 建议只在构造函数中创建所有的实例属性,保证变量声明在一个地方完成。

function Person () {
 this.family = ['father','mother']
}
var p1 = new Person()
var p2 = new Person()
p1.family.push('girlFriend')

p1.family // ["father", "mother", "girlFriend"]
p2.family // ["father", "mother"]

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

如果你还有印象,之前的构造函数模式不就是创建的实例属性和方法吗?所以,结合使用这两种方式,是目前ECMAScript中使用最广泛、认同度最高的创建自定义类型的方法。

function Person () {
    this.family = ['father','mother']
}
Person.prototype.age = 24
var p1 = new Person()
var p2 = new Person()
p1.family.push('girlFriend')
p1.family // ["father", "mother", "girlFriend"]
p2.family // ["father", "mother"]
p1.age = 25
p2.age // 25 

以上示例,既有原型对象的共享属性,也有实例自身的属性,各得其所。

动态原型模式

但是,上面示例在混合使用两种模式时,依然是割裂开的,两种模式并没有在一个方法中完成。而动态原型模式,正是来解决这个问题。

function Person () {
    this.age = 24
    if(typeof this.getAge !== 'function'){
        Person.prototype.getAge = function () {
            return this.age
        }
    }
}

可以看到,我们把原型对象模式的定义语句移动到了构建函数中,显式的将两种模式统一在了一起。

寄生构造函数模式

之前我们说过,尽量不要改动原生对象,但是如果想在原生对象上增加方法怎么办?我们可以在原生对象的基础上,增加方法,然后生成一个新的对象。这就是寄生构造函数模式。

function ArrayPlus () {
    var plus = []
    plus.pipeStr = function () {
        return this.join('|')
    }
    return plus
}

var plus1 = new ArrayPlus()
plus1.push('red')
plus1.push('black')
plus1.pipeStr() // red|black

plus1.constructor // Array
Object.getPrototypeOf(Object.getPrototypeOf(plus1)) === Array.prototype // true
plus1 instanceof ArrayPlus // false
plus1 instanceof Aarray // true

但是,生成的实例跟构造函数和原型对象是完全没有联系的,并且也无法通过instanceof确定其类型。所以,在其他模式可用的情况下,不推荐使用这个模式。

稳妥构造函数模式

稳妥对象是指没有公共属性,并且其方法也不引用this的对象。适合一些安全的环境。下面的示例中,除了对象提供的方法,是没有其他途径获得对象内部的原始数据的。 当前与寄生构造函数模式一样,生成的实例跟构造函数和原型对象是完全没有联系的,并且也无法通过instanceof确定其类型。

function Person (age) {
    return {
        getAge: function () {
            return age
        }
    }
}
var p1 = Person(12)
p1.getAge() // 12

Object.getPrototypeOf(p1) === Object.prototype // true
p1 instanceof Person // false

继承

通过原型对象和构造函数相结合的模式,我们可以批量的生成对象,这种模式可以称之为ECMAScript中的「类」;

那如果要批量生成「类」呢?这就要用到「继承」了。ECMAScript中的继承主要是依赖原型链来实现。

原型链

原型链的基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。实际做法就是:

  1. 让一个构造函数A的原型对象A.prototype指向另一个构造函数B的实例b1,此时A.prototype === b1,那么构造函数A的实例a1会拥有构造函数B的原型对象B.prototype的所有属性和方法。
  2. 如果构造函数B的原型对象B.prototype恰好又指向另一个构造函数C的实例c1,即B.prototype === c1。那么构造函数B的实例b1会拥有构造函数C的原型对象C.prototype的所有属性和方法。
  3. 如此层层递进,构造函数A的实例a1会同时拥有构造函数B的原型对象B.prototype和构造函数C的原型对象C.prototype的所有属性和方法。

这就是原型链的基本概念。代码实例如下:

function Grandpa () {}
Grandpa.prototype.sayHello = function () {
    return 'hello'
}
function Father () {}
Father.prototype = new Grandpa()
Father.prototype.sayWorld = function () {
    return 'world'
}
function Son () {}
Son.prototype = new Father()
var son1 = new Son()
son1.sayHello() // hello
son1.sayWorld() // world

如果你还记得之前的原型搜索机制(还是下面这张图),那么原型链其实就是对这种机制的向下拓展。

寻找属性值

// 调用son1实例上的sayWorld方法
son1.sayWorld()
// 先在实例上寻找,没有
Object.getOwnPropertyNames(son1) // []
// 继续在实例的原型上寻找,也没有
Object.getOwnPropertyNames(Son.prototype) // []
// 继续在实例的原型的原型上寻找,找到了
Object.getOwnPropertyNames(Father.prototype) // ["sayWorld"]

// 同样的,调用son1实例上的sayWorld方法
son1.sayHello() 
// 先在实例上寻找,没有
Object.getOwnPropertyNames(son1) // []
// 继续在实例的原型上寻找,也没有
Object.getOwnPropertyNames(Son.prototype) // []
// 继续在实例的原型的原型上寻找,没有
Object.getOwnPropertyNames(Father.prototype) // ["sayWorld"]
// 继续在实例的原型的原型的原型上寻找,找到了
Object.keys(Grandpa.prototype) // ["constructor", "sayHello"]

别忘记默认的类型

那原型链的尽头————实例的原型的原型的原型...的原型是谁呢? 所有函数的默认原型都是Object的实例,所以所有自定义类型都继承了Object.prototype上的属性和方法。

Object.getPrototypeOf(Son.prototype) === Father.prototype // true
Object.getPrototypeOf(Father.prototype) === Grandpa.prototype // true
Object.getPrototypeOf(Grandpa.prototype) === Object.prototype // true

Object的原型指向谁呢?

Object.getPrototypeOf(Object.prototype) // null

Object.getPrototypeOf的返回值是传入对象继承的原型对象,所以,如果传入对象没有继承值,那么就返回null

确定原型与实例的关系

instanceof 操作符。测试实例与原型链中的构造函数。

son1 instanceof Son // true
son1 instanceof Father // true
son1 instanceof Grandpa // true
son1 instanceof Object // true

isPrototypeOf()方法。只要是原型链中出现过的原型,都可以说是该原型链所派生的实例的原型。

Son.prototype.isPrototypeOf(son1) // true
Father.prototype.isPrototypeOf(son1) // true
Grandpa.prototype.isPrototypeOf(son1) // true
Object.prototype.isPrototypeOf(son1) // true

谨慎地定义方法

这一块在讲原型的时候也有提及,主要有两点:在原型链末端定义的重名属性或方法,会屏蔽掉在原型链顶端的定义;使用原型覆盖默认原型对象,要在添加原型的方法之前进行。

function Grandpa() {}
Grandpa.prototype.say = function () {
    return 'grandpa'
}
function Father() {}
Father.prototype = new Grandpa()
Father.prototype.say = function () {
    return 'father'
}
function Son () {}
Son.prototype.age = 12
Son.prototype = new Father()

var son1 = new Son()
son1.say() // father
son1.age // undefined

另外,使用对象字面量的方式为原型添加方法,也会覆盖之前的原型对象。

原型链的问题

第一个问题之前在讲原型的时候也说过,就是如果在原型对象上定义一个引用类型的属性,可能出现问题。

第二个问题是在创建子类型的实例(son1)时,不能向超类型的构造函数(Grandpa)传递参数。

有鉴于此,一般不单独使用原型链。

借用构造函数

使用构造函数,可以解决上面提到的问题一。

function Grandpa () {
    this.family = ['house', 'car']
}
function Father () {
    // 使用call,完成实例属性继承
    // 其实就是以当前函数的作用域,替换目标函数作用域,并执行目标函数
    Grandpa.call(this)
    this.age = 26
}
// 工厂模式写法
// function Father () {
//     var that = new Grandpa()
//     that.age = 26
// 	   return that
// }
var f1 = new Father()
var f2 = new Father()
f1.family.push('money')
f2.family // ['house', 'car']

可以传递参数,解决了问题2

function Grandpa (name) {
    this.name = name
}
function Father (name) {
    Grandpa.call(this, name)
    this.age = 26
}
// 工厂模式写法
// function Father (name) {
//     var that = new Grandpa(name)
//     that.age = 26
//     return that
// }
var f1 = new Father('jiahesheng')
f1.name // jiahesheng
f1.age // 26

但是,借用构造函数也有自己的问题。也就是不能复用共享属性和方法了。

组合继承

其实就是结合了原型链和借用构造函数两种技术。

function Father (name) {
    this.name = name
}
Father.prototype.sayWorld = function () {
    return 'world'
}
function Son (name) {
    Father.call(this, name)
}
Son.prototype = new Father()
var s1 = new Son('zhu')
var s2 = new Son('sang')
s1.name // 'zhu'
s2.name // sang
s1.sayWorld() // 'world'
s2.sayWorld() // 'world'

所谓的共享和自有,可以这么理解: 使用了原型链共享了属性和方法的实例,其实,就是包含了一堆指针,这些指针指向原型对象; 使用了借用构造函数技术拥有了自有的属性和方法的实例,其实,就是拥有了构造函数属性和方法的副本。

原型式继承

DC 最早提出,ECMAScript添加了Object.create方法规范化了这种模式。原型式继承的主要应用场景,就是返回一个对象,对象的原型指向传入的对象。

var person  = {
    age: 24
}
// from DC
function object (o) {
    function F() {}
    F.prototype = o
    return new F()
}
var p1 = object(person)
Object.getPrototypeOf(p1) === person // true
// from ECMAScript
var p2 = Object.create(person)
Object.getPrototypeOf(p2) === person // true

Object.create还支持第二个参数,格式与Object.defineProperties相同

var person = {
    age: 24
}
var p1 = Object.create(person, {
    age: {
        value: 12
    }
})
p1.age  // 12

Object.create最常用的方法还是创建一个纯净的数据字典(没有原型对象的对象实例,即实例的原型指向null): Object.create(null)

var p3 = Object.create(null)
p3.__proto__ // undefined
Object.getPrototypeOf(p3) // null

纯净的数据字典

使用Object.setPrototypeOf也可以实现:

var p4 = {}
Object.setPrototypeOf(p4, null)
p4.__proto__ // undefined
Object.getPrototypeOf(p4) // null

寄生式继承

寄生式继承就是创建一个仅用于封装继承过程的函数,该函数在内部增强对象之后,会返回新的对象。寄生式继承的实际用途在下一节能更好的表示。

寄生组合式继承

我们先来回顾一下,组合式继承:

function Father (name) {
    this.name = name || 'default'
}
Father.prototype.sayWorld = function () {
    return 'world'
}
function Son (name) {
    Father.call(this, name)
}
Son.prototype = new Father()
var s1 = new Son('zhu')
var s2 = new Son('sang')
s1.name // 'zhu'
s2.name // 'sang'
s1.sayWorld() // 'world'
s2.sayWorld() // 'world'

Son.prototype.name // 'default'

它实现了属性和方法的自有和共享。但是,也带来了一些问题。

  1. 构造函数Father被调用执行了两次。一次在new Father(),一次在Father.call(this, name)
  2. 因为调用了两次,所以产生了多余的属性。Son.prototype = new Father()这个语句后,其实Son.prototype也拥有了name属性。只是我们在使用name属性的时候,被实例上的name属性屏蔽了。

怎么解决这个问题呢?我们将原型链继承这一步(Son.prototype = new Father())重写即可!避免调用new Father(),避免继承Father的实例属性和方法。 我们可以组合使用寄生式继承和原型式继承,定义这样一个函数:

function inheritPrototype (prototypeObj, inheritor) {
    var prototype = Object.create(prototypeObj)
    prototype.constructor = inheritor
    inheritor.prototype = prototype
}

inheritPrototype方法做了两件事:恢复了原型对象对构造函数的指针属性,「浅复制」了原型对象。之前我们也说过,其实原型链的共享只是一堆指针的公用,指向的其实还是一个原型对象。所以,「浅复制」刚好用上。

现在我们把这个方法用起来!

function Father (name) {
    this.name = name
}
Father.prototype.sayWorld = function () {
    return 'world'
}
function Son (name) {
    Father.call(this, name)
}
inheritPrototype(Father.prototype, Son)
var s1 = new Son('zhu')

到此我们实现了最完美的继承!

ECMAScript 2015

历尽艰险,我们终于使用ES5实现了「类」和「继承」,但是这相较于其他的面向对象的语言,看起来很不「规范」,并且实现起来也太麻烦。所以,在ECMAScript 2015版本中,使用classextend 关键字,更加「规范」的实现了「类」和「继承」。

我们下篇文章继续探讨新的规范中的「面向对象」。

参考

《JavaScript高级程序设计第三版》