介绍
本文是 JavaScript 高级深入浅出系列的第十篇,将详细了解到 JS 中的面向对象、原型与继承相关知识
正文
1. 面向对象定义
1.1 什么是面向对象
面向对象是现实的抽象方式
对象是 JS 中非常重要的概念,这是因为对象可以将多个相关联的数据封装到一起,更好的描述一个事物:
比如一只猫,可以有属性年龄(age)、名字(name)、体重(weight)、颜色(color)、品种(breed)等等
用对象来描述事物,更有利我们将现实中的事物,抽离成代码中的数据结构:
- 所以某些编程语言是纯面向对象的编程语言,比如 Java
- 在实现任何事物的时候都是先抽象成一个类,再 new 这个类
1.2 JS 中的面向对象
JS 其实支持了多种编程范式,包括了函数式编程和面向对象编程
- JS的对象被设计为一组属性的无序集合,像是一个哈希表,有 key 和 value 结合
- key 是一个标识符名称,value 可以是任意类型,也可以是其他对象或者函数类型
- 如果值是一个函数,我们就可以称之为对象的方法
创建对象的方式
使用new
// 这种方式也是早期使用 JS 的人比较频繁使用的方式
var person = new Object() // new Object() 创建了一个空对象
obj.name = 'alex'
obj.age = 19
obj.run = function() {}
使用字面量
var person = {
name: 'alex',
age: 19,
run: function() {}
}
2. 对于对象属性的操作
var obj = {
name: 'alex',
age: 18,
}
// 读取属性
console.log(obj.name)
// 写入属性
obj.age = 20
// 删除属性
delete obj.age
2.1 定义单个属性
通过属性描述符可以精准的添加或者修改对象的属性
Object.defineProperty()方法用于配置属性描述符
2.1.1 Object.defineProperty
Object.defineProperty方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象
Object.defineProperty(obj, prop, descriptor)
接收 3 个参数:
obj要定义属性的对象prop要定义或修改属性的名称或 Symboldescriptor要定义或修改的属性描述符
2.1.2 属性描述符分类
属性描述符分为两类:
- 数据属性(Data Properties)描述符(Descriptor)
- 存取属性(Accessor 访问器 Properties)描述符(Desriptor)
| configurable | enumerable | value | writable | get | set | |
|---|---|---|---|---|---|---|
| 数据描述符 | ✔ | ✔ | ✔ | ✔ | ❌ | ❌ |
| 存取描述符 | ✔ | ✔ | ❌ | ❌ | ✔ | ✔ |
2.1.3 数据属性描述符
数据属性描述符有以下特性:
[[Configurable]]:表示属性是否可以通过delete删除属性,是否能够修改它的特性,或者是否可以将它修改为存取属性描述符- 当我们直接在一个对象上定义某个属性时,这个属性的
[[Configurable]]为true - 当我们通过属性描述符定义一个属性时,这个属性的
[[Configurable]]默认为false
- 当我们直接在一个对象上定义某个属性时,这个属性的
[[Enumerable]]:表示属性是否可以通过for-in或者Object.keys()返回该属性- 当我们直接在一个对象上定义该属性时,这个属性的
[[Enumerable]]为true - 当我们通过属性描述符定义一个属性时,这个属性的
[[Enumerable]]默认为false
- 当我们直接在一个对象上定义该属性时,这个属性的
[[Writable]]:表示是否可以修改属性的值- 当我们直接在一个对象上定义该属性时,这个属性的
[[Writable]]为true - 当我们通过属性描述符定义一个属性时,这个属性的
[[Writable]]默认为false
- 当我们直接在一个对象上定义该属性时,这个属性的
[[value]]:属性的 value 值,读取属性时会返回其值,修改属性时,会对其进行更改- 默认这个值时
undefined
- 默认这个值时
var obj = {
name: 'obj',
}
// 如果当前是数据属性描述符,可以接收 4 个属性描述符项
Object.defineProperty(obj, 'age', {
value: 20,
// 不可删除,不可重新定义属性配置符
configurable: false,
// 打印和 for-in 和 Object.keys() 都无法访问
enumerable: false,
// 无法被写入
writable: false,
})
2.1.4 存取属性描述符
数据属性描述符有以下特性:
[[Configurable]]:表示属性是否可以通过delete删除属性,是否能够修改它的特性,或者是否可以将它修改为存取属性描述符- 当我们直接在一个对象上定义某个属性时,这个属性的
[[Configurable]]为true - 当我们通过属性描述符定义一个属性时,这个属性的
[[Configurable]]默认为false
- 当我们直接在一个对象上定义某个属性时,这个属性的
[[Enumerable]]:表示属性是否可以通过for-in或者Object.keys()返回该属性- 当我们直接在一个对象上定义该属性时,这个属性的
[[Enumerable]]为true - 当我们通过属性描述符定义一个属性时,这个属性的
[[Enumerable]]默认为`false
- 当我们直接在一个对象上定义该属性时,这个属性的
[[get]]:获取属性时会执行的函数,默认undefined[[set]]:设置属性时会执行的函数,默认undefined
var obj = {
name: 'obj',
_age: 20,
}
// 存取属性描述符
Object.defineProperty(obj, 'age', {
configurable: true,
enumerable: true,
get() {
console.log(`返回 age 的值,${this._age}`)
return this._age
},
set(newVal) {
this._age = newVal
console.log(`捕获 age 的新值,${newVal}`)
},
})
obj.age = 20 // age is change, and the new value is 20
console.log(obj.age) // 返回 age 的值,20
2.1.5 属性描述符可接收项总结
注:这里默认值是在使用属性描述符定义一个属性的情况下
| 描述符 | 作用 | 默认值 |
|---|---|---|
| configurable | 当此描述符为true时,能操作属性描述符和从对应对象上删除 | false |
| enumerable | 当此描述符为true时,此属性能出现在对象的枚举属性上(可被遍历) | false |
| value | 属性对应的值,可以是值、函数、对象 | undefined |
| writable | 当此描述符为true时,此属性的 value 能被赋值运算符更改 | false |
| get | 属性的 getter 函数,当访问该属性的 value 时,该函数被触发,执行时不传入任何参数,但会传入 this。该函数的返回值会作为属性的值 | undefined |
| set | 属性的 setter 函数,当对该属性的 value 赋值时,该函数被触发。该函数接收一个参数(也就是被赋予的新值),会传入赋值对象的 this 对象 | undefined |
2.1.6 配置属性描述符案例
// 案例一:创建一个属性
let obj = {
name: 'alex',
}
Object.defineProperty(obj, 'age', {
value: 18,
// 这种方式创建的属性默认是不可被枚举的,需要打开枚举才能被遍历出来
enumerable: true,
})
console.log(obj) // { name: 'alex', age: 18 }
// 案例二:不允许某个属性能被遍历
const obj = {
name: 'obj',
age: 20,
gender: 'Male',
}
Object.defineProperty(obj, 'age', {
// 将不可枚举更改为false
enumerable: false,
})
for (const k in obj) {
// 在遍历时就不会出现 age
console.log(k, obj[k])
}
2.2 同时定义多个属性
通过Object.defineProperties()方法直接在一个对象上定义多个新的属性或者修改现有属性,并返回对象
let obj = {
_age: 19,
}
Object.defineProperties(obj, {
name: {
configurable: false,
writable: true,
enumerable: true,
value: 'obj',
},
age: {
configurable: false,
enumerable: false,
get() {
return this._age
},
set(val) {
this._age = val
},
},
})
console.log(obj)
如果使用属性描述符来定义一个新的属性时, configurable和enumerable都是true,只想设置set和get时,可以这样做:
const obj = {
name: 'obj',
_age: 18,
set age(newVal) {
console.log('age属性 set')
this._age = newVal
},
get age() {
console.log('age 属性 get')
return this._age
},
}
obj.age = 22
console.log(obj.age)
3. 对象方法补充
3.1 获取对象的属性操作符
- Object.
getOwnPropertyDescriptor(object, "attr name") - Object.
getOwnPropertyDesciptors(object)
3.2 禁止对象扩展新属性
- Object.
preventExtensions(object) - 该方法给一个对象添加新属性会失败(严格模式下会报错)
'use strict'
let obj = {
name: 'obj',
}
Object.preventExtensions(obj)
obj.age = 20 // 报错
3.3 密封对象
- Object.
seal(object) - 不允许对象添加新属性和删除已有属性,不允许修改属性操作符
- 其核心就是调用了
preventExtensions函数,并将属性描述符configurable: false
'use strict'
let obj = {
name: 'obj',
}
Object.seal(obj)
obj.name = 'alex' // 正常
delete obj.name // 报错
obj.age = 20 // 报错
Object.defineProperty(obj, 'name', {
writable: true,
}) // 报错
3.4 冻结对象
- Object.
freeze(obj) - 不允许更改已有属性的值,不允许增加属性,不允许删除属性,不允许修改属性操作符
- 其核心是调用了
seal,并将属性描述符writable: false
'use strict'
let obj = {
name: 'obj',
}
Object.freeze(obj)
obj.name = '123' // 报错
delete obj.name // 报错
obj.age = 20 // 报错
Object.defineProperty(obj, 'name', {
writable: true,
}) // 报错
注意:
freeze方法只能冻结一级属性
'use strict'
let obj = {
name: 'alex',
foo: {
name: 'foo',
},
}
Object.freeze(obj)
obj.foo.name = 'foo2' // 不报错
obj.name = 'obj' // 报错
4. 创建多个对象的方案
比如我们现在想要创建一个对象,所有的对象都基于Person模板对象,都有name、age、height等属性和方法,但是他们的值均不相同,那么采用什么方式创建比较好呢?
4.1 工厂模式
我们很快就能想象到一种创建对象的方式:工厂模式
- 工厂模式是一种很常见的设计模式
- 通常我们会有一个工厂函数,通过该方法来生产对象
'use strcit'
// createPerson 就是一个工厂函数
function createPerson(name, age, height) {
return {
name: name,
age: age,
height: height,
running: function() {
console.log(this.name, 'is running')
},
eating: function() {
console.log(this.name, 'is eating')
},
}
}
var ZhangSan = createPerson('zhangsan', 18, 1.8)
var LiSi = createPerson('lisi', 25, 1.88)
var WangWu = createPerson('wangwu', 19, 1.75)
// ......
工厂模式的缺点:
获取不到对象的真实类型(任何通过工厂模式创建的对象都属于 Object 类型)
4.2 构造函数
- 构造函数也称之为构造器(constructor),通常是我们在创建对象时调用的方法,ES6 引入了 class(只是这种形式的语法糖,最终还是会转成这种形式的)
- 在其他面向对象的编程语言中,构造函数时存在于一个类中的方法,称之为构造方法
- JS 中的构造函数也是一个普通的函数
- 如果一个普通的函数使用了
new进行调用,那么该函数就是一个构造函数
function foo() {}
// 直接调用,foo 只是一个普通函数
foo()
// 使用 new 关键字 foo 就是一个构造函数
// 如果不向构造函数中传入参数,可以不写小括号,但是不建议
new foo()
new 与普通调用的区别
如果一个函数被操作符new调用了,那么会执行以下操作:
- 在内存中创建一个新的空对象
- 这个对象内部的
[[prototype]]属性会被更改为该构造函数的[[prototype]] - 构造函数内部的 this,会指向创建出来的新对象
- 执行函数的内部代码(函数体)
- 如果构造函数没有返回非空对象,那返回的就是创建出来的新对象
var f1 = new foo()
console.log(f1)
// 此时,f1 打印 foo {},说明 new 的新对象是属于 foo 的
使用构造函数创建批量对象
'use strcit'
// createPerson 就是一个工厂方法
function Person(name, age, height) {
this.name = name
this.age = age
this.height = height
this.running = function() {
console.log(this.name, 'is running')
}
this.eating = function() {
console.log(this.name, 'is eating')
}
}
var ZhangSan = new Person('zhangsan', 18, 1.8)
var LiSi = new Person('lisi', 25, 1.88)
var WangWu = new Person('wangwu', 19, 1.75)
console.log(ZhangSan, LiSi, WangWu)
// 打印出这三个对象是属于 Person 类型的
构造函数的缺点
前置知识
function foo() {
function bar() {
console.log('bar')
}
return bar
}
var f1 = foo()
var f2 = foo()
console.log(f1 === f2) // false
f1和f2其实是两个独立的函数,虽然函数体相同,但是会占用两块内存空间
function Person(name) {
this.name = name
this.running = function() {
console.log(this.name, 'is running')
}
}
var p1 = new Person('zhangsan')
var p2 = new Person('lisi')
console.log(p1.running === p2.running) // false
上面示例代码中,Person是一个构造函数,p1和p2是两个实例对象,running方法是是一个实例方法,显然,这两个实例对象的实例方法的方法体都是相同的,而且我们也希望相同,但是却占用了两个内存空间。随即可以推算出来,创建的对象越多,内存也将占用的越多,但是大部分占用的内存的函数其实都是重复的
因为创建函数要开辟新内存空间,在节省内存的理想情况下,所有实例中的实例函数其实都要共用同一个函数,也就是p1.running === p2.running,最终不同的实例对象调用此函数无非就是修改函数内部的 this 而已。
接下来,我们将了解解决方案:prototype 原型
5. 原型
5.1 对象的原型
对象的原型一般称之为隐式原型
JS 中所有的对象都有一个特殊的内置属性[[prototype]],这个特殊的属性将指向一个对象。
// 这个对象中其实还隐藏着一个属性 [[Prototype]]
var obj = { name: 'alex' }
早期 ECMA 没有规范如何查看[[prototype]]这个属性的,部分浏览器给对象提供了__proto__属性,来方便我们查看原型对象。在生产环境中不要随意使用这个属性,可能有一些浏览器没有实现这个功能
在 ES5+ 后,为了方便在生产环境中访问原型,提供了一个方法Object.getPrototypeOf()
var obj = { name: 'alex' }
console.log(Object.getPrototypeOf(obj)) // [Object: null prototype] {}
说明obj对象的原型是Object,原型是一个空对象,并且Object的原型是null
总结
-
JS 中每一个对象都有
[[prototype]]属性,该属性指向该对象的原型**(隐式原型)** -
在早期为了能看到这个原型,部分浏览器实现了
__proto__属性来查看[[prototype]]属性 -
后面 ES5 版本 ECMA 规范实现了
Object.getPrototypeOf()属性来查看[[prototype]]
5.1.1 对象原型的作用
当我们在访问某个对象的某个属性时,会触发该属性的[[getter]],分为两步操作:
- 在当前的对象中查看属性,如果找到就使用
- 没有找到,就沿着原型链去查找
5.2 函数的原型
函数的原型称之为显式原型
函数也是一个对象,对于函数来说,它也是有__proto__属性的(有兼容问题,只有部分浏览器实现)
同时函数还会多出一个显式原型属性prototype(ECMA 规范的,没有兼容问题)
5.2.1 再看 new
上文中说到在 new 的过程中:
- 这个对象内部的
[[prototype]]属性会被更改为该构造函数的[[prototype]]
其实就是内部实现了类似于这样的
function foo() {
this.__proto__ = foo.prototype
}
// 通过 new 创建的实例
var f1 = foo()
var f2 = foo()
// f1 和 f2 的 __proto__ 都是 foo.prototype
console.log(f1.__proto__ === foo.prototype)
console.log(f2.__proto__ === foo.prototype)
5.2.2 看图理解
function Person() {}
var p1 = new Person()
var p2 = new Person()
- 创建 Person,在内存中创建一个函数对象,GO 中的 Person 将会指向此函数对象。在函数对象中,包括了
[[parentScope]]、函数执行体、prototype,而prototype将会指向 Person 的原型对象
new的过程,创建出p1和p2,内存中开辟出p1和p2,内部包含了__proto__属性,指向的就是其构造函数也就是Person的原型对象
-
所以
p1.__proto__ === p2.__proto__ === Person.prototype,上图的流程就是内部帮助我们做的 -
所以我们在访问实例对象中的某个属性的时候,就会先看自身有没有这个属性,如果没有则会根据
__proto__属性来找原型,看看原型中有没有这个对象// 因此会有这个操作 p1.__proto__.name = 'alex' console.log(p2.name) // alex // 不过 __proto__ 有兼容问题,不建议直接操作__proto__,而是操作构造函数的 prototype Person.prototype.name = 'zhang' console.log(p1.name) // zhang console.log(p2.name) // zhang
5.2.3 函数原型上的属性
function foo() {}
console.log(foo.prototype) // 虽然打印出来这个对象是 {} 空的,但是有很多内置属性
原型上有一个constructor属性
console.log(foo.prototype.constructor) // 值是指向 foo 自己
// 因此也会有这样的操作
console.log(foo.prototype.constructor.prototype.constructor.rototype.constructor)
同时,我们也可以在原型上添加自己的属性
function foo() {}
foo.prototype.message = 'hello world'
foo.prototype.age = 20
// 所有通过 new foo 的实例对象的 __proto__ 都将携带 message 和 age
var f1 = new foo()
console.log(f1.__proto__.message)
// 根据属性查找规则 f1.message 也是可以找到
console.log(f1.message)
如果添加的属性过多了,每次都要使用xxx.prototype过于繁琐,所以有时候我们还可以直接修改prototype
foo.prototype = {
name: 'alex',
age: 20,
message: 'hello world',
run: function() {}
}
直接覆盖有一个大问题:根据 ECMA 规范,prototype是需要内置一个constructor属性的,但是我们直接修改函数原型指向,指向的新的对象中是没有constrcutor属性,需要手动指定一下
foo.prototype = {
// 不要直接这样定义!因为默认原型的 constructor 属性的 enumerable 是 false
// constructor: foo,
name: 'alex',
age: 20,
message: 'hello world',
run: function() {}
}
// 开发中要这么做
Object.defineProperty(foo.prototype, 'constructor', {
configurable: true,
writable: true,
enumerable: false,
value: foo,
})
5.3 回到 4.2 的问题
在 5.1 中我们说到,每次 new 一个实例对象为了存放实例函数,都要开辟内存空间,学习了上文的原型知识,就能轻松想到解决方案了
// 原来的代码
function Person(name) {
this.name = name
this.running = function() {
console.log(this.name, 'is running')
}
}
var p1 = new Person('zhangsan')
var p2 = new Person('lisi')
console.log(p1.running === p2.running) // false
// 将实例方法挂载到构造函数的原型上,这样所有实例对象的实例函数都是指向同一个函数
function Person(name) {
this.name = name
}
Person.prototype.running = function() {
console.log(this.name, 'is running')
}
var p1 = new Person('zhangsan')
var p2 = new Person('lisi')
console.log(p1.running === p2.running) // true
-
从上文中我们了解到这里:
Person.prototype和p1.__proto__和p2.__proto__指向同一块内存空间,在Person.prototype中挂载了一个 running 函数 -
在
p1.running寻找running的函数中,首先会在本对象上找,找不到就通过__proto__再找,最终找到了p1.__proto__的running函数,指向的还是Person.prototype.running这一块内存空间 -
因此
Person.prototype.running===p1.__proto__.running===p2.__proto__.running -
轻松解决了实例函数造成内存空间占用过多的问题
思考:普通的属性也能放在原型中吗?
答案显然是不能
function Person(name) {
Person.prototype.name = name
}
var p1 = new Person('zhangsan')
var p2 = new Person('lizi')
console.log(p1.name) // lisi
因为prototype是共用的,所以 p2 的 name 覆盖了 Person 原型的 name。
因此只能将相同的逻辑放在共用的原型上,而特有属性需要放在自己内部进行维护
5.4 原型与实例断开链接
通过字面量来重新定义原型,定义的位置不同,可能会出现断开链接的隐患
function Person(name) {
this.name = name
}
var a1 = new Person('zhangsan')
Person.prototype = {
running: function() {},
}
Object.defineProperty(Person.prototype, 'constructor', {
configurable: true,
enumerable: false,
writable: true,
value: Person,
})
// 如果创建实例在覆盖 prototype 之前,
// 那么 a1.__proto__ 和 Person.prototype 就不指向同一个对象了
console.log(Person.prototype.running) // running
console.log(a1.running) // undefined
console.log(Person.prototype === a1.__proto__) // false
这个很好理解,只要避免定义实例在重新定义原型前就好了
5.5 原型链
前置知识:
JS 由于设计问题,和其他的纯面向对象不同,所有 JS 的对象都是克隆而来的,每个对象都会保存其原型信息,所有对象的根原型对象是Object的原型对象
var obj = {
name: 'alex',
message: '123',
}
console.log(obj.address)
- 上文中我们说到,在访问对象的属性时触发了
[[getter]]操作 - 首先会在本对象上找是否有该属性,找到直接返回
- 找不到,就去本对象的原型对象(
__proto__)上找,如果还找不到,就去原型对象的原型对象上找 - 直到找到所有对象的根原型对象(
Object的原型对象)的原型null为止,如果没有找到就返回undefined - 这种查找链条叫做原型链(
prototype chain) - Object 的原型对象上有很多默认的属性和方法,也正是因为原型链,所有对象都拥有这些属性和方法
var obj = {}
// 1. 创建一个空对象 {}
// 2. 将空对象的 this 修改为 obj
// 3. 将 obj.__proto__ = Object.prototype
// 4. 返回这个新对象
console.log(obj.__proto__ === Object.prototype) // true
console.log(Object.prototype.__proto__) // null
5.6 Object 的原型对象是最顶层的原型对象
- 构造函数的
prototype显式原型指向构造函数的原型对象 - 实例对象的
__proto__隐式原型指向构造函数的原型对象 - 构造函数的原型对象
__proto__隐式原型指向 Object 的原型对象 - 所有的对象或函数往上溯源原型,最终都会找到 Object 的原型对象
迷惑点:
Object.__proto__ === Function.prototype // true
- 和上图中是一样的道理,函数的显式原型会在内部指向到 Object 的原型对象
6. 实现继承
function Person() {
}
var p1 = new Person()
上面的示例代码中,我们是如何称呼 Person 的?
- 在 JS 中,Person 被称为是一个构造函数
- 从面向对象编程语言的开发者看来,Person 是一个类,因为通过类我们可以创建实例对象 a1
- 从编程范式的角度来看,Person 确实是一个类
6.1 面向对象的特性
面向对象有三大特性:封装、继承、多态
- 封装:之前示例代码中,将类和方法封装到一个对象中,可以称之为封装的过程
- 继承:继承是面向对象中最重要的,不仅仅可以减少重复的代码量,也是多态的前提(纯面向对象中)
- 多态:不同的对象在执行中会表现出不同的形态
6.2 封装
function foo(name) {
this.name = name
}
foo.prototype.running = function() {}
简化来说,将属性和方法封装到一个类中,这个过程是封装的过程
在纯面向对象的语言中,封装其实更多的是指将实现细节隐藏起来,只向外界暴露部分可操作的接口
6.3 继承
- 继承是做什么的?
- 继承可以帮助我们将重复的代码和逻辑抽取到父类中,子类只需要继承过来即可
- JS 当中是如何实现继承的?
- 通过原型链的机制去实现继承
6.3.1 为什么需要继承
function Student(name, sno) {
this.name = name
this.sno = sno
}
Student.prototype.running = function() {
console.log(this.name, ' running')
}
Student.prototype.studying = function() {
console.log(this.name, 'studying')
}
function Teacher(name, title) {
this.name = name
this.title = title
}
Student.prototype.running = function() {
console.log(this.name, ' running')
}
Student.prototype.teaching = function() {
console.log(this.name, 'teaching')
}
通过构造函数Student和Teacher的对比发现,我们有很多重复的代码name和running,有没有可能可以让两者有一个公共的构造函数呢?
6.4 原型链实现继承
// 父类:公共属性和方法
function Person() {
this.name = 'alex'
}
Person.prototype.running = function() {
console.log(this.name, ' running')
}
// 子类:私有属性和方法
function Student() {
this.sno = 123
}
// 为什么这里需要这样做呢?
Student.prototype = new Person()
var s1 = new Student()
s1.running() // 成功继承到 Person 的方法
为什么Student.prototype = new Person()就实现了继承?我们看图
- Person 构造函数与原型对象的关系
- new Person() 的过程
- 创建一个空对象
- 将 Person 函数的 this 指向这个空对象
- 将空对象的
[[prototype]]指向函数的 Prototype - 执行函数体
- 返回创建出来的对象(此时 name 属性就存在于这个创建的对象上)
Student.prototype = new Person(),此时 Student 的原型对象是一个空对象,__proto__指向 Person 的原型对象
- 创建 s1 的 Student 实例对象,
s1.__proto__指向 Student 的原型对象,访问s1.name,在本对象中找不到,根据原型链,去 s1 的原型也就是 Student 的原型对象去找,找到了 name
- 访问
s1.running,本对象找不到,Student 的原型对象也找不到,就去 Student 原型对象的原型对象,也就是 Person 的原型对象,找到 running 函数,执行,至此就通过原型链实现了“继承”
原型链实现继承的弊端
-
弊端一:打印 s1 对象,某些属性是看不到的(因为继承的属性和方法都放在了原型对象或者父原型对象上)
-
弊端二:类型归属错误,这个时候打印 s1 返回是一个 Person 类型。(因为 Student 的原型对象没有
constructor属性,自己定义一个就可以了)Object.defineProperty(Student.prototype, 'constructor', { enumerable: false, configurable: true, writable: true, value: Student, }) -
弊端三:公用属性存在的弊端
function Person() { this.name = 'alex' this.friends = [] } function Student() { this.sno = 123 } Student.prototype = new Person() var s1 = new Student() var s2 = new Student() s1.friends.push('s1 的朋友') // 因为 s1 和 s2 用到的都是 Student 原型对象中的 friends // 所以 s1 影响了 s2,但是这不是我们所期望的 console.log(s2.friends) // ["s1 的朋友"] // 迷惑点: s1.name = 's1' console.log(s2.name) // alex // 之所以看似 s1 没有影响 s2,实际上像这种直接赋值操作,如果在本对象中找不到,则直接在本对象中创建一个新的属性叫做 name,值是 s1 // 所以其实这里 s1.name 和 s2.name 根本不是一个东西了 -
弊端四:在上面代码中,我们在 new 的过程中没有传参,就无法做到自定义化
6.5 借用构造函数实现继承
为了解决原型链继承中存在的诸多弊端,社区提供了一种方案constructor stealing(有很多译称:借用构造函数、经典继承或者叫伪造对象)
function Person(name, friends) {
this.name = name
this.friends = friends
}
Person.prototype.running = function() {
return 'running is good'
}
function Student(name, friends, sno) {
// 通过 call 来隐式调用 Person 函数
// 在 new 一个对象的时候,执行函数体代码
// 其实就是 s1 和 s2 执行了 Person 函数,只不过函数内的 this 是 s1 和 s2
Person.call(this, name, friends)
this.sno = sno
}
Student.prototype = new Person()
var s1 = new Student('alex', ['s1'], 123)
// 解决弊端四
console.log(s1) // { name: 'alex', son: 123 }
// 同时这种情况也解决了弊端三
var s2 = new Student('zhangsan', ['s2'], 456)
s1.friends.push('s3')
console.log(s2.friends) // [ 's2' ]
Person.call(this, ...args) 运用这种方式非常巧妙的解决了无法传参的问题,就相当于是“借用了”构造函数,同时解决了弊端三和弊端四
构造函数继承的弊端
借用构造函数来实现继承也是有一定的弊端的:
Person函数被调用了 3 次(call 两次,new Person() 一次)Student原型对象中存在冗余的属性(new Person() 的时候添加的)
6.6 原型式继承
这种模式由道格拉斯·克罗克福德(Douglas Crockford,前端大师,JSON 创始人)在 2006 年发布的文章 Prototypal Inheritance in JavaScript(在 JS 种使用原型式继承)中提出
这篇文章中,提出了一种继承的方法,而且这种继承方法不是通过构造函数实现的
不过这种继承仅局限于对象
var obj = {
name: 'alex',
age: 18,
}
function createObject(o) {
var obj = {}
Object.setPrototypeOf(obj, o)
return obj
}
// 因为 Douglas 提出的时候还没有 setPrototype 方法,所以他是这样实现的
function createObject2(o) {
function fn() {}
fn.prototype = o
return new fn()
}
var newObj = createObject2(obj)
// { name: 'alex', age: 18 }
console.log(newObj.__proto__)
这样就实现了一个继承效果
// 在最新的 ECMA 规范中,Object 有一个默认方法 create,和我们自己写的 createObject 效果一样
var obj = {
name: 'alex',
age: 18,
}
var newObj = Object.create(obj)
// { name: 'alex', age: 18 }
console.log(newObj.__proto__)
6.7 寄生式继承
寄生式继承是和原型式继承紧密相关的一种继承思想,并且也是由 Douglas 提出并且推广的
寄生式继承是结合工厂模式与原型式继承的一种方式
即创建一个封装继承过程的函数,该函数在内部以某种方式来增强对象,最后将这个对象返回
var person = {
name: '',
running: function() {
console.log('running')
},
}
function createStudent(name) {
var stu = Object.create(person)
stu.name = name
stu.studying = function() {
console.log('studying')
}
return stu
}
var s1 = createStudent('alex')
var s2 = createStudent('zhangsan')
// 实现了继承
console.log(s1.name, s2.name)
当然,这种方式也有弊端:
- 这种方式只能作用于对象
- 每个对象都有公共方法
studying,创建多个对象,就占用多个内存空间,但是每个内存空间的函数都是一样的
6.8 寄生组合式继承
回顾我们设想的所有继承方式中,借用构造函数实现继承似乎是一种理想的方式
function Person(name, age, friends) {
this.name = name
this.age = age
this.friends = friends
}
Person.prototype.running = function() {
console.log(this.name, ' running')
}
function Student(name, age, friends, sno) {
Person.call(this, name, age, friends)
this.sno = sno
}
Student.prototype = new Person()
Object.defineProperty(Student.prototype, 'constructor', {
enumerable: false,
configurabl: true,
writable: true,
value: Student,
})
Student.prototype.studying = function() {
console.log(this.name, ' studying')
}
var s1 = new Student('alex', 18, ['john'], 123)
s1.running()
想要一种比较完美的继承方案,就需要将借用构造函数(也叫组合式)继承方式的两种弊端解决掉:
- 调用了多次构造函数
- Student 的原型对象中存在冗余的属性
实际上,我们可以通过寄生组合式继承的方式来解决掉这两个弊端:
Student.prototype = new Person()
// 👆 这一行改为 👇,就直接解决了两个弊端
Student.prototype = Object.create(Person.prototype)
接下来,我们还可以进行优化,我们发现,对于继承最核心的代码是这两个
Student.prototype = Object.create(Person.prototype)
Object.defineProperty(Student.prototype, 'constructor', {
enumerable: false,
configurabl: true,
writable: true,
value: Student,
})
实际上我们可以封装为一个函数
// inherit:继承
function inheritPrototype(obj, proto) {
obj.prototype = Object.create(proto.prototype)
Object.defineProperty(obj.prototype, 'constructor', {
enumerable: false,
configurabl: true,
writable: true,
value: obj,
})
}
inheritPrototype(Student, Person)
核心代码
Object.create()这一函数比较新,很多社区的老代码其实用的是我们上文中的createObject()方法,下面是寄生组合式继承的核心代码
function createObject(proto) {
function Fn() {}
Fn.prototype = proto
return new Fn()
}
function inheritPrototype(obj, proto) {
obj.prototype = createObject(proto.prototype)
Object.defineProperty(obj.prototype, 'constructor', {
enumerable: false,
configurable: true,
writable: true,
value: obj,
})
}
测试一下
function Person(name) {
this.name = name
}
Person.prototype.greeting = function() {
console.log(this.name, 'hello world')
}
function Student(name, age) {
Person.call(this, name)
this.age = age
}
inheritPrototype(Student, Person)
Student.prototype.studying = function() {
console.log(this.name, this.age, 'studying')
}
var s1 = new Student('alex', 18)
var s2 = new Student('john', 20)
s1.greeting() // 调用成功
s2.studying() // 调用成功
7. 原型补充
Object.create(obj, propDescriptors),创建一个对象,原型是传入的第一个参数
var obj = {
name: 'alex',
}
// 第二个参数属性描述符是加在了返回的对象上
var newObj = Object.create(obj, {
age: {
value: 20,
enumerable: true,
},
})
// { age: 20 } { name: 'alex' }
console.log(newObj, newObj.__proto__)
obj.hasOwnProperty(propName),判断某个属性是否是挂载在本身上的(不是在原型上)
console.log(newObj.hasOwnProperty('age')) // true
console.log(newObj.hasOwnProperty('name')) // false
in操作符,判断对象是否可以访问到某个属性(无论是不是在原型中)
console.log(age in newObj) // true
console.log(name in newObj) // true
instanceof操作符,检测构造函数的 prototype 是否出现在某个实例对象的原型链上
function foo1() {
this.mname = 'alex'
}
function foo2() {
this.name = 'zhangsan'
}
function foo3() {
this.age = 20
}
foo3.prototype = Object.create(foo1.prototype)
var f1 = new foo3()
console.log(f1 instanceof foo1) // true
console.log(f1 instanceof Object) // true
console.log(f1 instanceof foo2) // false
// 顶层构造函数默认继承自 Object
console.log(foo1.prototype.__proto__) // Object 的原型对象:[Object: null prototype] {}
obj.isPrototypeOf(obj),检测对象是否存在于某个实例对象的原型链上
var obj = {
name: 'alex',
}
var newObj = Object.create(obj)
console.log(obj.isPrototypeOf(newObj)) // true
instanceof和isPrototypeOf的使用场景区别:
instanceof的右边一定要是一个函数isPrototype则是要一个对象
8. 原型继承关系
上图是社区非常著名的关于原型继承的图片,下面我们将深入剖析这张图片:
// 我们就从这段代码讲起
function foo() {}
var f1 = new foo()
var o1 = new Object()
我们上文中也说了,在 JS 的世界中,万物生于 Object 的原型对象,所有的对象(函数也是对象)都克隆自 Object 的原型对象。当然 Object 的原型对象也有原型,是 null。
function foo() {}
我们创建了一个函数,名字叫做foo,此函数由new Function()而来,所以foo.__proto__ = Function.prototype。Function则是 JS 的内置构造函数,Function.prototype.__proto__指向Object.prototype
var f1 = new foo()
当我们使用new关键字时,foo 函数就变成了一个构造函数。
- 首先会创建出一个空对象
- 将构造函数内部的 this 指向这个空对象
- 将这个空对象的
__proto__指向构造函数的prototype - 执行构造函数体
- 返回新对象。
此时,前两行的内存空间其实是这样的:
此时,上面那幅图的上半部分和下半部分我们已经讲完了,接下来讲中间部分
var o1 = new Object()
使用new Object()创建对象,至少说明了两个事实:
Object是一个构造函数Object由new Function()而来
那么Object构造函数的__proto__指向Function.prototype
这一行的内存空间是这样的
此时,你再回头看看那幅图,是不是清晰了很多呢?
总结
本文中,你学到了三大知识点:
1. 关于面向对象
-
了解了什么是面向对象,以及 JS 中的面向对象
-
了解了两种创建对象的方式,使用对象字面量
{}其实也是通过new Object()创建的 -
了解了对象属性描述符的概念与操作
-
了解了几种 Object.prototype 挂载的默认方法
2. 关于原型
- 对象的原型是隐式原型,[[Prototype]]部分浏览器实现了
__proto__属性(此属性慎用,有兼容问题) - 函数的原型是显式原型 Prototype 属性
- 了解了 new 的过程
- 了解了原型链,与定义原型
Object.prototype.__proto__ = null
3. 关于继承
- 探讨了几种继承方式,并挖掘出每一种继承方式的缺陷
- 结合多种继承方式,写出一种较为完美的继承方案
寄生组合式继承 - 关于原型的补充,了解了几种关于判断原型的方法和操作符
- 最后,通过一张经典原型图收尾,彻底掌握原型与继承