【JavaScript】对象、原型、原型链迷惑行为大赏

​在如今的业务开发中,应该不多人在写原生JavaScript了,你们都一股脑地扑在各个框架上。原本,这些框架对于业务和开发者来讲是一种福音,减小了各类各样的开发痛点,可是带来的负面问题就是对于开发者来讲,愈来愈依赖框架,离原生JavaScript愈来愈远,对基础知识的记忆和理解慢慢地模糊、淡忘。javascript

而原型、原型链就是其中之一。前端

更使人感到煎熬的是,平时开发已经几乎用不到了,可是面试中几乎每一个面试官都会问到。结果即是在面试以前临时抱佛脚地看一看书或者是网上找几篇文章复习一下,可是因为平时用得太少了,这样的复习也只是走马观花,面试官稍微多问几句就背不住了。java

基于上述缘由,加上对象、原型、原型链自己在JavaScript中就有一些让人比较难理解的点,今天咱们来看看这些内容的迷惑行为。面试

对象

要说原型和原型链,就得先说说对象。ECMA-262把对象定义成“无序属性的集合,其属性能够包含基本值、对象或者函数”,咱们能够很容易把对象理解成散列表,其实就是一组键值对,键是字符串或Symbol,值是数据或者函数。浏览器

而对象有以下特色:babel

  • 惟一标识性:内容相同也不是同一个对象
  • 有状态:同一个对象不一样时刻可能有不一样的内容
  • 有行为:对象的状态,可能由于行为而变迁

惟一标识性很好理解,你们都知道在JavaScript中,辨认两个引用类型是否相同是看它们的内存地址是否相同,所以任意声明两个内容相同的变量,它们的内存地址确定是不一样的。框架

而状态和行为,在JavaScript中,都被称为属性。由于对象具备高度的动态性,咱们在任什么时候候都能改变属性的值,同时,咱们能够在对象的函数属性中修改对象的值属性或者是在声明对象以后再增长属性:函数

var obj = {
  a: 1,
  b () {
    this.a = 2
  }
}
obj.b() // obj.a的值被改为2
​
obj.c = 3
console.log(obj.c) // 3

以前说到,咱们能够把JavaScript对象的内容理解成简单的键值对,可是当JavaScript引擎处理对象的时候却不是这么的简单,相信你们都知道对象有两种属性:数据属性和访问器属性。this

数据属性

数据属性有四种特性:spa

  • [[configurable]]:可否配置当前属性,包括可否用delete删除、可否修改属性的特性、可否改为访问器属性等
  • [[enumerable]]:可否经过for-in遍历当前属性
  • [[writable]]:可否修改当前属性的值
  • [[value]]:保存当前属性的值

在初始化一个对象时,前三个特性的值,默认为true。

访问器属性

访问器属性也有四种特性:

  • [[configurable]]:同数据属性的configurable,默认为true
  • [[enumerable]]:同数据属性的enumerable,默认为true
  • [[getter]]:函数或undefined,取对象的属性的值时调用
  • [[setter]]:函数或undefined,设置对象的属性的值时调用

咱们能够经过Object.getOwnPropertyDescriptor(o)或Object.getOwnPropertyDescriptors(o, key)获取对象的属性描述。当咱们用字面量声明一个对象时,默认地使用的是数据属性。同时,也能够经过访问器属性声明变量:

var o = {
  get a () {
    return 1
  }
}
​
o.a // 1

能够看到,不管用哪一种方式声明变量,其结果都是同样的。所以在JavaScript运行时,对象能够看作是属性的集合。

而在其它语言中,是用“类”的方式来描述对象的。但是JavaScript中却并无“类”的概念,即使ES6提供了class关键字来声明类,但其实也只是语法糖罢了。

在前人的探索下,JavaScript基于本身的动态对象系统模型,设计了本身的原型系统用来模拟类的行为。

建立对象

在平时的开发中,最多见的建立对象的方式就是使用对象字面量,可是这就带来了一个问题,若是要建立重复的、相同内容的对象,就须要在多个地方写重复的、内容相同的代码,这会使得代码难以维护。
在JavaScript中,有如下几种常见的建立对象的方式

工厂函数模式

所谓工厂函数,"就是指这些内建函数都是类对象,当你调用他们时,其实是建立了一个类实例",可是JavaScript中没有真正的类,可是能够用对象代替:

function createPerson (name, age, job) {
  var o = new Object()
  
  o.name = name
  o.age = age
  o.job = job
  o.sayName = function () {
    console.log(this.name)
  }
}
​
var person1 = createPerson('Alex', 20, 'Programmer')
var person2 = createPerson('Bob', 25, 'Product Manager')

