TS的开发经验总结,主要包含以下几个方面的内容

  • 编译工具的特点与比较
  • 语言特性的选择与使用
  • 模块系统与自定义类型
  • 其他使用技巧

译自:TypeScriptをプロダクト開発に使う上でのベストプラクティスと心得 | @jagaapple

前言

  • 主要是有关Web应用开发的内容,也许与工具库开发的场景有所不同。
  • 所说的“规模”是一个比较模糊的词语,描述大量的开发人员/代码量/用户等
  • 由于笔者常用React/Redux,在多处示例中使用了它们,但内容本身并不依赖它们
  • 内容基于3.7.5版本的TypeScript所写

编译器的特性

TypeScript(以下简称TS)是无法直接在浏览器上运行的,需要先将其编译成JavaScript。这一步所用到的编译器首先要说的是Microsoft的tsc工具,也可以使用webpack和Babel进行编译。

编译器 优点 缺点 备注
tsc Microsoft官方工具/对应最新TS版本/具有全部特性 路径别名问题/与旧版ES兼容性较差 可以使用–watch选项实现部分编译
webpack(ts-loader) webpack loader/具有全部特性 大型项目中执行较慢 有性能优化的方法
Babel 擅长向旧版ES语法的转换 不会进行类型检查/编译速度较快 存在Re-exports问题

虽然都是使用TS的场景,不过不同编译器对TS语言特性的使用有不同的限制,也可能产生其他麻烦。考虑到每种编译器的特点,来看看它们存在的一些问题。

tsc

tsc是Microsoft的TS官方编译器,包含在使用TS时所安装的typescript包中。

由于它是官方的工具,因此其功能会对应最新版本的TS,可以使用语言的所有特性。但它不是打包工具,因此无法自定义minify与chunk等设置,并且存在未解决的路径别名(Path Alias)问题和较差的旧版ES语法兼容性问题。

可以通过tsconfig.json中的compilerOptions.target选项来设置编译目标的ECMAScript语法版本,会生成类似使用Babel的向后兼容代码。不过编译成指定版本的准确性不如Babel,直到2.1才支持将Async/Await转换成ES5/ES3的语法;在撰写本文时的最新版本3.7中,甚至会将globalThis原模原样的输出到文件中…诸如此类,感觉纯粹使用tsc来开发项目还是有一定难度的。

由于这样的原因,大多数项目中都会选择使用webpack与Babel进行编译,而将tsc作为TS的类型检查工具或在开发工具库时使用。

路径别名与tsc的未解決问题

在TS的ESM(ECMAScript Modules)语法中,除了导入node_modules等外部包外,必须使用相对路径来描述导入的模块。在从React开始的组件化UI开发方法成为主流的今天,开发中会遇到很多有大量层级嵌套很深的目录,如../../../../foo,在这样的目录下操作路径是很令人头秃的一件事。

路径别名是一种为项目中指定路径设置别名的功能,通过tsconfig.jsoncompilerOptions.paths来设置,它的目标路径会以compilerOptions.baseUrl属性的值为基础路径。

{
  "compilerOptions": {
    "baseUrl": "./src",
    "paths": {
      "@models/*": ["./models/*"]
    }
  }
}

如上在别名中使用通配符*,可以通过@models引用/src/models中的任何项目文件。我的个人习惯是@加上路径别名。如果别名与包名冲突时会很难通过import语法读取到该包,因此最好使用与包名不同的命名规则。

// /src/components/home/header/account/navigation/list.component.tsx
- import { UserModel } from "../../../../../models/user.model";
+ import { UserModel } from "@models/user.model";

它不会导致TypeScript语法错误或引用错误,并且在VS Code等编辑器中路径的代码跳转功能与使用常规路径时同样可用。但使用tsc编译包含路径别名的代码时会原封不动地输出别名,从而导致运行时错误。

// /src/components/home/header/account/navigation/list.component.js
"use strict";
exports.__esModule = true;
var user_model_1 = require("@models/user.model"); // `require`的文件不存在
console.log(user_model_1.UserModel);

