16.JS高级-彻底攻克原型链(详解)

1,339 阅读1小时+

该系列文章连载于公众号coderwhy和掘金XiaoYu2002中

  • 对该系列知识感兴趣和想要一起交流的可以添加wx:XiaoYu2002-AI,回复进群,拉你进群参与共学计划,一起成长进步
  • 课程对照进度:JavaScript高级系列69-83集(coderwhy)
  • 后续JavaScript高级知识技术会持续更新,如果喜欢我们的文章,欢迎关注、点赞、转发、评论,大家的支持是我们最大的动力

脉络探索

  • 原型链一直是JS当中一个难点,很少有视频和文章详细完整的讲述过,而在本章节中,我们会一步步的进行掌握
    • 从原型链的核心继承开始,一步步探索Object的原型,顶层原型来自哪里?构造函数的原型处于哪一个阶段?
    • 原型对象和原型属性的关系?为什么Object是所有类的父类?为什么需要继承和原型链?
    • 在JS的原型链发展历史中,都曾经出现过哪些惊艳的想法?是借用构造函数继承?还是原型式、寄生式继承函数?亦或者他们的结合版本:寄生组合式继承?
  • 在JS中,是继承成就了原型链,还是原型链成就了继承?在这一步步过程中,有多少方案被否定掉了,有多少想法被当作废稿,有多少努力方向是错误的,我想这是可以和大家来共同领略的风景,JS的发展史是如此迷人,前人的智慧是如此令人惊叹

一、内容补充(可枚举)

可枚举属性的补充

  • 我们使用Object.defineProperty设置可枚举为false,在node环境下是打印不出address地址的,但是在浏览器是会显式出来的,这是浏览器为了更加方便我们进行调试,所以把不可枚举的属性也展示出来了,在苹果电脑的谷歌浏览器中展示出来的不可枚举属性是会半透明显式的(提示开发者这是一个不可枚举的属性),但是在window电脑上的Edge浏览器中显式出来的不可枚举属性是没有半透明效果的,就正常显式,如图16-1
var obj = {
    name:"小余",
    age:20
}

Object.defineProperty(obj,"address",{
    enumerable:false,//设置是否可枚举,默认false 
    value:"福建省"
})

console.log(obj);

不可枚举属性半透明显示(浏览器控制台)

图16-1 不可枚举属性半透明显示(浏览器控制台)

二、JavaScript中的类与对象

在JS当中,通常能够听到各种各样的说法,有的人说没有类,有的人说有

  • 那到底有没有类?类又是什么?
  • 有没有准确的说法?
  • 不妨从代码中来寻找答案,当我们编写如下代码的时候,我们会如何来称呼这个Person呢?
function Person(){
    
}
var p1 = new Person()//通过了new调用,Person变为构造函数。生成新对象,由p1接收
var p2 = new Person()//但也可以称为 类,在ES6之后开始可以使用class去定义,但本质上还是通过原型、原型链 面向对象封装、继承,class它只是一个语法糖而已
  • 在JS中Person这种通过new调用的函数应该被称之为是一个构造函数,而构造函数能够生成一个新的对象(比如p1、p2)
  • 而从很多面向对象语言过来的开发者,更习惯称之为类,而不是构造函数,因为在其他面向对象语言中,类也可以帮助我们创建出来对象p1、p2
  • 如果从面向对象的编程范式角度来看,Person确实是可以称之为类的
//在Java中,类的写法
class Person {
  
}

Person p1 = new Person()
  • 那在JS中,这写法中,p1、p2是不是一个类呢?
    • 从严格意义上来说,他们并不是类,特别是在早期的JS当中,连类这个概念都没有
    • 更准确的说法是:类似于一个。早期的这说法,更多的是来源于JS的历史原因,也就是初期很多开发者之前都是学习Java这门面向对象语言的,所以才会这样称呼下来
    • 在ES6之后,做出了更明确的区分,有了语法糖class的写法,这在后续我们学习ES6系列语法中会进行详细学习

2.1 语法糖

语法糖(Syntactic sugar),也译为糖衣语法,是由英国计算机科学家彼得·约翰·兰达(Peter J. Landin)发明的一个术语。它指的是计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是在不改变其所在位置的语法结构的前提下,让编写程序更加方便,从而提高开发编码的效率

  • “语法糖”这个名字之所以被采用,是因为它形象地描述了这种语法特性给编程带来的便利和愉悦感。就像有些药物外面那层糖衣一样,虽然它本身并没有营养价值(即不改变语言的功能),但是能够给人带来愉悦的感觉(即提高编程的便捷性和可读性)。同时,“语法糖”这个词也具有一定的幽默感,让编程这个相对严肃的话题变得更加轻松有趣
  • 语法糖也凸显了编程语言设计者对于提高编程效率、降低编程门槛的考虑

三、面向对象特性-继承

  • 面向对象有三大特性:封装、继承、多态
    • 封装:我们前面将属性和方法封装到一个类中,可以称之为封装的过程
    • 继承:继承是面向对象中非常重要的,不仅仅可以减少重复代码的数量,也是多态前提(纯面向对象中)
    • 多态:不同的对象在执行时表现出不同的形态
  • 有些人认为面向对象的特性还有抽象,抽象是一种将复杂现实简化为模型的方法。在JavaScript中抽象意味着隐藏复杂性,是通过使用函数、类或对象字面量来隐藏具体实现细节,仅暴露必要的接口与功能

3.1 为什么主要讲解继承

  • 因为原型链是JavaScript中对象之间通过原型相互链接形成的链式结构,而继承则是利用这种链式结构来实现属性和方法的共享与复用
    • 也就是说继承是基于原型链而实现的效果,这在JS当中也是体现最多的地方之一,和本章节所学的内容也有着紧密的关系
    • 而继承的另一特性:多态的前提,则是很少体现。因为JS当中的多态较为灵活,处于一个可有可无的状态
  • 那么继承是做什么呢?
    • 继承可以帮助我们将重复的代码和逻辑抽取到父类中,子类只需要直接继承过来使用即可。这个父子关系,我们后续会详细讲解,在很多地方都会遇到
    • 通俗的来说就是重复利用一些代码(对代码的复用)
  • JavaScript当中要如何实现继承呢?
    • 不着急,我们需要先来看一下JavaScript原型链的机制,正如前面所说,继承是基于原型链的。如果跳过原型链去学继承,就会出现根基缺失的问题
    • 学习了原型链,再利用原型链的机制实现一下继承

3.2 JavaScript中的原型链

在真正实现继承之前,我们先来理解一个非常重要的概念:原型链。

  • 我们知道,从一个对象上获取属性,如果在当前对象中没有获取到就会去它的原型上面获取,如图16-2:

从原型链中获取属性

图16-2 从原型链中获取属性

  • 这个查找机制是通过[[GET]]来实现的,我们之前说过,通过[[]]括起来的内容,表示内部机制的内容,这是JS内在运行的原理之一,而[[GET]]也不例外

    • 而在此时,我们寻找属性,就是通过他来实现的。而这个查找的规则就显得尤为重要了,这对于我们掌握其数据流动脉络有着非常关键的作用

    1. 在当前对象上查找

    2. 找不到则沿着原型链查找

    3. 如果未找到,则返回undefined

    • 这个原型在控制台中,就通常显式为[[Prototype]],当然我们也可以在开发的时候,简单的用__proto__来进行测试,这个区别在前面章节已经进行说明
    • 很多时候,我们打开这个原型会发现内部非常多的内容,都是继承而来的,如图16-3

    对象的原型属性

    图16-3 对象的原型属性

var obj = {
    name:"小余",
    age:20
}
obj.__proto__ = {

}
obj.__proto__.__proto__ = {

}
obj.__proto__.__proto__.__proto__ = {
    address:"福建省"
}
//[[get]]操作
//1.在当前的对象里面查找属性
//2.如果没有找到,这个时候会去原型对象[[__proto__]]上查找
console.log(obj.address); 
  • 这个原型一个紧扣一个,形成一整串的链条,这完整的内容,我们就称为原型链,如图16-4
    • 这里面有很多的方法,基本上除了__xxx__之外,其他的方法都可以在MDN中找到对应的内容,这里面的方法也基本上都是为原型链所服务
    • 我们能够看到学习过的constructor构造函数,hasOwnProperty判断自有属性是否有对应的属性。其他内容也具备类似性质

环环相扣的原型

