整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

一篇文章告诉你JavaScript 如何实现继承

景简介

JavaScript 在编程语言界是个特殊种类,它和其他编程语言很不一样,JavaScript 可以在运行的时候动态地改变某个变量的类型。

比如你永远也没法想到像isTimeout这样一个变量可以存在多少种类型,除了布尔值true和false,它还可能是undefined、1和0、一个时间戳,甚至一个对象。

如果代码跑异常,打开浏览器,开始断点调试,发现InfoList这个变量第一次被赋值的时候是个数组:

[{name: 'test1', value: '11'}, {name: 'test2', value: '22'}]

过了一会竟然变成了一个对象:

{test1:'11', test2: '22'}

除了变量可以在运行时被赋值为任何类型以外,JavaScript 中也能实现继承,但它不像 Java、C++、C# 这些编程语言一样基于类来实现继承,而是基于原型进行继承。

这是因为 JavaScript 中有个特殊的存在:对象。每个对象还都拥有一个原型对象,并可以从中继承方法和属性。


提到对象和原型,有如下问题:

  1. JavaScript 的函数怎么也是个对象?
  2. proto和prototype到底是啥关系?
  3. JavaScript 中对象是怎么实现继承的?
  4. JavaScript 是怎么访问对象的方法和属性的?


原型对象和对象的关系

在 JavaScript 中,对象由一组或多组的属性和值组成:

{
  key1: value1,
  key2: value2,
  key3: value3,
}

在 JavaScript 中,对象的用途很是广泛,因为它的值既可以是原始类型(number、string、boolean、null、undefined、bigint和symbol),还可以是对象和函数。


不管是对象,还是函数和数组,它们都是Object的实例,也就是说在 JavaScript 中,除了原始类型以外,其余都是对象。

这也就解答了问题1:JavaScript 的函数怎么也是个对象?

在 JavaScript 中,函数也是一种特殊的对象,它同样拥有属性和值。所有的函数会有一个特别的属性prototype,该属性的值是一个对象,这个对象便是我们常说的“原型对象”。

我们可以在控制台打印一下这个属性:

function Person(name) {
  this.name = name;
}
console.log(Person.prototype);

打印结果显示为:

可以看到,该原型对象有两个属性:constructor和proto

到这里,我们仿佛看到疑惑 “2:proto和prototype到底是啥关系?”的答案要出现了。在 JavaScript 中,proto属性指向对象的原型对象,对于函数来说,它的原型对象便是prototype。函数的原型对象prototype有以下特点:

  • 默认情况下,所有函数的原型对象(prototype)都拥有constructor属性,该属性指向与之关联的构造函数,在这里构造函数便是Person函数;
  • Person函数的原型对象(prototype)同样拥有自己的原型对象,用proto属性表示。前面说过,函数是Object的实例,因此Person.prototype的原型对象为Object.prototype。

我们可以用这样一张图来描述prototype、proto和constructor三个属性的关系:

从这个图中,我们可以找到这样的关系:

  • 在 JavaScript 中,proto属性指向对象的原型对象;
  • 对于函数来说,每个函数都有一个prototype属性,该属性为该函数的原型对象;


使用 prototype 和 proto 实现继承

对象之所以使用广泛,是因为对象的属性值可以为任意类型。因此,属性的值同样可以为另外一个对象,这意味着 JavaScript 可以这么做:通过将对象 A 的proto属性赋值为对象 B,即:

A.__proto__ = B

此时使用A.proto便可以访问 B 的属性和方法。

这样,JavaScript 可以在两个对象之间创建一个关联,使得一个对象可以访问另一个对象的属性和方法,从而实现了继承;


使用prototype和proto实现继承

以Person为例,当我们使用new Person()创建对象时,JavaScript 就会创建构造函数Person的实例,比如这里我们创建了一个叫“zhangsan”的Person:

var zhangsan = new Person("zhangsan");

上述这段代码在运行时,JavaScript 引擎通过将Person的原型对象prototype赋值给实例对象zhangsan的proto属性,实现了zhangsan对Person的继承,即执行了以下代码:

//JavaScript 引擎执行了以下代码
var zhangsan = {};
zhangsan.__proto__ = Person.prototype;
Person.call(zhangsan, "zhangsan");

我们来打印一下zhangsan实例:

console.log(zhangsan)

结果如下图所示:




可以看到,zhangsan作为Person的实例对象,它的proto指向了Person的原型对象,即Person.prototype。

这时,我们再补充下上图中的关系:




从这幅图中,我们可以清晰地看到构造函数和constructor属性、原型对象(prototype)和proto、实例对象之间的关系,这是很多容易混淆。根据这张图,我们可以得到以下的关系:

  1. 每个函数的原型对象(Person.prototype)都拥有constructor属性,指向该原型对象的构造函数(Person);
  2. 使用构造函数(new Person())可以创建对象,创建的对象称为实例对象(lily);
  3. 实例对象通过将proto属性指向构造函数的原型对象(Person.prototype),实现了该原型对象的继承。