很多开发者都怀疑作为官方编译器的tsc不会解决路径别名的这个问题,实际上在TS仓库中已经存在针对这个问题的Issue,并且进行了泛泛的讨论,最终因为「路径解析不是tsc所负责的功能」的理由而被关闭。

因此,为了解决路径别名的解析问题,需要使用之后介绍的webpack等工具,或使用tsconfig-paths等库来实现。

webpack(ts-loader)

TypeStrong/ts-loader: TypeScript loader for webpack

虽然人们对于webpack的评价褒贬不一,但毫无疑问的是在开发生产级别的Web前端项目方面是一个必需品,甚至说是事实标准(de facto standard)也不为过。webpack也开发了编译TS的loader,其中最具代表性的就是ts-loader。

通过在ts-loader中将transpileOnly选项设为true来跳过类型检查,从而得到较高的执行性能。若使用的是webpack4,则将产生会在后面介绍的Re-exports问题,为了避免大量Warning警告信息需要进行一些必要的设置。

awesome-typescript-loader

较早的ts-loader性能较差,编译TS非常耗时,因此出现了awesome-typescript-loader这个loader。不过目前这个库已经不再维护了,GitHub仓库已处于归档状态。。

现在ts-loader的性能也有所改善了,并且依旧提供前述的transpileOnly选项,因此已经停止开发的awesome-typescript-loader应该没有什么优势了。不过包含Storybook在内的一些库仍然推荐使用awesome-typescript-loader。

类型检查方式与fork-ts-checker-webpack-plugin

如前所述,ts-loader中可以通过设置transpileOnly选项,不执行类型检查以此来提高性能。虽然VS Code等编辑器中会进行实时的类型检查,但还是推荐在构建时进行类型检查。

在类型检查的方法中包括使用tsc。尽管tsc本质是个编译器,不过通过设置--noEmit选项可以阻止编译文件的生成,即仅执行类型检查。笔者在其项目中将该操作放在npm-run-scipts的lint命令中。

{
  "scripts": {
    "lint": "tsc --noEmit"
  }
}

另外一种方法就是在运行时阶段执行类型检查,比如webpack插件fork-ts-checker-webpack-plugin 。由于检查是在独立的进程中执行的,因此不会影响ts-loader等的编译处理,在React框架Next.js的9版本中使用了这个插件。

Babel

Babel · The compiler for next generation JavaScript

Babel在2018年夏天发布的版本7中支持TS编译,需要设置@babel/preset-typescript。

遵循最新ES规范的JS项目中基本都使用了Babel,仅需要在preset设置中添加一个预设项即可使用TS。这仅仅是Babel的一个扩展功能,除了用在编译JS外没有其他作用。

TypeScript特性的使用限制

Babel中的TS编译限制了一部分TS特性的使用,比如Const Enums(常量枚举)。无法使用这个特性,自然也无法编译使用这个特性的库。

// Babel无法编译Const-Enums
export const enum Status {
       ^^^^^
  // 'const' enums are not supported.
  Published,
  Draft,
}

笔者比较中意的ts-key-enum库中使用了TS的Enums来处理event.key值的引用,在版本3中变为了使用Const Enums来实现。由于笔者在其项目中使用了Babel来编译TS,因此无法使用利用了Const Enums的ts-key-enum,只能继续使用版本2。

实际上@babel/preset-typescript所执行的仅仅是从TS文件中移除类型信息,不仅不会执行类型检查,还会产生之后所讲的Re-exports问题。

通过index文件简化模块引用

对于中等以上规模的应用,每个文件中的import语句和从其他文件中引用的模块数量会明显增多。因此,为了更方便地从外部引用某个目录内存在的文件,一般会创建一个index.ts的索引文件,在该文件中对模块进行再次导出,即执行Re-exports。

// /src/components/header/index.ts
// Re-exports每个组件
export { LogoComponent } from "./logo.component";
export { NavigationComponent } from "./navigation.component";

在ESM与CommonJS中,当导入目标的路径以目录名为结尾时,会引用目标目录中的index.js或index.ts文件,可以像如下的方式根据索引文件中的描述清楚的引入模块。