图16-4 环环相扣的原型

  • ECMAScript 2022,hasOwnProperty方法已经被hasOwn方法所替代,但使用规则是一样的

    • 由于 hasOwnPropertyObject.prototype 上的方法,它可以被继承对象覆盖,这可能导致意外行为,这个风险问题我们在往prototype身上添加方法的时候也有说过
    • Object.hasOwn() 作为静态方法,不受继承影响,提供了更安全的方式来检查属性,如图16-5
  • 这其实侧面的说明学习继承的重要性,如果不能够掌握,我们就不能够理解这一类改变意味着什么

    • 在这里,静态方法意味着是与构造函数直接关联的,不依赖于任何实例
    • 静态方法也可以被子类继承,但它们是作为构造函数的一部分被继承的,子类可以通过 super 调用父类的静态方法
    // 构造函数
    function Person(name) {
      this.name = name;
    }
    
    // 实例方法的定义
    Person.prototype.greet = function() {
      console.log(`Hello, my name is ${this.name}!`);
    };
    
    // 使用实例方法
    const person1 = new Person('Alice');
    person1.greet(); // 输出: Hello, my name is Alice!
    
    // 静态方法的定义
    Person.staticGreet = function() {
      console.log('Hello, this is a static method!');
    };
    
    // 使用静态方法
    Person.staticGreet(); // 输出: Hello, this is a static method!
    

MDN文档中的hasOwn方法

图16-5 MDN文档中的hasOwn方法

  • 根据Can I use的查找确认,如图16-6,兼容适配度是很高的
    • 94.03%和97%的差距已经变得微小,可以放心的去使用

hasOwn方法与hasOwnProperty方法的兼容性

图16-6 hasOwn方法与hasOwnProperty方法的兼容性

3.3 Object的原型

那么什么地方是原型链的尽头呢?比如第三个对象是否也是有原型__proto__属性呢?

  • 在我们刚才控制台简单打印中,发现了第二个对象的__proto__就已经是null了,没有继续下去,也就是没有第三层第三个对象了,如图16-7

__proto__的尽头

图16-7 __proto__的尽头

console.log(obj.__proto__.__proto__);//第二层就为null了

//到底是找到哪一层对象之后停止继续查找了呢?
console.log(obj.__proto__.__proto__.__proto__.__proto__);//[Object: null prototype] {}
//如果每个原型后面还有原型,那不就无穷无尽吗?但显然是不可能的,原型链的尽头就是Object原型,位于我们__proto__的下一层,你本身自带一个__proto__,在prototype里面,这个__proto__打开是js替我们实现的原型,再下一层就是Object的原型了,也就是最后一层,但是如果你在自身的身上继续叠加__proto__的话,那原型链的尽头就会在这个基础上继续加,加深几层取决于我们又套了几层__proto__
  • 在上面的代码案例中,发现打印的是 [Object: null prototype] {}

    • 事实上这个原型就是我们最顶层的原型了
    • 从Object直接创建出来的对象的原型都是 [Object: null prototype] {}
  • 那么我们可能会有问题: [Object: null prototype] {} 原型有什么特殊吗?

    • 特殊一:该对象有原型属性,但是它的原型属性已经指向的是null,也就是已经是顶层原型了
    • 特殊二:该对象上有很多默认的属性和方法
  • 原型链的尽头是由 null 表示的,当这个内部属性的值为 null 时,就意味着没有进一步的原型可以被访问,因此这是原型链的终点

    • 所以在正常的情况下,原型是 Object.prototype(Object原型),而 Object.prototype 的原型是 null
    • 这也对应了我们在控制台中的内容
  • 我们其实需要思考,为什么JS要这么做,为什么正常情况下只做到第二层就不继续下去了?

    1. 避免无限循环,如果无限的循环就会产生无限的遍历原型链,会卡死在这里

    2. 而且如果原型链很长,每次属性访问都可能需要遍历整个链,这会明显的降低性能。通过设置终点,JS引擎可以更快地确定一个属性是否在原型链上。ES13语法的hasOwn方法就是直接静态方法,放在构造函数身上,直接从根源上避免了这个问题,因而做到性能的提升

    3. 还有简洁性、避免命名冲突、安全性等多方面问题

    • 所以通过这些因素,原型链是能越短越好,而正常情况做到第二层就不继续下去了,则就是在正常情况下,到第二层就是保持原型链特性下,最精简的状态。其他的各种情况则就是根据真实需求去动态的进行添加原型链层数
    • 但通过思考这一层面,我们能够知道,原型链并不是越多层越好,而是越适合实际情况越好

3.3.1 Person构造函数原型

  • 我们创建对象通常有这两种方式,第一种方式是第二种方式的语法糖
    • 而我们前面刚说过,语法糖是不改变结构本质的,所以这两种创建方式本质是一样的
var obj1 = {}//创建一个对象
var obj2 = new Object()//创建一个对象
  • 那我们就可以来研究一下,这个层次是如何产生的,首先我们知道函数也是一种特殊的对象,而使用 new 关键字调用的函数被称为构造函数
    • 我们就以这为基础来进行分析,分析这个过程中发生了什么
function Person (){

}

var p = new Person()
console.log(p);
  1. 首先会在内存中创建一个对象,也就是p
  2. 将Person函数的显式原型prototype赋值给p对象的隐式原型,因为首先只有函数才有显式原型,其次p是因Person函数new调用而来的,所以p的初始基础内容来自Person
  3. Person.prototype指向了p的隐式原型,而p本身是一个对象,和直接new出来的对象本质是一样的,所以p的隐式原型直接指向于Object.prototype,如图16-8

原型链在对象中的表达形式

图16-8 原型链在对象中的层级表达

  • 基于此分析,我们可以明确知道从p出发的话

    1. p是一个对象,是一个Object,这和我们直接new出来一个对象是一样的,所以p本身具备一个指向原型的内部链接 [[Prototype]],而新对象p通过new调用的对象来自Person函数,所以p的 [[Prototype]] 链接默认指向 Person.prototype,而Person函数和普通对象一样,直接指向于Object.prototype

    2. 在这里其实有可能会有让人迷惑的地方,在第二层和第三层中,其实是同时拥有__proto__[[prototype]]

      所以假设__proto__为A,[[prototype]]为B

      就有很多种表述方式:

      完整的:B -> (B -> A) -> (B -> A) -> A = null

      A为主:B -> A -> A -> A = null

      B为主:B -> B -> B -> A = null

    3. 在描述原型链时,我们通常不会重复相同的层级,所以完整的表述方式在二选一的地方用括号所标注了出来。本身只是一种简化的表述方式,但意义是一样的

    • 在我们这里,则是以B,也就是prototype为主,因为在正规的表达中,__proto__是非标准的
    • 而最终的A,也就是__proto__就为null,也就是原型链的终点,A = null,我们就直接简写为null了,最终形成的就是下面这种指向,如图16-9
//正常创建的对象
obj1 (字面量对象) ---> Object.prototype ---> null
//通过new调用的对象p
p (Person 的实例) ---> Person.prototype ---> Object.prototype ---> null
B -> B -> B -> null

对象原型链的详细表达

图16-9 对象原型链的详细表达

  • 所以让我们总结一下
    • 第一层 "B"pPerson 的一个实例,p 自身有一个 [[Prototype]] 链接,这是第一层 "B"
    • 第二层 "B":通过第一层的 [[Prototype]] 链接,我们找到 p 的原型,即 Person.prototype,这是第二层 "B"
    • 第三层 "B"Person.prototype 也有自己的 [[Prototype]] 链接,它指向 Object.prototype,这是第三层 "B"
    • 末端 "NULL"Object.prototype[[Prototype]] 链接指向 null,表示原型链的结束
  • 所以,在new调用的时候,这过程发生了什么,已经无法难倒我们了
    • 需要注意,new调用的主体是谁?是Object还是函数
    • 如果是Object,则原型链中的下一层就直接是Object.prototype
    • 如果是Person之类的函数,则原型链中表达就先为Person.prototype,下一层才为Object.prototype
//创建了一个对象
var obj = {}
//创建了一个对象,相当于obj对象字面量的语法糖
var obj2 = new Object()//obj2.__proto__ = Object.prototype

function Person(){

}

//new出构造函数这个操作发生的步骤:
//1.在内存中创建一个对象,var moni = {}
//2.this的赋值,this = moni
//3.将Person函数的显示原型prototype赋值给前面创建出来的对象的隐式原型,moni.__proto__=Person.prototype
//4.执行代码体
//5.返回这个对象
var p = new Person()

//但是当我们在使用语法糖new Object()的时候,赋值的情况如下:
//var moni = {}
//this = moni
//moni.__proto__ = Object.prototype
//obj2.__proto__ = Object.prototype
//那这时候就证明了一点,obj.__protot__ === Object.prototype
  • 通过我们的比对,能够印证,obj的隐式原型就是指向于Object原型
    • 在内存中表达的位置是来自同一个地方,而不是创建出来两个一样的内容,因为我们采用的是全等(===)对比
