听风是风

学或不学,知识都在那里,只增不减。

导航

快速上手typescript(进阶篇)

壹 ❀ 引

我们在快速上手typescript(基础篇)一文中,已经介绍了typescript大部分基础知识,文章结尾也提到这些知识点已足以支撑日常typescript开发,而本文算是对于前文知识点的补充,比如类型枚举,泛型相关概念等。虽说是进阶,但是内容不算多也并不难理解,大家在阅读前不用有太大心理压力,那么本文开始。

贰 ❀ 元组(Tuple /ˈtʌpəl/)

在上文中我们已经介绍了数组类型,比如创建一个元素全是字符串类型的数组:

let arr: string[] = ['听风是风', '时间跳跃', 'echo', '行星飞行'];

但实际开发中,我们数组类型可能并不单一,在无法预估类型的情况下当然也可以用any表示,比如:

let arr: any[] = [8023, '行星飞行', true];

而假设一个数组已知元素数量以及对应的类型,我们可以使用元组来表示:

let arr: [number, string] = [8023, '行星飞行'];

需要注意的是类型[number, string]与数组元素位置是对应关系,比如上面的数组我们就不能将第一个元素替换成字符串:

let arr: [number, string] = [8023, '行星飞行'];
arr[0] = 1;
arr[0] = '听风是风';// error '听风是风'的类型不属于number

当然,元组并不是写了几个类型,数组就只能设置几个元素,当元组元素越界时那么元素类型只能属于前面设置的联合类型,比如我们继续往元组里push元素:

let arr: [number, string] = [8023, '行星飞行'];
arr.push('听风是风');
console.log(arr);// [8023, '行星飞行', '听风是风']
arr.push(true);// error true类型不属于字符串或者数字

但我们不能直接通过index往元组里塞元素,这会直接报错:

let arr: [number, string] = [8023, '行星飞行'];
arr[2] = '听风是风';// Index '2' is out-of-bounds in tuple of length 2.

测试发现,但我们可以通过将索引改为字符串的形式,达到直接越界添加,越界修改元素,比如:

let arr: [number, string] = [8023, '行星飞行'];
arr['2'] = '听风是风';
console.log(arr);// [8023, '行星飞行', '听风是风']

我们甚至能通过字符串索引添加超出联合类型范围的元素...这种做法违背了typescript本意,所以不推荐这么做,元组使用不多,作为了解有使用场景知道如何使用即可:

let arr: [number, string] = [8023, '行星飞行'];
arr['2'] = true;
console.log(arr);// [8023, '行星飞行', true]

叁 枚举enum/ 'enəm /

叁 ❀ 枚举手动赋值

日常开发中,我们常会遇到需要形容一组限定范围可描述的数据场景,比如赤橙黄绿青蓝紫七种颜色,星期一到周末,旧有情况我们一般用对象或者数组来描述它,不过typescript提供了枚举这一新的数据类型:

// 定义
enum Color {Red, Green, Blue};
// 访问
let a: number = Color.Green;
console.log(a);// 1

let b: string = Color[0];
console.log(b, typeof b);// Red string

可以看到枚举支持两种访问形式,你能通过元素下标访问到元素,能通过元素访问到元素下标。

数组拥有索引(下标)的概念,而枚举同样拥有元素编号的概念,而且也是从0开始,因此上述代码的a输出1,它等同于:

enum Color {Red = 0, Green = 1, Blue = 2};

当然我们可以手动定义起点编号数值,比如:

enum Color {Red = 1, Green = 2, Blue = 3};
let a: Color = Color.Green;
console.log(a);// 2

那假设我们部分元素设置了编号部分没设置,那么没设置的部分总是紧跟设置了数字然后进行递增(递增幅度为加1),比如

enum Color {Red = 1, Green, Blue = 4, Yellow};
console.log(Color.Green);// 2
console.log(Color.Yellow);// 5

