构造函数与原型(链)

 了解构造函数和原型链之前,我们先复习一下关于引用类型的知识。

# 引用类型

  在OO语言中这种数据结构通常称作“类”,在JS中这么称呼不太准确。是一种用于将数据和功能组织在一起的数据结构。引用类型的最大特征就是,使用一个指针来指向数据真正存储的位置,而指针与数据之间并没有强关联关系。常说的对象,是Object引用类型(以下简称Object类型)的实例。
  JavaScript中引用类型主要有以下几种:关于数组和对象更详细请阅读引用类型之「对象/数组」

(1)Object类型:使用键值对来存放数据的一种数据结构,通常判断方法有:实例.constructor === Object实例 instanceof Object返回true,而typeof 实例 === 'object'

(2)Array类型:数组中的每一项可以存储不同类型的数据。使用下标来取。实例.constructor === Array实例 instanceof Array返回true, 而typeof 实例 === 'array'

(3)Date类型:日期类型,通常使用UTC时间格式。new Date()接收毫秒参数。当直接传入字符串如2020, 4, 10时,其实是在Date()内模拟了Date.UTC()Date.parse()的字符串参数格式是'月/日/年')进行了解析,变成毫秒数后再传递给Date()构造函数的。注意UTC()方法中的月份从0开始,因此该例子是2020年5月10日(parse()无此问题)。

(4)RegExp类型:正则类型,语法:var exp = /pattern/flag。pattern定义使用^符号开始,$符号结束。flag中g表示全查询,i表示不区分大小写,m表示多行查询。

(5)Function类型:函数类型。JS中:函数是对象,函数名是指针。复制函数名只是新增了一个指向函数对象的指针。当我们把函数当做值在别的函数中进行传递时,使用的是函数名传递,也就是说只是把函数对象的地址告诉了宿主调用函数。但是如果在传递时我们给参数函数名增加了括号,此时表示将函数对象进行执行后,把返回值传递给宿主调用函数。此外,函数中内置有arguments对象和this对象,前者存放函数的参数对象以及callee(指向该函数名)和caller(指向调用该函数的宿主函数)等信息,而后者存放的是运行该函数时函数所在的执行环境。当我们需要修改或指定函数执行环境时,可以使用call()apply()方法。前者接收多个参数,后者接受两个参数,第一个参数均表示要调用该函数的执行环境如window、某个私有域等,该作用域近在本次函数运行时有效。bind()方法用于将该函数的执行环境长期绑定到某个环境对象中,注意bind()不改变当前函数的运行环境,而是返回拥有绑定环境对象的新函数。

(6)基本包装类型:String类型,Number类型,Boolean类型。它们的特点是在执行过程中自动创建,且生命周期仅存在于代码执行的那一瞬间,运行结束即销毁。这也是为什么作为值类型的String却拥有slice()substring()substr()indexOf()lastIndexOf()trim()等属性方法的原因,这些方法都继承于String类型的原型方法。

(7)单体内置对象:Global对象、Math对象。Global对象是JS中的“兜底儿对象”,其实并没有什么全局变量全局方法,它们只不过都是Global对象的属性和方法罢了,在各大Web浏览器中,将Global对象作为window对象的一部分加以实现(window还有别的任务),所以通常使用window来调用或直接省略window。比较经典的几个方法是encodeURIisNaN()parseInt()eval()构造函数Object构造函数Function构造函数SybtaxError构造函数TypeError,而属性也有undefinedNaN等。而Math对象中保存了数学计算中可能会用到的一些特殊值和常用方法,如Math.E(自然对数底数)Math.PIMath.SQRT(平方根)等、min()max()ceil()向上舍入floor()向下舍入round()四舍五入random()大于0小于1的随机数abs()绝对值pow(n, p)n的p次幂cos()余弦值等。


# 构造函数

  一般用来创建特定类型的对象。构造函数分为两种:原生构造函数和自定义构造函数。在实例中,一般使用 实例.constructor来获取构造函数名。注意JS惯例规定构造函数首字母要大写。构造函数是JavaScript被定义为 OO (Object Oriented)语言的重要因素之一,也是JS实现继承的重要手段

  • 原生构造函数
    最常见原生构造函数是Object、Array、Date,当然还有RegExp、Function等等。
    (1)Object: 用于创建Object类型的实例,即对象。用法:var obj = new Object()
    (2)Array: 用于创建Array类型的实例,即数组。用法var arr = new Array()
    (3)Date: 用于创建Date类型的实例,即日期。用法var date = new Date()

  • 自定义构造函数
    (1)一个自定义构造函数实践

