美文网首页Web 前端开发 让前端飞
不一样的面向对象,javaScript原型揭秘

不一样的面向对象,javaScript原型揭秘

作者: 老陈要上天 | 来源:发表于2019-12-30 21:51 被阅读0次

    一、导读

    本篇文章将说清楚javaScript的原型、原型链机制,说的不对评论区砸板砖~
    如果你看了很多篇博客仍然搞不清楚prototype 、_ _ proto _ _、new、constructor的关系,请往下看!
    如果你刚从java换坑到javaScript,请往下看!
    如果你还在满口“实例”、“构造”去理解/阅读javaScript代码的话,请往下看!
    总之,请往下看!

    二、[[prototype]] 属性才真正的叫做原型

    JS中所有的对象被创建时会被赋予一个特殊的隐式属性——[[prototype]] ,它是另一个对象的引用,并且几乎所有的对象的 [[prototype]] 属性都是非空的值。一个对象的 [[prototype]] 属性值就被称为这个对象的原型。一个对象成为另一个对象的原型,就称这两个对象被一条原型链关联起来。如果某个对象A,它的 [[prototype]] 属性值是对象B的引用的话,就称B成为了A的原型。
    JS函数的prototype 属性、_ _ proto _ _属性等其他的和“proto”字眼沾边儿的,我们统统不称为原型。所以下文任何地方出现的“原型”都只表示 [[prototype]] 属性。
    值得一提,上面说所有对象都会有 [[prototype]] 属性。严谨点说是除了null和undefined外所有JS对象都有,包括引用类型和数字、字符串这些基本类型。声明let a = 'hello' 那a也是有原型的,默认是String.prototype

    2.1、用JS标准api -- getPrototypeOf、setPrototypeOf访问原型

    开头讲到,如果某个对象A,它的 [[prototype]] 属性值是对象B的引用,就称B成为了A的原型,A的这个 [[prototype]] 属性是隐式的(JS里应该称为非公有属性),也就是说不能通过.点操作符去访问它,它是JS底层机制。为了灵活性,JS标准依然提供了两个api去访问它(setter和getter)-- getPrototypeOf、setPrototypeOf,Object.getPrototypeOf(A) 返回对象A的原型Object.setPrototypeOf(A,B) 把B设置成A的原型
    从这两个api可以看出,A的原型不是生来就唯一确定的,可以中途修改,所以对象和它原型的关系完全不是“类和实例的关系”,如果用类和实例的思维先入为主的理解话,很多现象解释不通。比如,我定义了一个Bird()函数用来创建一只鸟,语句let myBird = new Bird()执行后,如果认为myBird是Bird的实例好像一点都不违和,好像Bird就是myBird的构造函数,原型就约等于类。但事实是myBird这只鸟的原型是可以改的,let frog = {}; Object.setPrototypeOf(myBird,frog),frog(青蛙)成了这只鸟的原型,这个时候你还能说原型是类吗?显然是解释不通的。这种“死胡同”的现象有很多,造成混淆的一个因素是new操作符,它和java里面的new可不是一回事,后面会解释。所以理解JS原型链的第一步是摒弃“类”、“实例”、“构造”这些思想。

    我认为“贷款人--担保人”是比较合适的例子去形容原型关系:A去银行办贷款,银行要求A填写一个担保人B,如果A没钱还款,银行就找担保人还款。B就可以称为A的原型。这里A可以指定父母、朋友,甚至是一家公司(和A不是一个数据类型)作为自己的担保人,A也可以随时找银行申请重新指定担保人。这里“银行找A还款无果再找B还款”的动作就类似于访问A的属性,不存在的话则沿着原型链往上找,找到则返回,一直找到原型链尽头(和面向对象中访问子类属性找不到后访问父类、祖父类的现象很像)。这个动作在JS被称为委托(本篇不准备展开讲委托)

    2.2、JS对象的 _ _ proto _ _属性

    _ _ proto _ _属性想必大家非常熟悉,非JS标准,是各大浏览器为了方便程序员访问对象的原型而提供的显式属性,可以这样方便的访问对象A的原型:A. _ _ proto _ _,当然 _ _ proto _ _属性也是可以修改的,A. _ _proto _ _ = B是有效的。这个属性也许会成为你理解原型链的绊脚石,到此为止你完全可以认为 _ _ proto _ _ 完全等效于 [[prototype]] 属性,从目前来看他俩意义是一样的,都是表示原型。但是下文将不再出现 _ _ proto _ _ ,仅仅用[[prototype]] 表示原型,一是希望大家将原型当做是JS对象间产生联系的机制,而不是一个属性,二是尽管各家浏览器都“不约而同”的使用了 _ _ proto _ _ 显式的表示原型,但是最终JS标准组也没将它纳入标准中,肯定有个中原因的。
    (不约而同实际都是浏览器厂商向市场的妥协,带头大哥chrome用了 _ _ proto _ _ ,程序员写的网页在chrome上跑得风生水起,在你xxx浏览器就报错,用户只会说xxx浏览器真垃圾,哪会说你chrome不按标准来呢,所以xxx浏览器只能跟上大哥脚步)。

    2.3、JS函数的 prototype 属性

    先说结论:JS函数的prototype属性命名是极为失败的,随便换个名称都能让JS原型链更容易理解,它不表示函数的原型
    无疑,JS函数的prototype 是原型链非常重要的一环。它是JS专门为函数赋予的一个属性,并且是显式属性,它默认也是指向另一个对象。前面讲过,每个对象都有 [[prototype]] 属性(原型),并且只有 [[prototype]] 属性才表示原型。函数也是对象,所以它既有原型又有prototype属性,所以千万别被prototype这个名字给骗了,它不表示函数的原型,包括函数在内的所有对象的原型有且只用[[prototype]] 属性表示
    许多博客解释原型链喜欢用这个例子:

    let Foo = function(){}
    let myFoo = new Foo()
    

    虽然,new是关键,但我不打算管new操作,希望大家先看看let Foo = function(){}的时候发生了什么,然后引出我的观点。分析下面的代码:

    let Foo = function(){}
    console.log(Foo.prototype) //{constructor: ƒ},是一个带有constructor属性的对象
    Object.getPrototypeOf(Foo) === Foo.prototype //false
    Object.getPrototypeOf(Foo) === Function.prototype //true
    

    从上面的代码可以看出,我声明了一个name为Foo的函数,Foo便自动获得prototype属性,并且指向了一个包含constructor属性的全新对象(constructor属性后面再讨论)。再看一下,Object.getPrototypeOf(Foo) === Function.prototype 返回true说明JS系统指定Function.prototype这个对象成为了Foo的原型,Function是JS内置的函数,常常被当做所谓的“构造函数”使用(new Function()),也就是说Foo与系统中某些已经存在的对象产生了联系,一种称为原型的联系。我声明了个函数,系统就指派Function.prototype作为它的原型,显然是根据数据类型去指派的,如果声明一个字符串,那么原型将被指派为String.prototype,JS数据类型就那么几个,他们的原型也是能枚举出来的,我称它为固有原型,下一章就做这个事。

    再回头说说constructor属性,constructor属性也是JS命名的一大败笔,它妄想用“构造函数/构造器”的语义让程序员以为它是构造函数,但它却根本没有“构造”的含义在里面,是constructor这个名字误导了我们。前面说到,let myBird = new Bird()让程序员认为Bird是一个类的原因是new关键字,那么到目前为止我们又遇到一个因素了,这个constructor变本加厉的误导程序员。constructor属性所指向的函数对象是和原型一样,是可以更改的。
    分析下面代码:

    let Bird= function(){}
    let Frog= function(){}
    let myBird = new Bird()
    myBird.constructor === Bird //true
    myBird.constructor = Frog //代码生效,构造函数重新指向到Frog
    myBird.constructor === Bird //false
    

    如果你把constructor 当做构造函数去看待,那表示我很轻易的将小鸟的构造函数改成了青蛙,以后小鸟就是由青蛙构造而来的(WTF)含义上根本说不通。另外,你会发现constructor 到目前为止几乎没有其他用武之地,它纯粹是凑数的属性,所以在你彻底理解原型链之前,请放下这个属性,不要纠结。

    2.5、固有原型链

    首先我认为:每一个对象都不是孤立的,每一个对象诞生之初就已经身在一条原型链之中,只是根据自己的类型不同,身处的位置就不一样,因此程序员每一次的声明、赋值都意义重大,实际是解链再建链的过程。如果你按字面量给对象赋值时,那么就表示让系统给你分配原型,这个系统分配的原型就可以称为固有原型,如果你想自己指定原型就要用Object.create或者new操作符自行指定。先说一下让系统分配原型的情况:

    2.5.1、系统默认分配原型的情况

    1、Number类型—— let obj0 = 1024
    2、String类型—— let obj1 = 'hello'
    3、Boolean类型—— let obj2 = true
    4、Symbol类型—— let obj3 = Symbol('id')
    5、Function类型—— let obj4 = function ()
    6、Object类型—— let obj5 = {name:'张三'}
    7、Array类型—— let obj6 = [1,2,3]
    以上7种方式创建的对象我们在控制台打印出来,它们的原型是:

    let obj0 = 1024
    let obj1 = 'hello'
    let obj2 = true
    let obj3 = Symbol('id')
    let obj4 = function (){}
    let obj5 = {name:'张三'} 
    let obj6 = [1,2,3]
    Object.getPrototypeOf(obj0) === Number.prototype  //true
    Object.getPrototypeOf(obj1) === String.prototype  //true
    Object.getPrototypeOf(obj2) === Boolean.prototype  //true
    Object.getPrototypeOf(obj3) === Symbol.prototype  //true
    Object.getPrototypeOf(obj4) === Function.prototype  //true
    Object.getPrototypeOf(obj5) === Object.prototype  //true
    Object.getPrototypeOf(obj6) === Array.prototype  //true
    

    拿let obj0 = 1024举例,可以看到,声明obj0 并直接赋初值1024时,JS系统就默认指定了obj0的原型为Number.prototype(严格上应该说成obj0的原型和Number的prototype属性指向了同一个对象,因为总不能拿内存地址来解释,只能暂时把这个对象就称为Number.prototype)。前面说到,JS只有函数才有prototype属性,所以Number是个函数,并且Number.prototype是个object

    typeof Number === 'function' //true
    typeof Number.prototype === 'object' //true
    

    那么Number和Number.prototype也都有各自的原型。这里直接给出结果:


    let obj = 1024执行后,系统默认给obj分配的原型链

    Number.prototype的原型是Object.prototype,Number的原型是Function.prototype,Function.prototype的原型也是Object.prototype,再往上游Object.prototype的原型就是null了,意味着链到头儿了。其他类型的对象的原型和Number类型对象如出一辙,用一张总图表示:

    各种类型的对象的原型
    1、 Number.prototype > Object.prototype > null,这样的一条原型链便是固有原型链,每一个变量对象的诞生,都伴随着一条链的诞生。任何一个对象一定处在一条原型链的其中一环。
    2、前面讲到,可以用setPrototypeOf这个api随意设置对象的原型,固有的原型链也可以改,但是99.99%的情况下,你不会去改它的。MDN上说改变一个对象的原型,开销比较大,更合适的做法是用Object.create创造一个新的原型链。
    3、前面讲到Number、String、Boolean、Symbol、Function、Object、Array这些都是函数,所以它们各自的原型都是Function.prototype。
    4、大部分情况下,typeof为object的对象,它的原型是Object.prototype,只是数组有些区别,JS的怪异行为(缺陷)导致typeof [1,2,3] 等于object,而数组对象的原型是Array.prototype,而不是Object.prototype。
    5、null是所有原型链的尽头。null和undefined本身语义就表示“空的”、“没定义”,因此没有原型。尝试用getPrototypeOf(null)、getPrototypeOf(undefined)会报语法错。
    6、虽说setPrototypeOf可以随意设置,但是类型必须是object类型的对象、函数或者null)。尝试设置setPrototypeOf({},1024)会报语法错。
    7、前面讲到,尝试访问对象A的属性,不存在的话则沿着原型链往上找,找到则返回,一直找到原型链尽头。所以系统内置的这几个固有原型,上面承载了很多通用api。比如:
    number类型对象的原型Number.prototype,就被JS内置了toFixed、toPrecision等api,
    let obj0 = 1024
    obj0.toFixed(2)  //1024.00,精确到小数点后两位
    

    就是obj0对象去自己原型上借来的toFixed函数使用。


    Number.prototype对象上内置的api

    2.6、自行指定原型

    可以通过Object.create和new操作符,在赋值时自行指定原型。

    1、new操作符方式

    再来看看前面的示例代码:

    let Foo = function(){}
    let myFoo = new Foo()
    

    我们知道了Foo是函数类型的对象,所以它有Foo.prototype属性,并且Foo的原型是Function.prototype。new操作符会执行Foo函数,并且创造一个object类型的空对象,并返回,同时把这个空对象的原型指定为Foo.prototype。所以myFoo是一个空的对象,它的原型是Foo.prototype。
    1、new操作符会自动把返回的对象的原型指定为Foo的prototype。

    let Foo = function(){}
    let myFoo = new Foo()
    Object.getPrototypeOf(myFoo) === Foo.prototype //true
    

    2、myFoo是空对象这一点很重要,常常被忽略,(除非Foo执行完有确实返回了对象)。myFoo之所以能访问到Foo上的属性,完全是通过原型机制实现的,这和传统面向对象的实例化概念是完全不一样的,传统面向对象类定义的属性,在类实例化时,实例就实实在在的拥有了这个属性。
    3、无论Foo执行完返回的类型是什么,经过new后都会变为object类型。

    let Foo = function(){ 
        return 1024
     }
    let myFoo = new Foo()
    typeof myFoo //object
    

    4、既然new会把myFoo的原型指定为Foo的prototype,而函数才有prototype属性,所以new只能作用函数对象,作用于其他类型的对象会报语法错。

    let Foo = {name:'张三'}
    let myFoo = new Foo() //报Foo is not a constructor
    

    所以到这里就能看清new的面目了,和构造实例一点儿关系都没有。再看看和new作用差不多的Object.create。

    2、Object.create方式
    let Foo = function(){}
    let myFoo = Object.create(Foo)
    

    1、Object.create(Foo)执行的结果是创造一个空的对象,并返回,同时将返回的对象的原型指定为Foo本身

    let Foo = function(){}
    let myFoo = Object.create(Foo)
    Object.getPrototypeOf(myFoo) === Foo //true
    

    2、Object.create(Foo)和new操作符不同,不必要求Foo是函数。但是因为Foo要作为别人的原型,所以Foo就要满足原型的类型限制(object对象、函数或者null),否则会报语法错。
    3、如果Foo是函数,new操作符会执行一次Foo,而Object.create不会执行Foo,因为它的本质只是要取Foo本身作为它的原型而已,不需要Foo执行。因为Foo不会执行,所以Foo函数返回任何都不会影响到myFoo,所以无论Foo是函数还是普通对象,Object.create(Foo)返回的永远是空对象

    let Foo =  { age:24 }
    let myFoo = Object.create(Foo)
    myFoo.age //24
    myFoo.hasOwnProperty('age')  //false
    
    let Foo = function(){
      console.log('Foo执行')
      return { age:24 }
    }
    let myFoo = Object.create(Foo)  //Foo没执行
    myFoo.age //undefined
    myFoo.hasOwnProperty('age')  //false
    

    这点非常重要,myFoo能访问Foo的属性完全是因为Foo是myFoo的原型。一步小心就会造成下面这种情况:

    let Foo =  { age:24 }
    let myFoo = Object.create(Foo)
    let myFoo1 = Object.create(Foo)
    myFoo.age = 25
    myFoo1.age //25
    

    myFoo通过=赋值改变了原型链上的age属性值,导致污染全局。

    3、instanceof操作符

    解释了new操作符和构造实例没关系,还有一个和实例有关系的操作符——instanceof,它和实例也一点关系都没有。myFoo instanceof Foo 回答的不是myFoo是否是Foo的一个实例,而是回答Foo.prototype是否是myFoo原型链上的一环,看两个例子:

    let Foo = function(){ }
    let myFoo = new Foo()
    Object.getPrototypeOf(myFoo) === Foo.prototype //true
    myFoo instanceof Foo  //true,显然Foo.prototype就是myFoo的原型(第一环)。所以返回true。
    

    接着这个例子,我们创造一个新的原型链:

    let a0 = {age:24}
    let a1 = Object.create(a0)
    let a2 = Objece.create(a1)
    

    经过前文Object.create的作用和固有原型链的介绍,我们知道,JS为我们创建了这么一条原型链
    a2 > a1 > a0 > Object.prototype > null
    这时执行:

    Object.setPrototypeOf(myFoo,a2)  //把myFoo的原型指定为a2
    

    那么myFoo的原型链就是:
    myFoo > a2 > a1 > a0 > Object.prototype > null
    再执行:

    Foo.prototype = a0 //Foo.prototype属性重新赋值
    myFoo instanceof Foo //true
    

    Foo.prototype重新指向了a0,a0位于myFoo的原型链上,所以myFoo instanceof Foo返回true。同理把Foo.prototype指向a2、a1、a0甚至Object.prototype,myFoo instanceof Foo都会返回true。所以instanceof 虽然有“实例”的语义,却和实例没有关系,本质还是原型链机制。

    ES6有提出一个isPrototypeOf的api,它的作用和instanceof很像,但也有点误导人,Foo.isPropertyOf(myFoo)语义上好像是回答Foo是myFoo的原型吗?但是实际是回答Foo是myFoo原型链上的一环吗?所以上面a1.isPrototypeOf(myFoo)a2.isPrototypeOf(myFoo)都会返回true。

    总结:

    1、JS的原型链机制和类-实例机制完全不同,有相似之处,但不要用传统面向对象的思想去理解JS代码,遇到10个场景可能有9个都能解释得通,但总有那么1个你解释不了,比如前文讲到的“鸟由青蛙构造而来”一样。

    2、JS只有[[prototype]]这隐式属性才叫做原型,prototype是函数的一个属性,专属于函数,这个属性和原型大有关系。反之,有prototype属性的对象一定是函数。

    3、JS两个对象之间通过原型链产生联系,原型链上的对象之间不会发生复制,尽管你是我的原型,但你还是你,我还是我,我不会复制你身上的属性到我身上,我只是想办法引用你。

    4、每个对象诞生之时就已经处于一条原型链中,如果不指定就由JS自动分配原型,JS根据赋值时数据类型来分配到具体的链上,称为固有原型链。原型链上可以定义通用的api。一旦发生赋值操作,如果赋值前后的类型不一致,就会发生断链和接新链的操作,这种操作是有开销的,所以尽量保持变量类型一致。

    5、抛弃了“构造”、“实例”,忘掉了_ _ proto _ _、constructor才能有助于理解原型。

    6、new操作符和构造实例一点儿关系都没有,它的功能是创造新的原型链,和它功能很相似的是Object.create,但是两者有很多区别。

    7、instanceof 也和实例没有关系,myFoo instanceof Foo 回答的不是myFoo是否是Foo的一个实例,而是回答Foo.prototype是否是myFoo原型链上的一环。

    8、JS标准小组努力把自己往传统面向对象上靠,包括ES6出的class,但是太仓促了,有时候某些机制简直匪夷所思。

    举个匪夷所思的例子,看下面代码:

    let obj = {age:24}
    Object.defineProperty(obj,'age',{
      writable:false,
      configurable:false,
      enumerable:true,
    })
    obj.age = 25  //writable为false,只读,所以赋值不生效
    
    let newObj = Object.create(obj)
    newObj.hasOwnProperty('age') //false。前文分析过,obj1是空对象,没有age属性
    newObj.age = 26
    newObj.hasOwnProperty('age') //false,为什么还是false?
    

    Object.defineProperty是用来设置对象属性的描述符,将obj的age属性的writable设置为false(只读),所以用=赋值25不生效,这个没问题,但是用Object.create创建newObj后发现newObj .age = 26 竟然也没有生效!!newObj依然没有age这个属性。这个很奇怪,newObj竟然被原型链对象内的同名属性影响到了,貌似“继承”了writable:false,但事实却不是继承,只是JS在刻意的模仿类属性的继承,这么模仿的结果就是让程序员匪夷所思,不知道改怎么办!!
    更令人费解的是,newObj用=赋值没生效,但用Object.defineProperty赋值又可以。接上面的代码:

    Object.defineProperty(newObj,'age',{
      value:27,
      writable:false,
      configurable:false,
      enumerable:true,
    })
    newObj.hasOwnProperty('age') //true
    newObj.age //27
    

    用Object.defineProperty赋值27是生效的,并且把writable改成true也是生效的。
    还有一点是newObj对象age属性的configurable不管原型上设置的是什么,都不会产生影响(不继承,符合预期),有的会有继承的现象发生,有的又不会,我太难了~~。
    JS发展十多年来,不少设计缺陷令人抓狂,要修复bug,兼容是不可能了,没有别的招,只能新推出严格模式,或者靠TpyeScript力挽狂澜。

    相关文章

      网友评论

        本文标题:不一样的面向对象,javaScript原型揭秘

        本文链接:https://www.haomeiwen.com/subject/snntnctx.html