概述

什么是TypeScript

Typescript是一种语言,它是JavaScript的超集,向JavaScript添加了可选的静态类型和基于类的面向对象编程。
简单地说,TypeScript给JavaScript这个弱类型语言增加了强类型的支持。开发者编写程序时候,通过TypeScript语法告诉编译器变量的类型,然后编译器在开发和编译的时候帮助你检查类型是否合法。
typescript是一个工具,仅在开发阶段起作用(类型检查和提示),最终还是会编译成es6代码。
Typescript的语法用来表达类型,这里的类型和es的数据类型并不完全对应,Typescript所表达的类型更抽象。例如typescript中的枚举、元组,在ES6中没有对应的类型。Typescript中的类型和ES6的数据类型可能存在多对多的关系,例如interface可以用来表达对象、数组、函数,而函数可以用interface表达,也可以用function声明表达。
TypeScript 提供最新的和不断发展的 JavaScript 特性,例如optional chaining、nullish coalescing 等。

为什么需要TypeScript

使用TypeScript编写程序相对于使用ES6更加复杂,有一定的学习成本,前期可能会降低开发速度,但是程序经过类型检查可以在开发阶段就检测出一些可能出现的问题(如前端常见的“Uncaught TypeError: Cannot read properties of undefined ”、“XXX is not a function”)等错误,提升了前端项目的质量。
另外使用TypeScript编写的程序,TypeScript会有类型提示,对代码的可读性也有提升,例如之前前端习惯用注释说明函数的参数和返回值,而TypeScript根据函数的类型声明就可以提示类型,这样也避免了代码和注释可能不同步引起的问题。再比如前端和后端交互的一些复杂数据结构,由于JavaScript的弱类型特性,后面的开发者阅读代码时候很难根据代码搞清楚数据结构,而使用TypeScript开发的程序则能够很清楚地看到数据结构。

如何使用TypeScript

项目中支持TypeScript语法:引入TypeScript编译器,对代码进行转译;引入TypeScript代码的格式校验;编辑器插件支持TypeScript格式校验
然后使用TypeScript开发
构建,将TypeScript语法转成ES6。

TypeScript项目搭建

目标

●支持ts转译
●识别.ts、.tsx、.d.ts文件
●编辑器语法检查和提示(vscode默认支持TypeScript语法,会根据tsconfig.json检查类型,并给出提示)
●eslint(tslint已经不再推荐)
●支持JSX
●使用react

实现

以webpack打包工具为例。

支持ts转译

webpack支持ts转译有两种方案:基于babel解析器和基于tsc解析。

基于babel

(查看官网或者npmjs或者github以更详细地了解相关工具)

babel主要通过@babel/preset-typescript预设 编译TypeScript代码。

先安装工具

  1. npm install @babel/core babel-loader @babel/preset-typescript

进行相关配置

  1. // webpack.config.js
  2. module.exports = {
  3. // ......other configure
  4. module: {
  5. rules: [
  6. {
  7. test: /\.ts$/,
  8. use: ['babel-loader']
  9. }
  10. ]
  11. }
  12. };
  1. // .babelrc
  2. {
  3. "presets": ["@babel/preset-typescript"]
  4. }

babel不会读取tsconfig.json,它有自己的配置项,只有tsc才会读取tsconfig.json,另外babel-loader并不会进行类型检查,只是会去掉typescript中类型的部分。如果希望检查类型,需要使用其他工具(后面会说明)。虽然typeScript本身支持很多先进的es语法(如可选链)但是babel-loader的“@babel/preset-typescript”并不支持,所以还需要babel-preset配合。

使用babel-loader时候,目前不支持import moduleName = require(‘path’)语法,推荐使用import moduleName from ‘path’;参考: https://github.com/babel/babel/issues/7062

由于babel-loader并不会进行TypeScript类型检查,因此我们需要其他的工具来进行类型检查。

fork-ts-checker-webpack-plugin这个插件可以用来进行typescript类型检查。这个插件也是依赖tsc进行代码转换的。

当前版本的create-react-app的TypeScript模板,就是使用@babel/preset-typescript + fork-ts-checker-webpack-plugin方案支持TypeScript的。

基于tsc

使用ts-loader,这个loader调用tsc编译TypeScript代码,并会进行类型检查。
使用很简单,把ts-loader配置到webpack的loaders中即可。

安装

  1. npm install ts-loader

配置webpack

  1. // webpack.config.js
  2. module.exports = {
  3. // ......other configure
  4. module: {
  5. rules: [
  6. {
  7. test: /\.ts$/,
  8. use: ['babel-loader', 'ts-loader']
  9. }
  10. ]
  11. }
  12. };

识别.ts、.tsx、.d.ts文件

配置webpack的resolve解析扩展名

  1. module.exports = {
  2. // ......other configure
  3. resolve: {
  4. extensions: ['.tsx', '.ts', '.d.ts']
  5. }
  6. };

注意,如果webpack中配置了路径别名(resolve.alias),则需要在tsconfig.json中也配置一下,否则tsc会报错找不到模块。

eslint

eslint检查代码格式同样有两种方案,一种是babel方案,另一种是TypeScript社区的方案。

@babel/eslint-parser

@babel/eslint-parser
eslint的默认解析器无法解析一些新的语法,例如ts、flow。因此当你使用新语法,并且用babel解析时候,可以用@babel/eslint-parser代替eslint默认的parser。这样@babel/eslint-parser可以让eslint在babel转译过的代码上工作。
babel转译后语法再被解析为estree,这样对es语法的规则就可以进行校验了。校验的代码会和源码有对应关系,这样我们就能在格式不规范的代码上看到错误或者警告提示了。
和@babel/eslint-parser配套使用的是@babel/eslint-plugin,目前它不支持对ts语法进行格式化校验,因为ts社区已经做了相关工作,因此@babel的这个parser和plugin仅仅是用于新语法项目中的es语法格式化。如果需要对新语法格式进行提醒和校验,还是需要用其他的parser和plugin。
这里需要注意eslint-loader、eslint、@babel/eslint-parser、@babel/eslint-plugin之间的版本约束
安装工具

  1. npm install eslint eslint-loader @babel/eslint-parser @babel/eslint-plugin
  1. // webpack.config.js
  2. module.exports = {
  3. // ......other configure
  4. module: {
  5. rules: [
  6. {
  7. test: /\.ts$/,
  8. use: ['eslint-loader', 'babel-loader']
  9. }
  10. ]
  11. }
  12. };
  1. // .eslintrc
  2. module.exports = {
  3. parser: "@babel/eslint-parser",
  4. plugins: ["@babel"],
  5. rules: {
  6. "@babel/new-cap": "error",
  7. "@babel/no-invalid-this": "error",
  8. "@babel/no-unused-expressions": "error",
  9. "@babel/object-curly-spacing": "error",
  10. "@babel/semi": "error"
  11. }
  12. };

