深入理解JS(五) - 原型与原型链

215 阅读18分钟

前言:于无声处听惊雷

你是否好奇,为什么在 JS 中总能 “无中生有”?比如说一个普通的对象,明明定义的时候没有定义某个方法,但是我们却能够调用它。const obj = {}; obj.toString();,这个 toString()方法从何而来?这些你平时看来理所当然的操作,背后藏着怎样不为人知的秘密?
接下来,我将带你一套通关原型与原型链,出发!

一. 初识原型与原型链(浅显)

  • 定义: 在 JavaScript 中,每个对象都有一个内部属性[[Prototype]],它指向该对象的原型对象。当访问一个对象的属性时,JavaScript 会先查找对象本身,若找不到则沿着原型链向上查找。
  • 接下来我将从原型对象入手,为你讲解 JS 中的原型。

原型对象

JS 常被称为是一种基于原型的语言,因为在 JS 中,每个对象都拥有一个原型对象(Object.protoype除外)。让我们想想之前在《深入理解JS(二) - 堆栈与数据类型》中提到的关于对象的知识:“所有引用数据类型都是对象”,那么诸如函数(Function)、数组(Array)、日期(Date)乃至于对象(Object)等类型都是对象,它们也都有自己的原型对象。

  • 表现形式: 在 JS 中,以Object.prototypeFunction.prototypeArray.prototypeDate.prototype这样的格式表示各个对象的原型对象。

  • 定义: 每个对象在创建时都会关联到另一个对象,这个被关联的对象就是创建的对象的原型对象。原型对象就是一个模板,它包含了这类对象共享的属性和方法,并且可以被其他对象继承

  • 注意: 原型对象也是对象哦!所以它也会有自己的原型对象,以此类推,直到最终的原型为Object.prototype(其原型为null)。

    • 示例:

      // 定义一个数组arr1
      const arr1 = [1, 2, 3, 4, 5, 6];
      
      • 数组对象arr1的原型对象是Array.prototype,而Array.prototype的原型对象是Object.prototypeObject.prototype的原型指向null

原型链

现在我们知道了原型对象是什么,但是原型对象这东西是怎么和我们的对象关联,并形成原型链的呢?这里就要介绍一个属性__proto__了(注意!是双下划线!)。 注意: 凡是继承自Object.prototype的对象都有该属性,而通过Object.create(null)创建的对象没有,因为通过Object.create(null)创建的对象,其原型被设置为了null,未继承Object.prototype

  • __proto__定义: __proto__Object.prototype 上的一个访问器属性(getter/setter),用于间接操作对象的内部 [[Prototype]] 槽。它提供了一种在代码中读取或修改对象原型链的方式。
    __proto__作为桥梁,指向创建它的构造函数的原型对象。(仍以数组arr1为例,下一节“函数与原型”为你讲解构造函数与原型的关系)

  • 示例:

    图 1 初识原型链-1.png

  • 原型链定义: 原型链是 JavaScript 中实现继承的一种方式,它基于对象的内部 [[Prototype]] 属性形成的层级结构。当访问一个对象的属性或方法时,JavaScript 引擎首先在对象本身查找,若未找到,则沿着其 __proto__ 形成的链逐级向上查找,直到找到该属性或到达链的终点(null)。

  • 示例: 回到最开始我们提出的问题,当我们声明一个空对象const obj = {};,该对象却能使用toString方法obj.toString();,这种“意外之财”就是原型链为我们拿来的。

    图 2 初识原型链-2.png

  • 特殊情况:

    • 在 JS 中,对象的创建有三种方式,上面的原型链查找模式适用于其中两种情况:

        1. 使用new关键字通过构造函数创建对象实例的方式(const obj = new Object());
        1. 对象字面量,借助大括号{}直接创建对象(const obj = {}),不过使用对象字面量直接创建对象实例的方法其实是 JS 的语法糖,本质仍是new Object())。
    • 第三种就是特殊情况: 使用Object.create() 方法创建对象实例。

      • 该方法可以直接指定一个已有的原型对象来创建新对象,新对象可以继承该原型对象的所有属性和方法。
      • 在正常情况下,这种方式的原型链查找方式和上面两种情况一致,但是如果出现了指定null为原型对象的情况,则它没有原型链了,查找只在自身进行查找,如果在自身查找无果,则查找就在此终止,不会继续查找。(它也没有__proto__属性)
  • 注意: 请记住,原型链的核心是[[Prototype]],其指向该对象的原型对象;而不是我们常看到的__proto__,其指向[[Prototype]]

    • 对象可以没有__proto__,但是一定会有[[Prototype]],因为它是 JS 对象的内部属性,无论我们用何种方式创建对象,它都会存在,可以用Object.getPrototypeOf()方法来确定无__proto__属性的对象的[[Prototype]]

    • 打个比方:[[Prototype]]就是我们的血缘关系纽带,这是我们天生就有的;而__proto__只能算是一本族谱。在大家族中,有一本族谱可以省很多事,我们可以查阅族谱轻松逐级向上找到自己的祖先,而就算没有族谱,也不妨碍我们通过血缘关系找到自己的祖先(即使祖先为null,也没有__proto__属性指向null)。

    • 示例:

      const obj = Object.create(null);
      
      console.log(obj.__proto__); // undefined 说明该对象没有__proto__属性(obj没有继承Object.prototype)
      
      // 虽然没有__proto__属性,但是仍可以通过Object.getPrototypeOf()方法来确认该对象的原型为null
      // 这也验证了即使没有__proto__属性,[[Prototype]] 仍存在
      console.log(Object.getPrototypeOf(obj)); // null
      
      // 由于没有继承Object.prototype,所以obj调用自身没有的toString方法时会报错
      obj.toString(); // TypeError: obj.toString is not a function
      

      图 3 初识原型链-3.png