咱们能够屡次调用这个函数,每次返回的都是不同的对象,可是,这个方法却没法解决对象识别的问题,即怎样知道一个对象的类型。

构造函数模式

这个模式也熟为人知:

function Person (name, age, job) {
  this.name = name
  this.age = age
  this.job = job
  this.sayName = function () {
    console.log(this.name)
  }
}
​
var person1 = new Person('Alex', 20, 'Programmer')
var person2 = new Person('Bob', 25, 'Product Manager')

这里就涉及到了new操做符的执行过程:

  • 建立一个新对象
  • 将当前函数的做用域赋给这个新对象
  • 执行构造函数中的代码
  • 若是没有显式返回一个对象,那么就返回这个新对象

之前我很难记住这个过程,可是若是先理解了工厂函数的出现缘由、执行原理以及缺点,这时再来理解new操做符就会很简单了。

由于咱们最终须要一个对象,因此第一步就得先建立一个对象;而后由于在后面的代码里咱们会使用到this.xx这样的代码去把属性赋值给this对象,实际上是想把属性赋值给最终返回的对象,所以就须要绑定做用域,即,将当前函数的做用域赋值给新对象;前两步是准备工做,作完了以后就能够执行构造函数中的代码了;最后,开发者写的代码的优先级要高于JS引擎,也就是说若是代码中return了对象的话,那么就不须要再返回建立的对象了,不然就把建立的对象返回。

用构造函数建立的对象,可使用instanceof操做符判断其类型:

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

这也是构造函数模式比工厂模式更好的地方。然鹅,构造函数也有本身的问题。你们都能看出来,每次都会对实例对象新增一个sayName方法,但其实这并没必要要。虽然能够把方法名写在构造函数以外:

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

但这样会把sayName变成全局函数,很容易被别处代码误用,并且若是有多个类型都有同名函数,那就会有冲突。此时,原型闪亮登场了。

原型模式

原型你们都听过,也了解过,但到底什么是原型呢?

咱们所熟知的各类语言,都会用各类不一样的语言特性来抽象对象。使用最普遍和接受度最高的应该是基于类的抽象了,C++、Java等流行语言都是基于类的。在这种抽象方式中,开发者更关注类与类之间的关系。在这类语言中,老是先有类,再从类去实例化一个对象。而类与类之间又会有继承、组合等关系。
而还有一种就是基于原型,JavaScript就是其中之一。这类语言更提倡关注对象实例的行为,而后才关注如何将这些对象关联到使用方式类似的原型对象,而不是将它们分红类。

原型对象

图片截取自《JavaScript高级程序设计》

上面这张图你们确定很熟悉,单纯地背下来是没有意义的。将这张图和上述的代码相结合,就会发现理解原型也很简单。

首先咱们有一个构造函数Person,在建立这个函数的时候,会为这个函数建立一个prototype属性,这个属性指向函数的原型对象。而默认状况下,原型对象会有一个constructor属性,其值会指向构造函数Person。在构造函数内部,能够为原型对象添加其它属性和方法。

当调用构造函数实例化一个对象时,这个对象的内部会有一个指针指向构造函数的原型,而这个指针就是[[prototype]],在不少浏览器中,支持一个属性叫__proto__来访问[[prototype]]。
此时,两个实例并无属性和方法,可是却能够调用到sayName方法,这是经过查找对象属性的过程实现的,即如今实例上找,若是没有,就在实例的原型上找,若是尚未,再往上的原型找,直到找到对应的方法/属性,若是找不到就返回undefined。这也是多个对象实例共享原型所保存的属性和方法的基本原理。

function Person () {}
​
Person.prototype.name = 'Alex'
Person.prototype.age = 20
Person.prototype.job = 'Programmer'
​
var p1 = new Person()
var p2 = new Person()
​
p1.name = 'Bob'
console.log(p1.name) // Bob
console.log(p2.name) // Alex

这里修改了p1.name,可是p2.name仍然是初始化时的值。是由于p1.name是在实例对象上新增了一个属性,而p2.name仍然访问的是原型上的属性。即,在实例上的属性会屏蔽掉原型上的同名属性。

原型与in操做符

有两种方式使用in操做符,直接使用和for-in中使用:

function Person () {}
​
Person.prototype.name = 'Alex'
​
var p = new Person()
​
p.age = 20
​
console.log('name' in p) // true
console.log('age' in p) // true