var obj = {
    name:"xiaoyu",
    age:18
}
//obj.__proto__等价于Object.prototype	Object是所有类的父类 
console.log(obj.__proto__)//[Object: null prototype] {}
console.log(Object.prototype);//[Object: null prototype] {}
console.log(obj.__proto__ === Object.prototype);//true

3.3.2 Object顶层原型来自哪里

  • 而这个Object其实是很有意思的

Object内置对象为构造函数

图16-10 Object内置对象为构造函数

  • 通过我们的打印和编辑器提示,可以看到Object居然是一个构造函数,如图16-10
    • 而既然是一个函数,就必然有自己的显式原型:Object.prototype
    • Object.prototype里面的东西是很多的,只是因为都是不可枚举的,所以在node环境下打印是看不见的,但是可以打印在浏览器的控制台,这里是能看见的,就刚刚说的那样,浏览器为了方便开发者调试,所以会将不可枚举的属性也显示出来
    • 通过getOwnPropertyDescriptors方法,我们可以将对象原型所有的属性描述符打印出来,包括不可枚举的部分
//我们发现Object的原型对象身上也是有构造函数constructor的
console.log(Object.constructor);//[Function: Function]
//打印出Object的原型对象身上的所有属性,记得加上s 别打错啦
console.log(Object.getOwnPropertyDescriptors(Object.prototype))
//打印对象身上显式原型的隐式原型
console.log(Object.prototype.__proto__);//null,因为Object身上的原型已经是最后一层了,属于最顶层的原型,继续往下找只有null

对象与构造函数的关系

图16-11 对象与构造函数的关系

3.3.3 创建Object对象的内存图

  • [Function: Object]表示这是一个函数,[类型:名称],一个名称为Object的函数,而这个函数是一个构造函数,用来创建对象
    • 每个函数的原型对象都有一个 constructor 属性,所以Object.prototype.constructor 应该指向 Object 构造函数本身
    • 让我们来印证图16-11中的这一点,看是否如此
    • 通过代码的验证,这确实是相互指向的,根据我们所学的内容,我们知道这会产生无限的互相指向循环问题。而JS官方是如何解决的呢?
    • 这个解决方案其实早就告诉我们了,通过最终指向于null,终止了循环,而且我们并不是通过 constructor 属性进行潜在的循环引用,而是明确的方法来遍历原型链,比如Object.getPrototypeOf()
console.log(Object === Object.prototype.constructor);//true

obj.__proto__ = obj2的内存表达

图16-12 obj.__proto__ = obj2的内存表达

  • 我们再添加一个obj2对象,并赋值到obj的隐式原型身上,如图16-12
    • 在内存中,这其实是发生了指向内容的转变
    • obj作为一个普通对象,其隐式原型默认指向于Object.prototype,但我们将其改变,使其指向于obj2对象
    • 但obj2对象本身也是一个普通对象,所以最终还是指回了Object.prototype,只是在这个过程中,多了obj2对象这一层
var obj = {
  name:"XiaoYu",
  age:18
}

var obj2 = {
  address:"福建省"
}
obj.__proto__ = obj2

3.4 原型链关系的内存图

  • 最后,综合前面的所学内容,我们来看下完整的内存指向图
    • 到目前位置,最顶层的原型就来自Object.prototype,因为再接下去就是null了,也就是原型链的终点
    • 从最顶层出发,Object和Object.prototype其实通过[[prototype]]和constructor相互引用
    • 紧接着是普通对象(字面量和new)直接指向于Object.prototype
    • 通过改变__proto__指向其他地方的,最终正如下图16-13右边所示,指向多层隐式原型后,最终也会指回Object.prototype

完整的原型链关系内存图

图16-13 完整的原型链关系内存图

3.4.1 原型对象跟原型属性的关系

JavaScript 中所有对象都是通过构造函数创建的,并且对象都有一个隐式的属性,称为原型 (prototype)。每一个构造函数都有一个prototype属性,这个属性指向一个对象,这个对象也叫原型对象,继承了一些属性和方法

每当一个新对象被创建,它的内部指针就会指向它的构造函数的原型对象。这意味着如果我们在原型对象上修改了一个属性或方法,那么所有继承了这个原型对象的对象都会受到影响

// 定义一个构造函数Person
function Person(name) {
 this.name = name;
}

// 添加一个原型属性 sayName
Person.prototype.sayName = function() {
 console.log(`My name is ${this.name}`);
}

// 创建一个对象 person1
let person1 = new Person("XiaoYu");
console.log(person1.name); // "XiaoYu"
console.log(person1.sayName); // "My name is XiaoYu"

// 修改原型上的属性,所有继承原型的对象会受影响
Person.prototype.sayName = function() {
 console.log(`Hello, My name is ${this.name}`);
}
console.log(person1.sayName); // "Hello, My name is XiaoYu"

可以看出构造函数的原型对象的属性是被所有继承了它的对象共享的,而对象的属性是被对象本身独享的

  • 原型属性可以用来实现继承

3.5 Object是所有类的父类

  • 从我们上面的Object原型我们可以得出一个结论:原型链最顶层的原型对象就是Object的原型对象
    • 也就是Object.prototype,在往上就是null(原型链终点)了

3.5.1 Person原型对象指向顶层对象

  • 在这下面案例中,存在了三种角色:Person构造函数p1实例running方法
    • 如果死记硬背其中的结果,会导致理解难度提升到一个很高的程度,这也是我们前面不断结合内存图来进行理解的原因,需要掌握其顺序流动的规则
    • 通常在学习的时候,想要确定一条路径,需要有起点和终点。在我们学习原型链当中,起点是不固定的,因场景而变。但终点是固定的,是Object的原型
    • 所以其实难点在于判断这些代码中,哪里才是起点。就像一个毛线球一样,只要找到线头,后面就迎刃而解
    • 在我们这个案例中,线头是p1实例Person构造函数这两个角色,沿着这两个角色可以找到Person原型,而我们的running方法,也就是第三个角色就在Person原型这里等着我们,继续往后则就会找到Object原型,也意味着找到了原型链的顶层(原型对象指向顶层对象),可以结束了,如图16-14
  • 这个其实很有意思,就像是交朋友一样,当我们为了交朋友而去交朋友,就很容易会失去初心方向。正确的方式应该是沿着我们理念的道路去行走,朋友都在这条路上等着我们,等走到了这条道路的终点后,回头一看,想要寻找的朋友都已经在了
    • 原型链也一样,记住这个路径。所有的公共方法、属性等等,都在这条路上,走过去,自然会遇上,而不需要我们费尽心思去专门找
//Person原型指向顶层对象
function Person(name,age){
    this.name = name
    this.age = age
}

Person.prototype.running = function(){
    console.log(this.name+"running")
}
var p1 = new Person("why",18)
console.log(p1)
console.log(p1.valueOf())
console.log(p1.toString())

Person原型对象指向顶层对象

图16-14 Person原型对象指向顶层对象

3.5.2 为什么需要继承与原型链

JavaScript的继承是基于原型的,而不是类基的。每个对象都有一个原型对象,对象从其原型继承属性和方法。这种模型相对于传统的类继承模型来说,更加灵活和动态。在JS中,可以在运行时修改原型,从而改变一个类的行为,这在类基的语言中通常不支持或者不易实现

  • JS官方当初设计继承机制,主要是出于实现简单、灵活且高效的编程模型的需求。原型链提供了一种机制,让对象可以在不定义类的情况下实现继承,这符合JS快速、动态的设计目标。同时,这种继承模型支持了高度的代码复用和动态修改,适应了Web开发快速变化的需求
    • 所以需要理解JS的设计理念和哲学:简化Web开发,提高开发效率,很多的做法都是依据该点而进行考虑的
  • 在讨论需要继承的时候,同时也会涉及到为什么需要原型链?
    • 在JS早期版本中,为了让开发者能够创建可重用的组件和更丰富的交互式行为,提供一种机制来实现对象间代码的重用成为了一个关键需求。而原型链就是因这需求和产生的产物
    • 而原型链提供了相对简单的方法来实现继承,这和JS的整体设计哲学一致,即提供高效、易用的编程工具
  • 在理解原型链之后,我们会产生一个疑问:是为了实现继承而做出原型链,还是说继承是实现原型链之后才打算做出来的产物?
    • 继承作为面向对象编程中的一个核心概念,其目的是促进代码的重用和新对象的快速生成。JS的设计者意识到,通过允许对象访问共享的属性和方法,可以大大减少代码冗余并提高效率
  • 因为原型链涉及到一个关键的需求:实现对象间代码的重用,所以很容易让人认为是为了实现继承的铺垫,但确实如此吗?

3.5.3 原型链与继承的实现