[[Prototype]]__proto__

在某些文档中,如 MDN 的“继承与原型链”这一节中,是用obj.[[Prototype]]来表述一个对象与其原型对象的关联,而不是obj.__proto__,这并不冲突,甚至使用obj.[[Prototype]]来描述更为规范。

  • [[Prototype]]是JS引擎内部的原型机制,它是所有对象都有的内部属性,无法直接通过代码访问,是一个抽象概念,也是规范术语。

  • __proto__是浏览器环境中最早实现的访问器属性,ES6将其标准化,但仍不推荐直接使用(尤其是修改操作),本文为了方便理解,仍会直接使用__proto__属性。我们可以这样认为: __proto__是访问[[Prototype]]的一种方式,它是暴露出来给开发者使用的属性,我们可以通过这个关联到该对象的原型对象。

    • 推荐使用Object.getPrototypeOf(obj)方法来获取对象的原型
    • 推荐使用Object.setPrototypeOf(obj, newProto)方法设置对象的原型(谨慎使用,影响性能)
  • 所以,[[Prototype]]是规范术语,常常在描述语言内部机制时使用;而__proto__则在实际操作时常常被提及,这时候obj.__proto__obj.[[Prototype]]二者代表的意思都差不多,所以经常出现混用的情况,希望大家不会被它们两个绕晕。

  • 示例: __proto__[[Prototype]]的关系(__proto__访问对象的[[Prototype]]

    const obj = {};
    ​
    // 通过 __proto__属性(不推荐) 访问原型
    console.log(obj.__proto__ === Object.prototype); // true// 通过标准方法访问原型
    console.log(Object.getPrototypeOf(obj) === obj.__proto__); // true// 修改原型(不推荐直接使用 __proto__)
    const newProto = {
        customMethod() {
            console.log('Custom method');
        }
    };
    // obj.__proto__ = newProto; // 不推荐
    // 等价于 Object.setPrototypeOf(obj, newProto) 这个也要谨慎使用
    Object.setPrototypeOf(obj, newProto);
    // 访问新原型的方法
    obj.customMethod(); // Custom method
    

二. 函数与原型(深入)

前面我们所讲解的知识只能说是原型与原型链极为浅显的皮毛,把函数这个概念引入之后,才是原型真正的精华所在。

  • 是的,在 JS 中,很多东西都绕不过函数 —— 这位在 JS 中地位极高的存在。它不仅是对象中的一员(函数即对象),还被称为是“一等公民”。既然 JS 排了个三六九等,那就有特权存在,所以函数不但拥有对象普遍拥有的属性__proto__,还有自己独有的属性prototype,下面我们就要开始介绍函数的prototype属性了。

prototype属性

虽然我们从原型对象入手介绍了一下原型的概念,但是很多人都不会注意到上面的那些原型对象就是构造函数的一个属性——prototypeFunctionObjectArray等都是 JS 内置的构造函数)。prototype既是函数的属性,也是一个对象。它翻译过来正好是“原型”之意,由此可见在 JS 设计师心中prototype属性在原型系统当中的重要地位。

  • 在 JS 中,所有的函数天生具备双重身份:既可以作为普通函数直接调用,也可以作为构造函数通过 new 关键字创建对象实例。这种灵活性源于 JS 动态类型的特性 —— 函数的“身份”并非由定义决定,而是由调用方式动态切换
  • prototype属性的作用仅在函数作为构造函数时体现(决定实例的原型)。

