JavaScript 面向对象专题-原型和原型链

469 阅读14分钟

JavaScript 面向对象的实质是基于原型的对象系统,而不是基于类。这是由最初的设计所决定的,是基因层面的特点。随着 ES Next标准的进化和新特性的添加,JavaScript 面向对象更加贴近其他传统面向对象语言。有幸目睹语言的发展和变迁,伴随着某种语言成长,是开发者之幸。

原型

原型

JavaScript 中每个函数都有一个名为 prototype 的不可枚举属性——原型,它会指向一个对象——原型对象。原型对象上有一个名为 constructor 的不可枚举属性——构造器,它会指向函数本身,所以说函数本身就是一个构造器。

当你在函数前面加上 new 关键字之后它就变成了构造函数,每次调用构造函数都会创建一个新的对象——实例构造器是用来生成实例的模板,所有对象都是由构造器生成出来的实例

实例的内部属性 [[ Prototype ]] 就会被赋值为构造函数的原型对象。JS 中并没有访问 [[ Prototype ]] 特性的标准方式,但Firefox、Safari 和 Chrome 等主流浏览器会在每个对象上暴露一个__proto__ 属性,通过__proto__属性可以访问构造函数的原型,所以这个内部属性也叫隐式原型,相应的 prototype 属性就叫显示原型

构造函数、原型对象、实例三者之间的关系:

function Foo() {}
var obj = new Foo()

a.__proto__ === Foo.prototype
Foo.prototype.constructor === Foo

原型链

实例对象继承了其构造函数的原型对象,原型对象也是对象,它也继承它的构造函数的原型对象,以此类推,实例对象和原型对象之间就形成了一条链路——原型链

《JavaScript语言精髓》一书中对原型链的定义:对象所有的父类和祖先类的原型所形成的可上溯访问的链表

所有普通对象最终都指向内置的 Object.prototype—— 对象原型,它会指向一个空指针 null,也就是原型链的顶端,如果在原型链中找不到指定的属性就会停止。

function Foo() {}
var obj = new Foo()

a.__proto__ === Foo.prototype
a.__proto__.__proto__ === Object.prototype
a.__proto__.__proto__.__proto__ === null

操作原型

内置属性/方法

所有的实例之所以具有对象的某些属性,是因为它们都继承自 Object.prototype,Object.prototype下的所有原型相关的属性/方法都是每一个对象必然具备的。

对于一个具体的构造器,因为本身是一个对象,它除了具有 Object.prototype 具有的成员之外,还有一些属于函数类型的特别成员,比如 prototype。

当 Object 作为基类(祖先类)时,它还持有一些可以用来操作对象的类方法。

下表中总结了三者与原型相关的属性/方法:

对象原型(Object.prototype)属性/方法构造器(函数)属性/方法Object 类方法 Object.xxx
constructorprototypecreate()
hasOwnProperty()getPrototypeOf()
isPrototypeOf()setPrototypeOf()

有了这些内置属性/方法,我们就可以进一步操作原型了。

查找原型

原型与实例之间的关系可以由以下几种方式来确定,以下面的代码为例:

function Foo () {}
function Bar () {}

Bar.prototype = new Foo()

var a = new Foo()
var b = new Bar()

instanceof

instanceof 操作符除了判断类型之外,还可以用来检测某个实例对象的原型链上是否存在构造函数的 prototype 属性。但是这种方式只能处理一个实例和函数之间的关系,不能判断多个实例之间是否通过原型链关联。

a instanceof Foo // true
a instanceof Bar // false

b instanceof Foo // true
b instanceof Bar // true

a instanceof b // error

上面的代码中,b 是 Foo 和 Bar 的实例,因为 obj2 的原型链中包含这些构造函数的原型。

instanceof 实现原理就是遍历原型链,查找原型链上所有显示原型(prototype)是否等于隐式原型(__proto__),直至找到原型链的最顶层。下面我们来模拟一下:

function myInstanceof(instanceObj, constructorFun) {
  const prototypeObj = constructorFun.prototype // 获取构造函数的原型对象(显示原型)
  instanceObj = instanceObj.__proto__ // 获取实例对象的原型(隐式原型)
  while (instanceObj) {
    if (prototypeObj === instanceObj) {
      return true
    }
    instanceObj = instanceObj.__proto__ // 重点:遍历原型链
  }
  return false
}

// 测试
function Person(name) {
  this.name = name
}
const p = new Person('sunshine')
myInstanceof(p, Person) // true

isPrototypeOf(...)

isPrototypeOf() 会在传入对象的隐式原型指向它调用的对象时返回true,原型链中的每个原型属性都可以调用这个方法,isPrototypeOf() 可以判断函数的原型对象是否在实例的原型链上。

a.__proto__ === Foo.prototype // true
Foo.prototype.isPrototypeOf(a) // true
Foo.prototype.isPrototypeOf(b) // false

a.isPrototypeOf(b) // false

这里通过 Parent 和 Child 的原型对象调用检查了 father 和 son 两个实例

存取原型

Object 类型有很多内置方法,其中有两个方法可以用来存取原型。

Object.getPrototypeOf()

Object.getPrototypeOf() 返回传入对象的隐式原型,也就是它调用的构造函数的原型对象。

function Foo () {}
Foo.prototype.name = 'sunshine'
var a = new Foo()

Object.getPrototypeOf(a) === a.__proto__ // true
a.__proto__ === Foo.prototype // true

Object.getPrototypeOf(a) === Foo.prototype // true
Object.getPrototypeOf(a).name // 'sunshine'

这里 Object.getPrototypeOf() 获取了实例 a 的隐式原型,它的构造函数时 Foo,所以 Foo.prototype.name等价于 a.name

Object.setPrototypeOf()

Object.setPrototypeOf() 向实例的原型对象中写入一个新对象,这样就可以重写原型继承关系。

let obj1 = { a: 1 }
let obj2 = { b: 2 }

Object.setPrototypeOf(obj1, obj2)

obj1.a // 1
obj1.b // 2
Object.getPrototypeOf(a) === b // true

__proto__

__proto__是可设置属性,上面我们使用 ES6 的 Object.setPrototypeOf(...)进行了设置。此外,__proto__看起来像一个属性,但是实际上它更像一个 getter/setter,__proto__ 实现原理大致是这样的:

Object.defineProperty(Object.prototype, '__proto__', {
    get: function () {
        return Object.getPrototypeOf(this)
    },
    set: function (o) {
        Object.setPrototypeOf(this, o)
        return o
    }
})

因此访问 a.__proto__ 时,实际上是调用了 a.__proto__() 的getter函数。虽然 getter 函数存在于 Object.prototype 上,但是它的 this 指向 a,所以Object.getPrototypeOf(a) === a.__proto__ 返回的结果为 true。

重写原型

__proto__ 一样,函数的 prototype 属性也是可以被重写的

function Foo () {
  // ...
}

Foo.prototype = {} // 创建了一个新原型对象

var a = new Foo()

a instanceof Foo // true
a instanceof Object // true
a.constructor === Foo // false
a.constructor === Object // true

这里,instanceof 仍然对 Object 和 Foo 都返回 true。但 constructor 属性现在等于 Object 而不是 Foo了。如果 constructor 的值很重要,则可以像下面这样在重写原型对象的时候专门设置一下它的值:

function Foo () {
  // ...
}

Foo.prototype = {
  constructor: Foo
} // 创建了一个新原型对象

但是要注意,以这种方式恢复 constructor 会创建一个可枚举属性,而原生的 constructor 默认是不可枚举的,因此,应该用Object.defineProperty() 的方式来定义。除此之外,constructor 也是一个可写的属性,你可以给任意原型链中的任意对象添加一个名为 constructor 的属性,或者对其进行修改和赋值。

function Foo () {
  // ...
}

