「这是我参与2022首次更文挑战的第1天,活动详情查看:2022首次更文挑战」
面试官一问,面向对象有什么特点?
面试者:封装、继承、多态
面试官:没了?能不能给我讲讲原理,他们能干什么?
面试者:额...
本文将结合自绘的图形和浏览器运行时截图,带你理解透面向对象的三大特点,并且还会有作用域(对作用域不是特别理解的强烈建议先看作用域这篇!!!)与部分this指向的知识。
封装
封装就是创建一个对象,集中保存现实中一个事物的属性和功能。将零散的数据封装进对象结构中,极其便于大量数据的管理维护。只要使用面向对象思想开发,第一步都是先封装各种各样的对象结构备用。
封装对象的三种方式
1. 直接使用大括号
直接使用 {}
这种形式其实是 new Object() 的简写
const xiaoming = {
name: '小明',
getName: function () {
console.log(this.name)
}
}
xiaoming.age = 18
这里有一个疑问,为什么在xiaoming
这个对象内,访问自己的属性要用this.name
而不是直接使用name
?凭什么这个 this 就是指向xiaoming
,而不是 window
,关于这个我们先留一下,我们放到后面聊
2. 使用 new Object()
const xiaoming = new Object({
name: '小明',
getName: function() {
console.log(this.name)
}
})
xiaoming.age = 18
3. 使用构造函数
使用上面两种方式创建多个相同结构的对象时会写很多重复代码。
const xiaoming = {
name: '小明',
getName: function() {
console.log(this.name)
}
}
const xiaoguang = {
name: '小光',
getName: function() {
console.log(this.name)
}
}
// ....
使用构造函数可以解决这个问题。
function Person(name) {
this.name = name
this.getName = function() {
console.log(this.name)
}
}
const xiaoming = new Person('小明')
const xiaoguang = new Person('小光')
xiaoming.age = 18
xiaoming.getName() // 小明
console.log(xiaoming.age) // 18
xiaoguang.getName() // 小光
console.log(xiaoguang.age) // undefined
构造函数的好处就是代码复用。这种方式在很多框架中就有应用,比如在 React 中创建的 Fiber
对象内访问内部属性
为什么对象内不能直接访问自己内部的属性
先看我们一开始的第一个疑问——为什么在对象内部访问自己的属性不能直接用xxx
,为什么 this 指向的不是 window
我们以下面这段程序为例:
const xiaoming = {
name: '小明',
getName: function () {
console.log(name)
}
}
xiaoming.getName() // undefined
首先是创建对象时,new Object
出来一个对象的地址 0x3241 承接着对象内的属性 name='小明'
getName
的值是一个function
所以需要调用new Function
创建一个函数对象 0x5693 内部放着执行这个函数时的代码
然后调用 xiaoming.getName()
方法,首先是先创建临时的作用域对象,这个作用域对象是一个空的,根本就找不到name
属性。于是顺着作用域链往上找(往上就只有window了),也找不到有name
的属性,于是就只能报undefined
。
因为只有函数的 {} 才会产生作用域对象,那么这里const xiaoming = { ... } ,所以在对象内部无法直接访问到自身的属性。这就解释了为什么不能直接用 name,以及 this 为什么不指向 window。
关于作用域相关的可以看 这篇
通过对象自身引用访问
从上图中可以看到,我们在getName
内部可以访问到 window 中的 xiaoming 的引用,所以在对象内部可以 xiaoming.name
来访问自身的属性,但是这种方式紧耦合,属实不是好的解决方式。
通过 this
我们可以通过this
关键字来访问到对象本身
那么,什么是 this ?
- 每个函数内自带的
- 专门指向正在调用当前函数的
.前的对象
的关键字。
经典面试题:调用构造函数时 new 做了哪些事情?
function Person(name) {
this.name = name
this.getName = function() {
console.log(this.name)
}
}
const xiaoming = new Person('小明')
表面上来看,是两件事
- 使用 new 调用 Person 实参
"小明"
传给构造函数内的形参name
变量,然后再传给this.name
。 - 最后再交给对象
xiaoming
。
但其实,new 做了4件事
- 创建一个新的空对象等待
-
先留着,后面设计到这个知识点,会重新回顾这四步
-
调用构造函数:
a. 将构造函数中的 this 指向刚创建的新对象
b. 在构造函数内通过强行赋值的方式,为新对象添加属性和方法
在上一节中我们说,this 专门指向正在调用当前函数的 .前的对象
的关键字。但是这里我们哪儿来的.
?
这是因为 new 会将 this 连接到新创建的对象上,这就是 this 的第二种情况。
- 最后一步自然是返回新对象的地址,保存到 xiaoming 这个变量里
小结 :
new 做了四件事
- 创建一个新的空对象等待
- ?
- 将构造函数中的 this 指向刚刚创建的新对象,并且为其添加规定的属性和方法
- 返回新对象的地址,保存到 = 左边到变量里
目前了解到 this 的两种情况
- obj.fun(): fun 中的 this 指向 . 前的对象
- new Fun() Fun 中的 this 指向 new 创建的新对象
继承
继承用来解决重复创建的问题
我们通过上面的 new 一个构造函数时的执行图里面有一个问题,就是就是我每次调用 new Person
都会执行new Function
来创建构造函数里面的getName
方法,但是我们getName
方法它的功能总是相同的,这就造成了重复创建。
那么我们就要用到继承来解决这个问题
JS 中继承使用原型对象实现
首先我们要知道,JS 中的继承都是通过原型对象来实现的,原型对象是替所有子对象集中保存共有属性值和方法的父对象。
原型对象不需要自己创建,在定义构造函数时程序会自动创建一个原型对象 prototype 挂载到构造函数上
这样我们的getName
可以直接放到这个prototype
上就不用重复创建了
function Person(name) {
this.name = name
}
Person.prototype.getName = function() {
console.log(this.name)
}
const xiaoming = new Person('小明')
xiaoming.getName() // "小明"
我们来调用试试看
我们通过上面的图就能看出,xiaoming.__proto__
就等于Person.prototype
。xiaoming.getName
是通过访问 Person.prototype
拿到的。
执行到了getName
中,这是第三种 this
的情况,但是我们发现,这种this
与上面说的 this 指向. 前面的对象
其实是一摸一样的!那么这个 this 自然也就是xiaoming
了
控制台中打印的 xiaoming 的值中有个
[[Prototype]]
的属性,这玩意儿就是__proto__
小结:
使用继承能够避免同样的方法被重复创建,占用内存空间。
JS 中的继承是由原型对象实现的。函数定义时原型对象作为属性prototype
挂载。在调用构造函数时,将创建的空对象的__proto__
指向函数的prototype
。
通过 Fun.prototype.xxx = function () {}
来添加原型中的方法,然后实例对象可以直接instance.xxx
来通过__proto__
查找到xxx
那么我们上面说的 new 的四个步骤中的第二步就补上了,我们再回顾一遍这四个步骤
- (紫)创建一个新的空对象等待
- (蓝)让这个新对象继承构造函数的原型对象(__proto__ 属性指向构造函数的原型对象)
- (绿)将构造函数中的 this 指向这个新对象,并给新对象添加规定的属性
- (黄)返回新对象地址
同时this的三种情况:
- obj.fun(): fun 中的 this 指向 . 前的对象
- new Fun() Fun 中的 this 指向 new 创建的新对象
- 在构造函数的原型中与第一种一样,不看定义,只看调用
多态
同一个方法,在不同情况下表现出不同的状态。在JavaScript中,多态是基于原型链实现的。
原型链
我们先来看这样的一段代码(Person 仍是上面示例的构造函数)
const obj = { a: 1 }, xiaoming = new Person('小明')
obj.toString() // '[object Object]'
xiaoming.toString() // '[object Object]'
obj
上有toString
方法我们可以理解,应该是Object
构造对象出厂在 __proto__ 带的,但是Person
出厂时并没有添加toString
呀?
答案就是原型链,这里调用的toString
是通过原型链查找得到的。
我们Person
返回的是一个对象,而这个对象背后也是 new 出来的 object。 我们用画图来描述在xiaoming
上查找toString
的过程
- 首先是在
xiaoming
的这个对象上查找,没有找到toString
方法,但是有__proto__连接的Person.prototype
对象,于是去Person.prototype
上查找 - 到达了
Person.prototype
上发现也没有找到toString
方法,但是有__proto__连接的Object.prototype
对象,于是又跑到Object.prototype
上查找 - 最终,终于在
Object.prototype
上找到了toString
方法来调用。
总结一下什么是原型链: 原型链是由多级父对象逐级继承形成的链式结构。它保存着一个对象可用的所有属性和方法,控制着属性和方法的使用顺序——就近原则(先子后父,层层查找)。
可能你会有疑问,原型链这跟我们要聊的多态有什么关系?别着急,我们再来看一段代码
const obj = { a: 1 }, date = new Date()
typeof obj // 'object'
typeof date // 'object'
obj.toString() // '[object Object]'
date.toString() // 'Fri Jan 28 2022 16:18:23 GMT+0800 (中国标准时间)'
我们发现同样都是对象,都是调用toString
方法是不一样的结果,经过上面关于原型链以及原型链的查找规则的理解,我们应该懂了,这两个toString
不是同一个。以实际运行结果为证。
obj.toString === xiaoming.toString // ture
obj.toString === date.toString // false
这说明在date的原型对象中自己重新定义了toString
方法,使得得到的toString
方法与obj.toString
不同。
所以才有一开始的那句——在JavaScript中,多态是基于原型链实现的。
我们的
Person
也可以自己在prototype
上写一个toString
“覆盖”原本的方法
小结:
在JavaScript中,多态是基于原型链的查找规则实现的。因为在JavaScript中查找顺序是从自己本身找查找开始,如果自己本身没有,则去自己的原型上查找,如果在自己的原型上还没有找到,则去自己的原型的原型上查找....
- 如果查找到了,则返回查找到的结果,给调用对象使用
- 如果没有查找到,则一直向上查找,一直查找到Object的原型指向 null 为止
这个查找过程与作用域链的查找过程很相似
总结
封装
封装一个对象,将其零散的属性和功能保存到里面能够便于大量数据的管理维护。
封装对象有三种方式:
- 直接使用 { }
- 使用 new Object()
- 使用构造函数
对于需要多次使用同一结构的数据,使用构造函数能够避免代码冗余
继承
继承避免同样的方法被重复创建,占用内存空间。
JS 中的继承是由原型对象实现的。函数定义时原型对象作为属性prototype
挂载。在调用构造函数时,将创建的空对象的__proto__
指向函数的prototype
。
通过 Fun.prototype.xxx = function () {}
来添加原型中的方法,然后实例对象可以直接instance.xxx
来通过__proto__
查找到xxx
多态
多态是基于原型链的查找规则实现的,从实例本身开始查找,然后再向上面的原型上找,如果找到了则终止查找,返回给实例调用。如果找到Object的原型指向 null 则查找失败。
new 做的四件事
- 创建一个新的空对象等待
- 让这个新对象继承构造函数的原型对象(__proto__ 属性指向构造函数的原型对象)
- 将构造函数中的 this 指向这个新对象,并给新对象添加规定的属性
- 返回新对象地址
OK,面向对象的三大特点基本聊完了,如果觉得对你有帮助,还请点下赞👍 ,如果觉得有什么地方写的不好的,或者有什么建议或疑问的,欢迎在留言区给我留言反馈,谢谢~
最后,2022,加油!