enum Color {Red = -1, Green, Blue = 0.5, Yellow};
console.log(Color.Green);// 0
console.log(Color.Yellow);// 1.5

叁 ❀ 贰 枚举项类型

枚举元素类型有常数项计算所得项两种,上面例子的元素类型都是常数项,可以简单理解为它们的元素编号都是数字,看一个计算所得项的例子

enum Color {Red, Green= '听风是风'.length};

但需要注意的是,假设后续枚举项没指定编号,这会导致报错:

enum Color {Red, Green= '听风是风'.length, Yellow};// Yellow无法推算出编号,报错

解决办法是为后续枚举项手动添加元素编号:

enum Color { Red, Green = '听风是风'.length, Yellow = 1 };

除了上述提到的两种枚举类型,实际开发中其实我们还能将编号设置成字符串,比如:

enum Color {Red = 'RED', Green = 'GREEN'};

因为编号不是数字,这会导致后续没指定元素编号的枚举项无法进行自增,从而报错,比如:

enum Color {Red = 'RED', Green = 'GREEN', Yellow};// 报错,Yellow编号无法自增

我们可以手动为Yellow补一个元素编号就可以解决报错。

另外,当有枚举项是计算所得项类型时,无法与编号是字符串类型的枚举项共存,这也会报错:

enum Color {Red = 'RED', Green = 'GREEN'.length};// 不允许在具有字符串值成员的enum中使用计算值。

但我们可以利用类型断言来让typescript跳过类型检测,比如:

enum Color { Red = <any>'RED', Green = 'GREEN'.length };

叁 ❀ 叁 常数枚举

注意这里是常数枚举不是常数项,常数枚举使用const enum创建,这种枚举与普通枚举的区别在于,编译完成后常数枚举会被删除:

// 编译后只会剩下let yellowArr
const enum Color {Red, Green, Blue};
let yellowArr = [Color.Up, Color.Down, Color.Left];// [0, 1, 2]

肆 ❀ 类与接口

肆 ❀ 壹 typescript中的修饰符

typescript中的类与ES6中类的核心概念没有任何区别,只是在写法上增加了部分修饰,typescript支持private public protected三种访问修饰符,下面一一介绍。

ES6中我们形容一个属性是私有属性时,规范推荐使用#变量名,而在typescript中我们可以回归习惯使用private,私有属性表示只能在类内部使用,它无法在类外部访问,比如:

class Person {
    // 我们在这先声明变量是私有属性,它等同于
    // private this.name;
    private name;
    constructor(personName) {
        this.name = personName;
    }
    // 这是一个公有方法,外部也能访问
    public sayName() {
        console.log(this.name);
    }
}

let P = new Person('听风是风');
P.sayName();// 听风是风
P.name;// error 不能在类外使用一个私有属性

在上面的例子中,我们定义了一个私有属性name,它只能在组件内部使用,比如我们就在sayName中访问它,而在Person外我们创建了一个实例,通过实例并不能访问私有属性name,这导致了报错。

另外,站在继承的场景,子类也无法访问父类中的私有属性,比如:

class Person {
    name;
    constructor(personName) {
        this.name = personName;
    }

    static sayName() {
        console.log('我是静态方法');
    }
}

class Echo extends Person{
    constructor(name){
        super(name);
    }

    sayName2() {
        // 在子类中也无法方为父类的私有属性
        super.sayName();
    }
}

let p = new Echo('听风是风');
p.name//听风是风
p.sayName();// 无法访问父类的静态属性

无论是在子类内部,还是子类的实例对象,都无法访问父类的私有属性。

public你不用太在意,正常来说没加任何修饰符的属性方法,默认都是public,即它可以在组件内访问,也可以在组件外被实例访问,比如:

class Person {
    // 这里的name等同于直接声明了一个this.name
    public name;
    constructor(personName) {
        this.name = personName;
    }

