阅读 988

工程化和运行时、TypeScript 编程内参(三)

| 导语 工程化,旨在提高多人项目开发的效率,保证代码质量,那么在实际的 TS 工程中会遇到哪些问题呢?

本文是《约束即类型、TypeScript 编程内参》系列第三篇:工程化和运行时,主要记述 TypeScript 在工程中的应用和实际问题。

  1. 约束即类型、TypeScript 编程内参(一)
  2. 构造类型抽象、TypeScript 编程内参(二)
  3. 工程化和运行时、TypeScript 编程内参(三)

在 TS 开发中的工程化包含两个,一个是 js 本身的工程化,一个则为有关于 TS 自身的工程化,本文着重谈下 TS 里的工程化(编码规范、git、lint、模块加载等等)

tsconfig.json

tsconfig.json 用于指定项目中 TypeScript 编译器 tsc 的一些编译配置,通常放在项目根目录。可以用 --init 参数来让 tsc 自动地生成一个 tsconfig.json 模版:

$ tsc --init

复制代码

这个命令会在当前工作目录下生成一个 tsconfig.json,里面会写出 tsc 的全部可用配置,在对应的注释里基本说明了每一个配置基本用法、作用,这里不多赘述,只简要说明几个常用配置:

  1. compilerOptions.target 编译到哪个 JS 版本,我一般填 “ES5”。
  2. compilerOptions.module 编译后用哪个模块系统,举例:"commonjs"
  3. compilerOptions.lib 用哪个宿主环境,举例:["es2017","DOM"]
  4. compilerOptions.esModuleInterop 开启这个可以避免引入 import * as xxx from 'xxx' 的写法
  5. compilerOptions.strict TS 严格模式,强烈建议打开,这是写出 TS Style Code 的前提。

其他的配置项可以参考官方文档介绍:TypeScript - compilerOptions

💡💡💡 关于 TS 严格模式 如果你开启了 strict 一般就不用开 noImplicitAny 等这类开关了,strict 模式下 TS 会自动帮你开启这些的,详见官方文档介绍

宿主环境的类型定义

ECMAScript 是一种需要宿主环境注入 API 的语言,在浏览器上宿主环境就是浏览器本身提供的 DOM、BOM 接口;而在 node 上,宿主环境就是 node 提供的一整套标准 API,如 fs 模块,require 模块等。

一般编写 JS 的过程大概率会调用到宿主环境的相关 API,TS 开发也不例外,需要用到宿主环境的类型定义,不然会写出很多 any 出来,比如我想自己覆盖重写浏览器的 fetch 方法:

// 不建议这样做
<any> window.fetch = myMockFecth

复制代码

正确的做法应该是:

const F: typeof window.fetch = (/*略*/) => {/*略*/}
// 因为 F 类型跟 window.fetch 一样, 所以这里不用 any
window.fetch = F;

复制代码

