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

时间:2022-10-14 15:55:48

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

首先是目录结构。毫无疑问一个拥有TS的项目必须有像tsconfig.jsontypes/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行 targetES5,用来将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 typescript

然后笔者所在组是选择了 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 配置项可能在 .babelrcbabel.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 去强校验数据模型。后续看进一步的使用吧~