TS已经成为可以帮助项目顺利开发的存在了。在上半年笔者就被要求采用TS开发新的项目,并在一些老项目中用TS去改造(因为沟通原因我以为某个远程组件只有TS版本)。在其中也有了一些思考。
首先是目录结构。毫无疑问一个拥有TS的项目必须有像tsconfig.json
、types/typings
这样的说明文件,根据项目需要可能还有tslint.json
这样的配置文件 —— 它们都和 src
目录同级。我们的目录结构大致如下:
如图,有 src 目录组织开发代码、types 目录组织ts类型声明定义(重要‼️)、build 目录作为Web项目的构建产物、package和package-lock两个json文件限制三方包版本、tsconfig ts配置文件、还有 lint 规范文件和我司定制的脚手架配置文件。(如果项目中有node,还会有lib目录作为 Node.js 模块的构建产物)
图中蓝线圈住的就是我们需要注意的(项目改造时需要手动添加的)了。
配置tsconfig
我们的目的在于尽可能少地改动源码、让项目正常运行。所以我们应该尽量宽松地配置 tsconfig。如下所示:
其中比较重要的是:
- 第4行
target
为ES5
,用来将TS转译为低版本、兼容性好的ES5代码; - 第18行我们把 types 目录添加到类型查找路径,让Typescript可以查找到自定义类型声明,比如为缺少类型声明的第三方模块补齐类型声明;
- 最后的“exclude”是设置Typescript不需要识别的文件(这里你也可以直接用 include);
- 第6行我们把将 jsx 选项设置为 “preserve” 意味着 TypeScript 不应处理JSX;
你甚至可以启用 allowJS: true
,让 js 和 ts 能够混用。
因为Web项目中不会直接使用tsc转译Typescript,所以我们不需要配置 rootDir、outDir,甚至我们可以直接开启 noEmit 配置:
noEmit: true
构建工具集成Typescript
下面以我司的脚手架为例,它和webpack书写结构差不多一致。
首先需要安装typescript依赖:
然后笔者所在组是选择了 webpack loader来加载并转译typescript代码:
并在config.js中添加 resolve 和module 规则:
一个比较好的实践是,我们可以开启 ts-loader 的 transpileOnly 配置,让 ts-loader 在处理 TypeScript 文件时,只转译而不进行静态类型检测,这样就可以提升构建速度了。
不过,这并不意味着构建时静态检测不重要,相反这是保证类型安全的最后一道防线。此时,我们可以通过其他性能更优的插件做静态类型检测。
在最后我们引入了 fork-ts-checker-webpack-plugin
专门对 TypeScript 文件进行构建时静态类型检测。这样,只要出现任何 TypeScript 类型错误,构建就会失败并提示错误信息。
实际上,静态类型检测确实会耗费性能和时间,尤其是项目特别庞大的时候,这个损耗会极大地降低开发体验。此时,我们可以根据实际情况优化 Webpack 配置,比如仅在生产构建时开启静态类型检测、开发构建时关闭静态类型检测,这样既可以保证开发体验,也能保证生产构建的安全性。
除此之外,我们还可以用 babel-loader
作为typescript的加载器(注意:版本号必须大于7!)
然后,我们在config.js中添加支持 Typescript 的配置:
最后,我们在 babel 配置文件中添加了如下所示的 typescript presets。
注意:因为每个项目中使用的模板不同,所以 babel 配置项可能在 .babelrc
、babel.config.js
单独的配置文件中或者内置在 package.json
中。
这样,babel-loader 就可以加载并转换 TypeScript 代码了。
需要注意:因为 babel-loader 也是只对 TypeScript 代码做转换,而不进行静态类型检测,所以我们同样需要引入
fork-ts-checker-webpack-plugin 插件做静态类型检测。
解决问题
缺少类型声明
整体的结构引入以后,就需要解决ts文件中的类型错误了。
其中,“某个模块的类型声明文件缺失”可能是遇到概率最大的错误了。比如说我司的 sku 组件。
此时,我们有两种方案:可以直接命令行安装可能存在的类型声明依赖:
如果命令执行成功,则说明类型声明存在,并且安装成功,这也意味着我们快速且低成本地解决了一个错误。如果 DefinitelyTyped 上恰好没有定义好的依赖类型声明,那么我们就需要自己解决这个问题了。
首先我们需要频繁使用 declare module 补齐类型声明。然后,我们将各种补齐类型声明的文件统一放在 types 目录中:
对于全局变量、属性等缺少类型定义的问题,我们也可以使用 declare 或者补充相应的接口类型进行解决:
动态类型
另一类极有可能出现的错误是 JavaScript 动态类型特性造成的。
如下示例第 1~3 行所示,我们习惯先定义一个空对象,再动态添加属性,迁移到 TypeScript 后就会提示一个对象上属性不存在的 ts(2339) 错误 。
此时,我们需要通过重构代码解决这个问题,具体操作是预先定义完整的对象结构或类型断言。
代码重构后的示例如下:
在第 5 行中,我们使用了类型断言解决了 ts(2339) 错误。
定制工具类型
工具类型的本质就是构造复杂类型的泛型。它接受类型入参,并返回我们需要的东西。
Equal
我们实现一个自定义工具类型 Equal<S, T>
,它可以用来判断入参 S 和 T 是否是相同的类型。如果相同,则返回布尔字面量类型 true,否则返回 false。
首先我们很容易想到,如果 S 是 T 的子类型且 T 也是 S 的子类型,则说明 S 和 T 是相同的类型。
这里,我们需要注意!never 和 any 类型!
在第 1 行,我们定义了可以区分 any 和其他类型的泛型 IsAny,因为只有 any 和 1 交叉得到的类型(any)是 0 的父类型,所以如果入参是 any 则会返回 true,否则返回 false。
在第 2 ~ 7 行,我们定义了 Equal(首先特殊处理了类型入参 S 和 T 至少有一个是 any 的情况),当 S 和 T 都是 any 才返回 true,否则返回 false。因此,在第 15~17 行,Equal 是可以区分 any 和其他类型的。
在第 8 ~ 12 行,我们通过 []
解除了条件分配类型,所以第 13 ~ 14 行 Equal 可以判断出联合类型 1 | number & {}
和 number、never 和 never 是相同的类型。
在条件判断类型的定义中,将泛型参数使用
[]
括起来,即可阻断条件判断类型的分配,此时,传入参数 T 的类型将被当做一个整体,不再分配。
Merge
接下来我们再基于映射类型将类型入参 A 和 B 合并为一个类型的泛型 Merge<A, B>
,如下示例:
在第 2 行,我们限定了返回类型属性 key 为入参 A、B 属性的联合类型。当 key 为 A、B 的同名属性,合并后的属性类型为联合类型 A[key] | B[key]
(第 2 ~ 4 行);当 key 为 A 或者 B 的属性,合并后的属性类型为 A[key]
或者 B[key]
(第 5 ~ 7 行)。
最后,我们在第 10 行使用了 Merge 合并两个接口类型,从而得到了 { id: number | string; name: string; age: number }
。
思考
我仍然认为,ts 强校验失去了 js 弱检查的一些特性。这在开发中可能或多或少的会带来一些困扰。所以笔者比较认同“js 和 ts 混用”的开发模式。目前我认为,在非前后端交互和非业务方间数据传递(微前端)的业务逻辑中,都可以不用 TS 去强校验数据模型。后续看进一步的使用吧~