通过将一个对象的原型(prototype)设置为另一个对象的实例,JavaScript允许继承的发生。这种基于原型的继承是原型链存在的直接结果,而原型链的实现又是为了支持这种类型的继承

  • 在JavaScript的设计和发展中,原型链的引入和继承的需求是相辅相成的。可以说,原型链是为了实现继承而设计的,而继承的需求又进一步推动了原型链机制的发展和完善。这不是一个单向的过程,而是一个互动的发展过程,其中原型链提供了继承机制的实现基础,而继承的概念则指导了原型链如何被构造和优化以满足实际的编程需求

3.6 通过原型链继承

继承就是将公共的代码,公共的逻辑抽取到一个父类里面

父类是公用的,子类用来处理独有的特殊逻辑

  • 体现在原型链中,则是越往顶层的原型对象,就越可能是父类,这是一个相对关系,根据具体情况而发生变化
  • 但最顶层的对象只有一个,所以它是所有类的父类,也是我们前面所说的Object原型
父类子类
公共属性和方法特有属性和方法

如果我们现在需要实现继承,那么就可以利用原型链来实现了:

  • 我们父类是Person(人),子类是Student(学生),这两个类现在本质上都是函数
    • 需要清晰我们的目的:我们new调用的对象是Student构造函数,也就是子类。返回的是stu对象,需要stu既继承Student(学生)的内容,又继承Person(人)的内容
//未实现继承的效果,打印出来效果为undefined
//父类,公共属性和方法
function Person(){
    this.name = "小余"
}

Person.prototype.eating = function(){
    console.log(this.name +"eating~");
}

//子类:特有属性和方法
function Student(){
    this.sno = 111
}

Student.prototype.studying = function(){
    console.log(this.name + "studying");
}

var stu = new Student()
console.log(stu.name);//undefined
console.log(stu.eating);//undefined
//很明显,顺着原型链也是找不到name跟eating这两个属性的,因为stu是接收Student产生的新对象,只会在构造函数Student上面追溯,是没办法追到Person函数身上的
  • 就目前来说,stu实例和Student子类是通过new调用进行关联了
    • 但Student子类和Person父类没有关联起来,这里存在了割裂,没有链接起来,如图16-15
    • 所以我们stu实例想要拿到Person或者Person原型里的内容,就需要把Student原型和Person原型关联起来,组成完整的原型链

实例对象与原型之间的关联

图16-15 实例对象与原型之间的关联

  • 对于这一点的话,我们其实有实现过

    • Person函数目前本身是和Person原型相互指向的
    • 我们创建出一个p实例指向于Person原型,而这个p可以放到Student原型当中,就能够成功将Student原型Person原型关联起来了,p实例在这里产生一个中间人的作用,如图16-16、图16-17

    p实例指向于Person原型

    图16-16 p实例指向于Person原型

    • 但我们其实可以不需要这个中间人,new Person()是一定会返回一个实例对象的,我们直接放到Student原型当中,而不通过p实例来进行转接。但就本质上来看,是没有区别的
      • Student函数是直接指向Student.prototype的,而这个Student.prototype已经被我们修改指向为p实例对象
      • 而stu实例对象指向的也是Student.prototype,而这其实就相当于是指向p实例对象,所以此时stu实例对象就能够直接访问到p实例对象里的内容
//方式1
var p = new Person()
Student.prototype = p
//方式2
Student.prototype = new Person()

API语法参数分类

图16-17 p实例对象充当Student原型与Person原型的中间人

  • 此时我们看下完整的过程:
    • 在这里,我们通过了步骤1使Person原型和Student原型串联起来
    • 同时需要注意一点,步骤1和步骤2的顺序是不能颠倒的,首先在于这个JS是从上往下执行的,如果步骤2先执行的话,studying方法就会被放到Student默认的原型之上,紧接着我们原型就发生变动,此时原型是p实例对象,studying方法已经随着Student默认对象一起被丢弃了
    • 所以步骤1、2颠倒的话,会出现找不到studying方法的问题
//实现继承的效果,关键在步骤1、2,顺序不能调整
//定义父类构造函数
function Person(){
    this.name = "小余"
    this.friends = []
}
//往父类原型上添加内容
Person.prototype.eating = function(){
    console.log(this.name +"eating~");
}

//定义子类构造函数
function Student(){
    this.sno = 111
}
//步骤1:创建父类对象,并且作为子类的原型对象(关键)
var p = new Person()
Student.prototype = p//这一步赋值的操作之后,Student原来的原型对象就不再被指向,会在下一轮中被垃圾回收掉。我个人认为这个p更像是链接子类跟父类的中转站,但是它是会替代掉子类原来的原型

//步骤2:在子类原型上添加内容 这一步不能够跟步骤1换是很好理解的,因为步骤1要替换掉我们的原型,如果步骤2先的话,会绑定到要被替换掉的原型身上,然后跟着一起被替换掉。所以不能够这么做
Student.prototype.studying = function(){
    console.log(this.name + "studying");
}

var stu= new Student()
console.log(stu.name);
//console.log(stu.eating());这里不需要使用console.log(),因为stu.eating()自身已经会调用一次了
stu.eating()

3.6.1 原型链继承的弊端

但是目前有一个很大的弊端:某些属性其实是保存在p对象上的

function Person(){
    this.name = "小余"
    this.friends = []
}
Person.prototype.eating = function(){
    console.log(this.name +"eating~");
}
function Student(){
    this.sno = 111
}
var p = new Person()
Student.prototype = p

Student.prototype.studying = function(){
    console.log(this.name + "studying");
}

var stu= new Student()
console.log(stu.name);
stu.eating()
  • 第一,我们通过直接打印stu对象,并且原型发生了变化(new调用Student,原型却是Person),继承来属性是看不到的(看不到但可以打印出来)
    • 正常来说,继承来的属性,也应该属于继承者的一部分
    • 如果开发的时候看不到这个信息,在出现问题需要进行排查的时候就会造成信息差的误解
  • 第二,这个属性会被多个对象共享,如果这个对象是一个引用类型,那么就会造成问题
    • 引用具体的含义,在内存中我们有讨论过,是指向同一个地方的,所以修改内容会造成所有地方都进行修改
    • 如果一个实例修改了存储在原型中的引用类型数据,这个修改会影响到所有共享这个原型的实例。这种行为在多数应用场景中是不期望的,因为通常我们希望每个实例都维护自己的状态。这种做法会导致适用性降低
    • 进而产生数据污染、难以追踪和调试等问题,想要找出修改数据的具体位置变得更加复杂。这是一个很大的弊端
    • 所以为避免这些问题,最佳实践是在构造函数中定义引用类型的属性,而不是在原型上,这也就是前面介绍过的hasOwn方法出现原因
  • 第三,不能给Person传递参数,因为这个对象是一次性创建的(没办法定制化)
    • 我们传递参数的时候,是传给哪一层参数?
    • 我如果Student本身没有name属性,但继承的Person有name属性。我希望能够传递到继承来的name身上,但这是做不到的
    • var stu= new Student(name),在这里name想要能够传递进去,那我们Student就必须得有形参去接收并处理,但我Student已经继承自Person,有接收name了,我不想要在Student中还要重新处理,这会显得继承派不上用场。而这也是目前继承方案的痛点,从而引发我们对如何在继承中合理地处理和传递构造函数参数的思考
//原型链弊端演示1
console.log(stu);//Person { sno: 111 },类型怎么变成父类了,这里变为Person,然后内容也不止一个sno
console.log(stu.name);//小余    我们看不到name属性但可以打印出来

//原型链弊端演示2
//stu1跟stu2之间应该是相互独立的,因为stu1多了一个名叫小满的朋友,不代表stu2也能够获得同样的朋友
//2.创建出来两个stu对象
var stu1 = new Student()
var stu2 = new Student()
//那问题就来了,我们接下来要对stu1进行操作,但是同时影响到了stu2。因为我们friends是一个引用对象:数组,会造成问题。通常stu1.friends这种操作应该将内容放到自己的对象里面,也就是之前说的那个var moni = {},是影响不到原型上的(直接修改对象上的属性,是给本对象添加新属性的),但是当我们使用引用对象的时候,我们知道引用对象其实是获取引用,修改引用里面的值

//直接修改的例子:直接修改对象上的属性,是给本对象添加新属性的,不会影响其他地方
stu.name = "coderwhy"
console.log(stu2.name)//小余,对stu1的修改影响不到stu2的

//引用的例子,对stu1的修改会影响到stu2
stu1.friend.push("coderwhy")
console.log(stu1.friends);//[ 'coderwhy' ]
console.log(stu2.friends);//[ 'coderwhy' ]

