JS原型的理解

关于 JS 的原型和原型链,其实这个概念在我的心中,有个大概的形状,但是总是不能够很清楚的描述出来,当别人问我到底什么是原型,我在想我该怎么回答,所以就整理了这篇文章。
说原型之前,我们需要明确一个概念,在 JS 中有一个概念:万物皆对象。任何复杂数据类型,都可以最终抽象为Object,包括函数,他是一个特殊的对象。

方向

  1. 什么是 prototype
  2. 如何使用 prototype

什么是 prototype

我们先来看一个例子

  1. console.log(Object.prototype);
  2. console.log(Array.prototype);
  3. console.log(Boolean.prototype);
  4. console.log(Function.prototype);
  5. console.log(String.prototype);
  6. console.log(Date.prototype);
  7. console.log(Number.prototype);
  8. console.log(Error.prototype);
  9. console.log(RegExp.prototype);
  10. console.log(Promise.prototype);
  11. console.log(Symbol.prototype);
  12. console.log(Map.prototype);
  13. console.log(Set.prototype);
  14. // ...

15.JS原型的理解 - 图1

在这里我们就能大概的感觉 prototype 的模糊概念。其实 prototypeJS 实现面向对象的一个重要机制。上面的都是函数,在 JS 中本质上也是对象(Function),函数对象都有一个子对象:prototype 对象。在 JS 中类是以函数进行定义的。prototype 表示该函数的原型,也表示一个类的成员的集合。

一句话概括:JavaScript 中,每一个 javascript 对象(除 null 外)创建的时候,就会与之关联另一个对象,即每个函数都有一个 prototype 属性,这个属性指向函数的原型对象

例如:

  1. function Person(name) {
  2. this.name = name;
  3. }
  4. Person.prototype.sayName = function() {
  5. console.log(this.name);
  6. };
  7. const Tom = new Person("tom");
  8. const Jack = new Person("jack");
  9. Tom.sayName(); // tom
  10. Jack.sayName(); // jack

上述例子,Person 是一个构造函数,TomJack 是具体的实例函数对象。我们使用 new 创建出来的都是实例对象。而实例对象就是根据原型 prototype 这个模板,被生产出的属于自己特色的东西。
我们在阐述比较复杂的概念,还是先解释一下构造函数和实例原型之间的关系

15.JS原型的理解 - 图2

在这里,我们可以看见两个关键词,构造函数与实例原型,其实构造函数都比较容易理解,实例原型其实按照字面理解就是Person 创建的实例的原型,他是由 Person 创建的实例对象的原型。

但是这是从构造函数上的属性(即:Person.prototype),那么在真正创建的实例对象上面,也能使用 Tom.prototype 进行访问么?

通过实践,我们能够清楚的知道使用上述Tom.prototype是不行的,但是通过Tom.__proto__却可以访问到。

那么,我们就要引出另外一个关键词,历史的遗留产物:__proto__

那么 prototype__proto__之间有什么区别和联系呢?我们可以再打印两个东西

  1. console.log(Person.prototype);
  2. console.log(Person.__proto__);
  3. console.log(Tom.prototype);
  4. console.log(Tom.__proto__);

15.JS原型的理解 - 图3

那么,再结合:
15.JS原型的理解 - 图4

结合上图,再结合下列的打印:

  1. console.log(Tom.__proto__ === Person.prototype); // true

到这里,我们可以进行一个总结:
在实例对象中使用__proto__,它相当于一个对外暴露的访问器,相当于settergetter可以方便的访问到当前实例对象的实例原型。我们从浏览器打印 Person.prototype 的打印结果:

  1. sayName: ƒ ()
  2. constructor: ƒ Person(name)
  3. __proto__: Object

我们可以看到 Person.prototype 中也有一个__proto__,其实这里我们可以这样理解。
JS 中允许这样一个事实:

  1. 所有的对象都含有__proto__constructor
  2. prototype 属性是函数所独有的。

但在 JS 中函数也是一种对象,所以也拥有__proto__constructor 属性。
结合上述的阐述,解释构造函数 Person
Person 相对于 Tom 来说,Person 是构造函数,Tom 是实例对象。
Person 相对于 Function 来说,Function 是构造函数,Person 是实例对象,但是这个实例对象是一个函数。
Function 相对于 Object 来说,Object 是构造函数,Function 是实例对象,但是这个实例对象是一个函数。

这种说法的准确性还有待商榷,但是表达的还是一个意思:如果实例对象是一个函数实例对象,可以用来生产再创造,那么他就有原型,如果产生的实例对象无法进行再创造,那么相对的来说实例对象就是私有的,独立的,个体性质的,所以没有原型,所以找原型也是找他的父函数的(创造他的构造函数)原型,通过构造函数创建的子实例对象继承了父函数的一些性质,所以子实例对象也是继承了父函数的原型,通过proto来访问

