尽管走下去,不必逗留着采鲜花来保存,因为在这一路上,花自然会继续开放。
——泰戈尔《飞鸟集》
在上篇文章里,我对 TypeScript 中的类型声明做了介绍,这块也是 TypeScript 的基础知识。讲解的内容包括:
- 指定类型的语法:
: type
- 指定为基本类型:
boolean
、number
、string
、null
、undefiend
和symbol
- 指定为对象类型
- 普通对象:
interface Type {...}
- 数组:
type[]
或(type1 | type2 | ...)[]
- 普通对象:
- 扩展类型
- 字面量类型:字符串字面量、数值字面量和布尔值字面量
- 枚举:
enum Colors {...}
接下来要介绍的包括:面向对象编程、访问控制修饰符、类和接口、泛型。
我们一个个来讲。
面向对象编程
属性和方法
ES6 引入了 class
关键字,为 JavaScript 引入了类似于“类”的能力。
我们先看一段 JavaScript 代码:
class Cat {
constructor(name) {
this.name = name
}
sayHi() {
return `Meow, my name is ${this.name}`
}
}
let tom = new Cat('Tom')
tom.sayHi() // Meow, my name is Tom
复制代码
我们声明了一个类 Cat
,包含一个属性 name
和方法 sayHi
。如果用 TypeScript 怎么去改写,添加类型限制呢?这样做:
class Cat {
name: string;
sayHi(): string {
return `Meow, my name is ${this.name}`
}
}
let tom = new Cat()
tom.name = 'Tom'
复制代码
上例我们以给实例 tom
刻意添加属性 name
的方式,来说明在类中声明属性类型的做法:在 TypeScript 类中,在使用诸如 this.name
的方式引入或设置实例属性时,如果没有提前以 prop: type
的方式声明实例属性的话,就会报错:Property 'name' does not exist on type 'Cat'.
;同时,我们限制实例方法的返回值类型是字符串。
访问控制修饰符
JavaScript 并没有私有属性的概念,通常我们使用约定或者函数作用域来实现“私有属性”。TypeScript 就实现了这个特性,引入了访问控制修饰符。
访问控制修饰符就是一些关键字,用来限制类成员(属性或方法)的访问范围。比如 public
用来修饰公用的属性或方法。
类的修饰符包括:private
、protected
、和 public
(默认)。
对于类的每一个属性和方法,都可以在之前添加一个修饰符,限制其可访问范围:
class Cat {
name: string
constructor(name: string) {
this.name = name
}
sayHi(): string {
return `Meow, my name is ${this.name}`
}
}
复制代码
这里的 nam
是共有属性,也就是说,通过 new Cat('foo')
返回一个实例对象后,我们可以通过 .name
的方式访问 属性 name
,但是如果我们给 name
加了一个修饰符 private
,这里的 name
就成为私有属性了,在实例上通过 .name
的方式不再能访问到。而关键字 protected
,则限制类成员只能在当前类(也就是本例中的 Cat
)及其子类中使用。
总结下来就是:
当前类 | 子类 | 实例 | |
---|---|---|---|
private | ✔️ | ||
protected | ✔️ | ✔️ | |
public | ✔️ | ✔️ | ✔️ |
类和接口
类的继承
继承能够增强代码的可复用性,将子类公用的方法和属性抽象出来,一些表现不一致的子类,则可以通过覆写(override)的方式,将父类的属性或者方法覆盖掉,定义自己的逻辑。
// 在此定义一个 `Animal` 类
class Animal {
name: string;
constructor(name) {
this.name = name
}
// 包含一个方法 `sayHi`
sayHi() {
console.log('Hi');
}
}
// 类 `Cat` 继承自 `Animal`
class Cat extends Animal {
constructor(name) {
super(name)
}
// 这里定义的 `sayHi` 方法会覆盖在父类中定义的
sayHi() {
console.log(`Meow, my name this ${this.name}`);
}
// 除了方法 `sayHi`,子类 `Cat` 还定义了自己的方法 `catchMouse`
catchMouse() {
console.log('捉到一只老鼠')
}
}
复制代码
类实现接口
接口是行为的抽象。
举个例子,看下面的代码:
// 鸟类(`Bird`)包含两个方法 `fly` 和 `jump`
class Bird {
fly() {
console.log('鸟在飞')
}
jump() {
console.log('鸟在跳')
}
}
// 蜜蜂(`Bee`)与鸟类类似有一个 `fly` 方法,初次之外,还拥有一个采蜜(`honey`)的能力。
class Bee {
fly() {
console.log('蜜蜂在飞')
}
honey() {
console.log('蜜蜂在采蜜')
}
}
复制代码
Bird
和 Bee
中具有一个同名的方法,我们可以将相似类的共同的属性和方法抽象出来,放在接口里。
// 此处定义了一个接口 `Wings`
// 接口中定义了一个方法 `fly`,无返回值(void)
interface Wings {
fly(): void;
}
复制代码
然后再让 Bird
和 Bee
来实现这个接口:
// 使用关键字 `implements` 声明要实现的接口
// 实现了某一接口的类(也就是这里的 `Bird`),必须实现接口中定义的所有属性或方法
class Bird implements Wings {
// 因为继承了接口 `Wings`,因此必须实现 `fly` 方法
fly() {
console.log('鸟在飞')
}
// `jump` 是 `Bird` 中特有的方法
jump() {
console.log('鸟在跳')
}
}
// `Bee` 也实现了 `Wings`,因此也要实现 `fly` 方法
class Bee implements Wings {
fly() {
console.log('蜜蜂在飞')
}
// 除了 `fly`,蜜蜂也定义了自己的 `honey` 方法
honey() {
console.log('蜜蜂在采蜜')
}
}
复制代码
由此可见,接口的作用:就是在一个统一的地方定义实现的接口,使实现接口的类有统一的行为。
那么继承类和实现接口有什么限制呢?答案是:一个类只可以继承自一个类,但可以实现多个接口。
我们举个例子:
// 接口 `Wings`,定义了一个方法 `fly`
interface Wings {
fly(): void;
}
// 接口 `Mouth`,定义了一个方法 `sing`
interface Mouth {
sing(): void;
}
// 声明一个抽象类 `Animal`
abstract class Animal {
// 用关键字 `abstract` 修饰的方法称为“抽象方法”
// 抽象方法是让继承类去实现的
abstract eat(): void;
}
// `Bird` 类继承自 `Animal`,并实现了两个接口 `Wings` 和 `Mouth`
class Bird extends Animal implements Wings, Mouth {
fly() {
console.log('鸟在飞')
}
eat() { ... } // 这个是接口 `Wings` 中定义的方法,必须实现
sing() { ... } // 这个是接口 `Mouth` 中定义的方法,必须实现
}
复制代码
大家会会发现,抽象类与接口非常类似,那么有何区别呢?首先说共同点:都定义了公共的方法,然后让具体的类去实现。
而区别在于:
- 接口就像插件一样,是用来增强类的,而抽象类则是具体类的抽象概念。比如这里的鸟(
Bird
)就是动物(Animal
)的一种。 - 类实现接口是多对多的关系,一个类可以实现多个接口,一个接口也可以被多类实现;而类继承类则是一对多的关系,一个类的父类只能有一个,一个类的子类则可以有多个。
注意:在抽象类中,除了可以定义抽象方法,也可以直接书写实现的方法,这样的话,继承此抽象类的子类实例会自动具有这些已实现的方法了。
接口继承类
接口除了可以继承(extends
)接口外,还可以继承类。
// 声明了一个类 `Dragon`
class Dragon {
fly() {
console.log('龙在飞')
}
}
// 接口 `FireDragon` 继承了 `Dragon`
// 也就是说此接口额外多了一个 `'() => string'` 类型的方法 `fly` 的定义
interface FireDragon extends Dragon {
fire(): void
}
// 我们将变量 `f` 的类型声明为 `FireGragon`,因此 `f` 必须实现 `fire` 和 `fly` 两个方法
// 否则会报错
let f: FireGragon = {
fire() {
console.log('龙在喷火')
}
fly() {
console.log('龙在飞')
}
}
复制代码
泛型函数
泛型函数
如果需要设计一个函数:接收一个 number 类型参数,然后返回值也是 number 类型,该怎么做呢?通过之前的学习,我们能写出:
function double(num: number): number {
return num * 2
}
复制代码
那么如果是这样的:函数的返回值类型总是跟传入的参数类型,保持一致,该怎么做呢?这就引入了泛型的概念。
我们在声明函数时,可以指定使用泛型。
我们先写一个函数:
// 我们声明了一个函数 `createArray`
function createArray(value, length) {
let arr = []
for (let i = 0; i < length; i++) {
arr[i] = value
}
return arr
}
let fooArray = createArray('foo', 3)
fooArray // ["foo", "foo", "foo"]
复制代码
上面的 createArray
就是一个普通的函数,未使用 TypeScript 的类型限制。在 TypeScript 中,未声明的变量缺省类型是 any
。因此上面代码的写法等同于:
function createArray(value: any, length: any): any[] {
let arr = []
for (let i = 0; i < length; i++) {
arr[i] = value
}
return arr
}
复制代码
这样导致的不方便的地方是,当我们要操作数组里的某一个成员时,由于无法得知成员类型,写代码是就不能给到便捷的代码提示。
fooArray[0].??? // 由于返回数组成员类型不确定,编辑器不能很好地给我们提供代码提示
复制代码
接下来我们应用泛型:
function createArray<T>(value: T, length: number): T[] {
let arr = []
for (let i = 0; i < length; i++) {
arr[i] = value
}
return arr
}
let fooArray = createArray<string>('foo', 3)
fooArray[0].√ // 这里,我们就能得到字符串相关的属性/方法提示了
复制代码
在函数名(即这里的 createArray
)后面使用一对尖括号包裹字母的形式(func<T>
)声明函数接收一个泛型 T
。其中,将参数和函数返回值类型也指定为 T
了。如此一来,就能实现传入的参数类型与函数返回值类型是一致的了。
接下来在上例中,调用函数时,指定当前泛型 T
所代表的类型是 string
,这样函数的返回值——数组的类型也确定了,也就是仅包含字符串成员的数组。
调用函数时,也可以不用显式通过
<xxx>
指明当前泛型所表示的具体类型。TypeScript 会自动根据传入的参数 的类型,推断出泛型T
所代表的类型。但笔者认为这样并不直观,因此还是建议在使用泛型时,显式指明泛型类型。
我们不难能看出,使用泛型的优势,就是我们可以动态调整 T
所代表的具体类型。比如,我们稍微修成下面这样:
let fooArray = createArray<number>(100, 3)
fooArray[0].√ // 此时,我们就得到数值相关的代码提示了
复制代码
泛型作用域
注意,前面定义函数时使用的字符
T
只在函数调用之后,才能知道它所表示的具体类型;并且泛型作用域也仅局限在声明此泛型的函数作用域内。
前面函数代码中的 let arr = []
由于我们没有加类型,会被自动推测为 any[]
,这里也修改下:
function createArray<T>(value: T, length: number): T[] {
let arr: T[] = []
for (let i = 0; i < length; i++) {
arr[i] = value
}
return arr
}
复制代码
我们我么不仅在函数参数、返回值中使用了泛型 T
,还在函数作用域内使用了它。
多泛型
请看下面的一个函数 swap
:
// 函数 `swap` 用于交换数组里的两个值
function swap(tuple) {
return [tuple[1], tuple[0]]
}
let swapped = swap(['foo', 3])
swapped // [3, "foo"]
复制代码
函数 swap
的作用是颠倒一个数组里两个成员的顺序。如果这两个成员的类型是不同的,那么我们该如何去声明这个数组呢?如下:
// 数组 `arr` 由两个成员组成,第一个
let arr: [string, number] = ['hi', 8]
复制代码
使用泛型改写的话,就不再是使用单个泛型了,而是需要使用两个泛型,也就是多泛型。我们需要这样指定泛型:
function swap<T, U>(tuple: [T, U]): [U, T] {
return [tuple[1], tuple[0]]
}
复制代码
泛型接口
不仅是函数,接口也可以应用泛型。
// 这里我们使用了一个接口 `ListApi`,使用了泛型 `T`
interface ListApi<T> {
data: T[]; // `data` 被声明为一个类型为 `T` 的数组
// 除此之外,此接口还包含一个字符串属性 `error_message` 和数值属性 `status_code`
error_message: string;
status_code: number;
}
// 在此我们将泛型 `T` 指定为 `{ name: string; age: number }`
// `listResult` 成为了一个确定的类型了
let listResult: ListApi<{ name: string; age: number }>;
复制代码
泛型类
在定义类的时候,也可以使用尖括号 <>
定义泛型。
// 这里声明了一个类 `Component`,指定使用了泛型 `T`
class Component<T> {
public props: T;
constructor(props: T) {
this.props = props;
}
}
// 定义了一个接口类型
interface ButtonProps {
color: string;
}
// 创建 `Component` 实例时,将 `T` 声明为 `ButtonProps` 类型
let button = new Component<ButtonProps>({
color: 'red'
});
复制代码
这里的 Component
表示一个组件类,需要传入一个 props
,类型是泛型 T
所代表的具体类型。
在使用时,传入了一个 ButtonProps
类型,那么在初始化的时候,就要根据 ButtonProps
中定义的结构,传入参数,是不是很灵活呢。
贡献指北
感谢你花费宝贵的时间阅读这篇文章。
如果你觉得这篇文章让你的生活美好了一点点,欢迎给我鲜(diǎn)花(zàn)或鼓(diǎn)励(zàn)?。如果能在文章下面留下你宝贵的评论或意见是再合适不过的了,因为研究证明,参与讨论比单纯阅读更能让人对知识印象深刻,假期愉快?~。
(完)