面向对象概述
我们为什么要去学习面向对象
笔者作为耕耘前端领域,现在技术迭代更新不穷,如果还停留在以前的学习模式和应用的知识框架下,对于自身的发展会很有限。那就来说说TS给我们到底能带来什么吧。
-
TS为前端面向对象开发带来了契机
JS语言是一门弱类型语言,没有类型检查,如果使用面向对象的方式开发,会产生大量的接口,而大量的接口会导致调用复杂度剧增,这种复杂度必须通过严格的类型检查来避免错误,尽管我们可以使用注释、文档或记忆力,但是它们没有强的一个约束力。
而TS带来了完整的类型系统,因此在我们开发复杂程序时,无论接口数量有多少,都可以获得完整的类型检查,并且这种检查是具有强约束力的。
-
面向对象中有许多非常成熟的模式,能处理复杂问题
在过去的很多年中,在大型应用或复杂领域,面向对象已经积累了非常多的经验。(后续也会在相应的部分介绍相对成熟的设计模式)
什么是面向对象
面向对象:Oriented(基于) Object(事物),简称OO。
是一种编程思想,它提出一切以类对切入点思考问题。
当然还有其他编程思想:比如面向过程、函数式编程等等
开发中最重要最难的是什么?
比起书写代码,最难的是开发思维和设计模式
面向过程:以功能流程为思考切入点,不太适合大型应用
函数式编程:以数学运算为思考切入点
面向对象:以划分类为思考切入点。类是最小的功能单元
类的继承
继承的作用
继承可以描述类与类之间的关系
如果A和B都是类,并且可以描述为A是B,则A和B形成继承关系:
- B是父类,A是子类
- B派生A,A继承自B
- B是A的基类,A是B的派生类
如果A继承自B,则A中自动拥有B中的所有成员
export class Fruits{
color: string = '水果的颜色';
name: string = '水果'
showInfo() {
console.log(`这是一个${this.name},他是${this.color}`)
}
}
export class Apple extends Fruits {
color: string = '红色';
name: string = '苹果';
life: string = '3个月'
showInfo() {
console.log('我是苹果');
}
}
export class Banana extends Fruits {
color: string = '黄色';
name: string = '香蕉'
}
const a = new Apple()
const b = new Banana()
a.showInfo();
b.showInfo();
打印结果:
成员的重写
重写(override):子类中覆盖父类的成员
子类成员不能改变父类成员的类型
在上面的例子,创建一个苹果实例对象,对实例属性name进行重新赋值number类型, 这时候TS提示:不能将类型“number”分配给类型“string”。
const a = new Apple()
a.name = 123 // 不能将类型“number”分配给类型“string”。
无论是属性还是方法,子类都可以对父类的相应成员进行重写,但是重写时,需要保证类型的匹配。
🔥注意:
this关键字:在继承关系中,this的指向是动态
——调用方法时,根据具体的调用者确定this指向
super关键字:在子类的方法中,可以使用super关键字读取父类成员
class Apple extends Fruits {
color: string = '红色';
life: string = '3个月'
test() {
super.showInfo()
// 或者
this.showInfo()
}
showInfo() {
console.log('我是苹果');
}
}
类型匹配(继承关系中怎么类型匹配)
鸭子辨型法
子类的对象,始终可以赋值给父类
eg: 给常量a限制Fruits类型
const a: Fruits = new Apple()` a.showInfo() // 我是苹果 a.life // ??? 能使用吗
给常量a限制约束Fruits后,
a.life
ts推断:类型“Fruits”上不存在属性“life”。因为约束了Fruits,a只能使用Fruits中共有的部分,如果确实要使用这个属性,我们可以使用类型保护,如下:
// 判断原型链是否具有 if (a instanceof Apple) { a.life }
面向对象中,这种现象,叫做 里氏替换原则
如果需要判断一个数据的具体子类类型,可以使用 instanceof
protected
修饰符
readonly
:只读修饰符
访问权限修饰符:
private
:私有的
public
:共有的
protected
: 受保护的成员,只能在自身和子类中访问
继承的单根性和传递性
单根性:每个类最多只能拥有一个父类,
传递性:如果A是B的父类,并且B是C的父类,则,可以认为A也是C的父类
抽象类
为什么需要抽象类
比如中国象棋里面的旗子,马,兵,炮...都是旗子的子类,但是我们又不能用旗子这个概念去描述他,我们可以使用类具体去new对应的实例,但是我们不用去具体的创建一个旗子的类。比如:
class Chess {}
class Horse extends Chess {}
class Pao extends Chess {}
class Soldier extends Chess {}
class King extends Chess {}
const h = new Horse()
const p = new Pao()
const s = new Soldier()
const k = new King()
有时,某个类只表示一个抽象概念,主要用于提取子类共有的成员,而不能直接创建它的对象。该类可以作为抽象类。
给类前面加上abstract
,表示该类是一个抽象类,不可以创建一个抽象类的对象。
abstract class Chess {}
在我们创建一个抽象类的实例时,TS会提示:
抽象成员
abstract class Chess {
x: number = 0
y: number = 0
// 在父类中不知道具体的旗子名字,也可定义为抽象
abstract readonly name: string;
}
// 让子类中必须实现name属性
class Horse extends Chess {
readonly name: string = '马';
}
父类中,可能知道有些成员是必须存在的,但是不知道该成员的值或实现是什么,因此,需要有一种强约束,让继承该类的子类,必须要实现该成员。
抽象类中,可以有抽象成员,这些抽象成员必须在子类中实现
扩展设计模式之 「模板模式」
设计模式: 面对一些常见的功能场景,有一些固定的、经过多年实践的成熟方法,这些方法称之为设计模式。
比如在中国象棋里面,定义了棋子的抽象父类,每个棋子都有共有的移动规则,比如每个棋子都要进行边界判断,目标位置是否有己方的棋子,但是每个棋子的移动规则又不一样,马踏斜日、象飞田等等,当是在父类中我们想不到怎么去处理这么复杂的类型,我们可以在父类中做成抽象方法:
abstract class Chess {
x: number = 0
y: number = 0
abstract readonly name: string;
move(targetX: number, targetY: number): boolean {
// 1. 边界判断
console.log('1. 边界判断')
// 2. 目标位置是否有己方旗子
console.log('2. 目标位置是否有己方旗子')
if (this.rule(targetX, targetY)) {
console.log(`${this.name}移动成功`)
}
return false
}
protected abstract rule(targetX: number, targetY: number): boolean
}
class Horse extends Chess {
readonly name: string = '马';
protected rule(targetX: number, targetY: number): boolean {
return true
}
}
class Pao extends Chess {
name: string
constructor() {
super()
this.name = '炮'
}
protected rule(targetX: number, targetY: number): boolean {
return false
}
}
class Soldier extends Chess {
// 访问器
get name() {
return '兵'
}
protected rule(targetX: number, targetY: number): boolean {
return true
}
}
class King extends Chess {
name: string = '将';
protected rule(targetX: number, targetY: number): boolean {
return true
}
}
const h = new Horse()
const p = new Pao()
const s = new Soldier()
const k = new King()
console.log(h.name)
console.log(p.name)
console.log(s.name)
h.move(1, 2)
p.move(1, 2)
s.move(1, 2)
k.move(2, 3)
模板模式:有些方法,所有的子类实现的流程完全一致,只是流程中的某个步骤的具体实现不一致,可以将该方法提取到父类,在父类中完成整个流程的实现,遇到实现不一致的方法时,将该方法做成抽象方法。
静态成员
什么是静态成员
如果有这么一个登录注册的场景,创建实例的时候相当于注册,登录的时候去判断是否存在,登录的时候我们我们不能创建用户对象,可以使用静态成员来实现:
class User {
static users: User[] = [];
constructor(
public loginId: string,
public loginPwd: string,
public name: string,
) {
//需要将新建的用户加入到数组中
User.users.push(this);
}
sayHello() {
console.log(`大家好,我叫${this.name},我的账号是${this.loginId}`)
}
static login(loginId: string, loginPwd: string): User | undefined {
return this.users.find(u => u.loginId === loginId && u.loginPwd === loginPwd)
}
}
new User("s1", "123", "小花");
new User("s2", "123", "小明");
new User("s3", "123", "程程");
const result = User.login("s3", "123");
if(result){
result.sayHello();
}
else{
console.log("登录失败,账号或密码不正确")
}
静态成员是指,附着在类上的成员(属于某个构造函数的成员)
使用static修饰的成员,是静态成员
实例成员:对象成员,属于某个类的对象
静态成员:非实例成员,属于某个类
静态方法中的this
实例方法中的this指向的是当前对象
而静态方法中的this指向的是当前类
扩展设计模式之「单例模式」
中国象棋,棋盘可以是个类,它有宽度高度,初始化等属性和方法,创建棋盘的时候new Board()
,初始化的时候有需要创建一个new Board()
,如何避免去创建一些没有意义的对象,可以使用单例模式,把构造函数私有化,只能在类的内部创建对象。
定义私有的静态的棋·_board
,也是唯一的棋盘, 在内部定义一个创建棋盘的方法createBoard()
,内部判断是否有值,有值的话直接返回,没有值的话新建一个对象并返回。
class Board {
width: number = 500;
height: number = 700;
init() {
console.log("初始化棋盘");
}
private constructor() { }
// 定义私有的静态的棋盘,也是唯一的棋盘
private static _board;
static createBoard(): Board {
// 静态方法中的this指向的是Board类
if (this._board) {
return this._board;
}
this._board = new Board();
return this._board;
}
}
const b1 = Board.createBoard();
const b2 = Board.createBoard();
console.log(b1 === b2); // true
单例模式:某些类的对象,在系统中最多只能有一个,为了避免开发者造成随意创建多个类对象的错误,可以使用单例模式进行强约束。
接口
接口用于约束类、对象、函数,是一个类型契约。
有一个马戏团,马戏团中有很多动物,包括:狮子、老虎、猴子、狗,这些动物都具有共同的特征:名字、年龄、种类名称,还包含一个共同的方法:打招呼,它们各自有各自的技能,技能是可以通过训练改变的。狮子和老虎能进行火圈表演,猴子能进行平衡表演,狗能进行智慧表演
马戏团中有以下常见的技能:
- 火圈表演:单火圈、双火圈
- 平衡表演:独木桥、走钢丝
- 智慧表演:算术题、跳舞
首先创建动物类:
export abstract class Animal {
abstract type: string;
constructor(
public name: string,
public age: number
) {}
sayHello() {
console.log(`各位观众,大家好,我是${this.type},今年${this.age}岁了!`)
}
}
export class Lion extends Animal{
type: string = '狮子'
singeFire() {
console.log(`${this.name}表演了单火圈!`)
}
doubleFire() {
console.log(`${this.name}表演了双火圈!`)
}
}
export class Tiger extends Animal{
type: string = '老虎'
singeFire() {
console.log(`${this.name}表演了单火圈!`)
}
doubleFire() {
console.log(`${this.name}表演了双火圈!`)
}
}
export class Monkey extends Animal{
type: string = '猴子'
dumuqiao() {
console.log(`${this.name}表演走独木桥!`)
}
}
export class Dog extends Animal{
type: string = '狗'
}
引用:
import { Animal, Lion, Tiger, Monkey, Dog } from "./animals";
const animals: Animal[] = [
new Lion('小狮子', 2),
new Tiger('小老虎', 3),
new Monkey('小猴子', 4),
new Dog('小狗', 2),
]
// 1. 所以的动物打招呼
console.log('====================================');
console.log('1. 所以的动物打招呼');
animals.forEach(element => {
element.sayHello()
})
console.log('====================================');
// 2. 所有会进行火圈表演的动物完成火圈表演
console.log('====================================');
console.log('2. 所有会进行火圈表演的动物完成火圈表演');
/**
* 存在隐患,变相变成判断狮子老虎了
* 后续如果狮子老虎如果不具有火圈表演的能力,而猴子具有火圈表演,狮子老虎类中没有对应的函数支持,导致问题
* 判断能力变成了判断类型,容易将类型和能力耦合在一起
*/
animals.forEach(element => {
if (element instanceof Lion || element instanceof Tiger) {
element.singeFire()
element.doubleFire()
}
})
console.log('====================================');
在上面的第二点中,存在隐患,变相变成判断狮子老虎了,后续如果狮子老虎如果不具有火圈表演的能力,而猴子具有火圈表演,狮子老虎类中没有对应的函数支持,导致问题,判断能力变成了判断类型,容易将类型和能力耦合在一起,不适用接口实现时:
- 对能力(成员函数)没有强约束力
- 容易将类型和能力耦合在一起
系统中缺少对能力的定义 —— 接口,新增interface.ts
对动物的能力做定义
// 火圈能力
export interface IFireshow {
singleFire(): void;
doubleFire(): void;
}
// 智慧能力
export interface IWisdomShow{
suanshuti(): void;
dance(): void;
}
// 表演能力
export interface IBalanceShow{
dumuqiao(): void;
zougangsi(): void;
}
在继承类后使用implements:实现
了某某具体的能力,实现了接口意味着类方法必须有对应函数,不然会有报错,实现了成员的强约束力。
import { IBalanceShow, IFireshow, IWisdomShow } from './interface';
export abstract class Animal {
... ...
}
export class Lion extends Animal implements IFireshow{
type: string = '狮子'
singleFire() {
console.log(`${this.name}表演了单火圈!`)
}
doubleFire() {
console.log(`${this.name}表演了双火圈!`)
}
}
export class Tiger extends Animal implements IFireshow{
type: string = '老虎'
singleFire() {
console.log(`${this.name}表演了单火圈!`)
}
doubleFire() {
console.log(`${this.name}表演了双火圈!`)
}
}
export class Monkey extends Animal implements IBalanceShow{
type: string = '猴子'
dumuqiao() {
console.log(`${this.name}表演走独木桥!`)
}
zougangsi(): void {
console.log(`${this.name}表演走钢丝!`)
}
}
export class Dog extends Animal implements IWisdomShow{
suanshuti(): void {
console.log(`${this.name}表演算术题!`)
}
dance(): void {
console.log(`${this.name}表演跳舞!`)
}
type: string = '狗'
}
面向对象领域中的接口的语义:表达了某个类是否拥有某种能力
某个类具有某种能力,其实,就是实现了某种接口
如何具体的去判断某个类是否具有接口的能力,比如刚刚的第二点,在Java中可用 element instanceof IFireShow
来判断, 但是是在运行过程中,TS中不可以,只能使用类型保护函数来实现
// 类型保护函数
function hasFireShow(ani: object): ani is IFireshow {
if ((ani as unknown as IFireshow).singleFire && (ani as unknown as IFireshow).doubleFire) {
return true
}
return false
}
animals.forEach(element => {
if (hasFireShow(element)) {
element.singleFire()
element.doubleFire()
}
})
运行结果:
类型保护函数:通过调用该函数,会触发TS的类型保护,该函数必须返回boolean
给老虎移除火圈表演的能力,猴子培养火圈表演的能力, implements
可以实现多个接口的能力:
export class Monkey extends Animal implements IBalanceShow,IFireshow{
type: string = '猴子'
dumuqiao() {
console.log(`${this.name}表演走独木桥!`)
}
zougangsi(): void {
console.log(`${this.name}表演走钢丝!`)
}
singleFire() {
console.log(`${this.name}表演了单火圈!`)
}
doubleFire() {
console.log(`${this.name}表演了双火圈!`)
}
}
接口和类型别名的区别
接口和类型别名的最大区别:接口可以被类实现,而类型别名不可以
扩展: 接口可以继承类,表示该类的所有成员都在接口中。
class A {
a1: string = ""
a2: string = ""
a3: string = ""
}
class B {
b1: number = 0;
b2: number = 0;
b3: number = 0;
}
// 接口可以继承类
interface C extends A, B { }
const c: C = {
a1: "",
a2: "",
a3: "",
b1: 0,
b2: 3,
b3: 4
}
索引器
索引器: 对象[值]
,使用成员表达式
export class User {
public name: string = 'xiaohua'
public age: number = 18
}
const u = new User()
console.log(u['pid']) // undefined
在TS中,默认情况下,不对索引器(成员表达式)做严格的类型检查
在tsconfig.json
使用配置noImplicitAny
开启对隐式any的检查。
隐式any:TS根据实际情况推导出的any类型
在索引器中,键的类型可以是字符串,也可以是数字
在类中,索引器书写的位置应该是所有成员之前
export class User {
[prop: string]: string | number | {(): void} // 对类中所有成员也进行了限制
public name: string = 'xiaohua'
public age: number = 18
sayHello() { }
}
const u = new User()
u["sayHello"]
console.log(u['pid'])
TS中索引器的作用
- 在严格的检查下,可以实现为类动态增加成员
- 可以实现动态的操作类成员
在JS中,所有的成员名本质上,都是字符串,如果使用数字作为成员名,会自动转换为字符串。
在TS中,如果某个类中使用了两种类型的索引器,要求两种索引器的值类型必须匹配
this指向约束
在JS中this指向的几种情况
在JS大部分时候,this的指向取决于函数的调用方式
- 如果直接调用函数(全局调用),this指向全局对象或undefined (启用严格模式)
- 如果使用
对象.方法
调用,this指向对象本身 - 如果是dom事件的处理函数,this指向事件处理对象
特殊情况:
-
箭头函数,this在函数声明时确定指向,指向函数位置的this
-
使用bind、apply、call手动绑定this对象
TS中的this
在tsconfig.json
配置noImplicitThis:true
,表示不允许this隐式的指向any
class User {
constructor(
public name: string,
public age: number
) { }
sayHello(this: User) {
console.log(this, this.name, this.age)
}
}
const u = new User('huahua', 18);
const say = u.sayHello
在TS中,允许在书写函数时,手动声明该函数中this的指向,将this作为函数的第一个参数,该参数只用于约束this,并不是真正的参数,也不会出现在编译结果中。