@typescript-eslint/parser

typescript-eslint
使用@typescript-eslint/parser作为eslint的解析器,配套地使用@typescript-eslint/eslint-plugin插件。
安装工具

  1. npm install eslint eslint-loader @typescript-eslint/parser @typescript-eslint/eslint-plugin
  1. // webpack.config.js
  2. module.exports = {
  3. // ......other configure
  4. module: {
  5. rules: [
  6. {
  7. test: /\.ts$/,
  8. use: ['eslint-loader', 'babel-loader']
  9. }
  10. ]
  11. }
  12. };
  1. // .eslintrc
  2. module.exports = {
  3. parser: "@typescript-eslint/parser",
  4. plugins: ["@typescript-eslint/eslint-plugin"],
  5. rules: {
  6. "@typescript-eslint/await-thenable": "error",
  7. "@typescript-eslint/explicit-function-return-type": "error"
  8. },
  9. parserOptions: {
  10. project: "./tsconfig.json"
  11. }
  12. };

@typescript-eslint/parser 可以解析ts语法,并将ts语法转成estree,因此eslint可以正常将es的格式规则校验。同时@typescript-eslint/eslint-plugin可以对ts语法格式规则进行校验。
实际项目推荐@typescript-eslint/进行校验。
代码提示配置好eslint后,还需要安装vscode的eslint插件,然后就能看到报错提示了。

JSX

支持解析JSX语法需要:

  1. 文件以.tsx为后缀
  2. 启用选项(通过tsconfig.json文件中设置compilerOptions.jsx'react'来实现)

    react

    当前版本react使用js编写,因此需要安装其ts声明才能使用
    npm install @types/react

    TypeScript语法

    简述

    通过概述,我们已经了解到Typescript就是给ES6加入了强类型,这样可以在代码转译时候进行类型检查(type-check)。
    从开发者角度,就是编写程序时候,通过声明,将代码中的变量、函数等的类型告知TypeScript编译器,在变量的使用或者函数的调用/类的实例化和访问属性等操作代码中,编译器就能发现潜在的问题。

我们看下面的示例

  1. let a: number = 1;

上面代码声明了一个数值类型的变量a,并用1来初始化。:number类型注解,就是告诉TypeScript该变量类型。如果变量类型和赋值类型不对,TypeScript会报错,一个变量从声明之后,类型就是固定的,后面不能改变。

  1. let a: number = 1;
  2. a = ''; // error TS2322: Type 'string' is not assignable to type 'number'.

下面看函数如何声明其类型

  1. function add(a: number, b: number): number {
  2. return a + b;
  3. }
  4. add(1); // error TS2554: Expected 2 arguments, but got 1.

上面代码声明并定义了一个add函数,使用注解声明了函数参数和返回值的类型。
如果我们调用函数时候参数不对,TypeScript会编译报错。通常项目中会存在调用错误的情况,在运行时候才会发现,如果我们使用TypeScript编写程序,就能在开发阶段避免这类错误。
除了函数调用参数列表错误,还有很多其他的类型错误可以通过TypeScript在开发阶段就排查出来。这在很大程度上提升了项目质量。
另外,当我们定义了这个函数之后,在其他模块使用这个函数时候,TypeScript会根据函数的类型声明给出提示,除此之外,我们定义好一个类之后,在其他模块使用对象,也会有属性的提示,这极大的提升了代码的可读性。
我们知道ES6中的数据类型有布尔、数值、字符串、对象、函数、undefined。TypeScript为了提供强大的类型检查能力,它的语法内容主要包括几个方面:

  1. 增加和扩展数据类型
  2. 增加了模块语法
  3. 增强类型表达能力
  4. TypeScript还通过一些语法特性让开发者可以控制类型检查的过程

    增加和扩展基础类型

    ES6是弱类型语言,只有几个基础的类型和宽泛的引用类型,TypeScript在ES6基础上增加了很多类型以更精确地进行类型描述。

    基本类型

    布尔

    1. let isDone: boolean = false;

    数值

    1. let decLiteral: number = 6; // 10进制
    2. let hexLiteral: number = 0xf00d; // 16进制
    3. let binaryLiteral: number = 0b1010; // 2进制
    4. let octalLiteral: number = 0o744; // 8进制

    字符串

    1. let name: string = "bob";
    2. name = "smith";

    null和undefined

    null和undefined这两个类型对应的取值只能是null/undefined。值null的类型是null;值undefined的类型是undefined。
    默认情况下null和undefined是所有类型(除了never)的子类型,即可以将null和undefined赋值给任意类型的变量。如果使用了编译参数—strictNullChecks,则这两个值只能赋值给各自的类型(undefined还是可以赋值给void)。
    实际项目中推荐使用—strictNullChecks。

    数组

    元素类型+“[]”
    1. let list: number[] = [1, 2, 3];
    数组泛型
    1. let list: Array<number> = [1, 2, 3];
    TypeScript中的数组中的元素必须是相同类型,否则会报错 ```javascript let list: Array = [1, 2, 3];

list.push(‘4’); // error TS2345: Argument of type ‘string’ is not assignable to parameter of type ‘number’.

  1. <a name="JYJha"></a>
  2. #### 元组
  3. 元组类型允许表示一个已知元素数量和类型的向量,各元素类型不必相同,但要求元素数量和每个index对应的类型固定。
  4. ```javascript
  5. let x: [string, number];
  6. x = [1, 2]; // error TS2322: Type 'number' is not assignable to type 'string'.