function Person(name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job;
  this.sayName = function() {
    alert(this.name)
  }
}
var person1 = new Person('Nic', '22', 'softEngineer')
var person2 = new Person('Xud', '16', 'teacher')

  new操作符主要执行的逻辑包括:1)创建了一个新对象;2)将构造函数的作用域(this)赋给新对象;3)执行构造函数中的逻辑;4)返回新对象。

(2)构造函数和函数
  主要的区别就是调用方式不同。构造函数其实也是函数。任何函数使用new操作符来调用,就可以作为构造函数来使用,任何构造函数直接函数名调用,那它和普通函数也就没有区别。构造函数同样也有call()apply()方法,可以根据需要改变它的执行环境。

(3)构造函数和工厂函数
  以对象为例,工厂函数旨在在函数内部new一个Object实例,根据人参处理对象属性并return出该对象,运行工厂函数后实实在在的从无到有的创建了一个新的对象并返回。
  而自定义构造函数则是根据需要的数据结构先定义好一个构造模板,在需要的地方直接使用new来实例化这个构造函数而非实时实例化Object,且这个过程不需要手动return,因为在执行实例化的时候实例自动就拥有了构造函数所拥有的实例属性和原型属性。

(4)!!!使用new操作符来实例化构造函数时,每个属性和方法都要重新创建一遍,无论是值类型还是引用类型。从而保证了每次创建出来的实例都是互相独立的。但我们知道,对一个相同的方法进行多次定义是不必要的,因此通常建议不在构造函数中定义方法,而是定义在原型对象中。


# 原型对象、原型

  原型对象共享于所有实例中,这为实现继承创造了天然的条件。下文将原型对象,原型指针,实例原型指针等概念区分的比较精确,实际上在称呼时常有混淆,需要注意观察称呼时表示的是谁的属性,就能理解是什么意思。

(1)只要创建了一个新函数,就会根据一组特定规则为该函数创建一个prototype(原型)属性,它是一个指针,指向函数的原型对象。默认的原型对象会自动获取一个constructor(构造函数)属性,该属性指向prototype所在函数对象的指针(即函数名)。
  拿上面的构造函数来举例:Person.prototype.constructor指向了Person

(2)除constructor属性是自动拥有外,我们还能够为其添加自定义的原型属性,这些原型属性被所有的实例共享,假设构造函数Person 有两个实例 person1 和 person2,它们的指向关系如下:

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

1)理解一点:Person.prototype是一个指针,而图中的 Person Prototype是 Person的原型对象,在JS中,正是用Person.prototype来访问和设置该原型对象的。原型对象上的属性被称为构造函数或实例的原型属性。而构造函数和实例自身的属性称为实例属性
2)图中实例person1和实例person2中的 [[Prototype]] 是在实例化构造函数时,每个实例都会自动获取的一个内部指针属性,ECMA-262第5版将他表示为[[Prototype]],在各大Web浏览器中,将这个内部属性表示为__proto__,可以称为实例原型指针。在JS中,执行hasOwnProperty()查找对象某个属性时,当实例属性里没有需要继续查找原型属性时,就是通过该属性找到的原型对象并访问其属性的。
3)[[Prototype]]__proto__,是实例与原型对象之间的关系,它与构造函数并没有直接关系。
4)图中可以看出,实例.constructor获取到构造函数名的原因,正式因为通过__proto__这个指针找到了共享在原型对象中的constructor属性,而该属性的值正是该原型对象的构造函数名。
5)原型对象自身是一个数据结构存放在内存中,构造函数通过prototype指针属性来访问,而实例通过__proto__指针属性来访问。这形成了一个三角关系

Person.prototype === person1.__proto__ === person2.__proto__
三角关系

(3)新创建一个函数时默认会生成它的原型对象,我们可以通过构造函数的prototype属性或任意一个实例的__proto__(通常用前者)来给原型对象中添加和删除原型属性,这些属性的最大特征就是在所有的实例中共享。但某个属性是值类型时,如果不想共享可以在实例定义来屏蔽原型属性;当某个原型属性是引用类型时,某个实例对该属性的修改,将会实时影响其他实例,毕竟这些属性只是保存了引用类型的地址。为了规避这个问题,可以将所有除方法外的引用类型属性定义在够造函数中即可。
  通常设置原型属性会使用Person.prototype来一个个的添加,这样不会重写protorype指针的指向。当使用字面量法操作原型对象时,即如下:

function Person() {}
var person = new Person();
// 使用字面量法修改原型对象上的属性
Person.prototype = {
  constructor: Person, // 见下方解释
  name: 'Nic',
  age: '22',
  job: 'Soft Engineer',
  sayName: function() {
    alert(this.name)
  }
}
alert(person.name); // 'Nic'