普通函数

普通函数直接可以调用执行,这时函数的prototype属性不参与运行时,但是仍然存在。

  • __proto__: 函数都继承自Function.prototype,所以普通函数的__proto__属性指向Function.prototype。这意味着普通函数能够使用自Function.prototype起,原型链之上的原型对象所提供的属性和方法,如call()apply()bind()等。

  • prototype: 普通函数的prototype属性中有一个constructor属性,该属性又会指回函数本身。但是要记住,普通函数中的prototype在执行时几乎不会被用到!但是它仍然存在,因为 JS 默认所有函数都是构造函数prototype是必备的。

  • 示例:

    function func1(){
        console.log('这是一个普通函数');
    }
    
    // __proto__指向Function.prototype
    console.log(func1.__proto__ === Function.prototype); // true
    
    // func1.prototype的constructor属性又指回了func1函数本身
    console.log(func1.prototype.constructor); // [Function: func1]
    console.log(func1.prototype.constructor === func1); // true
    
    
    • 普通函数与原型链的简化示意图:

      图 4 普通函数与原型链简化图.png

    • 普通函数与原型链的详情示意图:

      图 5 普通函数与原型链详情图.png 现在我们看不出prototype到底有啥用,因为普通函数几乎用不到prototype属性,请看下面的构造函数。

构造函数

构造函数通过 new 关键字调用时,prototype会成为新实例的原型对象(即实例的 [[Prototype]] 指向该函数的 prototype)。在 JS 中,为了方便区分普通函数和构造函数,我们规定构造函数的首字母要大写(千万要遵守,规范最重要!!!)。

  • 示例: 我们最最最常见的构造函数就是ObjectFunctionArray等,它们的首字母都是大写的。

  • __proto__: 构造函数本质上也是函数,它的__proto__属性同普通函数一致。

  • prototype: 构造函数的prototype属性和普通函数的prototype属性一致,有一个constructor属性指向构造函数本身。但是与普通函数不同,通过new关键字调用构造函数创建实例时,该实例的__proto__会指向构造函数的prototype对象,这时prototype属性开始发挥作用了。

  • 原型链的继承: 由于通过构造函数创建的实例,其__proto__指向该构造函数的prototype属性,所以通过该构造函数创建的所有实例,都会共享该构造函数的prototype属性上的属性和方法。这种特性使得原型链继承得以实现,可以说,这才是原型链真正的精髓,原型链的运行机制。

  • 示例:

    // 构造函数首字母大写
    function Func2(name) {
        this.name = name;
        console.log('这是一个构造函数');
        function sayInFunc2() {
            console.log('Func2中创建的局部函数,属于Func2实例对象私有,无法继承!');
        }
    }
    // 1. 通过 new 关键字调用构造函数创建对象实例objFunc2
    // 其过程中仍会执行该构造函数中的代码
    const objFunc2 = new Func2('对象实例2'); // 输出:这是一个构造函数
    
    
    // 2. 构造函数Func2仍遵守普通函数的规则
    console.log(Func2.__proto__ === Function.prototype); // true
    console.log(Func2.prototype.constructor === Func2); // true
    
    
    // 3. 验证创建的对象实例
    // 表示objFunc2确实为构造函数Func2创建的对象实例
    console.log(objFunc2); // 输出:Func2 { name: '对象实例2' }
    // objFunc2 的__proto__ 指向创建它的构造函数的prototype,即Func2.prototype
    console.log(objFunc2.__proto__ === Func2.prototype); // true
    
    
    // 4. 由于objFunc2的__proto__指向构造函数的prototype,原型链得以实现
    // 向Func2.prototype中添加say()方法
    Func2.prototype.say = function (){
        console.log('Func2.prototype中的方法,可以被对象实例继承,共享使用');
    }
    // 对象实例共享该构造函数的prototype的属性和方法,所以可以使用say()方法
    objFunc2.say(); // Func2.prototype中的方法,可以被对象实例继承,共享使用
    
    // 注意!!!直接定义在Func2函数体中的方法,是Func2的私有的局部函数,不能被对象实例继承
    objFunc2.sayInFunc2(); // TypeError: objFunc2.sayInFunc2 is not a function
    
    • 构造函数与原型链的简化示意图:

      图 6 构造函数与原型链简化图.png

    • 构造函数与原型链的详情示意图:

      图 7 构造函数与原型链详情图.png

  • 继承小结: 对象实例可通过原型链继承 构造函数.prototype 中的属性和方法,但无法通过原型链访问构造函数内部的局部方法或构造函数自身的属性。继承的本质是原型链的属性查找,仅作用于 prototype 对象及其原型链上层对象。

    • 所以我们能看到示例代码中objFunc2.sayInFunc2();,对象实例 objFunc2 调用 构造函数 Func2 内部的局部函数 sayInFunc2() 时,出现报错TypeError: objFunc2.sayInFunc2 is not a function,因为这时候objFunc2在自身找不到这个方法,在原型链上也找不到只能返回 undefined