// Before
import { LogoComponent } from "./components/header/logo.component";
import { NavigationComponent } from "./components/header/navigation.component";

// After
import { LogoComponent, NavigationComponent } from "./components/header";

不仅路径的描述更加简洁,还可以使用一个import语句来引入多个模块,从而减少了文件中模块描述的数量。此外,添加禁止引用未在index.ts中Re-exports的文件的规则,可以减少模块相互之间的依赖与影响。

Re-exports问题

不管是设置webpack的transpileOnly选项还是使用Babel的@babel/preset-typescript,Re-exports总会产生大量的Warning警告信息。具体是在Type Alias(类型别名)与Interfaces(接口)等类型信息的Re-exports时会产生的。

// Babel等删除类型信息的例子
- const num: number = 123;
+ const num = 123;
- const getUserName: string = (user: userModel.UserModel) => {
+ const getUserName = (user) => {

编译操作仅仅从TS文件删除了类型信息,不会进行类型检查和类型解析。TS作为JS的超集,如果把类型信息都删了的话与JS无异,然而只有经过这种处理才能进行编译。

// /src/models/user.model.ts
export type UserModel = {
  id: number;
};
export const createUser = (json: JSON) => ({
  id: json.id,
});

// /src/models/index.ts
export { UserModel, createUser } from "./user.model";

考虑下上面这种情况:user.model.ts中使用类型别名来定义模块(UserModel),并在同一级目录下的Index.ts中进行了Re-exports。

// /src/models/user.model.ts
/* 类型信息会被删除
export type UserModel = {
  id: number;
};
*/
export const createUser = (json: JSON) => ({
  id: json.id,
});

// /src/models/index.ts
/* 由于已经删除了UserModel,因此无法执行Re-export */
export { UserModel, createUser } from "./user.model";

在仅会删除类型信息的Babel等编译器中,首先会删除user.model.ts的类型别名UserModel,这样一来,在index.ts中执行的Re-exports就会因引用了已经不存在的UerModel而发出警告。

想要根本的解决这个问题,需要让Babel等工具执行严格的类型检查与类型解析,但考虑到Babel的职责与其性能,不太可能会采取这种方法。

使用webpack-filter-warnings-plugin可以避免警告的产生,除此之外,无需任何设置即可解决的唯一方法是使用import * as来一次性导入所有模块。

// /src/models/index.ts
// 删除UserModel了后,由于未指明Re-exports的目标,因此不会产生警告
import * as userModel from "./user.model";

export { userModel }; // 可以引用`userModel.UserModel` `userModel.createUser`

在tsc的类型检查与VS Code的编辑检查时为了避免这个Re-exports问题,需要将tsconfig.json中的compilerOptions.isolatedModules选项设为true。这样一来,当Re-exports类型对象时,会被当做类型错误处理。

// /src/models/index.ts
// compilerOptions.isolatedModules: true
export { UserModel, createUser } from "./user.model";
         ^^^^^^^^^
/* Cannot re-export a type when the '--isolatedModules' flag is provided.ts(1205) */

总结以下就是,使用Babel等不执行类型检查的工具编译TS时注意以下两点:

  1. 不要显式地Re-exports(包括Name Exports)类型定义
  2. 将compilerOptions.isolatedModules设置为true

使类型引用规范化

由于TS中可以引用类型定义中的某些类型信息,因此在类型定义的上下文中,应尽可能的规范化使用类型引用。

比如说,在笔者的项目中数据模型一般使用类型别名定义的,若要为这个模型的属性或函数提供类型信息,需要引用模型的属性类型来而不是通过标准类型名称。

// /src/models/user.model.ts
export type UserModel = {
  id: number;
  name: string;
  email: string;
};
// /src/components/users/user.component.tsx
import { userModel } from "@models/index";

type Props = {
  // 不用写`name: string;`
  name: userModel.UserModel["name"];
}

export const UserComponent = (props: Props) => (
  <div>
    {props.name}
  </div>
)

这样做尽管需要写一些额外的import语句,不过有下面两个好处:

  1. 当类型信息变化时,影响范围是清晰明了的,且得益于类型检查
  2. 可以跳转到相关类型的代码

项目越复杂越会受益于这两个点。

如上面的例子所示,UserComponent是用于显示与UserModel相关的用户信息的函数(组件),可以从中直接跳转到数据模型的定义中,在处理视图时方便所描述的数据。

不要使用Enums

尽管TS中存在Enums枚举类型,但其用例较少。就算在2.4版本中添加了可以包含字符串类型值的String Enums,它的使用还是完全可以被对象来替代。

enum Status {
  Published = "published",
  Draft = "draft",
}

// 上面的String Enums可以使用Object来替代
const status = {
  Published: "published",
  Draft: "draft",
};

使用Object而非Enums的理由如下:

  1. 如前所述,Babel等无法使用Const Enums
  2. 可将任何值作为Enums值
  3. 调用以Enums为参数的函数时,每次都需要用import引用该Enums
  4. 轻松检查外部输入值是否为Enums成员值

可将任何值作为Enums值

type Status = {
  published: "published",
  invisible: "draft" | "deleted",
};

const status: Status = {
  published: "published",
  invisible: "deleted",
};

Enums中的值仅能使用数字和字符串类型,而Object则可以使用布尔值和通过Type Annotations组合出的联合类型。

调用以Enums为参数的函数时,每次都需要用import引用该Enums

// 使用Enums的场景
// status.ts
enum Status {
  Published,
  Draft,
}

// func.ts
export const updateStatus(status: Status) = () => ...;

// main.ts
import { Status } from "./status";
import { updateStatus } from "./func";

updateStatus(Status.Published);

当调用以Enums为参数的函数时,在输入实参时需要传入在Enums中指定的键,因此还必须引入该Enums。如果像Swift那样直接使用.Published这种描述的键名同时还具备类型推断的话,应该就更容易使用了吧(译者注:在Swift中,一旦变量的类型被推断或声明为某一枚举成员,就可以简单的使用一个点语法,将它设置为相同枚举类型的不同的值)。

// 使用Object的场景
// status.ts
const status = {
  published: "published",
  draft: "draft",
};

// func.ts
export const updateStatus(status: keyof typeof status) () => ...;

// main.ts
updateStatus("published"); // 看着像硬编码,但这里的类型检查是有效的
updateStatus("foo"); 
             ^^^^^
/* Argument of type '"foo"' is not assignable to parameter of type '"published" | "draft"'.ts(2345) */

轻松检查外部输入值是否为Enums成员值

// 使用Enums的场景
const checkValidStatus = (value: string): value is Status => {
  if (value === Status.Published) return true;
  if (value === Status.Draft) return true;
  ...

  return false;
};

不管是传入的JSON对象还是用户输入的数据,使用Object都可以轻松的验证外部输入的值。

由于无法在循环中依次引用每个Enums中的键值(无迭代器),因此如果要判断Enums成员中是否包含某个值,只能一个一个地去比较。

// 使用Object的场景
const checkValidStatus = (value: string): value is keyof typeof status =>
  Object.values(status).includes(value);

使用Object时可以利用键或值的循环实现,如果用Array.prototype.includes的话一行就可以搞定了。

利用TypeScript/框架内置的标准类型

不管是TS语言还是React等框架,均提供了用于开发的标准类型。虽然自己也可以定义类型,不过最好还是使用经过充分测试并且作为开发者之间公共知识的标准类型。

比如使用React时,@types/react提供了一些类型定义,可以使用其中的ComponentProps类型从组件中提取其Props类型。

// 不使用ComponentProps类型的场景
// foo.comopnent.tsx
export type Props = { ... };
export const FooComponent = (props: Props) => ...;

// other.component.tsx
/* 类型名称有冲突需要重命名下 */
import { Props as FooComponentProps, FooComponent } from "./foo.component";

type Props = { ... } & Pick<FooComponentProps, "xxx">;
export const OtherComponent = (props: Props) => (
  ...
  <FooComponent xxx={props.xxx} />
  ...
);
// 使用ComponentProps类型的场景
// foo.comopnent.tsx
type Props = { ... };
export const FooComponent = (props: Props) => ...;

// other.component.tsx
import { FooComponent } from "./foo.component";

type Props = { ... } & Pick<React.ComponentProps<typeof FooComponent>, "xxx">;
export const OtherComponent = (props: Props) => (
  ...
  <FooComponent xxx={props.xxx} />
  ...
);

经常在React的项目中看到形如export type Props的代码,这里如果使用ComponentProps来提取Props的话会更加方便。

使用类型别名而不是Interface

对于刚接触TS的人来说Interface与类型别名看起来没什么区别,现在的应用开发中Interface的使用应该比较少了。

两者均可以用于实现class的interface,并且与类型相关的错误信息也是一致的。在老版本的TS中Interface较易使用,而在目前的版本中类型别名则更为方便。

类型别名中不仅可以使用联合类型(|),使用交叉类型(&)实现类型合并的场景也是很多的。

下面是笔者在项目中的应用:在react与react-redux中定义Container Component的例子。

// user-information.component.tsx
// 使用类型别名来定义Props
type Props = {
  user: userModel.UserModel;
  updateUser: (name: userModel.UserModel["name"]) => void;
  updateRequestStatus: RequestStatus;
};
export const UserInformationComponent = (props: Props) => ...;
// user-information.container.ts
import { UserInformationComponent } from "./user-information.component";

type Props = React.ComponentProps<typeof UserInformationComponent>;
type StateProps = Pick<Props, "updateRequestStatus">;
type DispatchProps = Pick<Props, "updateUser">;
type OwnProps = Omit<Props, keyof (StateProps & DispatchProps)>;

export const UserInformationContainer = connect(
  (state): StateProps => ({ ... }),
  (dispatch, props: OwnProps): DispatchProps = > ({ ... }),
)(UserInformationComponent);

不使用TypeScript独有的模块加载方式(TSM)

// module.ts
export = 123;

// main.ts
import number = require("./module");

先说下结论:不要使用像上面那样的TS独有的模块加载方式。

ESM与CommonJS的Default Exports/Imports

不管是工具库还是服务端的Node.js,现在开发Web前端应用的话基本上都是使用ESM的模块方案。

最初JS是没有自己的模块管理方案的,经过不断的发展产生了两种主流的模块方案:ESM(ECMAScript Modules)和CommonJS。CommonJS作为早期的模块管理方案诞生于Node.js中,被使用在服务端Node.js及工具库中,之后ECMAScript推出了ESM方案,未来ESM的使用应该会越来越广泛。

ESM与CommonJS的模块系统间不互相兼容,此外,Node.js中使用的是严格遵循参考CommonJS规范的模块系统,而不是CommonJS本身。

CommonJS中是这样定义的:导出的目标是具有属性的对象。它在进行Default Exports时习惯上设置一个default属性值。 而Node.js会将该值传递给module.exports本身,当Default Exports时会处理该值。据说相同的CommonJS平台Default Exports的处理方式也有所不同。

// Node.js的Default Exports
module.exports = 123;

// CommonJS的Default Exports
module.exports.default = 123;

// 在Node.js中实现Default Imports时,必须要显式地引用`module.exports.default`
/* `module.exports = 123` 的情况 */
require("./module"); // 123

/* `module.exports.default = 123` 的情况 */
require("./module"); // { default: 123 }
require("./module").default; // 123

ESM的Default Exports今后或许会成为事实标准,其定义为使用export default xxx导出的值是具有default属性的对象,并且使用import xxx from导入的值是引用导出值的default属性。换句话说,与Node.js使用的module.exports = xxx等导出方法刚好是相反的(译者注:Node是默认导出时不带default属性,而导入时使用default)。

// 以下两种方式均符合ESM的Default Exports规范
export default 123;
module.exports.default = 123;

// 遵循ESM的Default Imports规范,使用下面的方式读取上面导出的值
import number from "./xxx"; // 123

TypeScript Modules

不管是使用Node.js的module.exports = xxx写法,还是遵循ESM规范使用Default Exports,Babel从版本6开始会在Default Imports时引用导出值本身,自动判断是否引用default属性。

但与Babel不同,TypeScript的Default Imports是遵循ESM规范的,并不支持Node.js的default Exports,而是准备了TS自己的模块系统TSM(TypeScript Modules,笔者自己的起名) 。以下是使用方法:

// module.ts
/* 等价于`module.exports = 123` */
export = 123;

// main.ts
import number = require("./module"); /* 不用通过`default`属性引用 */

对于这种写法,可以在使用了Node.js Default Exports的库中提供类型定义时使用,而在开发应用时不应该使用,应该使用ESM规范的写法。不过由于TSM的export = xxx与ESM不兼容,如果想在应用中使用利用了TSM的库的话,可以使用ESM的导入方法来引入吗?

结论是可以的。在TS2.7及更高的版本中,可以使用ESM的导入语法来引入TSM形式的导出模块。TS2.7提供了compilerOptions.esModuleInterop选项,将其设置为true可以启用这个特性(默认为true)

// compilerOptions.esModuleInterop为true时
// 某库.ts
export = FooClass;

// 应用内
import FooClass from "某库";

在一些环境中,可以通过修改TS1.8版本添加的compilerOptions.allowSyntheticDefaultImports选项来实现使用ESM规范导入TSM的导出值,但与esModuleInterop不同的是allowSyntheticDefaultImports不会影响编译结果,仅仅让其通过TS的类型检查阶段而已。

开启esModuleInterop后改变输出结果,因为它会在编译时将代码变为符合ESM规范的代码,但它不会像allowSyntheticDefaultImports仅仅是隐藏错误信息,因此无需担心会导致运行时错误。

并且,当esModuleInterop可用时会自动开启allowSyntheticDefaultImports。

根据[是否使用TS编写>是否包含类型定义文件>是否通过@types提供类型定义]的优先级来选择库

库的选择是一个开发应用时的关键问题,在多个类似的库中如何选择往往取决于个人,一般都会参考GitHub的star数、npm下载量、社区活跃度、文档的丰富度等等。另外,使用TypeScript开发应用时,库是否是由TypeScript开发的也是重要的指标之一。

如果在库的star数等指标差不多的情况下,笔者一般会按下列指标为优先级来选择:

  1. 是使用TypeScript编写的库吗?
  2. 库中是否包含类型定义文件?
  3. 库是否通过@types/*提供类型定义?

可能会有人认为“如果提供了所有类型定义的话就没问题了吧”,但如果不是用TS编写的库,库本身的类型和类型定义文件中的类型不一致的可能性还是很高的。

是使用TypeScript编写的库吗?

当库本身是用TS编写的,则使用tsc工具会自动生成类型定义文件,提供最具有可信度的定义文件。除非代码中存在大量用as标注的类型断言,否则生成的类型定义与实际的API之间基本是一致的。

库中是否包含类型定义文件?

即便当库本身是用JS写的,如果类型定义文件与库文件是放在一起的,那么在发布新版本时也会大概率的一起更新类型定义文件。那么就算出于疏忽类型定义和实际API之间存在偏差,“类型定义落后于实际API”的可能性也比较小。

库是否通过@types/*提供类型定义?

通过@type/*提供类型定义的库中,类型定义文件与库本身是分开的两个仓库,基本类型定义文件的更新会晚于库本身。因此有可能在使用最新版本的库时,还是用的旧的API类型描述文件。

并且由于**@types/***是作为一个独立的包提供,与库本身的版本也不会保持一致。如果一个名为foo的库最新版本为1.2.0,它@types/foo的最新版本可能是相差很远的2.3.1,因此必须知道库本身对应的类型定义文件的版本。

要尽可能避免选择没有类型定义文件的库,并且还要避免使用使用**@types/***类型定义文件的库。

实际上,React(react包)本来计划在16.7版本中提供最新的Hooks API,最后推迟到了16.8中发布,但其引用了在@types/react的16.7版本中定义的原本不存在的Hooks API类型,因此在使用时编译器和tsc都不会出现类型检查的错误,但在运行时会报错。

定义Nullable类型

JS中有null与undefined两种表示空值的值,是区分使用这两个值,还是统一使用他们,根据项目的不同方案会有差异,甚至在大公司与工具库中的使用方式也是摇摆不定。

两者的主要不同列举如下:

  • null
    • 除非开发者有意使用它否则不存在的值
    • 当给预设了参数默认值的函数传递参数时,传递null
    • 存在使用typeof运算符时会返回"object"的遗留问题
  • undefined
    • 引用一个不存在属性时的返回值
    • 当给预设了参数默认值的函数传递参数时,使用undefined作为默认值
    • undefined自身为全局对象undeinfed属性的引用,在ES5.0(非严格模式下)之前可以修改全局对象的undefined属性。

关于他们的使用大致分为两派:undefined统一派和区分使用派,不管哪种都各有优劣,不过开发过程中重要的不是好坏而是制定的规则。笔者个人的话常将null与undefined区分使用。

// `null` `undefined` 区别使用党
type Nullable<T> = T | undefined;

// `null` `undefined` 统一党
type Nullable<T> = T | null | undefined;

const getUserName = (user: Nullable<userModel.UserModel>) => ...;

当变量和函数参数接受空值时,定义一个Nullable类型是很方便的。TS中除了null和undeinfed外提供了NonNullable标准类型,而没有提供Nullable类型。原因的话如上所述,项目不同方案不同。

空值:统一使用undefined ─ TypeScript开发团队

TS开发团队的代码风格指南中写道“不区分null与undefined,空值必须使用undefined表示”。

该风格指南仅仅是在开发TypeScript语言时的指南,不会强制/推荐给TS用户。也许是因为很多人误解了这个问题,让TS开发团队很头大,因此在文档页面的顶部写有大(也指字体)量的声明。

Coding guidelines · microsoft/TypeScript Wiki

笔者看到了从「无声明->一行声明->如图所示的大量声明」的过程,深感其中的怨念。

空值:区分null与undefined的使用 ─ Facebook

另一方面,在开发了React等库的Facebook开发团队中,明确的区分了null与undefined的使用。

现在的React中,若定义一个不渲染任何DOM节点的组件时,必须返回null而不是undefined。

// 不会渲染任何东西的React组件
export const NoRendering = () => {
  ...

  return null; // `undefined`会发生错误
};

区分使用可选参数与Nullable类型

与上面声明的Nullable类型相关的还有TS的可选参数(Optional Parameters),可以用于对象的属性与函数的参数。不过很多项目中都误解了可选参数的用法,它是允许省略值的语法而不是接受空值的语法。

// 虽然这些函数的上下文及API均不一样,但形参b与c的类型都为`string | undefined`,可选参数会自动包含undefined
const func1(a: string, b: string | undefined, c: string | undefined) => ...;
const func2(a: string, b?: string, c?: string) => ...;

// Nullable( T | undefined )允许「传入空值」
func1("a", undefined, undefined);
func1("a"); // Error

// Optional Parameters允许「省略值」
func2("a", undefined, undefined);
func2("a"); // OK

即使在JavaScript中,如果不显式传入指定的对象属性或函数参数的话,它的值也为undefined,若带有可选参数标记则其值的类型为T | undefined,不能传入null。

从类型安全性的角度看推荐区分使用Nullable与可选参数,甚至可以说不要使用可选参数。因为在添加新的参数时,可选参数不会明确其影响范围,使引用其的其他模块或功能不可控。在定义React组件时,需要考虑考虑是否应该在Props中使用可选参数。

// 定义React中Props类型的场景(with-JSDoc)
type Props = {
  /** 要显示的用户,若不存在则显示表示不存在的信息 */
  account: Nullable<userModel.UserModel>;
  /**
   *  是否禁用
   *  @default false
   */
  isDisabled?: boolean;
};

不要使用除了any外不安全的类型

TS是一门保证类型安全的语言,因此在面向TS初学者的文章与书中经常写到:「尽量不要使用any来标记变量类型」,应该没有什么反对的人,不过除了any之外还存在其他不安全的类型。

比如新手很容易遇到的{}类型,{}类型不仅与空对象是同种类型,同时也是与空值外的任何类型一致的低安全性类型。

TS中有允许将描述的值作为类型的字面量类型(Literal Types),可以灵活的定义类型。

// 字面量类型
type A = "sample";

const a: A = "foo";
     ^^^
// Type '"foo"' is not assignable to type '"sample"'.ts(2322)

不过需要注意对象类型的定义。TS的类型系统是基于结构性子类型的,再加上JS具有的「对象式操作」特性,这两个因素的存在使得在操作对象时有时会很反直觉。

JS「对象式操作」的特性

与Ruby等语言不同,JS不是仅包含对象的语言,还存在number与string等原始值类型(基本类型)。与此同时,这些原始值中可以引用对应的标准对象的属性,在这个过程中,原始值会暂时转换成对应的标准对象,当评估(evaluated)结束后返回原始值。

const num1 = 123;         // number类型的原始值
const num2 = Number(123); // number类型的标准对象

num2.toString(); // "123" -- 引用标准对象的属性(对应的函数) 
num1.toString(); // "123" -- 原始值->转换为标准对象->引用属性->评估后返回原始值

换言之,由于这种原始值可以调用对应标准对象属性的特性,因此被称为「对象式操作」的语言。不过,也有不存在标准对象的原始值,即空值undefined与null,因此可以加个前缀几乎,即「JS中几乎都可以像对象般操作」。

TypeScript结构子类型带来的坑

由于TS的类型具有结构子类型的特性,因此当子结构一致时则认为是同种类型。比如不管是String对象还是Number对象,他们的原型中都包含toString属性,因此下面的对象会被认为是同种类型。

// 若对象具有相同的属性,则会被当做同种类型
type StringConvertible = {
  toString: () => void;
};

const a: StringConvertible = Number(123);
const b: StringConvertible = String("123");

由于之前说到的「除个别情况,原始值均可如对象般操作」的特性,因此上面的类型与他们的原始值也是同一类型。

// 即便换成原始值,其对应的对象也满足子结构的一致
type StringConvertible = {
  toString: () => void;
};

const a: StringConvertible = 123;
const b: StringConvertible = "123";

看到这里大概就明白了,{}类型看起来是与空对象同类型,它可以像对象一样操作所有值,而「像对象一样操作所有值=除空值以外的所有值」,因此两者也是同一类型,这样看来与any类型基本没什么区别。

type EmptyObject = {};

// 以下写法均不会报错
const a: EmptyObject = 123;
const b: EmptyObject = "123";
const c: EmptyObject = false;
const d: EmptyObject = [];
const e: EmptyObject = {};

// 以下写法均会报错
const f: EmptyObject = null;
const g: EmptyObject = undefined;

一般不会直接使用*{}*像type XXX = {}这样定义类型,需要与泛型结合。根据笔者经验,经常在Foo<{}>等这样的地方传入使用。

活用编辑器的重命名功能

TypeScript中的Language Service可以让不同的编辑器实现类似的代码解析与代码跳转功能(通过提供的ServiceHost)。(Language Service的具体情况笔者不太了解)Language Service是个很棒的功能,基本没见到有什么bug。

若想在VS Code等IDE型编辑器中利用这个服务,可以使用重命名功能,VS Code中使用F2快捷键调出重命名功能,使用频率较高但键位太远,笔者将其改为了Ctrl+R快捷键。

// 重命名对象类型的属性
export type UserModel = {
-   email: string;
+   emailAddress: string;
};

// 在规范化类型引用的地方也会自动被重命名
type Props = {
-   email: userModel.UserModel["email"];
+   email: userModel.UserModel["emailAddress"];
};

TS的重命名功能,会分析文件内所有引用该变量的模块(ESM/CommonJS),并重命名所有目标变量,因此无需使用grep来查看受影响的部分。并且它不仅会检查参数和函数名,类型别名、对象属性名与枚举键名等也会被重命名,非常方便重构。

参考

  1. Modules/Meta - CommonJS Spec Wiki
  2. ECMAScript 2015 Language Specification – ECMA-262 6th Edition
  3. Nullable types on TypeScript - aka Maybe Types from Flow · Issue #23477 · microsoft/TypeScript
  4. Coding guidelines · microsoft/TypeScript Wiki