    public sayName() {
        // 内部可以访问this.name
        console.log(this.name);
    }
}
let P = new Person('听风是风');
P.sayName();// 听风是风
P.name;// 外部也可以访问

上述代码的public name你可以直接当这个public不存在,毕竟你啥也不加默认就是公共属性方法。

另外,注意区别publicstatic的概念,前者表示在类里面外面都能用,但在外是通过实例访问,而不是类自身访问;而后者表示静态属性,这个属性只能被类自己访问,实例无法访问,看个例子区分这两者的差异:

class Person {
    public name;
    constructor(personName) {
        this.name = personName;
    }

    public sayName() {
        // 内部可以访问this.name
        console.log(this.name);
    }

    static sayName2() {
        console.log('我是静态方法');
    }
}
let P = new Person('听风是风');
// 实例可以访问public属性,本质上就是原型上的属性,但不能访问静态属性
P.sayName();// 听风是风
P.sayName2();// 报错
// Person可以访问静态属性,不能直接访问原型上的属性
Person.sayName();// 报错
Person.sayName2();// 我是静态方法

最后来聊聊projected,它的与private类型,同样只能在类内部使用,但又有一点不同,它也支持在子类中使用,比如:

class Person {
    protected name;
    constructor(personName) {
        this.name = personName;
    }

    protected sayName() {
        console.log('我是protected方法');
    }
}

class Echo extends Person{
    constructor(name){
        super(name);
    }

    sayName2() {
        super.sayName();
    }
}
let p1 = new Person('echo');
// 无法在外部访问protected属性
p1.sayName();
let p2 = new Echo('听风是风');
// 子类可以在内部访问父类protected的属性
p2.sayName2();// 我是protected方法

另外对于protected而言还有个特殊点,假设类中constructor加了protected,那么这个类只能用于继承,不能被new 调用构造实例化,比如:

class Person {
  public name;
  protected constructor(name) {
    this.name = name;
  }
}
class Echo extends Person {
  constructor(name) {
    super(name);
  }
}

let a = new Person('Jack');// 不能new Person
let b = new Echo('Jack');// 可以new Echo

最后,修饰符能直接结合构造器的参数一起使用,能极大程度简化代码,比如:

class Person {
    constructor(public name) {
    }
}

// 等同于如下写法
class Animal1 {
    public name;
    constructor(name) {
        this.name = name;
    }
}

肆 ❀ 贰 readonly关键字

readonly关键字表示某个属性方法在类外部只读,不可以通过实例二次修改:

class Person {
    readonly name;
    constructor(personName) {
        this.name = personName;
    }
}
let a = new Person('听风是风');
a.name// 听风是风
a.name = 1;// 不能修改常量或只读属性

但假设我们的属性是一个引用类型,比如一个基本对象,由于引用没变只修改部分属性这是允许的,这也const行为保持一致:

class Person {
    readonly name;
    constructor(personName) {
        this.name = personName;
    }
}
let a = new Person({name:'听风是风'});

a.name.name// 听风是风
a.name.name = '行星飞行';
console.log(a.name.name);// 行星飞行

关键字可以与修饰符组合使用,此时关键字要写在修饰符后面,比如:

class Person {
    constructor(public readonly personName) {
    }
}

肆 ❀ 叁 抽象类abstract/ˈæbstrækt]/

抽象类属于无法实例化只用于继承的类,但它与上文中提到的constructor前加protected不同,抽象类更像是共有属性方法的抽离,比如猫狗都有腿,都会奔跑,你可以把这些属性都抽离出来,但不需要提供具体的实现,它看起来更像一种约束。

比如狗继承了这个抽象类,那我们就知道狗一定有legs属性,以及run方法,但具体猫狗怎么跑,有什么差异,这可以在狗类中具体实现,我们可以使用abstract来定义抽象类以及类中的抽象方法,来看个简单的例子:

abstract class Animal {
    public legs;
    constructor(legs) {
        this.legs = legs;
    }
    // abstract同样写在修饰符之后
    public abstract run();
}


