JavaScript夯实基础系列(四):原型

  在JavaScript中有六种数据类型:number、string、boolean、null、undefined以及对象,ES6加入了一种新的数据类型symbol。其中对象称为引用类型,其他数据类型称为基础类型。在面向对象编程的语言中,对象一般是由类实例化出来的,但是在JavaScript中并没有类,对象是以与类完全不同的设计模式产生的。

一、创建对象

  最常用来创建的方式是通过对象字面量的方式,简单便捷。但是该方式为单例模式,如果创建类似的对象会产生过多重复的代码,如下代码所示:

var person1 = {
    name: 'LiLei',
    age: 18,
    sayName: function () {
        console.log(this.name)
    }
}
var person2 = {
    name: 'HanMeiMei',
    age: 18,
    sayName: function () {
        console.log(this.name)
    }
}
person1.sayName() // LiLei
person2.sayName() // HanMeiMei

  使用工厂模式能够避免产生重复代码,但是工厂模式的弊端在于不能很好的将产生的对象分类。如下代码所示:

function person (name, age) {
    var obj = new Object()
    obj.name = name
    obj.age = age
    obj.sayName = function () {
        console.log(this.name)
    }
    return obj
}
        
var person1 = person('LiLei',18)
var person2 = person('HanMeiMei',18)

person1.sayName() // LiLei
person2.sayName() // HanMeiMei

1、构造函数模式

  构造函数模式能够将实例化出来的对象很好的分类,如下代码所示:

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

var person1 = new Person('LiLei',18)
var person2 = new Person('HanMeiMei',18)

person1.sayName() // LiLei
person2.sayName() // HanMeiMei

  首先要说明的是,在JavaScript中并不存在构造函数的语法,有的仅仅是函数的构造调用。构造函数与普通的函数没有什么不同,当函数通过关键字new来调用时才称为构造函数。用来作为构造函数调用的函数名以大写字母开头也是一种书写规范,JavaScript语言层面没有这种约束。通过new关键字来调用构造函数经历了以下四个阶段:

1、创造一个空对象。
2、对象的[[proto]]属性指向构造函数prototype属性指向的对象。
3、以新建的空对象为执行上下文,通过构造函数内部的代码初始化空对象。
4、如果构造函数有返回值且是对象,则返回该对象。否则,返回新建的对象。

  对比工厂模式来说,构造函数模式能够清楚的将对象分类;对比单例模式来说,构造函数不会产生大量重复的代码。但是也不是完全没有产生重复代码,如上代码所示:person1对象和person2对象是可以共享一个sayName方法的。只使用构造函数模式,每个对象都有各自新的方法,彼此之间不能共享,为了克服这种缺陷,原型模式应运而生。

2、原型模式

  每一个函数在创建时会拥有一个prototype属性(Function.bind()方法返回的函数除外),该属性指向同时创建的该函数的原型对象,在这个原型对象中拥有一个不可枚举的属性constructor,该属性指向原型对应的函数。

  通过构造函数生成的实例对象拥有一个指向该构造函数原型对象的的指针,在ES5中被称为[[proto]],ES6中称为__proto__。在通过构造函数创建对象的过程中,实质上就做了两件事:1、将实例对象的[[proto]]属性指向构造函数prototype属性指向的对象;2、将实例对象作为构造函数的执行上下文,执行构造函数完成初始化。一旦实例对象构建完毕,跟原有的构造函数不再有什么关系,即使可以将实例对象以构造函数名称分类也是通过原型对象来判定的。

  在实例对象中查找属性时,先在对象自身上查找,如果没有找到会通过[[proto]]来在原型链上查找。

  构造函数、原型对象、实例对象三者的关系如下图所示:

  原型模式本质上就是共享,所有[[proto]]指针指向原型对象的实例对象,都可以访问该原型对象上的属性。实例对象上如果有跟原型对象同名的属性,就会形成“遮蔽”,访问该属性时就会访问实例对象上的值,而不是原型对象上的值。不能通过实例对象来修改原型上的属性值,但是这个性质就跟ES6中定义常量的关键字const一样,不可以改变的是这个属性的地址,如果原型对象上的属性是引用类型的话,引用类型的地址不可以改变,引用指向的对象却可以通过实例对象来改变。如下代码所示:

function Student () {}
Student.prototype.score = 60
Student.prototype.course = ['语文','数学']

let LiLei = new Student()
let HanMeiMei = new Student()

console.log(LiLei.score) // 60
console.log(HanMeiMei.score) // 60

LiLei.score = 90
console.log(LiLei.score) // 90
console.log(HanMeiMei.score) // 60

LiLei.course.push('英语')
console.log(LiLei.course) // ['语文','数学','英语']
console.log(HanMeiMei.course) // ['语文','数学','英语']

  原型模式基本上不单独使用,因为原型模式产生的对象都一样,不能像构造函数那样通过传递参数来完成各自的初始化。罗素说过:“参差多态才是幸福的源泉”,面向对象编程很大程度上是模拟现实世界,所以这句话在编程世界里同样适用。

3、构造函数与原型模式组合使用

  组合使用构造函数和原型模式是创建自定义对象最常见的方式。通过构造函数传参使对象拥有实例属性,通过原型对象来使同一类型的对象共享属性。一般来说,引用对象属于实例属性,原型对象上没有除了函数以外的引用对象,防止一个对象修改原型对象上的引用对象影响其它对象。如下代码所示:

function Student (name,age) {
    this.name = name
    this.age = age
}
Student.prototype.score = 60

let LiLei = new Student('LiLei',18)
let HanMeiMei = new Student('HanMeiMei',16)

console.log(LiLei.name) // LiLei
console.log(LiLei.age) // 18
console.log(LiLei.score) // 60
console.log(HanMeiMei.name) // HanMeiMei
console.log(HanMeiMei.age) // 16
console.log(HanMeiMei.score) // 60

4、修改原型对象

  在向构造函数的原型中添加属性或者函数时,逐个添加有时候显得比较繁琐,因此很多时候是直接更改构造函数的原型对象。如下代码所示:

function Student (name,age) {
    this.name = name
    this.age = age
}

Student.prototype = {
    score: 60,
    sayName: function () {
        console.log(this.name)
    },
    sayAge: function () {
        console.log(this.age)
    }
}

let LiLei = new Student('LiLei',18)
LiLei.sayName() // LiLei

  在这里有两点需要注意,第一点是这种方式废弃构造函数原有的原型对象,新建一个对象来作为构造函数的原型对象。这就导致了一个问题:如果在添加新原型对象之前就通过构造函数创建对象,那么创建的对象的[[proto]]属性指向的依然是老的原型对象,新原型对象上的一切属性与该对象无关。第二点是函数创建时自动生成的原型对象中有一个不可枚举的属性constructor,该属性是一个指向函数的指针。在新创建的对象中没有这个属性,如果要用到该属性来判定对象的类别,那么可以自行添加。如下代码所示:

function Student (name,age) {
    this.name = name
    this.age = age
}
Student.prototype.score = 80
let HanMeiMei = new Student('HanMeiMei',16)

Student.prototype = {
    score: 60,
    sayName: function () {
        console.log(this.name)
    }
}
Object.defineProperty(Student.prototype,'constructor',{
    value: Student,
    enumerable: false
})

let LiLei = new Student('LiLei',18)
console.log(LiLei.score) // 60
LiLei.sayName() // LiLei

console.log(HanMeiMei.score) // 80
HanMeiMei.sayName() // TypeError

二、继承

  继承是面向对象编程中的一个重要概念,在JavaScript中继承是通过原型链来实现的。

1、原型继承

  原型继承的思路是子对象的原型是父对象的实例,几乎所有的对象都继承自Object,这里说几乎是因为可以通过Object.create()方法来创建不继承自任何对象的对象。

function Person () {
    this.name = 'person'
}
Person.prototype.sayName = function () {
    console.log(this.name)
}

function Student () {
    this.id = '001'
}
Student.prototype = new Person()
Student.prototype.sayId = function () {
    console.log(this.id)
}

