42

1 引言

精读原文是 typescript 2.0-2.9 的文档:

2.0-2.82.9 草案.

我发现,许多写了一年以上 Typescript 开发者,对 Typescript 对理解和使用水平都停留在入门阶段。造成这个现象的原因是,Typescript 知识的积累需要 刻意练习,使用 Typescript 的时间与对它的了解程度几乎没有关系。

这篇文章精选了 TS 在 2.0-2.9 版本中最重要的功能,并配合实际案例解读,帮助你快速跟上 TS 的更新节奏。

对于 TS 内部优化的用户无感部分并不会罗列出来,因为这些优化都可在日常使用过程中感受到。

2 精读

由于 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 总结

增加了 never object 类型

当一个函数无法执行完,或者理解为中途中断时,TS 2.0 认为它是 never 类型。

比如 throw Error 或者 while(true) 都会导致函数返回值类型时 never

null undefined 特性一样,never 等于是函数返回值中的 nullundefined它们都是子类型,比如类型 number 自带了 nullundefined 这两个子类型,是因为任何有类型的值都有可能是空(也就是执行期间可能没有值)。

这里涉及到很重要的概念,就是预定义了类型不代表类型一定如预期,就好比函数运行时可能因为 throw Error 而中断。所以 ts 为了处理这种情况,null undefined 设定为了所有类型的子类型,而从 2.0 开始,函数的返回值类型又多了一种子类型 never

TS 2.2 支持了 object 类型, 但许多时候我们总把 objectany 类型弄混淆,比如下面的代码:

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] };

可以定义函数的 this 类型

也是 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]>,将对象每个 keyvalue 类型覆盖。

基于这些能力,我们拓展出一系列上层很有用的 interface

  • Readonly<T>。把对象 key 全部设置为只读,或者利用 2.8 的条件类型语法,实现递归设置只读。
  • Partial<T>。把对象的 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>。排除 Tnullundefined 的可能性。
  • ReturnType<T>。获取函数 T 返回值的类型,这个类型意义很大。
  • InstanceType<T>。获取一个构造函数类型的实例类型。
以上类型都内置在 lib.d.ts 中,不需要定义就可直接使用,可以认为是 Typescript 的 utils 工具库。

单独拿 ReturnType 举个例子,体现出其重要性:

Redux 的 Connect 第一个参数是 mapStateToProps,这些 Props 会自动与 React Props 聚合,我们可以利用 ReturnType<typeof currentMapStateToProps> 拿到当前 Connect 注入给 Props 的类型,就可以打通 Connect 与 React 组件的类型系统了。

对 Generators 和 async/await 的类型定义

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 {
  //...
}

动态 Import

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

Enum 类型支持字符串

从 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: string[][] = [[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;
  }
}

4 总结

Typescript 2.0-2.9 文档整体读下来,可以看出还是有较强连贯性的。但我们可能并不习惯一步步学习新语法,因为新语法需要时间消化、同时要连接到以往语法的上下文才能更好理解,所以本文从功能角度,而非版本角度梳理了 TS 的新特性,比较符合学习习惯。

另一个感悟是,我们也许要用追月刊漫画的思维去学习新语言,特别是 TS 这种正在发展中,并且迭代速度很快的语言。

5 更多讨论

讨论地址是:精读《Typescript2.0 - 2.9》 · Issue #85 · dt-fe/weekly

如果你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。


黄子毅
7k 声望9.5k 粉丝