class Dog extends Animal {
    // 注意,不需要重复定义constructor
    public run() {
        console.log('狗狗跑的飞快');
    }
}

let dog = new Dog(4);

dog.legs;// 4
dog.run()// 狗狗跑的非常

通过这个例子我们总结出以下几点:

  • abstract只需要描述类以及类的方法,属性不要额外描述。
  • abstract可以与其它修饰符组合使用,它需要跟在修饰符之后。
  • 当子类继承抽象类时,不需要额外定义constructor
  • 子类一定要实现抽象类中的方法,否则会报错。

肆 ❀ 肆 类的类型使用

前面的内容大多数是在typescript中如何定义类,而类的类型定义一接口类式,比如:

class Animal {
  public name: string;
  constructor(name: string) {
    this.name = name;
  }
  static run(): string {
    return '跑的飞快';
  }
}

肆 ❀ 伍 类实现(implements/ˈɪmplɪments/)接口

在上文中我们知道,抽象类可用于表示那些高度相似子类之间的共有属性方法,比如猫和狗都是动物,我们可以抽象一个Animal类用于描述子类的形状,然后在狗类中具体去实现这些抽象属性方法。

但在实际开发中,毫不相干的两个类可能也有一些共有特征,比如汽车有报警功能,而防盗门也有报警功能,我们就可以把报警提取成一个接口,然后对于防盗门以及汽车分别使用implements来实现这个接口,比如:

interface Alarm {
    alert(): void;
}

class Door {
  // 我们假设门类有一些属性
}

// 防盗门继承了门类,同时自己实现了报警接口
class SecurityDoor extends Door implements Alarm {
    alert() {
        console.log('我是防盗门的报警功能');
    }
}

// 汽车自己实现了报警接口
class Car implements Alarm {
    alert() {
        console.log('我是汽车的报警功能');
    }
}

而实际开发中,一个类可能会实现多个接口,这种情况下只需用逗号隔开这些接口即可,比如:

interface Alarm {
  alert(): void;
}

interface Drive {
  drive(): void;
}

// 汽车自己实现了报警接口
class Car implements Alarm, Drive {
  alert() {
    console.log('Car alert');
  }
  drive() {
    console.log('...')
  }
}

肆 ❀ 陆 接口继承接口

既然类能继承类,我想接口继承接口你应该非常好理解,比如:

interface Alarm {
  alert(): void;
}

interface Drive extends Alarm {
  drive(): void;
}

此时接口Drive不仅有drive方法,同时还有alert方法,当然具体怎么实现需要类来决定。

伍 ❀ 类型别名

类型别名作用就是给一个类型取一个新名称,比如联合类型种类比较多时,我们就能通过类型别名代指某个联合类型:

type MyTpe = number | string | boolean;
let arr: MyTpe[] = [1, 2, 3, 'echo', true];

陆 ❀ 字符串字面量类型

在基础篇限制接口属性范围提及过一次,本质上其实就是字符串字面量类型,此时的类型就是明确的几个值,而使用了此类型的变量的值,必须属于我们提供的字面量类型其一。

type MyType = 'echo' | '听风是风' | 1;
let a: MyType = 'echo';
a = 2;// 2不在MyType类型范围中,报错了

柒 ❀ 泛型

柒 ❀ 壹 泛型概念

我们在数组类型中提到,创建数组类型有两种方式,一种是string[],另一种就是利用数组泛型Array<string>。而所谓泛型,其实就是指在定义接口,函数或类时,我们不预先指定具体类型,而是在使用时动态指定类型。

什么意思呢?我们假设有一个用于创建数组的函数,如下:

function createArray(length: number, val: any): any[] {
    let arr: any[] = [];
    for (let i = 0; i < length; i++) {
        arr[i] = val;
    };
    return arr;
}
createArray(6, '听风是风');

