| 导语 工程化,旨在提升多人项目开发的效率,保证代码质量,那么在实际的 TS 工程中会遇到哪些问题呢?html
本文是《约束即类型、TypeScript 编程内参》系列第三篇:工程化和运行时,主要记述 TypeScript 在工程中的应用和实际问题。node
在 TS 开发中的工程化包含两个,一个是 js 自己的工程化,一个则为有关于 TS 自身的工程化,本文着重谈下 TS 里的工程化(编码规范、git、lint、模块加载等等)react
tsconfig.json 用于指定项目中 TypeScript 编译器 tsc 的一些编译配置,一般放在项目根目录。能够用 --init 参数来让 tsc 自动地生成一个 tsconfig.json 模版:webpack
$ tsc --init
复制代码
这个命令会在当前工做目录下生成一个 tsconfig.json,里面会写出 tsc 的所有可用配置,在对应的注释里基本说明了每个配置基本用法、做用,这里很少赘述,只简要说明几个经常使用配置:git
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 - compilerOptionsgithub
💡💡💡 关于 TS 严格模式 若是你开启了 strict 通常就不用开 noImplicitAny 等这类开关了,strict 模式下 TS 会自动帮你开启这些的,详见官方文档介绍web
ECMAScript 是一种须要宿主环境注入 API 的语言,在浏览器上宿主环境就是浏览器自己提供的 DOM、BOM 接口;而在 node 上,宿主环境就是 node 提供的一整套标准 API,如 fs 模块,require 模块等。面试
通常编写 JS 的过程大几率会调用到宿主环境的相关 API,TS 开发也不例外,须要用到宿主环境的类型定义,否则会写出不少 any 出来,好比我想本身覆盖重写浏览器的 fetch
方法:算法
// 不建议这样作
<any> window.fetch = myMockFecth
复制代码
正确的作法应该是:typescript
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,可是都没答到电子上,其实用 interface 的缘由在于 interface 能够重名合并,也就是 interface 能够被拓展,在 TS 里只有 namespace interface module 能被拓展:
declare interface Window {}
declare namespace NodeJS {
export interface Global {}
}
declare module "xxx" {}
复制代码
能被拓展的东西就能够像前文那样被其余人修改定义,而若是用了不少 type 来定义对象,其余人就不能拓展了,只能修改原始定义去拓展,形成各类各样的 issues。
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。 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 写断言才能正确使用。
对于 ts 项目来讲,必定须要 webpack 吗?不必定,我我的倾向是 node 项目直接用 tsc 就好,而打包这个步骤对于服务端应用来讲没那么重要,所以 webpack 是可选的。
那有人会问了,静态图片、pb 等静态资源要如何处理?
这种状况下推荐用 webpack 去处理了,固然对于 node 的 proto 文件来讲,用后置脚本去复制文件也是一种办法。
若是须要用到 webpack,可使用 typescript-starter
create-react-app-typescript
这些开源脚手架。
tslint
官方已经不维护了,目前若是想引入代码检查只有 eslint
这一种方案了,具体的配置网络上有不少,这里再也不赘述。
熟练了写 ts 的开发者永远不会再想去写 js 了,由于对于标好类型的 TS 项目来讲, IDE 实在是太好用了:
静态类型的定义在于从类型的角度上证实程序的正确性,通俗的来讲就是: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:
本文的下一篇是「经常使用类型举例、TypeScript 编程内参(四)」主要举例一些状况下类型的写法、套路等等,敬请期待