类似的例子还有 DOM 的 Event 定义等等 … 这些宿主环境的 TS 定义并不是凭空而来的,而是 TS 官方及社区提供的 @type/* 一起定义出来的,比如浏览器的宿主环境定义,我们利用 compilerOptions.lib 中的 DOM 来引入;而 node 的定义我们一般用 @types/node 来定义各种 node 原生模块:

$ npm i --save @types/node

复制代码

在安装了上述的模块之后,就可以直接访问到 node 相关的对象了,如 Buffer。

拓展类型定义

TS 的类型定义主要分两类,一类是宿主环境的定义,一类是模块的类型定义,在此之前,我们需要了解一下 TS 的环境上下文的概念。


环境上下文

我们可以把 TSC 看成一个编译函数,执行它的时候我们需要传入两个参数:

一个是我们自己的代码,另外一个则是一些环境声明(如 Promise 的声明定义)对于这类环境声明产生类型定义,我们称之为 环境上下文;另外,ts 如果在用户代码中发现了以 d.ts 结尾的代码,也会把 这类文件当成环境声明的一部分加入到环境上下文中,换言之,文件名后缀是有语义的。

环境上下文的作用是给用户代码提供类型声明,不会被实际编译,因此在环境上下文(d.ts)中不允许出现含有语义的计算,比如不能出现 1 + 1 这样的表达式运算。

建议读者自行建一个 d.ts 文件出来,然后在里面试试。

更多关于环境上下文的内容可以看看这个: jkchao.github.io/typescript-…

💡💡💡 浏览器的宿主环境、node 的宿主环境的定义是环境上下文定义的真子集。


拓展环境上下文(拓展宿主环境)

有时候我们需要改造、修饰宿主环境,比方说浏览器里我想加个 UMD 变量,即在 window.xxx 下挂一个我的变量而 TS 能识别出来;亦或者,在 node 下面有可能需要往 global 加东西,这种情况如何处理?

compilerOptions.lib 里有 DOM 的时候,ts 会加载内置的 lib.dom.d.ts,里面定义浏览器的各种 API,属于环境上下文,如果想要拓展他们,可以利用 declare 语句拓展,像这样:

// umd.d.ts d.ts 结尾的环境声明
declare interface Window {
ECZN_FLAG: 'ECZN_FLAG'
}
declare var ECZN_FLAG: 'ECZN_FLAG';

// index.ts
window.ECZN_FLAG;
ECZN_FLAG;

复制代码

写完了上面的声明之后,TS 项目中 window.ECZN_FLAG 就不会报错了,而且能正确提示信息。

下面是 Node 环境下的拓展声明。

// app-global.d.ts
declare namespace NodeJS {
export interface Global {
ECZN_FLAG: 'ECZN_FLAG'
}
}
declare var ECZN_FLAG: 'ECZN_FLAG';

// index.ts
window.ECZN_FLAG;
ECZN_FLAG;

复制代码

拓展模块类型定义

有时候别人模块的类型定义不一定符合我们的需求,这时候需要拓展他们的定义,而这些第三方模块有的是 TS 写的,有的是 JS 写的,有的是你自己的模块,有的是别人的模块 … 引用别人的模块有很多情况,大体来说主要分下面五种情况:


情况之一:我自己写的纯 js 项目怎么添加 d.ts 给其他 JS/TS 项目使用

这个可参考 TehShrike/deepmerge, 注意其中的 package.json 中的 types 字段指向的 d.ts 文件


情况之二:我自己写的纯 TS 项目怎么添加 d.ts 给其他 JS/TS 项目使用

这个容易,编辑修改 tsconfig.json 中的 declaration 为 true 即可让 ts 自动生成对应的 d.ts 环境上下文声明文件(记得还需要修改 package.json 中的 types 指向这个文件)

或者,可以将 package.json 的 main 设为 src/index.ts 也可以,main 设为 ts 文件,这个要看构建是否支持。


情况之三: jQuery 等这类有 UMD 需求的模块

这类模块一般是一个常用工具库,需要挂在全局来用(方便),然后其大概率会提供一个 d.ts,但这个 d.ts 没有帮你把模块挂在 UMD 上,因此你需要自己挂上去,这个请参考前文进行拓展。


情况之四: 别人的模块没有编写 d.ts,需要自己编写

这个稍微有点棘手,需要自己在本地项目中编写类型声明:

declare module "js-fetch-get" {
type Fetch<T> = (url: string) => Promise<T>;
var fetch: Fetch;
export = fetch;
}

// 有了上面的定义之后
// 下面这个就不会报找不到定义的错误了
import fetchGet from 'js-fetch-get'

复制代码

通常上面的声明写在 xxx.d.ts 里,xxx 可以随意,但这个文件需要放在 src/ 下,更确切的说应放在 compilerOptions.rootDir 下 (这个选项默认是 ./)


情况之五 别人的 JS/TS 项目虽然提供了 d.ts,但它写的不够好,不能满足我的需求

利用 declare module 的写法同样可以用于拓展模块的定义,这个建议读者自己试试看看(参考前文所述的宿主环境的拓展)

多用 interface 少用 type

谈到该用 interface 还是 type,大家都常说尽量用 interface,但是都没答到电子上,其实用 interface 的原因在于 interface 可以重名合并,也就是 interface 可以被拓展,在 TS 里只有 namespace interface module 能被拓展:

declare interface Window {}
declare namespace NodeJS {
export interface Global {}
}
declare module "xxx" {}

复制代码

能被拓展的东西就可以像前文那样被其他人修改定义,而如果用了很多 type 来定义对象,其他人就不能拓展了,只能修改原始定义去拓展,造成各种各样的 issues。

不要传播 any

TS = 静态类型系统 + js 反过来说就是: JS = TS + any

当我们讨论不要随便用 any 的时候,其实最担心的是怕 any 传播出去,而不是说我们一定不能用,有些情况不得不用,比如在一个 JSON 配置加载器里:

function loadConfig() {
try {
const rawJson = fs.readFileSync('xxxx.json', 'utf-8');
return JSON.parse(rawJson);
} catch (err) {
console.warn('load config error', err);
return null;
}
}

复制代码

上面这个函数的签名 TS 会自动推断为: () => any (JSON.parse 返回 any),这样的话在其他地方调用的时候就会产生额外的 any(这种情况算作隐式 any)

// http.Server 是 http.createServer 的返回结果
function initServer(app: http.Server) {
const conf = loadConfigFromDist();
app.listen(conf.port);
// 这里变量 conf 是 any
// 因此 conf.port 也是 any
// 这段代码被污染了 (传播了 any)
}

复制代码

这样就造成了 any 的传播,这个东西传播多了,相当于退化为 js。因此不要随便用 any,即使要用,也应该切断传播,比如显式指定签名

interface AppConf { /* 系统配置定义 */ }
function loadConfigFromDist(): AppConf {
// 注意,这里显式地钦点了类型,从而切断了 any 的传播
/* 具体实现省略 */
}