很明显站在函数定义的角度,我们并不知道使用者会将val传递什么类型的参数,这就导致我们只能使用any来代指任意类型。但使用者一定知道我将传递什么类型进去,那能不能将参数类型变量化呢?你传递什么类型进来,我函数就就帮你生成什么类型的元素,泛型就可以解决这个问题,如下:

// 在函数名后面定义泛型,代指某一类型,具体由调用处来决定它是什么类型
function createArray<T>(length: number, val: T): Array<T> {
    let arr: T[] = [];
    for (let i = 0; i < length; i++) {
        arr[i] = val;
    };
    return arr;
}
// 调用处我们指定泛型是字符串类型
createArray<string>(6, '听风是风');

上述代码我们在函数名后方使用了泛型,而它被应用到了函数参数以及返回结果中,它就像一个类型变量,是什么类型由调用处来决定。事实上我们还可以省略掉调用处的泛型类型指定,因为typescript能根据我们函数定义参数val,以及函数调用传递的参数类型,动态去推断出泛型类型:

function createArray<T>(length: number, val: T): Array<T> {
    let arr: T[] = [];
    for (let i = 0; i < length; i++) {
        arr[i] = val;
    };
    return arr;
}
// 我们省略掉调用处的泛型类型
createArray(6, '听风是风');

上述代码即便我们省略了调用处的泛型,但因为<T>被应用于参数val,所以它会根据传参推断出具体类型。

柒 ❀ 贰 多个泛型参数

既然泛型可以理解成类型的变量,那么泛型肯定也能支持多个参数,比如我们改写上面的例子用于创建一个元素是元组的数组:

function createArray<T, S>(length: number, val1: T, val2: S): Array<[T, S]> {
    let arr: [T, S][] = [];
    for (let i = 0; i < length; i++) {
        let tuple: [T, S] = [val1, val2];
        arr[i] = tuple;
    };
    return arr;
}
createArray(6, '听风是风', 8023);

由于泛型本质就是变量,所以并不是固定的要求一定是字母T,S,你可以用任意大写字母来表示泛型,这个纯看个人习惯。

柒 ❀ 叁 泛型约束

在讲泛型之前,我们函数参数类型经常是已知的,所以在函数内部我们能直接使用这个类型上的属性方法,比如:

function fn(a: string): number {
    return a.length;
}

但因为泛型,我们现在可以理解为类型是一个动态的变量,因此再直接使用属性方法,你会发现报错,比如:

function fn<T>(a: T): number {
    return a.length;// Property 'length' does not exist on type 'T'.
}

为什么呢?因为<T>毕竟是一个未知的类型变量,我怎么知道它具体是什么类型,这个类型上有没有length属性,有同学可能马上就想到了,我用类型断言来转一次不就OK了:

function fn<T>(a: T): number {
    return (a as string).length;
}

结果又报错了,它会告诉你把<T>断言成string可能会导致错误,毕竟咱也不知道fn调用时到底会传什么进来,假设传个数字还是得报错,还不如提前告诉你用断言指定不行,直接报错不让你过了。

那怎么解决呢?第一种直接ifreturn圈起来,只有a是字符串类型时才会返回a.length,这个肯定没问题。第二种就是泛型约束了,比如:

interface Length {
    length: number
}

function fn<T extends Length>(a: T): number {
    return a.length;
}

我们定义了一个接口Length,这个接口有一个length属性,值是number类型。extends什么意思?继承,所以这段代码就表示我们调用函数传递的参数,一定也得有length属性。

假设我们现在调用这个函数,传递一个数字,它就会报错:

interface Length {
    length: number
}

function fn<T extends Length>(a: T): number {
    return a.length;
}
fn(1)// 1不能传递给类型是Length的参数a

不知道你发现没有,虽然是一个未知的类型变量,但我们还是能预设它的形状,这样使用者只要传递不合规的参数立马就会得到错误提醒,而ts要做的就是静态检查提醒你这里不合规,这样就避免了数字访问length属性的代码上线到生产环境(你还得跑起来才知道错没错)。