那么现在,关于proto和prototype的关系,我们可以得到这样的答案:

  • 每个对象都有proto属性来标识自己所继承的原型对象,但只有函数才有prototype属性;
  • 对于函数来说,每个函数都有一个prototype属性,该属性为该函数的原型对象;
  • 通过将实例对象的proto属性赋值为其构造函数的原型对象prototype,JavaScript 可以使用构造函数创建对象的方式,来实现继承。


所以一个对象可通过proto访问原型对象上的属性和方法,而该原型同样也可通过proto访问它的原型对象,这样我们就在实例和原型之间构造了一条原型链。红色线条所示:



通过原型链访问对象的方法和属性

当 JavaScript 试图访问一个对象的属性时,会基于原型链进行查找。查找的过程是这样的:

  1. 首先会优先在该对象上搜寻。如果找不到,还会依次层层向上搜索该对象的原型对象、该对象的原型对象的原型对象等(套娃告警);
  2. JavaScript 中的所有对象都来自Object,Object.prototype.proto === null。null没有原型,并作为这个原型链中的最后一个环节;
  3. JavaScript 会遍历访问对象的整个原型链,如果最终依然找不到,此时会认为该对象的属性值为undefined。

我们可以通过一个具体的例子,来表示基于原型链的对象属性的访问过程,在该例子中我们构建了一条对象的原型链,并进行属性值的访问:

var o = {a: 1, b: 2}; // 让我们假设我们有一个对象 o, 其有自己的属性 a 和 b:
o.__proto__ = {b: 3, c: 4}; // o 的原型 o.__proto__有属性 b 和 c:

当我们在获取属性值的时候,就会触发原型链的查找:

console.log(o.a); // o.a => 1
console.log(o.b); // o.b => 2
console.log(o.c); // o.c => o.__proto__.c => 4
console.log(o.d); // o.c => o.__proto__.d => o.__proto__.__proto__ == null => undefined

综上,整个原型链如下:

{a:1, b:2} ---> {b:3, c:4} ---> null, // 这就是原型链的末尾,即 null


可以看到,当我们对对象进行属性值的获取时,会触发该对象的原型链查找过程。

既然 JavaScript 中会通过遍历原型链来访问对象的属性,那么我们可以通过原型链的方式进行继承。

也就是说,可以通过原型链去访问原型对象上的属性和方法,我们不需要在创建对象的时候给该对象重新赋值/添加方法。比如,我们调用lily.toString()时,JavaScript 引擎会进行以下操作:

  1. 先检查lily对象是否具有可用的toString()方法;
  2. 如果没有,则``检查lily的原型对象(Person.prototype)是否具有可用的toString()方法;
  3. 如果也没有,则检查Person()构造函数的prototype属性所指向的对象的原型对象(即Object.prototype)是否具有可用的toString()方法,于是该方法被调用。

由于通过原型链进行属性的查找,需要层层遍历各个原型对象,此时可能会带来性能问题:

  1. 当试图访问不存在的属性时,会遍历整个原型链;
  2. 在原型链上查找属性比较耗时,对性能有副作用,这在性能要求苛刻的情况下很重要。

因此,我们在设计对象的时候,需要注意代码中原型链的长度。当原型链过长时,可以选择进行分解,来避免可能带来的性能问题。


其他方式实现继承

除了通过原型链的方式实现 JavaScript 继承,JavaScript 中实现继承的方式还包括经典继承(盗用构造函数)、组合继承、原型式继承、寄生式继承,等等。

  • 原型链继承方式中引用类型的属性被所有实例共享,无法做到实例私有;
  • 经典继承方式可以实现实例属性私有,但要求类型只能通过构造函数来定义;
  • 组合继承融合原型链继承和构造函数的优点,它的实现如下:
function Parent(name) {
  // 私有属性,不共享
  this.name = name;
}
// 需要复用、共享的方法定义在父类原型上
Parent.prototype.speak = function() {
  console.log("hello");
};
function Child(name) {
  Parent.call(this, name);
}
// 继承方法
Child.prototype = new Parent();

组合继承模式通过将共享属性定义在父类原型上、将私有属性通过构造函数赋值的方式,实现了按需共享对象和方法,是 JavaScript 中最常用的继承模式。

虽然在继承的实现方式上有很多种,但实际上都离不开原型对象和原型链的内容,因此掌握proto和prototype、对象的继承等这些知识,是我们实现各种继承方式的前提条件。


总结

关于 JavaScript 的原型和继承,常常会在我们面试题中出现。随着 ES6/ES7 等新语法糖的出现,可能更倾向于使用class/extends等语法来编写代码,原型继承等概念逐渐变淡。