特殊情况

前面我们讲的只是普通情况的函数与原型链,在 JS 中,还有两种函数是特殊情况:Function构造函数 和 箭头函数

Function构造函数

这里的 Function 构造函数就是 Function.prototype 中的Function

  • 定义: 它是一个函数对象,同时也是 JS 内置函数构造函数(Function Constructor),是 JS 中所有函数对象的构造函数,包括内置函数(如ObjectArray等内置构造函数)、用户自定义的函数、甚至包括它自己——Function(这是最特殊的)。

  • 也就是说它的原型链形成了特殊的自引用闭环Function 的 __proto__ 指向 Function.prototype ,Function.prototype.constructor 指向 Function)。

  • 代码验证: 这里我们将用 JS 中的 instanceof 运算符进行验证。该运算符用于验证一个对象是否为某构造函数的实例。

    // 1. 内置函数(如内置的构造函数Object、Array等)
    // 验证结果均为true,可以看出内置函数的构造函数均为Function
    console.log(Object instanceof Function); // true
    console.log(Array instanceof Function); // true
    
    // 哪怕是中间隔了一层,仍是Function构造函数创建的实例
    console.log(Array.toString instanceof Function); // true
    
    
    // 2. 自定义函数Person
    function Person() { };
    // 验证结果为true,可以看出自定义函数的构造函数为Function
    console.log(Person instanceof Function); // true
    
    
    // 3. Function为原型链自引用
    // 可以先验证一下内置函数与自定义函数是否也满足原型链自引用的情况
    console.log(Person instanceof Person); // false
    console.log(Array instanceof Array); // false
    // 从结果可以看出,内置函数与自定义函数都不满足这个情况
    
    // 只有Function满足原型链自引用
    console.log(Function instanceof Function); // true
    
    // 使用 instanceof 运算符,其实等价于下面的__proto__指向验证
    // 因为Function是函数,也是对象,所以也有__proto__属性
    // 对象的__proto__属性指向构造函数的prototype
    console.log(Function.__proto__ === Function.prototype); // true
    
  • 作用:

    • Function 的原型链出现自引用情况(Function.__proto__ 指向 Function.prototype),这是 JS 原型系统的特殊设计,以确保 JS 的函数系统遵守统一性所有的函数都遵循相同的原型规则,包括Function自身。
    • 这是 JS 动态性的基石之一,也是原型链和元编程的关键。
误解更正
    1. 不存在“Function 自己创建了自己” 这种说法哈,别忘了 Function 是 JS 内置的对象哦,从程序一开始运行就存在于运行环境中,我们也一直强调,Function原型链自引用,这是属性指向问题,不是 “自构建” 这种说法。
    • 这样只是为了表示 Function是一个函数,同时自身也是一个函数对象,遵循了函数与对象的统一性,使得 JS 的函数与对象系统能够逻辑自洽,不出现 BUG。
    1. 虽然 Function.__proto__ 指向 Function.prototype,形成了自引用,但这种 Function 的自引用是 JS 设计师的有意为之,不会造成死循环。看图说话:
    • Function示意图

      图 8 Function.png

  • 从图中可以看出,__proto__属性立下大功,通过Function.prototype.__proto__ === Object.prototype,使得 Function 能够在原型链查找时摆脱自引用,沿着原型链继续向上。
  • 这里我还想说:JS 的引用类型皆为对象 这一设计真的非常巧妙啊!在该大前提下,JS 各特性得以在统一基础上,稳定且有序地发挥自身作用,对象的统一特性成为这些特性的底层支撑。
    • 这也是 Function 为什么使用 自引用 这种反直觉的设计——保持 JS 函数系统的统一性。只要整个 JS 函数系统保持统一,就能稳定运行,减少底层设计冲突带来的BUG
  • 注意: 虽然Function是 JS 内置的构造函数,但是我们一般不使用 “new关键字 + 构造函数” 的形式来创建函数,更常见的是 函数声明function 或是 箭头函数,它们两个是 new Function()方式的语法糖。
    • 示例:

      // 使用 Function 构造函数创建函数
      const add = new Function("a", "b", "return a + b");
      
      // 更常见的方式:使用函数声明或箭头函数
      // 函数声明
      function subtract(a, b) {
          return a - b;
      }
      // 箭头函数
      const multiply = (a, b) => a * b;
      