经过上述代码能够看出来,不管属性是在实例上仍是原型上,in操做符均可以访问到。

Object有一个原型方法是hasOwnProperty()用来判断属性是否存在于实例中。所以经过hasOwnProperty()和in操做符来判断属性是否为原型属性。

而在for-in循环中,返回的是全部可以经过对象访问的、可枚举的属性,这既包括实例属性也包括原型属性:

function Person () {}
​
Person.prototype.name = 'Alex'
​
var p = new Person()
​
Object.defineProperty(p, 'age', {
  configurable: true,
  enumerable: false,
  writable: true,
  value: 20
})
​
p.job = 'Programmer'
​
for (let key in p) {
  console.log(key) // job name
}

能够看到,若是属性是不可枚举了,在for-in中确实不会输出了。

而若是只想取得实例上全部的可枚举属性,可使用Object.keys():

console.log(Object.keys(p)) // ["job"]

若是想获取全部实例属性,不管是否能够枚举,可使用Object.getOwnPropertyNames():

​console.log(Object.getOwnPropertyNames(p)) // ["age", "job"]
console.log(Object.getOwnPropertyNames(Person)) // ["length", "name", "arguments", "caller", "prototype"]

更简单的原型语法

按照上面的方法,若是须要在原型上添加多个属性或者方法,那么就要写多个Person.prototype.xx = yy,这对于追求简洁代码的程序猿来讲简直就是灾难,所以咱们能够用对象字面量来代替:

function Person () {}
​
Person.prototype = {
  name: 'Alex',
  age: 20,
  job: 'Programmer',
  sayName: function () { console.log(this.name) }
}

但这又引入了一个问题,那就是Person的原型对象被一个普通对象代替了,结果就是constructor属性再也不指向Person了。所以咱们须要显式地把constructor再设置正确,而且要让constructor不可枚举:

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

原型的动态性

咱们知道,能够先实例化对象,再向原型上添加属性或方法,这是由于咱们在调用实例的属性或方法时,JS引擎先在实例上寻找,若是没有再在原型上寻找。这很容易理解,可是若是是重写整个原型对象,状况又不同了:

function Person () {}
​
var p = new Person()
​
Person.prototype = {
  constructor: Person,
  name: 'Alex',
  sayName: function () {
    console.log(this.name)
  }
}
​
console.log(p.sayName()) // Uncaught TypeError: p.sayName is not a function

这是由于,新的原型对象并非以前的原型对象了,重写原型对象切断了现有原型与任何以前已经存在的对象实例之间的联系,这些实例使用的仍然是最初的原型。

原型对象的问题

原型对象的问题不易被发现,但倒是很容易踩中这个坑,那就是当有属性是引用类型时,一个实例对原型属性的修改会影响到全部实例:

function Person () {}
​
Person.prototype = {
  constructor: Person,
  name: 'Alex',
  age: 20,
  friends: ['Bob', 'Cindy']
}
​
var p1 = new Person()
var p2 = new Person()
​
p1.friends.push('David')
console.log(p1.friends) // ["Bob", "Cindy", "David"]
console.log(p2.friends) // ["Bob", "Cindy", "David"]

构造函数+原型模式

这是最多见的自定义类型方式,构造函数用于定义实例属性,而原型模式用于定义方法和共享的属性:

function Person (name, age, job) {
  this.name = name
  this.age = age
  this.job = job
  this.friends = ['Bob', 'Cindy']
}
​
Person.prototype = {
  constructor: Person,
  sayName: function () {
    console.log(this.name)
  }
}
​
var p1 = new Person()
var p2 = new Person()
​
p1.friends.push('David')
console.log(p1.friends) // ["Bob", "Cindy", "David"]
console.log(p2.friends) // ["Bob", "Cindy"]

另外,经常使用的模式还有动态原型模式、寄生构造函数模式和稳妥构造函数模式,具体内容能够参考《JavaScript高级程序设计》。

继承

继承是OOP中你们最喜欢谈论的内容之一,通常来讲,继承都两种方式:接口继承和实现继承。而JavaScript中没有接口继承须要的方法签名,所以只能依靠实现继承。

原型链

原型链实现起来十分简单,即,让原型对象等于另外一个类型的实例。此时,原型对象会包含指向另外一个原型对象的指针,若是以此持续延伸开,那么咱们看到的就是一条原型对象的链条:

function SuperType () {
  this.property = true
}
​
SuperType.prototype.getSuperValue = function () {
  return this.property
}
​
function SubType () {
  this.subProperty = false
}
​
SubType.prototype = new SuperType()
SubType.prototype.getSubValue = function () {
  return this.subProperty
}
​
var instance = new SubType()
console.log(instance.getSuperValue()) // true

