JS的原型和原型链

一、 JS创建对象的方法

1. 简单方式

我们可以直接通过 new Object() 创建:

let person = new Object()
person.name = 'Jack'
person.age = 18

person.sayName = function () {
  console.log(this.name)
}

每次创建通过 new Object() 比较麻烦,所以可以通过它的简写形式对象字面量来创建:

let person = {
  name: 'Jack',
  age: 18,
  sayName: function () {
    console.log(this.name)
  }
}

对于上面的写法固然没有问题,但是假如我们要生成两个 person 实例对象呢?

let person1 = {
  name: 'Jack',
  age: 18,
  sayName: function () {
    console.log(this.name)
  }
}

let person2 = {
  name: 'Mike',
  age: 16,
  sayName: function () {
    console.log(this.name)
  }
}

通过上面的代码我们不难看出,这样写的代码太过冗余,重复性太高。

2. 简单方式的改进:工厂函数

我们可以写一个函数,解决代码重复问题:

function createPerson (name, age) {
  return {
    name: name,
    age: age,
    sayName: function () {
      console.log(this.name)
    }
  }
}

然后生成实例对象:

let p1 = createPerson('Jack', 18)
let p2 = createPerson('Mike', 18)

这样封装确实爽多了,通过工厂模式我们解决了创建多个相似对象代码冗余的问题,
但却没有解决对象识别的问题(即怎样知道一个对象的类型)。

3. 更优雅的工厂函数:构造函数

一种更优雅的工厂函数就是下面这样,构造函数:

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

let p1 = new Person('Jack', 18)
p1.sayName() // => Jack

let p2 = new Person('Mike', 23)
p2.sayName() // => Mike

解析构造函数代码的执行

在上面的示例中,Person() 函数取代了 createPerson() 工厂函数,但是实现效果是一样的。
这是为什么呢?

我们注意到,Person() 中的代码与 createPerson() 有以下几点不同之处:

  • 没有显示的创建对象
  • 直接将属性和方法赋给了 this 对象
  • 没有 return 语句
  • 函数名使用的是大写的 Person

而要创建 Person 实例,则必须使用 new 操作符。
以这种方式调用构造函数会经历以下 4 个步骤:

  1. 创建一个新的空对象
  2. 将构造函数的作用域赋给新对象(因此 this 就指向了这个新对象)
  3. 执行构造函数中的代码
  4. 返回新对象给变量

下面是具体的伪代码:

function Person (name, age) {
  // 当使用 new 操作符调用 Person() 的时候,实际上这里会先创建一个对象
  // let instance = {}
  // 然后让内部的 this 指向 instance 对象
  // this = instance
  // 接下来所有针对 this 的操作实际上操作的就是 instance

  this.name = name
  this.age = age
  this.sayName = function () {
    console.log(this.name)
  }

  // 在函数的结尾处会将 this 返回,也就是 instance
  // return this
}

构造函数和实例对象的关系

使用构造函数的好处不仅仅在于代码的简洁性,更重要的是我们可以识别对象的具体类型了。
在每一个实例对象中同时有一个 constructor 属性,该属性指向创建该实例的构造函数:

console.log(p1.constructor === Person) // => true
console.log(p2.constructor === Person) // => true
console.log(p1.constructor === p2.constructor) // => true

对象的 constructor 属性最初是用来标识对象类型的,
但是,如果要检测对象的类型,还是使用 instanceof 操作符更可靠一些:

console.log(p1 instanceof Person) // => true
console.log(p2 instanceof Person) // => true

构造函数的问题

使用构造函数带来的最大的好处就是创建对象更方便了,但是其本身也存在一个浪费内存的问题:

function Person (name, age) {
  this.name = name
  this.age = age
  this.type = 'human'
  this.sayHello = function () {
    console.log('hello ' + this.name)
  }
}

let p1 = new Person('Tom', 18)
let p2 = new Person('Jack', 16)

在该示例中,从表面上好像没什么问题,但是实际上这样做,有一个很大的弊端。
那就是对于每一个实例对象,typesayHello 都是一模一样的内容,
每一次生成一个实例,都必须为重复的内容,多占用一些内存,如果实例对象很多,会造成极大的内存浪费。

console.log(p1.sayHello === p2.sayHello) // => false

对于这种问题我们可以把需要共享的函数定义到构造函数外部:

function sayHello = function () {
  console.log('hello ' + this.name)
}

function Person (name, age) {
  this.name = name
  this.age = age
  this.type = 'human'
  this.sayHello = sayHello
}

let p1 = new Person('Top', 18)
let p2 = new Person('Jack', 16)

console.log(p1.sayHello === p2.sayHello) // => true

这样确实可以了,但是如果有多个需要共享的函数的话就会造成全局命名空间冲突的问题。

你肯定想到了可以把多个函数放到一个对象中用来避免全局命名空间冲突的问题:

let fns = {
  sayHello: function () {
    console.log('hello ' + this.name);
  },
  sayAge: function () {
    console.log(this.age);
  }
}

function Person (name, age) {
  this.name = name;
  this.age = age;
  this.type = 'human';
  this.sayHello = fns.sayHello;
  this.sayAge = fns.sayAge;
}

let p1 = new Person('lpz', 18);
let p2 = new Person('Jack', 16);

console.log(p1.sayHello === p2.sayHello) // => true
console.log(p1.sayAge === p2.sayAge) // => true

至此,我们利用自己的方式基本上解决了构造函数的内存浪费问题。
但是代码看起来还是那么的格格不入,那有没有更好的方式呢?

