阅读 912

TypeScript 从听说到入门(下篇)

尽管走下去,不必逗留着采鲜花来保存,因为在这一路上,花自然会继续开放。

——泰戈尔《飞鸟集》

上篇文章里,我对 TypeScript 中的类型声明做了介绍,这块也是 TypeScript 的基础知识。讲解的内容包括:

  1. 指定类型的语法:: type
  2. 指定为基本类型:booleannumberstringnullundefiendsymbol
  3. 指定为对象类型
    • 普通对象:interface Type {...}
    • 数组:type[](type1 | type2 | ...)[]
  4. 扩展类型
    • 字面量类型:字符串字面量、数值字面量和布尔值字面量
    • 枚举: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 用来修饰公用的属性或方法。

类的修饰符包括:privateprotected、和 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('蜜蜂在采蜜')
    }
}
复制代码

BirdBee 中具有一个同名的方法,我们可以将相似类的共同的属性和方法抽象出来,放在接口里。

// 此处定义了一个接口 `Wings`
// 接口中定义了一个方法 `fly`,无返回值(void)
interface Wings {
    fly(): void;
}
复制代码

然后再让 BirdBee 来实现这个接口:

// 使用关键字 `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` 中定义的方法,必须实现
}
复制代码

大家会会发现,抽象类与接口非常类似,那么有何区别呢?首先说共同点:都定义了公共的方法,然后让具体的类去实现。

而区别在于:

  1. 接口就像插件一样,是用来增强类的,而抽象类则是具体类的抽象概念。比如这里的鸟(Bird)就是动物(Animal)的一种。
  2. 类实现接口是多对多的关系,一个类可以实现多个接口,一个接口也可以被多类实现;而类继承类则是一对多的关系,一个类的父类只能有一个,一个类的子类则可以有多个。

注意:在抽象类中,除了可以定义抽象方法,也可以直接书写实现的方法,这样的话,继承此抽象类的子类实例会自动具有这些已实现的方法了。

接口继承类

接口除了可以继承(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)😀。如果能在文章下面留下你宝贵的评论或意见是再合适不过的了,因为研究证明,参与讨论比单纯阅读更能让人对知识印象深刻,假期愉快😉~。

(完)

文章分类
前端
文章标签