Foo.prototype = {} // 创建了一个新原型对象
Object.defineProperty(Foo.prototype, 'constructor', {
    enumerable: false,
    writable: true,
    configurable: true,
    value: Foo
})

var a = new Foo()
a.constructor === Foo // true

修复constructor的过程需要很多手动操作,a.constructor 是不被信任的,它不一定会指向默认函数的引用。所以,对象的 constructor 属性是一个非常不安全的引用,要尽量避免使用它。

创建对象

为了避免使用原型修改造成的性能下降,可以通过 Object.create(...) 来创建一个新对象。使用 Object.create() 方式创建对象时,可以显式指定新对象的原型。该方法接受两个参数:第一个参数为新对象的原型,第二个参数描述了新对象的属性。

如下所示:

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

Foo.prototype.myName = function () {
  return this.name
}

function Bar (name, label) {
  Foo.call(this, name)
  this.label = label
}

// 创建一个新的 Bar.prototype 对象并关联到 Foo.prototype
Bar.prototype = Object.create(Foo.prototype)
// 此时 Bar.prototype.constructor 已经没有了,如果需要,手动创建

Bar.prototype.myLabel = function () {
  return this.label
}

var a = new Bar('a', 'obj a')
a.myName() // 'a'
a.myLabel() // 'obj a'

这段代码的核心部门就是语句Bar.prototype = Object.create(Foo.prototype),声明function Bar 时,和其他函数一样,Bar.prototype 会指向默认的对象,但是这个原型对象并不是我们想要的,所以我们创建了一个新对象,让Bar.prototype 指向他,直接把原来的原型对象抛弃。

纯净对象

Object.create() 也可以创建一个原型为 null 的对象:var obj = Object.create(null)。对象 obj 是一个没有原型链的对象,这意味着 toString()valueOf 等存在于 Object 原型上的方法同样不存在于该对象上,通常我们将这样创建出来的对象为纯净对象

polyfill

Object.create(...)是在ES5中新增的函数,所以在ES5之前的环境中(比如IE6),如果要支持这个功能就需要使用polyfill (垫片)来实现它:

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

这段 polyfill 使用了一个一次性函数 F,我们通过改写它的 prototype 属性使其指向想要关联的对象,然后再使用 new F() 来构造一个新对象并返回。

属性检查

hasOwnProperty()

hasOwnProperty() 方法可以用于确定某个属性是否是实例属性。这个方法是继承自Object 的,会在属性存在于调用它的对象实例上时返回true。

function Foo () {}
Foo.prototype.name = 'sunshine'
let a = new Foo()

console.log(a.name) // 'sunshine' 来自原型
console.log(a.hasOwnProperty('name')) // false 

a.name = 'colorful'
console.log(a.name) // 'colorful' 来自实例
console.log(a.hasOwnProperty('name')) // true

delete a.name
console.log(a.name) // 'sunshine' 来自原型
console.log(a.hasOwnProperty('name')) // false

这里例子中,可以看到 a.hasOwnProperty('name') 只在重写了 a.name 之后才返回 true,表明此时 name 属性是一个实例属性,不是原型属性。

实际上,如果属性名出现在了实例的原型链上层,就会发生屏蔽。像这样实例上创建和原型对象同名的属性,我们称之为屏蔽属性。实例中包含的属性会屏蔽上层所有的同名属性,因为实例属性的值总会选择原型链最底层的属性。

屏蔽的过程中会出现三种情况:

  1. 如果原型链上层存在同名非只读属性,属性会添加到对象上
  2. 如果原型链上层存在同名只读属性,严格模式下无法修改,非严格模式下属性会添加到对象上
  3. 如果原型链上层存在同名属性,但是它是响应式的,调用它setter方法,属性不会添加到对象上,也不会重新定义属性的setter

屏蔽属性一旦创建,原型链上的同名属性就不能被访问到了,即使你将这个属性设置为null,也不会恢复访问。除非使用delete 操作符。

上例中的属性增添过程如下所示:

in 操作符

in 操作符可以通过对象访问指定属性时返回 true,无论该属性是在实例上还是原型上。来看下面的例子:

function Foo () {}
Foo.prototype.name = 'sunshine'
let a = new Foo()

console.log(a.name) // 'sunshine' 来自原型
console.log(a.hasOwnProperty('name')) // false 
console.log('name' in a) // true 

a.name = 'colorful'
console.log(a.name) // 'colorful' 来自实例
console.log(a.hasOwnProperty('name')) // true
console.log('name' in a) // true 

delete a.name
console.log(a.name) // 'sunshine' 来自原型
console.log(a.hasOwnProperty('name')) // false
console.log('name' in a) // true 

在上面的例子中个,name 属性随时可以通过实例或原型访问到。因此,调用 name' in a 时始终返回 true,无论这个属性是否在实例上。

如果要确定某个属性是否在原型上,则可以同时使用 hasOwnProperty() 和 in 操作符,为此我们可以封装一个方法:

function hasPrototypeProperty (obj, name) {
  return !obj.hasOwnProperty(name) && (name in obj)
}

function Foo () {}
Foo.prototype.name = 'sunshine'
let a = new Foo()

console.log(hasPrototypeProperty(a, 'name')) // true 来自原型
console.log(a.hasOwnProperty('name')) // false 

a.name = 'colorful'
console.log(a.hasOwnProperty('name')) // true 来自实例
console.log(hasPrototypeProperty(a, 'name')) // false

模拟 new

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

关于 new 关键字,有一段很有趣的历史:

JavaScript 的创造者 Brendan Eich 实现 new 是为了获得更高的流行度,它是强行学习 Java 的一个残留产物,他想让 JavaScript 成为Java 的 “小弟”。很多人认为这个设计掩盖了JS中真正的原型继承,只是表面上看更像是基于类的继承。这样的误会使得 Java 开发者并不能很好地理解 JavaScript。

实际上,前端工程师应该明白, new 关键字到底做了什么事情。从例子出发:

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

Foo.prototype.myName = function () {
  return this.name
}

var a = new Foo('sunshine')
a.name // "sunshine"
a.myName() // "sunshine"
a.__proto__ === Foo.prototype // true

function Bar () {
  return { name: 'colorful' }
}
var b = new Bar()
console.log(b) // { name: 'colorful' }

执行 Foo 函数返回了一个实例对象 a,Foo 函数的参数被传入,同时上下文 this 会被指定为这个新的实例 a ,所以 this.name等价于a.name,a 继承了 Foo 函数原型对象上的方法,所以 a.myName 的结果也为 sunshine。Bar 函数返回了一个对象,所以 new 之后的实例 b 等于这个对象。

所以,new 函数内部的逻辑如下:

  1. 创建一个空对象,这个对象将会作为执行构造函数之后返回的对象实例
  2. 将这个空对象的隐式原型(__proto__)指向构造函数的显示原型( prototype)
  3. 将这个空对象赋值给构造函数内部的this,并执行构造函数逻辑
  4. 根据构造函数执行逻辑,如果构造函数返回了一个对象,那么这个对象会取代 new 出来的结果
  5. 如果函数没有返回其他对象,那么 new 函数会自动返回这个新对象

因为 new 是关键字,所以我们不能直接将其覆盖,这里通过一个函数来模拟:

function newFunction() {
  var constructor = Array.prototype.shift.call(arguments)
  
  /* if(!Object.create) {
    Object.create = function (o) {
        function F(){}
        F.prototype = o;
        return new F()
    }
	} */
  
  var obj = Object.create(constructor.prototype)

  obj.__proto__ = constructor.prototype

  var res = constructor.apply(obj, arguments)

  return res instanceof Object ? res : obj
}

上述代码并不复杂,涉及的几个关键点如下:

  • 使用 Object.create 让 obj 的隐式原型指向构造函数的原型
  • 使用apply方法使构造函数内的this指向obj
  • 在 newFunction 返回时,使用 instanceof 判断返回结果是否为对象类型

修改原型

