【JS核心概念】原型与原型链
一、构造函数
1.1 创建对象的方法
我们常说,万物皆对象。在JS中常用的创建对象的方式有两种:对象字面量、构造函数(使用new运算符调用的函数就是构造函数)。
// 1. 对象字面量
const student1 = {
name: 'Lily',
age: 18,
sayName: function() {
console.log(this.name)
}
}
console.log(student1) // { name: 'Lily', age: 18, sayName: [Function: sayName] }
student1.sayName() // Lily
console.log(student1.constructor) // [Function: Object]
// 2. 构造函数
function Student(name, age) {
this.name = name
this.age = age
this.sayName = function() {
console.log(this.name)
}
}
const student2 = new Student('Lucy', 18)
console.log(student2) // Student { name: 'Lucy', age: 18, sayName: [Function] }
student2.sayName() // Lucy
console.log(student2.constructor) // [Function: Student]
根据以上代码,我们可以发现:
- 使用构造函数创建的对象可以准确的检测出它的类型,如这里的Student,而使用对象字面量创建的对象其类型为Object。
- 使用构造函数创建对象时,如果需要创建多个同类型的对象,则再次使用new运算符调用即可,而使用对象字面量则需要重新写一遍代码。
综上,如果需要准确的判断对象的类型或者需要创建多个同类型的对象时,建议使用构造函数。
看到这里,你或许会想,为什么使用new运算符调用函数就会有此奇效呢?接下来,我们就来揭开new运算符的面纱。
1.2 new运算符
new运算符创建一个用户定义的对象类型的实例或具有构造函数的内置对象的实例。
new运算符会进行如下的操作:
- 创建一个新对象;
- 链接该对象(即设置该对象的构造函数)到另一个对象;
- 将新对象作为this的上下文(因此this指向这个新对象);
- 为新对象添加属性(即执行构造函数中的代码);
- 如果构造函数中没有显示的返回对象,则返回第一步创建的新对象(即返回this)。
拿 new Student('Lucy', 18) 来说,当代码 new Student('Lucy', 18) 执行的时候,会发生以下事情:
- 一个继承自 Student.prototype 的新对象被创建;
- 使用指定的参数调用构造函数 Student ,并将this绑定到新创建的对象;
- 返回这个新创建的对象。
都说到这个份上了,不模拟一下new运算符的实现似乎不太合适,嗯,来看看吧~
/**
* 模拟new运算符的实现:
* 第一个参数为构造函数名
* 后面的参数为传递到构造函数中的参数
*/
function create() {
// 1.创建一个新对象
var obj = new Object(),
// 2.获得构造函数,arguments去除第一个参数
Con = [].shift.call(arguments)
// 链接到原型,obj可以访问到构造函数原型中的属性
obj._proto_ = Con.prototype
// 绑定this实现继承,obj可以访问构造函数中的属性
var res = Con.apply(obj, arguments)
// 如果构造函数返回了一个对象,则优先返回这个对象;反之,则返回新创建的这个对象。
return res instanceof Object ? res : obj
}
// 测试一下
function Student(name, age) {
this.name = name
this.age = age
this.sayName = function() {
console.log(this.name)
}
}
const student = create(Student, 'Lily', 18)
student.sayName() // Lily
二、原型
2.1 原型与构造函数组合创建对象
当我们使用构造函数的时候,每创建一个student实例,构造函数中的代码就会执行一次,如下代码段,我们发现每个student实例的sayName方法是指向不同的引用的,但实际上,sayName完成的是同一个操作,没有必要创建两个函数,消耗内存。
const student3 = new Student('Jack', 20)
console.log(student2.sayName === student3.sayName) // false
使用原型就可以解决这个问题。
当我们创建一个函数的时候,这个函数对象就会默认获得一个prototype属性,这个属性实际上是一个指针,指向一个对象,这个对象的用途是:包含由特定类型的所有实例对象共享的属性和方法。如果按照字面意思来理解,那么prototype指向通过调用构造函数而创建的那个实例对象的原型对象,使用原型对象的好处就是可以让所有实例对象共享原型对象所包含的属性和方法。
因此我们可以将实例对象不共享的属性或方法定义在构造函数中,而那些可以共享的属性或方法则直接定义在其原型对象上,如下:
function Student(name, age) {
this.name = name
this.age = age
}
Student.prototype.sayName = function() {
console.log(this.name)
}
const student1 = new Student('Lucy', 18)
console.log(student1) // Student { name: 'Lucy', age: 18 }
student1.sayName() // Lucy
const student2 = new Student('Lily', 20)
console.log(student2) // Student { name: 'Lily', age: 20 }
student2.sayName() // Lily
// sayName方法实现了共享
console.log(student1.sayName === student2.sayName) // true
2.2 理解原型对象
每个函数都有一个prototype属性,它实际上是一个指针,当我们通过new运算符调用一个函数时,prototype就指向由这个函数创建的实例对象的原型对象。默认情况下,原型对象会拥有一个constructor属性,该属性指向prototype属性所在的函数。拿上面的例子来说,就是:Student.prototype.constructor 指向 Student
console.log(Student.prototype.constructor === Student) // true
当调用构造函数创建一个新实例后,该实例的内部将包含一个指针[[Prototype]],指向该实例对象的原型对象,虽然在脚本中没有标准的方式访问[[Prototype]],但Firefox、Safari和Chrome在每个对象上都支持一个属性_proto_,而在其他实现中,这个属性对脚本是完全不可见的。可以通过getPrototypeOf方法获取某个对象的原型,如下:
function Iphone(brand) {
this.brand = brand;
}
const iphone = new Iphone('apple');
const pro1 = Object.getPrototypeOf(iphone); // 原型对象为Iphone.prototype
console.log(pro1.constructor); // [Function: Iphone]
function Yphone(brand) {
this.brand = brand;
return {
brand
}
}
const yphone = new Yphone('you');
const pro2 = Object.getPrototypeOf(yphone); // 原型对象为Object.prototype
console.log(pro2.constructor); // [Function: Object]
通过以上代码,可以得出:
- 如果构造函数没有显式的返回某个对象,则其创建的实例的原型对象为该构造函数的prototype属性所指的对象;
- 如果构造函数显式的返回了某个对象,则其创建的实例的原型对象为返回的那个对象的构造函数的prototype属性所指的对象。
下图以Student为例展示了构造函数、原型以及实例之间的关系:
从上图我们可以得出:
- 构造函数中的属性或者方法是定义在实例对象上的,各属性之间相互独立,互不影响;
- student1和student2这两个实例上虽然没有sayName方法,但是依然可以调用sayName方法,这是因为原型对象上有sayName 方法,定义在原型上的属性或者方法由各实例对象共享(读取时);
- _proto_存在与实例对象与原型对象之间,而不是存在与实例对象与构造函数之间,实例对象与构造函数没有直接的关系。
2.3 对象属性的查找过程
当我们需要访问对象的属性时,都会进行一次搜索,搜索的目标是具有给定名字的属性:
- 首先查找当前对象上是否含有这个属性,如果有,则直接返回该属性的值;
- 如果没有,则继续查找这个对象的[[prototye]]指针指向的原型对象,如果原型对象上有这个属性,则返回该属性的值;
- 如果原型对象上没有,则继续查找原型对象的[[prototye]]指针指向的原型对象(每个对象都有内置的[[prototye]]属性),有则返回,没有则继续,直到找到该属性或者null为止。
注意:我们可以通过实例访问原型上的属性和方法,但是不能直接修改它。如果实例上添加了一个属性且该属性与原型上的某个属性同名时,并不会修改原型上的属性,而是直接在实例对象上添加了该属性,下次访问通过该实例对象访问这个属性时,会自己方法实例对象上这个属性的值,如下:
function Car(series, color) {
this.series = series;
this.color = color;
}
Car.prototype.brand = 'BMW';
let car1 = new Car('q7', 'black');
let car2 = new Car('x7', 'black');
// 在实例car1上添加属性brand,不会对原型产生影响
car1.brand = 'audi';
// 通过实例car1访问brand属性时,会屏蔽原型上的brand属性,直接返回实例上的brand属性
console.log(car1.brand); // audi
// 通过实例car2访问brand属性时,返回的是原型上的brand属性
console.log(car2.brand); // BMW
三、原型链
student1.toString() // Student { name: 'Lucy', age: 18 }
如上,student1本身以及它的原型上都没有定义toString方法,但是却可以直接调用,根据对象属性的查找规则,toString方法应该是student1原型链中的一个方法。
原型链: 如果一个原型对象是另一个类型的实例,那么这个原型对象将包含另一个原型的指针(proto),而另一个原型中也包含着指向另一个构造函数的指针。如果另一个原型又是另一个类型的实例,那么上述关系依然成立,如此层层递进,就构成了实例与原型的链条。这就是所谓的原型链。
student1调用的toString方法实际上是Object.prototype.toString,所有的引用类型默认都继承了Object。下图展示了完整的原型链: