TypeScript高级用法详解

引言

做为一门强大的静态类型检查工具,现在在许多中大型应用程序以及流行的JS库中均能看到TypeScript的身影。JS做为一门弱类型语言,在咱们写代码的过程当中稍不留神便会修改掉变量的类型,从而致使一些出乎意料的运行时错误。然而TypeScript在编译过程当中便能帮咱们解决这个难题,不只在JS中引入了强类型检查,而且编译后的JS代码可以运行在任何浏览器环境,Node环境和任何支持ECMAScript 3(或更高版本)的JS引擎中。最近公司恰好准备使用TypeScript来对现有系统进行重构,之前使用TypeScript的机会也很少,特别是一些有用的高级用法,因此借着此次机会,从新巩固夯实一下这方面的知识点,若是有错误的地方,还请指出。javascript

一、类继承

在ES5中,咱们通常经过函数或者基于原型的继承来封装一些组件公共的部分方便复用,然而在TypeScript中,咱们能够像相似Java语言中以面向对象的方式使用类继承来建立可复用的组件。咱们能够经过class关键字来建立类,并基于它使用new操做符来实例化一个对象。为了将多个类的公共部分进行抽象,咱们能够建立一个父类并让子类经过extends关键字来继承父类,从而减小一些冗余代码的编写增长代码的可复用性和可维护性。示例以下:前端

class Parent {
    readonly x: number;
    constructor() {
        this.x = 1;
    }
    
    print() {
        console.log(this.x);
    }
}

class Child extends Parent {
    readonly y: number;
    constructor() {
        // 注意此处必须优先调用super()方法
        super();
        this.y = 2;
    }
    
    print() {
        // 经过super调用父类原型上的方法,可是方法中的this指向的是子类的实例
        super.print();
        console.log(this.y);
    }
}

const child = new Child();
console.log(child.print()) // -> 1 2
复制代码

在上述示例中,Child子类中对父类的print方法进行重写,同时在内部使用super.print()来调用父类的公共逻辑,从而实现逻辑复用。class关键字做为构造函数的语法糖,在通过TypeScript编译后,最终会被转换为兼容性好的浏览器可识别的ES5代码。class在面向对象的编程范式中很是常见,所以为了弄清楚其背后的实现机制,咱们不妨多花点时间来看下通过编译转换以后的代码是什么样子的(固然这部分已经比较熟悉的同窗能够直接跳过)。java

var __extends = (this && this.__extends) || (function () {
    var extendStatics = function (d, b) {
        extendStatics = Object.setPrototypeOf ||
            ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
            function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; };
        return extendStatics(d, b);
    }
    return function (d, b) {
        extendStatics(d, b);
        function __() { this.constructor = d; }
        d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
    };
})();
var Parent = /** @class */ (function () {
    function Parent() {
        this.x = 1;
    }
    Parent.prototype.print = function () {
        console.log(this.x);
    };
    return Parent;
}());
var Child = /** @class */ (function (_super) {
    __extends(Child, _super);
    function Child() {
        var _this = 
        // 注意此处必须优先调用super()方法
        _super.call(this) || this;
        _this.y = 2;
        return _this;
    }
    Child.prototype.print = function () {
        // 经过super调用父类原型上的方法,可是方法中的this指向的是子类的实例
        _super.prototype.print.call(this);
        console.log(this.y);
    };
    return Child;
}(Parent));
var child = new Child();
console.log(child.print()); // -> 1 2
复制代码

以上就是转换后的完整代码,为了方便对比,这里将原来的注释信息保留,仔细研究这段代码咱们会发现如下几个要点:git

  1. 子类Child的构造函数中super()方法被转换成了var _this = _super.call(this) || this,这里的_super指的就是父类Parent,所以这句代码的含义就是调用父类构造函数并将this绑定到子类的实例上,这样的话子类实例即可拥有父类的x属性。所以为了实现属性继承,咱们必须在子类构造函数中调用super()方法,若是不调用会编译不经过。github

  2. 子类Childprint方法中super.print()方法被转换成了_super.prototype.print.call(this),这句代码的含义就是调用父类原型上的print方法并将方法中的this指向子类实例,因为在上一步操做中咱们已经继承到父类的x属性,所以这里咱们将直接打印出子类实例的x属性的值。编程

  3. extends关键字最终被转换为__extends(Child, _super)方法,其中_super指的是父类Parent,为了方便查看,这里将_extends方法单独提出来进行研究。浏览器