枚举

枚举类型是对JavaScript标准数据类型的一个补充

  1. enum Color {Red, Green, Blue}
  2. let c: Color = Color.Green;

默认从0开始编号,可以手动指定起始编号

  1. enum Color {Red = 1, Green, Blue}
  2. let c: Color = Color.Green;

也可以全部手动赋值

  1. enum Color {Red = 1, Green = 2, Blue = 4}
  2. let c: Color = Color.Green;

枚举支持根据取值访问名字

  1. enum Color {Red = 1, Green, Blue}
  2. let colorName: string = Color[2];
  3. alert(colorName); // 'Green'

any

有时候,我们会想要为那些在编程阶段还不清楚类型的变量指定一个类型。 这些值可能来自于动态的内容,比如来自用户输入或第三方代码库。 这种情况下,我们不希望类型检查器对这些值进行检查而是直接让它们通过编译阶段的检查。 那么我们可以使用any类型来标记这些变量:

  1. let notSure: any = 4;
  2. notSure = "maybe a string instead";
  3. notSure = false;

或者一个长度和类型都不确定的数组

  1. let list: any[] = [1, true, 'free'];
  2. list[1] = 100;
  3. list.push(2);

unknown

在typescript中,当我们不知道一个变量是什么类型的时候,可以给它声明为unknown类型。
unknown类型是一切类型的父类型,所以可以把任意类型赋值给unknown类型的变量,但不能把unknown类型的变量赋值给其他类型的变量(左父右子)。
unknown和any有什么区别呢?
在不知道一个变量的类型时候,既可以声明其为unknown类型也可以声明其为any类型。
但两者有几点区别:

  1. 含义不同:any表示任意类型,unknown表示未知类型。
  2. 编译处理不同:any变量不会对其进行类型校验,可以对any变量进行任何操作,如给一个any变量赋值、把any变量赋值给其他变量、调用any变量的任意方法。但是unknown不同,首先不能把unknown类型变量赋值给其他变量,另外如果没有断言,对unknown变量无法进行其他操作。
  3. 类型安全性不同,any由于没有任何类型检查,所以不安全,unknown由于有类型检查,更安全一些。

在实际使用typescript时候,如果不知道一个变量的类型,推荐使用unknown而非any,因为unknown是类型安全的。

void

某种程度上来说,void类型像是与any类型相反,它表示没有任何类型。
一个函数如果没有返回值,它的返回值类型就是void

  1. function logString(message: string): void {
  2. console.log(message);
  3. }

也可以给一个变量声明为void类型,这时候,它的取值只能是undefined/null。当指定编译参数--strictNullChecks时候,取值只能是undefined。

  1. let a: void = null; // error TS2322: Type 'null' is not assignable to type 'void'.

never

函数有一个不可达端点时候,返回值为never

  1. // 返回never的函数必须存在无法达到的终点
  2. function error(message: string): never {
  3. throw new Error(message);
  4. }
  5. // 返回never的函数必须存在无法达到的终点
  6. function infiniteLoop(): never {
  7. while (true) {
  8. }
  9. }

变量被收窄(narrowing)为never时候,可以声明为never类型
参考这个回答:TypeScript中的never类型具体有什么用? ——尤雨溪

  1. function handleValue(val: string | number): void {
  2. switch (typeof val) {
  3. case 'string':
  4. // 这里 val 被收窄为 string
  5. break;
  6. case 'number':
  7. // val 在这里是 number
  8. break;
  9. default:
  10. // val 在这里是 never
  11. const exhaustiveCheck: never = val;
  12. break;
  13. }
  14. }

如果迭代过程中,val的类型被改为string | number | boolean,但是没有增加逻辑,那么default分支就没有收窄到never,编译时候会报错。这样就保证了每种情况都能够有对应的处理逻辑。

字面量

字面量类型允许一个变量取几个固定的值。

  1. let a: 1 | '2' = 1;
  2. a = '2';
  3. a = 3; // error TS2322: Type '3' is not assignable to type '1 | "2"'.

接口

简述

TypeScript的接口(interface)用于描述类型的结构,它可以用来给对象、函数、数组等声明类型。

“当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。” 意思就是: 一个东西究竟是不是鸭子,取决于它能不能满足鸭子的工作。

接口使用鸭式辩型法(duck typing)检查类型,鸭式辩型法是动态类型的一种风格。在这种风格中,一个对象有效的语义,不是由继承自特定的类或实现特定的接口,而是由”当前方法和属性的集合”决定。

描述类型

对象
  1. interface LabelledValue {
  2. label: string;
  3. }
  4. function printLabel(labelledObj: LabelledValue) {
  5. console.log(labelledObj.label);
  6. }
  7. let myObj = {size: 10, label: "Size 10 Object"};
  8. printLabel(myObj);

上面代码用interface定义的接口LabelledValue来声明函数的参数,函数参数是一个有着label属性的对象。
也可以不用interface关键字

  1. function printLabel(labelledObj: {label: string}) {
  2. console.log(labelledObj.label);
  3. }

可选属性:interface定义的对象的属性可以通过问号“?”标记为可选的

  1. interface SquareConfig {
  2. color?: string;
  3. width?: number;
  4. }
  5. function createSquare(config: SquareConfig): {color: string; area: number} {
  6. let newSquare = {color: "white", area: 100};
  7. if (config.color) {
  8. newSquare.color = config.color;
  9. }
  10. if (config.width) {
  11. newSquare.area = config.width * config.width;
  12. }
  13. return newSquare;
  14. }
  15. let mySquare = createSquare({color: "black"});

只读属性:可以在属性前加readonly关键字来指定其只读

  1. interface Point {
  2. readonly x: number;
  3. readonly y: number;
  4. }
  5. let p1: Point = { x: 10, y: 20 };
  6. p1.x = 5; // error TS2540: Cannot assign to 'x' because it is a read-only property.

函数

接口也可以用来描述函数类型,函数类型包括参数列表和返回值。

  1. interface SearchFunc {
  2. (source: string, subString: string): boolean;
  3. }
  4. let mySearch: SearchFunc;
  5. mySearch = function(source: string, subString: string) {
  6. let result = source.search(subString);
  7. return result > -1;
  8. }

混合类型

JavaScript中允许一个对象同时作为函数使用,这也可以通过接口来声明类型
即这个接口描述的类型,即是一个函数也是一个对象。

  1. interface Counter {
  2. (start: number): string;
  3. interval: number;
  4. reset(): void;
  5. }
  6. function getCounter(): Counter {
  7. let counter = <Counter>function (start: number) { };
  8. counter.interval = 123;
  9. counter.reset = function () { };
  10. return counter;
  11. }
  12. let c = getCounter();
  13. c(10);
  14. c.reset();
  15. c.interval = 5.0;

索引类型

接口还可以用来声明“索引”类型,这包括Array和Map两种

我们也可以使用接口描述那些能够“通过索引得到”的类型,比如a[10]或ageMap[“daniel”]

接口可以声明数组类型

  1. interface StringArray {
  2. [index: number]: string;
  3. }
  4. let myArray: StringArray;
  5. myArray = ["Bob", "Fred"];
  6. let myStr: string = myArray[0];

接口也可以用来声明map,其key须是number、string、symbol中的一种。
注意:可以同时使用number和string两种类型的索引,但是数字索引的返回值必须是字符串索引返回值类型的子类型。 这是因为当使用number来索引时,JavaScript会将它转换成string然后再去索引对象。 也就是说用100(一个number)去索引等同于使用”100”(一个string)去索引,因此两者需要保持一致。

  1. class Animal {
  2. name: string;
  3. }
  4. class Dog extends Animal {
  5. breed: string;
  6. }
  7. interface NotOkay {
  8. [x: number]: Animal;
  9. [x: string]: Dog; // error TS2413: 'number' index type 'Animal' is not assignable to 'string' index type 'Dog'.
  10. }
  11. interface Okay {
  12. [x: number]: Dog;
  13. [x: string]: Animal;
  14. }

keyof

keyof关键字用来提取类型中的key,作为类型使用

  1. interface User {
  2. userName: string;
  3. userAge: number;
  4. }
  5. let key: keyof User = 'userName'; // keyof User的类型是 'userName' | 'userAge'
  6. let key1: keyof User = 'userSex'; // error TS2322: Type '"userSex"' is not assignable to type 'keyof User'.
  7. class Person {
  8. sex = 'famale';
  9. }
  10. let str: keyof Person = 'sex';

interface可以定义一个类所符合的规则,当一个类继承一个接口时候,这个类需要实现接口中的所有属性和方法。注意:类的静态方法和私有方法不会被检查,只有公共方法会被TypeScript检查。

  1. interface ClockInterface {
  2. currentTime: Date;
  3. setTime(d: Date): void;
  4. }
  5. class Clock implements ClockInterface {
  6. currentTime: Date;
  7. setTime(d: Date) {
  8. this.currentTime = d;
  9. }
  10. constructor(h: number, m: number) { }
  11. }
  12. const c = new Clock(10, 10);

若仅仅是静态方法或者私有方法实现了接口,而公有方法未实现,则该类不会通过类型检查

  1. interface ClockInterface {
  2. currentTime: Date;
  3. setTime(d: Date): void;
  4. }
  5. // error TS2576: Property 'currentTime' does not exist on type 'Clock'. Did you mean to access the static member 'Clock.currentTime' instead?
  6. class Clock implements ClockInterface {
  7. static currentTime: Date;
  8. setTime(d: Date) {
  9. this.currentTime = d;
  10. }
  11. constructor(h: number, m: number) { }
  12. }
  1. interface ClockInterface {
  2. currentTime: Date;
  3. setTime(d: Date): void;
  4. }
  5. // error TS2420: Class 'Clock' incorrectly implements interface 'ClockInterface'. Property 'currentTime' is private in type 'Clock' but not in type 'ClockInterface'.
  6. class Clock implements ClockInterface {
  7. private currentTime: Date;
  8. setTime(d: Date) {
  9. this.currentTime = d;
  10. }
  11. constructor(h: number, m: number) { }
  12. }
  13. const c = new Clock(10, 10);

注意,类implements一个接口时候,TypeScript不会校验公有方法和静态方法,构造函数也属于静态部分,因此TypeScript不会检查类的构造函数。

  1. interface ClockInterface {
  2. new(hour: number);
  3. }
  4. // error TS2420: Class 'Clock' incorrectly implements interface 'ClockInterface'.
  5. // Type 'Clock' provides no match for the signature 'new (hour: number): any'.
  6. class Clock implements ClockInterface {
  7. constructor(hour: number) { }
  8. }
  9. const c = new Clock(10);

上面代码中,对Clock类,TypeScript不检查其构造函数,因此认为Clock没有实现new方法,所以会报错。
但是TypeScript对作为函数参数的类的构造函数是会检查的。

  1. interface ClockInterface {
  2. new(hour: number);
  3. }
  4. function createClock(ctor: ClockInterface): void {
  5. new ctor(1);
  6. }
  7. class Clock {
  8. constructor(hour: string) { }
  9. }
  10. // error TS2345: Argument of type 'typeof Clock' is not assignable to parameter of type 'ClockInterface'.
  11. // Types of parameters 'hour' and 'hour' are incompatible.
  12. // Type 'number' is not assignable to type 'string'.
  13. const c = createClock(Clock);

继承

接口可以继承类和其他接口,以此实现类型的复用。

接口继承接口
  1. interface Shape {
  2. color: string;
  3. }
  4. interface Square extends Shape { // 继承其他接口
  5. sideLength: number;
  6. }
  7. let square = <Square>{}; // 断言语法,后续会讲到
  8. square.color = "blue";
  9. square.sideLength = 10;

接口继承类

当接口继承了一个类类型时,它会继承类的成员但不包括其实现。
接口同样会继承到类的private和protected成员。 这意味着当你创建了一个接口继承了一个拥有私有或受保护的成员的类时,这个接口类型只能被这个类或其子类所实现(implement)

  1. class Control {
  2. private state: any;
  3. }
  4. interface SelectableControl extends Control {
  5. select(): void;
  6. }
  7. class Button extends Control implements SelectableControl {
  8. select() {}
  9. }

TypeScript在ES6基础上对类的语法进行了扩展,让程序员可以更好地编写面向对象的程序。
下面介绍TypeScript在ES6的类语法上增加的语法,ES6的类语法可以参考其他教程。

原型方法和私有方法

注意,ES6的方法有实例方法和原型的区别

  1. class Logger {
  2. log() {} // 原型方法
  3. warn = () => {}; //实例方法
  4. }

上面代码会被TypeScript编译为

  1. var Logger = /** @class */ (function () {
  2. function Logger() {
  3. this.warn = function () {}; //实例方法
  4. }
  5. Logger.prototype.log = function () { }; // 原型方法
  6. return Logger;
  7. }());

由于原型方法在定义后不能改变,因此要求在定义一个类的时候必须实现原型方法,如果只声明不定义,会报错。

  1. class Logger {
  2. log(): void // error TS2391: Function implementation is missing or not immediately following the declaration.
  3. }

而实例属性和方法可以只声明不定义,但是一个属性/方法只有声明的话,TypeScript编译后,该类不会有这个属性/方法,等待后续动态赋值。

  1. class Logger {
  2. warn: () => void
  3. }
  4. const logger = new Logger();
  5. logger.warn = () => {console.warn('test')};

上面代码会被编译为

  1. var Logger = /** @class */ (function () {
  2. function Logger() {
  3. }
  4. return Logger;
  5. }());
  6. var logger = new Logger();
  7. logger.warn = function () { console.warn('test'); };

如果warn赋值的类型和声明类型不符合,TypeScript编译时候会报错。

访问修饰符

和大部分传统的面向对象语言一样,TypeScript支持了publicprivateproteced的访问属性。
public修饰的属性是可以在类的外部访问的。
private修饰的属性只能在类内部访问。
protected修饰的属性只能在类的内部或者子类的内部访问到。

readonly修饰符

使用readonly关键字修饰的属性是只读的,必须在声明时候或者构造函数里面初始化

  1. class Octopus {
  2. readonly name: string;
  3. readonly numberOfLegs: number = 8;
  4. constructor (theName: string) {
  5. this.name = theName;
  6. }
  7. }
  8. let dad = new Octopus("Man with the 8 strong legs");
  9. dad.name = "Man with the 3-piece suit"; // error TS2540: Cannot assign to 'name' because it is a read-only property.

参数属性

参数属性语法允许我们在类的构造函数的参数中声明属性,这样做可以在实例化类的时候,从构造函数中传入参数来初始化属性。
这样的语法能让我们更简洁地声明和初始化属性。

  1. class Person {
  2. constructor(private name: string, public age: number) { }
  3. log(): void {
  4. console.log(this.name, this.age);
  5. }
  6. }

抽象类

抽象类做为其它派生类的基类使用,不能直接实例化。
抽象类可以包含成员的实现细节,也可以包含抽象的属性和抽象的方法。
abstract关键字可以用来定义抽象类和抽象类内部的抽象属性。
用abstract关键字修饰的属性,在派生类中必须实现。
抽象的属性和方法不能初始化和定义,只能声明其类型。

  1. abstract class Department {
  2. constructor(public name: string) {
  3. }
  4. printName(): void {
  5. console.log('Department name: ' + this.name);
  6. }
  7. abstract printMeeting(): void; // 必须在派生类中实现
  8. }

函数

JavaScript的函数声明有“函数声明”和“函数表达式”两种方式,下面看一下这两种声明方式如何指定类型
函数声明

  1. function add(x: number, y: number): number {
  2. return x + y;
  3. }

函数表达式

  1. // 完整的函数表达式声明
  2. let add: (x:number, y:number) => number =
  3. function(x: number, y: number): number { return x + y; };
  4. // 简写,省略注解
  5. let add = function(x: number, y: number): number { return x + y; };
  6. // 简写,省略表达式中的类型
  7. let add: (x: number, y: number) => number = function(x, y) { return x + y; };

可选参数

TypeScript会对函数调用进行类型检查,如果传入的参数和参数列表不匹配,则报错。

  1. function add(x: number, y: number): number {
  2. return x + y;
  3. }
  4. add(1, 2);
  5. add(1); // error TS2554: Expected 2 arguments, but got 1.
  6. add(1, '2'); // error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

通过在参数后面加“?”标识参数可选。在调用时候可以传,也可以不传

  1. function add(x: number, y?: number): number {
  2. return x + (y || 0);
  3. }

注意,所有的可选参数都需要位于必传参数之后

  1. function add(y?: number, x: number): number {
  2. return x + (y || 0);
  3. }
  4. // error TS1016: A required parameter cannot follow an optional parameter.

函数重载

js因为是动态类型,本身不需要支持重载,直接对参数进行类型判断即可,但是ts为了保证类型安全,支持了函数签名的类型重载。
声明函数重载需要声明多个overload signatures和一个implementation signatures。
implementation signatures是对多个overload signatures的汇总。

  1. function add(x: string, y: string): string; // 重载签名
  2. function add(x: number, y: number): number; // 重载签名
  3. function add(x: string | number, y: number | string): number | string { // 实现签名
  4. return +x + +y;
  5. }

也可以使用any来汇总类型

  1. function add(x: string, y: string): string;
  2. function add(x: number, y: number): number;
  3. function add(x: any, y: any): any {
  4. return +x + +y;
  5. }

TypeScript会选择第一个匹配到的重载当解析函数调用的时候。 当前面的重载比后面的“普通”,那么后面的被隐藏了不会被调用。所以,应该排序重载令精确的排在一般的之前。

模块

在TypeScript变量,函数,类,类型别名或接口都可以导入和导出

命名空间

现代前端项目,通常我们使用ESM模块化开发,很少使用全局变量,命名空间是为了兼容老的全局变量的语法而设计的。
使用namespace关键字可以声明一个全局变量。
namespace下面可以声明私有变量和暴露的变量(使用export关键字)。

  1. namespace util {
  2. const defaultValue = 100; // 私有属性
  3. export function add(x: number, y: number): number { // 对外暴露的属性
  4. return (x + y) || defaultValue;
  5. }
  6. }
  7. util.add(1, 2);

