一. TypeScript 中类的使用
在早期的 JavaScript(ES5)开发中,需要通过函数和原型链来实现类和继承,从 ES6 开始,引入了 class 关键字,可以更加方便的定义和使用类。
在现代 JavaScript(JS)和 TypeScript(TS)中,类是面向对象编程(OOP)的核心概念。 TS 作为 JS 的超集,可以对类的属性和方法等进行静态类型检测,为我们带来了更强大的类型检查能力。
即使在一些更偏函数式编程的框架(如 React 的函数组件和 Vue3 的 Composition API)中,我们在封装复杂的业务逻辑时依然会频繁使用类来实现模块化、继承和多态。
类的定义
在 TypeScript 中,我们使用 class 关键字来定义类,在类的内部声明类的属性以及对应的类型。
⚠️ 注意
- 如果类属性类型没有显式声明或属性没有初始化值,则类型默认 any。
- 在 tsconfig.json 配置文件中的 strictPropertyInitialization 开启的情况下,类属性默认是必须初始化的,如果类属性没有初始化值且未在构造函数中明确赋值,那么编译时就会报错。
- 如果 strictPropertyInitialization 选项为 true,但确实不希望给类属性初始化,可以使用明确赋值断言:
property!: type
语法,即在类属性后面加上!
,来绕过初始化检查。
示例:
class Person {
name: string; // Error:属性“name”没有初始化表达式,且未在构造函数中明确赋值
name2: any; // 或改为 any 类型
name3: number; // 或改为在构造函数中有明确赋值
name4!: boolean; // 或改为使用明确赋值断言,跳过初始化检查(带有明确赋值断言的声明还必须具有类型注释)
name5 = 1; // 或改为初始化赋值,可省略类型注解,会根据初始值自动推导出类型
constructor(name: string, name3: number) {
this.name3 = name3
}
}
const p1 = new Person('later', 18)
类的继承
面向对象的其中一大特性就是继承,继承不仅仅可以减少我们的代码量,也是多态的使用前提。 在 TS 中可以使用 extends 关键字实现类的继承。子类继承父类的属性和方法,还可以有自己的属性和方法。通过 super 关键字,子类可以调用父类的构造函数和方法。
示例:定义一个 Student 类继承 Person 类
class Person {
name: string
age: number
constructor(name: string, age: number) {
this.name = name
this.age = age
}
greet() {
console.log(`Hello, I am ${this.name}.`)
}
}
class Student extends Person {
studentId: number
constructor(name: string, age: number, studentId: number) {
super(name, age) // 调用父类的构造函数(必须写在构造函数体的第一行)
this.studentId = studentId
}
sayHello() {
super.greet()
}
study() {
console.log(`${this.name} is studying.`)
}
}
const student = new Student("Bob", 20, 12345)
student.sayHello() // 输出: Hello, I am Bob.
student.study() // 输出: Bob is studying.
示例说明:
① Student 类通过 extends 关键字继承了 Person 类。
② 在 Student 的构造函数中,super 关键字调用了 Person 的构造函数,完成父类属性的初始化。
③ eStudent 拥有自己特有的 study 方法。
类的成员修饰符
TypeScript 提供了 public、private 和 protected 三种修饰符来控制类成员的可见性:
🔔 提示
类成员指的是类中定义的属性,方法等,readonly 修饰符只能用于类属性,不能用于类方法,因此在本节内容不适用。
修饰符 | 含义 | 作用范围 |
---|---|---|
public(默认) | 公有 | 无限制 |
private | 私有 | 只能被类自身访问 |
protected | 受保护 | 只能被类自身及子类自身访问 |
示例:
// 示例一:protected 修饰的类成员,只能被类自身及子类自身访问
class Person {
protected name: string
constructor(name: string) {
this.name = name
}
}
class Student extends Person {
constructor(name: string) {
super(name)
}
running() {
console.log(this.name) // protected 修饰的类成员,可以被子类自身访问
}
}
const p1 = new Person('later')
const s1 = new Student('later-zc')
p1.name// Error:属性“name”是受保护的,只能在类“Person”及其子类中访问
s1.name// Error:属性“name”是受保护的,只能在类“Person”及其子类中访问
s1.running() // 输出: later-zc
Person.name // 输出: later-zc
Student.name // 输出: later-zc
// 示例二:private 修饰的类成员,只能被类自身访问
class Person {
private name: string
constructor(name: string) {
this.name = name
}
}
const p2 = new Person('later')
Person.name // 输出: later
p2.name// Error:属性“name”为私有属性,只能在类“Person”中访问
readonly 修饰类属性为只读
⚠️ 注意
readonly 修饰符只能出现在属性声明或索引签名上,不能用于类方法。
readonly 是 TypeScript 关键字,它不会在 JavaScript 中生效,只会在 TS 编译时限制对属性的修改。
使用 readonly 修饰符的属性只能在初始化或构造函数中赋值,赋值后不可修改。
class Person {
readonly name: string
constructor(name: string) {
this.name = name
}
updateName(_name: string) {
this.name = _name// Error:无法分配到 "name" ,因为它是只读属性
}
}
const p1 = new Person('later')
console.log(p1.name) // 可以访问
p1.name = 'later-zc'; // Error:无法分配到 "name" ,因为它是只读属性
p1.updateName('coder') // Error:无法分配到 "name" ,因为它是只读属性
如果有一个属性我们不希望外界可以任意的修改,只希望确定值后直接使用。 那么可以使用 readonly。
getters / setters
在前面一些私有属性我们是不能直接访问的,或者某些属性我们想要监听它的获取 getter
和设置 setter
的过程,这个时候我们可以使用存取器。
class Person {
private _name: string
constructor(name: string) {
this._name = name
}
get name() {
return this._name
}
set name(value: string) {
this._name = value
}
}
const p1 = new Person('later')
p1.name = 'later-zc' // 设置 name 属性
console.log(p1.name) // 输出:later-zc
在这个示例中,我们通过 get 和 set 关键字控制对 _name 属性的访问,这样可以对赋值过程进行校验或修改。
参数属性(Parameter Properties)
参数属性是 TS 提供的一种简写方式,可以在构造函数参数前直接添加修饰符(如 public、private、protected 或 readonly)将该构造函数参数转成一个同名同值的类属性(同时这些类属性也会获得相应的修饰符)。
class Person {
constructor(public name: string, private _age: number) {}
set age(value: number) {
this._age = value
}
get age() {
return this._age
}
}
const p1 = new Person('later', 18)
console.log(p1.name) // 'later'
console.log(p1._age) // Error: 属性‘_age’是私有的,只能在类‘Person’中访问。
console.log(p1.age) // 18
⚠️ 注意
- 参数属性的访问修饰符
publice
不能省略,否则会报错。- 类成员的访问修饰符
public
可以省略,因为其默认就是public
。
二. TypeScript 中的抽象类
什么是抽象类和抽象方法?
我们知道,继承是多态使用的前提,抽象类和抽象方法则是实现多态的有效方式。所以在定义很多通用的调用接口时,我们通常会让调用者传入父类,通过多态来实现更加灵活的调用方式, 但是,父类本身可能并不需要对某些方法进行具体的实现,所以父类中定义的方法,我们可以定义为抽象方法。
抽象方法
指的是用 abstract 关键字声明的方法。不能有方法体,只能存在于抽象类中,且必须在子类中被实现(除非子类也是抽象类)。
抽象类
指的是用 abstract 关键字声明的类,不能被实例化,只能被继承,抽象类可以包含抽象方法,也可以包含有实现体的方法。 通常用来定义一组共有的属性和方法接口,具体的实现交由子类完成。
为什么要使用抽象类?
- 实现多态:可以在父类中定义通用接口(方法或属性),在子类中实现特定的功能。
- 限制类的实例化:抽象类只能作为基类存在,不能直接被实例化,避免了父类实例化带来的不必要问题。
例子:
// 定义一个抽象类 Shape(图形类)
abstract class Shape {
// 声明抽象方法 getArea,无方法体,非抽象类的子类必须实现它
abstract getArea(): number
// 非抽象方法:可以在抽象类中定义带有具体实现的方法
showArea() {
console.log("图形的面积是: " + this.getArea())
}
}
// 定义具体的子类 Rectangle(矩形),继承 Shape
class Rectangle extends Shape {
constructor(public width: number, public height: number) {
super()
}
// 非抽象类的子类必须实现父类的抽象方法 getArea
getArea() {
return this.width * this.height
}
}
// 定义另一个子类 Circle(圆形),继承 Shape
class Circle extends Shape {
constructor(public radius: number) {
super()
}
getArea(): number {
return Math.pow(this.radius, 2) * Math.PI
}
}
function printArea(shape: Shape) {
return shape.showArea()
}
printArea(new Rectangle(10, 20)) // 输出:图形的面积是: 200
printArea(new Circle(5)) // 输出:图形的面积是: 78.53981633974483
printArea(new Shape()) // Error:不能创建抽象类的实例
鸭子类型
TypeScript 使用鸭子类型(Duck Typing)来进行类型检测,即只检查对象的结构是否匹配,而不关心对象的实际类型。也就是说,只要对象具有对应的属性和方法,它就可以被视为符合某个类型。
示例:
class Person {
constructor(public name: string, public age: number) {}
run() {}
}
class Coder {
constructor(public name: string, public age: number) {}
run() {}
}
function printPerson(p: Person) {
console.log(p.name, p.age)
}
// 下面三种情况都不会报错,就是因为 TypeScript 使用的鸭子类型来进行类型检测
// 鸭子类型: 如果一只鸟, 走起来像鸭子, 游起来像鸭子, 看起来像鸭子, 那么你可以认为它就是一只鸭子
// 鸭子类型, 只关心属性和行为, 不关心你具体是不是对应的类型
printPerson(new Coder('later', 20))
const p: Person = new Coder('later', 20)
printPerson({name: 'later', age: 20, run(){}}) // 甚至可以直接传递一个拥有相同属性的对象字面量
class Dog {}
const d: Dog = {} // 这也是符合鸭子类型的
在这个例子中,尽管 coder 类不是 Person 类的实例,但它的结构满足 Person 的要求,因此它可以被当作 Person 类型使用。这就是鸭子类型的思想。 不要求对象一定是由对应的类创建的实例,只要结构匹配即可。
类作为一种数据类型
在 TypeScript 中,类不仅可以用来创建实例,也可以作为数据类型。一个类的类型描述了该类的实例的结构,包括属性和方法。
class Person {}
/**
* 类的作用:
* 1. 可以创建类对应的实例对象
* 2. 类本身可以作为这个实例的类型
* 3. 类也可以当作有一个构造签名的函数,因为类可以被 new
*/
const p: Person = new Person() // 1
function printPerson(p: Person) {}
printPerson(p) // 2
function factory(ctor: new () => void) {}
factory(Person) // 3
抽象类和接口的区别
抽象类和接口看起来功能相似,但在使用上有一些关键区别。可以将抽象类理解为更具体的事物描述,而接口则是行为的描述。抽象类通常是一个事物的抽象,比如“动物”,而接口则是描述行为的抽象,比如“跑步能力”。
主要区别:
- 设计目的:抽象类是捕捉事物的共性,通常用来描述相近类型的对象;接口则是定义行为,可以给多种对象提供通用的行为。
- 多继承:接口支持多继承,抽象类只能单继承。
- 成员实现:抽象类中可以有实现的方法,而接口中只能有方法的声明。
- is a vs has a 关系:抽象类一般表示“是一个”的关系(例如“猫”是“动物”);接口一般表示“有一个”的关系(例如“猫”有“跑”的行为)。
示例:
abstract class Animal {
abstract sound(): void
move() {
console.log("动物移动")
}
}
interface IRun {
run(): void
}
class Cat extends Animal implements IRun {
sound() {
console.log("喵喵")
}
run() {
console.log("猫在跑")
}
}
在这个例子中,Cat 类继承了 Animal 抽象类,并实现了 IRun 接口。Cat 既有“动物”类的移动行为,也有“跑步”的具体实现。
三. TypeScript 对象类型
在 TypeScript 中,定义对象类型时,我们可以使用各种修饰符和类型特性来控制属性的可选性、只读性、索引特性等。这样不仅可以定义对象的基本结构,还能限制和规范对象的使用。
对象类型的属性修饰符(Property Modifiers)
对象类型中的每个属性可以说明它的类型、属性是否可选、属性是否只读等信息。
可选属性(Optional Properties)
在 TypeScript 中,我们可以通过在属性名后加一个 ? 来标记该属性为可选。这样可以定义对象类型时允许某些属性不出现。
示例:
interface IPerson {
name: string
age?: number // 等价于联合类型 number | undefined
}
const p1: IPerson = { name: "Alice" } // 没有 age 属性
const p2: IPerson = { name: "Bob", age: 30 } // 包含 age 属性
⚠️ 注意
- 可选属性的类型: 可选属性在 TypeScript 中的定义实际上等价于联合类型,即
原有类型 | undefined
。这表示属性可能是原始类型,也可能是 undefined,或者根本不存在(在这种情况下,TypeScript 会自动推断为 undefined)。
- 缺失的属性: 如果你没有显式给一个可选属性赋值,它就被认为是 undefined,但属性依然是存在的,只是值为 undefined。
只读属性(Readonly Properties)
有时候我们希望对象中的某些属性在赋值后不可修改,这时可以在属性前加 readonly 关键字,将该属性设置为只读。这只会在编译时有效,防止该属性被意外修改。
示例:
interface ICar {
readonly brand: string
model: string
}
const c1: ICar = { brand: "Toyota", model: "Corolla" }
c1.model = "Camry"; // 可以修改 model
c1.brand = "Honda"; // Error:因为 brand 是只读属性
索引签名(Index Signatures)
索引签名用于描述对象中某类动态键的类型,例如数组、字典类型对象等。索引签名可以使用字符串或数字作为索引,通过索引签名可以灵活定义对象的属性而不必预定义具体的键名。
🔔 提示
带有索引签名的接口也可以包含特定名称的属性,但这些属性类型必须兼容索引签名的返回类型。
字符串索引签名
当对象的属性名为字符串类型时,可以使用 [index: string]
来定义字符串索引签名。比如描述一个字典对象的结构,属性名为字符串,值为指定类型。
interface IDictionary {
// 键名是字符串,值是字符串
[key: string]: string
}
const myDictionary: IDictionary = {
firstName: "Alice",
lastName: "Smith",
}
数字索引签名
当属性名为数字类型时,可以使用 [index: number]
定义数字索引签名。常见的应用场景是定义数组类型。
interface ICollection {
// 返回值类型的目的是告知通过索引去获取到的值是什么类型
[index: number]: string
length: number
}
function printCollection(collection: ICollection) {
for (let i = 0; i < collection.length; i++) {
const item = collection[i]
console.log(item.length)
}
}
const array = ["abc", "cba", "nba"]
const tuple: [string, string] = ["why", "广州"]
printCollection(array)
printCollection(tuple)
索引签名的严格类型检查
当我们定义的类型有数组特性时,TypeScript 也会对其进行严格检查。通过字面量方式创建的数组,会自动关联数组类型的其他方法属性,例如 forEach、map、filter 等。这些属性的值类型不一定符合索引签名定义的类型。
示例 1:字面量数组的严格检查
interface IStringIndex {
[index: string]: string;
}
const myArray: IStringIndex = ["apple", "banana"] // Error: 类型‘string[]’不能赋值给类型‘IStringIndex’。类型‘string[]’中缺少类型‘string’的索引签名。
为什么会出现上面的情况? 因为我们字面量形式定义的数组,是会经过严格字面量赋值检测的,除了会检测 ['abc', 'cba', 'nba'] 中的 ['0']、['1']、['2'] 这几个字符串索引之外, 因为数组有
forEach
、filter
等方法,这些属性类型与 string 不匹配,还会检测到['forEach']、['filter']这些字符串索引,而这些索引对应的值就并不符合 string 类型,所以就会报错。typescript// ["abc", "cba", "nba"] => Array实例 => names[0]、names.forEach、... // names["forEach"] => function // names["map/filter"] => function
既然有严格字面量检查,那我们让它不是新鲜的不就可以绕过检查了吗? 经过测试发现,可能索引签名这里是比较特殊的,即使使其失去新鲜,仍然会保持严格的字面量检查。
示例 2:解决方式
可以通过定义 any 类型索引签名或使用非字面量形式创建数组来解决这个问题。
interface IStringIndex {
[index: string]: any
}
const myArray: IStringIndex = ["apple", "banana"] // 不报错
当索引类型为 number 时,只会检测 ['abc', 'cba', 'nba'] 中的 [0]、[1]、[2] 这几个数字索引,这几个索引所对应的值也是符合 string 类型的,所以不会报错。
interface IStringIndex {
[index: number]: string
}
const myArray: IStringIndex = ["apple", "banana"] // 不报错
数字和字符串索引签名的混用
TypeScript 的一个限制是:当对象同时包含字符串和数字索引签名时,数字索引的值类型必须是字符串索引值类型的子类型。
这是因为在 JavaScript 中,当索引一个数字时,JS引擎 实际上会在索引对象之前将其隐式转换为字符串,obj[0] 等同于 obj["0"]。
错误示例:
interface IInvalidIndexType {
[index: number]: string | number; // Error:“number”索引类型“string | number”不能分配给“string”索引类型“string”
[key: string]: string;
}
正确示例:
为了避免报错,可以将字符串索引返回类型改为 any 或者包含数字索引返回类型。
interface IValidIndexType {
[index: number]: string | number
[key: string]: any
}
const example: IValidIndexType = {
0: "apple",
1: "banana",
fruit: "cherry",
count: 3,
}
🔔 提示
- 索引签名的属性类型必须是
string
或者是number
。- 如果索引签名中有定义其他属性,其他属性返回的类型,也必须符合
string
类型返回的属性。
综合示例:属性修饰符与索引签名的结合
假设我们要定义一个接口 IUserInfo,其中包含用户信息,支持任意扩展字段(例如用户的偏好设置等)。可以结合使用可选属性、只读属性和索引签名。
interface IUserInfo {
readonly id: number
name: string
age?: number
[key: string]: any // 任意扩展字段
}
const user: IUserInfo = {
id: 101,
name: 'later',
age: 25,
address: 'Shen Zhen',
isAdmin: true
}
// 不允许修改只读属性 id
user.id = 102; // 报错
四. 特殊: 严格字面量检测
严格的字面量赋值检测
在 TypeScript 中,当我们将对象字面量赋值给一个类型接口(例如 IPerson)时,TypeScript 会进行严格的属性检查。如果对象字面量包含接口未定义的额外属性,编译器会报错;不过,如果对象赋值后再使用,这种检查就不再严格。
示例一:
interface IPerson {
name: string
age: number
}
// 1.直接将字面量赋值给接口类型变量
const info: IPerson = {
name: "why",
age: 18,
// 多一个height属性
height: 1.88, // Error:不能将类型“{ name: string; age: number; height: number; }”分配给类型“IPerson”。对象文字可以只指定已知属性,并且“height”不在类型“IPerson”中
}
// 2.将对象字面量先赋值给变量,再赋值给接口类型变量
const obj = {
name: "why",
age: 18,
height: 1.88
}
const info2: IPerson = obj // 不报错
// 验证二:
function printPerson(person: IPerson) {}
printPerson({ name: "kobe", age: 30, height: 1.98 }) // Error:类型“{ name: string; age: number; height: number; }”的参数不能赋给类型“IPerson”的参数。对象文字可以只指定已知属性,并且“height”不在类型“IPerson”中
const kobe = { name: "kobe", age: 30, height: 1.98 }
printPerson(kobe) // 不报错
在第一个示例中,info 的字面量直接赋值给 IPerson 类型的变量,所以 TypeScript 会严格检查是否有多余的属性 height,发现后报错。而在第二个示例中,obj 先定义为一个普通对象,再赋值给 IPerson,此时不会报错,因为严格检查只在第一次创建对象字面量时触发。
示例二:函数参数传递
interface IPerson {
name: string
age: number
}
function printPerson(person: IPerson) {
console.log(person)
}
// 直接传入字面量参数会报错
printPerson({ name: "kobe", age: 30, height: 1.98 }) // Error:不能将“{ name: string; age: number; height: number; }”分配给类型“IPerson”
// 先定义对象,再传入则不会报错
const kobe = { name: "kobe", age: 30, height: 1.98 }
printPerson(kobe) // 不报错
总结
- 直接赋值或传递字面量时:TypeScript 会进行严格的属性检查,确保对象字面量中的属性完全符合目标接口(不能多或少)。
- 将对象先赋值给变量再传递:TypeScript 检查不再严格,允许多余属性存在(新鲜度检查失效)。
为什么会出现这种情况呢?
这里引入 TypeScript 成员在 GitHub 的 issue 中的回答:

简单对上面的英文进行翻译解释:
- 每个对象字面量最初都被认为是“新鲜的(fresh)”。
- 当一个新的对象字面量分配给一个变量或传递给一个非空目标类型的参数时,对象字面量指定目标类型中不存在的属性是错误的。
- 当类型断言或对象字面量的类型扩大时(别的场景扩大使用时),新鲜度会消失。
根据 TypeScript 团队的解释,每个对象字面量在创建时都带有一个“新鲜性”标记(freshness),即对象被视为“新鲜的(fresh)”。当将新鲜对象字面量分配给一个具体类型或接口时,TypeScript 会检查它是否仅包含目标类型的属性,任何额外属性都被视为错误。以下几种情况会导致新鲜度消失,变成“宽松的”对象,放松属性检查:
① 字面量被赋值到一个变量后再使用。
② 类型断言。
③ 字面量的类型被扩大。
这种机制帮助开发者在对象创建阶段进行更精确的类型检查,有助于早期发现错误,提升代码的安全性和可维护性。
总结
- 直接赋值时,注意属性完整性:确保字面量只包含接口要求的属性。
- 想要避免严格检查:可以先将字面量赋值给变量,或使用类型断言。