var __extends = (this && this.__extends) || (function () {
    var extendStatics = function (d, b) {
        extendStatics = Object.setPrototypeOf ||
            ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
            function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; };
        return extendStatics(d, b);
    }
    return function (d, b) {
        // 第一部分
        extendStatics(d, b);
        
        // 第二部分
        function __() { this.constructor = d; }
        d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
    };
})();
复制代码

在以上代码中,主要能够分为两个部分来进行理解,第一部分为extendStatics(d, b)方法,第二部分为该方法后面的两行代码。ide

第一部分函数

extendStatics方法内部虽然代码量相对较多,可是不难发现其实仍是主要为了兼容ES5版本的执行环境。在ES6中新增了Object.setPrototypeOf方法用于手动设置对象的原型,可是在ES5的环境中咱们通常经过一个非标准的__proto__属性来进行设置,Object.setPrototypeOf方法的原理其实也是经过该属性来设置对象的原型,其实现方式以下:工具

Object.setPrototypeOf = function(obj, proto) {
    obj.__proto__ = proto;
    return obj;
}
复制代码

extendStatics(d, b)方法中,d指子类Childb指父类Parent,所以该方法的做用能够解释为:

// 将子类Child的__proto__属性指向父类Parent
Child.__proto__ = Parent;
复制代码

能够将这行代码理解为构造函数的继承,或者叫静态属性和静态方法的继承,即属性和方法不是挂载到构造函数的prototype原型上的,而是直接挂载到构造函数自己,由于在JS中函数自己也能够做为一个对象,并能够为其赋予任何其余的属性,示例以下:

function Foo() {
  this.x = 1;
  this.y = 2;
}

Foo.bar = function() {
  console.log(3);
}

Foo.baz = 4;
console.log(Foo.bar()) // -> 3
console.log(Foo.baz) // -> 4
复制代码

所以当咱们在子类Child中以Child.someProperty访问属性时,若是子类中不存在就会经过Child.__proto__寻找父类的同名属性,经过这种方式来实现静态属性和静态方法的路径查找。

第二部分

在第二部分中仅包含如下两行代码:

function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
复制代码

其中d指子类Childb指父类Parent,这里对于JS中实现继承的几种方式比较熟悉的同窗能够一眼看出,这里使用了寄生组合式继承的方式,经过借用一个中间函数__()来避免当修改子类的prototype上的方法时对父类的prototype所形成的影响。咱们知道,在JS中经过构造函数实例化一个对象以后,该对象会拥有一个__proto__属性并指向其构造函数的prototype属性,示例以下:

function Foo() {
  this.x = 1;
  this.y = 2;
}

const foo = new Foo();
foo.__proto__ === Foo.prototype; // -> true
复制代码

对于本例中,若是经过子类Child来实例化一个对象以后,会产生以下关联:

const child = new Child();
child.__proto__ === (Child.prototype = new __());
child.__proto__.__proto__ === __.prototype === Parent.prototype; 

// 上述代码等价于下面这种方式
Child.prototype.__proto__ === Parent.prototype;
复制代码

所以当咱们在子类Child的实例child对象中经过child.someMethod()调用某个方法时,若是在实例中不存在该方法,则会沿着__proto__继续往上查找,最终会通过父类Parentprototype原型,即经过这种方式来实现方法的继承。

基于对以上两个部分的分析,咱们能够总结出如下两点:

// 表示构造函数的继承,或者叫作静态属性和静态方法的继承,老是指向父类
1. Child.__proto__ === Parent;

// 表示方法的继承,老是指向父类的prototype属性
2. Child.prototype.__proto__ === Parent.prototype;
复制代码

二、访问修饰符