上面的代码会被编译为

  1. var util;
  2. (function (util) {
  3. function add(x, y) {
  4. return x + y;
  5. }
  6. util.add = add;
  7. })(util || (util = {}));
  8. util.add(1, 2);

ESM

TypeScript支持ES6中的模块。

  1. // util.ts
  2. // export后接一个声明语句
  3. export const throttle = () => {};
  4. // export后接一个声明语句
  5. export const debouce = () => {};
  6. export default {log: () => {}};
  1. // index.ts
  2. import util, {debouce, throttle} from './util';
  3. util.log();

引入时候也可以整体引入

  1. import * as util from './util';
  2. util.debouce();
  3. util.throttle();
  4. util.default.log();

也可以使用大括号导出,好处是能够一眼看出导出了哪些变量。

  1. // util.ts
  2. const throttle = () => {};
  3. const debouce = () => {};
  4. export {
  5. throttle,
  6. debouce
  7. }
  8. export default {log: () => {}};

可以在导出模块时候重命名

  1. // util.ts
  2. const throttle = () => {};
  3. export {
  4. throttle as jieliu,
  5. }

import时候也可以指定别名

  1. // index.ts
  2. import {jieliu as throttle} from './util;

当需要重新导出一个模块,即先引入再导出时候,可以用export和import一起使用来实现

  1. export {throttle, debouce, default} from './util';
  2. // 等同于
  3. export * from './util';

对于一些有副作用的模块,模块的引入者不关心其导出,可以使用下面的方法导入

  1. import 'path/to/module';

CommonJS和AMD

TypeScript增加了和export= 语法来支持传统的CommonJS和AMD的模块模式。
使用export =导出一个模块

  1. // util.ts
  2. const throttle = () => {};
  3. const debouce = () => {};
  4. export = {
  5. throttle, debouce
  6. };

相应地,用import = require()来引入模块

  1. import util = require('./util');
  2. util.debouce();
  3. util.throttle();

通常我们开发项目时候,习惯使用ESM模块的语法,但是对于第三方模块需要一定的处理,第三方模块有使用ESM模块语法声明模块的类型,也有的第三方模块使用export =语法来声明模块的类型(例如React库,被编译成CommonJS模块,声明文件是CommonJS模块格式)。那么我们应该如何引用第三方模块呢?可以根据导出模块的语法,如果是ESM,我们就用ESM的引入模块的语法,如果导出模块的语法是CommonJS/AMD,我们就用export =语法引用。
但是这样比较麻烦,我们希望项目中都用ESM来导入。这需要配置编译选项esModuleInterop为true来实现。
为什么需要这个编译选项呢?
ESM和CommonJS模块本身是不兼容的。ESM只能导出模块的属性,无法导出顶层的模块。import moduleName form 'path';实际是引入名为default的属性。如果想导入整个模块只能使用import * as moduleName form 'path';但CommonJS可以导出整个模块。所以用ESM引入语法导入CommonJS导出的模块,只能用import * as moduleName form 'path';而不能用
import moduleName form 'path';因为后者导入的是模块的“default”属性,而CommonJS模块可能未没有default属性。
如果我们希望用ESM更为习惯的的语法import moduleName form 'path';来引入CommonJS的第三方模块,需要在转译import语句时候判断一下引入的是ESM模块还是CommonJS模块,如果是ESM模块则正常引入如果是CommonJS需要把顶层模块赋值给default属性。而esModuleInterop编译选项就是用来做这个事情的。
如果设置了esModuleInterop选项,TypeScript会对代码进行如下编译。

  1. import util from './util';
  2. console.log(util);

编译为

  1. "use strict";
  2. var __importDefault = (this && this.__importDefault) || function (mod) {
  3. return (mod && mod.__esModule) ? mod : { "default": mod };
  4. };
  5. exports.__esModule = true;
  6. var util_1 = __importDefault(require("./util"));
  7. console.log(util_1["default"]);

而整体导入的语法

  1. import * as util from './util';
  2. console.log(util);

编译为

  1. var __importStar = (this && this.__importStar) || function (mod) {
  2. if (mod && mod.__esModule) return mod;
  3. var result = {};
  4. if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
  5. result["default"] = mod;
  6. return result;
  7. };
  8. exports.__esModule = true;
  9. var util = __importStar(require("./util"));
  10. console.log(util);

这样就可以在项目使用习惯的ESM语法来引入第三方模块了
参考文章
esModuleInterop 到底做了什么?
typescript中的esModuleInterop选项
为了更深刻理解TypeScript模块语法和esModuleInterop编译选项,可以使用TypeScript编译导入和导出的代码,通过观察分析编译后的代码来加深理解。

高级类型

TypeScript提供了一些高级类型来提升类型的表达能力。

泛型

泛型就是“模板”,更准确地说,泛型是函数和类的模板。它提供了复用数据结构和代码逻辑成的支持,也使我们构建大型的类型系统成为可能。
为什么需要泛型呢,举个例子,我们希望实现一个方法,返回它传入的参数。
那么这个方法有两个特征:

  • 可能传入各种类型的参数
  • 传入的参数和返回值类型相同

这可以使用泛型来实现

  1. function identity<T>(arg: T): T {
  2. return arg;
  3. }

它是一个“模板”,当我们要使用这个方法时候,也就是具体化这个方法时候,给它传入具体的类型,然后调用即可。

函数泛型

定义一个函数泛型

  1. function identity<T>(arg: T): T {
  2. return arg;
  3. }

使用函数泛型

  1. let output = identity<string>("myString");

简写(类型推断)

  1. let output = identity("myString");

使用接口定义函数泛型

  1. interface GenericIdentityFn {
  2. <T>(arg: T): T;
  3. }
  4. function identity<T>(arg: T): T {
  5. return arg;
  6. }
  7. let myIdentity: GenericIdentityFn = identity;

类泛型

