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

我与影子孤独终老i 提交于 2019-12-03 12:19:33

一、构造函数和原型

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实现继承

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!