除此之外,参数之间也能相互约束,比如我们传递了两个对象A B,确保B上的属性A都有,那么就可以这样:

function fn<A extends B, B>(a: A, b: B): void {
    for (let i in b) {
        a[i] = (b as A)[i];
    }
}

在这个例子中,A extends B用于约束泛型,保证B上不会出现A上没有的属性,这是传参层面的限制。而在内部赋值时,程序层面不确定b的属性a一定有,因此又通过b as A断言修改b的类型。

柒 ❀ 肆 泛型接口

在介绍函数类型时,我们提到函数表达式情况下需要对于变量以及匿名函数体两个部分都做类型声明,比如:

interface createArrayFn {
    <T>(length: number, val: T): Array<T>
}
const createArray: createArrayFn = function <T>(length: number, val: T): Array<T> {
    let arr: T[] = [];
    for (let i = 0; i < length; i++) {
        arr[i] = val;
    };
    return arr;
}

进一步将接口内的泛型提升到接口上:

interface createArrayFn<T> {
    (length: number, val: T): Array<T>
}
const createArray: createArrayFn<any> = function <T>(length: number, val: T): Array<T> {
    let arr: T[] = [];
    for (let i = 0; i < length; i++) {
        arr[i] = val;
    };
    return arr;
}

注意,在将接口内部的泛型提出去之后,你可以简单理解为接口接受了一个参数,这就必须在函数声明处的const createArray: createArrayFncreateArrayFn补一个类型参数,且这里的类型参数得是一个明确的类型,很显然我们期望传递任意类型的参数进去,然后创建对应类型的数组,所以这里我们声明为<any>即可。

另外需要强调的是,不要为了用泛型而用泛型,比如下面这个例子:

interface MySum<T> {
    (x: T, y: T): T
}

let sum: MySum<number> = function <T>(x: T, y: T): number {
  	// 报错,不能对类型T和T进行+操作
    return x + y;
};

为什么报错?因为泛型T是一个类型变量,它可能是任意类型,那么非数字字符串之外的其它类型怎么能执行 + 操作呢?而我们设计这个方法的目的为了做数字求和,所以这里根本没必要用泛型,直接确定类型为number即可,比如:

interface MySum {
    (x: number, y: number): number
}

let sum: MySum = function (x: number, y: number): number {
    return x + y;
};
sum('听风是风', 1)// 听风是风不属于数字类型

你看,调用方法处没传递数字类型,同样会报错。

柒 ❀ 肆 泛型类

在介绍类与接口时我们提到,类的类型定义与接口高度相似,类的泛型定义同样如此:

class Person<T> {
    name: T | undefined;
    sayName(): T | undefined {
        return this.name;
    }
}

let echo = new Person<string>();
echo.name = '听风是风';
echo.sayName();//听风是风

柒 ❀ 伍 泛型默认值

与参数默认值一样,当我们担心某个泛型无法正确推断出类型时,我们可以为泛型提供一个默认值,比如:

function fn<T = number>(a: T): T {
  return a;
}

上述代码中的T = number只有当typescript无法正确推断出类型时才会生效,这个表现与函数参数默认值一致。

捌 ❀ 总

那么到这里,我们通过两篇文章快速过完了typescript,知识点虽然看着有些多,但大体上都不难理解。整篇文章着重点还是围绕类与泛型,若你对于类理解吃力,还是建议先补充ES6中类的概念,这会对于你理解typescript的类有很大的帮助;

而对于泛型,我们也强调了不要为了使用而使用,除非真的是类型无法预估,但是我们又希望根据输入的类型来限制内部逻辑的类型或者输出的类型,那么此时泛型将会有非常棒的效果,否则还是建议直接标明类型。

那么到这里,关于typescript的介绍就结束了。

posted on 2022-03-12 01:28  听风是风  阅读(655)  评论(0编辑  收藏  举报