类泛型即类的模板,类的属性和方法可以是“泛泛、抽象的类型”,即先定义类的数据结构和方法逻辑,一些属性和方法参数、返回值等数据类型作为参数在使用的时候再具体化。
泛型类使用尖括号“<>”括起泛型类型,跟在类名后面。

  1. // 定义类泛型,注意这里的属性和方法都是实例方法,因此只需要声明
  2. class GenericNumber<T> {
  3. zeroValue: T;
  4. add: (x: T, y: T) => T;
  5. }
  6. // 使用number类型具体化类泛型,并用具体化后的类实例化一个对象
  7. let myGenericNumber = new GenericNumber<number>();
  8. // 给实例属性动态赋值,值的类型必须是number
  9. myGenericNumber.zeroValue = 0;
  10. // 给实例方法动态赋值,add方法的参数x, y和返回值已经具体化为number
  11. myGenericNumber.add = function(x, y) { return x + y; };
  12. // error TS2322: Type '(x: string, y: number) => string' is not assignable to type '(x: number, y: number) => number'.
  13. myGenericNumber.add = function(x: string, y) { return x + y; };

接口泛型

和函数泛型及类泛型类似,接口泛型在接口名字后面的尖括号内定义类型变量,在接口实现中使用类型变量。在使用接口泛型时候,也是需要通过尖括号指定具体的类型,即给类型变量赋值。

  1. interface IPerson<T> {
  2. id: T
  3. numList: T[],
  4. getID:( value: T) => void;
  5. }
  6. const p: IPerson<number> = {
  7. id: 1,
  8. numList: [99, 10, 10],
  9. getID: function(id: number) {
  10. console.log(id)
  11. }
  12. }

typescript中内置的Pick就是一个接口泛型,也可以自己实现

  1. type MyPick<T, K extends keyof T> = {
  2. [key in K]: T[key]
  3. }
  4. interface TState {
  5. name: string;
  6. age: number;
  7. like: string[];
  8. }
  9. interface TSingleState extends MyPick<TState, "name" | "age"> {};
  10. let a: Pick<TState, "name" | "age"> = {name: '', age: 1};

泛型约束

在上面的示例中我们发现,作为模板的类型参数的“T”比较抽象,代表任何类型,但有时候我们希望给这个抽象的类型加一些约束,以便让TypeScript对代码进行更好的检查。
对类型约束通过extends关键字来实现,T extends EmbedType,T就被约束为具体的EmbedType类型。
例如,我们希望实现一个方法,接受一个数组作为参数,打印数组的长度,并返回这个参数。
如果不对抽象类型进行约束,TypeScript会报错

  1. // 由于T抽象类型不一定具有"length"属性,因此不会通过TypeScript的类型检查
  2. function loggingIdentity<T>(arg: T): T {
  3. console.log(arg.length); // error TS2339: Property 'length' does not exist on type 'T'.
  4. return arg;
  5. }

对T进行类型约束,就可以避免错误

  1. interface Lengthwise {
  2. length: number;
  3. }
  4. function loggingIdentity<T extends Lengthwise>(arg: T): T {
  5. console.log(arg.length);
  6. return arg;
  7. }

泛型也可以约束不同的抽象类型之间的关系,例如一个抽象类型是另一个抽象类型的索引的关系

  1. function getProperty<T, K extends keyof T>(obj: T, key: K) {
  2. return obj[key];
  3. }
  4. let x = {a: 1, b: 2};
  5. getProperty(x, 'a');
  6. getProperty(x, 'm'); // error TS2345: Argument of type '"m"' is not assignable to parameter of type '"a" | "b"'.

交叉类型

交叉类型是将多个类型合并为一个类型,合并后的类型是现有类型的父类型,即如果有3个类型ABC,那么交叉后的类型A & B & C,即是A,也是B,也是C
对于接口,交叉后的类型必须包含每个接口中声明的属性。

  1. interface A {
  2. name: string;
  3. }
  4. interface B {
  5. age: number;
  6. }
  7. let a: A = {name: 'a'};
  8. let b: B = {age: 1};
  9. // A & B类型必须包含A和B中的每个属性
  10. let c: A & B = {name: 'c', age: 2};
  11. a = c; // A是C的子类型

对于基础类型,交叉后是never,因为不可能有类型即是number,又是string

  1. // error TS2322: Type 'number' is not assignable to type 'never'.
  2. let a: string & number = 1;

联合类型

联合类型也是将多个类型进行运算得到的新类型,联合后的类型,可以是多个类型中的一个。
例如有两个类型A、B,那么它们联合后的类型 A | B,可以是A或者B中的一个。
对于接口定义的对象类型,联合类型只要兼容任意一个接口即可

  1. interface A {
  2. name: string;
  3. }
  4. interface B {
  5. age: number;
  6. }
  7. let a: A = {name: 'a'};
  8. let b: B = {age: 1};
  9. // A | B中只要兼容A或者B其中任意一个即可
  10. let c: A | B = {age: 2};
  11. // c类型兼容 A | B
  12. c = a;

对于基础类型,联合后可以是多个类型中的任意一个

  1. let a: string | number = 1;
  2. a = '';

映射类型

映射类型可以简单理解为“类型的函数”,给它传入类型作为参数,它会返回一个类型
设想两个常见的场景,将已知类型的每个属性都变成可选的。例如我们将接口Person变成PersonPartial

  1. interface Person {
  2. name: string;
  3. age: number;
  4. }
  5. // 可选属性版的Person
  6. interface PersonPartial {
  7. name?: string;
  8. age?: number;
  9. }

再比如我们想让已知类型的每个属性变成只读的,例如我们将接口Person变成PersonReadonly

  1. // 将传入的类型的属性都转成可选的
  2. type Partial<T> = {
  3. [P in keyof T]?: T[P];
  4. }
  5. // 将传入的类型的属性都转成只读的
  6. type Readonly<T> = {
  7. readonly [P in keyof T]: T[P];
  8. }

更具通用性地,我们可以提供一个映射类型,将每个类型映射到一个可选或者可读版的新类型

  1. // 将传入的类型的属性都转成可选的
  2. type Partial<T> = {
  3. [P in keyof T]?: T[P];
  4. }
  5. // 将传入的类型的属性都转成只读的
  6. type Readonly<T> = {
  7. readonly [P in keyof T]: T[P];
  8. }