其次JavaScript 的设计在本质上依然没有变化,依然是基于原型来实现继承的。如果不了解这些内容,可能在我们遇到一些超出自己认知范围的内容时,很容易束手无策。

端开发者丨JavaScript

实际需求中开始

要求:

  • 此类继承自 Date,拥有Date的所有属性和对象

  • 此类可以自由拓展方法

形象点描述,就是要求可以这样:

  1. // 假设最终的类是 MyDate,有一个getTest拓展方法

  2. let date = newMyDate();

  3. // 调用Date的方法,输出GMT绝对毫秒数

  4. console.log(date.getTime());

  5. // 调用拓展的方法,随便输出什么,譬如helloworld!

  6. console.log(date.getTest());

于是,随手用JS中经典的组合寄生法写了一个继承,然后,刚准备完美收工,一运行,却出现了以下的情景:

但是的心情是这样的: 囧

以前也没有遇到过类似的问题,然后自己尝试着用其它方法,多次尝试,均无果(不算暴力混合法的情况),其实回过头来看,是因为思路新奇,凭空想不到,并不是原理上有多难。。。

于是,借助强大的搜素引擎,搜集资料,最后,再自己总结了一番,才有了本文。

正文开始前,各位看官可以先暂停往下读,尝试下,在不借助任何网络资料的情况下,是否能实现上面的需求?(就以 10分钟为限吧)

分析问题的关键

借助stackoverflow上的回答。

经典的继承法有何问题