TypeScript为咱们提供了访问修饰符(Access Modifiers)来限制在class外部对内部属性的访问,访问修饰符主要包含如下三种:

  • public:公共修饰符,其修饰的属性和方法都是公有的,能够在任何地方被访问到,默认状况下全部属性和方法都是public的。
  • private:私有修饰符,其修饰的属性和方法在class外部不可见。
  • protected:受保护修饰符,和private比较类似,可是其修饰的属性和方法在子类内部是被容许访问的

咱们经过一些示例来对几种修饰符进行对比:

class Human {
    public name: string;
    public age: number;
    public constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }
}

const man = new Human('tom', 20);
console.log(man.name, man.age); // -> tom 20
man.age = 21;
console.log(man.age); // -> 21
复制代码

在上述示例中,因为咱们将访问修饰符设置为public,所以咱们经过实例man来访问nameage属性是被容许的,同时对age属性从新赋值也是容许的。可是在某些状况下,咱们但愿某些属性是对外不可见的,同时不容许被修改,那么咱们就可使用private修饰符:

class Human {
    public name: string;
    private age: number; // 此处修改成使用private修饰符
    public constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }
}

const man = new Human('tom', 20);
console.log(man.name); // -> tom
console.log(man.age);
// -> Property 'age' is private and only accessible within class 'Human'.
复制代码

咱们将age属性的修饰符修改成private后,在外部经过man.age对其进行访问,TypeScript在编译阶段就会发现其是一个私有属性并最终将会报错。

注意:在TypeScript编译以后的代码中并无限制对私有属性的存取操做。

编译后的代码以下:

var Human = /** @class */ (function () {
    function Human(name, age) {
        this.name = name;
        this.age = age;
    }
    return Human;
}());
var man = new Human('tom', 20);
console.log(man.name); // -> tom
console.log(man.age); // -> 20
复制代码

使用private修饰符修饰的属性或者方法在子类中也是不容许访问的,示例以下:

class Human {
    public name: string;
    private age: number;
    public constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }
}

class Woman extends Human {
    private gender: number = 0;
    public constructor(name: string, age: number) {
        super(name, age);
        console.log(this.age);
    }
}

const woman = new Woman('Alice', 18);
// -> Property 'age' is private and only accessible within class 'Human'.
复制代码

在上述示例中因为在父类Humanage属性被设置为private,所以在子类Woman中没法访问到age属性,为了让在子类中容许访问age属性,咱们可使用protected修饰符来对其进行修饰:

class Human {
    public name: string;
    protected age: number; // 此处修改成使用protected修饰符
    public constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }
}

class Woman extends Human {
    private gender: number = 0;
    public constructor(name: string, age: number) {
        super(name, age);
        console.log(this.age);
    }
}

const woman = new Woman('Alice', 18); // -> 18
复制代码

当咱们将private修饰符用于构造函数时,则表示该类不容许被继承或实例化,示例以下:

class Human {
    public name: string;
    public age: number;
    
    // 此处修改成使用private修饰符
    private constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }
}

class Woman extends Human {
    private gender: number = 0;
    public constructor(name: string, age: number) {
        super(name, age);
    }
}

const man = new Human('Alice', 18);
// -> Cannot extend a class 'Human'. Class constructor is marked as private.
// -> Constructor of class 'Human' is private and only accessible within the class declaration.
复制代码

当咱们将protected修饰符用于构造函数时,则表示该类只容许被继承,示例以下:

class Human {
    public name: string;
    public age: number;
    
    // 此处修改成使用protected修饰符
    protected constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }
}

class Woman extends Human {
    private gender: number = 0;
    public constructor(name: string, age: number) {
        super(name, age);
    }
}

const man = new Human('Alice', 18);
// -> Constructor of class 'Human' is protected and only accessible within the class declaration.
复制代码

另外咱们还能够直接将修饰符放到构造函数的参数中,示例以下:

class Human {
    // public name: string;
    // private age: number;
    
    public constructor(public name: string, private age: number) {
        this.name = name;
        this.age = age;
    }
}

const man = new Human('tom', 20);
console.log(man.name); // -> tom
console.log(man.age);
// -> Property 'age' is private and only accessible within class 'Human'.
复制代码

