工程化和运行时、TypeScript 编程内参(三)
本文转载自腾讯IVWEB社区,作者eczn
本文是《约束即类型、TypeScript 编程内参》系列第三篇:工程化和运行时,主要记述 TypeScript 在工程中的应用和实际问题。
-
约束即类型、TypeScript 编程内参(一) -
构造类型抽象、TypeScript 编程内参(二) -
工程化和运行时、TypeScript 编程内参(三)
在 TS 开发中的工程化包含两个,一个是 js 本身的工程化,一个则为有关于 TS 自身的工程化,本文着重谈下 TS 里的工程化(编码规范、git、lint、模块加载等等)
tsconfig.json
tsconfig.json 用于指定项目中 TypeScript 编译器 tsc 的一些编译配置,通常放在项目根目录。可以用 --init 参数来让 tsc 自动地生成一个 tsconfig.json 模版:
$ tsc --init
这个命令会在当前工作目录下生成一个 tsconfig.json,里面会写出 tsc 的全部可用配置,在对应的注释里基本说明了每一个配置基本用法、作用,这里不多赘述,只简要说明几个常用配置:
-
compilerOptions.target
编译到哪个 JS 版本,我一般填 “ES5”。 -
compilerOptions.module
编译后用哪个模块系统,举例:"commonjs"
-
compilerOptions.lib
用哪个宿主环境,举例:["es2017","DOM"]
-
compilerOptions.esModuleInterop
开启这个可以避免引入import * as xxx from 'xxx'
的写法 -
compilerOptions.strict
TS 严格模式,强烈建议打开,这是写出 TS Style Code 的前提。
其他的配置项可以参考官方文档介绍:TypeScript - compilerOptions(https://www.typescriptlang.org/docs/handbook/compiler-options.html)
💡💡💡 关于 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 文件出来,然后在里面试试。
更多关于环境上下文的内容可以看看这个:(https://jkchao.github.io/typescript-book-chinese/typings/ambient.html#%E5%A3%B0%E6%98%8E%E6%96%87%E4%BB%B6)
💡💡💡 浏览器的宿主环境、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(https://github.com/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(https://github.com/gcanti/unknown-ts) :
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 实在是太好用了:
-
可以正确地、安全地、批量地修改变量名 -
自动修改 import 路径、自动 import ts 文件 -
代码智能提示相当于活的开发文档 -
… 等等等等
静态类型的定义在于从类型的角度上证明程序的正确性,通俗的来说就是:TS 里的每一行、每一处、每一个函数的调用,都是受类型规则约束的;如果你的代码能正确标好类型,那基本上你的程序的出错的概率会大大降低,而出现的错误一般是算法的边界条件、触发条件这些逻辑性错误,也就是说,你标的类型其实是一种单元测试,类型是对程序的证明。
当然,一切的前提在于,你得标好类型。
谨慎地处理错误
TS 有个大坑,比如错误处理的问题,在 TS 里我们不能给 catch 子句的 error 标类型,error 的类型被强制定为 any:
try {
const err = new MyError();
throw err;
} catch (e: MyError) {
// ^^^^^^^^^^^ 这里会报错
// ts 不允许用户定义 e 的类型
// e 被 ts 强制设定为 any
}
这个问题最早提出在 TS 官方仓库的 issues 里:https://github.com/microsoft/TypeScript/issues/13219
目前 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:
-
tsconfig.json -
环境上下文 -
拓展环境/模块类型定义 -
unknown 和 any -
TS 打包、eslint 等等 -
错误处理及其坑
本文的下一篇是「常用类型举例、TypeScript 编程内参(四)」主要举例一些情况下类型的写法、套路等等,敬请期待。
关注「 前端时空 」
传递一线全栈技术,与你一起穿越前端时空