Appearance
TypeScript 面向对象
一. TypeScript 类的使用
认识类的使用
- 在早期的 JavaScript 开发中(ES5)我们需要通过函数和原型链来实现类和继承,从 ES6 开始,引入了class 关键字,可以更加方便的定义和使用类。
- TypeScript 作为 JavaScript 的超集,也是支持使用 class 关键字的,并且还可以对类的属性和方法等进行静态类型检测。
- 实际上在 JavaScript 的开发过程中,我们更加习惯于函数式编程:
- 比如React开发中,目前更多使用的函数组件以及结合
Hook的开发模式; - 比如在Vue3开发中,目前也更加推崇使用
Composition API;
- 比如React开发中,目前更多使用的函数组件以及结合
- 但是在封装某些业务的时候,类具有更强大封装性,所以我们也需要掌握它们。
- 类的定义我们通常会使用class 关键字:
- 在面向对象的世界里,任何事物都可以使用类的结构来描述;
- 类中包含特有的属性和方法;
类的定义
- 我们来定义一个 Person 类:
- 使用 class 关键字来定义一个类;
- 声明类的属性:在类的内部声明类的属性以及其对应的类型
- 如果属性的类型没有声明,那么它们默认是 any的;
- 在 ts 默认的
strictPropertyInitialization模式下面我们的属性是必须初始化的,如果没有初始化,那么编译时就会报错;
typescript
// 只声明类型,默认ts配置下会报错
class Person {
name: string; // ❌ 属性“name”没有初始化表达式,且未在构造函数中明确赋值
}
- 设置初始值的方法包括使用ES2022 类属性声明新写法 或者在 constructor 里赋值
typescript
// ES2022 类属性声明新写法
class Person {
name = "why"; // 类型自动推导
}
// constructor里赋值
class Person {
name: string;
constructor() {
this.name = "why";
}
}
- 如果我们在
strictPropertyInitialization模式下确实不希望给属性初始化,可以使用属性: 属性类型这种语法;
typescript
class Person {
name = "why";
age!: number;
}
类可以有自己的构造函数 constructor,当我们通过 new 关键字创建 一个实例时,构造函数会被调用;
- 构造函数不需要返回任何值,new 后默认返回当前创建出来的实例;
类中可以有自己的函数,定义的函数称之为方法;
typescript
class Person {
name: string;
age!: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
getAge() {
return this.age;
}
getName() {
return this.name;
}
}
const person = new Person("why", 18);
console.log(person.getAge());
console.log(person.getName());
类的继承
- 面向对象的其中一大特性就是继承,继承不仅仅可以减少我们的代码量,也是多态的使用前提。
- 我们使用
extends关键字来实现继承,子类中使用super来访问父类。 - 我们来看一下 Student 类继承自 Person:
- Student 类可以有自己的属性和方法,并且会继承 Person 的属性和方法;
- 在构造函数中,我们可以通过 super 来调用父类的构造方法,对父类中的属性进行初始化;
typescript
// 此处略去Person类的定义
class Student extends Person {
sId: number;
constructor(name: string, age: number, sId: number) {
super(name, age);
this.sId = sId;
}
studying() {
console.log(this.name + " is studing");
}
running() {
console.log(super.getName(), "is studing");
}
}
const student = new Student("coderwhy", 18, 1001);
student.studying();
student.running();
类的成员修饰符
在 TypeScript 中,类的属性和方法支持三种修饰符: public、private、protected
- public 修饰的是在任何地方可见、公有的属性或方法,默认编写的属性就是 public 的;
- private 修饰的是仅在同一类中可见、私有的属性或方法;
- protected 修饰的是仅在类自身及子类中可见、受保护的属性或方法;
public 是默认的修饰符,也是可以直接访问的,我们这里来演示一下 protected 和 private。
typescript
class Person {
private name: string;
protected age!: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
class Student extends Person {
sId: number;
constructor(name: string, age: number, sId: number) {
super(name, age);
this.sId = sId;
}
studying() {
console.log(this.name + " is studing"); // ❌ 属性name为private属性,只能在Person里访问
}
getAge() {
return this.age; // ✅ protected属性可以在子类里访问
}
}
const student = new Student("coderwhy", 18, 1001);
student.studying();
student.getAge();
console.log(student.name); // ❌ 属性name为private属性,只能在Person里访问
console.log(student.age); // ❌ 属性age为protected属性,只能在Person和其子类里访问
只读属性 readonly
- 如果有一个属性我们不希望外界可以任意的修改,只希望确定值后直接使用,那么可以使用 readonly:
typescript
class Person {
readonly name = "zhangsan";
}
const person = new Person();
person.name = "why"; // ❌ 无法为“name”赋值,因为它是只读属性
getters/setters
- 在前面一些私有属性我们是不能直接访问的,或者某些属性我们想要监听它的获取(getter)和设置(setter)的过程,这个时候我们 可以使用存取器。
typescript
class Person {
private _name = "zhangsan";
set name(name: string) {
this._name = name;
}
get name() {
return this._name;
}
}
const person = new Person();
console.log(person.name);
person.name = "why";
参数属性(Parameter Properties)
- TypeScript 提供了特殊的语法,可以把一个构造函数参数转成一个同名同值的类属性。
- 这些就被称为参数属性(parameter properties);
- 你可以通过在构造函数参数前添加一个可见性修饰符 public private protected 或者 readonly 来创建参数属性,构造函数参数也会自动赋值给对应的类属性,最后得到的这些类属性字段也会得到这些修饰符;
- 注意:即使是 public 也不可以省略
typescript
class Person {
constructor(public name: string) {}
study() {
console.log(`${this.name} is studying`);
}
}
const person = new Person("John");
console.log(person.name);
person.study();
类的类型
TypeScript 里类有三个作用:
- 创建类对应的实例对象
- 类本身同时也是对应实例对象的类型
typescript
class Person {
constructor(public name: string) {}
}
function foo(p: Person) {}
foo(new Person("Jack"));
- 类还可以当做一个有构造签名的函数
typescript
class Person {
constructor(public name: string) {}
}
function foo(p: new (name: string) => void) {}
// 写为 new (name: string) => Person 更加准确
// 但是TS不限制传入参数的返回值是否为void
foo(Person);
鸭子类型
- TS 对于类型检测用的是鸭子类型
- 鸭子类型:”当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子“
- 鸭子类型,只关心属性和行为,不关心你是不是具体的类型
- 鸭子类型和后面要说的非严格字面量赋值检测,增加了类型的宽松和易用性
typescript
class Person {
constructor(public name: string) {}
}
class Student {
constructor(public name: string) {}
}
function foo(p: Person) {}
// 写为 new (name: string) => Person 更加准确
// 但是TS不限制传入参数的返回值是否为void
foo(new Student("Jack"));
foo({ name: "why" });
二. TypeScript 抽象类
我们知道,继承是多态使用的前提。
- 所以在定义很多通用的调用接口时, 我们通常会让调用者传入父类,通过多态来实现更加灵活的调用方式。
- 但是,父类本身可能并不需要对某些方法进行具体的实现,所以父类中定义的方法,我们可以定义为抽象方法。
什么是抽象方法? 在 TypeScript 中没有具体实现的方法(没有方法体),就是抽象方法。
- 抽象方法,必须存在于抽象类中;
- 抽象类是使用
abstract声明的类; - 抽象方法声明也需要有
abstract关键词
抽象类有如下的特点:
- 抽象类是不能被实例化(也就是不能通过 new 创建)
- 抽象方法必须被子类实现,除非子类也是一个抽象类;
typescript
abstract class Shape {
abstract getArea(): number;
}
class Circle extends Shape {
constructor(private radius: number) {
super();
}
getArea() {
return Math.PI * this.radius ** 2;
}
}
class Rectangle extends Shape {
constructor(private width: number, private height: number) {
// 派生类的构造函数必须包含 "super" 调用
super();
}
getArea() {
return this.width * this.height;
}
}
const circle = new Circle(10);
const rectange = new Rectangle(20, 30);
function calcArea(shape: Shape) {
return shape.getArea();
}
calcArea(rectange);
calcArea(circle);
三. TypeScript 对象类型
对象类型的属性修饰符(Property Modifiers)
- 对象类型中的每个属性可以说明它的类型、属性是否可选、属性是否只读等信息。
- 可选属性(Optional Properties)
- 我们可以在属性名后面加一个
?标记表示这个属性是可选的;
- 我们可以在属性名后面加一个
- 只读属性(Readonly Properties)
- 在 TypeScript 中,属性可以被标记为
readonly,这不会改变任何运行时的行为; - 但在编译期类型检查的时候,一个标记为 readonly 的属性是不能被写入的(指会报错)
- 在 TypeScript 中,属性可以被标记为
typescript
interface IPerson {
name: string
age?. number
readonly height: number
}
const p: IPerson = {
name: "why",
height: 1.88
}
p.height = 2 // ❌ readonly属性不能被写入,但是运行时一切都会正常
索引签名(Index Signatures)
- 什么是索引签名呢?
- 在我们声明对象类型的时候,有时候我们不明确此类型里的所有属性的名字,但是你知道这些值的特征;
- 这种情况,你就可以用一个索引签名 (index signature) 来描述可能的值的类型;
- 索引签名格式形如
{ [key: KeyType]: ValueType } - 需要注意的是,如
[index:number]:string里的index除了可读性外,并无任何意义.但有利于下一个开发者理解你的代码,也可以使用 key 等名称
typescript
// 摘自 https://segmentfault.com/a/1190000040727281
const salary1 = {
baseSalary: 100_000,
yearlyBonus: 20_000,
};
const salary2 = {
contractSalary: 110_000,
};
interface ISalary {
// 也可以用type声明
[key: string]: number;
}
function totalSalary(salaryObject: ISalary) {
let total = 0;
for (const name in salaryObject) {
total += salaryObject[name];
}
return total;
}
totalSalary(salary1); // => 120_000
totalSalary(salary2); // => 110_000
以下是对象和数组索引签名的几个现象
- obj1 符合预期
- obj2,
obj2[0]和obj2["0"]是一样的,在 js 中访问数字属性会隐式的转化为字符串类型访问,ts 也是一样的 - arr1,同上,是访问数字会自动转化的
- arr2 符合预期
- arr3 这个不好解释,老师的解释是 arr3 上还有 forEach 等函数,
arr3.forEach访问的结果是函数而不是字符串 - 建议不要深究这个字符串和数字的问题~ 宽松中又带着诡异
typescript
const obj1: { [index: string]: string } = {
"0": "abc",
"1": "cba",
"2": "nba",
};
const obj2: { [index: number]: string } = {
"0": "abc",
"1": "cba",
"2": "nba",
};
const arr1: { [index: string]: any } = ["abc", "cba", "nba"];
const arr2: { [index: string]: string } = ["abc", "cba", "nba"];
const arr3: { [index: number]: string } = ["abc", "cba", "nba"];
- 一个索引签名的属性类型必须是
string或者是number- 注意这个或者,不能是二者的联合类型,但是我们可以写两行索引签名
- 虽然 TypeScript 可以同时支持
string和number类型,但数字索引的返回类型一定要是字符索引返回类型的子类型- 还是因为所有的数字类型都是会转成字符串类型去对象中获取内容,当我们是一个数字的时候, 既要满足通过 number 去拿到的内容, 不会和 string 拿到的结果矛盾
- 如果索引签名中有定义其他属性, 其他属性返回的类型, 必须符合
string类型返回的属性
typescript
interface IIndexType {
// 两个索引类型的写法
[index: number]: string;
[key: string]: any;
aaa: string;
// bbb: boolean ❌ 错误的类型
// 要求一:下面的写法不允许: 数字类型索引的类型, 必须是字符串类型索引的类型的 子类型
// 要求二: 如果索引签名中有定义其他属性, 其他属性返回的类型, 必须符合string类型返回的属性
}
const names: IIndexType = {
0: "abc",
1: "cba",
2: "nba",
aaa: "123",
// bbb: true
};
const item1 = names[0];
const forEachFn = names["forEach"];
names["aaa"];
export {};
- 索引签名并非强制一定要有对应的属性,似乎只校验是否矛盾,如果试图访问一个索引签名为
{ [key: string]: string }的对象的一个不存在的属性,会发生什么?- 正如预期的那样,TypeScript 将 aaa 的类型推断为
string,但是检查运行时值,它是undefined - 为了使输入更准确,将索引值标记为
string或undefined,这样,TypeScript 就会意识到你访问的属性可能不存在
- 正如预期的那样,TypeScript 将 aaa 的类型推断为
typescript
const obj1: { [index: string]: string } = {}; // ✅ 没报错
const aaa = obj[0];
console.log(aaa); // undefined
接口继承
接口和类一样是可以进行继承的,也是使用 extends 关键字:
从其他的接口中继承过来属性:
- 能减少了相同代码的重复编写
- 使用的第三库一般会给我们定义了一些属性,如果我们希望自定义的接口拥有第三方某一个类型中所有的属性,就可以使用继承来完成
并且我们会发现,接口是支持多继承的(类不支持多继承)
typescript
interface Person {
name: string;
eating: () => void;
}
interface Animal {
running: () => void;
}
interface Student extends Person, Animal {
sno: number;
}
const student: Student = {
name: "why",
sno: 10,
eating: () => {},
running: () => {},
};
接口的实现
- 接口定义后,也是可以被类实现的:
- 如果被一个类实现,那么在之后需要传入接口的地方,都可以将这个类传入;
- 一个类可以实现多个接口
- 这就是面向接口开发;
- 所以一般我们定义一个接口会在两个地方用到
- 一个是直接作为类型
- 二是被类实现
typescript
interface IKun {
name: string;
age: number;
slogan: string;
playBasketball: () => void;
}
interface IRun {
running: () => void;
}
const ikun: IKun = {
name: "why",
age: 18,
slogan: "你干嘛!",
playBasketball: function () {},
};
// 作用: 接口被类实现
class Person implements IKun, IRun {
name: string;
age: number;
slogan: string;
playBasketball() {}
running() {}
}
const ikun2 = new Person();
// const ikun3 = new Person()
// const ikun4 = new Person()
console.log(ikun2.name, ikun2.age, ikun2.slogan);
ikun2.playBasketball();
ikun2.running();
抽象类和接口的区别
抽象类在很大程度上和接口会有点类似:都可以在其中定义一个方法,让子类或实现类来实现对应的方法。
那么抽象类和接口有什么区别呢?
- 抽象类是事物的抽象,抽象类用来捕捉子类的通用特性,接口通常是一些行为的描述,只关心有没有此类行为;
- 抽象类通常用于一系列关系紧密的类之间,接口只是用来描述一个类应该具有什么行为;
- 接口可以多继承,而抽象类只能单一继承;
- 抽象类中可以有实现体(指非抽象方法),接口中只能有函数的声明;
通常我们会这样来描述类和抽象类、接口之间的关系:
- 抽象类是对事物的抽象,表达的是 is a 的关系。猫是一种动物(动物就可以定义成一个抽象类)
- 接口是对行为的抽象,表达的是 has a 的关系。猫拥有跑(可以定义一个单独的接口)、爬树(可以定义一个单独的接口) 的行为。
typescript
abstract class Animal {
abstract eating(): void;
abstract running(): void;
}
// 这里接口名字不太恰当
interface IAnimalAction {
eating: () => void;
running: () => void;
}
class Dog extends Animal implements IAnimalAction {
eating() {}
running() {}
sleeping() {}
}
function foo(animal: Animal) {}
// function foo(animal: IAnimalAction) {}
foo(new Dog());
四. 特殊严格字面量检测
奇怪的现象
- 对于对象的字面量赋值,在 TypeScript 中有一个非常有意思的现象:
- 一般来说,我们定义对象类型时需要列出其所有属性的类型
- 所以显式指定类型的对象字面量定义、对象字面量作为函数的入参等赋值行为都可能触发报错
typescript
interface IPerson {
name: string;
eating: () => void;
}
const person: IPerson = {
name: "why",
age: 18, // ❌ 对象字面量只能指定已知属性,并且“age”不在类型“IPerson”中
eating: () => {},
};
typescript
function printInfo(info: { name: string; age: number }) {
console.log(info.name, info.age);
}
printInfo({ name: "why", age: 18, height: 1.88 });
// ❌ 对象字面量只能指定已知属性,并且“height”不在类型“{ name: string; age: number; }”中
- 但是如果先定义好变量,再操作定义好的变量,则不会有报错
- 这种现象看起来不符合我们之前对象类型的知识,但是显著的提高了 TS 的易用程度
typescript
interface IPerson {
name: string;
eating: () => void;
}
const person = {
name: "why",
age: 18,
eating: () => {},
};
const p: IPerson = person; // ✅ 没有报错
- 这就是 TypeScript 的严格字面量赋值检测现象
为什么会出现这种情况呢?
- 这里我引入 TypeScript 成员在 GitHub 的 issue 中的回答:

- 简单对上面的英文进行翻译解释:
- 每个对象字面量最初都被认为是 “新鲜的(fresh)”。
- 当一个新的对象字面量分配给一个变量或传递给一个非空目标类型的参数时,对象字面量指定目标类型中不存在的属性是错误的。
- 当类型断言或对象字面量的类型扩大时,新鲜度会消失。
五. TypeScript 枚举类型
TypeScript 枚举类型
- 枚举类型是为数不多的 TypeScript 特有的特性之一:
- 枚举其实就是将一组可能出现的值,一个个列举出来,定义在一个类型中,这个类型就是枚举类型;
- 枚举允许开发者定义一组命名常量,常量可以是数字、字符串类型;
typescript
// 定义枚举类型
enum Direction {
LEFT,
RIGHT,
TOP,
BOTTOM,
// other options
}
const d1: Direction = Direction.LEFT;
function turnDirection(direction: Direction) {
switch (direction) {
case Direction.LEFT:
console.log("角色向左移动一个格子");
break;
case Direction.RIGHT:
console.log("角色向右移动一个格子");
break;
case Direction.TOP:
console.log("角色向上移动一个格子");
break;
case Direction.BOTTOM:
console.log("角色向下移动一个格子");
break;
default:
// ⚠️ 如果添加了枚举类型但是没增加对应的处理,就会报错
// 利用了never类型的特性:不能被除了never外的类型赋值
// 有没有想到之前学联合类型的时候?
const myDirection: never = direction;
}
}
// 监听键盘的点击
turnDirection(Direction.LEFT);
枚举类型的值
- 枚举类型默认是有值的,比如上面的枚举,默认值是这样的:
- 当然,我们也可以给枚举其他值:
- 这个时候会从 100 进行递增;
- 我们也可以给他们赋值其他的类型:
typescript
// 定义枚举类型
// 默认情况,从0开始以此类推,即使没有显式赋值值也一样
enum Direction {
LEFT = 0,
RIGHT = 1,
TOP = 2,
BOTTOM = 3,
}
// 如果从100开始赋值呢?
enum Direction2 {
LEFT = 100,
RIGHT, // 101
TOP = 2, // 102
BOTTOM = 3, // 103
}
enum Direction3 {
TOP,
LEFT = "LEFT",
RIGHT = "RIGHT",
BOTTOM, // ❌ 必须要有初始化表达式
}
// 移位运算符
enum Operation {
Read = 1 << 0,
Write = 1 << 1,
RW = 1 << 2,
foo, // 5
}