精读原文是 typescript 2.0-2.9 的文档:
我发现,许多写了一年以上 Typescript 开发者,对 Typescript 对理解和使用水平都停留在入门阶段。造成这个现象的原因是,Typescript 知识的积累需要 刻意练习,使用 Typescript 的时间与对它的了解程度几乎没有关系。
这篇文章精选了 TS 在 2.0-2.9
版本中最重要的功能,并配合实际案例解读,帮助你快速跟上 TS 的更新节奏。
对于 TS 内部优化的用户无感部分并不会罗列出来,因为这些优化都可在日常使用过程中感受到。
由于 Typescript 在严格模式下的许多表现都与非严格模式不同,为了避免不必要的记忆,建议只记严格模式就好了!
直接访问一个变量的属性时,如果这个变量是 undefined
,不但属性访问不到,js 还会抛出异常,这几乎是业务开发中最高频的报错了(往往是后端数据异常导致的),而 typescript 的 strict
模式会检查这种情况,不允许不安全的代码出现。
在 2.0
版本,提供了 “非空断言标志符” !.
解决明确不会报错的情况,比如配置文件是静态的,那肯定不会抛出异常,但在 2.0
之前的版本,我们可能要这么调用对象:
const config = {
port: 8000
};
if (config) {
console.log(config.port);
}
有了 2.0
提供的 “非空断言标志符”,我们可以这么写了:
console.log(config!.port);
在 2.8
版本,ts 支持了条件类型语法:
type TypeName<T> = T extends string ? "string"
当 T 的类型是 string 时,TypeName 的表达式类型为 "string"。
这这时可以构造一个自动 “非空断言” 的类型,把代码简化为:
console.log(config.port);
前提是框架先把 config
指定为这个特殊类型,这个特殊类型的定义如下:
export type PowerPartial<T> = {
[U in keyof T]?: T[U] extends object ? PowerPartial<T[U]> : T[U]
};
也就是 2.8
的条件类型允许我们在类型判断进行递归,把所有对象的 key 都包一层 “非空断言”!
此处灵感来自 egg-ts 总结
当一个函数无法执行完,或者理解为中途中断时,TS 2.0
认为它是 never
类型。
比如 throw Error
或者 while(true)
都会导致函数返回值类型时 never
。
和 null
undefined
特性一样,never
等于是函数返回值中的 null
或 undefined
。它们都是子类型,比如类型 number
自带了 null
与 undefined
这两个子类型,是因为任何有类型的值都有可能是空(也就是执行期间可能没有值)。
这里涉及到很重要的概念,就是预定义了类型不代表类型一定如预期,就好比函数运行时可能因为 throw Error
而中断。所以 ts 为了处理这种情况,将 null
undefined
设定为了所有类型的子类型,而从 2.0
开始,函数的返回值类型又多了一种子类型 never
。
TS 2.2
支持了 object
类型, 但许多时候我们总把 object
与 any
类型弄混淆,比如下面的代码:
const persion: object = {
age: 5
};
console.log(persion.age); // Error: Property 'age' does not exist on type 'object'.
这时候报错会出现,有时候闭个眼改成 any
就完事了。其实这时候只要把 object
删掉,换成 TS 的自动推导就搞定了。那么问题出在哪里?
首先 object
不是这么用的,它是 TS 2.3
版本中加入的,用来描述一种非基础类型,所以一般用在类型校验上,比如作为参数类型。如果参数类型是 object
,那么允许任何对象数据传入,但不允许 3
"abc"
这种非对象类型:
declare function create(o: object | null): void;
create({ prop: 0 }); // 正确
create(null); // 正确
create(42); // 错误
create("string"); // 错误
create(false); // 错误
create(undefined); // 错误
而一开始 const persion: object
这种用法,是将能精确推导的对象类型,扩大到了整体的,模糊的对象类型,TS 自然无法推断这个对象拥有哪些 key
,因为对象类型仅表示它是一个对象类型,在将对象作为整体观察时是成立的,但是 object
类型是不承认任何具体的 key
的。
TS 在 2.0
版本支持了 readonly
修饰符,被它修饰的变量无法被修改。
在 TS 2.8
版本,又增加了 -
与 +
修饰修饰符,有点像副词作用于形容词。举个例子,readonly
就是 +readonly
,我们也可以使用 -readonly
移除只读的特性;也可以通过 -?:
的方式移除可选类型,因此可以延伸出一种新类型:Required<T>
,将对象所有可选修饰移除,自然就成为了必选类型:
type Required<T> = { [P in keyof T]-?: T[P] };
也是 TS 2.0
版本中,我们可以定制 this
的类型,这个在 vue
框架中尤为有用:
function f(this: void) {
// make sure `this` is unusable in this standalone function
}
this
类型是一种假参数,所以并不会影响函数真正参数数量与位置,只不过它定义在参数位置上,而且永远会插队在第一个。
简单来说,就是模块名可以用 *
表示任何单词了:
declare module "*!text" {
const content: string;
export default content;
}
它的类型可以辐射到:
import fileContent from "./xyz.txt!text";
这个特性很强大的一个点是用在拓展模块上,因为包括 tsconfig.json
的模块查找也支持通配符了!举个例子一下就懂:
最近比较火的 umi
框架,它有一个 locale
插件,只要安装了这个插件,就可以从 umi/locale
获取国际化内容:
import { locale } from "umi/locale";
其实它的实现是创建了一个文件,通过 webpack.alias
将引用指了过去。这个做法非常棒,那么如何为它加上类型支持呢?只要这么配置 tsconfig.json
:
{
"compilerOptions": {
"paths": {
"umi/*": ["umi", "<somePath>"]
}
}
}
将所有 umi/*
的类型都指向 <somePath>
,那么 umi/locale
就会指向 <somePath>/locale.ts
这个文件,如果插件自动创建的文件名也恰好叫 locale.ts
,那么类型就自动对应上了。
TS 在 2.x
支持了许多新 compileOptions
,但 skipLibCheck
实在是太耀眼了,笔者必须单独提出来说。
skipLibCheck
这个属性不但可以忽略 npm 不规范带来的报错,还能最大限度的支持类型系统,可谓一举两得。
拿某 UI 库举例,某天发布的小版本 d.ts
文件出现一个漏洞,导致整个项目构建失败,你不再需要提 PR 催促作者修复了!skipLibCheck
可以忽略这种报错,同时还能保持类型的自动推导,也就是说这比 declare module "ui-lib"
将类型设置为 any
更强大。
TS 2.1
版本可谓是针对类型操作革命性的版本,我们可以通过 keyof
拿到对象 key 的类型:
interface Person {
name: string;
age: number;
}
type K1 = keyof Person; // "name" | "age"
基于 keyof
,我们可以增强对象的类型:
type NewObjType<T> = { [P in keyof T]: T[P] };
Tips:在 TS 2.8
版本,我们可以以表达式作为 keyof
的参数,比如 keyof (A & B)
。
Tips:在 TS 2.9
版本,keyof
可能返回非 string
类型的值,因此从一开始就不要认为 keyof
的返回类型一定是 string
。
NewObjType
原封不动的将对象类型重新描述了一遍,这看上去没什么意义。但实际上我们有三处拓展的地方:
- 左边:比如可以通过
readonly
修饰,将对象的属性变成只读。 - 中间:比如将
:
改成?:
,将对象所有属性变成可选。 - 右边:比如套一层
Promise<T[P]>
,将对象每个key
的value
类型覆盖。
基于这些能力,我们拓展出一系列上层很有用的 interface
:
- Readonly。把对象 key 全部设置为只读,或者利用
2.8
的条件类型语法,实现递归设置只读。 - Partial。把对象的 key 都设置为可选。
- Pick<T, K>。从对象类型 T 挑选一些属性 K,比如对象拥有 10 个 key,只需要将 K 设置为
"name" | "age"
就可以生成仅支持这两个 key 的新对象类型。 - Extract<T, U>。是 Pick 的底层 API,直到
2.8
版本才内置进来,可以认为 Pick 是挑选对象的某些 key,Extract 是挑选 key 中的 key。 - Record<K, U>。将对象某些属性转换成另一个类型。比较常见用在回调场景,回调函数返回的类型会覆盖对象每一个 key 的类型,此时类型系统需要
Record
接口才能完成推导。 - Exclude<T, U>。将 T 中的 U 类型排除,和 Extract 功能相反。
- Omit<T, K>(未内置)。从对象 T 中排除 key 是 K 的属性。可以利用内置类型方便推导出来:
type Omit<T, K> = Pick<T, Exclude<keyof T, K>>
- NonNullable。排除
T
的null
与undefined
的可能性。 - ReturnType。获取函数
T
返回值的类型,这个类型意义很大。 - InstanceType。获取一个构造函数类型的实例类型。
以上类型都内置在 lib.d.ts 中,不需要定义就可直接使用,可以认为是 Typescript 的 utils 工具库。
单独拿 ReturnType
举个例子,体现出其重要性:
Redux 的 Connect 第一个参数是 mapStateToProps
,这些 Props 会自动与 React Props 聚合,我们可以利用 ReturnType<typeof currentMapStateToProps>
拿到当前 Connect 注入给 Props 的类型,就可以打通 Connect 与 React 组件的类型系统了。
TS 2.3
版本做了许多对 Generators 的增强,但实际上我们早已用 async/await 替代了它,所以 TS 对 Generators 的增强可以忽略。需要注意的一块是对 for..of
语法的异步迭代支持:
async function f() {
for await (const x of fn1()) {
console.log(x);
}
}
这可以对每一步进行异步迭代。注意对比下面的写法:
async function f() {
for (const x of await fn2()) {
console.log(x);
}
}
对于 fn1
,它的返回值是可迭代的对象,并且每个 item 类型都是 Promise 或者 Generator。对于 fn2
,它自身是个异步函数,返回值是可迭代的,而且每个 item 都不是异步的。举个例子:
function fn1() {
return [Promise.resolve(1), Promise.resolve(2)];
}
function fn2() {
return [1, 2];
}
在这里顺带一提,对 Array.map
的每一项进行异步等待的方法:
await Promise.all(
arr.map(async item => {
return await item.run();
})
);
如果为了执行顺序,可以换成 for..of
的语法,因为数组类型是一种可迭代类型。
了解这个之前,先介绍一下 TS 2.0
之前就支持的函数类型重载。
首先 JS 是不支持方法重载的,Java 是支持的,而 TS 类型系统一定程度在对标 Java,当然要支持这个功能。好在 JS 有一些偏方实现伪方法重载,典型的是 redux 的 createStore
:
export default function createStore(reducer, preloadedState, enhancer) {
if (typeof preloadedState === "function" && typeof enhancer === "undefined") {
enhancer = preloadedState;
preloadedState = undefined;
}
}
既然 JS 有办法支持方法重载,那 TS 补充了函数类型重载,两者结合就等于 Java 方法重载:
declare function createStore(
reducer: Reducer,
preloadedState: PreloadedState,
enhancer: Enhancer
);
declare function createStore(reducer: Reducer, enhancer: Enhancer);
可以清晰的看到,createStore
想表现的是对参数个数的重载,如果定义了函数类型重载,TS 会根据函数类型自动判断对应的是哪个定义。
而在 TS 2.3
版本支持了泛型默认参数,可以某些场景减少函数类型重载的代码量,比如对于下面的代码:
declare function create(): Container<HTMLDivElement, HTMLDivElement[]>;
declare function create<T extends HTMLElement>(element: T): Container<T, T[]>;
declare function create<T extends HTMLElement, U extends HTMLElement>(
element: T,
children: U[]
): Container<T, U[]>;
通过枚举表达了范型默认值,以及 U 与 T 之间可能存在的关系,这些都可以用泛型默认参数解决:
declare function create<T extends HTMLElement = HTMLDivElement, U = T[]>(
element?: T,
children?: U
): Container<T, U>;
尤其在 React 使用过程中,如果用泛型默认值定义了 Component
:
.. Component<Props = {}, State = {}> ..
就可以实现以下等价的效果:
class Component extends React.PureComponent<any, any> {
//...
}
// 等价于
class Component extends React.PureComponent {
//...
}
TS 从 2.4
版本开始支持了动态 Import,同时 Webpack4.0 也支持了这个语法(在 精读《webpack4.0%20 升级指南》 有详细介绍),这个语法就正式可以用于生产环境了:
const zipUtil = await import("./utils/create-zip-file");
准确的说,动态 Import 实现于 webpack 2.1.0-beta.28,最终在 TS
2.4
版本获得了语法支持。
在 TS 2.9
版本开始,支持了 import()
类型定义:
const zipUtil: typeof import('./utils/create-zip-file') = await import('./utils/create-zip-file')
也就是 typeof
可以作用于 import()
语法,而不真正引入 js 内容。不过要注意的是,这个 import('./utils/create-zip-file')
路径需要可被推导,比如要存在这个 npm 模块、相对路径、或者在 tsconfig.json
定义了 paths
。
好在 import
语法本身限制了路径必须是字面量,使得自动推导的成功率非常高,只要是正确的代码几乎一定可以推导出来。好吧,所以这也从另一个角度推荐大家放弃 require
。
从 Typescript 2.4
开始,支持了枚举类型使用字符串做为 value:
enum Colors {
Red = "RED",
Green = "GREEN",
Blue = "BLUE"
}
笔者在这提醒一句,这个功能在纯前端代码内可能没有用。因为在 TS 中所有 enum
的地方都建议使用 enum
接收,下面给出例子:
// 正确
{
type: monaco.languages.types.Folder;
}
// 错误
{
type: 75;
}
不仅是可读性,enum
对应的数字可能会改变,直接写 75
的做法存在风险。
但如果前后端存在交互,前端是不可能发送 enum
对象的,必须要转化成数字,这时使用字符串作为 value 会更安全:
enum types {
Folder = "FOLDER"
}
fetch(`/api?type=${monaco.languages.types.Folder}`);
最典型的是 chart 图,经常是这样的二维数组数据类型:
[[1, 5.5], [2, 3.7], [3, 2.0], [4, 5.9], [5, 3.9]]
一般我们会这么描述其数据结构:
const data: number[][] = [[1, 5.5], [2, 3.7], [3, 2.0], [4, 5.9], [5, 3.9]];
在 TS 2.7
版本中,我们可以更精确的描述每一项的类型与数组总长度:
interface ChartData extends Array<number> {
0: number;
1: number;
length: 2;
}
自动类型推导有两种,分别是 typeof
:
function foo(x: string | number) {
if (typeof x === "string") {
return x; // string
}
return x; // number
}
和 instanceof
:
function f1(x: B | C | D) {
if (x instanceof B) {
x; // B
} else if (x instanceof C) {
x; // C
} else {
x; // D
}
}
在 TS 2.7
版本中,新增了 in
的推导:
interface A {
a: number;
}
interface B {
b: string;
}
function foo(x: A | B) {
if ("a" in x) {
return x.a;
}
return x.b;
}
这个解决了 object
类型的自动推导问题,因为 object
既无法用 keyof
也无法用 instanceof
判定类型,因此找到对象的特征吧,再也不要用 as
了:
// Bad
function foo(x: A | B) {
// I know it's A, but i can't describe it.
(x as A).keyofA;
}
// Good
function foo(x: A | B) {
// I know it's A, because it has property `keyofA`
if ("keyofA" in x) {
x.keyofA;
}
}
Typescript 2.0-2.9
文档整体读下来,可以看出还是有较强连贯性的。但我们可能并不习惯一步步学习新语法,因为新语法需要时间消化、同时要连接到以往语法的上下文才能更好理解,所以本文从功能角度,而非版本角度梳理了 TS 的新特性,比较符合学习习惯。
另一个感悟是,我们也许要用追月刊漫画的思维去学习新语言,特别是 TS 这种正在发展中,并且迭代速度很快的语言。
如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。