!!!需要警惕的是,这种方式虽然很方便很实用,但实质上是给Person.prototype重新赋值给新定义的对象,由于指针的指向发生了改变,该原型指针已经切断了和创建函数时默认的原型对象的关系,也就是说,重写了原型对象。它将无法正确的获取默认原型对象上constructor指向值,而变成什么?
  把等号后面的内容单独看,其实就是使用字面量法创建的一个Object类型的实例,那么它的构造器应该是 Object,即Person.prototype.constructor === Object!!!
  为了修正为Person.prototype.constructor === Person,我们可以重写字面量定义里的constructor属性为 Person。如上代码所示。

  另外,从该案例中可以发现,虽然先执行了实例化,在重写Person的原型,实例依然能正确获取原型属性,这归功于原型具有动态性,也就是prototype指针适用于引用类型的特性。


# 原型链、继承

  上一个例子中,在没有重写constructor属性之前,细心的你可能发现这个结构其实就是把 Person 的protorype指针指向了Object类型的一个实例上,而由字面量定义的对象实例,也有prototype指针属性和原型对象,那么就可以得到如下信息:

person.__proto__.__proto__.constructor === Object

这里所出现的两个__proto__指针属性,形成了原型对象上的链式调用,我们称之为原型链。

实现继承的主要方法是利用原型让一个引用类型继承另一个引用类型的属性和方法,其主要思想是重写一个引用类型的原型对象,使其等于另一个类型的实例。

(1)一个实现继承的实践

function Super() {
  this.sex = 'male'
}

Super.prototype.getSex = function() {
  return this.sex
}

function Sub() {
  this.hobby= 'runing'
}

// 重写Sub构造函数的prototype指针,利用原型链实现继承
Sub.prototype = new Super()

Sub.prototype.getName = function() {
  return this.name
}

var person = new Sub()

alert(person.getSex())  // male

  这段代码中,person是构造函数Sub的实例,但Sub构造函数中并没有getSex()这个实例方法,因此会去查找Sub的原型属性,而案例中把Sub的默认原型对象重写成了Super的实例,而Super的实例可以通过__proto__指针取到Super原型对象中的getSex()方法,因此,输出了male

  我们把上例中实现继承的关键代码拆开,可得到如下描述

var superObj = new Super()
alert(superObj.sex); // 'male'
alert(superObj.getSex()); // 'male'

Sub.prototype = superObj
alert(Sub.sex); // 'male'
alert(Sub.getSex()); // 'male'

var person = new Sub()
alert(person.__proto__.__proto__ === Super.prototype); // true
alert(person.__proto__.__proto__.constructor === Super); // true


(2)默认的原型
  需要了解的是,所有的函数的默认原型都是Object的实例,也就是说,所有的自定义构造函数都默认继承了原生的Object类型,而这个继承也是通过原型链实现的。因此默认原型都会包含一个内部指针指向Object.prototype,这也是为什么所有的自定义类型都有toString()valueOf()等默认的方法的根本原因。
  因此针对上述实践,我们可以再加上默认的原型,就有了如下关系

alert(person.__proto__.__proto__.__proto__ === Object.prototype) // true
alert(person.__proto__.__proto__.__proto__.constructor === Object) // true

# 确定实例和原型的关系

(1) instanceof

instance 即:“实例”。instanceof操作符用来表示操作符左侧的实例是否是右侧构造函数实例化来的,这个构造函数可以是实例原型链上constructor中的一员。借用上面的例子有:

person instanceof Sub // true
person instanceof Super  // true
person instanceof Object // true

  在真实业务环境中,通常判断的是直接由原生构造函数Object或Array创建的实例,原型链上只有一环,就是如上第三个表达式。
  以对象为例来看instanceof 操作符的执行原理,其实就是操作符右侧的构造函数的原型指针,和操作符左侧的原型指针是否指向了同一个原型对象。

person instanceof Object
!等价于判断
person.__proto__=== Object.prototype
!注意不是以下判断,可以用字面量发重定义原型再修改原型构造函数的constructor来验证
person.__proto__.constructor===Object
(2) isPrototypeOf()

isPrototypeOf()方法是原型对象的一个方法属性,表示只要是原型链中出现过的原型,都是该原型链所派生的实例的原型。或者理解为:某个构造函数的原型对象,是否是某个实例的原型链上的一员。上面的例子用该方法描述即:

Object.prototype.isPrototypeOf(person)  // true
Super.prototype.isPrototypeOf(person)  // true
Sub.prototype.isPrototypeOf(person)  // true