先看看本文最开始时提到的经典继承法实现,如下:

  1. /**

  2. * 经典的js组合寄生继承

  3. */

  4. functionMyDate() {

  5. Date.apply(this, arguments);

  6. this.abc = 1;

  7. }

  8. functioninherits(subClass, superClass) {

  9. functionInner() {}

  10. Inner.prototype = superClass.prototype;

  11. subClass.prototype = newInner();

  12. subClass.prototype.constructor = subClass;

  13. }

  14. inherits(MyDate,Date);

  15. MyDate.prototype.getTest = function() {

  16. returnthis.getTime();

  17. };

  18. let date = newMyDate();

  19. console.log(date.getTest());

  20. 就是这段代码⬆,这也是JavaScript高程(红宝书)中推荐的一种,一直用,从未失手,结果现在马失前蹄。。。

    我们再回顾下它的报错:

    再打印它的原型看看:

    怎么看都没问题,因为按照原型链回溯规则, Date的所有原型方法都可以通过 MyDate对象的原型链往上回溯到。再仔细看看,发现它的关键并不是找不到方法,而是 thisisnotaDateobject.

    嗯哼,也就是说,关键是:由于调用的对象不是Date的实例,所以不允许调用,就算是自己通过原型继承的也不行。

    为什么无法被继承?

    首先,看看 MDN上的解释,上面有提到,JavaScript的日期对象只能通过 JavaScriptDate作为构造函数来实例化。

    然后再看看stackoverflow上的回答:

    有提到, v8引擎底层代码中有限制,如果调用对象的 [[Class]]不是 Date,则抛出错误。

    总的来说,结合这两点,可以得出一个结论:要调用Date上方法的实例对象必须通过Date构造出来,否则不允许调用Date的方法。

    该如何实现继承?

    虽然原因找到了,但是问题仍然要解决啊,真的就没办法了么?当然不是,事实上还是有不少实现的方法的。

    暴力混合法

    首先,说说说下暴力的混合法,它是下面这样子的:

    说到底就是:内部生成一个 Date对象,然后此类暴露的方法中,把原有 Date中所有的方法都代理一遍,而且严格来说,这根本算不上继承(都没有原型链回溯)。

    ES5黑魔法

    然后,再看看ES5中如何实现?

    1. // 需要考虑polyfill情况

    2. Object.setPrototypeOf = Object.setPrototypeOf ||

    3. function(obj, proto) {

    4. obj.__proto__ = proto;

    5. returnobj;

    6. };

    7. /**

    8. * 用了点技巧的继承,实际上返回的是Date对象

    9. */

    10. functionMyDate() {

    11. // bind属于Function.prototype,接收的参数是:object, param1, params2...

    12. vardateInst =new(Function.prototype.bind.apply(Date, [Date].concat(Array.prototype.slice.call(arguments))))();

    13. // 更改原型指向,否则无法调用MyDate原型上的方法

    14. // ES6方案中,这里就是[[prototype]]这个隐式原型对象,在没有标准以前就是__proto__

    15. Object.setPrototypeOf(dateInst,MyDate.prototype);

    16. dateInst.abc = 1;

    17. returndateInst;

    18. }

    19. // 原型重新指回Date,否则根本无法算是继承

    20. Object.setPrototypeOf(MyDate.prototype,Date.prototype);

    21. MyDate.prototype.getTest = functiongetTest() {

    22. returnthis.getTime();

    23. };

    24. let date = newMyDate();

    25. // 正常输出,譬如1515638988725

    26. console.log(date.getTest());

    27. 一眼看上去不知所措?没关系,先看下图来理解:(原型链关系一目了然)

      可以看到,用的是非常巧妙的一种做法:

      正常继承的情况如下:

      • newMyDate()返回实例对象 date是由 MyDate构造的

      • 原型链回溯是: date(MyDate对象)-date.__proto__-MyDate.prototype-MyDate.prototype.__proto__-Date.prototype

      这种做法的继承的情况如下:

      • newMyDate()返回实例对象 date是由 Date构造的

      • 原型链回溯是: date(Date对象)-date.__proto__-MyDate.prototype-MyDate.prototype.__proto__-Date.prototype

      可以看出,关键点在于:

      • 构造函数里返回了一个真正的 Date对象(由 Date构造,所以有这些内部类中的关键 [[Class]]标志),所以它有调用 Date原型上方法的权利

      • 构造函数里的Date对象的 [[ptototype]](对外,浏览器中可通过 __proto__访问)指向 MyDate.prototype,然后 MyDate.prototype再指向 Date.prototype。

      所以最终的实例对象仍然能进行正常的原型链回溯,回溯到原本Date的所有原型方法。

      这样通过一个巧妙的欺骗技巧,就实现了完美的Date继承。不过补充一点, MDN上有提到尽量不要修改对象的 [[Prototype]],因为这样可能会干涉到浏览器本身的优化。如果你关心性能,你就不应该在一个对象中修改它的 [[Prototype]]

      ES6大法

      当然,除了上述的ES5实现,ES6中也可以直接继承(自带支持继承 Date),而且更为简单:

      1. classMyDateextendsDate{

      2. constructor() {

      3. super();

      4. this.abc = 1;

      5. }

      6. getTest() {

      7. returnthis.getTime();

      8. }

      9. }

      10. let date = newMyDate();

      11. // 正常输出,譬如1515638988725

      12. console.log(date.getTest());

      对比下ES5中的实现,这个真的是简单的不行,直接使用ES6的Class语法就行了。而且,也可以正常输出。

      注意:这里的正常输出环境是直接用ES6运行,不经过babel打包,打包后实质上是转化成ES5的,所以效果完全不一样。

      ES6写法,然后Babel打包

      虽然说上述ES6大法是可以直接继承Date的,但是,考虑到实质上大部分的生产环境是: ES6+Babel

      直接这样用ES6 + Babel是会出问题的。

      不信的话,可以自行尝试下,Babel打包成ES5后代码大致是这样的:

      然后当信心满满的开始用时,会发现:

      对,又出现了这个问题,也许这时候是这样的⊙?⊙

      因为转译后的ES5源码中,仍然是通过 MyDate来构造,而 MyDate的构造中又无法修改属于 Date内部的 [[Class]]之类的私有标志,因此构造出的对象仍然不允许调用 Date方法(调用时,被引擎底层代码识别为 [[Class]]标志不符合,不允许调用,抛出错误)。

      由此可见,ES6继承的内部实现和Babel打包编译出来的实现是有区别的。(虽说Babel的polyfill一般会按照定义的规范去实现的,但也不要过度迷信)。

      几种继承的细微区别

      虽然上述提到的三种方法都可以达到继承 Date的目的-混合法严格说不能算继承,只不过是另类实现。

      于是,将所有能打印的主要信息都打印出来,分析几种继承的区别,大致场景是这样的:

      可以参考:( 请进入调试模式)https://dailc.github.io/fe-interview/demo/extends_date.html

      从上往下, 1,2,3,4四种继承实现分别是:(排出了混合法)

      • ES6的Class大法

      • 经典组合寄生继承法

      • 本文中的取巧做法,Date构造实例,然后更改 __proto__的那种

      • ES6的Class大法,Babel打包后的实现(无法正常调用的)

      1. ~~~~以下是MyDate们的prototype~~~~~~~~~

      2. Date{constructor: ƒ, getTest: ƒ}

      3. Date{constructor: ƒ, getTest: ƒ}

      4. Date{getTest: ƒ, constructor: ƒ}

      5. Date{constructor: ƒ, getTest: ƒ}

      6. ~~~~以下是new出的对象~~~~~~~~~

      7. SatJan13201821:58:55GMT+0800(CST)

      8. MyDate2{abc:1}

      9. SatJan13201821:58:55GMT+0800(CST)

      10. MyDate{abc:1}

      11. ~~~~以下是new出的对象的Object.prototype.toString.call~~~~~~~~~

      12. [objectDate]

      13. [objectObject]

      14. [objectDate]

      15. [objectObject]

      16. ~~~~以下是MyDate们的__proto__~~~~~~~~~

      17. ƒDate() { [native code] }

      18. ƒ () { [native code] }

      19. ƒ () { [native code] }

      20. ƒDate() { [native code] }

      21. ~~~~以下是new出的对象的__proto__~~~~~~~~~

      22. Date{constructor: ƒ, getTest: ƒ}

      23. Date{constructor: ƒ, getTest: ƒ}

      24. Date{getTest: ƒ, constructor: ƒ}

      25. Date{constructor: ƒ, getTest: ƒ}

      26. ~~~~以下是对象的__proto__与MyDate们的prototype比较~~~~~~~~~

      27. true

      28. true

      29. true

      30. true

      31. 看出,主要差别有几点:

        1. MyDate们的proto指向不一样

        2. Object.prototype.toString.call的输出不一样

        3. 对象本质不一样,可以正常调用的 1,3都是 Date构造出的,而其它的则是 MyDate构造出的

        我们上文中得出的一个结论是:由于调用的对象不是由Date构造出的实例,所以不允许调用,就算是自己的原型链上有Date.prototype也不行

        但是这里有两个变量:分别是底层构造实例的方法不一样,以及对象的 Object.prototype.toString.call的输出不一样(另一个 MyDate.__proto__可以排除,因为原型链回溯肯定与它无关)。

        万一它的判断是根据 Object.prototype.toString.call来的呢?那这样结论不就有误差了?

        于是,根据ES6中的, Symbol.toStringTag,使用黑魔法,动态的修改下它,排除下干扰:

        1. // 分别可以给date2,date3设置

        2. Object.defineProperty(date2,Symbol.toStringTag, {

        3. get:function() {

        4. returnDate;

        5. }

        6. });

        然后在打印下看看,变成这样了:

        1. [objectDate]

        2. [objectDate]

        3. [objectDate]

        4. [objectObject]

        可以看到,第二个的 MyDate2构造出的实例,虽然打印出来是 [objectDate],但是调用Date方法仍然是有错误。

        此时我们可以更加准确一点的确认:由于调用的对象不是由Date构造出的实例,所以不允许调用。

        而且我们可以看到,就算通过黑魔法修改 Object.prototype.toString.call,内部的 [[Class]]标识位也是无法修改的。(这块知识点大概是Object.prototype.toString.call可以输出内部的[[Class]],但无法改变它,由于不是重点,这里不赘述)。

        ES6继承与ES5继承的区别

        从上午中的分析可以看到一点:ES6的Class写法继承是没问题的。但是换成ES5写法就不行了。

        所以ES6的继承大法和ES5肯定是有区别的,那么究竟是哪里不同呢?(主要是结合的本文继承Date来说)

        区别:(以 SubClass, SuperClass, instance为例)

        ES5中继承的实质是:(那种经典组合寄生继承法)

        • 先由子类( SubClass)构造出实例对象this

        • 然后在子类的构造函数中,将父类( SuperClass)的属性添加到 this上, SuperClass.apply(this,arguments)

        • 子类原型( SubClass.prototype)指向父类原型( SuperClass.prototype)

        • 所以 instance是子类( SubClass)构造出的(所以没有父类的 [[Class]]关键标志)

        • 所以, instance有 SubClass和 SuperClass的所有实例属性,以及可以通过原型链回溯,获取 SubClass和 SuperClass原型上的方法

        ES6中继承的实质是:

        • 先由父类( SuperClass)构造出实例对象this,这也是为什么必须先调用父类的 super()方法(子类没有自己的this对象,需先由父类构造)

        • 然后在子类的构造函数中,修改this(进行加工),譬如让它指向子类原型( SubClass.prototype),这一步很关键,否则无法找到子类原型(注,子类构造中加工这一步的实际做法是推测出的,从最终效果来推测)

        • 然后同样,子类原型( SubClass.prototype)指向父类原型( SuperClass.prototype)

        • 所以 instance是父类( SuperClass)构造出的(所以有着父类的 [[Class]]关键标志)

        • 所以, instance有 SubClass和 SuperClass的所有实例属性,以及可以通过原型链回溯,获取 SubClass和 SuperClass原型上的方法

        以上⬆就列举了些重要信息,其它的如静态方法的继承没有赘述。(静态方法继承实质上只需要更改下 SubClass.__proto__到 SuperClass即可)

        可以看着这张图快速理解:

        有没有发现呢:ES6中的步骤和本文中取巧继承Date的方法一模一样,不同的是ES6是语言底层的做法,有它的底层优化之处,而本文中的直接修改_proto_容易影响性能。

        ES6中在super中构建this的好处?

        因为ES6中允许我们继承内置的类,如Date,Array,Error等。如果this先被创建出来,在传给Array等系统内置类的构造函数,这些内置类的构造函数是不认这个this的。所以需要现在super中构建出来,这样才能有着super中关键的 [[Class]]标志,才能被允许调用。(否则就算继承了,也无法调用这些内置类的方法)

        构造函数与实例对象

        看到这里,不知道是否对上午中频繁提到的构造函数,实例对象有所混淆与困惑呢?这里稍微描述下。

        要弄懂这一点,需要先知道 new一个对象到底发生了什么?先形象点说:

        new MyClass()中,都做了些什么工作
        1. functionMyClass() {

        2. this.abc = 1;

        3. }

        4. MyClass.prototype.print = function() {

        5. console.log('this.abc:'+this.abc);

        6. };

        7. let instance = newMyClass();

        譬如,上述就是一个标准的实例对象生成,都发生了什么呢?

        步骤简述如下:(参考MDN,还有部分关于底层的描述略去-如[[Class]]标识位等)

        1. 构造函数内部,创建一个新的对象,它继承自 MyClass.prototype, letinstance=Object.create(MyClass.prototype);

        2. 使用指定的参数调用构造函数 MyClass,并将 this绑定到新创建的对象, MyClass.call(instance);,执行后拥有所有实例属性

        3. 如果构造函数返回了一个“对象”,那么这个对象会取代整个 new出来的结果。如果构造函数没有返回对象,那么new出来的结果为步骤1创建的对象。 (一般情况下构造函数不返回任何值,不过用户如果想覆盖这个返回值,可以自己选择返回一个普通对象来覆盖。当然,返回数组也会覆盖,因为数组也是对象。)

        结合上述的描述,大概可以还原成以下代码(简单还原,不考虑各种其它逻辑):

        1. let instance = Object.create(MyClass.prototype);

        2. let innerConstructReturn = MyClass.call(instance);

        3. let innerConstructReturnIsObj =typeofinnerConstructReturn ==='object'||typeofinnerConstructReturn ==='function';

        4. returninnerConstructReturnIsObj ? innerConstructReturn : instance;

        注意⚠:普通的函数构建,可以简单的认为就是上述步骤。实际上对于一些内置类(如Date等),并没有这么简单,还有一些自己的隐藏逻辑,譬如 [[Class]]标识位等一些重要私有属性。譬如可以在MDN上看到,以常规函数调用Date(即不加 new 操作符)将会返回一个字符串,而不是一个日期对象,如果这样模拟的话会无效。

        觉得看起来比较繁琐?可以看下图梳理:

        那现在再回头看看。

        什么是构造函数?

        如上述中的 MyClass就是一个构造函数,在内部它构造出了 instance对象。

        什么是实例对象?

        instance就是一个实例对象,它是通过 new出来的?

        实例与构造的关系

        有时候浅显点,可以认为构造函数是xxx就是xxx的实例。即:

        1. let instance = newMyClass();

        此时我们就可以认为 instance是 MyClass的实例,因为它的构造函数就是它。

        实例就一定是由对应的构造函数构造出的么?

        不一定,我们那ES5黑魔法来做示例。

        1. functionMyDate() {

        2. // bind属于Function.prototype,接收的参数是:object, param1, params2...

        3. vardateInst =new(Function.prototype.bind.apply(Date, [Date].concat(Array.prototype.slice.call(arguments))))();

        4. // 更改原型指向,否则无法调用MyDate原型上的方法

        5. // ES6方案中,这里就是[[prototype]]这个隐式原型对象,在没有标准以前就是__proto__

        6. Object.setPrototypeOf(dateInst,MyDate.prototype);

        7. dateInst.abc = 1;

        8. returndateInst;

        9. }

        10. 我们可以看到 instance的最终指向的原型是 MyDate.prototype,而 MyDate.prototype的构造函数是 MyDate,因此可以认为 instance是 MyDate的实例。

          但是,实际上, instance却是由 Date构造的,我们可以继续用 ES6中的 new.target来验证。

          注意⚠:关于 new.target, MDN中的定义是:new.target返回一个指向构造方法或函数的引用。

          嗯哼,也就是说,返回的是构造函数。

          我们可以在相应的构造中测试打印:

          1. classMyDateextendsDate{

          2. constructor() {

          3. super();

          4. this.abc = 1;

          5. console.log('~~~new.target.name:MyDate~~~~');

          6. console.log(new.target.name);

          7. }

          8. }

          9. // new操作时的打印结果是:

          10. // ~~~new.target.name:MyDate~~~~

          11. // MyDate

          然后,可以在上面的示例中看到,就算是ES6的Class继承, MyDate构造中打印 new.target也显示 MyDate,但实际上它是由 Date来构造(有着 Date关键的 [[Class]]标志,因为如果不是Date构造(如没有标志)是无法调用Date的方法的)。

          这也算是一次小小的勘误吧。

          所以,实际上用 new.target是无法判断实例对象到底是由哪一个构造构造的(这里指的是判断底层真正的 [[Class]]标志来源的构造)。

          再回到结论:实例对象不一定就是由它的原型上的构造函数构造的,有可能构造函数内部有着寄生等逻辑,偷偷的用另一个函数来构造了下,当然,简单情况下,我们直接说实例对象由对应构造函数构造也没错(不过,在涉及到这种Date之类的分析时,我们还是得明白)。

          [[Class]]与Internal slot

          这一部分为补充内容。

          前文中一直提到一个概念:Date内部的 [[Class]]标识。

          其实,严格来说,不能这样泛而称之(前文中只是用这个概念是为了降低复杂度,便于理解),它可以分为以下两部分:

          在ES5中,每种内置对象都定义了 [[Class]] 内部属性的值,[[Class]] 内部属性的值用于内部区分对象的种类

          • Object.prototype.toString访问的就是这个[[Class]]

          • 规范中除了通过 Object.prototype.toString,没有提供任何手段使程序访问此值。

          • 而且Object.prototype.toString输出无法被修改

          而在ES5中,之前的 [[Class]] 不再使用,取而代之的是一系列的 internalslot

          • Internal slot 对应于与对象相关联并由各种ECMAScript规范算法使用的内部状态,它们没有对象属性,也不能被继承

          • 根据具体的 Internal slot 规范,这种状态可以由任何ECMAScript语言类型或特定ECMAScript规范类型值的值组成

          • 通过 Object.prototype.toString,仍然可以输出Internal slot值

          • 简单点理解(简化理解),Object.prototype.toString的流程是:如果是基本数据类型(除去Object以外的几大类型),则返回原本的slot,如果是Object类型(包括内置对象以及自己写的对象),则调用 Symbol.toStringTag。 Symbol.toStringTag方法的默认实现就是返回对象的Internal slot,这个方法可以被重写

          这两点是有所差异的,需要区分(不过简单点可以统一理解为内置对象内部都有一个特殊标识,用来区分对应类型-不符合类型就不给调用)。

          JS内置对象是这些:

          1. Arguments,Array,Boolean,Date,Error,Function,JSON,Math,Number,Object,RegExp,String

          ES6新增的一些,这里未提到:(如Promise对象可以输出 [objectPromise]),而前文中提到的:

          1. Object.defineProperty(date,Symbol.toStringTag, {

          2. get:function() {

          3. returnDate;

          4. }

          5. });

          它的作用是重写Symbol.toStringTag,截取date(虽然是内置对象,但是仍然属于Object)的 Object.prototype.toString的输出,让这个对象输出自己修改后的 [objectDate]。

          但是,仅仅是做到输出的时候变成了Date,实际上内部的 internalslot值并没有被改变,因此仍然不被认为是Date。

          如何快速判断是否继承?

          其实,在判断继承时,没有那么多的技巧,就只有关键的一点: [[prototype]]( __ptoto__)的指向关系。

          譬如:

          1. console.log(instanceinstanceofSubClass);

          2. console.log(instanceinstanceofSuperClass);

          实质上就是:

          • SubClass.prototype是否出现在 instance的原型链上

          • SuperClass.prototype是否出现在 instance的原型链上

          然后,对照本文中列举的一些图,一目了然就可以看清关系。有时候,完全没有必要弄的太复杂。

          觉得本文对你有帮助?请分享给更多人

          前端开发者丨JavaScript

S虽然不像是JAVA那种强类型的语言,但也有着与JAVA类型的继承属性,那么JS中的继承是如何实现的呢?

一、构造函数继承

在构造函数中,同样属于两个新创建的函数,也是不相等的
 function Fn(name){
 this.name = name;
 this.show = function(){
 alert(this.name);
 }
 }
 var obj1 = new Fn("AAA");
 var obj2 = new Fn("BBB");
 console.log(obj1.show==obj2.show); //false
 此时可以看出构造函数的多次创建会产生多个相同函数,造成冗余太多。
 利用原型prototype解决。首先观察prototype是什么东西
 function Fn(){}
 console.log(Fn.prototype);
 //constructor表示当前的函数属于谁
 //__proto__ == [[prototype]],书面用语,表示原型指针
 var fn1 = new Fn();
 var fn2 = new Fn();
 Fn.prototype.show = function(){
 alert(1);
 }
 console.log(fn1.show==fn2.show); //ture

此时,任何一个对象的原型上都有了show方法,由此得出,构造函数Fn.prototype身上的添加的方法,相当于添加到了所有的Fn身上。

二、call和applay继承

function Father(skill){
 this.skill = skill;
 this.show = function(){
 alert("我会"+this.skill);
 }
 }
 var father = new Father("绝世木匠");
 function Son(abc){
 //这里的this指向函数Son的实例化对象
 //将Father里面的this改变成指向Son的实例化对象,当相遇将father里面所有的属性和方法都复制到了son身上
 //Father.call(this,abc);//继承结束,call适合固定参数的继承
 //Father.apply(this,arguments);//继承结束,apply适合不定参数的继承
 }
 father.show()
 var son = new Son("一般木匠");
 son.show();

三、原型链继承(demo)

这个的么实现一个一个简单的拖拽,a->b的一个继承。把a的功能继承给b。

HTML:

<div id="drag1"></div>
<div id="drag2"></div>

CSS:

*{margin: 0;padding: 0;}
 #drag1{width: 100px;height: 100px;background: red;position: absolute;}
 #drag2{width: 100px;height: 100px;background: black;position: absolute;left: 500px;}

JS:

function Drag(){}
 Drag.prototype={
 constructor:Drag,
 init:function(id){
 this.ele=document.getElementById(id);
 this.cliW=document.documentElement.clientWidth||document.body.clientWidth;
 this.cliH=document.documentElement.clientHeight||document.body.clientHeight;
 var that=this;
 this.ele.onmousedown=function(e){
 var e=event||window.event;
 that.disX=e.offsetX;
 that.disY=e.offsetY;
 document.onmousemove=function(e){
 var e=event||window.event;
 that.move(e);
 }
 that.ele.onmouseup=function(){
 document.onmousemove=null;
 }
 } 
 },
 move:function(e){
 this.x=e.clientX-this.disX;
 this.y=e.clientY-this.disY;
 this.x=this.x<0?this.x=0:this.x;
 this.y=this.y<0?this.y=0:this.y;
 this.x=this.x>this.cliW-this.ele.offsetWidth?this.x=this.cliW-this.ele.offsetWidth:this.x;
 this.y=this.y>this.cliH-this.ele.offsetHeight?this.y=this.cliH-this.ele.offsetHeight:this.y;
 this.ele.style.left=this.x+'px';
 this.ele.style.top=this.y+'px';
 }
 }
 new Drag().init('drag1')
 function ChidrenDrag(){}
 ChidrenDrag.prototype=new Drag()
 new ChidrenDrag().init('drag2')

