• JS原型,原型链,类,继承,class,extends,由浅到深


    一、构造函数和原型

    1、构造函数、静态成员和实例成员

    在ES6之前,通常用一种称为构造函数的特殊函数来定义对象及其特征,然后用构造函数来创建对象。像其他面向对象的语言一样,将抽象后的属性和方法封装到对象内部。

    function Person(uname, age) {
        this.uname = uname;
        this.age = age;
        this.say = function() {
            console.log('我叫' + this.uname + ',今年' + this.age + '岁。');
        }
    }
    var zhangsan = new Person('张三', 18);
    zhangsan.say(); //输出:我叫张三,今年18岁。
    var lisi = new Person('李四', 20);
    lisi.say(); //输出:我叫李四,今年20岁。
    

    在创建对象时,构造函数总与new一起使用(而不是直接调用)。new创建了一个新的对象,然后将this指向这个新对象,这样我们才能通过this为这个新对象赋值,函数体内的代码执行完毕后,返回这个新对象(不需要写return)。

    构造函数内部通过this添加的成员(属性/方法),称为实例成员(属性/方法),只能通过实例化的对象来访问,构造函数上是没有这个成员的。

    >> Person.uname
    undefined
    

    我们也可以给构造函数本身添加成员,称为静态成员(属性/方法),只能通过构造函数本身来访问。

    2、原型

    上面的例子中,我们借助构造函数创建了两个对象zhangsan和lisi,它们有各自独立的属性和方法。对于实例方法而言,由于函数是复杂数据类型,所以会专门开辟一块内存空间存放函数。又由于zhangsan和lisi的方法是独立的,所以zhangsan的say方法和lisi的say方法分别占据了两块内存,尽管它们是同一套代码,做同一件事情。

    >> zhangsan.say === lisi.say
    false
    

    试想,实例方法越多,创建的对象越多,浪费的空间也就越大。为了节约空间,我们希望所有的对象调用同一个say方法。为了实现这个目的,就要用到原型。

    每个构造函数都有一个prototype属性,指向另一个对象,称为原型,由于它是一个对象,也称为原型对象。(以下为了不产生混淆,将构造函数创建的对象称为实例)另一方面,实例有一个属性__proto__,通过它也会指向这个原型对象。为了区分,__proto__的指向一般叫对象的原型,prototype叫原型对象。

    原型对象里还有一个constructor属性,它指回构造函数本身,以记录该原型对象引用自哪个构造函数。这样,构造函数、原型和实例就构成了一个三角关系。

    三角关系

    引入原型对象后,构造函数改为如下方式定义:

    function Person(uname, age) {
        this.uname = uname;
        this.age = age;
    }
    Person.prototype.say = function() {
        return '我叫' + this.uname + ',今年' + this.age + '岁。';
    };
    
    var zhangsan = new Person('张三', 18);
    console.log(zhangsan.say());
    var lisi = new Person('李四', 20);
    console.log(lisi.say());
    console.log(zhangsan.say === lisi.say); //输出:true
    

    在查找对象的成员时,首先在对象自己身上寻找,如果自己没有,就通过__proto__去原型对象上找。通过构造函数的原型对象定义的函数是所有实例共享的。

    一般情况下,我们把实例属性定义到构造函数中,实例方法放到原型对象中。

    3、原型链

    原型对象也是一个对象,它也有自己的原型,指向Object的原型对象Object.prototype。

    >> Person.prototype.__proto__ === Object.prototype
    true
    >> Person.prototype.__proto__.constructor === Object
    true
    

    也就是说,Person的原型对象是由Object这个构造函数创建的。

    继续往下追溯Object.prototype的原型:

    >> Object.prototype.__proto__
    null
    

    终于到头了。回过头来看,我们从zhangsan这个实例开始追溯:

    zhangsan.__proto__指向Person的原型对象

    zhangsan.__proto__.__proto__指向Object的原型对象Object.prototype

    zhangsan.__proto__.__proto__.__proto__指向null

    这种链式的结构,就称为原型链。

    原型链

    对象的成员查找机制依靠原型链:当访问一个对象的属性(或方法)时,首先查找这个对象自身有没有该属性;如果没有就找它的原型;如果还没有就找原型对象的原型;以此类推一直找到null为止,此时返回undefined。__proto__属性为查找机制提供了一条路线,一个方向。

    有了原型链的概念之后,现在再来回顾new在执行时做了什么:

    1.在内存中创建一个新的空对象;

    2.将对象的__proto__属性指向构造函数的原型对象;

    3.让构造函数里的this指向这个新的对象;

    4.执行构造函数里的代码,给这个新对象添加成员,最后返回这个新对象。

    二、继承

    ES6以前,如何实现类的继承?这就要用到call方法。

    function.call(thisArg, arg1, arg2, ...)

    thisArg:在function函数运行时指定的this值。

    arg1, arg2, ...:要传递给function的实参。

    我们知道,所调用的函数内部有一个this属性,根据不同的场景指向调用者、window或undefined。call方法允许我们调用函数时指定另一个this。

    var obj = {};
    function f () {
        console.log(this === window, this === obj);
    }
    f(); //输出:true false
    f.call(obj); //输出:false true
    

    以普通的方式调用f函数,this指向window;以call来调用,this就指向obj。

    利用call,在子构造函数中调用父构造函数,令其内部的this由父类实例指向子类实例,从而在父构造函数中完成一部分成员的初始化。

    来看例子,我们从Person继承一个Student类,不仅要有Person的一切属性和方法,还要新增一个grade属性表示年级,一个exam方法用来考试:

    function Person(uname, age) {
        this.uname = uname;
        this.age = age;
    }
    Person.prototype.say = function() {
        return '我叫' + this.uname + ',今年' + this.age + '岁。';
    };
    function Student(uname, age, grade) {
        Person.call(this, uname, age);
        this.grade = grade;
    }
    Student.prototype.exam = function() {
        console.log('正在考试!');
    };
    var stu = new Student('张三', 16, '高一');
    console.log(stu.uname, stu.age, stu.grade); //输出:张三 16 高一
    stu.exam(); //输出:正在考试!
    

    在Student中,调用了Person函数,令其内部的this指向Student的this,这样uname和age都给了Student的this,最后给原型对象加了个exam方法。注意我们的目的不是创建一个Person的实例,所以没有加new,只是把构造函数当普通函数调用而已。

    接下来让我们调用父构造函数中的say方法,看看有没有被继承。

    >> stu.say()
    TypeError: stu.say is not a function //报错了!
    >> stu.say
    undefined //stu实例并没有say这个成员
    

    哦哦,say是放在Person.prototype中的,但是stu并没有和它产生联系,得改原型链。又由于stu的原型上已经挂了exam,不能直接改变stu.__proto__的指向,只好沿着原型链修改Student.prototype.__proto__的指向(它原本指向Object.prototype):

    >> Student.prototype.__proto__ = Person.prototype
    >> stu.say()
    "我叫张三,今年16岁。" //调用成功了!
    

    继承

    say方法执行了,打印出了姓名、年龄,但我们的Student构造函数还新增了个grade,也需要打印出来。这可难为say方法了,毕竟当时我们定义它时是基于Person的,并没有grade属性。所以我们要覆写这个方法,让它能打印grade,同时不影响原有的say方法,也就是和exam一样挂到Student.prototye上。

    >> Student.prototype.say = function() { return '我叫' + this.uname + ',今年' + this.age + '岁。' + this.grade + '学生。'; }
    >> stu.say()
    "我叫张三,今年16岁。高一学生。"
    

    搞定了!我们碰了好几次壁,总算解决了继承的问题。

    在很多资料中提到了“寄生组合式继承”,思路与上面的分析一样,就是原型对象+构造函数组合使用。不同之处仅在于,没有保留原有的子构造函数的原型对象,而是将它指向另一个通过Object.create()方法创建的对象:

    Student.prototype = Object.create(Person.prototype);
    Student.prototype.constructor = Student;
    

    Object.create()方法创建一个新对象,这个新对象的__proto__指向作为实参传入的Person.prototype。既然指定了另一个对象作为原型,那么constructor应该指回构造函数。

    此外,还有另一种继承方式也经常被提及,称为“组合式继承”,同样要修改Student.prototype的指向:

    Student.prototype = new Person(); //不用赋值,我们不关心原型里的uname和age
    Student.prototype.constructor = Student;
    

    使用父类实例作为Student.prototype的值,因为父类实例的__proto__一定指向父构造函数的原型对象。这样做的弊端在于Person总共调用了2次,并且Student.prototype中存在一部分用不到的属性。

    现在,还有最后一个问题:子类的say方法中存在和父类say方法中相同的代码片段,如何优化这样的冗余?答案是,调用父构造函数原型中的say方法:

    Student.prototype.say = function() {
        return Person.prototype.say.call(this) + this.grade + '学生。';
    };
    

    直接调用只会打印出undefined,因为this默认指向调用者,即Student.prototype,所以要用call修改this为子类实例。

    最后附上一份完整的代码,采用寄生组合式继承:

    function Person(uname, age) {
        this.uname = uname;
        this.age = age;
    }
    Person.prototype.say = function() {
        return '我叫' + this.uname + ',今年' + this.age + '岁。';
    };
    function Student(uname, age, grade) {
        Person.call(this, uname, age);
        this.grade = grade;
    }
    Student.prototype = Object.create(Person.prototype);
    Student.prototype.constructor = Student; //别忘了把constructor指回来
    //Student.__proto__ = Person; //这里挖个坑,后面填
    Student.prototype.exam = function() {
        console.log('正在考试!');
    };
    Student.prototype.say = function() {
        return Person.prototype.say.call(this) + this.grade + '学生。';
    };
    var stu = new Student('张三', 16, '高一');
    console.log(stu.say()); //输出:我叫张三,今年16岁。高一学生。
    

    于是原型链愈发壮大了:

    原型链2

    最后总结一下继承的思路:

    1.首先在子构造函数中用call方法调用父构造函数,修改this指向,实现继承父类的实例属性;

    2.然后修改子构造函数的prototype的指向,无论是寄生组合式继承,还是组合式继承,还是我们自己探索时的修改方式,本质都是把子类的原型链挂到父构造函数的原型对象上,从而实现子类继承父类的实例方法;

    3.如果需要给子类新增实例方法,挂到子构造函数的prototype上;

    4.如果子类的实例方法需要调用父类的实例方法,通过父构造函数的原型调用,但是要更改this指向。

    核心就是原型对象+构造函数组合使用。只使用原型对象,子类无法继承父类的实例属性;只使用构造函数,又无法继承原型对象上的方法。但是双剑合璧后,就能互补长短。打个不恰当的比方,天龙八部中虚竹救天山童姥那段,天山童姥腿断了行动不便,但自己有一定法力;虚竹学到轻功之后跑得快,但他不懂得使用内力。最后他俩都成功跑路了。_(:з」∠)_

    三、ES6的类和继承

    1、类

    ES6中新增了类的概念,使用class关键字来定义一个类,语法和其他面向对象的语言很相似。

    class Person {
        constructor(uname, age) {
            this.uname = uname;
            this.age = age;
        }
        say() { //实例方法
            return `我叫${this.uname},今年${this.age}岁。`; //模板字符串
        }
        static staticMethod() { //静态方法
            console.log(`这是静态方法`);
        }
    }
    let zhangsan = new Person('张三', 18);
    console.log(zhangsan.say());
    

    注意点:

    1.实例属性定义在constructor中。constructor不写也会默认创建。

    2.类中方法前面不需要加function关键字,各方法也不需要用逗号隔开。

    3.静态方法前加static关键字,实例方法不需要。

    4.ES6中静态属性无法在class内部定义,需使用传统的Person.xxx或Person['xxx']。

    5.class没有变量提升,必须先定义类,再通过类实例化对象。

    2、继承

    使用extends关键字实现继承:

    class Person {
        constructor(uname, age) {
            this.uname = uname;
            this.age = age;
        }
        say() {
            return `我叫${this.uname},今年${this.age}岁。`;
        }
    }
    class Student extends Person {
        constructor (uname, age, grade) {
            super(uname, age);
            this.grade = grade;
        }
        say() {
            return `${super.say()}${this.grade}学生。`;
        }
        exam() {
            console.log('正在考试!');
        }
    }
    let stu = new Student('张三', 16, '高一');
    console.log(stu.say());
    stu.exam();
    

    这段代码是前面ES5继承例子的ES6版本。

    注意点:

    1.子类的constructor中,必须调用super方法,否则新建实例时会报错。

    2.constructor和say中虽然都用到了super,但是它们的意义不一样,后文会讲。

    3、class的本质

    先说结论:class的原理基本上还是ES5中那一套,只是写法上更加简洁明了。

    使用class定义的Student仍然是一个构造函数,原型链和之前一模一样:

    >> typeof Person //class定义出来的仍然是一个函数
    "function"
    >> Person.prototype === (new Person()).__proto__
    true
    >> Person.prototype.constructor === Person //三角关系一模一样
    true
    >> stu.__proto__ instanceof Person //stu的原型是Person的实例
    true
    >> Object.getOwnPropertyNames(stu)
    [ "uname", "age", "grade" ]
    >> Object.getOwnPropertyNames(stu.__proto__)
    [ "constructor", "say", "exam" ] //Student的say和exam挂在原型里
    

    Object.getOwnPropertyNames()方法获得指定对象的所有挂在自己身上的属性和方法的名称(不会去原型链上找),这些名称组成数组返回。我们通过stu能访问say和exam,因为它们挂在原型里。

    其他的我就不一一试了,直接给结论:

    1.class定义的仍然是一个构造函数;

    2.class中定义的实例方法,挂在原型对象里;静态方法,挂在构造函数自己身上;

    3.子类有两个地方用到了super,含义不同:constructor中,super被当做函数看待,super(uname, age)代表调用父类的构造函数,相当于Person.call(this, uname, age),另外super()只能用于constructor中;say方法中的super.say()是将super当对象看待,它指向父类的原型对象Person.prototype,super.say()相当于Person.protoype.say.call(this)。

    实际上,ES6的类的绝大部分功能,在ES5中都可以实现。当然,class和extends的引入,使得JS在写法上更加简洁明了,在语法上更像其他面向对象编程的语言。所以ES6中的类就是语法糖。

    4、继承内建对象

    通过extends同样可以继承内建对象:

    class MyArray extends Array {
        constructor() {
            super();
        }
    }
    let a_es6 = new MyArray();
    a_es6[1] = 'a';
    console.log(a_es6.length); //输出:2
    

    MyArray的表现和Array几乎无二。

    但是如果想用ES5的做法的话,比如说组合式继承:

    function MyArray2() {
        Array.call(this);
    }
    MyArray2.prototype = new Array();
    MyArray2.prototype.constructor = MyArray2;
    var a_es5 = new MyArray2();
    a_es5[1] = 'a';
    console.log(a_es5.length); //输出:0
    

    我们给a_es5的下标1的位置赋了个值,令人失望的是,length还是0。

    为什么这两个类的行为完全不同?因为在ES5的组合继承中,首先由子类构造函数创建this的值,MyArray2的this指向新创建的对象,然后再调用父构造函数令Array内部的成员添加到this上,但这种方法无法得到Array内部的成员。来看下面这个例子的模拟:

    >> let o = {}
    >> Object.getOwnPropertyNames(o)
    [] //空列表
    >> Array.call(o)
    >> Object.getOwnPropertyNames(o)
    [] //仍然是空列表
    

    我们通过Array.call(o)试图让空对象o获取Array内所有属性,但是失败了,o并没有发生什么变化。“继承”自Array的a_es5也是如此,它自己连length属性都没有,我们能访问length是因为它挂在原型上。

    但在ES6的class中,通过super()创建的this首先指向父类Array的实例,接着子类再在父类实例的基础上修改值,因此ES6中this可以访问父类实例的功能。

    四、函数的原型

    我们知道,函数除了用function和表达式定义,还可以用new Function(参数1,参数2,...,函数体)的方式定义:

    >> var f = new Function('a', 'b', 'console.log(a + b);')
    >> f(1, 2)
    3
    

    换而言之,所有的函数都是Function这个构造函数的实例,都是对象,函数的内部既有prototype属性也有__proto__属性,前者指向自己的原型对象,后者指向Funtion的原型对象。当我们创建函数的时候(无论是用ES5中哪种方式去创建),new大致做了这些事情:

    1.在内存中创建一个空对象,这里记作F;

    2.令F.__proto__指向Function.prototype;

    3.用new Object()再创建另一个对象,记作proto;

    4.令proto.constructor指向F;

    5.令F.prototype指向proto;

    6.返回F。

    特别地,ES6使用extends进行继承后,子类的__proto__将指向父类以表示继承关系,而不是Function.prototype。(还记得前面在寄生组合式继承的代码里挖了个坑吗?)

    再来看Function函数。它也有原型对象,Function.prototype。另一方面,作为对象,ES5规定Function的__proto__属性就指向它自己的原型对象,即Function.__proto__全等于Function.prototype。

    Function.prototype也是对象,由new Object创建,因此Function.prototype.__proto__指向Object.prototype。

    现在一切都指向Object.prototype,即Object的原型对象,这是除了null以外站在原型链顶端的人,它的上面,Object.prototype.__proto__为null。

    原型链终极图:

    原型链3

    五、原型链的实际应用

    除了上面介绍的继承和查找方向,原型链也可以反过来用,封掉不想给别人用的内置方法。以b漫为例,我们想把某张漫画保存下来,首先打开漫画的阅读页面:

    漫画阅读页

    可以看到2张图就是2个canvas。从canvas提取图像信息,我们想到了toDataUrl和toBlob方法。前者返回一个经过base64加密的data url,后者返回Blob对象,不管哪个,最后都能转换成图片文件:

    >> let c = document.getElementsByTagName('canvas')[0]
    >> c.toDataUrl
    undefined //没了
    >> c.toBlob
    undefined //这个也没了
    

    canvas对象的类为HTMLCanvasElement,toDataUrl和toBlob定义于其原型对象上。经查找,JS代码中有一个立即执行函数把这两个属性指向了undefined,就在reader.xxxxxxxxxx.js这个文件里面(这10个x是占位符,均为数字和小写英文字母之一,比如说我现在的文件名叫reader.8d59f9bef4.js)。

    ……(前略)
    function () {
      try {
        HTMLCanvasElement.prototype.toDataURL = void 0,
        HTMLCanvasElement.prototype.toBlob = void 0
      } catch (e) {}
    }(),
    ……(后略)
    

    不能用ad block之类的扩展把这个js文件屏蔽掉,这将导致canvas元素都不会生成,但可以用其他方法下载图片,并非本文重点,不详述:

    1.Chrome的sources页面直接就把图片展示出来了;

    2.火狐给canvas加了个非标准方法mozGetAsFile(),可以转换为File对象,该方法没有被封;

    3.分析前后的http请求和响应,用爬虫爬;

    4.用fiddler将该文件替换为本地文件,在本地文件中你当然可以注释掉这两行代码。

    六、参考资料(扩展阅读)

    1.ECMAScript

    2.从Object和Function说说JS的原型链

    3.JavaScript(ES6) - Class

    4.es5实现继承

  • 相关阅读:
    不敢相信!JDK 8 的 HashMap 依然会死循环…
    为什么 MySQL 不推荐默认值为 null ?
    Spring 事务的那些坑,都在这里了!
    Spring Boot 启动事件和监听器,太强大了!
    Oracle 要慌了!华为终于开源了自家的 Huawei JDK——毕昇 JDK!
    ArcMap与REST时间不一致,SQL Server时间转换
    为什么jsonloader被从threejs中移除?-threejs jsonloader has been removed
    Dojo小部件(widget)和样式(themes)自定义
    ReferenceError: require is not defined
    Nodejs是什么?
  • 原文地址:https://www.cnblogs.com/jushou233/p/11795943.html
Copyright © 2020-2023  润新知