15.JS原型的理解 - 图5

这张图简单的解释就是:
它只解释了构造函数 Person 与实例对象 person1person2(可以理解为 TomJack)之间的关系。
当我们在构造函数中,去访问构造函数的原型,实际上就是类似于这种地址的引用,将保存 prototype 的地址就引用自 Person Prototype 这个地址块。也就可以理解成 prototype 就是类似于一个指针,由Person Prototype -> Person
在创建的实例对象中,也提供了一个简便访问原型的属性就是,__proto__,他也类似一个指针由Person Prototype -> 实例对象
在原型中,还有一个属性是 constructor,他保存的是 Person这个构造函数的主体。由Person Prototype constructor -> Person

在这里,还要明确几个问题

  1. 创建的实例对象都是独立的么?

    创建的实例对象都是独立的,没有联系的,他们共享从父函数继承而来的属性和方法。

  1. 创建的实例对象通过__proto__访问的原型是谁的?

    创建的实例对象通过proto访问的原型是父函数的(Person)。

  1. 通过构造函数创建的实例对象有什么特点?

    他会继承来自构造函数的方法和属性,当方法和属性有重名时,优先使用自身的。

说到这里,再深入一步
15.JS原型的理解 - 图6

这个图就比较复杂了,但是也很好理解。他就是上面所有东西的总结。当然还提出了一个新的概念:原型链
简单的说,就是当原型不断的向上溯源,这个过程是链式的,所以就有了原型链。
需要注意的一点就是:所有对象的原型,祖先就是 Object.prototype,当在向上查找时就是 null,所以在JS的基本数据类型中Null没有原型。

如何使用原型

了解了原型的概念,使用原型就能够干什么,我们需要总结一下

原型实例化

这也是我们上述说到的构造函数的形式。因为在我们的应用程序中,我们可能需要创建多个类似 Person 的实例,有 LucytomJack 等等。而这些实例对象使用构函数上的原型提供的方法,这就是原型实例化模式,将函数本身称为“构造函数”,它负责构造一个个新的实例对象。

  1. class Person {
  2. constructor(name, age) {
  3. // const this = Object.create(Person.prototype) 将原型传递给this
  4. this.name = name;
  5. this.age = age;
  6. // return this // 默认将this进行返回
  7. }
  8. sayName() {
  9. console.log(`Hello ${this.name} !`);
  10. }
  11. sayAge() {
  12. console.log(`your age is ${this.age}`);
  13. }
  14. toString() {
  15. this.age += 1;
  16. console.log(this.age);
  17. }
  18. }
  19. const Tom = new Person("tom", 18);
  20. const Jack = new Person("jack", 18);
  21. Tom.sayName(); // Hello tom !
  22. Jack.sayName(); // Hello jack !
  23. Tom.toString(); // 19

使用这种方式,我们创建出来的实例对象可以使用原型上的方法。避免了我们使用大量重复的方法干同样的事情,同时可以对原型上的方法进行重写,比如我们重写了 toString 方法。在上面的代码中,我们还注释了两行代码,其实那个就是构造函数内部做的事情,其中 Object.create 的含义就是:

Object.create(proto, [propertiesObject])
方法创建一个新对象,使用现有的对象来提供新创建的对象的 proto。
proto : 必须。表示新建对象的原型对象,即该参数会被赋值到目标对象(即新对象,或说是最后返回的对象)的原型上。该参数可以是 null, 对象, 函数的 prototype 属性 (创建空的对象时需传 null , 否则会抛出 TypeError 异常)。
propertiesObject : 可选。 添加到新创建对象的可枚举属性(即其自身的属性,而不是原型链上的枚举属性)对象的属性描述符以及相应的属性名称。这些属性对应 Object.defineProperties()的第二个参数。
返回值:在指定原型对象上添加新属性后的对象。

原型实例化有几个好处:

  1. 在原型上扩展方法,节省内存。
  2. 方便我们创建实例对象。
  3. 方便我们实现继承。

直接使用 JS 内置对象上原型的方法

  1. Object
  2. Function
  3. Array
  4. String
  5. RegExp
  6. Date
  7. Number
  8. Boolean
  9. Symbol

上述的标准内置对象中,都包含了原型上的方法的使用

继承

其实继承不是本文章的重点,网上也有很多关于实现继承的方式,大概上来说也就是那么几种,通过内存占用,传递参数,复杂程度等角度考虑去实现继承,其目的就是为了让子对象能够使用父对象的属性或方法。
比如:

  1. JS 实现继承的 6 种方式
  2. javascript 面向对象(实现继承的几种方式)
  3. Js 实现继承的几种方式

上面的博客文章都已经对继承讲解的比较详细,如果感兴趣可以看看。

ES6关键字extends