//原型链弊端演示3
//在前面实现类的过程中都没有传递参数,这在目前是不好处理的
var stu3 = new Student("XiaoYu2002",18)
//除非我们在Student函数中设置好形参,并且在函数体中处理好内容,这和我们继承的目的就背道而驰了
  • 在这里还有一个有意思的地方可以讨论,那就是stu1.friends.push("coderwhy")这个操作,为什么是引用类型的,我们如果直接stu1.friends = 'xxx'是不会影响到stu2的
    • 在对stu1.friends进行操作的时候,目标对象是friends属性,这已经根据[[Get]]去原型链中查找了,而push方法就直接影响到原型链当中的直接属性,不幸的是这里的属性刚好是源头,有不少地方在引用着这里,从而导致所有引用这里的内容都产生同步修改
    • 这和stu1.friends = 'xxx'区别最大的一点就在于,push方法前面是.操作符,这说明了以链式的方式访问深层属性,直接作用于friends属性本身,这些方法只会影响目标对象的直接属性,所以看似影响到原型链上的属性了,实际这个事情并不是push方法做的,而是[[Get]]实现的效果
    • stu1.friends = 'xxx'friends之后直接等于了,这其实是一种JS的赋值方式,并不会触发[[Get]]效果。而是在目标对象进行判定,有该属性则直接赋值覆盖,无属性则先自动创建局部属性然后赋值,是属性遮蔽的一个例子
    • 新的friends属性与原型上的friends属性不再有关联。因此,这个改变不会影响stu2或任何其他共享原型的实例

3.7 借用构造函数继承

  • 为了解决原型链继承中存在的问题,开发人员提供了一种新的技术: constructor stealing(有很多名称: 借用构造函数或者称之为经典继承或者称之为伪造对象):
    • steal是偷窃、剽窃的意思,但是这里可以翻译成借用
    • 这个方案是社区提供,而非官方提供
  • 借用继承的做法非常简单:在子类型构造函数的内部调用父类型构造函数
    • 因为函数可以在任意的时刻被调用
    • 因此通过apply()call()方法也可以在新创建的对象上执行构造函数,通过这两个方法,配合this的绑定,从而撬动借用继承,实现借鸡生蛋的效果(在Person函数运行Student的初始化代码)。就无需在Student构造函数中进行处理
    • 通过call进行的显式绑定,实现在Person构造函数中的调用,产生this.name实际上等于stu.name的效益
//解决无法在子类传递参数的问题
function Person(name,age,sex,address){
  this.name = name 
  this.age = age
  this.sex = sex
  this.address = address
  this.friends = []
}

Person.prototype.eating = function(){
  console.log(this.name +"在吃早餐");
}


function Student(name,age,sex,address){
  Person.call(this,name,age,sex,address)//这里的this是new Student时创建出来的对象,通过call调用这三个参数,就是一个普通的函数调用,就会去父类中调用函数了(子类型构造函数的内部调用父类型构造函数)
  // this.name = name 不能够这么传递,这样就把处理逻辑放到子类里面了,公共的应该抽到父类中去
  // this.age = age
  // this.sex = sex
}

var p = new Person()
Student.prototype = p

Student.prototype.learn = function(){
  console.log(this.name+"在学习");
}

var stu = new Student("小余","男",20,"福建")
//成功传递参数,但是类型还是Person,还有问题,后续解决
console.log(stu);//Person { name: '小余', age: '男', sex: 20, address: '福建' }
  • 在内存中的体现是这样的:
    • 这其实说明了我们利用call在Student构造函数的this中创建了对应的属性,而在进行赋值属性操作的时候,则进行new调用,我们在讲解this绑定的时候说过,这是优先度最高的,所以this才相当于绑定到stu身上
    • 虽然是借用Person构造函数实现的,但生效的位置是在我们身上,借用他人的过程,完善我们的结果,如图16-18
    • 在这个过程当中,既然已经通过this直接在本身上创建并赋值对应内容了,自然就不会继续往上找到p实例对象进行二次赋值操作,所以p对象上的内容都为默认的undefined
    • 在这个过程,巧妙的规避掉原型链弊端的第二点(属性被多方共享)和第三点(无法定制化)

Student构造函数借用Person构造函数

图16-18 Student构造函数借用Person构造函数

//在上面的基础上,两者不会相互影响
var stu = new Student("小余", "男", 20, "福建");
var stu1 = new Student("coderwhy", "男", 35, "广州");

console.log(stu);//Person { name: '小余', age: '男', sex: 20, address: '福建' }
console.log(stu1);//Person { name: 'coderwhy', age: '男', sex: 35, address: '广州' }
///////////接下来验证push了
stu.friend.push("JS")
console.log(stu.friends,"这是stu");//[ 'JS' ] 这是stu
console.log(stu1.friends,"这是stu1");//[] 这是stu1

3.7.1 借用构造函数弊端

  • 需要强调的是,借用构造函数也是有弊端的:

    • 第一个弊端: 组合继承最大的问题就是无论在什么情况下,都会调用两次父类构造函数

      1. 一次在创建子类原型的时候

      2. 另一次在子类构造函数内部(也就是每次创建子类实例的时候),因为call方法会进行一次自调用

    • 第二个弊端: stu的原型对象上会多出一些属性, 但是这些属性是没有存在的必要(多出的属性来自p对象的属性,因为我们原来的原型被p对象替换了,详细的看最上面那张图,p里面的age,name,friends都是要么跟本身的对象重复要么就没有必要的,undefined就是没必要的一种情况)

      1. 所有的子类实例事实上会拥有两份父类的 属性

      2. 一份在当前的实例自己里面(也就是Person本身的),另一份在子类对应的原型对象中(也就是 Person.__proto__里面)

      3. 当然,这两份属性我们无需担心访问出现问题,因为默认一定是先访问实例本身这一部分的

    • 第三个弊端:子类和父类脱离了原型链体系,导致了子类不能够访问父类原型上定义的方法,所以后续所有相关联类型都只能使用构造函数模式

  • 在MDN文档当中,其实也不推荐使用这种方式,如图16-19

MDN文档对call的定义

图16-19 MDN文档对call的定义

3.8 原型式继承函数

  • 原型式继承函数是一种在JavaScript中实现对象之间继承的技术,主要通过克隆已存在的对象来创建新对象。这种继承方式不依赖于传统的类结构或构造函数,而是直接操作对象的原型。在ECMAScript 5中,这种方法被标准化为Object.create函数,之前则通常通过自定义函数来实现
  • 原型式继承的渊源
    • 这种模式要从道格拉斯·克罗克福德(Douglas Crockford,著名的前端大师,JSON的创立者)在2006年写的 一篇文章说起: Prototypal Inheritance in JavaScript(在JS中使用原型式继承)
    • 为了理解这种方式,我们先再次回顾一下JavaScript想实现继承的目的:重复利用另外一个对象的属性和方法
  • 原型式继承函数最终的目的:Student对象原型指向了Person对象原型
//Student原型对象指向Person的原型对象可不可行 => 回顾一下JavaScript想实现继承的目的:重复利用另外一个对象的属性和方法
//替换前
var p = new Person()
Student.prototype = p
//替换后
Student.prototype = Person.prototype//这样甚至连Person都不需要变成构造函数,内容直接传到Person的原型上面了。这样就影响不到Person自身的对象了,也就不会在Person自身对象多出一些不需要的属性了
  • 这种原型指向原型的做法在一般情况是有局限性的,从内存图中去分析
    • 在正常的情况下,对应函数会指向对应的原型身上,如图16-20

函数指向对应原型

图16-20 函数指向对应原型

  • 一旦我们做出原型上的修改,Student指向Person原型,而非Student原型时
    • 此时Person跟Student指向同一个原型(Person原型)了。此时如果再多一个Teacher来指向Person原型的话(同种方案),我们对Student的修改影响了Person的原型,那Person的原型也会影响到Teacher
    • 这是原型式继承的主要缺陷:共享的引用属性,原型中包含的引用值属性将在所有实例间共享,这在多数情况下是不被期望的,如图16-21

原型变动将影响所有相关引用

图16-21 原型变动将影响所有相关引用

  • 通过画图的表达形式,我们能够清晰明确对象原型直接进行转换的缺陷,但这在特殊的场合下仍有对应的使用价值
    • 在正式的使用当中,我们并不会像上图这样直接改变已有对象的原型,因为已有对象只有一个,而我们该对象有可能还需要使用而不能改变,并且需要使用的对象数量是不固定
    • 所以,我们应该以已有对象为蓝本,去创建出新的对象,这种做法会更加稳定,这是Douglas Crockford的主要目的,该做法的思想也被称为数据的不可变性
var Person = {
  name:"xiaoyu",
  age:18
}
//原型式继承函数最终需要做到:Student的原型指向obj本身
var Student = {}
  • 理解了Douglas Crockford的目的,我们就能够清楚知道所有单纯在原有基础上进行修改的方式,都不符合我们的需求,如下图也是如此,因为这种直接修改原始对象(包括原始对象的原型)做法会失去数据不可变性的收益,从而产生一定的"副作用"
    • 在不可变数据模型中,每次数据变更都会生成新的数据实例,从而可以轻松地实现撤销或回退到先前状态的操作。不可变性的缺失则会使这一过程变得复杂或不可行