这边为啥是语法糖我们以后再聊,篇幅要爆炸了😭。

箭头函数

箭头函数是 ES6 推出的简洁版函数,JS 设计师希望箭头函数成为一种轻量级、无状态的函数,专门用于简洁的表达式和词法作用域绑定,而非作为构造对象的蓝图。

  • 说白了,术业有专攻,JS 中的其他函数一开始就是按照构造函数的标准进行设计的,都能创建对象实例;而箭头函数,就不是被设计作为构造函数创建对象的,就是用来当函数表达式的。所以为了从语法层面彻底禁止这种误用,JS 直接移除了箭头函数的 prototype 属性。

  • 验证:

    // 定义了一个简单的箭头函数add
    const add = (x, y) => {
        return x + y;
    };
    
    // 使用 箭头函数add 创建对象会报错
    // const obj = new add(); // TypeError: add is not a constructor
    
    
    // 箭头函数可以像普通函数一样正常执行
    console.log(add(1, 2)); // 3
    // 获取箭头函数的prototype属性时,会发现它并不存在
    console.log(add.prototype); // undefined
    
    // 但是箭头函数也是对象
    // 这时候对象的__proto__属性又立大功,带领箭头函数找到了爹妈
    console.log(add.__proto__ === Function.prototype); // true
    

    图 9 箭头函数.png

三. 其真“继承”耶?

经过前面的学习,我们已拆解原型与原型链的运行机制。此时不难发现:JS 的 “继承” 实在只是一个习惯用语而已,其本质更似基于原型链的委托机制,而非传统面向类语言中的 “属性复制”。
我们常读的“小黄书”——《你不知道的JavaScript》一书中也有相关描述:“相比之下,“委托”是一个更合适的术语,因为对象之间的关系不是复制而是委托。

  • 当对象实例调用某个属性或方法时,它并非 “拥有” 这些成员,而是通过原型链查找到原型对象,然后再委托给该原型对象。
  • 如果原型链上的原型对象都没有这个属性或方法,则返回undefined,也就是爱莫能助了。

实例的 “无中生有”

该实例自身可能不包含某属性,但通过原型链向上查找,会 “借用” 原型对象中的成员。这就像小孩(实例)遇到超过能力范围的问题时,会委托父母(原型对象)处理,而非自己直接解决问题。

  • 示例:

    // 此时parent对象将作为一个原型对象
    const parent = {
        entrust() {
            console.log('父母帮助孩子');
        }
    };
    
    // 以parent对象为原型对象创建实例child
    const child = Object.create(parent);
    
    // 输出结果表示,确实是 实例child 委托 原型对象parent 进行调用entrust()方法
    child.entrust(); // 父母帮助孩子
    

委托的动态性

若修改原型对象的属性,所有依赖该原型的实例都会立即感知变化 —— 这如同一条河流的源头(原型对象)出现了变化,从源头流出的所有支流(实例)都会立即受到影响,这体现了委托关系的实时联动性。

  • 示例:

    // 此时 source 对象将作为一个原型对象
    const source = {
        // 源头径流量此时为 3000
        runoff: 3000
    };
    
    // 以 source 对象为原型对象创建 支流实例 tributary1、tributary2
    const tributary1 = Object.create(source);
    const tributary2 = Object.create(source);
    // 探究两个支流的源头此时的径流量
    console.log(tributary1.runoff); // 3000
    console.log(tributary2.runoff); // 3000
    
    // 修改源头径流量
    source.runoff = 5000;
    // 原型对象source 径流量变化后,所有支流实例均立刻受到影响
    console.log(tributary1.runoff); // 5000
    console.log(tributary2.runoff); // 5000
    

结语

原型与原型链是 JS 中最重要的知识点,许多核心机制都基于原型与原型链。学好这个知识点,我们就相当于学会了天下武学的总纲,从此拿到了高深武学的入场券。
希望这篇博客能帮助到大家,如果有错误或是纰漏,请在评论区指正,大家一起进步,感谢大家支持🙏!