三、接口与构造器签名

当咱们的项目中拥有不少不一样的类时而且这些类之间可能存在某方面的共同点,为了描述这种共同点,咱们能够将其提取到一个接口(interface)中用于集中维护,并使用implements关键字来实现这个接口,示例以下:

interface IHuman {
    name: string;
    age: number;
    walk(): void;
}

class Human implements IHuman {
    
    public constructor(public name: string, public age: number) {
        this.name = name;
        this.age = age;
    }

    walk(): void {
        console.log('I am walking...');
    }
}
复制代码

上述代码在编译阶段能顺利经过,可是咱们注意到在Human类中包含constructor构造函数,若是咱们想在接口中为该构造函数定义一个签名并让Human类来实现这个接口,看会发生什么:

interface HumanConstructor {
  new (name: string, age: number);    
}

class Human implements HumanConstructor {
    
    public constructor(public name: string, public age: number) {
        this.name = name;
        this.age = age;
    }

    walk(): void {
        console.log('I am walking...');
    }
}
// -> Class 'Human' incorrectly implements interface 'HumanConstructor'.
// -> Type 'Human' provides no match for the signature 'new (name: string, age: number): any'.
复制代码

然而TypeScript会编译出错,告诉咱们错误地实现了HumanConstructor接口,这是由于当一个类实现一个接口时,只会对实例部分进行编译检查,类的静态部分是不会被编译器检查的。所以这里咱们尝试换种方式,直接操做类的静态部分,示例以下:

interface HumanConstructor {
  new (name: string, age: number);    
}

interface IHuman {
    name: string;
    age: number;
    walk(): void;
}

class Human implements IHuman {
    
    public constructor(public name: string, public age: number) {
        this.name = name;
        this.age = age;
    }

    walk(): void {
        console.log('I am walking...');
    }
}

// 定义一个工厂方法
function createHuman(constructor: HumanConstructor, name: string, age: number): IHuman {
    return new constructor(name, age);
}

const man = createHuman(Human, 'tom', 18);
console.log(man.name, man.age); // -> tom 18
复制代码

在上述示例中经过额外建立一个工厂方法createHuman并将构造函数做为第一个参数传入,此时当咱们调用createHuman(Human, 'tom', 18)时编译器便会检查第一个参数是否符合HumanConstructor接口的构造器签名。

四、声明合并

在声明合并中最多见的合并类型就是接口了,所以这里先从接口开始介绍几种比较常见的合并方式。

4.1 接口合并

示例代码以下:

interface A {
    name: string;
}

interface A {
    age: number;
}

// 等价于
interface A {
    name: string;
    age: number;
}

const a: A = {name: 'tom', age: 18};
复制代码

接口合并的方式比较容易理解,即声明多个同名的接口,每一个接口中包含不一样的属性声明,最终这些来自多个接口的属性声明会被合并到同一个接口中。

注意:全部同名接口中的非函数成员必须惟一,若是不惟一则必须保证类型相同,不然编译器会报错。对于函数成员,后声明的同名接口会覆盖掉以前声明的同名接口,即后声明的同名接口中的函数至关于一次重载,具备更高的优先级。

4.2 函数合并

函数的合并能够简单理解为函数的重载,即经过同时定义多个不一样类型参数或不一样类型返回值的同名函数来实现,示例代码以下:

// 函数定义
function foo(x: number): number; function foo(x: string): string; // 函数具体实现 function foo(x: number | string): number | string {
    if (typeof x === 'number') {
        return (x).toFixed(2);
    }
    
    return x.substring(0, x.length - 1);
}
复制代码

在上述示例中,咱们对foo函数进行屡次定义,每次定义的函数参数类型不一样,返回值类型不一样,最后一次为函数的具体实现,在实现中只有在兼容到前面的全部定义时,编译器才不会报错。

注意:TypeScript编译器会优先从最开始的函数定义进行匹配,所以若是多个函数定义存在包含关系,则须要将最精确的函数定义放到最前面,不然将始终不会被匹配到。

4.3 类型别名联合