用图示会更清楚一些:

图截取自《JavaScript高级程序设计》

从图中能够看出来,SubType的原型对象指向了SuperType的原型对象。同时,由于全部引用类型都继承自Object,这让原型链扩展了原型搜索机制,当咱们调用某个实例的属性或方法时,JS引擎会根据原型对象从当前实例一直往“父级”上找,直到找到Object。

用instanceof能够很容易地判断一个实例的原型链上是否出现过指定的类型。同时也可使用isPrototypeOf()方法来判断。

直接替换子类的原型会有两个问题:

  • 父类的引用类型属性会被多个子类同时修改
  • 不能向父类传递初始化参数

为了解决第一个问题,能够在子类的构造函数中手动调用父类的构造函数:

function SuperType (name) {
  this.name = name
}
​
function SubType () {
  SuperType.call(this, 'Alex')
  this.age = 20
}
​
var instance = new SubType()
​
console.log(instance.name) // Alex
console.log(instance.age) // 20

然鹅,构造函数依然会有以前提到过的问题,方法都在构造函数定义,那么久不存在方法复用了。

因此,像建立对象同样,继承也有相似组合模式的组合继承:

function SuperType (name, friends) {
    this.name = name
    this.friends = friends
}
​
SuperType.prototype.sayName = function () {
    console.log(this.name)
}
​
function SubType (name, age, friends) {
  SuperType.call(this, name, friends)
​
    this.age = age
}
​
SubType.prototype = new SuperType()
SubType.prototype.sayAge = function () {
    console.log(this.age)
}
​
var instance1 = new SubType('Alex', 20, ['Bob', 'Cindy'])
var instance2 = new SubType('Bob', 25, ['David'])
​
console.log(instance1)
console.log(instance2)

此外还有原型式继承和寄生式继承,能够参考《JavaScript高级程序设计》。

上面提到的组合继承会有一个问题,就是调用了两次父类的构造函数,从而在原型链上多出了一组值为undefined的name和friends属性:

能够经过寄生组合继承的方式来解决这个问题,其基本思路是:没必要为了指定子类的类型而调用父类的构造函数,咱们须要的只是父类的原型对象的一个副本罢了:

function inheritPrototype (SuperType, SubType) {
  var prototype = Object.create(SuperType.prototype)
  
  prototype.constructor = SubType
  SubType.prototype = prototype  
}

第一步是建立一个父类原型对象的副本,第二步是补上constructor的指向,最后一步是将族类的原型对象知道建立的对象。将以前的代码稍微修改一下:

function SuperType (name, friends) {
  this.name = name
  this.friends = friends
}
​
SuperType.prototype.sayName = function () {
  console.log(this.name)
 }
​
function SubType (name, age, friends) {
  SuperType.call(this, name, friends)
​
  this.age = age
}
​
inheritPrototype(SuperType, SubType)
SubType.prototype.sayAge = function () {
  console.log(this.age)
 }
​
var instance1 = new SubType('Alex', 20, ['Bob', 'Cindy'])
var instance2 = new SubType('Bob', 25, ['David'])
​
console.log(instance1)
console.log(instance2)

图截取自《JavaScript高级程序设计》

ES6中的类

一直以来,JavaScript中的继承对于作业务的前端开发工程师来讲都比较少接触,一旦要写,也是心惊胆战;并且new和function的搭配也比较怪异,让function既当普通函数也要当构造函数。可是ES6提供了class关键字,让咱们以更直观地方式书写类,也让function回归本身自己的含义。一个简单的示例来看一下:

class Animal {
  constructor (name) {
    this.name = name
  }
  
  speak () {
    console.log(this.name + ' makes a noise.')
  }
 }
 
 class Dog extends Animal {
   constructor (name) {
     super(name) // call the super class constructor and pass in the name parameter
   }
   
   speak () {
     console.log(this.name + ' barks.')
   }
 }
 
 let d = new Dog('Mitzie')
 d.speak() // Mitzie barks.

比起早期的原型模拟方式,使用 extends 关键字自动设置了 constructor,而且会自动调用父类的构造函数,这是一种更少坑的设计。

因此从ES6开始,咱们不须要再去模拟类了,直接使用class关键字吧(虽然babel编译仍是使用的寄生组合继承模式)。

参考资料:

  • 重学前端 - winter
  • JavaScript高级程序设计

相关文章
相关标签/搜索