四、混合继承

function Father(skill,id){
 this.skill = skill;
 this.id = id;
 }
 Father.prototype.show = function(){
 alert("我是father,这是我的技能"+this.skill);
 }
 function Son(){
 Father.apply(this,arguments);
 }
 //如果不做son的原型即成father的原型,此时会报错:son.show is not a function
 Son.prototype = Father.prototype;
 //因为,如果不让son的原型等于father的原型,son使用apply是继承不到原型上的方法
 //但这是一种错误的原型继承示例,如果使用这种方式,会导致修改son原型上的show方法时,会把father身上的show也修改
 //内存的堆和栈机制
 Son.prototype.show = function(){
 alert("我是son,这是我的技能"+this.skill);
 }
 var father = new Father("专家级铁匠","father");
 var son = new Son("熟练级铁匠","son");
 father.show();
 son.show();
 上面的示例应该修改成以下形式:
 以上红色的代码应改成:
 for(var i in Father.prototype){
 Son.prototype[i] = Father.prototype[i];
 }
 //遍历father的原型身上的所有方法,依次拷贝给son的原型,这种方式称为深拷贝
 这种继承方式叫做混合继承,用到了for-in继承,cell和apple继承。

五、Es6的class继承(demo)

这个demo的功能和原型链继承的demo功能一样,a->b的继承