类型别名联合与接口合并有所区别,类型别名不会新建一个类型,只是建立一个新的别名来对多个类型进行引用,同时不能像接口同样被实现(implements)继承(extends),示例以下:

type HumanProperty = {
    name: string;
    age: number;
    gender: number;
};

type HumanBehavior = {
    eat(): void;
    walk(): void;
}

type Human = HumanProperty & HumanBehavior;

let woman: Human = {
    name: 'tom',
    age: 18,
    gender: 0,
    eat() {
        console.log('I can eat.');
    },
    walk() {
        console.log('I can walk.');
    }
}

class HumanComponent extends Human {
    constructor(public name: string, public age: number, public gender: number) {
        this.name = name;
        this.age = age;
        this.gender = gender;
    }
    
    eat() {
        console.log('I can eat.');
    }
    
    walk() {
        console.log('I can walk.');
    }
}
// -> 'Human' only refers to a type, but is being used as a value here.
复制代码

五、keyof 索引查询

在TypeScript中的keyof有点相似于JS中的Object.keys()方法,可是区别在于前者遍历的是类型中的字符串索引,后者遍历的是对象中的键名,示例以下:

interface Rectangle {
    x: number;
    y: number;
    width: number;
    height: number;
}

type keys = keyof Rectangle;
// 等价于
type keys = "x" | "y" | "width" | "height";

// 这里使用了泛型,强制要求第二个参数的参数名必须包含在第一个参数的全部字符串索引中
function getRectProperty<T extends object, K extends keyof T>(rect: T, property: K): T[K] {
    return rect[property];
} 

let rect: Rectangle = {
    x: 50,
    y: 50,
    width: 100,
    height: 200
};

console.log(getRectProperty(rect, 'width')); // -> 100
console.log(getRectProperty(rect, 'notExist'));
// -> Argument of type '"notExist"' is not assignable to parameter of type '"width" | "x" | "y" | "height"'.
复制代码

在上述示例中咱们经过使用keyof来限制函数的参数名property必须被包含在类型Rectangle的全部字符串索引中,若是没有被包含则编译器会报错,能够用来在编译时检测对象的属性名是否书写有误。

六、Partial 可选属性

在某些状况下,咱们但愿类型中的全部属性都不是必需的,只有在某些条件下才存在,咱们就可使用Partial来将已声明的类型中的全部属性标识为可选的,示例以下:

// 该类型已内置在TypeScript中
type Partial<T> = {
    [P in keyof T]?: T[P]
};

interface Rectangle {
    x: number;
    y: number;
    width: number;
    height: number;
}

type PartialRectangle = Partial<Rectangle>;
// 等价于
type PartialRectangle = {
    x?: number;
    y?: number;
    width?: number;
    height?: number;
}

let rect: PartialRectangle = {
    width: 100,
    height: 200
};
复制代码

在上述示例中因为咱们使用Partial将全部属性标识为可选的,所以最终rect对象中虽然只包含widthheight属性,可是编译器依旧没有报错,当咱们不能明确地肯定对象中包含哪些属性时,咱们就能够经过Partial来声明。

七、Pick 部分选择

在某些应用场景下,咱们可能须要从一个已声明的类型中抽取出一个子类型,在子类型中包含父类型中的部分或所有属性,这时咱们可使用Pick来实现,示例代码以下:

// 该类型已内置在TypeScript中
type Pick<T, K extends keyof T> = {
    [P in K]: T[P]
};

interface User {
    id: number;
    name: string;
    age: number;
    gender: number;
    email: string;
}

type PickUser = Pick<User, "id" | "name" | "gender">;
// 等价于
type PickUser = {
    id: number;
    name: string;
    gender: number;
};

let user: PickUser = {
    id: 1,
    name: 'tom',
    gender: 1
};
复制代码

在上述示例中,因为咱们只关心user对象中的idnamegender是否存在,其余属性不作明确规定,所以咱们就可使用PickUser接口中拣选出咱们关心的属性而忽略其余属性的编译检查。

八、never 永不存在

never表示的是那些永不存在的值的类型,好比在函数中抛出异常或者无限循环,never类型能够是任何类型的子类型,也能够赋值给任何类型,可是相反却没有一个类型能够做为never类型的子类型,示例以下:

// 函数抛出异常
function throwError(message: string): never {
    throw new Error(message);
}

// 函数自动推断出返回值为never类型
function reportError(message: string) {
    return throwError(message);
}

// 无限循环
function loop(): never {
    while(true) {
        console.log(1);
    }
}

// never类型能够是任何类型的子类型
let n: never;
let a: string = n;
let b: number = n;
let c: boolean = n;
let d: null = n;
let e: undefined = n;
let f: any = n;

// 任何类型都不能赋值给never类型
let a: string = '123';
let b: number = 0;
let c: boolean = true;
let d: null = null;
let e: undefined = undefined;
let f: any = [];

let n: never = a;
// -> Type 'string' is not assignable to type 'never'.

let n: never = b;
// -> Type 'number' is not assignable to type 'never'.

let n: never = c;
// -> Type 'true' is not assignable to type 'never'.

let n: never = d;
// -> Type 'null' is not assignable to type 'never'.

let n: never = e;
// -> Type 'undefined' is not assignable to type 'never'.

let n: never = f;
// -> Type 'any' is not assignable to type 'never'.
复制代码

九、Exclude 属性排除

Pick相反,Pick用于拣选出咱们须要关心的属性,而Exclude用于排除掉咱们不须要关心的属性,示例以下:

// 该类型已内置在TypeScript中
// 这里使用了条件类型(Conditional Type),和JS中的三目运算符效果一致
type Exclude<T, U> = T extends U ? never : T;

interface User {
    id: number;
    name: string;
    age: number;
    gender: number;
    email: string;
}

type keys = keyof User; // -> "id" | "name" | "age" | "gender" | "email"

type ExcludeUser = Exclude<keys, "age" | "email">;
// 等价于
type ExcludeUser = "id" | "name" | "gender";
复制代码

在上述示例中咱们经过在ExcludeUser中传入咱们不须要关心的ageemail属性,Exclude会帮助咱们将不须要的属性进行剔除,留下的属性idnamegender即为咱们须要关心的属性。通常来讲,Exclude不多单独使用,能够与其余类型配合实现更复杂更有用的功能。

十、Omit 属性忽略

在上一个用法中,咱们使用Exclude来排除掉其余不须要的属性,可是在上述示例中的写法耦合度较高,当有其余类型也须要这样处理时,就必须再实现一遍相同的逻辑,不妨咱们再进一步封装,隐藏这些底层的处理细节,只对外暴露简单的公共接口,示例以下:

// 使用Pick和Exclude组合实现
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

interface User {
    id: number;
    name: string;
    age: number;
    gender: number;
    email: string;
}

// 表示忽略掉User接口中的age和email属性
type OmitUser = Omit<User, "age" | "email">;
// 等价于
type OmitUser = {
  id: number;
  name: string;
  gender: number;
};

let user: OmitUser = {
    id: 1,
    name: 'tom',
    gender: 1
};
复制代码

在上述示例中,咱们须要忽略掉User接口中的ageemail属性,则只须要将接口名和属性传入Omit便可,对于其余类型也是如此,大大提升了类型的可扩展能力,方便复用。

总结

在本文中总结了几种TypeScript的使用技巧,若是在咱们的TypeScript项目中发现有不少类型声明的地方具备共性,那么不妨可使用文中的几种技巧来对其进行优化改善,增长代码的可维护性和可复用性。笔者以前使用TypeScript的机会也很少,因此最近也是一边学习一边总结,若是文中有错误的地方,还但愿可以在评论区指正。

交流

若是你以为这篇文章的内容对你有帮助,可否帮个忙关注一下笔者的公众号[前端之境],每周都会努力原创一些前端技术干货,关注公众号后能够邀你加入前端技术交流群,咱们能够一块儿互相交流,共同进步。

文章已同步更新至Github博客,若觉文章尚可,欢迎前往star!

你的一个点赞,值得让我付出更多的努力!

逆境中成长,只有不断地学习,才能成为更好的本身,与君共勉!

相关文章
相关标签/搜索