上面我们看到,使用type关键字,加映射类型名,再加尖括号“<>”(尖括号中是映射类型的“形参”),然后在大括号内通过key:value形式指定从旧类型属性到新类型属性的映射关系。
类型映射的使用和调用函数类似。

  1. type PersonPartial = Partial<Person>;
  2. type ReadonlyPerson = Readonly<Person>;

TypeScript标准库中内置了几个常用的映射类型

  1. // 可选
  2. type Partial<T> = {
  3. [P in keyof T]?: T[P];
  4. }
  5. // 只读
  6. type Readonly<T> = {
  7. readonly [P in keyof T]: T[P];
  8. }
  9. // 选择
  10. type Pick<T, K extends keyof T> = {
  11. [P in K]: T[P];
  12. }
  13. // 记录,和Map类似
  14. type Record<K extends string, T> = {
  15. [P in K]: T;
  16. }

语法特性

除了需要掌握必要的TypeScript语法,还需要了解TypeScript的一些语法特性,这些语法特性涉及TypeScript在进行类型检查的过程,了解特性可以让我们写出更简洁易读的TypeScript代码,并且能在TypeScript报错时候更快地搞清楚问题出在哪里。

类型兼容性

类型兼容性用于确定一个类型是否能够赋值给另一个类型。例如一个类型为A的变量x和一个类型为B的变量y,编译赋值语句时候,就会检查类型兼容性,以判断该语句是否合法

  1. let x: A;
  2. let y: B;
  3. x = y; // 如果B兼容A,则该语句合法

TypeScript里的类型兼容性是基于结构子类型的。 结构类型是一种只使用其成员来描述类型的方式。 它正好与名义(nominal)类型形成对比。 在基于名义类型的类型系统中,数据类型的兼容性或等价性是通过明确的声明和/或类型的名称来决定的。这与结构性类型系统不同,它是基于类型的组成结构,且不要求明确地声明。

看下面的例子

  1. interface Named {
  2. name: string;
  3. }
  4. class Person {
  5. name: string;
  6. }
  7. let p: Named;
  8. // OK, because of structural typing
  9. p = new Person();

在使用基于名义类型的语言,比如C#或Java中,这段代码会报错,因为Person类没有明确说明其实现了Named接口。

TypeScript的结构性子类型是根据JavaScript代码的典型写法来设计的。 因为JavaScript里广泛地使用匿名对象,例如函数表达式和对象字面量,所以使用结构类型系统来描述这些类型比使用名义类型系统更好。

结构子类型指结构上是其子集就是子类型,除了结构子类型,一些基础的类型也有子类型的关系:null和undefined是任何类型的子类型。

接口类型兼容性

当一个类型B中包含类型A的所有属性时候,B兼容A,B类型的变量可以赋值给A类型的变量。

  1. interface A {
  2. name: string;
  3. age: number;
  4. }
  5. interface B {
  6. name: string;
  7. age: number;
  8. sex: string
  9. }
  10. let a: A = {name: 'Ann', age: 2};
  11. let b: B = {name: 'Sam', age: 1, sex: 'famale'};
  12. a = b;

函数类型兼容性

函数类型的兼容性要从参数列表和返回值两个方面考虑。当参数列表和返回值都兼容,函数才能兼容。
当函数x的参数列表中的参数在函数y中都能找到时候,x的参数列表兼容y的参数列表。
函数返回值的兼容性和变量的兼容性一样。
可以简单记忆:如果x参数列表是y的子集,y的返回值是x的子集,x兼容y。当然这里的“子集”不是严格意义的概念。
下面先看参数列表兼容性的例子。
官网的例子

  1. let x = (a: number) => 0;
  2. let y = (b: number, s: string) => 0;
  3. y = x; // x的参数列表兼容y的参数列表
  4. x = y; // Error y的参数列表没有兼容x的参数列表

为什么参数列表的兼容性是这样的呢?可以通过下面的例子理解

  1. let add: (x: number, y: number) => number =
  2. function(x: number): number { return x; };
  3. add(1, 2);
  4. add(1); // error TS2554: Expected 2 arguments, but got 1.

我们看到TypeScript会根据声明的函数的参数列表进行类型检查,如果类型中的参数列表能够完全包括实现的参数列表,那么调用时候多传入的参数可以不处理,不会发生运行时错误,但是如果声明的函数参数列表中不能完全包含实现的参数列表,比如

  1. let add: (x: number) => number =
  2. function(x: number, y: number): number { return x + y; };
  3. add(1);

这样的话调用时候会发现少参数,因此,实际的函数参数列表必须包含在在声明的函数类型的参数列表中。
看下面的例子来理解函数返回值的兼容性

  1. let x = (b: number): {name: string, age: number} => ({name: "Ann", age: 1});
  2. let y = (a: number): {name: string} => ({name: 'Jam'});
  3. y = x; // x的返回值兼容y的参数列表
  4. x = y; // Error y的返回值没有兼容x的参数列表

返回值的兼容性比较好理解,因为TypeScript会根据声明的类型检查调用,函数实现的返回值必须兼容函数
声明的返回值的类型,否则调用时候就可以根据类型发现赋值错误。

  1. let add: () => {name: string, age: number} =
  2. function(): {name: string, age: number, sex: string} {
  3. return {name: '', age: 1, sex: 'male'};
  4. };
  5. // 如果函数实现的返回值类型不兼容声明类型(例如函数实现的返回值类型是{name: string}),则没办法检测出来调用函数的赋值错误
  6. const result: {name: string, age: number} = add();

枚举类型兼容性

枚举类型与数字类型兼容,并且数字类型与枚举类型兼容。不同枚举类型之间是不兼容的。
枚举变量的类型就是枚举类型本身

  1. enum State { Ready, Waiting };
  2. let state = State.Ready;
  3. // 等价于
  4. let state: State = State.Ready;

因此,将state赋值给其他枚举类型不会通过类型检查

  1. enum State { Ready, Waiting };
  2. enum Color { Red, Blue, Green };
  3. let state = State.Ready;
  4. state = Color.Green; // error TS2322: Type 'Color.Green' is not assignable to type 'State'.

枚举类型与数值类型兼容

  1. enum State { Ready, Waiting };
  2. enum Color { Red, Blue, Green };
  3. let state: number = State.Ready; // ok
  4. state = Color.Green; // ok