JavaScript 面向对象
一、对象的定义
对象的定义为 “无序属性的集合,其属性可以包含基本值,对象或者函数” 。
也就是说,在 JavaScript
中,对象无非就是由一些列无序的 key - value
对组成。其中 value
可以是基本值,对象或者函数。
// 这里的person就是一个对象
var person = {
name: 'Tom',
age: 18,
getName: function () {},
parent: {},
}
2
3
4
5
6
7
1.1 属性无序
对象的属性无序,意味着属性的声明顺序不一定会保留或反映在对象的属性列表中。然而,当你迭代或枚举对象的属性时,JavaScript
引擎会按照一定的规则来确定属性的顺序。这种顺序不是随机的,而是遵循一些特定的规则,具体规则如下:
- 整数键(Array Indices):首先迭代那些被解析为整数的属性键(如 “0”, “1”, “2”),按照数值顺序进行迭代。这些整数键通常用于数组和类数组对象(如 arguments 对象)。
- 字符串键(非整数键):接下来迭代非整数的字符串键,按照它们被添加到对象中的顺序进行迭代。这些通常是常规对象的属性。
- Symbol 键:最后迭代以
Symbol
作为键的属性,按照它们被添加到对象中的顺序进行迭代。
const obj = {
2: 'two',
1: 'one',
b: 'bee',
a: 'ay',
3: 'three',
0: 'zero',
[Symbol('sym')]: 'symbol',
}
// 使用 for...in 迭代对象属性
for (let key in obj) {
console.log(key)
}
// 输出:0, 1, 2, 3, "b", "a"
// 使用 Object.keys() 迭代对象属性
Object.keys(obj).forEach((key) => console.log(key))
// 输出:0, 1, 2, 3, "b", "a"
// 使用 Object.getOwnPropertyNames() 迭代对象属性
Object.getOwnPropertyNames(obj).forEach((key) => console.log(key))
// 输出:0, 1, 2, 3, "b", "a"
// 因为Symbol不可枚举,上述迭代不能打印出Symbol。 故需要Reflect.ownKeys()
Reflect.ownKeys(obj).forEach((key) => console.log(key))
// 输出:0, 1, 2, 3, "b", "a", Symbol(sym)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
二、创建对象的方式
对象的创建方式多种多样,大概有 9 种甚之更多,常用的如下:
2.1 对象字面量
最常见和简洁的创建对象方式是使用对象字面量。对象字面量允许你在代码中直接创建一个对象并初始化其属性。
var Person = {} //等同于var Person =new Object();
var Person = { name: 'Jason', age: 21 }
2
对象字面量是对象定义的一种简写形式,第一种和第二种创建形式的缺点就是:他们用同一个接口创建很多对象和,会产生大量的重复代码,如果你有 500 个对象,那么你就要输入 500 次很多相同的代码。
2.2 Object 构造函数
var Person = new Object()
Person.name = 'Jason'
Person.age = 21
2
3
2.3 使用构造函数创建对象
在 JavaScript
中,你可以定义一个构造函数来创建对象。构造函数本质上是一个普通的函数,但习惯上首字母大写,并使用 new
关键字来调用。通过构造函数创建的 实例对象 会自动继承构造函数的 prototype
属性中的方法和属性。
function Person(name, age, job) {
this.name = name
this.age = age
this.job = job
this.sayName = function () {
alert(this.name)
}
}
var person1 = new Person('轻语', 29, 'teacher')
var person2 = new Person('天歌', 20, 'student')
2
3
4
5
6
7
8
9
10
关于构造函数
- 与普通函数相比,构造函数并没有任何特别的地方,首字母大写只是我们约定的小规定,用于区分普通函数;
- new 关键字让构造函数具有了与普通函数不同的许多特点,而 new 的过程中,执行了如下过程:
- 声明一个中间对象;
- 将该中间对象的原型指向构造函数的原型;
- 将构造函数的 this,指向该中间对象;
- 返回该中间对象,即返回实例对象。
- 实例:构造函数的产物,它继承了构造函数的原型对象,并具有构造函数中定义的属性和方法。每个实例都有自己的属性和方法,但它们都共享着构造函数的原型对象。
三、原型
我们创建的每一个函数,都有一个 prototype
(可称之为显式原型) 属性,该属性指向一个对象,这个对象,就是该函数的原型对象。
function Person() {}
console.log(Person.prototype) // {}
2
当我们在创建对象时,可以根据自己的需求,选择性的将一些属性和方法通过 prototype
属性,挂载在原型对象上。
而每一个 new
出来的实例,都有一个 proto (可称之为隐式原型) 属性,该属性指向构造函数的原型对象,通过这个属性,让实例对象也能够访问原型对象上的方法。
因此,当所有的实例都能够通过 proto 访问到原型对象时,原型对象的方法与属性就变成了共有方法与属性。
通过图示我们可以看出:
- 构造函数的
prototype
(显式原型) 与所有实例对象的__proto__
(隐式原型) 都指向原型对象。 - 原型对象的
constructor
指向构造函数。
根据构造函数与原型的特性,我们就可以将在构造函数中,通过 this
声明的属性与方法称为私有变量与方法,它们被当前被某一个实例对象所独有。而通过原型声明的属性与方法,我们可以称之为共有属性与方法,它们可以被所有的实例对象访问。
// 构造函数
function Person(name, age) {
this.name = name // 私有属性
this.age = age
}
// 在其原型对象挂载共有方法getName
Person.prototype.getName = function () {
return this.name
}
// 创建两个实例对象
var person1 = new Person('Tom', 18)
var person2 = new Person('Jerry', 20)
console.log(person1.getName()) // Tom
console.log(person2.getName()) // Jerry
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
如上,我们在 Person 构造函数中声明了 name
和 age
属性,并在原型对象上声明了 getName
方法。然后我们创建了两个 Person
实例,并分别调用了 getName
方法。 两个实例分别用于各自的私有属性,但是通过原型链,两个实例都可以访问到共有方法 getName
。
关于 this
在 JavaScript
中,this
关键字在函数中是一个特殊的变量,它代表了当前函数执行的上下文对象。在构造函数中,this
代表的是实例对象,因此在构造函数中,我们可以将属性和方法挂载在 this
上,从而让实例对象拥有这些属性和方法。 关于 this
详细介绍, 后面的篇幅会详细介绍。
关于 in
操作符
我们还可以通过 in
来判断,一个对象是否拥有某一个属性/方法,无论是该属性/方法存在与实例对象还是原型对象。 in 的这种特性最常用的场景之一,就是判断当前页面是否在移动端打开。
// 很多人喜欢用浏览器UA的方式来判断,但并不是很好的方式
isMobile = 'ontouchstart' in document
2
四、原型链
原型对象其实也是普通的对象。几乎所有的对象都可能是原型对象,也可能是实例对象,而且还可以同时是原型对象与实例对象。这样的一个对象,正是构成原型链的一个节点。因此理解了原型,那么原型链并不是一个多么复杂的概念。
我们知道所有的函数都有一个叫做 toString
的方法。那么这个方法到底是在哪里的呢?
function add() {}
console.log(add.toString()) // function add() {}
2
用如下的图来表示这个函数的原型链。
add
是Function
对象的实例Function
的原型对象同时又是Object
原型的实例。这样就构成了一条原型链。
原型链的访问,其实跟作用域链有很大的相似之处,他们都是一次单向的查找过程。因此实例对象能够通过原型链,访问到处于原型链上对象的所有属性与方法。这也是 add
最终能够访问到处于 Object
原型对象上的 toString
方法的原因。
基于原型链的特性,就可以很轻松的实现继承。
更简洁的原型链图示
如果觉着上图晦涩,下面有个更简单的示例:
五、继承
继承是面向对象编程的一个重要概念。在 JavaScript
中,继承是通过原型链来实现的。
我们常常结合构造函数与原型来创建一个对象。因为构造函数与原型的不同特性,分别解决了我们不同的困扰。 因此当我们想要实现继承时,就必须得根据构造函数与原型的不同而采取不同的策略。
我们声明一个 Person
对象,该对象将作为父级,而子级 cPerson
将要继承 Person
的所有属性与方法。
function Person(name, age) {
this.name = name
this.age = age
}
Person.prototype.getName = function () {
return this.name
}
2
3
4
5
6
7
8
首先我们来看构造函数的继承。在上面我们已经理解了构造函数的本质,它其实是在 new
内部实现的一个复制过程。而我们在继承时想要的,就是想父级构造函数中的操作在子级的构造函数中重现一遍即可。我们可以通过 call 方法来达到目的。
// 构造函数的继承
function cPerson(name, age, job) {
Person.call(this, name, age)
this.job = job
}
2
3
4
5
而原型的继承,则只需要将子级的原型对象设置为父级的一个实例,加入到原型链中即可。
// 继承原型
cPerson.prototype = new Person(name, age)
// 添加更多方法
cPerson.prototype.getLive = function () {}
2
3
4
5
当然关于继承还有更好的方式。
六、其他继承方式
假设原型链的终点 Object.prototype
为原型链的 E(end)
端,原型链的起点为 S(start)
端。 处于 S 端的对象,可以通过 S -> E 的单向查找,访问到原型链上的所有方法与属性。
因此,只需要在 S 端添加新的对象,那么新对象就能够通过原型链访问到父级的方法与属性。是故想要实现继承并不复杂。
由于封装一个对象由构造函数与原型共同组成,因此继承也会分别有构造函数的继承与原型的继承。
假设已有封装好的父类对象 Person
。如下。
var Person = function (name, age) {
this.name = name
this.age = age
}
Person.prototype.getName = function () {
return this.name
}
Person.prototype.getAge = function () {
return this.age
}
2
3
4
5
6
7
8
9
10
11
12
构造函数的继承可以借助 call/apply
来实现。假设需要通过继承封装一个 Student
的子类对象,那么构造函数可以如下实现:
var Student = function (name, age, grade) {
// 通过call方法还原Person构造函数中的所有处理逻辑
Student.call(Person, name, age)
this.grade = grade
}
// 等价于
var Student = function (name, age, grade) {
this.name = name
this.age = age
this.grade = grade
}
2
3
4
5
6
7
8
9
10
11
12
原型的继承相对复杂。 首先应该考虑,如何将子类对象的原型加入到原型链中?
只需要让子类对象的原型,成为父类对象的一个实例,然后通过 proto 就可以访问父类对象的原型。这样就继承了父类原型中的方法与属性了。
因此可以先封装一个方法,该方法根据父类对象的原型创建一个实例,该实例将会作为子类对象的原型。
function create(proto, options) {
// 创建一个空对象
var tmp = {}
// 让这个新的空对象成为父类对象的实例
tmp.__proto__ = proto
// 传入的方法都挂载到新对象上,新的对象将作为子类对象的原型
Object.defineProperties(tmp, options)
return tmp
}
2
3
4
5
6
7
8
9
10
11
简单封装了 create
对象之后,就可以使用该方法来实现原型继承了。
Student.prototype = create(Person.prototype, {
// 重新指定构造函数
constructor: {
value: Student
}
getGrade: {
value: function() {
return this.grade
}
}
})
2
3
4
5
6
7
8
9
10
11
验证下继承是否正确:
var s1 = new Student('ming', 22, 5)
console.log(s1.getName()) // ming
console.log(s1.getAge()) // 22
console.log(s1.getGrade()) // 5
2
3
4
5
全部都能正常访问,没问题。事实上,在 ECMAScript5 中直接提供了一个 Object.create
方法来完成我们上面自己封装的 create 的功能。因此也可以直接使用 Object.create
.
Student.prototype = create(Person.prototype, {
// 不要忘了重新指定构造函数
constructor: {
value: Student
}
getGrade: {
value: function() {
return this.grade
}
}
})
2
3
4
5
6
7
8
9
10
11
完整代码如下:
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.getName = function() {
return this.name
}
Person.prototype.getAge = function() {
return this.age;
}
function Student(name, age, grade) {
// 构造函数继承
Person.call(this, name, age);
this.grade = grade;
}
// 原型继承
Student.prototype = Object.create(Person.prototype, {
// 不要忘了重新指定构造函数
constructor: {
value: Student
}
getGrade: {
value: function() {
return this.grade
}
}
})
var s1 = new Student('ming', 22, 5);
console.log(s1.getName()); // ming
console.log(s1.getAge()); // 22
console.log(s1.getGrade()); // 5
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36