TypeScript学习1 | 青训营笔记

95 阅读10分钟

这是我参与「第四届青训营 」笔记创作活动的的第8天

上了《TypeScript入门》课程后,一直对ts很感兴趣,花了一下午了解ts,下面是笔记。

什么是TypeScript

官网是这么定义的:Typed JavaScript at Any Scale,添加了类型系统的js,适用于任何规模的项目。

类型系统

类型系统按照类型检查的时机来分类,可以分为动态类型和静态类型

动态类型指运行时才会进行类型检查。js是解释性语言,没有编译阶段,所以是动态类型。这种语言的类型错误往往会导致运行时错误

静态类型指编译阶段就能确定每个变量的类型。ts在运行前会先编译为js,在编译过程中就会进行类型检查,所以ts是静态类型。这种语言的类型错误往往会导致语法错误

静态类型的好处在于:

  1. 可读性增强,基于语法解析TSDoc,ide增强
  2. 可维护性增强,在编译阶段暴露大部分错误

因此在多人合作的大型项目中能够获得更好的稳定性和开发效率

类型系统按照是否允许隐式类型转换来分类,可以分为强类型和弱类型

对于js来说,下面的例子中,运行时数字1会被隐式类型转换为字符串1,加号被识别为字符串连接,所以最后打印结果是字符串'12'

console.log(1 + '2');
// 打印出字符串 '12'

ts是完全兼容js的,所以ts和js一样都是弱类型

而强类型语言诸如java,不允许隐式类型转换,只能进行强制类型转换

适用于任何规模

上面提到过,类型系统可以为大型项目带来更高的可维护性及更少的bug

而在中小型项目中,因为ts增强了ide的功能,包括代码补全、接口提示、跳转到定义、代码重构等,在很大程度上提高了开发效率。并且ts有近百个编译选项,可以通过修改编译选项来降低类型检查的标准

因为ts完全兼容js,所以如果有一个使用 JavaScript 开发的旧项目,又想使用 TypeScript 的特性,那么不需急着把整个项目都迁移到 ts,可以使用ts编写新文件,然后在后续更迭中逐步迁移旧文件

VSCode编辑器中编写js时,代码补全和接口提示等功能就是通过TypeScript Language Service实现的

所以,ts的发展已经深入到前端社区的方方面面了,任何规模的项目都或多或少得到了ts的支持

基本语法

原始数据类型

  • ts中使用 : 指定变量的类型,: 的前后有没有空格都可以
let isDone: string = 'string';
  • 在 TypeScript 中,boolean 是 JavaScript 中的基本类型,而 Boolean 是 JavaScript 中的构造函数
let createdByNewBoolean: boolean = new Boolean(1);
// Type 'Boolean' is not assignable to type 'boolean'.
//   'boolean' is a primitive, but 'Boolean' is a wrapper object. Prefer using 'boolean' when possible.
  • void 的区别是,undefinednull 是所有类型的子类型。也就是说 undefined 类型的变量,可以赋值给 number 类型的变量,而 void 类型的变量不能赋值给 number 类型的变量

// 这样不会报错
let num: number = undefined;

// 这样也不会报错
let u: undefined;
let num: number = u;

//这样会报错
let u: void;
let num: number = u;
// Type 'void' is not assignable to type 'number'.

类型推论

  • 如果没有明确的指定类型,那么 TypeScript 会依照类型推论(Type Inference)的规则推断出一个类型
let myFavoriteNumber = 'seven';
myFavoriteNumber = 7;

// index.ts(2,1): error TS2322: Type 'number' is not assignable to type 'string'.
  • 如果定义的时候没有赋值,不管之后有没有赋值,都会被推断成 any 类型而完全不被类型检查
let myFavoriteNumber;
myFavoriteNumber = 'seven';
myFavoriteNumber = 7;

联合类型

  • 当 TypeScript 不确定一个联合类型的变量到底是哪个类型的时候,我们只能访问此联合类型的所有类型里共有的属性或方法
function getLength(something: string | number): number {
    return something.length;
}

// index.ts(2,22): error TS2339: Property 'length' does not exist on type 'string | number'.
//   Property 'length' does not exist on type 'number'.

接口

  • 定义的变量比接口少了一些属性是不允许的,多一些属性也是不允许的
interface Person {
    name: string;
    age: number;
}

let tom: Person = {
    name: 'Tom'
};

// index.ts(6,5): error TS2322: Type '{ name: string; }' is not assignable to type 'Person'.
//   Property 'age' is missing in type '{ name: string; }'.

let tom: Person = {
    name: 'Tom',
    age: 25,
    gender: 'male'
};

// index.ts(9,5): error TS2322: Type '{ name: string; age: number; gender: string; }' is not assignable to type 'Person'.
//   Object literal may only specify known properties, and 'gender' does not exist in type 'Person'.
  • 使用 [propName: string] 定义了任意属性取 string 类型的值,一旦定义了任意属性,那么确定属性和可选属性的类型都必须是它的类型的子集
interface Person {
    name: string;
    age?: number;
    [propName: string]: string;
}

let tom: Person = {
    name: 'Tom',
    age: 25,
    gender: 'male'
};
//任意属性的值允许是 `string`,但是可选属性 `age` 的值却是 `number`,`number` 不是 `string` 的子属性,所以会报错
  • 一个接口中只能定义一个任意属性。如果接口中有多个类型的属性,则可以在任意属性中使用联合类型
[propName: string]: string | number;
  • readonly 定义只读属性
readonly id: number;
  • 只读的约束存在于第一次给对象赋值的时候,而不是第一次给只读属性赋值的时候
interface Person {
    readonly id: number;
    name: string;
    age?: number;
    [propName: string]: any;
}
//报错:对tom进行赋值的时候,没有给id赋值
let tom: Person = {
    name: 'Tom',
    gender: 'male'
};
//报错:在给tom.id赋值的时候,由于它是只读属性,所以报错
tom.id = 89757;

函数的类型

  • ts中,=> 用来表示函数的定义,左边是输入类型,需要用括号括起来,右边是输出类型。
let mySum: (x: number, y: number) => number = function (x: number, y: number): number {
    return x + y;
};
  • 重载允许一个函数接受不同数量或类型的参数时,作出不同的处理
function reverse(x: number): number;
function reverse(x: string): string;
function reverse(x: number | string): number | string | void {
    if (typeof x === 'number') {
        return Number(x.toString().split('').reverse().join(''));
    } else if (typeof x === 'string') {
        return x.split('').reverse().join('');
    }
}
//重复定义了多次函数 `reverse`,前几次都是函数定义,最后一次是函数实现
//TypeScript 会优先从最前面的函数定义开始匹配,所以多个函数定义如果有包含关系,需要优先把精确的定义写在前面

类型断言的限制

联合类型可以被断言为其中一个类型

interface Cat {
    name: string;
    run(): void;
}
interface Fish {
    name: string;
    swim(): void;
}

function isFish(animal: Cat | Fish) {
    if (typeof (animal as Fish).swim === 'function') {
        return true;
    }
    return false;
}

父类可以被断言为子类

class ApiError extends Error {
    code: number = 0;
}
class HttpError extends Error {
    statusCode: number = 200;
}

function isApiError(error: Error) {
    if (typeof (error as ApiError).code === 'number') {
        return true;
    }
    return false;
}

任何类型都可以被断言为 any

(window as any).foo = 1;

any 可以被断言为任何类型

function getCacheData(key: string): any {
    return (window as any).cache[key];
}

interface Cat {
    name: string;
    run(): void;
}

const tom = getCacheData('tom') as Cat;
tom.run();

A 兼容 B,那么 A 能够被断言为 BB 也能被断言为 A

interface Animal {
    name: string;
}
interface Cat {
    name: string;
    run(): void;
}

let tom: Cat = {
    name: 'Tom',
    run: () => { console.log('run') }
};
let animal: Animal = tom;

TypeScript 是结构类型系统,类型之间的对比只会比较它们最终的结构,而会忽略它们定义时的关系。

在上面的例子中,Cat 包含了 Animal 中的所有属性,除此之外,它还有一个额外的方法 run。TypeScript 并不关心 CatAnimal 之间定义时是什么关系,而只会看它们最终的结构有什么关系——所以它与 Cat extends Animal 是等价的:

interface Animal {
    name: string;
}
interface Cat extends Animal {
    run(): void;
}

那么也不难理解为什么 Cat 类型的 tom 可以赋值给 Animal 类型的 animal 了——就像面向对象编程中我们可以将子类的实例赋值给类型为父类的变量。

我们把它换成 TypeScript 中更专业的说法,即:Animal 兼容 Cat

Animal 兼容 Cat 时,它们就可以互相进行类型断言了:

interface Animal {
    name: string;
}
interface Cat {
    name: string;
    run(): void;
}

function testAnimal(animal: Animal) {
    return (animal as Cat);
}
function testCat(cat: Cat) {
    return (cat as Animal);
}
  • 允许 animal as Cat 是因为「父类可以被断言为子类」,这个前面已经学习过了

  • 允许 cat as Animal 是因为既然子类拥有父类的属性和方法,那么被断言为父类,获取父类的属性、调用父类的方法,就不会有任何问题,故「子类可以被断言为父类」

类型断言 vs 类型转换

类型断言不是类型转换,它不会真的影响到变量的类型

类型断言只会影响 TypeScript 编译时的类型,类型断言语句在编译结果中会被删除:

function toBoolean(something: any): boolean {
    return something as boolean;
}

toBoolean(1);
// 返回值为 1

在上面的例子中,将 something 断言为 boolean 虽然可以通过编译,但是并没有什么用,代码在编译后会变成:

function toBoolean(something) {
    return something;
}

toBoolean(1);
// 返回值为 1

若要进行类型转换,需要直接调用类型转换的方法:

function toBoolean(something: any): boolean {
    return Boolean(something);
}

toBoolean(1);
// 返回值为 true

类型断言 vs 类型声明

在这个例子中:

function getCacheData(key: string): any {
    return (window as any).cache[key];
}

interface Cat {
    name: string;
    run(): void;
}

const tom = getCacheData('tom') as Cat;
tom.run();

我们使用 as Catany 类型断言为了 Cat 类型。

但实际上还有其他方式可以解决这个问题:

function getCacheData(key: string): any {
    return (window as any).cache[key];
}

interface Cat {
    name: string;
    run(): void;
}

const tom: Cat = getCacheData('tom');
tom.run();

上面的例子中,我们通过类型声明的方式,将 tom 声明为 Cat,然后再将 any 类型的 getCacheData('tom') 赋值给 Cat 类型的 tom

这和类型断言是非常相似的,而且产生的结果也几乎是一样的——tom 在接下来的代码中都变成了 Cat 类型。

它们的区别,可以通过这个例子来理解:

interface Animal {
    name: string;
}
interface Cat {
    name: string;
    run(): void;
}

const animal: Animal = {
    name: 'tom'
};
let tom = animal as Cat;

在上面的例子中,由于 Animal 兼容 Cat,故可以将 animal 断言为 Cat 赋值给 tom

但是若直接声明 tomCat 类型:

interface Animal {
    name: string;
}
interface Cat {
    name: string;
    run(): void;
}

const animal: Animal = {
    name: 'tom'
};
let tom: Cat = animal;

// index.ts:12:5 - error TS2741: Property 'run' is missing in type 'Animal' but required in type 'Cat'.

则会报错,不允许将 animal 赋值为 Cat 类型的 tom

这很容易理解,Animal 可以看作是 Cat 的父类,当然不能将父类的实例赋值给类型为子类的变量。

深入的讲,它们的核心区别就在于:

  • animal 断言为 Cat,只需要满足 Animal 兼容 Cat Cat 兼容 Animal 即可
  • animal 赋值给 tom,需要满足 Cat 兼容 Animal 才行

但是 Cat 并不兼容 Animal

而在前一个例子中,由于 getCacheData('tom')any 类型,any 兼容 CatCat 也兼容 any,故

const tom = getCacheData('tom') as Cat;

等价于

const tom: Cat = getCacheData('tom');

知道了它们的核心区别,就知道了类型声明是比类型断言更加严格的。

所以为了增加代码的质量,我们最好优先使用类型声明,这也比类型断言的 as 语法更加优雅。

类型断言 vs 泛型

还是这个例子:

function getCacheData(key: string): any {
    return (window as any).cache[key];
}

interface Cat {
    name: string;
    run(): void;
}

const tom = getCacheData('tom') as Cat;
tom.run();

我们还有第三种方式可以解决这个问题,那就是泛型:

function getCacheData<T>(key: string): T {
    return (window as any).cache[key];
}

interface Cat {
    name: string;
    run(): void;
}

const tom = getCacheData<Cat>('tom');
tom.run();

通过给 getCacheData 函数添加了一个泛型 <T>,我们可以更加规范的实现对 getCacheData 返回值的约束,这也同时去除掉了代码中的 any,是最优的一个解决方案。