总结:

  • 构造函数是根据具体的事物抽象出来的抽象模板
  • 实例对象是根据抽象的构造函数模板得到的具体实例对象
  • 每一个实例对象都具有一个 constructor 属性,指向创建该实例的构造函数
  • 可以通过实例的 constructor 属性判断实例和构造函数之间的关系, 注意:这种方式不严谨,推荐使用 instanceof 操作符

二、 原型

1. 更好的解决方案: prototype

JavaScript 规定,每一个构造函数都有一个 prototype 属性,指向另一个对象。
这个对象的所有属性和方法,都会被构造函数的所拥有。

这也就意味着,我们可以把所有对象实例需要共享的属性和方法直接定义在 prototype 对象上。

function Person (name, age) {
  this.name = name;
  this.age = age;
}

console.log(Person.prototype);

Person.prototype.type = 'human';

Person.prototype.sayName = function () {
  console.log(this.name);
}

let p1 = new Person('zs',19);
let p2 = new Person('ls',18);

console.log(p1.sayName === p2.sayName) // => true

这时所有实例的 type 属性和 sayName() 方法,
其实都是同一个内存地址,指向 prototype 对象,因此就提高了运行效率。

2. 构造函数、实例、原型三者之间的关系
构造函数-实例-原型之间的关系.png

任何函数都具有一个 prototype 属性,该属性是一个对象。

function F () {}
console.log(F.prototype) // => object

F.prototype.sayHi = function () {
  console.log('hi!')
}

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

console.log(F.prototype.constructor === F) // => true

通过构造函数得到的实例对象内部会包含一个指向构造函数的 prototype 对象的指针 __proto__

let instance = new F()
console.log(instance.__proto__ === F.prototype) // => true

实例对象可以直接访问原型对象成员。

instance.sayHi() // => hi!

总结:

  • 任何函数都具有一个 prototype 属性,该属性是一个对象
  • 构造函数的 prototype 对象默认都有一个 constructor 属性,指向 prototype 对象所在函数
  • 通过构造函数得到的实例对象内部会包含一个指向构造函数的 prototype 对象的指针 __proto__
  • 所有实例都直接或间接继承了原型对象的成员
3.属性成员的搜索原则:原型链

了解了 构造函数-实例-原型对象 三者之间的关系后,接下来我们来解释一下为什么实例对象可以访问原型对象中的成员。

每当代码读取某个对象的某个属性时,都会执行一次搜索,目标是具有给定名字的属性

  • 搜索首先从对象实例本身开始
  • 如果在实例中找到了具有给定名字的属性,则返回该属性的值
  • 如果没有找到,则继续搜索指针指向的原型对象,在原型对象中查找具有给定名字的属性
  • 如果在原型对象中找到了这个属性,则返回该属性的值

也就是说,在我们调用 person1.sayName() 的时候,会先后执行两次搜索:

  • 首先,解析器会问:“实例 person1 有 sayName 属性吗?”答:“没有。
  • ”然后,它继续搜索,再问:“ person1 的原型有 sayName 属性吗?”答:“有。
  • ”于是,它就读取那个保存在原型对象中的函数。
  • 当我们调用 person2.sayName() 时,将会重现相同的搜索过程,得到相同的结果。

而这正是多个对象实例共享原型所保存的属性和方法的基本原理。

总结:

  • 先在自己身上找,找到即返回
  • 自己身上找不到,则沿着原型链向上查找,找到即返回
  • 如果一直到原型链的末端还没有找到,则返回 undefined
4. 实例对象读写原型对象成员

读取:

  • 先在自己身上找,找到即返回
  • 自己身上找不到,则沿着原型链向上查找,找到即返回
  • 如果一直到原型链的末端还没有找到,则返回 undefined

值类型成员写入(实例对象.值类型成员 = xx):

  • 当实例期望重写原型对象中的某个普通数据成员时实际上会把该成员添加到自己身上
  • 也就是说该行为实际上会屏蔽掉对原型对象成员的访问

引用类型成员写入(实例对象.引用类型成员 = xx):

  • 同上

复杂类型修改(实例对象.成员.xx = xx):

  • 同样会先在自己身上找该成员,如果自己身上找到则直接修改
  • 如果自己身上找不到,则沿着原型链继续查找,如果找到则修改
  • 如果一直到原型链的末端还没有找到该成员,则报错(实例对象.undefined.xx = xx
5. 更简单的原型语法

我们注意到,前面例子中每添加一个属性和方法就要敲一遍 Person.prototype
为减少不必要的输入,更常见的做法是用一个包含所有属性和方法的对象字面量来重写整个原型对象:

function Person (name, age) {
  this.name = name
  this.age = age
}

Person.prototype = {
  type: 'human',
  sayHello: function () {
    console.log('我叫' + this.name + ',我今年' + this.age + '岁了')
  }
}

在该示例中,我们将 Person.prototype 重置到了一个新的对象。
这样做的好处就是为 Person.prototype 添加成员简单了,但是也会带来一个问题,那就是原型对象丢失了 constructor 成员。

所以,我们为了保持 constructor 的指向正确,建议的写法是:

function Person (name, age) {
  this.name = name
  this.age = age
}

Person.prototype = {
  constructor: Person, // => 手动将 constructor 指向正确的构造函数
  type: 'human',
  sayHello: function () {
    console.log('我叫' + this.name + ',我今年' + this.age + '岁了')
  }
}
6.原生对象的原型

所有函数都有 prototype 属性对象。

  • Object.prototype
  • Function.prototype
  • Array.prototype
  • String.prototype
  • Number.prototype
  • Date.prototype
  • ...
7.原型对象使用建议
  • 私有成员(一般就是非函数成员)放到构造函数中
  • 共享成员(一般就是函数)放到原型对象中
  • 如果重置了 prototype 记得修正 constructor 的指向

你可能感兴趣的:(JS的原型和原型链)