# JavaScript教程 - 9 面向对象

面向对象编程,是一种编程思想,简单的理解就是:先有模板,也就是类,根据模板去创建实例,也就是对象,然后使用对象来完成功能开发。

我们经常说面向过程编程和面向对象编程,面向过程编程关注的是实现功能的步骤,面向对象编程更关注的是谁来实现功能。

面向对象编程有3大特性:

  • 封装
  • 继承
  • 多态

下面依次开始讲起。

# 9.2 封装

在前面我们创建类,在类中定义了属性和方法,通过属性和方法来对现实世界的事物进行抽象的描述。

一个事物有很多的属性和方法,但是并不是所有属性和方法都需要开放出来。例如我们定义了一个手机类,我们可以使用手机打电话、拍照等,但是我们并不关心手机电压,驱动信息,也不关心内存的分配,CPU的调度等,虽然这些都属于手机的属性和行为。

我们可以将用户不关心的属性和方法封装并隐藏起来,只给类内部的方法调用,例如上网会用到4G模块,但是不是由用户来使用4G模块,而是由手机上网的功能来调用4G模块,只开放用户直接使用的信息和功能。

那么回过头来看,什么是封装?

它指的是将对象的状态信息隐藏在对象内部,不允许外部程序直接访问对象的内部信息。如果要访问这些信息,需要通过该类所提供的方法来实现对内部信息的操作和访问。

# 1 私有属性和方法

那么怎么将不想暴露的属性和方法隐藏起来呢,就需要用到私有属性和私有方法。

定义私有属性和方法,使用 # 开头来定义。

举个栗子:

class Phone {
  #voltage; // 私有属性需要显式声明

  constructor(producer, voltage) {
    this.producer = producer; // 生产商
    this.#voltage = voltage; // 电压
  }

  // 定义公共方法
  call() {
    console.log("打电话");

    this.#getRunVoltage(); // 在类内部可以调用私有方法
  }

  // 定义一个私有方法
  #getRunVoltage() {
    console.log("当前电压:" + this.#voltage);
  }
}

let phone = new Phone("小米", 12);
console.log(phone.producer); // 小米,调用公有属性
// console.log(phone.#voltage); // 报错:私有变量,类外部无法访问

phone.call(); // 调用公有方法
// phone.#getRunVoltage();  // 报错,私有方法,类外部无法访问
1
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
  • 上面定义了私有属性 #voltage 和私有方法 #getRunVoltage(),私有属性和私有方法只能在类内部的方法访问,在类的外部不能通过对象来调用。
  • 需要注意,私有方法需要显式声明。
  • 静态属性和方法,同样在属性和方法前添加 # 即可。

如果不想暴露的属性和方法可以定义为私有成员。私有属性只能在类内部的方法中调用,不能通过对象来调用。

# 2 getter和setter

我们前面说到,封装是将对象的状态信息隐藏在对象内部,不允许外部程序直接访问对象的内部信息。如果要访问这些信息,需要通过该类所提供的方法来实现对内部信息的操作和访问。

我们一般会针对私有属性提供两个对应的方法,分别用来获取和设置对应的属性,这两个方法称为 getter 和 setter 方法。

举个栗子:

class Phone {
  #voltage; // 私有属性需要显式声明

  constructor(producer, voltage) {
    this.producer = producer;
    this.#voltage = voltage;
  }

  // voltage属性的getter
  getVoltage() {
    return this.#voltage;
  }

  // voltage属性的setter
  setVoltage(voltage) {
    // 还可以在设置的时候进行判断
    if (voltage > 36 || voltage < 0) {
      return;
    }
    this.#voltage = voltage;
  }
}

let phone = new Phone('小米', 12);
phone.setVoltage(20);  // 调用 setter 设置值
console.log(phone.getVoltage());  // 调用 getter获取值
1
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
  • 在上面为私有属性 voltage 定义了 get 和 set 方法,get 方法以 get 开头,后跟属性名,遵循方法命名规则。set 方法类似,get 方法有返回值,没有参数,set 方法接收参数,没有返回值。
  • 我们还可以根据需求,在设置或获取属性值的时候,对属性值进行额外的处理,例如格式验证。
  • 如果有多个私有属性,根据需要,可以为每个私有属性添加 getter 和 setter 。

通过 getter 和 setter 来访问私有属性和为私有属性赋值。这样做的话,严格控制了属性获取和设置的入口,如果通过 对象.属性 来修改,代码很多的时候完全会不知道在哪里修改了属性导致出现了问题。


上面的 getter 和 setter 在进行属性访问的时候,需要调用方法,有点麻烦。JavaScript 还提供了访问器属性方式的 getter 和 setter。

举个栗子:

class Phone {
  #voltage; // 私有属性需要显式声明

  constructor(producer, voltage) {
    this.producer = producer;
    this.#voltage = voltage;
  }

  // voltage属性的getter
  get voltage() {
    console.log('调用了getter');
    return this.#voltage;
  }

  // voltage属性的setter
  set voltage(voltage) {
    console.log('调用了setter', voltage);
    this.#voltage = voltage;
  }
}

let phone = new Phone('小米', 12);
phone.voltage = 20;   // 以属性的方式访问,其实调用的是voltage的setter方法
console.log(phone.voltage);  // 调用voltage的getter方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  • 在上面的代码中,通过 get或set 关键字 + 属性名 构成的 getter 和 setter,可以以属性访问的方式来调用方法。
  • phone.voltage = 20; 调用的是 set voltage() 方法。

# 9.3 继承

在现实世界,有麻雀和鸽子,它们都属于鸟类,麻雀、鸽子和鸟类的关系是父类和子类的关系。

麻雀和鸽子都会飞,我们可以在麻雀类中定义一个飞的方法,在鸽子类中定义一个飞的方法,但是这两个飞的方法是一样的,都是用翅膀飞。那么我们可以直接在鸟类中定义一个飞的方法,让麻雀和鸽子类都继承这个鸟类,那么它们就拥有了飞的方法,就不用重复再定义了。

# 1 继承的语法

在 JS 中,使用extends关键字实现继承。

class 子类名 extends 父类名 {
  	类内容
}
1
2
3

举个栗子:

麻雀继承鸟类,鸽子继承鸟类:

class Bird {
  constructor(age) {
    this.age = age;
  }

  fly() {
    // 定义了一个飞的方法
    console.log(`${this.age}岁了,我会飞`);
  }

  tweet() {
    // 定义了一个叫的方法
    console.log("我会叫");
  }
}

class Sparrow extends Bird {
  // 定义一个麻雀类,继承自鸟类
}

class Pigeon extends Bird {
  // 定义一个鸽子类,继承自鸟类
}

let sparrow = new Sparrow(1); // 创建一个麻雀对象
sparrow.fly();
sparrow.tweet();

let pigeon = new Pigeon(2); // 创建一个鸽子对象
pigeon.fly();
pigeon.tweet();
1
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
  • 在上面的代码中,我们先定义了一个鸟类,然后在鸟类中定义了 age 属性 ,并定义了两个方法 fly方法tweet方法

  • 然后定义类麻雀类,类中没有代码,但是继承鸟类,那么就拥有了鸟类的属性和方法。鸽子类也同样。

执行结果:

我1岁了,我会飞 我会叫 我2岁了,我会飞 我会叫

子类继承父类后,就拥有了父类中声明的属性和方法。但是注意,继承不能继承父类私有的成员变量和方法。如果父类的成员变量和属性不想被子类继承,可以设置为私有成员。

子类也会继承父类的静态属性和方法,父类的静态属性和方法

# 2 重写父类方法

所以继承父类就拥有了父类非私有的成员变量和方法,如果父类不是我想要的方法,我还可以进行覆盖。

例如,鸟类提供了叫的方法,但是我麻雀有自己的叫法,我要啾啾叫,那么我们可以重写父类的方法,实现自己的个性化。

class Bird {
  constructor(age) {
    this.age = age; // 鸟都有年龄
  }

  fly() {
    console.log(`${this.age}岁了,我会飞`);
  }

  tweet() {
    console.log("我会叫");
  }
}

// 麻雀类
class Sparrow extends Bird {
  tweet() {
    console.log("我会啾啾叫");
  }
}

// 鸽子类
class Pigeon extends Bird {
  // 不重写父类方法,继承原样即可
}

// 测试代码
const sparrow = new Sparrow(1);
sparrow.fly(); // 输出: 我1岁了,我会飞
sparrow.tweet(); // 输出: 我会啾啾叫

const pigeon = new Pigeon(2);
pigeon.fly(); // 输出: 我2岁了,我会飞
pigeon.tweet(); // 输出: 我会叫
1
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
  • 在上面的 Sparrow 类中重写了 tweet() 方法(需要和父类中的方法同名),那么子类就有了自己定义的方法,可以实现个性化的功能。

那么如果我在父类的 fly() 方法中调用 tweet() 方法,然后子类对象调用 fly() 方法,那么会如何执行呢?

class Bird {
  fly() {
    console.log("我会飞");

    this.tweet(); // 这里调用方法
  }

  tweet() {
    console.log("我会叫");
  }
}

class Sparrow extends Bird {
  tweet() {
    console.log("我会啾啾叫");
  }
}

let sparrow = new Sparrow(); // 创建一个麻雀对象
sparrow.fly();  // 这里调用fly()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  • sparrow.fly() 调用的是继承自父类的方法,在 fly() 方法中调用了 tweet() 方法,那么调用的是父类的tweet() 方法还是子类的tweet() 方法呢?

是调用子类的方法,因为子类继承父类,子类拥有了父类的方法,所以是子类的 fly() 调用了子类的 tweet()

执行结果:

我会飞 我会啾啾叫

# 3 重写构造方法

如果一个类没有写构造方法,那么其实它是有一个默认的隐式构造方法的。

constructor() { }
1

当你显式的写了构造方法以后,默认的隐式构造方法就不会添加了。

另外,子类的隐式构造方法会调用父类的隐式的构造方法,但是如果重写了子类的构造方法,那子类构造方法的第一句代码必须手动调用父类的构造方法,否则会报错,调用父类的构造方法使用 super() 来调用。

举个栗子:

class Bird {
  constructor(age) {
    this.age = age;
  }
}

class Pigeon extends Bird {
  constructor(name, age) {
    super(age)  // 重写构造方法,第一句代码必须调用父类的构造方法,可以传递参数

    this.name = name;
  }
}

const p = new Pigeon('Niubi', 5);
console.log(p); // 输出:Pigeon {age: 5, name: 'Niubi'}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  • 在上面的代码中,子类重写了构造方法,第一句代码必须通过 super 来调用父类的构造方法;在调用父类构造方法的时候,可以传递参数。

需要注意,父类没有构造方法,子类第一句也是要调用父类的隐式构造方法的:

class Bird {
}

class Pigeon extends Bird {
  constructor(name) {
    super()  // 重写构造方法,需要调用父类的构造方法

    this.name = name;
  }
}

const p = new Pigeon('Niubi', 5);
console.log(p); // 输出:Pigeon {name: 'Niubi'}
1
2
3
4
5
6
7
8
9
10
11
12
13
  • 在上面的代码中,子类定义了构造方法,那构造方法的第一句就是要调用父类的构造方法。

# 4 调用父类方法

我们在重写父类方法的时候,应该尽量做到在父类方法的基础上进行扩展。所以很有可能需要调用父类被重写的方法,然后在父类原来的方法上进行扩展。

可以使用 super 关键字来调用父类的方法。

举个栗子:

class Bird {
  tweet() {
    console.log("我会叫");
  }
}

class Pigeon extends Bird {
  tweet() {
    super.tweet();  // 调用父类的方法

    console.log("我会咕咕叫");
  }
}

let pigeon = new Pigeon();
pigeon.tweet();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  • 如果要在子类中调用父类的方法,可以通过 super.父类方法() 来调用。

执行结果:

我会叫 我会咕咕叫

# 9.4 多态

什么是多态?

多态就是多种状态,相同的类型或方法,因为指向的是不同的对象,而表现出的不同的状态。

举个栗子:

class Bird {
  tweet() {
    console.log("我会叫");
  }
}

class Sparrow extends Bird {
  tweet() {
    console.log("我会啾啾叫");
  }
}

class Pigeon extends Bird {
  tweet() {
    console.log("我会咕咕叫");
  }
}

function makeItTweet(bird) {
  bird.tweet(); // 不关心具体类型,统一调用接口
}

let bird1 = new Sparrow(); 
let bird2 = new Pigeon(); 

makeItTweet(bird1);  // 我会啾啾叫
makeItTweet(bird2);  // 我会咕咕叫
1
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
  • 上面麻雀类和鸽子类都继承自鸟类,然后创建了一个麻雀对象和鸽子对象,通过 makeItTweet 函数调用变量的 tweet() 方法
  • 虽然两个都是鸟类的变量,执行的都是 tweet() 方法,但是因为是不同的子类对象,却得到不同的结果。

变量因为只想不同的对象,执行相同的方法,却表现不同的行为,这就是多态。


多态的作用:

  • 提高代码的维护性
  • 提高代码的扩展性
  • 把不同的子类对象当做父类来看待,可以屏蔽不同子类对象之间的差异,写出通用的代码,以适应需求的不断变化。

在其他语言中,一般通过继承或接口实现来实现多态,但是 JavaScript 是弱类型语言,没有类型检查,只要对象“看起来像”就可以用。

所以不一定要通过继承来实现,只要有相同的方法就可以实现多态。

举个栗子:

const bird = {
    speak() {
        console.log("鸟在叫");
    }
};

const robot = {
    speak() {
        console.log("机器人在说话");
    }
};

function letItSpeak(entity) {
    entity.speak();
}

letItSpeak(bird);   // 鸟在叫
letItSpeak(robot);  // 机器人在说话
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  • 在上面的代码中,两个对象没有任何关系,只是有相同的方法,就可以实现多态。
  • 所以如果它走起来像鸭子,叫起来像鸭子,那它就是鸭子。