TypeScript再学习
尽管项目中已经用上了TypeScript,但是主要场景下对TS的高级特性设计较少,再看过leetcode面试题后,觉得自己的了解程度还远远不够。于是参考《TypeScript Deep Dive》这本开源书(中文版)开始再学习
TypeScript Playground: http://www.typescriptlang.org/play/
TypeScript项目
编译
TS的编译过程主要通过tsconfig.json
文件来配置(当然你也可以通过命令行的方式指定)。TS有些自己的默认配置,你也可以在complierOptions
下自定义你的配置。
1 | { |
TS有几种不同的编译方式:
- 运行
tsc
,自动定位当前目录下tsconfig.json - 运行
tsc -p [your path]
,手动指定config路径 - 运行
tsc -w
进入观测模式,在文件更改时自动重新编译
你可以通过不同方式指定要编译的文件:
files
直接指定要编译的文件include
指定包含的文件exclude
指定排除的文件
配置值可以是glob格式。
声明空间
TypeScript中有两种声明空间:类型声明空间和变量声明空间。前者只能用作类型注解,后者可以用来当做变量使用。
文件模块
TS中有多种模块系统选项:
- AMD:仅在浏览器端使用
- SystemJS:已被ES模块替代
- ES模块:当前的支持有限
- CommonJS:当前比较好的一个选择
一般在工程中使用ES模块语法,模块选项使用CommonJS。TS中对类型也可以同样适用import和export。
路径
通常情况由moduleResolution
选项指定。这个选项在tsconfig.json
中声明。在声明module: commonjs
时,moduleResolution
自动指定为node
。
导入路径分两种:
- 相对路径,使用
./
或是../
与文件、文件夹名称组成 - 动态路径,TS模块解析将会模仿Node模块解析规则,即去当前目录、所有父目录的node_modules下寻找对应路径模块
如果你本身对node下的模块查找很熟悉,那么恭喜,你已经掌握了TS的模块查找。
global.d.ts
在项目中可以通过declare module 'somepath' {}
的方式声明一个全局模块,这样的一个global.d.ts
是声明全局类型的好地方。从js迁移到ts的项目通常需要一个这样的声明
命名空间
TypeScript下可以使用namespace
拆分变量的命名空间。
1 | namespace Logger { |
namespace
支持嵌套定义,在快速演示和移植旧的JavaScript
动态导入
在使用ES的动态导入功能时,为了保证TS在转换代码时保留import()
语句,tsconfig.json
中的module
需要是esnext
。
类型系统
概览
基本注解
包括JS的原始类型:
string
number
boolean
object
- 其他基本类型
数组类型在元素类型后追加[]
实现。键值对类型使用{[key: string]: any}
实现。
你可以使用interface
封装自己的类型:
1 | interface User { |
另外,对于临时的类型需要。可以直接使用内联的方式注解类型。
1 | const user: { |
特殊类型
除了上面的基本类型,还有一些常用的特殊类型。
any意味着任意类型,any
类型可以兼容任何TypeScript中的类型。因此:
- 任意类型都可以赋值给
any
any
也可以赋值给任意类型
初学者在从JavaScript迁移到TypeScript时,通常要借助any
的帮助。但实际上使用any
就代表告诉TypeScript编译器不要进行任何类型检查。
在TypeScript 3.0特性中,出现了和any
类似的unknown
关键字。但是后者是type safe的.
- 任何类型都可以赋值给
unknown
unknown
在类型检查后才能赋值给任意类型
另外在设置编译属性strictNullChecks
为false
时,字面量null
和undefined
也可以赋值给任意类型变量。
void
用来表示一个函数没有返回值,never
表示不会发生的类型。例如抛出错误的函数、死循环函数的返回值类型、以及字面量空数组的元素类型。
1 | let emptyArr = []; // never[] |
never
类型间可以相互赋值,但不能和其他类型相互赋值。
泛型
计算机算法在对封装类型操作时,往往不依赖于元素类型,这种情况下使用泛型描述,TypeScript会帮助推断元素类型,保证类型安全。
1 | function reverse<T>(items: T[]): T[] { |
高级类型
交叉类型,写作A & B
,表示同时具有A
和B
两种类型的属性,新类型的对象可以使用A或者B的功能。
联合类型,写作A | B
,表示是A
或B
其中一种类型,较常用在入参的内联描述中。
1 | function extend<T, U>(first: T, second: U): T & U { |
元组类型,这不是一种新类型,它用来描述不同类型元素的集合,就像宽容的JS数组一样。
1 | type user = [string, number]; |
类型别名
除开interface,还有type
可以更快捷地定义类型别名。在结合上述高级属性使用时,类型别名type
会是不错的选择。对比interface
和type
:
- 使用
interface
定义基本的层级结构,它可以和implements
以及extends
配合使用 - 在需要的类型不需要从头构造,而是从已有类型推导出来时,使用
type
,它更像是给这些computed type一个语义化的名字
枚举
枚举是常见的组织互斥的一组常量的方式。TypeScript中用enum
关键字表示。默认的枚举是数字类型的,即使用数字作为索引值;
1 | enum Color { |
在使用数字类型时,枚举值可以用数字代替。默认情况下,枚举值从0开始,当然可以用 = 1
修改默认的枚举值。下面有一个枚举值和标记的组合用法。
1 | enum AnimalFlags { |
在不同的Animal
的flags
做位运算时可以非常方便地完成布尔代数的一些操作。
另外,枚举类型的值可以通过赋值成为字符串类型。在使用常量枚举时,TypeScript会将所有出现枚举的位置都替换成内联的常量,而不需要查找枚举变量,从而提高性能提升。
从JavaScript中迁移
总的来说有下面几步:
Step1:添加tsconfig.json
文件。
Step2:修改文件拓展名为ts
,使用any
避免干扰你主要工作的报错,记得在之后规范
Step3:写新的TypeScript代码,减少any
使用
Step4:回头为你的老代码添加类型
Step5:为你的第三方库引用类型声明,绝大多数优秀的JS库都已经有人帮忙写好类型声明了
Step6:对于那些没有声明的第三方库,需要你自己书写类型声明或者declare module yourmodule
一劳永逸
上面提到的类型声明,即DefinitelyTyped通过npm包的方式引入,包有固定前缀@types
。
有些类型声明的引入会带来全局scope的定义,可以通过在tsconfig.json
里配置types
来限制引入的声明文件
1 | { |
类型声明文件
通过declare
关键字告诉TypeScript,你正在表述其他位置已经存在的全局变量。强烈建议把所有的声都放在以.d.ts
结尾的文件名的文件内。环境声明不会被编译成代码。
在这样的模块、变量、类型声明文件里,interface
是最常见的。用户代码中可以用类实现这些接口。但是请记住,interface
旨在声明JavaScript中可能存在的数据结构。
1 | interface Point { |
lib.d.ts
为了便于你能快速开始书写类型检查的代码,TypeScript自带了BOM的变量声明(包含window、document、math等)位于lib.d.ts
中。你可以在你的项目下添加global.d.ts
,对已有的全局变量做自己的拓展。
1 | interface Window { |
你在自己定义的global.d.ts
中可以通过拓展global,修改全局空间内的类型定义。
1 | declare global { |
编译选项
- 指定
--noLib
可以排除TypeScript自动引入的lib.d.ts
,这通常出现在- 运行JavaScript的环境和标准浏览器相距甚远
- 你希望严格控制全局变量的使用
- 指定
--lib
可以对编译环境进行细粒度控制引入的包类型- tsc中,
tsc --target es5 --lib dom,es6
- 也可以在
tsconfig.json
中声明1
2
3"compilerOptions": {
"lib": ["dom", "es6"]
}
- tsc中,
如果没有指定--lib
,TypeScript会根据当前编译选项中的target
导入默认库。
--target
为es5时,导入es5、dom、scriptdom--target
为es6时,导入es6、dom、dom.iterable、scripthost
函数
函数注解可以使用内联或interface
的方式。通常编译器可以根据代码自动推断函数的返回类型。函数入参的可选参数通过类型注解前的?
说明。另外,TypeScript允许你声明函数重载,注意,这里只是声明,重载需要自己实现。
1 | function adult(itself: human); |
可调用的
可以用类型别名或接口表示可调用的类型。函数重载和构造函数定义都可以在其中实现。使用new
作为前缀后,需要使用new
关键字去调用这个函数。
1 | interface Overloaded { |
除此之外,还可以使用箭头函数作内联函数注解,但这种时候无法表示重载。
1 | const foo: (bar: number) => string = bar => bar.toString(); |
字面量类型
字面量 + 联合类型构成TS中常用的字面量类型。
1 | type Seasons = 'spring' | 'summer' | 'autumn' | 'winter'; |
很多时候字面量类型会通过keyof
一个键值对的形式来构造。
1 | // 用于创建字符串列表映射至 `K: V` 的函数 |
类型断言
TypeScript有自己的类型推断,但是允许你使用类型断言去覆盖。通过as Something
或<Something>
的方式。但是后者接近JSX语法,所以更多使用前者。
断言是编译时的,为编译器提供分析代码的方法。TypeScript在进行类型断言时,会判断源类型S
是否是目标类型T
的子集,若不是则不能成功断言。
类型保护
使用JS中typeof
和instanceof
可以帮助TypeScript推导出条件语句内的变量类型。使用in
操作符,也可以帮助TypeScript判断类型。
1 | interface A { |
在联合类型中,如果有类型使用字面量,TypeScript甚至可以通过判断字面量确定变量类型。
1 | type Foo = { |
最后,弥补JS中plain object没有instanceof
或typeof
自我检查的漏洞。TypeScript提供了is
允许自定义类型判断。
1 | // 仅仅是一个 interface |
类型推断
TypeScript可以根据一些规则推断出变量类型:
- 定义变量
- 函数返回
- 赋值
- 结构化(数组元素、对象属性)
- 解构
在推断不出类型或使用第三方JS库时,类型会被判定为any
。开启编译选项noImplicitAny
可以避免这种问题。
类型兼容
- 结构化:只要对象结构匹配,名称无关紧要
- 多态性:子类实例可以复制给基类实例,相反则不行
- 函数
- 返回类型:数据较多的可以赋值给数据较少的
- 入参:入参较少的可以赋值给入参较多的
- 可选参数、Rest参数:可以相互赋值(可选和必选仅在
strictNullChecks
选中时相互兼容) - 入参类型:父类子类相互兼容(牺牲安全性确保便利性)
- 枚举:和数字类型兼容、不同枚举间不兼容
- 类:仅比较实例成员和实例方法,不比较构造函数和静态成员,
private
和protected
成员必须来自相同的类 - 泛型:泛型对兼容性没有影响(这可能会带来一些潜在问题)
1 | interface Poin2D { |
readonly
用readonly
标记接口属性,表示预期不可修改。获取使用Readonly
封装一个泛型T
,表示泛型内的属性均不可修改。同样地,你可以为索引签名声明readonly
,表示所有索引元素均不可修改。还有些情况下,如果属性配置了getter
,但没有setter
也会被认为是只读的。
1 | interface Foo { |
readonly
和const
的主要不同在于,前者用来修改属性,后者用于变量。
索引签名
索引即数组或键值对的索引。TypeScript中索引类型只能是string
或number
类型。这意味着,也可以使用字面量类型作为索引类型。
1 | type Index = 'a' | 'b' | 'c'; |
在一些特殊场景下,可以同时支持string
和number
类型。
1 | interface ArrStr { |
流动的类型
typeof
可以捕获变量、类成员类型。使用typeof
在捕获一个字符串字面量时,得到的类型是字面量类型。
1 | let foo = 123; |
使用keyof
捕获一个类型的键。
ThisType
在对象字面量方法的类型定义上声明ThisType()
可以修改发放内this
的类型,这常被用在this
值被重新绑定的情况。
Tips
bind
的隐患
在lib.d.ts
中,对bind
的定义如下:
1 | bind(thisArg: any, ...argArray: any[]): any |
由于返回值是any
类型,意味着bind返回的函数将失去类型检查(最新的TS 3.2已经优化了这个问题)。
柯里化函数
用一系列箭头表示。
1 | // 一个柯里化函数 |
一些建议
- 使用继承而不是
as
来实现泛型实例化 - 使用
as
来初始化对象字面量的空对象 - 尝试使用类组织代码
- 小心使用
setter
,不要牺牲代码可读性 - 在参数名可以很好提高可读性、入参很多时,考虑让函数接受一个对象参数
Reflect Metadata
Reflect Metadata是ES7的提案,用于在声明时添加和读取元数据。Reflect Metadata的API可以用于类或类属性上,
1 | metadata('inClass', 'A') . |
因此可以通过Reflect.getMetadata
的API来获取类相关的元数据。
自定义metadatakey
可以定义自己的reflect metadata。
1 | function classDecorator(): ClassDecorator { |
可以借助Reflect Metadata的这个特点,实现诸如控制反转、依赖注入、装饰器等功能。
条件类型
1 | T extends U ? X: Y |
TypeScript 2.8的一个PR里第一次提到条件类型。条件类型主要规则如下:
- 上式表示T如果可以赋值给U,返回类型
X
,否则返回Y
- 在
U
中出现infer
时,TypeScript会去推断infer
后的类型变量(假设是V
),如果V
出在协变位置,则返回V
所有可能性的联合类型,如果V
出现在逆变位置,则返回V
所有可能性的交叉类型(参考:协变与逆变)
分布条件类型
在检查类型(extends
前的类型参数)是原始类型(即没有被泛型等封装)时,称为分布条件类型(Distributive conditional types)。在实例化为实际类型时,联合类型会被拆分开。
例如,T
实例化为A | B | C
时,T extends U ? X : Y
会被解析成(A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y)
。
1 | type FunctionPropertyNames<T> = { [K in keyof T]: T[K] extends Function ? K : never }[keyof T]; |
infer
如上文所说,infer
最初出现是用来表示extends
条件语句中待推断的类型。下文中若T满足(param: infer P) => any
类型,则推出P
类型。
1 | type ParamType<T> = T extends (param: infer P) => any ? P : T; |
infer
有下面一些常规使用场景。
1 | // 获取返回值 |
联合infer
和分布条件类型,可以实现一些骚操作,如tuple、intersection、union之间的转换。
1 | type tupleToIntersection<T> = T[number]; |
如原文列的LeetCode TypeScript面试题。借助强大的条件类型和infer
就能实现。
1 | interface Action<T> { |
TypeScript编译原理
这部分内容较为简要。
编译器源文件位于src/compiler
下,主要由以下部分组成:
- 扫描器 Scanner
- 解析器 Parser
- 绑定器 Binder
- 检查器 Checker
- 发射器 Emitter
处理流程分下面几步:
Source --扫描器--> Token流
Token流 --解析器--> AST(抽象语法树)
AST --绑定器--> Symbols
AST + 符号 --检查器--> 类型验证
AST + 检查器 --发射器--> JavaScript代码
重要文件
core.ts
TypeScript编译器使用的核心工具集types.ts
包含整个编译器使用的关键数据结构和接口system.ts
控制编译器和操作系统的所有交互
程序与抽象语法树
这里的“程序”指一个“编译上下文”。它包含SourceFile和编译选项。TypeScript有API获取SourceFile列表,每个SourceFile都是一棵抽象语法树的根节点。
节点
节点(Node)是AST的基本组成单位。Node有一些关键成员:
TextRange
标识节点在源文件的起止位置parent?: Node
标识节点在AST中的父节点- 标志(flags)和修饰符(modifier)等有助于节点遍历的成员
下面有一些常用工具函数的用法:
ts.forEachChild
用来访问任一节点的所有子节点。这个函数会根据每个节点的node.kind
判断node类型,然后再在子节点上调用cbNode。ts.SyntaxKind
是一个节点类型的常量枚举,用以表示不同的语法树节点ts.getLeadingCommentRanges
和ts.getTrailingCommentRanges
分别获取给定位置第一个换行符到token和之前的注释范围。ts.getStart
和ts.getFullStart
分别获取一个token文本开始位置和上一个重要token开始扫描的位置
1 | export const enum SyntaxKind { |
扫描器与解析器
扫描器用于读取文本,并转换为Token流。扫描器由解析器(parser.ts
)创建,为了避免创建扫描器的开销。parser.ts
创建的扫描器是单例。
扫描器scanner.ts
本身提供API给出扫描过程中的各种信息。尽管解析器创建的扫描器是单例,你仍可以使用createScanner
创建自己的扫描器,并调用setText
、setTextPos
随意扫描文件的不同位置。
解析器由程序经由CompilerHost
创建,CompileHost
通过getSourceFile
准备好待编译文件,再交由解析器处理。解析器根据内部扫描器得到的Token构造一个SourceFile下的语法树。
解析器使用parseSourceFileWorker
和parseStatements
创建根节点和其余节点。具体解析每种节点的过程写在parseXxx
中。
绑定器
绑定器主要职责是创建符号(Symbol)。符号将AST的声明节点和其他声明连接到相同实体上。绑定器会在检查器内被调用,检查器又被程序调用。
绑定器有几个重要函数:
bindSourceFile
,检查file.locals
是否定义,没有则交给内部函数bind
处理。bindSourceFile
内部还定义了许多别的内部变量,通过闭包被其他内部函数使用bind
处理任意节点绑定,先分配node.parent
,在交给bindWorker
做主要工作,之后调用bindChildren
执行子节点的绑定bindWorker
根据节点类型,委托工作给特定的bindXXX
函数完成。在bindXXX
内最常用的是createSymbol
函数
1 | function createSymbol(flags: SymbolFlags, name: string): Symbol { |
绑定器会调用addDeclarationToSymbol
绑定一个节点到符号,并把节点添加成符号的一个声明。声明就是一个有可选名字的节点。
检查器与发射器
检查器由程序初始化。在发射器中,类型检查在getDiagnostics
中发生,函数被调用时会返回一个EmitResolver
。这是一个createTypeChecker
的本地函数集合。
TypeScript有两个发射器,emitter.ts
完成TS到JavaScript,declarationEmitter.ts
为.ts
创建声明文件(.d.ts
)。
程序(Program)通过emit
函数,把工作委托给emitter.ts
的emitFiles
函数。emitFiles
中借助emitJavaScript
完成主要工作,
emitJavaScript
中有大量内部函数,之后借给emitSourceFile
发射文本,该函数设置currentSourceFile
后交给本地的emit
函数处理。在emitJavaScriptWorker
中会根据不同符号类型调用不同发射器处理。在emitJavaScript
的过程中,initializeEmitterWithSourceMaps
使用带有sourceMap的版本覆盖部分本地函数,使大多数发射器代码无需考虑SourceMap。
FAQ
类型系统的行为
首先有几个需要格外说明的:
- TypeScript使用结构化类型,即类型间的成员类型兼容即类型兼容。
- TypeScript的类型时编译时的,在运行时并没有类型信息,无法从反射或元数据中拿到。
此外有些常见问题:
- 没有setter的getter并没有体现出只读属性,这在TypeScript2.0+已修复
- 更少参数的函数可以赋值给更多参数的函数;返回值更多的函数可以复制给返回值更少的函数
- 任何类型都可以等价替代没有属性的interface
- 类型别名只是别名而已,进行类型判断时使用的是别名对应的类型
- 由于结构化类型,两个不同名但是结构相同的类型,实际上是相互兼容的,有个相关issue,但是尚没有结论
- 由于TS的类型只存在于编译时,不能用运行时的
typeof
或instanceof
判断类型。同样地,错误的TS类型转化也不会造成运行时的错误 - 重载的最后一个声明签名对签名本身没有影响,所以为了获得重载本身的行为,需要添加额外的重载
1 | function createLog(message: string): number; |
一些常见的Feature Request
- 安全的导航操作符,类似
a?.b?.c
,目前已在tc39的Stage 3阶段,将并入TS的3.7.0版本 - 代码压缩
- bind(), call(), apply()返回的函数无类型
其他问题
空类的行为很奇怪
1 | class Empty {} |
和之前提到的一样,任何内容都可以赋值给空接口。所以一般来说,永远不要声明一个没有任何属性的类,对于子类而是如此。
如何比较类
TypeScript中,类进行结构上的比较,但是对于private
和protected
属性除外。类在比较时,如果有成员是private
或protected
,它们必须来自同一个声明。
1 | class Alpha { |
class
和typeof class
的区别
1 | class MyClass { |
上面混用了类型名和类本身,在JavaScript中,类仅仅是一个函数而已。而在TypeScript中,类名表示类实例的类型。
子类的属性在constructor中会被父类同名属性覆盖
1 | class Base { |
直接原因是在子类constructor中,父类的constructor要先执行。见Stack Overflow的解释。
interface
和declare class
的区别
interface
用来声明一种类型,不会生成实际代码。declare class
用来描述一个已有类的结构
为什么我导入的模块在编译后被删除了
TypeScript默认导入的模块不包含副作用,所以会移除不用于任何表达式的模块导入。使用import 'xxx';
强制导入有副作用的模块。
tsconfig.json
- 为什么exclude中的文件仍然会被编译器选中?
- 当exclude的文件被其他include文件依赖时,仍然会被包含进来
- 除了
include
外,还有没有指定包含文件的方式files
指定文件列表- 目录中添加
///<reference path="">
引入