前端边角料 | 浅析 JavaScript 语言的原型与原型链

关键词:原型 原型链 类

这是我参与11月更文挑战的第3天,活动详情查看:2021最后一次更文挑战

背景

对于原型/原型链这个概念,除了日常工作中就是面试的时候会遇到了。但是在搜罗了大部分跟这两者相关的文章后,发现大家还是没有很简短的定义出什么是原型,什么是原型链。往往还没有一个明确的定义,就开始做示例。所以我在阅读的过程中也感到很困惑。

因此决定自己好好查一下资料,包括翻阅已出版的 JavaScript 相关的书籍,以及国内、国外对原型/原型链的讨论或文章。有了一些自己的体会吧,也希望可以把这段时间的收获写出来,从不同的视角分享给大家。

目录

  • 下定义-狭义的理解
  • 寻历史-广义的理解
  • 理思路-个人理解

下定义-狭义的理解

减少误导,客观阐述

其实和现有的很多文章一样,在一些比较出名的书中,对原型、原型链的解释也是比较模糊。往往还没有输出比较明确的定义,就开始用代码作为示例,这让我看的很困惑。为了能够相对准确的给自己一个概念定义,我参考了不少书籍和文章,选择了如下的说法。

  • 原型:

    • MDN 解释:JavaScript 常被描述为一种基于原型的语言 (prototype-based language)——每个对象拥有一个原型对象,对象以其原型为模板、从原型继承方法和属性。
    • 红宝书(《JavaScript 高级程序设计(第4版)》,下同)解释:每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。这个对象就是通过调用构造函数创建的对象的原型。
  • 原型链:

    • MDN 解释:原型对象也可能拥有原型,并从中继承方法和属性,一层一层、以此类推。这种关系常被称为原型链 (prototype chain),它解释了为何一个对象会拥有定义在其他对象中的属性和方法。
    • 红宝书解释:每个构造函数都有一个原型对象,原型有一个属性指回构造函数,而实例有一个内部指针指向原型。如果原型对象本身是另一个类型的实例,那就意味着这个原型本身有一个内部指针指向另一个原型,相应地另一个原型也有一个指针指向另一个构造函数。这样就在实例和原型之间构造了一条原型链,而原型链的对顶层指向了 Object.prototype , 此原型对象的原型 __proto__null
  • 补充说明:

    • 在 JavaScript 中,只要创建一个函数,就会按照特定的规则为这个函数创建一个 prototype 属性(指向原型对象)。默认情况下,所有原型对象自动获得一个名为 constructor 的属性,指向与之关联的构造函数。
    • 每次调用构造函数创建一个新实例时(如:new XXXFunction()),这个实例内部 [[Prototype]] 指针就会被赋值为构造函数的原型对象。脚本中没有访问这个 [[Prototype]] 特性的标准方式,但是常见的浏览器会在每个对象上暴露 __proto__ 属性,通过这个属性可以访问对象的原型。

寻历史-广义的理解

知其然,知其所以然

在了解了什么是原型和原型链之后,我开始对“为什么 JavaScript 要使用原型,以及它解决的问题”产生了兴趣,并且在初步的查阅资料之后,有了一定的收获。

先抛答案,JavaScript 选择原型、原型链作为面向对象这种编程范式的一种实现。我们常说的面向对象思想的编程范式,有两种主要的实现方式,一种是基于原型的编程实现,一种是基于类的编程实现。

在 JavaScript 诞生之初,语言设计的作者 Brendan Eich 最开始选择了基于原型的编程,用于实现 JavaScript 的对象的继承。这门语言还参考了其他语言的一些特性。

  • 借鉴C语言的基本语法;
  • 借鉴Java语言的数据类型和内存管理;
  • 借鉴Scheme语言,将函数提升到"第一等公民"(first class)的地位;
  • 借鉴Self语言,使用基于原型(prototype)的继承机制。

在刚接触 JavaScript 时,很多文章和教程推荐用类似 Java 等基于类实现继承的语言一样,将代码的风格也写成仿照类继承的实现。这一点,在我查资料时有很深感悟。因为作为一门动态脚本语言,在被更多人接受的过程中,往往人们会用自己更熟悉的方式来实现一些已经很熟悉的功能。也就是为什么会有“将 JavaScript 语言的继承写的更像 Java 的继承”。

但是在更深入的了解之后,发现 JavaScript 这种基于原型的编程范式,有其独特的优势。就拿面向对象编程思想中最重要的继承特性来讲,JavaScript 中有3种方式实现继承:

  • 基于构造器的继承
        function Rectangle(width, height) {
        this.height = height;
        this.width = width;
        }
        
        Rectangle.prototype.area = function () {
            return this.width * this.height;
        };
        
        var rect = new Rectangle(5, 10);
    复制代码
  • 基于原型的继承
    var square = Object.create(rectangle);
    
    square.create = function (side) {
        return rectangle.create.call(this, side, side);
    };
    
    var sq = square.create(5);
    复制代码
  • 基于类的继承(ES6以后支持)
    class A {}
    class B extends A {
    }
    let bObj = new B()
    复制代码

上述仅做代码示例,不做深入分析,同时需要知道的是,ES6以后支持的基于类的继承底层也是一种基于原型继承的语法糖,并非一种特殊的新特性。可以看到,基于 JavaScript 本身,更期望开发者充分利用的是基于原型的继承实现。也就是说更多的利用 JavaScript 这门语言基于原型编程的特性。

在去寻找这一过程中,也有看到有人在说 ECMAScript 标准在引入 class 这一特性的背后也像是在引导用户将 JavaScript 语言作为一种基于类编程的面向对象的语言实现方向上走。其实我的理解是,JavaScript 本身是早于 ECMAScript 标准的,即使引入了 class 的也并有改变 JavaScript 底层的设计思路,底层仍然是基于原型的实现。在我看来更像是为了减少大家在应用层面对原型、类这两者的混淆和争论,更关注于应用的开发和新特性的拓展。当然这一观点仅是我个人的感受,也许仅符合现在的情况。

理思路-个人理解

用自己的话,复述一遍

JavaScript 是基于原型的方式实现面向对象的编程范式。而不是像 Java / C# 这种基于类的方式实现面向对象的语言。但是在应用的过程中用户们往往用仿类的方式(new + 构造器)的方式来实现面向对象。

原型是实现面向对象编程的一种方式,JavaScript 通过 prototype 实现。

原型链是继承的一种实现方式,通过 prototype 的一层一层指向,让对象可以拥有其本身类没有的属性或方法,当其自身类没有找到对应的属性或方法时,会向上一层原型中寻找,直到最终的 Object 类,如果还没有,会指向 null。以此形成的一个链式调用,可以成为原型链。

在 JavaScript 中,只有在查询属性时才会体会到继承的存在,而设置属性则和继承无关。

原型及原型链代码解释:

  • js 中万物皆对象,每个对象的属性中都有一个 __proto__ 的属性,这个属性就是原型。它可以包含任何属性用于继承,所以 __proto__(原型)本身也是一个对象,所以有了.__proto__.__proto__ 这样一级一级向上,从而构成了原型链。
  • 举例:
    function F(){};
    let f1 = new F();
    
    console.log(f1.__prorto__); 
    // {
    //    constructor: f F(),
    //    [[Prototype]]: Object
    //     ...
    // }
    console.log(f1.__proto__ === F.prototype); // true
    console.log(F.prototype.__proto__ === Object.prototype);// true
    console.log(Object.prototype.__proto__); // null
复制代码
  • 分析:

    • f1.__proto__ 的值是 F.prototype,这个值是包含了 constructorprototype 对象
    • F.prototype 这个对象的 __proto__ 指向了 Object.prototype,同样,它也包含了一个 constructorprototype 对戏
    • Object 已经是顶层的对象了,所以 Object.prototype.__proto__ 指向了 null,至此原型链达到了顶点
  • 原型链经典示例图:

图1.png

  • 参考示例图,对上述代码理解:

    • f1.__proto__ 到最后的 nullf1.__proto__ 的值和 F.prototype 的值相同,都包含了 constructorprototype 对象,正因为这是一个对象,暂且叫这个对象为 f1_obj,可以视为 f1_obj = new Object() 的实例(虽然不准确,但是可以这么理解),因此才会有 f1_obj.__proto__ 等于 Object.prototype 的值
    • F.prototype.constructor.__proto__ 指向 Function.prototypeF.prototype 为一个包含了 constructorprototype 的对象,这个对戏的构造函数就是 functoin(){} 本身,暂且叫这个对象为 f_obj,在 js 中,可以视为 f_obj = new Function() 的实例(虽然不准确,但可以这么理解),因此才会有 f_obj.__proto__ === Function.prototype
    • Function.prototype 到最后的 nullFunction.prototype 也是个对象,所以它的 __proto__ 也会指向 Object.prototype,最终会指向 null
    • 关于 Object 的特殊情况,Object() 作为构造函数,也是由 Function 创建的,所以视为一个函数对象,因此它的 __proto__ 也指向 Function.prototype
    • Function 自身的 __proto__prototype 都指向 Function.prototype ,这块涉及到底层 JS 在设计的时候,Object 如何产生的问题,暂时没有深挖
  • 注意点:

    • Chrome 中时无法100%复原原型链经典图的链路,因为在 F.prototype. constructor.__proto__指向Function.prototype 的之后就已经返回为 [native code]
    • 但是可以再 Firefox 中复原整个原型链经典图的链路,尤其是 F.prototype. constructor.__proto__指向Function.prototype 这一块