为原型划分继承优先级

图16-22 为原型划分继承优先级

  • 在原型式继承当中,我们并没有利用到构造函数,在这里最核心的就是中间一整条路线的原型赋值原型,进而让两个newObj对象的原型指向为Person原型,从而实现重复利用另外一个对象的属性和方法,如图16-22
    • 不涉及构造函数,也就不会出现借用构造函数继承方案的问题了

原型式继承

图16-23 原型式继承

  • 所以我们采用函数来实现这个过程,分为几个步骤:

    1. 创建出一个newObj新对象

    2. 原对象赋值新对象(该操作是最核心的一步,实现了"副本"的作用,新对象后续操作不会影响到原对象)

    3. 新对象原型指向Person原型对象

    4. 返回新对象

    • 这种方式,不通过new调用对应函数来产生新对象,而是字面量产生对象再进行额外处理原型,从而实现更好的效果(没有实例,不会无意义调用两次)
    • 通过setPrototypeOf方法来修改原型,后续有更好的方法来实现
var Person = {
  name: "xiaoyu",
  age: 18,
}

function createObject(o) {
  var newObj = {}
  Object.setPrototypeOf(newObj,o)
  return newObj
}

var Student = createObject(Person)
console.log(Student.__proto__);//{ name: 'xiaoyu', age: 18 }
  • 只要掌握了原理,想如何实现都行
    • 在目前的版本当中,使用Object.create方法会更好也更简洁,但setPrototypeOf方法会更直观
    • Object.create() 静态方法以一个现有对象作为原型,创建一个新对象
var Person = {
  name: "xiaoyu",
  age: 18,
}

var Student = Object.create(Person)
console.log(Student.__proto__);//{ name: 'xiaoyu', age: 18 }
  • 但是在 道格拉斯·克罗克福德 那个年代,Object.setPropertypeOf内置函数或者Object.create还没有出来,所以我们来看看他当时是怎么写的吧!

    • 这是在Object.create方法标准化之前的一种解决方案。这种方法利用了JavaScript原型继承的核心概念

    1. 避免使用具体构造函数创建对象:克罗克福德的方法避免了直接使用具体构造函数创建对象,从而也避免了构造函数中可能存在的副作用和额外的资源消耗。这在只需要继承现有对象属性而不需要执行任何初始化逻辑时尤其有用
    2. 使用空构造函数(Fn)维护原型链:通过创建一个空的构造函数Fn,并将其原型设置为目标对象o,这种方法创造了一个干净的隔离层,让新创建的对象newObj能够继承o的属性而不直接修改o本身。这是一种非常巧妙的隔离继承方式,避免了原型对象与新对象之间的直接联系
    3. 原型链的纯粹继承:使用new Fn()创建的对象newObj会自动拥有一个指向Fn.prototype__proto__链接,由于Fn.prototype已被设置为o,因此newObj实际上是通过原型链间接继承了o的属性。这种方式完美地展示了JavaScript原型链的工作原理,即对象通过其__proto__属性(现在通常通过Object.getPrototypeOf()访问)链接到其原型
var obj = {
    name:"小余",
    age:20
}

//这一步要实现的是传入给我的对象要成为新创建出来的对象原型
function createObject(o){
    function Fn(){}
    //然后将Fn的函数原型设置为传进来的函数原型
    Fn.prototype = o
    //然后只有构造函数才有prototype,所以我们需要让Fn变成构造函数
    var newObj = new Fn()//同时这里还有一步深意,那就是o已经变成Fn的构造原型了
    //最后返回结果
    return newObj
}

var info = createObject(obj)
console.log(info.__proto__);//实现了info原型指向obj对象
  • 所以在最原生的方法中,暴露出直白的信息就越多,我们越能够感受到其中的思想体现
    • 这对于我们理解新特性会有很大的帮助,比如Object.create方法的目的是什么,底层是如何实现的

3.9 寄生式继承函数

寄生式继承也是JavaScript中一种实现对象继承的模式,它结合了原型式继承和工厂模式的特点。这种继承方式的核心在于创建一个“寄生”函数,这个函数的作用是创建一个已有对象的副本,然后在副本上扩展新的特性,最后返回这个副本

  • 寄生式(Parasitic)继承是与原型式继承紧密相关的一种思想, 并且同样由道格拉斯·克罗克福德(Douglas Crockford)提出和推广的
    • 这种对象上的继承,更多是作为一个了解,为最终方案做出铺垫
//寄生式继承是一种创建对象的模式,它通过创建一个函数并在其上面添加属性和方法来实现继承。这种方式的优点是可以在不改变原型链的情况下进行继承,并且可以在创建新对象时不需要使用关键字 new

//原型函数
var personObj = {
    running:function(){
        console.log("小余正在跑步");
    }
}
// 1. 创建寄生函数
// 定义一个函数,这个函数将作为创建新对象的工厂
function createStudent(name) {

  // 2. 原型链继承
  // 使用Object.create创建一个新对象,其原型是personObj对象
  var student = Object.create(personObj);

  // 3. 创建新对象
  // 给新对象添加特定的属性
  student.name = name;

  // 4. 修改新对象
  // 给新对象添加特有的方法
  student.learn = function() {
    console.log(name + "在学习");
  };

  // 5. 返回新对象
  // 返回创建并修改过的对象
  return student;
}

//寄生式继承 = 工厂函数+原型函数
var stu1 = createStudent("小余")
var stu2 = createStudent("coderwhy")
//一般情况下我们也不会使用,因为是有缺陷的
  • 主要分为五个步骤
    1. 创建寄生函数:定义一个函数,这个函数通常不会执行任何操作,它的目的是用于借用构造函数
    2. 原型链继承:将寄生函数的原型指向要继承的对象的原型,从而实现原型链的继承
    3. 创建新对象:使用 new 操作符创建寄生函数的实例,这将执行寄生函数并创建一个新对象
    4. 修改新对象:在寄生函数内部,创建一个要继承的对象的副本,并在这个副本上添加或修改属性和方法
    5. 返回新对象:返回修改后的副本对象,这个对象将具有寄生函数的实例属性,同时也继承了原型链上的属性和方法
  • 在原型式继承当中,我们如果要添加属性,且不能干扰到原型,就只能一个个添加。如果需要添加的目标较多,我们就会出现重复代码
    • 在原型式继承中,这是很难避免的一个问题,但结合工厂函数即可变为寄生式继承
    • 在寄生式继承当中,我们可以直接创建一个添加属性副本,然后对副本多次返回来应对多个目标,从而避免重复代码
// 原型式继承的基础:Person 构造函数
function Person(name) {
  this.name = name;
  this.age = 0;
  this.running = function() {
    console.log(this.name + " is running.");
  };
}

// 使用原型式继承创建 Student 构造函数
function Student(name, grade) {
  Person.call(this, name); // 继承 Person 的属性
  this.grade = grade; // 添加 Student 特有的属性
}

var stu1 = new Student("xiaoyu", 20);
var stu2 = new Student("coderwhy", 35);

// 这里为每个学生添加属性时,需要重复编写代码,是原型式继承的弊端
stu1.hobby = "reading";
stu2.hobby = "swimming";
  • 寄生式继承函数对比,createStudent则是其中的副本,返回多次副本从而避免重复代码
    • 寄生式继承函数就像是一个中间商,把原有共性的部分和后续添加特有的属性进行一个汇聚,形成我们想要的内容
    • 所以相对于通过原型式继承创建的对象都会共享相同的属性和方法而言。寄生式继承因"中间商"的存在,在原型式继承的基础上,可以添加新的属性或方法,使得继承得到的对象不仅包含从原型继承的属性
  • 所以其实可以简单的概括为:
    1. 原型式继承:只有共性部分
    2. 寄生式继承:共性部分+特有部分
// Person 构造函数保持不变
function Person(name) {
  this.name = name;
  this.age = 0;
  this.running = function() {
    console.log(this.name + " is running.");
  };
}

// 工厂函数,用于创建 Student 对象
function createStudent(name, grade, hobby) {
  // 创建一个新对象,其原型是 Person.prototype
  var student = Object.create(Person.prototype);
  Person.call(student, name); // 继承 Person 的属性
  
  // 为新对象添加 Student 特有的属性
  student.grade = grade;
  student.hobby = hobby;
  
  // 返回新对象
  return student;
}

// 使用工厂函数创建学生对象
var stu1 = createStudent("Alice", 10, "reading");
var stu2 = createStudent("Bob", 12, "swimming");

// 现在,为多个学生对象添加属性时,不需要重复编写代码
// 所有属性都是在 createStudent 函数中统一添加的