至此,我们可以对比之前修改原型的几种方式,以下面的代码为例:

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

Foo.prototype.myName = function () {
  return this.name
}

function Bar (name, label) {
  Foo.call(this, name)
  this.label = label
}

// Bar.prototype = Foo.prototype
// Bar.prototype = new Foo()
// Object.setPrototypeOf(Bar.prototype, Foo.prototype)
Bar.prototype = Object.create(Foo.prototype)

Bar.prototype.myLabel = function () {
  return this.label
}

var a = new Bar('a', 'obj a')
a.myName() // 'a'
a.myLabel() // 'obj a'
  1. Bar.prototype = Foo.prototype:这种做法并不会指定 Bar 的原型,我们知道修改原型的是修改对象的隐式原型(__proto__) 指向显示原型,所以这里只会修改显示原型,而且是无意义的,因为你可以直接使用 Foo.prototype
  2. Bar.prototype = new Foo():new 关键字的确会创建一个新的对象,并修改隐式原型,但是如果函数 Foo 有一些副作用,就会影响 Bar() 的后代,后果不堪设想。
  3. Object.setPrototypeOf(Bar.prototype, Foo.prototype):Object.setPrototypeOf 是ES6新增的辅助函数,可以用标准的方式来修改关联,但是可读性差,使用不当依然和 new 一样影响继承关系。
  4. Bar.prototype = Object.create(Foo.prototype):这种方式的缺点是需要创建一个新对象,然后把旧对象抛弃掉,不能直接修改已有的默认对象,这样会带来轻微性能损耗——旧对象抛弃后需要进行垃圾回收。

这里,附上一句Mozilla文档给出的警告:

在所有浏览器的 JavaScript 引擎中,修改继承关系的影响都是微妙且深远的。这种影响不仅是执行 Object.setPrototypeOf() 语句那么简单,而是会涉及所有访问量那些修改过原型的对象的代码

总结

至此,操作原型以及原型的各种操作方式就讲完了,现将知识点整理如下。

原型和原型链

  1. 只有函数才有显示原型属性( prototype
  2. 所有对象都含有隐式原型(__proto__
  3. 所有函数的默认原型都是 Object 的实例
  4. 对象所有的父类和祖先类的原型所形成的可上溯访问的链表称之为原型链

原型操作

  1. 3种查找原型的方式:实例 instanceof 函数函数.prototype.isPrototypeOf(实例)Object.getPrototypeOf(实例) === 函数.prototype
  2. 3种改变原型的方式:构造函数、Object.setPrototypeOf(...)Object.create(...)
  3. 原型为null的对象没有原型链,没有原型链的对象称之为纯净对象,Object.create(null)可以创建纯净对象
  4. 检查属性是否在实例上:实例.hasOwnProperty(属性)
  5. 检查属性是否在实例的原型上:实例.hasOwnProperty(属性) && (属性 in 实例)
  6. 创建对象的几种方式:对象字面量{},new Object(),new Fn(),Object.create()

面试题

为什么说null是原型链的终点?

  • 对于一个普通的函数 function fn() {}来说,它是由 Function 函数生成的

    fn.__proto__ === Function.prototype  // true
    fn instanceof Function  // true
    fn instanceof Object    // true 
    
  • Function 也是由 Function 生成的

    Function.__proto__ === Function.prototype  // true
    
  • Object 函数也是一个函数对象,也是由 Function 生成的

    Object.__proto__ === Function.prototype  //true
    
  • Function.prototype 是由 Object.prototype 生成的

    Function.prototype.__proto__ === Object.prototype
    
  • Object.prototype 就是原型链的终点

    Object.prototype.__proto__ === null
    

所以,函数含有__proto__prototype属性,__proto__指向Function.prototype,prototype指向Object.prototype。所有的类型的[[Prototype]]特性,即 __proto__属性均指向的是 Function.prototype,同时 Function.prototype[[Prototype]]特性,即 __proto__属性又指向了 Object.prototypeObject.prototype__proto__又指向null,即原型链的终点。