Skip to content

面向对象

一、对象的定义

对象的定义为 “无序属性的集合,其属性可以包含基本值,对象或者函数” 。 也就是说,在 JavaScript 中,对象无非就是由一些列无序的 key-value 对组成。其中 value 可以是基本值,对象或者函数。

javascript
// 这里的person就是一个对象
var person = {
  name: 'Tom',
  age: 18,
  getName: function () {},
  parent: {},
}

1.1 属性无序

对象的属性无序,意味着属性的声明顺序不一定会保留或反映在对象的属性列表中。然而,当你迭代或枚举对象的属性时,JavaScript 引擎会按照一定的规则来确定属性的顺序。这种顺序不是随机的,而是遵循一些特定的规则,具体规则如下:

  1. 整数键(Array Indices):首先迭代那些被解析为整数的属性键(如 “0”, “1”, “2”),按照数值顺序进行迭代。这些整数键通常用于数组和类数组对象(如 arguments 对象)。
  2. 字符串键(非整数键):接下来迭代非整数的字符串键,按照它们被添加到对象中的顺序进行迭代。这些通常是常规对象的属性。
  3. Symbol 键:最后迭代以 Symbol 作为键的属性,按照它们被添加到对象中的顺序进行迭代。
javascript
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)

二、创建对象的方式

对象的创建方式多种多样,大概有 9 种甚之更多,这里只讲常用的几种。

2.1 对象字面量

最常见和简洁的创建对象方式是使用对象字面量。对象字面量允许你在代码中直接创建一个对象并初始化其属性。

javascript
var Person = {} //等同于var Person =new Object();
var Person = { name: 'Jason', age: 21 }

对象字面量是对象定义的一种简写形式,第一种和第二种创建形式的缺点就是:他们用同一个接口创建很多对象和,会产生大量的重复代码,如果你有 500 个对象,那么你就要输入 500 次很多相同的代码。

2.2 Object 构造函数

javascript
var Person = new Object()
Person.name = 'Jason'
Person.age = 21

2.3 使用构造函数创建对象

JavaScript 中,你可以定义一个构造函数来创建对象。构造函数本质上是一个普通的函数,但习惯上首字母大写,并使用 new 关键字来调用。通过构造函数创建的 实例对象 会自动继承构造函数的 prototype 属性中的方法和属性。

javascript
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')

关于构造函数

  • 与普通函数相比,构造函数并没有任何特别的地方,首字母大写只是我们约定的小规定,用于区分普通函数;
  • new 关键字让构造函数具有了与普通函数不同的许多特点,而 new 的过程中,执行了如下过程:
    1. 声明一个中间对象
    2. 将该中间对象的原型指向构造函数的原型;
    3. 将构造函数的 this,指向该中间对象;
    4. 返回该中间对象,即返回实例对象。
  • 实例:构造函数的产物,它继承了构造函数的原型对象,并具有构造函数中定义的属性和方法。每个实例都有自己的属性和方法,但它们都共享着构造函数的原型对象。

三、原型

我们创建的每一个函数,都有一个 prototype(可称之为显式原型) 属性,该属性指向一个对象,这个对象,就是该函数的原型对象

javascript
function Person() {}
console.log(Person.prototype) // {}

当我们在创建对象时,可以根据自己的需求,选择性的将一些属性和方法通过 prototype 属性,挂载在原型对象上。
而每一个 new 出来的实例,都有一个 proto (可称之为隐式原型) 属性,该属性指向构造函数的原型对象,通过这个属性,让实例对象也能够访问原型对象上的方法。
因此,当所有的实例都能够通过 proto 访问到原型对象时,原型对象的方法与属性就变成了共有方法与属性。

image.png

通过图示我们可以看出:

  • 构造函数的 prototype(显式原型) 与所有实例对象的 proto隐式原型)( 都指向原型对象。
  • 原型对象的 constructor 指向构造函数。

根据构造函数与原型的特性,我们就可以将在构造函数中,通过 this 声明的属性与方法称为私有变量与方法,它们被当前被某一个实例对象所独有。而通过原型声明的属性与方法,我们可以称之为共有属性与方法,它们可以被所有的实例对象访问。

javascript
// 构造函数
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

如上,我们在 Person 构造函数中声明了 nameage 属性,并在原型对象上声明了 getName 方法。然后我们创建了两个 Person 实例,并分别调用了 getName 方法。 两个实例分别用于各自的私有属性,但是通过原型链,两个实例都可以访问到共有方法 getName

关于 this

JavaScript 中,this 关键字在函数中是一个特殊的变量,它代表了当前函数执行的上下文对象。在构造函数中,this 代表的是实例对象,因此在构造函数中,我们可以将属性和方法挂载在 this 上,从而让实例对象拥有这些属性和方法。 关于 this详细介绍, 后面的篇幅会详细介绍。

关于 in 操作符

我们还可以通过 in 来判断,一个对象是否拥有某一个属性/方法,无论是该属性/方法存在与实例对象还是原型对象。 in 的这种特性最常用的场景之一,就是判断当前页面是否在移动端打开。

javascript
// 很多人喜欢用浏览器UA的方式来判断,但并不是很好的方式
isMobile = 'ontouchstart' in document

四、原型链

原型对象其实也是普通的对象。几乎所有的对象都可能是原型对象,也可能是实例对象,而且还可以同时是原型对象与实例对象。这样的一个对象,正是构成原型链的一个节点。因此理解了原型,那么原型链并不是一个多么复杂的概念。
我们知道所有的函数都有一个叫做 toString 的方法。那么这个方法到底是在哪里的呢?

javascript
function add() {}
console.log(add.toString()) // function add() {}

用如下的图来表示这个函数的原型链。 image.png

  • addFunction 对象的实例
  • Function 的原型对象同时又是 Object 原型的实例。这样就构成了一条原型链。

原型链的访问,其实跟作用域链有很大的相似之处,他们都是一次单向的查找过程。因此实例对象能够通过原型链,访问到处于原型链上对象的所有属性与方法。这也是 add 最终能够访问到处于 Object 原型对象上的 toString 方法的原因。

基于原型链的特性,就可以很轻松的实现继承

更简洁的原型链图示

如果觉着上图晦涩,下面有个更简单的示例: 原型链

五、继承

继承是面向对象编程的一个重要概念。在 JavaScript 中,继承是通过原型链来实现的。

我们常常结合构造函数与原型来创建一个对象。因为构造函数与原型的不同特性,分别解决了我们不同的困扰。 因此当我们想要实现继承时,就必须得根据构造函数与原型的不同而采取不同的策略。
我们声明一个 Person 对象,该对象将作为父级,而子级 cPerson 将要继承 Person 的所有属性与方法。

javascript
function Person(name, age) {
  this.name = name
  this.age = age
}

Person.prototype.getName = function () {
  return this.name
}

首先我们来看构造函数的继承。在上面我们已经理解了构造函数的本质,它其实是在 new 内部实现的一个复制过程。而我们在继承时想要的,就是想父级构造函数中的操作在子级的构造函数中重现一遍即可。我们可以通过 call 方法来达到目的。

javascript
// 构造函数的继承
function cPerson(name, age, job) {
  Person.call(this, name, age)
  this.job = job
}

而原型的继承,则只需要将子级的原型对象设置为父级的一个实例,加入到原型链中即可。

javascript
// 继承原型
cPerson.prototype = new Person(name, age)

// 添加更多方法
cPerson.prototype.getLive = function () {}

image.png 当然关于继承还有更好的方式。

六、更好的继承

假设原型链的终点 Object.prototype 为原型链的 E(end)端,原型链的起点为 S(start)端。 通过前面原型链的学习我们知道,处于 S 端的对象,可以通过 S -> E 的单向查找,访问到原型链上的所有方法与属性。因此这给继承提供了理论基础。我们只需要在 S 端添加新的对象,那么新对象就能够通过原型链访问到父级的方法与属性。因此想要实现继承,是一件非常简单的事情。 因为封装一个对象由构造函数与原型共同组成,因此继承也会分别有构造函数的继承与原型的继承。 假设我们已经封装好了一个父类对象 Person。如下。

javascript
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
}

构造函数的继承比较简单,我们可以借助 call/apply 来实现。假设我们要通过继承封装一个 Student 的子类对象。那么构造函数可以如下实现。

javascript
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
}

原型的继承则稍微需要一点思考。首先我们应该考虑,如何将子类对象的原型加入到原型链中?我们只需要让子类对象的原型,成为父类对象的一个实例,然后通过 proto 就可以访问父类对象的原型。这样就继承了父类原型中的方法与属性了。 因此我们可以先封装一个方法,该方法根据父类对象的原型创建一个实例,该实例将会作为子类对象的原型。

javascript
function create(proto, options) {
  // 创建一个空对象
  var tmp = {}

  // 让这个新的空对象成为父类对象的实例
  tmp.__proto__ = proto

  // 传入的方法都挂载到新对象上,新的对象将作为子类对象的原型
  Object.defineProperties(tmp, options)
  return tmp
}

简单封装了 create 对象之后,我们就可以使用该方法来实现原型的继承了。

javascript
Student.prototype = create(Person.prototype, {
    // 不要忘了重新指定构造函数
    constructor: {
        value: Student
    }
    getGrade: {
        value: function() {
            return this.grade
        }
    }
})

那么我们来验证一下我们这里实现的继承是否正确。

javascript
var s1 = new Student('ming', 22, 5)

console.log(s1.getName()) // ming
console.log(s1.getAge()) // 22
console.log(s1.getGrade()) // 5

全部都能正常访问,没问题。事实上,在 ECMAScript5 中直接提供了一个 Object.create 方法来完成我们上面自己封装的 create 的功能。因此我们可以直接使用 Object.create.

javascript
Student.prototype = create(Person.prototype, {
    // 不要忘了重新指定构造函数
    constructor: {
        value: Student
    }
    getGrade: {
        value: function() {
            return this.grade
        }
    }
})

完整代码如下:

javascript
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

君子慎独