Skip to content

JavaScript基础之原型和原型链 #14

@jimdeng92

Description

@jimdeng92

原型和原型链

构造函数

JavaScript通过构造函数来生成新对象,因此构造函数可以视为对象的模板。

function Cat(name, color) {
  this.name = name
  this.color = color
}
let cat1 = new Cat('大毛', '白色')
cat1.name // '大毛'
cat1.color // '白色'
Details `new` 操作符在这里都干了什么?
  1. 创建一个新对象;
  2. 将构造函数的作用域赋给新对象(因此this就指向了这个新对象);
  3. 执行构造函数中的代码(为这个新对象添加属性);
  4. 返回新对象(如果构造函数中显式返回了一个对象类型,则是返回的那个对象)。

通过上面的描述可以用代码表示new操作符的简易流程。

/**
  * constructor 构造函数
  * params 构造函数参数
  */
function _new(constructor, params) {
  // 将arguments转化为数组
  const args = [].slice.call(arguments)
  // 取出构造函数
  const constructor = args.shift()
  // 创建一个新对象,继承构造函数的prototype属性
  const context = Object.create(constructor.prototype)
  // 执行构造函数
  const rusult = constructor.apply(context, args)
  // 如果返回结果是对象,就直接返回,否则返回context
  return (typeof rusult === 'object' && result != null) ? result : context
}

通过构造函数来为实例对象定义属性很方便,但是有一个缺点,就是构造函数的多个实例无法共享属性,造成对资源的浪费。

function Cat(name, color) {
  this.name = name
  this.color = color
  this.eat = function () {
    console.log(`${this.name} eatting.`)
  }
}
const cat1 = new Cat('Tom', 'blue')
const cat2 = new Cat('Lucy', 'pink')
cat1.eat === cat2.eat // false

上面的代码中,cat1和cat2分别是Cat的两个实例,他们都有eat方法。由于eat方法是生成在每个对象实例上面,所以两个对象就生成了两次。也就是说,每新建一个实例,就会新建一个eat方法。这没有必要又浪费系统资源,因为eat方法都是相同的行为,完全可以共享。
这个问题的解决办法,就是JavaScript的原型对象(prototype)。

prototype

JavaScript规定,每个函数都有一个prototype属性,指向一个对象。

function fn() {}
typeof fn.prototype // 'object'

对于普通函数而言,这个属性基本是无用的。但是对于构造函数来说,该属性会自动成为实例对象的原型。
原型对象的属性不是实例对象自身的属性。只要改变了原型对象,变动就立即会体现在所有的实例对象上。
之所以被成为原型对象,是因为实例对象可以视作从原型对象衍生出来的子对象。

原型链

JavaScript规定,所有对象都有自己的原型对象(prototype)。一方面,任何一个对象都可以充当其他对象的原型,而原型对象也是对象,它也有自己的原型,因此就形成了原型链。如此追溯,可以一直追溯到Object.prototype。常见的valueOf、toString就是定义在该原型上,而Object.prototype它的原型就是null了。

Object.getPrototypeOf(Object.prototype) // null

:::tip
Object.getPrototypeOf( object )返回指定对象的原型(内部[[Prototype]]属性的值)。
:::
读取对象属性,JS引擎先在对象本身中查找,如果找不到,就到它的原型中找,如果还找不到,就到原型的原型中找。如果直到顶层的Object.prototype还是找不到,就返回undefined。如果对象本身和原型都有这个属性,那么就优先读取对象本身的属性,原型的属性会被覆盖。
也就是说,如果构造函数的prototype指向一个数组,那么实例对象就可以使用数组的方法了。

const arr = function() {}
arr.prototype = new Array()
arr.prototype.constructor = arr
const instanceArr = new arr()
instanceArr.push(1, 2, 3)
instanceArr.length() // 3
instanceArr instanceof Array // true

constructor

prototype对象上有一个constructor属性,默认指向prototype对象所在的构造函数。

function fn() {}
fn.prototype.constructor === fn // true

要注意的是,在修改prototype对象时,一般要同时修改constructor属性的指向。

// 坏的写法
C.prototype = {
  method1: function (...) { ... },
  // ...
};
// 好的写法
C.prototype = {
  constructor: C,
  method1: function (...) { ... },
  // ...
};
// 更好的写法
C.prototype.method1 = function (...) { ... };

结合一张图来理解原型链可能更好:

构造函数的继承

让一个构造函数继承另一个构造函数是非常常见的需求。这可以分两步实现,第一步是在子类的构造函数中调用父类的构造函数。

function Sub(value) {
  Super.call(this)
  this.prop = value
}

::: tip
call() 方法使用一个指定的 this 值和单独给出的一个或多个参数来调用一个函数。
call和apply类似,唯一的区别是call接收一个参数列表,而apply接收的是一个包含多个参数的数组。
:::
第二步,让子类的原型指向父类的原型,这样子类就可以继承父类原型。

Sub.prototype = Object.create(Super.prototype)
Sub.prototype.constructor = Sub
// ...

::: tip
Object.create()方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__。
Object.create()和{}的区别:
Object.create(Object.prototype)相当于{}
:::
Sub.prototype是子类的原型,要将它赋值为Object.create(Super.prototype),而不是直接等于Super.prototype。否则后面两行对Sub.prototype的操作,会连父类的原型Super.prototype也一起修改掉。
另一种写法是Sub.prototype等于一个父类的实例。

Sub.prototype = new Super()

上面这个方法也可以实现继承,但是子类也会继承父类实例的方法,有时候这可能不是我们需要的,因此不推荐这种写法。

混入

由上面这种继承的方式也可以衍生出混入继承,即一个子类同时继承两个或多个父类的属性和方法。

function Super(name) {
  this.name = name
} 
function OtherSuper(age) {
  this.age = age
}
function Sub(name, age) {
  Super.call(this, name)
  OtherSuper.call(this, age)
}
Sub.prototype = Object.create(Super.prototype)
Object.assign(Sub.prototype, OtherSuper.prototype)
Sub.prototype.constructor = Sub

当然还有ES6的继承方式,这里不累述了。

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions