做前端开发有段时间了,遇到过很多坎,若是要排出个先后顺序,那么JavaScript的原型与对象绝对逃不出TOP3。

如果说前端是海,JavaScript就是海里的水

一直以来都想写篇文章梳理一下这块,为了加深自己的理解,也为了帮助后来者尽快出坑,但总觉缺少恰当的切入点,使读者能看到清晰的路径而非生硬的教科书。最近看到句话“好的问题如庖丁之刃,能帮你轻松剖开现象直达本质”,所以本文以层层探问解答的方式,试图提供一个易于理解的角度。

现在的软件开发,很少有不是面向对象的,那么JavaScript如何创建对象?

一、 创建对象的方法

在传统的面向对象编程语言(如:C++,Java等)中,都用定义类的关键字class,首先声明一个类,然后再通过类实例化出对象实例。但在JavaScript中若实现这样逻辑的对象创建,需要先定义一个代表类的构造函数,再通过new运算符执行构造函数实例化出对象。

  1. 对象字面量

    var object1 = { name: "object1" }
  2. 构造函数法

    var ClassMethod = function() {
        this.name = "Class"
    }
    var object2 = new ClassMethod()
    // 这种方式创建的对象字面量
    var object3 = new Object({ name: "object3" })

    这里提到的new运算符,后面会详述

  3. Object.create(proto)
    创建一个新对象,使用入参proto对象来提供新创建的对象的__proto__,也就入参对象时新创建对象的原型对象。

    var Parent = { name: "Parent" }
    var object4 = Object.create(Parent)
想要明白JavaScript原型继承的幺蛾子,势必要搞清楚原型对象、实例对象、构造函数以及原型链的概念和关系,接下来我尽量做到表述地结构清晰,言简意赅。

二、原型继承

暂时搁置一下原型链,我先讲清楚其余三个概念的门门道道,如果你手边有纸笔最好,没有在脑中想象也不复杂。

  1. 画一个等边三角形,从顶点顺时针为每个角编号(1)、(2)、(3)
  2. 其中(1)旁边标注“原型对象”,(2)构造函数,(3)实例对象
  3. 从(2)构造函数(如上节例中的ClassMethod)指向(3)实例对象(上节例中的object2)画一条带箭头的线。线上注明new运算符,表示var object2 = new ClassName()
  4. 从(2)构造函数指向(1)原型对象画一条带箭头的线。线上标注prototype,表示该构造函数的原型对象等于ClassName.prototype。(函数都有prototype属性,指向它的原型对象)
  5. 从(3)实例对象指向(1)原型对象画一条带箭头的线。线上标注__proto__,表示该实例对象的原型对象等于object2.__proto__,结合第4步,便有ClassName.prototype === object2.__proto__
  6. 从(1)原型对象指向(2)构造函数画一条带箭头的线。线上标注constructor,表示该原型对象的构造函数等于ClassName === object2.__proto__.constructor

关于JavaScript函数与对象自带的属性有一句需要画重点的话:所有的对象都有一个__proto__属性指向其原型对象,所有的函数都有prototype属性,指向它的原型对象。函数其实也是一种对象,那么函数便有两个原型对象。由于平时更关注对象依据__proto__属性,指向的原型对象所构成的原型链,为了区分函数的两个原型,便将__proto__所指的原型对象称作隐式原型,而把prototype所指向的原型对象称作显示原型

看到这里你应该已经知道原型对象、实例对象、构造函数以及原型链是什么了,但是对于为什么是这样应该还比较懵,因为我也曾如此,用以往类与对象,父类与子类的概念对照原型与实例,试图想找出一些熟悉的关系,让自己能够理解。

人们总是习惯通过熟悉的事物,类比去认识陌生的事物。这或许是一种快速的方式,但这绝对不是一种有效的方式。类比总会让我们轻视逻辑推理

三、从instanceof再看原型链

语法格式为object instanceof constructor,从字面上理解instanceof,是用来判断object是否为constructor构造函数实例化出的对象。但除此之外,若构造函数所指的显示原型对象constructor.prototype存在于object的原型链上,结果也都会为true

字面理解多少会有些偏差,请及时查阅MDN文档

原型链就是JavaScript相关对象之间,由__proto__属性依次引用形成的有向关系链,原型对象上的属性和方法可以被其实例对象使用。(这种有向的父子关系链就具有了实现类继承的特性)

四、new运算符

new Foo()执行过程中,都发生了什么?

以下三步:

  1. 创建一个继承自Foo.prototype的新对象。
  2. 执行构造函数Foo,并将this指针绑定到新创建的对象上。
  3. 如果构造函数返回一个对象,则这个对象就是new运算符执行的结果;如果没返回对象,则使用第一步创建出的新对象。

为了直观的理解,这里自定义一个函数myNew来模拟new运算符

function myNew(Foo){
    var tmp = Object.create(Foo.prototype)
    var ret = Foo.call(tmp)
    if (typeof ret === 'object') {
        return ret
    } else {
        return tmp
    }
}

五、实现继承

在ES6中,出现了更为直观的语法糖形式:class Child extends Parent{},但这里我们只看看之前没有这种语法糖是怎么实现的。我一直有一个体会:要想快速的了解一个事物,就去了解它的源起流变

首先定义一个父类Parent,以及它的一个属性name:

function Parent() {
    this.name = 'parent'
}

接下来如何定义一个继承自Parent的子类Child

  1. 构造函数方式

    function Child() {
        Parent.call(this)
        this.type = 'subClass' // ... 这里还可定义些子类的属性和方法
    }

    这种方式的缺陷是:父类原型链上的属性和方法不会被子类继承。

  2. 原型链方式

    function Child() {
        this.type = 'subClass'
    }
    Child.prototype = new Parent()

    这种方式弥补了子类没法继承父类原型链上属性和方法的缺陷,与此同时又引入一个新的问题:父类上的对象或数组属性会引用传递给子类实例。
    比如父类上有一个数组属性arr,现通过new Child()实例化出两个实例对象c1c2,那么c1对其arr属性的操作同时也会引起c2.arr的改变,这当然不是我们想要的。

  3. 组合方式(综合1,2两种方式)

    function Child() {
        Parent.call(this)
        this.type = 'subClass'
    }
    Child.prototype = new Parent()

    虽然解决了上述问题,但明显看到这里构造函数执行了两遍,显然有些多余。

  4. 组合优化方式

    function Child() {
        Parent.call(this)
        this.type = 'subClass'
    }
    Child.prototype = Parent.prototype

    这种方式减少了多余的父类构造函数调用,但子类的显示原型会被覆盖。此例中通过子类构造函数实例化一个对象:var cObj = new Child(),可以验证出实例对象的原型对象,是父类构造函数的显示原型:cObj.__proto__.constructor === Parent,显然这种方式依旧不很完美。

  5. 终极方式

    function Child() {
        Parent.call(this)
        this.type = 'subClass'
    }
    Child.prototype = Object.create(Parent.prototype)
    Child.prototype.constructor = Child

    实例对象的__proto__属性值总是该实例对象的构造函数的prototype属性。这里关于构造函数的从属关系存在一个易混淆的点,我多啰嗦几句来试图把这块讲清楚:还记的上面我们画的那个三角形么?三个角分别代表构造函数、实例对象和原型对象,三条有向边分别代表new,__proto__,prototype,根据__proto__有向边串联起来链便是原型链。

    要解释清楚构造函数的从属关系,我们先在上面所画的原型链三角形中的每个三角形中,添加一条有向边:从原型对象指向构造函数,这表示原型对象有一个constructor属性指向它的构造函数,而该构造函数的prototype属性又指向这个构造函数,于是便在局部形成了一个有向环。

    现在一切都协调了,唯独还有一点,就是原型链末端的实例对象构造函数的指向,不论通过new运算符还是通过Object.create创建出来的实例对象的constructor属性,都和其原型对象的constructor相同。所以为了保持一致性便有了上面那句Child.prototype.constructor = Child,为的是在你想要知道一个对象是由哪个构造函数实例化出来的,可以根据obj.__proto__.constructor获取到。

  6. 多继承

    function Child() {
        Parent1.call(this)
        Parent2.call(this)
    }
    Child.prototype = Object.create(Parent1.prototype)
    Object.assign(Child.prototype, Parent2.prototype)
    Child.prototype.constructor = Child

    利用Obejct.assign方法将Parent2原型上的方法复制到Child的原型。


sept08
235 声望7 粉丝