Typescript助力项目开发:JS切换TS、TS类型定制与思考

时间:2022-10-15 22:59:58


TS已经成为可以帮助项目顺利开发的存在了。在上半年笔者就被要求采用TS开发新的项目,并在一些老项目中用TS去改造(因为沟通原因我以为某个远程组件只有TS版本)。在其中也有了一些思考。

首先是目录结构。毫无疑问一个拥有TS的项目必须有像​​tsconfig.json​​​、​​types/typings​​​ 这样的说明文件,根据项目需要可能还有​​tslint.json​​​这样的配置文件 —— 它们都和 ​​src​​ 目录同级。我们的目录结构大致如下:

Typescript助力项目开发:JS切换TS、TS类型定制与思考

如图,有 src 目录组织开发代码、types 目录组织ts类型声明定义(重要‼️)、build 目录作为Web项目的构建产物、package和package-lock两个json文件限制三方包版本、tsconfig ts配置文件、还有 lint 规范文件和我司定制的脚手架配置文件。(如果项目中有node,还会有lib目录作为 Node.js 模块的构建产物)
图中蓝线圈住的就是我们需要注意的(项目改造时需要手动添加的)了。

配置tsconfig

我们的目的在于尽可能少地改动源码、让项目正常运行。所以我们应该尽量宽松地配置 tsconfig。如下所示:

{
"compilerOptions": {
"module": "es2015",
"target": "ES5",
"strict": true,
"jsx": "preserve",
"importHelpers": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"esModuleInterop": true,
"strictPropertyInitialization": false,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": false,
"emitDecoratorMetadata": true,
"isolatedModules": true,
"sourceMap": true,
"baseUrl": ".",
"typeRoots": ["node_modules/@types", "./types"],
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
},
"exclude": [
"node_modules",
"config",
"build",
"test"
],
}

其中比较重要的是:

  • 第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依赖:

npm install -D

然后笔者所在组是选择了 webpack loader来加载并转译typescript代码:

npm install -D ts-loader

并在config.js中添加 resolve 和module 规则:

module.exports = ({ userFolder, srcFolder, buildFolder, currentEnv, webpack, webpackDevServer }) => {
// 其他配置 ...,
webpackConfig: { //webpack中怎么配置,这里面就怎么写
resolve: {
extensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
},
module: {
rules: [
// 其他配置 loader 规则...,
{
test: /\.tsx?$/,
use: [
{
loader: "ts-loader",
options: { transpileOnly: true }
}
]
}
],
},
plugins: [
// ...其他配置
new require('fork-ts-checker-webpack-plugin')({
async: false,
tsconfig: '...' // tsconfig.json 文件地址
});
]
// 其他配置...
}
};

一个比较好的实践是,我们可以开启 ts-loader 的 transpileOnly 配置,让 ts-loader 在处理 TypeScript 文件时,只转译而不进行静态类型检测,这样就可以提升构建速度了。
不过,这并不意味着构建时静态检测不重要,相反这是保证类型安全的最后一道防线。此时,我们可以通过其他性能更优的插件做静态类型检测。

在最后我们引入了 ​​fork-ts-checker-webpack-plugin​​ 专门对 TypeScript 文件进行构建时静态类型检测。这样,只要出现任何 TypeScript 类型错误,构建就会失败并提示错误信息。

npm install -D fork-ts-checker-webpack-plugin;

实际上,静态类型检测确实会耗费性能和时间,尤其是项目特别庞大的时候,这个损耗会极大地降低开发体验。此时,我们可以根据实际情况优化 Webpack 配置,比如仅在生产构建时开启静态类型检测、开发构建时关闭静态类型检测,这样既可以保证开发体验,也能保证生产构建的安全性。

除此之外,我们还可以用 ​​babel-loader​​ 作为typescript的加载器(注意:版本号必须大于7!)

npm i -D babel-loader; // 确保安装版本 > 7
npm i -D @babel/preset-typescript;

然后,我们在config.js中添加支持 Typescript 的配置:

resolve: {
extensions: [".ts", ".tsx", ".js", ".jsx", ".json"]
},
module: {
rules: [
{
test: /\.(js|jsx|ts|tsx)$/,
use: ['babel-loader']
},
// ...其他配置
]
},

最后,我们在 babel 配置文件中添加了如下所示的 typescript presets。

{
"presets": [
//...
['@babel/preset-typescript', { allowNamespaces: true }]
],
// ...其他配置
}

注意:因为每个项目中使用的模板不同,所以 babel 配置项可能在 ​​.babelrc​​​、​​babel.config.js​​​ 单独的配置文件中或者内置在 ​​package.json​​ 中。

这样,babel-loader 就可以加载并转换 TypeScript 代码了。

需要注意:因为 babel-loader 也是只对 TypeScript 代码做转换,而不进行静态类型检测,所以我们同样需要引入
fork-ts-checker-webpack-plugin 插件做静态类型检测。

解决问题

缺少类型声明

整体的结构引入以后,就需要解决ts文件中的类型错误了。
其中,“某个模块的类型声明文件缺失”可能是遇到概率最大的错误了。比如说我司的 sku 组件。

此时,我们有两种方案:可以直接命令行安装可能存在的类型声明依赖:

npm install -D @types/@vdian/vue-sku;

如果命令执行成功,则说明类型声明存在,并且安装成功,这也意味着我们快速且低成本地解决了一个错误。如果 DefinitelyTyped 上恰好没有定义好的依赖类型声明,那么我们就需要自己解决这个问题了。

首先我们需要频繁使用 declare module 补齐类型声明。然后,我们将各种补齐类型声明的文件统一放在 types 目录中:

declare module "@vdian/vue-sku";

Typescript助力项目开发:JS切换TS、TS类型定制与思考

对于全局变量、属性等缺少类型定义的问题,我们也可以使用 declare 或者补充相应的接口类型进行解决:

// 身份全局接口
interface StoreIdentityData {
baseVersionStore: boolean, // 判断是否是基础版
paidStore: boolean, // 付费商家 - 连锁店 或 商城版
mallVersionExpired: boolean, // 付费商家 过期
//...
createToolEnable: boolean, //是否该店铺能够创建营销工具 如果不指定toolCode,返回true
disableCreateToolReason: string, // 不能创建的原因
}

动态类型

另一类极有可能出现的错误是 JavaScript 动态类型特性造成的。

如下示例第 1~3 行所示,我们习惯先定义一个空对象,再动态添加属性,迁移到 TypeScript 后就会提示一个对象上属性不存在的 ts(2339) 错误 。

const obj = {};
obj.id = 1; // ts(2339)
obj.obj = 22; // ts(2339)

此时,我们需要通过重构代码解决这个问题,具体操作是预先定义完整的对象结构或类型断言。

代码重构后的示例如下:

interface IUserInfo {
id: number;
name: number;
}
const obj = {} as IUserInfo;
obj.id = 1; // ok
obj.obj = 23; // ok

在第 5 行中,我们使用了类型断言解决了 ts(2339) 错误。

定制工具类型

工具类型的本质就是构造复杂类型的泛型。它接受类型入参,并返回我们需要的东西。

Equal

我们实现一个自定义工具类型 ​​Equal<S, T>​​,它可以用来判断入参 S 和 T 是否是相同的类型。如果相同,则返回布尔字面量类型 true,否则返回 false。

首先我们很容易想到,如果 S 是 T 的子类型且 T 也是 S 的子类型,则说明 S 和 T 是相同的类型。
这里,我们需要注意!never 和 any 类型!

type IsAny<T> = 0 extends (1 & T) ? true : false;
type Equal<S, T> = IsAny<S> extends true
? IsAny<T> extends true
? true
: false
: IsAny<T> extends true
? false
: [S] extends [T]
? [T] extends [S]
? true
: false
: false;

type Example1 = Equal<1 | number & {}, number>; // true but false got
type Example2 = Equal<never, never>; // true
type Example4 = Equal<any, any>; // true
type Example3 = Equal<any, number>; // false
type Example5 = Equal<never, any>; // false

在第 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>​​,如下示例:

type Merge<A, B> = {
[key in keyof A | keyof B]: key extends keyof A
? key extends keyof B
? A[key] | B[key]
: A[key]
: key extends keyof B
? B[key]
: never;
};
type Merged = Merge<{ id: number; name: string }, { id: string; age: number }>;

在第 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 去强校验数据模型。后续看进一步的使用吧~