四、寄生组合式继承

  • 现在我们来回顾一下之前提出的比较理想的组合继承

    • 组合继承(原型链继承+借用构造函数继承+原型式继承)是比较理想的继承方式, 但是存在两个问题:

      问题一: 构造函数会被调用两次: 一次在创建子类型原型对象的时候, 一次在创建子类型实例的时候

      问题二: 父类型中的属性会有两份: 一份在原型对象中, 一份在子类型实例中

  • 事实上, 我们现在可以利用寄生式继承将这两个问题给解决掉

    • 需要先明确一点: 当我们在子类型的构造函数中调用父类型.call(this, 参数)这个函数方法的时候, 就会将父类型中的属性和方法复制一份到子类型中,所以父类型本身里面的内容, 我们不再需要
    • 这个时候, 我们还需要获取到一份父类型的原型对象中的属性和方法
  • 能不能直接让子类型的原型对象 = 父类型的原型对象呢?

    • 不要这么做, 因为这么做意味着以后修改了子类型原型对象的某个引用类型的时候, 父类型原生对象的引用类型也会被修改,这在讲解原型式继承的时候就说明该缺陷了
  • 我们先暂且继续使用组合式继承,看存在的的问题都体现在哪,以及寄生式继承会如何解决掉这些问题

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

function Student(name, age, friends, sno, score) {
    Person.call(this, name, age, friends);  // 借用父类构造函数初始化属性(借用构造函数继承)
    this.sno = sno;
    this.score = score;
}

Student.prototype = Object.create(Person.prototype);  // 继承Person的方法

var stu = new Student("小余", 20, ["coderwhy", "xiaoyu2002", "李银河", "kobe"], 111, 100);//原型链继承
console.log(stu); 
//输出:
// Person {
//   name: '小余',
//   age: 20,
//   friends: [ 'coderwhy', 'xiaoyu2002', '李银河', 'kobe' ],
//   sno: 111,
//   score: 100
// }
  • 通过打印结果我们发现这个对象的名称怎么还是Person(父类),而不是Student(子类)。这是我们一开始就遇到的问题,当时暂时跳过,现在来进行解决
    • 首先我们直接输出打印的内容,一共是分为两个部分:对象名称、对象内容
    • 其中对象内容是来自通过new Student(...)创建的实例属性,以及通过原型链继承的方法。而尚未解决的对象名称其实是来自constructor,也就是构造函数属性,所以对象名称通常也可以叫做构造函数名称
    • 通过在控制台打印构造函数属性的名称,就能够印证我们的想法
//这里是名字拼接对象,对象里就是我们的内容,名字是Person,通过找到我们stu.constructor进行拼接了
console.log(stu.constructor.name);//Person,证明了确实如此,是constructor进行拼接的
  • 我们知道构造函数属性是指向构造函数本身的,这其实本身就是一个对应关系。而对象名称与对象内容也是如此
    • 构造函数属性也是一层层往上找的,我们在原型章节中已经学到constructor属性是在[[prototype]]内的
    • 因此这个往上找的顺序是:寻找自身的constructor => 寻找自身原型的constructor => ....,还是依照原型链的顺序去对应查找
  • 在我们这个案例当中,stu本身是没有constructor的,而它的原型正常来说是直接指向于Student,按照我们的理论来说,这应该打印出来的对象名称就该是Student,就如下图所示16-24

构造函数对应关系

图16-24 构造函数对应关系

  • 在这里让我们效果产生误差的主要来源于这行代码:Student.prototype = Object.create(Person.prototype)
    • 我们在利用原型继承了Person对应的方法之后,连着原型内含的constructor也一并继承过来了,如图16-25
    • 这也是导致我们打印出来的对象名称是Person的原因

构造函数被替换继承

图16-25 构造函数被替换继承

  • 所以,我们只需要改动constructor的名称就OK了

    • 直接修改对应的constructor并不会让Person的constructor发生转变,不会产生副作用,这是因为Object.create方法发挥的作用,原因是在原型式继承中有讲述该方法会产生的原型链的纯粹继承
  • 在改动这个构造函数属性的时候,其实也有不少细节之处,让我们来探索一下

  • 首先是最基础的直接改动

Student.prototype = Object.create(Person.prototype);  // 继承Person的方法
Student.prototype.constructor = Student //对应改动措施
  • 最直接的改动也是最粗暴的方式,该方法没办法进一步设置对应的属性描述符
    • 所以我们可以使用defineProperty方法来进行定义
Student.prototype = Object.create(Person.prototype);  // 继承Person的方法
Object.defineProperty(Student.prototype,'constructor',{
  value:Student,
  enumerable:true,
  configurable:true,
  writable:true
})
  • 但这依旧不是最好的方式,因为实例是可以不断的new出来的,这个数量有可能不止一个,当数量一多的时候,我们想要一个个去调整设置,就会多出很多的重复代码
    • 因此,在满足个体细节上的处理之后,我们需要开始注重对应的复用性
    • 我们可以封装一个工具函数,将能够复用的内容抽取起来,我们只需要传入想要修正的构造函数属性(子类型)以及想要继承的对象(父类型)即可
//工具函数,将这个核心功能封装起来
function inheritPrototype(SubType,SuperType){//SubType:子类型、SuperType:父类型
    SubType.prototype = Object.create(SuperType.prototype)
    Object.defineProperty(SubType.prototype,"constructor",{
        configurable:true,
        writable:true,
        enumerable:false,
        value:SubType,
    })
}
//需要的时候直接使用
inheritPrototype(Student,Person)//Student继承自Person
  • 而在旧社区的使用当中,也就是还未出现Object.create方法的时期,往往还会继续创建另一个工具函数来实现和Object.create方法相似的效果
    • 该做法其实就是我们之前说的,道格拉斯提出的理念和方式
//社区的旧写法(目前),其实就是自己封装Object.create进行使用
//通过这种方式创建的对象是一个浅拷贝,如果o对象里面有引用类型的数据,新对象和o对象指向的是同一个内存地址,对新对象的修改会影响到o对象。这种方式创建的对象也叫原型继承,是一种实现继承的方式
function createObject(o){
    function Fn(){}//创建一个空函数
    Fn.prototype = o//然后将其 prototype 属性设置为传入的对象 o,这样,Fn 函数的所有实例都会继承 o 对象的属性和方法
    return new Fn()//最后,我们使用 new 运算符创建一个新的 Fn 实例并返回这个实例,这样我们就得到了一个继承了 o 对象属性和方法的新对象
}
  • 所以我们在经历了多种继承方式的学习,敲定了最终的方案:寄生组合式继承
    • 该方案其实是我们前面四种方案:原型链继承、借用构造函数继承、原型式继承、寄生式继承的结合体
    • 如图16-26可以看出继承在一代代的改进中,吸收前人的经验,一步步的走向更完美的形态
  • 这是一个不断进步的过程,直到目前为止仍未停下脚步,仍具备未来的潜力,这也是这门语言还值得我们去学习的原因之一

仍在权衡特性与负担中的API

图16-26 仍在权衡特性与负担中的API

五、对象的方法补充

  • 对象的方法在前面已经直接或者间接讲解了一半以上的部分了
    • 但相对于图16-27所展示的整体方法(30+)而言,剩下的也不少
    • 剩下的部分我们会分为两块内容:ES6之前的在此补充,ES6及之后的部分会放到后面的模块当中进行讲解

Object的30余种静态方法

图16-27 Object的30余种静态方法

  • Object.create方法在前面间接涉及到,但除了直接改变原型之外,它还有其他的功能,通过参数2来进行实现
    • 该参数名为propertiesObject(属性对象)
    • 如果该参数被指定且不为 undefined,则该传入对象可枚举的自有属性将为新创建的对象添加具有对应属性名称的属性描述符,这是深层次的定制
Object.create(proto, propertiesObject)
  • 如果大家有注意到的话,会发现这个方法把添加属性添加属性的属性描述符功能都集成到参数2当中了
    • 这其实是一种复用性更强的写法,当要添加更多的属性的时候,就能够进行排列下去
var obj = {
  name:"xiaoyu",
  age:18
}

var info = Object.create(obj,{
  address:{
    value:'福建省',
    enumerable:true
  }
  //....添加更多的属性
})
console.log(info);
  • 对此,我们可以进行回顾一下曾经学过的defineProperty方法
    • 该方法将这两个功能拆分为两个参数,导致说要定义和修改属性键只能够一个个去设置,如图16-28
    • 如果需要定义和修改的属性较多,在一定程度是会造成不方便的,所以后续用defineProperties来补救这个方法
    • 而defineProperties方法本身是可以完全兼容defineProperty方法的,从这个角度来说,造成了Object方法一定程度上的臃肿,但该做法优势做法都已经被后续新的方法所采纳吸收,如图16-29
  • Object.create方法的参数调用形式正是因此形成的,在经历这个探讨归纳历程之后,Object.create方法对我们已经不具备难度