杂谈

  • 一些感想:
    • 有时候仅关注语言的实现及应用,会对一些语言特性产生困惑,比如 JavaScript 的原型,这个时候可以换个角度去看一看为何如此设计,更高一层面的去理解这个特性,很多问题就很容易理解了
    • JavaScript 的原型设计初衷是借鉴了 Self 语言,更合适的继承方式应该是基于原型的继承,但在大家使用的过程中更多的仿照了基于类的继承,所以不断会有人疑惑,在JS中面向对象为什么既不是完全基于类的写法,却又相似,但本质又引入了原型这种概念。
    • 遇到问题多问问为什么,会有意外的收获。
  • 编程语言的拓展:
    • 编程范型、编程范式或程序设计法(英语:Programming paradigm),是指软件工程中的一类典型的编程风格。常见的编程范型有:函数式编程、指令式编程、过程式编程、面向对象编程等等。下面我就对自己有过了解的一些编程范式做一个小介绍,基本来源于维基百科。
    • 过程式编程:过程式程序设计(英语:Procedural programming),又称过程化编程,一种编程典范,派生自指令式编程[1],有时会被视为是同义语。主要要采取过程调用或函数调用的方式来进行流程控制。流程则由包涵一系列运算步骤的过程(Procedures),例程(routines),子程序(subroutines), 方法(methods),或函数(functions)来控制。在程序执行的任何一个时间点,都可以调用某个特定的程序。任何一个特定的程序,也能被任意一个程序或是它自己本身调用。
    • 函数式编程:函数式编程(英语:functional programming)或称函数程序设计、泛函编程,是一种编程范式,它将电脑运算视为函数运算,并且避免使用程序状态以及易变对象。其中,λ演算为该语言最重要的基础。而且,λ演算的函数可以接受函数作为输入参数和输出返回值。函数式编程更加强调程序执行的结果而非执行的过程,倡导利用若干简单的执行单元让计算结果不断渐进,逐层推导复杂的运算,而不是设计一个复杂的执行过程。在函数式编程中,函数是第一类对象,意思是说一个函数,既可以作为其它函数的输入参数值,也可以从函数中返回值,被修改或者被分配给一个变量。
    • 面向对象程序设计:面向对象程序设计(英语:Object-oriented programming,缩写:OOP)是种具有对象概念的编程典范,同时也是一种程序开发的抽象方针。它可能包含数据、特性、代码与方法。对象则指的是类(class)的实例。它将对象作为程序的基本单元,将程序和数据封装其中,以提高软件的重用性、灵活性和扩展性,对象里的程序可以访问及经常修改对象相关连的数据。在面向对象程序编程里,计算机程序会被设计成彼此相关的对象。
      • 基于原型编程:基于原型编程(英语:prototype-based programming)或称为原型程序设计、原型编程,是面向对象编程的一种风格和方式。在原型编程中,行为重用(在基于类的语言通常称为继承)是通过复制已经存在的原型对象的过程实现的。这个模型一般被认为是无类的、面向原型、或者是基于实例的编程。
      • 基于类编程:基于类编程(英语:class-based programming),又称基于类的编程、面向类(class-orientation),是面向对象编程(OOP)的一种风格,在程序设计时,强调对象(object)的类别(class)。在这种编程范型中,一个对象必须基于类别,才能被创造出来;此乃它跟重视对象本身的基于原型编程的差异。
    • 面向切面的程序设计:面向切面的程序设计(Aspect-oriented programming,AOP,又译作面向方面的程序设计、剖面导向程序设计)是计算机科学中的一种程序设计思想,旨在将横切关注点与业务主体进行进一步分离,以提高程序代码的模块化程度。通过在现有代码基础上增加额外的通知(Advice)机制,能够对被声明为“切点(Pointcut)”的代码块进行统一管理与装饰,如“对所有方法名以‘set*’开头的方法添加后台日志”。该思想使得开发人员能够将与代码核心业务逻辑关系不那么密切的功能(如日志功能)添加至程序中,同时又不降低业务代码的可读性。面向切面的程序设计思想也是面向切面软件开发的基础。
  • 一些编程范式的小结
    • 面向过程编程:一种以过程为中心的编程思想
      • 优点:性能比面向对象高,因为类调用时需要实例化,开销比较大,比较消耗资源;比如单片机、嵌入式开发、 Linux/Unix等一般采用面向过程开发,性能是最重要的因素。
      • 缺点:没有面向对象易维护、易复用、易扩展
      • 举例:C
    • 面向对象编程:封装、继承、多态
      • 优点:易维护、易复用、易扩展,由于面向对象有封装、继承、多态性的特性,可以设计出低耦合的系统,使系统 更加灵活、更加易于维护
      • 缺点:性能比面向过程低
      • 举例:Java、C++、C#
    • 面向原型编程:是一种 面向对象编程 的风格。在这种风格中,我们不会显式地定义类 ,而会通过向其它类的实例(对象)中添加属性和方法来创建类,甚至偶尔使用空对象创建类
      • 举例:Self 、JavaScript
    • 函数式编程:主要思想是把运算过程尽量写成一系列嵌套的函数调用
      • 特点:函数是"第一等公民"、只用"表达式",不用"语句"、没有"副作用"、不修改状态、引用透明
      • 意义:代码简洁,开发快速;接近自然语言,易于理解;更方便的代码管理;易于"并发编程";代码的热升级
      • 举例:Lisp,Erlang

参考资料

浏览知识共享许可协议

知识共享许可协议
本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。

猜你喜欢

转载自juejin.im/post/7032610062233239582