复制代码

同理,在我们拉取接口请求的响应也一样,要显式标注类型,不要用 any。

💡💡💡 关于 tsconfig implictAny 选项 这个选项要求你将有 any 的地方全部标出来,不能出现隐式的情况,可以让 tsc 来帮你检测 any 的传播,从而避免上述问题 (开启了 strict 之后这个选项会被默认开启)

unknown 和 any

如果不确定某处的类型,建议用 unknown 而不是 any。 any 的语义是:任何对于 any 的类型推导都是通过的;而 unknown 则是:unknown 是任何可能的类型,类型是不确定的,除非有断言才能确定其具体类型。

前者很好理解,大家都写过,但后者提到的断言是啥,简单来说断言就是钦点某变量为某类型的语义:

var aVar: unknown;
aVar.toUpperCase(); // 报错

if (typeof aVar === 'string') {
aVar.toUpperCase();
// 不报错,typeof aVar === 'string' 语句是 string 断言
// 也就是说,这个分支下 aVar 的类型为 string
}
if (aVar instanceof Date) {
aVar.getTime();
// 这里断言为 Date 对象
// 这里的 instanceof 是一种 Date 断言
}

type Person = { name: string };
// 自己为某类型声明断言函数
// 注意这里的签名返回值
function isPerson(x: any): x is Person {
return typeof x.name === 'string'
}

if (isPerson(aVar)) {
aVar.name;
// 不会报错,因为 isPerson 是断言函数
// (仔细看看 isPerson 的签名)
// 因为有 Person 断言,所以这个分支下 aVar 的类型为 Person
}

复制代码

而如果一开头 aVar 声明为 any, 那不论是 aVar.toUpperCase aVar.getTime 都不会报错了,因此引入 unknown 的意义在于让你多自己写断言检查类型,减少错误。 (也有可能是代码没写完,写个 unknown 占位)

题外话,老版本的 ts 是没有 unknown 的,因此有个 polyfill :