HTML:

<div id="drag1"></div>
<div id="drag2"></div>

CSS:

*{margin: 0;padding: 0;}
#drag1{width: 100px;height: 100px;background: red;position: absolute;}
#drag2{width: 100px;height: 100px;background: black;position: absolute;left: 500px;}

JS:

class Drag{
 constructor(id){
 this.ele=document.getElementById(id);
 this.init();
 };
 init(){
 var that=this;
 this.ele.onmousedown=function(e){
 var e=event||window.event;
 that.disX=e.offsetX;
 that.disY=e.offsetY;
 document.onmousemove=function(e){
 var e=event||window.event;
 that.move(e);
 }
 that.ele.onmouseup=function(){
 document.onmousemove=null;
 that.ele.onmouseup=null;
 }
 }
 };
 move(e){
 this.ele.style.left=e.clientX-this.disX+"px";
 this.ele.style.top=e.clientY-this.disY+"px";
 }
 }
 new Drag("drag1");
 class ExtendsDrag extends Drag{
 constructor(id){
 super(id);
 }
 }
 new ExtendsDrag("drag2")

我总结的这几种继承方法.两个demo继承的方法大家最好在编译器上跑一下,看看。这样才能更深刻的去理解。尤其是原型链的继承,js作为一个面向对象的编程语言,还是很常用的。

对前端的技术,架构技术感兴趣的同学关注我的头条号,并在后台私信发送关键字:“前端”即可获取免费的架构师学习资料

知识体系已整理好,欢迎免费领取。还有面试视频分享可以免费获取。关注我,可以获得没有的架构经验哦!!