上篇我们讲解了构造函数和原型等前端概念的原理,知道了实例之间如何通过构造函数的prototype来共享方法,下篇我们主要看下用es6的class怎么来实现,以及class的继承等。同上篇一样,重点在于背后的原理,只有懂得了为什么要这么设计,我们才能真正的说【精通】。
假如下篇你看得很辛苦,那说明你对原型的掌握还不够啊喂,请务必先熟读理解上篇。
一次性精通javascript原型/继承/构造函数/类的原理(上)
ES6的class
回顾下之前es5的写法:
function User(name, age) {
this.name = name
this.age = age
}
User.prototype.grow = function(years) {
this.age += years
console.log(`${this.name} is now ${this.age}`)
}
User.prototype.sing = function(song) {
console.log(`${this.name} is now singing ${song}`)
}
const zac = new User('zac', 28)
es6的写法:
class User {
constructor(name, age) {
this.name = name
this.age = age
}
grow(years) {
this.age += years
console.log(`${this.name} is now ${this.age}`)
}
sing(song) {
console.log(`${this.name} is now singing ${song}`)
}
}
const zac = new User('zac', 28)
当我们调用new User('zac', 28)
时:
- 创建了一个新对象
zac
constructor
方法自动运行一次,同时把传参'zac', 28
赋值给新对象
所以这个class到底是什么呢?其实class就是个函数而已。
console.log(typeof User) // function
那么class背后到底是怎么运作的呢?
- 首先创建了一个叫做User的函数
- 然后把class的constructor里面的代码原封不动的放到User函数里
- 最后将class的方法,如grow,sing放到User.prototype里
完事,看到了吗?javascript的class只是构造函数的语法糖而已(当然class还做了一些其他的小工作)
es6为我们引进了类class的概念,看起来更接近其他面向对象编程的语言了,但不同于其他oop语言的类继承,javascript的继承仍然是通过原型来实现的。
ES6的extends
我们接下来看class之间的继承要怎么实现,es6为我们提供了一个extends的方法:
class User {
constructor(name, age) {
this.name = name
this.age = age
}
grow(years) {
this.age += years
console.log(`${this.name} is now ${this.age}`)
}
sing(song) {
console.log(`${this.name} is now singing ${song}`)
}
}
class Admin extends User {
constructor(name, age, address) {
super(name, age) //to call a parent constructor
this.address = address
}
grow(years) {
super.grow(years) // to call a parent method
console.log(`he is admin, he lives in ${this.address}`)
}
}
const zac = new User('zac', 28)
这里我们重点看下两次super
的调用,首先要明确的是super
关键词是class
提供给我们的。主要有两个用法:
super(...)
是用来调用父类的constructor方法(只能在constructor里这么调用)super.method(...)
是用来调用父类的方法
覆写父类的constructor
我们现在分开来看这两点,先解决第一个问题:为什么要在子类的constructor里调用一下super()
?原因很简单,因为javascript规定了:通过继承(extends)来而的class,必须在constructor里调用super()
,否则在constructor里调用this
会报错!
不信你看:
class Admin extends User {
constructor(name, age, address) {
this.name = name
this.age = age
this.address = address
}
...
}
const zac = new admin('zac', 28, 'China')
// VM1569:3 Uncaught ReferenceError:
// Must call super constructor in derived class before accessing 'this' or returning from derived constructor
简单的解释下,javascript为什么这么设计:
因为当使用new
来实例化class时,直接创建的class和通过extends来创建的class有一个本质区别:
- 前者会先创建一个空对象,然后把这个空对象赋值给this
- 后者则直接不做这件事,因为它只需要等自己的父类做这个事就行了
所以,子类必须在自己的construtor里调用super()
来让它的父类去执行父类的constructor,否则this
就不会被创建,然后如上例所示我们就得到一个error。
在react里为什么要写super(props)?
顺便说一句,现在你应该能理解为什么我们在写react组件时,为什么要写这句super(props)
了吧?
class Checkbox extends React.Component {
constructor(props) {
// 现在你还无法使用this
super(props);
// 现在你可以使用this啦
this.state = { isOn: true };
}
// ...
}
当然这里还有有个小问题,假如我不传props会怎样super()
?
// React内部的代码
class Component {
constructor(props) {
this.props = props;
// ...
}
}
// 我们自己代码
class Checkbox extends React.Component {
constructor(props) {
super();
console.log(this.props); // undefined
//
}
这个不难理解吧?你不把props传给父组件,自然无法在constructor里调用this.props咯。但其实你在其他地方还是可以正常调用this.props的,因为react帮我们多做了一件事:
// React内部的代码
const instance = new YourComponent(props);
instance.props = props;
覆写父类的方法
相对来说,super.method()
就好理解多了。我们的父类User里有一个grow的方法,我们的子类Admin也想有这个方法,同时可能还想在这个方法上再加点别的操作。所以呢,它就先通过class提供的super来先调用一遍父类的grow方法,然后再添加自己的逻辑。
这里有一些爱思考的同学可能就会想了,为什么我可以通过super来调用父类的方法呢?为什么我可以写super.method()
呢?如果你在考虑这个问题,说明你真的很爱思考,你很棒!
简单来理解,既然super.method()
是调用父类的方法,而我们的子类又是通过继承父类而来的,结合之前讲过的原型的知识,那super.method()
是不是就应该相当于this.__proto__.method()
呢?直观上来说确实应该如此,我们做个简单的实验来看下:
let user = {
name: "User",
sing() {
console.log(`${this.name} is singing.`)
}
}
let admin = {
__proto__: user,
name: "Admin",
sing() {
this.__proto__.sing.call(this) //(*)
console.log('calling from admin')
}
}
admin.sing(); // Admin is singing. calling from admin
可以看到,user对象是admin对象的原型,主要看下(*)
这句话,我们在当前对象的上下文(this)里调用了原型对象user的sing方法。注意我用了.call(this)
,如果没有这个的话,我们执行this.__proto__.sing()
时是在原型对象user的上下文里执行的,所以执行this.name
时this指向的是user对象:
...
let admin = {
__proto__: user,
name: "Admin",
sing() {
this.__proto__.sing()
console.log('calling from admin')
}
}
admin.sing(); // User is singing. calling from admin
这里顺便解释下this
,敲黑板了,不管你是在对象里还是原型了发现了this
,它永远是点(.)左边的那个对象。假如是user.sing()
那this是(.)左边的user;假如admin.sing()
那this就是(.)左边的admin。
然后我们再看下上面的例子,我们调用的方法是admin.sing()
,所以运行admin中的sing方法时,this就是admin,因此:
- 假如是
this.__proto__.sing()
,调用者是this.__proto__
,相当于admin.__proto__
, 也就是user对象,所以最后打印出来的是:User is singing. - 假如是
this.__proto__.sing.call(this)
,这时候我们通过call手动将调用者改为admin了,所以最后打印出来是:Admin is singing.
好了,有点扯远了,我们再回来。刚刚的例子好像确实证明了super.method()
相当于this.__proto__.method()
,我们再看下面的代码:
let user = {
name: "User",
sing() {
console.log(`${this.name} is singing.`)
}
}
let admin = {
__proto__: user,
name: "Admin",
sing() {
this.__proto__.sing.call(this) //(*)
console.log('calling from admin')
}
}
let superAdmin = {
__proto__: admin,
name: "SuperAdmin",
sing() {
this.__proto__.sing.call(this) //(**)
console.log('calling from superAdmin')
}
}
superAdmin.sing(); // VM1900:12 Uncaught RangeError: Maximum call stack size exceeded
运行上面的代码,马上就报错了,报错告诉我们超过了最大调用栈的范围,这个错一般说明我们的代码出现里无限循环调用。我们再来逐层解析:
- 首先我们来看调用方法是:
superAdmin.sing()
,所以运行第(**)
句时,this=superAdmin,因此:
this.__proto__.sing.call(this) //(**)
//相当于
superAdmin.__proto__.sing.call(this)
//相当于
admin.sing.call(this) //相当于我们去执行admin里的sing方法时,this仍然是superAdmin
- 然后就运行到了第
(*)
句,这时候this=superAdmin,因此:
this.__proto__.sing.call(this) //(*)
//相当于
superAdmin.__proto__.sing.call(this)
//相当于
admin.sing.call(this) //又回到了这里
然后,结局你就知道,admin.sing不断循环地调用者自己。所以啊,单纯的通过this是无法解决这个问题的。javascript为了解决这个问题设计了一个新的内部属性[[HomeObject]]
,每当一个函数被指定为一个对象的方法时,这个方法就有了一个属性[[HomeObject]]
,这个属性固定的指向这个对象:
let user = {
name: "User",
sing() {
console.log(`${this.name} is singing.`)
}
}
//admin.sing.[[HomeObject]] == admin
let admin = {
__proto__: user,
name: "Admin",
sing() {
super.sing()
console.log('calling from admin')
}
}
// admin.sing.[[HomeObject]] == admin
let superAdmin = {
__proto__: admin,
name: "SuperAdmin",
sing() {
super.sing()
console.log('calling from superAdmin')
}
}
superAdmin.sing()
// SuperAdmin is singing.
// calling from admin
// calling from superAdmin
ok,当我们运行superAdmin.sing()
时,也就是执行super.sing()
,每当super
关键词出现,javascript引擎就会去找当前方法的[[HomeObject]]
对象,然后去找这个对象的原型,最后在这个原型上调用相应的方法。
所以当我们调用superAdmin.sing()
时,相当于执行:
const currentHomeObject = this.sing.[[HomeObject]]
const currentPrototype = Object.getPrototypeOf(currentHomeObject)
currentPrototype.sing.call(this)
ES5如何实现extends
下面我们对比的来看下,es5是怎么实现extends语法的:
function User(name, age) {
this.name = name
this.age = age
}
User.prototype.grow = function(years) {
this.age += years
console.log(`${this.name} is now ${this.age}`)
}
User.prototype.sing = function(song) {
console.log(`${this.name} is now singing ${song}`)
}
function Admin(name, age, address) {
User.call(this, name, age) //(*)
this.address = address
}
Admin.prototype = Object.create(User.prototype) //(**)
Admin.prototype.grow = function(years) {
User.prototype.grow.call(this, years)
console.log(`he is admin, he lives in ${this.address}`)
}
Admin.prototype.constructor = Admin //(***)
const zac = new Admin('zac', 28, 'China')
如果你完整的吸收理解了上下篇的内容,上面的代码应该很好理解了吧?我还是带着大家再来解析一次:
- 第
(*)
句,我们希望Admin构造函数能够拥有User构造函数的属性name和age,所以我们用当前的上下文this去执行一遍User构造函数 - 现在我们在Admin实例化出来的对象里可以找到name、age和address属性了,但我们还无法使用grow方法,因为grow方法是定义在User.prototype上的,所以第
(**)
句,我们将Admin.prototype设置为以User.prototype为原型的新对象。这里你可能有疑问为什么要用Object.create,而不是直接把User.prototype赋值给Admin?原因就是我们不希望Admin和User共用同一个prototype啊,这是我们为什么要使用继承的初衷啊 - 上篇我们讲过,每个函数都有一个prototype对象,这个对象里有一个construtor对象指向这个函数本身。而此时,我们的Admin构造函数的prototype由于是直接从User构造函数的prototype继承来的,所以Admin.prototype.constructor === User.prototype.constructor,因此我们需要手动的修正下Admin.prototype.constructor,将它指向构造函数Admin本身。这也就是第
(***)
句做的事
总结
好,写到这里我觉得差不多了,我们从创建一个对象讲起,由于想要批量创建对象我们讲到了构造函数,又因为对象之间想要共享方法从而讲到了原型,最后顺理成章讲到了对象的继承。整个脉络应该是比较清晰的。
这是《前端原理系列》的初篇,下一篇按计划应该是讲调用栈/执行上下文/闭包/事件循环机制这个主题,大家记得关注,下期再会。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。