最近给团队系统性地培训了 TypeScipt,本文简单地对工作中常见知识点进行了总结,以便大家随时翻阅巩固。
TypeScipt 的类型系统主要包含以下几个类别:
其中 string、number、boolean、null、undefined、symbol、Array、Object 与 JavaScript 中的用法相同,不做过多阐述,下面对其它几个类别进行简单介绍。
有些情况下某个变量在编译期无法确定其类型,此时可以使用 unknown 或 any 对该变量进行类型声明,关于这两个类型的差异,先看下面的例子:
let value1: unknown;
value1 = 12;
value1.toFixed();
let value2: any;
value2 = 12;
value2.toFixed();
我们定义了 value1 和 value2 两个变量,其中 value1 为 unknown 类型、value2 为 any 类型,然后分别对其进行赋值,接着调用 toFixed 方法,如果编译上述代码,编译器会针对 value1.toFixed() 语句抛出 Property 'toFixed' does not exist on type 'unknown' 的异常,这是由于相对于 any,TypeScript 编译器会对 unknown 类型的变量进行更为严格的类型检查,我们只需要手动检查 value1 的实际类型,即可修复该问题:
let value1: unknown;
value1 = 12;
if (typeof value1 === 'number') {value1.toFixed();
}
上述代码中,在执行 value1.toFixed() 语句之前,先对 value1 是否为 number 类型进行了判断,毕竟只有 number 对象才拥有 toFixed 方法,这相对于 any 类型来说,更好地保证了代码的安全性及行为的可预期性。
根据上面的讨论,建议大家在遇到未知类型时优先考虑使用 unknown,不到万不得已不要使用 any。
当某个函数或方法是个死循环或直接抛出异常时,可将返回值设置为 never 类型来表示永不返回,该类型主要用在泛型编程中,比如下面的例子:
function eventLoop(): never {while (true) {//do something...}
}
function raiseError(message: string): never {throw new Error(message);
}
通过 interface 或 type 可以定义各种复杂的类型,其用法参见 www.typescriptlang.org/docs/handbo… 和 www.typescriptlang.org/docs/handbo… ,此处不做过多说明,唯一要注意的这两者之间的区别:
interface,它的属性可叠加,该特性使得我们可以极其方便地对全局变量、第三方库的类型做扩展;type 可为 string、number 等基本类型定义别名;type 可用来声明联合、元组(即元素类型不相同的数组)类型。对于第一条,我们看下面的例子:
interface Person {name: string;
}
interface Person {age: number;
}
上述代码中的类型声明等同于:
interface Person {name: string;age: number;
}
联合类型表示变量、参数的类型不是单一的原子类型,而可能多种不同类型的组合;比如下面的例子:
type UnionType = string | number; // 联合类型声明
let value: UnionType;
上例中,我们定义了联合类型 UnionType 以及类型为 UnionType 的变量 value,此时能够将 string、number 或 UnionType 类型的值赋予变量 value。
交叉类型表示把多个类型合并成一个类型,合并后的类型将拥有所有成员类型的特性;比如下面的例子:
interface Name {name: string;
}
interface Age {age: number;
}
type Person = Name & Age; // 交叉类型声明
上例中,我们定义了交叉类型 Person,该类型将拥有类型 Name 和 Age 的所有属性。
有些情况下我们只需要知道变量是否为某一类型(比如页面中的各种事件类型)而不关心它实际的值,在 JavaScript 中通常定义一堆常量来达到该 目的,在 TypeScript 中我们可以使用枚举来完成相关需求。比如下面的例子:
enum EventType {Click, // 0MouseUp, // 1MouseLeave, // 2
}
enum EventType {Click = 1, // 1MouseUp, // 2MouseLeave, // 3
}
enum EventType {Click, // 0MouseUp = 2, // 2MouseLeave, // 3
}
枚举中每一项的赋值规则如下:
0,否则为指定的值;1,否则为指定的值。枚举中每一项值的类型为 string 或 number,因此下面的例子都是合法的:
enum EventType {Click = 1,MouseUp,MouseLeave = 'MouseLeave',
}
不过需要注意的是,如果某一项值为 string 类型,必须手动为该项的下一项赋值,比如下面的例子:
// 不合法
enum EventType {Click = 'Click',MouseUp,MouseLeave,
}
// 合法
enum EventType {Click = 'Click',MouseUp = 1,MouseLeave,
}
之所以有这个规定是因为编译器无法或者不知道如何为 string 做递增操作。
所谓泛型编程,其本质就是将类型参数化,最终目的是进行类型抽象,即将多个类型中具有某一共同行为的逻辑进行抽象,以达到代码重用的目的。在 TypeScript 中,我们可以定义泛型方法、泛型类 和 泛型类型,比如下面的例子:
// 泛型方法
function doSomething(arg: A): A {return arg;
}
// 泛型类
class Memory {store: S;constructor(store: S) {this.store = store;}set(store: S) {this.store = store;}get() {return this.store;}
}
// 泛型类型
type ReflectFuncton = (param: P) => P;
interface ReflectFuncton {
(param: P): P
}
interface ReflectFuncton
{(param: P): P
}
默认情况下,所指定的类型参数 P 是 any 的子类型(关于父子类型的讨论,参见笔者的另一篇文章 TypeScript 类型兼容性——父子类型),我们可通过 extend 关键字来修改泛型参数 P 的父类型,以便对泛型参数的行为进行约束,比如下面的例子:
function doSomething(arg: P) {//do something...
}
上例中,我们指定了 doSomething 的类型参数 P 的类型只能是 number 的子类型,如果给该函数传入字符串等其它类型,那么编译器将抛出异常。
在 TypeScript 泛型编程中,我们还会遇到 分配条件类型、infer、变型,这些概念在笔者的其它文章中均已讨论过,故此处不再重述,具体详情参看下面链接:
在 TypeScript 中,如果遇到 typeof、instanceof、in、==、===、!=、!== 关键字时,它会在语句的块级作用域内将相关变量的类型缩小为具体的类型,以便减少不必要的类型断言及提高程序的安全性.
该操作符主要用于获得变量类型:
const convert = (c: number | string) => {if (typeof c === 'number') {return c.toFixed();}return c.toLowerCase();
}
该操作符主要用于判断变量是否是某个类的实例:
class Dog {wang = 'wangwang';
}
class Cat {miao = 'miaomiao';
}
const getName = (animal: Dog | Cat) => {if (animal instanceof Dog) {return animal.wang;}return animal.miao;
}
该操作符主要用于判断变量是否为某个对象的属性:
interface Dog {wang: string;
}
interface Cat {miao: string;
}
const getName = (animal: Dog | Cat) => {if ('wang' in animal) {return animal.wang;}return animal.miao;
}
主要包含 ==、===、!= 和 !== 操作符,主要用于判断变量是否为某个字面值:
const getName = (animal: 'Dog' | 'Cat') => {if (animal === 'Dog') {return 'wangwang';}return 'miaomiao';
}
除了上述操作符外,我们也可以使用 is 关键字自定义类型守卫:
function isDog(animal: Dog | Cat): animal is Dog {return 'wang' in animal;
}
const getName = (animal: Dog | Cat) => {if (isDog(animal)) {return animal.wang;}return animal.miao;
}
上例中我们定义了一个类型守卫 isDog,它其实就是一个函数,只不过返回值是 {参数名} is {类型} 的格式,通过该方式,我们可以实现一套符合自己业务属性的类型守卫系统,以达到提高效率及程序安全性的目的。
相信大家都遇到过不同类型变量之间相互赋值而引起的各种问题,不同的类型之间,为什么有的可以相互赋值,有的则不行,这主要是由 TypeScript 的类型兼容系统决定的,掌握了 TypeScript 的类型兼容系统,我们才能从容应对上述问题,关于 TypeScript 的类型兼容系统,请参见笔者的另一篇文章 TypeScript 类型兼容性,此处不再阐述。
元编程能力的强弱在笔者心里始终是衡量一个编程语言好不好玩的第一标准,TypeScript 为我们提供了强大的类型元编程能力,通过它我们可以构建各种自定义类型来应对各种复杂的场景,关于 TypeScript 的类型元编程,请参见笔者的另一篇文章 TypeScript 类型编程,此处不再阐述。
由于历史原因,很多第三方库并无类型声明文件(即 .d.ts 文件,作用类似于 C/C++ 中的头文件),如在使用过程中想要对这些第三方库进行类型检查,可手动为这些库添加类型声明,本节便对其进行简单介绍。
变量类型声明只需在变量声明的前面加上 declare 关键字即可:
declare let value1: number;
declare const value2: boolean;
上例的意义是:
number 类型的值赋予 value1;value2 的类型是 boolean,且 value2 是只读的(即无法给该变量赋予任何新的值)。函数类型声明与函数声明一样,只不过需要在 function 前加上 declare 关键字,且无实现体:
declare function doSomething(x: number);
类的类型声明与类声明一样,只不过需要在 class 前加上 declare 关键字,且类中的方法(包含静态方法、构造方法、实例方法)无实现体:
declare class Person {public name: string;private age: number;constructor(name: string);getAge(): number;
}
枚举类型声明只需在枚举声明的前面加上 declare 关键字即可:
declare enum Direction {Up,Down,Left,Right,
}
模块声明常用来对具有很多变量、函数、类、枚举等子属性的模块(一个模块即对应一个 JS 文件)或文件(比如图片)进行类型声明:
// tom.d.ts
declare module 'tom' {export function doSomething(money: number);
}
// jpg.d.ts
declare module '*.jpg' {const src: string;export default src;
}
上例中对模块 tom 和后缀为 jpg 的文件进行了类型声明,且在各自内部通过 export 导出了可供外部访问的成员,完成声明后下面的代码便能正常编译:
// index.ts
import { doSomething} from 'tom';
import bg from './bg.jpg';
命名空间与模块类似,都是用来对具有很多变量、函数、类、枚举等子属性的对象进行类型声明,只不过命名空间主要用于对全局对象进行类型声明,比如下面的例子:
declare namespace $ {const version: number;function ajax(settings?: any): void;
}
命名空间与模块还有一点差异是在命名空间中声明的子属性都能被外部访问到,而无需 export(也不支持)。
在 .d.ts 文件中,会经常看到类似下面的代码:
// /root/src/index.d.ts
///
///
///
///
上例中由 /// 开头的语句便是所谓的三斜线指令,其中各指令的用途如下所述:
no-default-lib:用于将类型声明文件标记为默认库(常见于 lib.d.ts),用于指示编译器在编译时不包含默认库(即 lib.d.ts);* path:常用于引入自定义的类型声明文件,根据指定的路径加载类型声明文件;* types:常用于引入第三方库的类型声明文件,其加载顺序如下(此处假设 types 的值为 lodash):* /root/src/node_modules/@types/lodash/index.d.ts* /root/src/node_modules/@types/lodash/package.json(加载 package.json 中 types 属性指定的文件)* /root/src/node_modules/lodash/index.d.ts* /root/src/node_modules/lodash/package.json(加载 package.json 中 types 属性指定的文件)* /root/node_modules/@types/lodash/index.d.ts* /root/node_modules/@types/lodash/package.json(加载 package.json 中 types 属性指定的文件)* /root/node_modules/lodash/index.d.ts* /root/node_modules/lodash/package.json(加载 package.json 中 types 属性指定的文件)* /node_modules/@types/lodash/index.d.ts* /node_modules/@types/lodash/package.json(加载 package.json 中 types 属性指定的文件)* /node_modules/lodash/index.d.ts* /node_modules/lodash/package.json(加载 package.json 中 types 属性指定的文件)
lib:常用于引入 TypeScript 内置库的类型声明文件。在使用三斜线指令时需要注意以下两点:
指令必须位于文件的最顶部;
指令前如有注释,只能是单行注释或多行注释。
除了上述指令外,还有 /// 和 /// 指令,由于不常用,故此不再阐述,相关详情可参见官网 www.typescriptlang.org/docs/handbo…。
本文仅对 tsconfig.json 中的常用配置进行说明,完整信息可查看 www.typescriptlang.org/tsconfig。
这里模块解析主要作用是告诉 TypeScript 编译器在引入其它模块时如何加载该模块,下面我们便对不同的加载策略进行简单介绍。
假设在 /root/src/folder/moduleA.ts 中以相对路径的形式引入 moduleB(即 import { b } from "./moduleB"),那不同策略下的加载逻辑如下:
该模式下,TypeScript 将根据以下顺序进行加载:
/root/src/folder/moduleB.ts/root/src/folder/moduleB.d.ts该模式下,TypeScript 将根据以下顺序进行加载:
/root/src/moduleB.ts/root/src/moduleB.tsx/root/src/moduleB.d.ts/root/src/moduleB/package.json(加载 package.json 中 types 属性指定的文件)/root/src/moduleB/index.ts/root/src/moduleB/index.tsx/root/src/moduleB/index.d.ts假设在 /root/src/folder/moduleA.ts 中引入的模块 moduleB 不包含路径信息(即 import { b } from "moduleB"),那不同策略下的加载逻辑如下:
该模式下,TypeScript 将根据以下顺序进行加载:
/root/src/folder/moduleB.ts/root/src/folder/moduleB.d.ts/root/src/moduleB.ts/root/src/moduleB.d.ts/root/moduleB.ts/root/moduleB.d.ts/moduleB.ts/moduleB.d.ts该模式下,TypeScript 将根据以下顺序进行加载:
/root/src/node_modules/moduleB.ts/root/src/node_modules/moduleB.tsx/root/src/node_modules/moduleB.d.ts/root/src/node_modules/moduleB/package.json(加载 package.json 中 types 属性指定的文件)/root/src/node_modules/@types/moduleB.d.ts/root/src/node_modules/moduleB/index.ts/root/src/node_modules/moduleB/index.tsx/root/src/node_modules/moduleB/index.d.ts/root/node_modules/moduleB.ts/root/node_modules/moduleB.tsx/root/node_modules/moduleB.d.ts/root/node_modules/moduleB/package.json(加载 package.json 中 types 属性指定的文件)/root/node_modules/@types/moduleB.d.ts/root/node_modules/moduleB/index.ts/root/node_modules/moduleB/index.tsx/root/node_modules/moduleB/index.d.ts/node_modules/moduleB.ts/node_modules/moduleB.tsx/node_modules/moduleB.d.ts/node_modules/moduleB/package.json(加载 package.json 中 types 属性指定的文件)/node_modules/@types/moduleB.d.ts/node_modules/moduleB/index.ts/node_modules/moduleB/index.tsx/node_modules/moduleB/index.d.tstrue 时,需同步开启 allowSyntheticDefaultImports)。为了更加直观的理解该属性,看下面的例子:
// tsconfig.json
{"compilerOptions": {"target": "ESNext","module": "commonjs"},"include": ["./src",],"exclude": ["node_modules"]
}
// src/index.js
import fs from "fs";
fs.readFileSync("file.txt", "utf8");
执行 npx tsc 命令,TypeScript 编译器将抛出 Module '"fs"' has no default export 异常;之所以抛出这个异常,是因为 CommonJS 中没有 export default 这个东西,为了能够正确的引入 CommonJS 模块,需要将 esModuleInterop 设置为 true,再次执行 npx tsc 命令后查看生成的 JS 文件,会发现 TypeScript 会通过辅助函数 __importDefault 动态为 CommonJS 模块设置了 default 属性。
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const fs_1 = __importDefault(require("fs"));
fs_1.default.readFileSync("file.txt", "utf8");
需要注意的时,该属性仅在 module 属性值为 CommonJS、UMD 或 AMD 才有意义。
通过前面的讨论可知,如果因导入了 CommonJS 模块而导致编译抛出了 Module '"xxx"' has no default export 异常,此刻需要将 esModuleInterop 设置为 true 以便编译器能够通过辅助函数 __importDefault 动态为 CommonJS 模块设置 default 属性。然而有些时候我们可能希望编译器忽略此类错误且不做除类型检查外的任何处理,比如下面的例子:
const allFunctions = {};
module.exports = allFunctions;
module.exports.default = allFunctions;
上例中通过设置 module.exports.default 为 CommonJS 模块设置 default 导出,如果我们引入这样的 CommonJS 模块,便可将 allowSyntheticDefaultImports 设置为 true 以让编译器忽略此类错误且不做除类型检查外的任何处理。
假设有如下的项目结构:
├── src/
│ └── index.ts
│ └── tsconfig.json
├── test/
│ ├── index.spec.ts
│ └── tsconfig.json
└── tsconfig.json
上例需要满足以下两个需求:
test 依赖于 src;src 目录下的代码进行编译。可通过以下方式进行编译配置:
tsconfig.json 并设置公共的编译选项;* 在 src 目录下创建 tsconfig.json,继承根目录下的配置,并将 composite 属性设置为 true:{"extends": "../tsconfig.json","compilerOptions": {"composite": true}} * 在 test 目录下创建 tsconfig.json,继承根目录下的配置,并设置 references 属性的值:{"extends": "../tsconfig.json","compilerOptions": {"references": [{ "path": "../src" }]}} 通过上面的配置,我们以还算优雅的形式处理了多个相互依赖项目间的编译配置组织问题,不过需要注意的是,在运行测试之前,需要通过 tsc --build src 先行构建 test 的依赖项(此处为 src)。tsconfig.json 所在目录。tsconfig.json 所在目录。比如下面的例子:
├── src
│ ├── index.ts
在配置 outDir 为 lib 的情况下进行编译,生成的目录结构如下:
└── lib ├── src |├── index.js
查看编译结果会发现 lib 下多了一层 src,这或许不符合我们的预期,为此可将 rootDir 设置为 src 后再次进行编译:
└── lib ├── index.js
此时,生成的目录结构符合了我们的预期(即不包含 src 这一层)。
虚拟目录中解析相对应的模块导入,就像它们被合并到同一目录中一样。比如下面的例子:
src
└── utils.ts
└── view.ts
views
└── render.ts
{"compilerOptions": {"rootDirs": ["src", "views"]}
}
上例中,我们可以:
src/view.ts 中以 import Render from "./render" 的形式加载 views/render.ts 中的内容;views/render.ts 中以 import Utils from "./utils" 的形式加载 views/utils.ts 中的内容。需要注意的是,该配置仅在类型检测时有效,build 时还需要将相关文件放在同一个目录。
除了该属性外,也可通过下列选项更加细粒度地进行严格检测:
alwaysStrict:保证编译出的文件是 ECMAScript 的严格模式,并且每个文件的头部会添加 ‘use strict’。strictNullChecks:更严格地检查 call、bind、apply 函数的调用,比如会检查参数的类型与函数类型是否一致。strictFunctionTypes:更严格地检查函数参数类型和类型兼容性。strictPropertyInitialization:更严格地检查类属性初始化,如果类的属性没有初始化,则会提示错误。noImplicitAny:禁止隐式 any 类型,需要显式指定类型;TypeScript 在不能根据上下文推断出类型时,会回退到 any 类型。noImplicitThis:禁止隐式 this 类型,需要显示指定 this 的类型。noImplicitReturns:禁止隐式返回。如果代码的逻辑分支中有返回,则所有的逻辑分支都应该有返回。noUnusedLocals:禁止未使用的本地变量。noUnusedParameters:禁止未使用的函数参数。noFallthroughCasesInSwitch:禁止 switch 语句中的穿透的情况;开启后,如果 switch 语句的流程分支中没有 break 或 return,则会抛出错误,从而避免了意外的 swtich 判断穿透导致的问题。以上选项的默认值为 strict 的值。
本文对 TypeScript 的类型系统、泛型编程、类型守卫、类型兼容、类型元编程、类型声明、tsconfig.json 配置等主要知识点进行了简单地总结,如有疏漏之处还望诸位海涵,最后祝大家快乐编码每一天。