在上面,我已经贴了很多的文章关于实现继承的方式。但是在这里还是要讲一下关于ES6实现继承的一些原理

  1. class Person {
  2. constructor(name, age) {
  3. this.name = name
  4. this.age = age
  5. }
  6. }
  7. class sayName extends Person {
  8. constructor(name, age, grade) {
  9. this.name = name
  10. this.age = age
  11. this.grade = grade
  12. }
  13. sayGrade(){
  14. console.log(`The grade is ${this.grade}`)
  15. }
  16. }

我们可以使用babel + babel-preset-es2015-loose进行转义:

  1. function _inheritsLoose(subClass, superClass) {
  2. subClass.prototype = Object.create(superClass.prototype)
  3. subClass.prototype.constructor = subClass
  4. subClass.__proto__ = superClass
  5. }
  6. var Person = function(name, age) {
  7. this.name = name
  8. this.age = age
  9. }
  10. var Student = (function(_Person) {
  11. _inheritsLoose(Student, _Person)
  12. function Student(name, age, grade) {
  13. var _this
  14. // 组合继承
  15. _this = _Person.call(this, name, age) || this
  16. _this.grade = grade
  17. return _this
  18. }
  19. var _proto = Student.prototype
  20. _proto.sayGrade = function sayGrade() {
  21. // console.log()
  22. }
  23. return Student
  24. })(Person)

在这个里面,我们就能够清楚看见:整个ES6extends实现的是原型继承+组合继承

与原型相关的操作符

new

老是有人会问执行new操作符到底干了什么,我们可以从代码的角度去认识。

  1. function isObject(value) {
  2. const type = typeof value
  3. return value !== null &&(type === 'object' || type === 'function')
  4. }
  5. /*
  6. constructor: 表示new的构造器
  7. args: 表示给构造器传递的参数
  8. */
  9. function New(constructor, ...args) {
  10. // new对象如果不是函数就会抛出异常
  11. if(typeof constructor !== 'function') throw new TypeError(`${constructor} is not a constructor`)
  12. // 创建一个target用于接收当前构造函数构造器的原型,在默认的情况下就是this, 这里使用target也是同样的意思
  13. const target = Object.create(constructor.prototype)
  14. // 将构造器的this指向上一步创建的空对象并执行,为了给this添加实例属性
  15. const result = constructor.apply(target, args)
  16. // 检查返回的结果是否为对象
  17. return isObject(result) ? result : target
  18. }

instanceof

这个操作符用于判断对象是否是某个类的实例,它的原理就是比较简单,就是通过比较两者的原型(也可以是多级原型)是否相等。

  1. function isObject(value) {
  2. const type = typeof value
  3. return value !== null &&(type === 'object' || type === 'function')
  4. }
  5. function instanceOf(object, constructor) {
  6. if (!isObject(constructor)) {
  7. throw new TypeError(`Right-hand side of 'instanceof' is not an object`);
  8. } else if (typeof constructor !== 'function') {
  9. throw new TypeError(`Right-hand side of 'instanceof' is not callable`);
  10. }
  11. // 关键
  12. return constructor.prototype.isPrototypeOf(object);
  13. }

需要注意的点

  1. 箭头函数无法作为构造函数使用,同时和 new 搭配会发生错误,原因就是箭头函数内部没有 this
  2. 箭头函数没有 prototype 属性。
  3. 创建的实例对象不一定需要使用 new 才能使用 prototype 上的方法。我们可以使用 callapplybind 通过传递 this 使用原型上的方法。
  4. 使用 Object.getPrototypeOf 获取实例对象的原型。也意味着 Object.getPrototypeOf(Tom) === Person.prototype (Person 是构造函数,Tom 是根据 Person 创建出来的实例对象)。
  5. 我们可以使用 for in 枚举原型上是否包含某一个属性。比如 for (let key in Tom),此种方法会打印原型以及实例对象上的所有可枚举对象。
  6. 我们使用 hasOwnProperty 可以帮助我们枚举创建的实例对象上的属性。
  7. 可以使用 instanceof 检查对象是否是类的实例。Tom instanceof Person

总结

总的来说,其实原型,原型链。本质上当我们使用到 prototype,他就是一个对象罢了,没什么复杂的,他们被提出来都是为了解决一些实际性的问题,为了方便使用 JS 面向对象,其实这里也是形似,在这里 JS 是都能够真的如同 Java 一样面向对象么?值得我们深思。我们更重要的是学习 JS 中面向对象编程的思想。如何去封装,去封装私有化,去继承,这个值得我们去学习和掌握。当然还有原型污染的问题,有兴趣的可以了解一下。


【参考资料】

  1. 彻底理解什么是原型链,prototype 和proto的区别以及 es5 中的继承
  2. 一篇文章看懂proto和 prototype 的关系及区别
  3. javascript——原型与原型链
  4. 彻底理解什么是原型链,prototype 和proto的区别。
  5. 搞懂 JS 原型及作用
  6. 讲清楚 JavaScript 原型