let LiLei = new Student()
LiLei.sayId() // 001
LiLei.sayName() // person
console.log(LiLei.toString()) // [object Object]

  如上代码所示,函数Student的原型对象是Person函数的实例,所以对象LiLei可以使用Person原型对象上的属性。而函数Person的原型对象是Object函数的实例,所以对象LiLei可以使用Object.prototype.toString()方法。如下图所示:

  对象查找属性的规则是:先在对象自身上查找,若查找到则停止,否则沿着原型链继续查找。即查找[[proto]]指针指向的对象,查找到则停止,查询不到则查找该对象[[proto]]指针指向的对象,直到Object.prototype为止。

  对象添加属性的情况却比较复杂:对象自身有要添加的属性时,只是修改自身的值;当要添加的属性不在对象自身上,而在对象原型链上能够找到时分三种情况,以向对象obj上添加属性add为例:

1、在原型链上找到的add属性是数据属性且不是只读属性,则add添加到obj上,形成遮蔽

2、在原型链上找到的add属性是数据属性且是只读属性,则add属性添加失败,obj对象自身不会有add属性。

3、在原型链上找到的add属性是一个setter,这个setter会被调用,add属性添加失败。

  上述描述的后两种情况都是属性添加失败,这两种情况下想要强制添加属性可以用Object.defineProperty()方法。

2、组合继承

  在使用原型模式创建对象的时候有两个问题:一是引用类型不适合放在原型对象上,二是没办法通过传参来初始化对象。使用原型继承依然会存在这两方面的问题,上述代码将父构造函数的实例作为子构造函数的原型,那么在很多时候子构造函数的原型中拥有引用类型的属性,而且还不能向父构造函数中传递参数。为消除单独使用原型继承的弊端,一般都是使用借用构造函数原型继承的组合来实现继承的。

  函数通过new来构造调用时执行上下文默认是新创建的对象,但是在JavaScript中可以通过apply()和call()方法来给函数任意指定执行上下文,所谓借用构造函数就是使用这条性质,来使用父构造函数完成对子构造函数实例对象的初始化。如下代码所示:

function Person (person1,person2) {
    this.friends = []
    this.friends.push(person1)
    this.friends.push(person2)
}

function Student (person1,person2) {
    Person.call(this,person1,person2)
}

let LiLei = new Student('tom','mary')
let HanMeiMei = new Student('tom','mary')
LiLei.friends.push('penny')
console.log(LiLei.friends) // ['tom','mary','penny']
console.log(HanMeiMei.friends) // ['tom','mary']

  单独使用借用构造函数可以很方便的定制对象的实例属性,但是在共享方面却很欠缺,因此实现继承一般是将原型继承借用构造函数组合使用。组合继承的思路是:通过原型链实现原型属性和方法的继承,通过借用构造函数实现对实例属性的继承。如下代码所示:

function Person (name) {
    this.name = name
    this.friends = ['tom','mary']
}

Person.prototype.sayName = function () {
    console.log(this.name)
}

function Student (name,age) {
    Person.call(this,name)
    this.age = age
}

Student.prototype = new Person()
Student.prototype.sayAge = function () {
    console.log(this.age)
}

let LiLei = new Student('LiLei',20)
LiLei.friends.push('pony')
console.log(LiLei.name)
console.log(LiLei.friends)

let HanMeiMei = new Student('HanMeiMei',18)
console.log(HanMeiMei.name)
console.log(HanMeiMei.friends)

3、继承的本质

  在优化上述代码之前,先说一下JavaScript中继承的本质。通过new关键字对函数进行构造调用时,将新对象中[[proto]]指针指向构造函数的prototype指向的对象,然后将实例对象作为执行上下文来执行一遍构造函数。所谓继承,本质上就是对象的[[proto]]指针指向另一个对象,当查找对象上的属性时,先查询自身,没找到的话再沿着[[proto]]指针查询。其实这种方式称为委托更为合适,对象一部分属性在对象自身上,另外一部分属性或者方法委托给了另外一个对象。如果只考虑这种委托关系,不考虑对象自身上的属性的话可以不经过new关键字,直接为新创建的对象指定原型对象。

  在ES5中添加了Object.create()方法体现了继承实际上是对象委托的本质。该方法创建一个新对象链接到指定的对象,可以接收两个参数,第一个参数为指定的原型对象,第二个参数是要添加进新对象自身上的属性。其中该方法接收的第二个参数与Object.definePropertier()方法的第二个参数相同,每个属性都是通过自己的描述符定义的。如下代码所示:

let Person = {
    name: 'any',
    age: 18
}

let LiLei = Object.create(Person,{
    name: {
        value: 'LiLei'
    }
})

console.log(LiLei.name) // LiLei
console.log(LiLei.age) // 18

  Object.create()方法是对原型式继承的规范实现。所谓原型式继承是指不必创建自定义类型,基于已有对象创建新对象。在不兼容Object.create()的情况下可以使用如下代码部分填补:

if (!Object.create) {
    Object.create = function(o) {
        function F(){}
        F.prototype = o;
        return new F();
    };
}

  之所以说是部分填补是因为上述方式并没用办法使用像Object.create()第二个参数那样为新对象添加属性。

4、优化组合继承

  说完继承的本质之后来优化一下组合继承的代码。组合继承的方式通过借用父构造函数来实例化新对象,然后将子构造函数的原型链接到父构造函数的实例对象上,这会导致调用两次父构造函数来重复创建相同的属性。如下代码所示:

function Person (name) {
    this.name = name
    this.friends = ['tom','mary']
}

function Student (name,age) {
    Person.call(this,name)
    this.age = age
}

Student.prototype = new Person()

let LiLei = new Student('LiLei',20)
console.log(LiLei) // {age: 20, friends:[ "tom", "mary" ], name: "LiLei"}
console.log(Student.prototype) // {friends:[ "tom", "mary" ], name: undefined}

  可以看出实例对象上和原型对象上的属性有重复,这是将父构造函数实例对象作为原型对象的后遗症。因为查询对象属性的时候,对象自身拥有属性会对原型对象上的属性形成“遮蔽”,从这个角度来说组合继承完全可以达到继承的目的。但是冗余数据终归是不好的,可以通过原型式继承来加以优化:通过借用父构造函数来实例化对象,通过直接将子构造函数的prototype指向父构造函数的原型对象来实现继承。如下代码所示:

function Person (name) {
    this.name = name
    this.friends = ['tom','mary']
}

Person.prototype.score = 60

function Student (name,age) {
    Person.call(this,name)
    this.age = age
}

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

let LiLei = new Student('LiLei',20)
console.log(LiLei) // {age: 20, friends:[ "tom", "mary" ], name: "LiLei"}
console.log(Student.prototype) // {}

  这种方式被称为寄生组合式继承,是实现引用类型继承的最佳方式。

三、对象的类型

  在JavaScript中检测数据类型可以使用typeof操作符,如下代码所示:

var a;
typeof a;                // "undefined"

a = "hello world";
typeof a;                // "string"

a = 42;
typeof a;                // "number"

a = true;
typeof a;                // "boolean"

a = null;
typeof a;                // "object" -- 当做空对象处理

a = undefined;
typeof a;                // "undefined"

a = { b: "c" };
typeof a;                // "object"

  在使用typeof关键字检测对象类型时,并不能返回对象的具体类型。对于对象类型的检测,通常遵循“鸭式辩型”的原则:

像鸭子一样走路、游泳以及鸣叫的鸟就是鸭子

  在JavaScript中,不关心对象的类型是什么,而是关心对象能够做什么。当然有些时候也需要知道对象的类型,可以通过如下几种各有缺陷的方式来判定对象类型。

1、constructor

  除了Function.bind()返回的函数之外,所有的函数执行时都有一个prototype属性,prototype指向该函数的原型对象,在自动生成的原型对象中有一个不可枚举的属性constructor,constructor指向该函数。在该函数构造调用时,创建的实例对象中[[proto]]与构造函数prototype指向相同的原型对象。可以通过这个关系来确定实例对象的类型,如下代码所示:

function Person () {
    this.age = 18
}
let LiLei = new Person()
console.log(Person.prototype.constructor === Person) // true
console.log(LiLei.constructor === Person) // true

  使用constructor属性来判定对象的类型在多个执行上下文的场景下无法工作,比如在浏览器打开多个窗口来显示子页面, 子页面中的对象是同一类型也没办法使用constructor属性来判定。

  使用constructor属性来判定对象类型是建立在不变更构造函数默认原型对象的基础上,但是在很多时候都会改变默认原型的。比如感觉一个一个往函数原型上添加属性和方法比较繁琐,一般会通过字面量的方式创建一个新的对象来作为原型对象;还有在实现继承时将父构造函数的实例对象或者原型对象作为子构造函数的原型时;在这些情况下,需要往新的原型对象上添加一个不可枚举的属性constructor来指向构造函数。

  一般没有必要特意去维持这种比较脆弱的关系,在很多时候不经意间就改变了constructor属性的指向或者改变了原型对象,因此不提倡使用constructor来判定对象的类型。

2、prototype

  原型对象是对象类型的唯一标志。当且仅当两个对象继承自同一原型对象时,这两个对象才属于相同类型。

  isPrototypeOf()方法可以判断对象之间是否有原型关系,用于测试一个对象是否存在于另一个对象的原型链上。如下代码所示:

function Person (name) {
    this.name = name
}

let LiLei = new Person('LiLei')
      
console.log(Person.prototype.isPrototypeOf(LiLei)) // true
console.log(Object.prototype.isPrototypeOf(LiLei)) // true

  ES5中新增的了Object.getPrototypeOf()方法,该方法返回指定对象的原型,即内部[[Prototype]]属性的值。如下代码所示:

function Person (name) {
    this.name = name
}

Person.prototype.score = 60

let LiLei = new Person('LiLei')

console.log(Object.getPrototypeOf(LiLei)) // { score:60 }
console.log(Object.getPrototypeOf(LiLei) === Person.prototype) // true

3、instanceof

  检测对象和原型对象之间的关系简单直接,有时希望确定实例对象与构造函数之间的关系,可以通过instanceof运算符来检测。instanceof运算符用来检测函数的prototype属性指向的对象是否存在于参数实例对象的原型链上。如下代码所示:

function Person (name) {
    this.name = name
}

Person.prototype.score = 60
      
function Animal () {
    this.type = 'dog'
}

function Student (name,age) {
    Person.call(this,name)
    this.age = age
}

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

let LiLei = new Student('LiLei',20)

console.log(LiLei instanceof Student) // true
console.log(LiLei instanceof Person) // true
console.log(LiLei instanceof Object) // true
console.log(LiLei instanceof Animal) // false

  isPrototypeOf()、instanceof运算符来判断对象的类型时,只能检测对象属不属于某一类型,而不能通过对象来获取类型名称。Object.getPrototypeOf()方法只能获取对象[[proto]]指针指向的对象。另外这些方法检测对象类型和constructor运算符一样,在多个执行上下文的场景下无法工作。

四、总结

  作为面向对象编程语言,JavaScript中没有类,只有对象。在通过构造函数创建对象时,没有发生其他面向对象编程语言中从类中“拷贝”的事情,只是创建一个新对象,作为构造函数的执行上下文来执行构造函数而已,对象最终通过原型链链接在一起。

  创建对象最常见的方式是采用对象字面量的方式,而创建特定类型的自定义对象最常用的组合使用构造函数和原型模式的方式。使用构造函数添加实例对象的自身属性,使用原型对象来添加同一类型对象共享的属性和方法,一般对象的引用类型属性的添加都是放在构造函数中的。

  JavaScript中的继承更确切的名称是委托,通过对象之间的委托来实现方法和属性的共享以及复用,可以通过Object.create()方法来直接完成这种委托关系。一个对象想要继承自身拥有引用类型属性的对象的全部属性和方法,实现的最佳方式是寄生组合式继承

  原型对象是对象类型的唯一标志,想要确定对象的类型,可以通过验证对象的原型来实现。JavaScript更多关注的是对象能做什么,而不是对象是什么类型。

如需转载,烦请注明出处:https://www.cnblogs.com/lidengfeng/p/9300910.html

posted @ 2018-07-12 18:05  雾雪天涯  阅读(224)  评论(0编辑  收藏  举报