type AnyObj = { [key: string]: any }

type unknown = (
AnyObj |
object |
number |
string |
boolean |
symbol |
undefined |
null |
void
);

复制代码

可见,unknown 的语义是 任何可能的类型组合而成的复合类型,这也能解释为啥要给 unknown 写断言才能正确使用。

脚手架、打包、编译、ESLint 问题

对于 ts 项目来说,一定需要 webpack 吗?不一定,我个人倾向是 node 项目直接用 tsc 就好,而打包这个步骤对于服务端应用来说没那么重要,因此 webpack 是可选的。

那有人会问了,静态图片、pb 等静态资源要如何处理?

这种情况下推荐用 webpack 去处理了,当然对于 node 的 proto 文件来说,用后置脚本去复制文件也是一种办法。

如果需要用到 webpack,可以使用 typescript-starter create-react-app-typescript 这些开源脚手架。


tslint 官方已经不维护了,目前如果想引入代码检查只有 eslint 这一种方案了,具体的配置网络上有很多,这里不再赘述。

放心地编写代码

熟练了写 ts 的开发者永远不会再想去写 js 了,因为对于标好类型的 TS 项目来说, IDE 实在是太好用了:

  1. 可以正确地、安全地、批量地修改变量名
  2. 自动修改 import 路径、自动 import ts 文件
  3. 代码智能提示相当于活的开发文档
  4. … 等等等等

静态类型的定义在于从类型的角度上证明程序的正确性,通俗的来说就是:TS 里的每一行、每一处、每一个函数的调用,都是受类型规则约束的;如果你的代码能正确标好类型,那基本上你的程序的出错的概率会大大降低,而出现的错误一般是算法的边界条件、触发条件这些逻辑性错误,也就是说,你标的类型其实是一种单元测试,类型是对程序的证明。

当然,一切的前提在于,你得标好类型。

谨慎地处理错误

TS 有个大坑,比如错误处理的问题,在 TS 里我们不能给 catch 子句的 error 标类型,error 的类型被强制定为 any:

try {
const err = new MyError();
throw err;
} catch (e: MyError) {
// ^^^^^^^^^^^ 这里会报错
// ts 不允许用户定义 e 的类型
// e 被 ts 强制设定为 any
}

复制代码

这个问题最早提出在 TS 官方仓库的 issues 里: github.com/microsoft/T…

目前 TS 在语意上强制了 try catch 的 error 类型为 any,因此里面的错误处理会很不 TS Style,很容易传播 any。 那解决方案呢?如果说 Error 类型的抛出、捕获也要走静态类型标注、推导,那这类特性大概最终会演化成类似 Java 的 Checked Exception (CE):

function loadFile(path: string) throws IOError {
return fs.readFile(path);
}

try {
loadFile()
} catch (err) {
// 这里 err 会自动推断为 IOError
}

复制代码

该不该引入 CE ?这是一个见仁见智的问题,换我来说,这很好,可以标好 Error 的类型,同时不标的话就默认为 any/unknown 类型,这样开发者可以选择标或者不标,在这样的体系下 TS 整体类型系统的设计也会比较完整,何乐不为? 不过,要加的话,基本整套 TS 生态里面的代码都要 review 重构了,这个又是一个很大很重的工作量了。

当然了,按照社区尿性,一定会有大量 throws any 的写法出来的,但如果不得不这么做,建议你写成 throws unknown,少用 any。

错误处理的问题一定会随着 TS 的发展以及在大型项目中的使用而变得越来越明显。

本篇末

本篇主要讲述的是如何构造类型抽象以便描述/生成更多的类型,以下是 Checklist:

  1. tsconfig.json
  2. 环境上下文
  3. 拓展环境/模块类型定义
  4. unknown 和 any
  5. TS 打包、eslint 等等
  6. 错误处理及其坑

本文的下一篇是「常用类型举例、TypeScript 编程内参(四)」主要举例一些情况下类型的写法、套路等等,敬请期待