参数功能拆分

图16-28 defineProperty参数功能拆分

defineProperties参数功能合并

图16-29 defineProperties参数功能合并

5.1 判断方法

  • 在添加属性以及对应的属性描述符的时候,我们还有对应的判断方法
    • 这分别是:hasOwnPropertyin/for in 操作符instanceofisPrototypeOf这四个
  • hasOwnProperty通常用来判断对象是否有某一个属于自己的属性(不是在原型上的属性),这个方法在之前有介绍过了,并且我们说明了已经拥有更好的上位替代:hasOwn方法,所以在这我们就不继续进行拓展
  • in/for in 操作符
    • in实际上来说应该叫做运算符,判断指定的属性是否在指定的对象或其原型链。通常在描述中说到是否的时候,基本上意味着这是一个判断的过程,而判断作为返回结果往往是一个布尔值
    • 在知道in的作用之后,我们在往后还会看到有方法的前缀会是in,这同样意味着该方法很可能同样是一个判断方法,返回的是布尔值
    • for in操作符则是在in运算符的基础上进行一个循环遍历(for带来的能力),这个遍历是具备局限性的,只能遍历迭代一个对象(包括继承来的部分)可枚举(enumerable属性描述符)的部分,所以需要清楚该方式的能力边界
for (variable in object)
  statement
  
//variable:每次迭代时接收当前object的一个字符串属性名
//object:被迭代非符号可枚举属性的对象
//statement:每次迭代后执行的语句,可以引用 variable
  • for in操作符遍历对象的时候,会把继承来的属性一起遍历出来,如果想要进行区分的话,还得在内部执行体使用hasOwn方法进行判断一下
    • 这个遍历出来的内容严格来说是左侧内容
    • 对于对象来说,左侧是属性。对于数组来说,左侧是索引
    • for in虽然可以遍历出数组的索引,但显然没什么太大的意义,还具备一定的风险(因为数组对象有可能被扩展了额外的属性或方法,这个会被遍历出来),有这个想法不如直接通过arr.length获取数组的长度
    • 所以for in通常最好只用来遍历对象,其他非对象内容采用其他方式
{属性:内容}
//对于数组来说,索引从0开始,往后自增,非人为干涉,所以数组往往只体现内容。只有在获取的内容的时候才通过索引获取
[索引:内容]
  • instanceof运算符用于检测构造函数的pototype,是否出现在某个实例对象的原型链上
    • 换个说法就是:instanceof方法是用来判断一个对象是否是某个构造函数的实例
    • 这个其实很像是在做亲子鉴定,只不过这个"亲子"的范围比较广泛,只要是一脉相传(处于同一原型链上端)的都算
    • 鉴定者(对象) instanceof 来源者(构造函数),如图16-30
object instanceof constructor
//其中,object是要检测的对象,constructor是构造函数。如果object是constructor的实例,则返回true,否则返回false

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

var Student = new Person()
console.log(Student instanceof Person);//true  Perosn的实例是否是Student

instanceof鉴定原理

图16-30 instanceof鉴定原理

  • 最后一个判断方法则是isPrototypeOf,用于检查一个对象是否存在于另一个对象的原型链中
    • 这和instanceof运算符不同,在表达式 object instanceof AFunction 中,会检查 object 的原型链是否与 AFunction.prototype 匹配,而不是与 AFunction本身匹配,这就是我们上图中步骤1、2的关联匹配部分
    • 这意味着instanceof运算符检测来源只能是构造函数,而isPrototypeOf的检测来源是一个对象本身以及该对象身后的原型链,如图16-31
    • 通过我们学过的Object.create可以简单的验证这一点,且刚好我们了解对应的内部实现原理

isPrototypeOf判断方式

图16-31 isPrototypeOf判断方式

//isPrototypeOf:用于检测某个对象,是否出现在某个实例对象的原型链上
var obj = {
    name:"小余",
    age:18
}

var info = Object.create(obj)

console.log(obj.isPrototypeOf(info));//obj是不是出现在info的原型链上面,结果是true
//检测来源.isPrototypeOf(检测方)
  • 对于已经掌握原型链的我们来说,这些都能够游刃有余的去进行解决,所以把这块内容放在学完原型链之后的位置能够更好的理解

六、原型继承关系

  • 对于原型继承,我们已经学习得差不多了,因此可以总结出一条完整的原型链图和这之间的继承关系
    • 在网上有一幅对应的继承图非常有名,该图的来源网站:Javascript 对象层次结构 (mollypages.org)
    • 这张图16-32很详细的阐述了原型继承的关系,从这里当中,我们可以很清晰的看到原型链的走向

API语法参数分类

图16-32 对象原型链完整继承图(来源网络)

  • 但对于很多人来说,这张图还是过于复杂了
    • 对此,我们可以用我们的方式来进行一定程度的简化
    • 对于学习该类型的知识,通过画图去理解是更好的选择,我们可以也自己来画一副对应的图16-33

API语法参数分类

图16-33 根据文章所总结出的对象原型链完成继承图

  • 首先这里面的"角色"分为四种:基本对象函数对象自定义函数原型和构造器
  • 在基本对象中:
    • new创建和字面量创建都属于这种方式,是我们最常见,也是在讲解原型链章节中最早提出的部分
    • new Object 创建了一个普通的对象实例 obj1,这个实例的 __proto__ 属性(隐式原型)指向 Object 构造函数的 prototype 属性
  • 在函数对象中:
    • 所有函数(比如 FunctionObject)本身也是对象,它们的 __proto__ 属性指向 Function.prototype。这包括 Function 函数自身,它也是一个特殊的函数对象,其 __proto__ 指向自己的 prototype
  • 在自定义函数中:
    • new Foo() 创建了一个通过自定义构造函数 Foo 实例化的对象 foo1。这个实例的 __proto__ 属性指向 Fooprototype
  • 在原型和构造器中:
    • 每个函数都有一个 prototype 属性,其中包含一个 constructor 属性,指回该函数自身。例如,Foo.prototype.constructor 指向 Foo,这是一个相互指向的过程
    • 函数的 prototype 对象本身也是一个普通对象,其 __proto__ 属性默认指向 Object.prototype,这是原型链的顶端,其 __proto__ 属性为 null,表示原型链的结束
//最根上面的原型对象
var obj = {
    name:"小余"
}
//很明显通过以下两种方式证明了这点
console.log(obj.__proto__)//[Object: null prototype] {}
console.log(obj.__proto__ === Object.prototype)//true
//对象里面是有一个__proto__:隐式原型

//Foo是一个函数,那么它会有一个显式原型对象:Foo.prototype
//Foo是一个对象,那么它会有一个隐式原型对象:Foo.__proto__
//显式原型对象必然是跟隐式原型对象不相等的

//prototype来自哪里?
//=>来自你一旦创建一个函数,那函数本身会有js引擎帮你创建出来一个新的对象的。而且Foo.prototype的身上还有constructor,这个又指向回Foo函数了
//__proto__来自哪里?
//new Funtion() 	Foo.__proto__ = Funtion.prototype
//Funtion.prototype = {constructor:Funtion}

//var Foo = new Function()
function Foo(){
    
}
//Foo既是函数,也是一个对象,那是由谁创建出来的呢?由new Funtion
function Funtion(){}//这就像是一个类一样
var Foo = new Funtion()//然后就这样子创建出来了

console.log(Foo.prototype === Foo.__proto__);//false
console.log(Foo.prototype.constructor);//[Function: Foo]
console.log(Foo.__proto__.constructor);//[Function: Funtion]
  • 对象身上隐式原型=>指向 Object 的原型对象,函数身上显式原型+隐式原型=>指向 Function 的原型对象
  • 最后,我们应该了解原型继承模型是使用它编写复杂代码的重要基础。此外,要注意代码中原型链的长度,在必要时可以将其分解,以避免潜在的性能问题。除非是为了与新的 JavaScript 特性兼容,否则永远不应扩展原生原型

后续预告

  • 掌握原型链后,是一个分水岭,从这往后,我们将进入ES6的世界!
    • ES6是2015年的大版本更新,细数这些年,10年的时间如风沙转瞬即消失,也许这已经不能够再称为"ES6新特性"了,而是属于JS语言的基石,是经过时间证明的内容
    • 在下一章节中,我们要学习Class类,探索类与构造函数之间的关系,该特性是ES6的主要更新之一,曾经的繁琐操作,会被Class所简化,而React更是将Class发扬到极致,这是一段盛行的阶段,尽管从目前来看,已经被函数式编程所追赶上来,但并不妨碍我们进行理解学习