Skip to content

Typescript中工程层面的类型能力

TIP

今天来扩展工程化层面的类型知识

类型检查指令

行检查

  1. 关闭下一行的Ts代码检查
typescript
// @ts-ignore
const name: string = 100;
  1. 关闭下一行的Ts代码检查,前提是真的有错误
typescript
// @ts-expect-error
const name: string = 100; 

const age: number = 599; // 报错

NOTE

无条件使用ts-expect-error指令, 确定它真的是有错误而不得不去屏蔽它

文件检查

  1. 关闭文件检查
typescript
// @ts-nocheck
const name: string = 100;
const age: number = 'Harexs';
  1. 开启文件检查

@ts-chekc 主要适用于JS文件的主动检查,配合JSDoc使用

类型声明

通过declare关键字可以定义类型声明,它们不会被实际打包到生产代码中,而是在编译层面上提供类型检查和类型信息

需注意它们不能被赋值

typescript
declare var f1: () => void;

declare interface Foo {
  prop: string;
}

declare function foo(input: Foo): Foo;

declare class Foo {}


// × 不允许在环境上下文中使用初始值
declare let result = foo();

// √ Foo
declare let result: ReturnType<typeof foo>;

类型覆盖和扩展

通常.d.ts文件都会被TS作为类型声明文件自动加载到环境中实现代码补全,但对于无类型的npm包,我们可以通过declare module提供类型

typescript
import foo from 'pkg'

const res = foo.handler() //无类型提示
typescript
// x.d.ts
declare module 'pkg'{
  export const handler: ()=> boolean;
}  // 此时上面的res就有布尔的返回类型了
typescript
declare module 'pkg2' {
  const handler: () => boolean;
  export default handler;
}

import bar from 'pkg2';

bar();

非代码文件

对于非代码文件,其本质和包文件一样是一个导入语句,也可以通过声明来决定其内容和类型

typescript
// x.d.ts
declare module '*.md' {
  const raw: string;
  export default raw;
}

//index.ts
import raw from './1.md'

const content = raw.replace('123','321')

DefinitelyTyped

在npm上通常可以看到有@types/开头的包,这类都属于由TS官方维护的无类型定义的JS库的类型支持包,typescript会自动加载所有@types/下的包,比如@types/node@type/react中:

typescript
// @types/node
declare module 'fs' { 
    export function readFileSync(/** 省略 */): Buffer;
}

// @types/react
declare namespace React {
    function useState<S>(): [S, Dispatch<SetStateAction<S>>];
}

前者提供了单独的模块声明, 后者则是使用命名空间做了统一声明

扩展类型

对于没有类型声明的包或者需要扩展的变量,如window下需要挂载一个全局变量, 则通过利用接口合并的特性进行扩展

typescript
interface Window {
  userTracker: (...args: any[]) => Promise<void>;
}

window.userTracker("click!")

当然也可以扩展已经有类型的包的定义,模块的合并特性有个别区别,命名空间则与接口一致

typescript
declare module 'fs' {
  export function bump(): void;  // 如果是模块增强(合并),至少需要包含一个import或者export,否则报错
}

import { bump } from 'fs';

namespace MyNamespace {
  export const a = 1;
}

namespace MyNamespace {
  export const b = 2;
}

三斜线指令

三斜线指令就是声明文件的导入语句,让当前文件依赖其他类型声明文件,包含TS内置类型

该声明应放置于文件顶部

typescript
/// <reference path="./other.d.ts" />
/// <reference types="node" />
/// <reference lib="dom" />
  1. path类型会根据当前位置一直向上查找,直到找到第一个类型文件
  2. types类型则是根据@types/类型包进行查找
  3. lib类型则是Ts的内置类型声明

命名空间

除了在类型文件中使用, 在类型声明文件中也可以使用命名空间,它的作用和模块类似,是模块还没完全实现前的模块化方案

typescript
// react.d.ts
export = React; // 表示整个模块导出的值就是 React 这个对象
export as namespace React;  // 即使没有 import React from 'react',也可以在全局直接使用 React.useState 这样的类型
declare namespace React {  // 声明了一个命名空间 React,里面包含了一堆类型和函数签名
  // 倘若对命名空间使用了导出,那么内部的声明则不再需要再次declare
  function useState<S>(initialState): [];
}


// ESM风格则必须要声明对应的类型变量来明确导出
declare namespace React {
  function useState<S>(initialState: S): [S, (value: S) => void];
}

declare const React: {
  useState: typeof React.useState;
};

export default React;
export { React };

还可以利用命名空间合并特性,在全局命名空间注入其他类型,来提供类型支持

typescript
declare global {
  namespace JSX {
    interface Element extends React.ReactElement<any, any> { }
  }
}

类型导入

Ts提供了单独的类型导入语法, 以更好区分实际的模块导入,和类型导入

typescript
import { Foo } from "./foo";
import type { FooType } from "./foo";
// 或者
import { Foo, type FooType } from "./foo";

根据类型编排导入顺序

typescript
import { useEffect } from 'react';

import { Button, Dialog } from 'ui';
import { ChildComp } from './child';

import { store } from '@/store'
import { useCookie } from '@/hooks/useCookie';
import { SOME_CONSTANTS } from '@/utils/constants';

import type { FC } from 'react';
import type { Foo } from '@/typings/foo';
import type { Shared } from '@/typings/shared';

import styles from './index.module.scss';

TsConfig配置

特殊属性

experimentalDecorators 与 emitDecoratorMetadata

主要用于装饰器相关,如NestJsIoc相关的框架或者工具中基本都会开启这个属性, 支持装饰器和元数据特性

target 与 lib

target决定构建代码的语法, 其中esnext代表最新的支持版本, 它会影响lib加载的对应类型文件, 如果你发现replaceAll提示不存在,通常就是指定的版本过低了

构建解析

files、include 与 exclude

files代表使用到的所有文件, 通常只在小项目中会用到, include和exclude则是通过正则匹配会用到的文件和排除文件

exclude中剔除的文件必须在include中存在

json
{
  "compilerOptions": {},
  "files": [
    "src/index.ts",
    "src/handler.ts"
  ],
  "include": ["src/**/*", "generated/*.ts", "internal/*"],
  "exclude": ["src/file-excluded", "/**/*.test.ts", "/**/*.e2e.ts"]
}

baseUrl

决定文件解析的根目录,通常是相对路径,根据tsocnfig.json的所在目录来确定,通常是"baseUrl":"./"

types 与 typeRoots

决定类型文件加载的逻辑,通常会默认加载/@types/下的所有声明文件,通过types可以指定类型

json
{
  "compilerOptions": {
    "types": ["node", "jest", "react"]
  }
}

而typeRoots则决定了 所有声明文件的加载来源

json
{
  "compilerOptions": {
    "typeRoots": ["./node_modules/@types", "./node_modules/@team-types", "./typings"],
    "types": ["react"],
    "skipLibCheck": true
  }
}

上面这个代码中,Ts还会从@team-types ./typings 下去加载所有类型声明文件

paths

类似打包构建的alias即别名,让类型导入简化

通常这个属性要配合baseUrl一起使用,更清晰的知道解析路径走向

json
{
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "@/utils/*": ["src/utils/*", "src/other/utils/*"]
    }
  }
}

resolveJsonModule

支持JSON文件的导入和类型支持

构建产物

noEmit 与 noEmitOnError

控制是否将产物写入, 如果没开启的情况下则是仅做类型检查、语法检查的操作,比如很多时候会见到的tsc --noEmit

module

控制产物的模块标准,通常不需要特别调整,默认使用ESNext保证最新的特性,如果是纯Node环境则可以用nodenext

declaration

是否产生声明文件,即.d.ts之类的文件,需要注意默认情况下不产生的

sourceMap

源文件映射, 根据使用场景决定是否开启

语法检查

noImplicitAny

没有为变量或参数指定类型,同时 TypeScript 也无法自动推导其类型时,这里变量的类型就会被推导为 any。 如果希望没有这种隐式类型导致的任意调用则开启这个配置,显式声明不影响

useUnknownInCatchVariables

改变try/catch 语法中的catch类型为unknown,而不是默认的any

noUnusedLocals 与 noUnusedParameters

是否允许存在声明但没使用的变量和函数参数

alwaysStrict

对所有文件开启严格模式并在产物写入严格声明

strict

开启内置的严格规则,包含:

text
alwaysStrict、useUnknownInCatchVariables
noFallthroughCasesInSwitch、noImplicitAny、noImplicitThis
strictNullChecks、strictBindCallApply、strictFunctionTypes、strictPropertyInitialization

strictNullChecks

这是一个必须开启的规则,它通常体现在联合类型中隐式的undefiend、null类型

typescript
const matcher: string = "budu";
const list = ['linbudu', '599'];
// 为 string 类型
const target = list.find((u) => u.includes(matcher)); 
// target为 string | undefined, 如果不开启规则就可能导致  cannot read property 'xxx' of undefined 

console.log(target.replace('budu', 'wuhu'));

工程模块

Project References

通常对于多个模块或者项目之间的引用,不同的模块项目之间可能使用的构建规则不一致,那么这个时候就需要用到引用属性了 假设有一个项目结构是:

text
PROJECT
├── app
│   ├── index.ts
│   ├── tsconfig.json
├── core
│   ├── index.ts
│   ├── tsconfig.json
├── ui
│   ├── index.ts
│   ├── tsconfig.json
├── utils
│   ├── index.ts
│   ├── tsconfig.json
├── tsconfig.base.json

关系是

text
app -> core, ui, utils
core -> utils
app模块
json

  "extends": "../tsconfig.base.json",
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "baseUrl": ".",
    "outDir": "../dist/app"
  },
  "include": ["./**/*.ts"],
  "references": [
    {
      "path": "../utils"
    },
    {
      "path": "../core"
    },
    {
      "path": "../ui"
    }
  ]
}
core模块
text
{
  "extends": "../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "baseUrl": ".",
    "outDir": "../dist/core"
  },
  "include": ["./**/*.ts"],
  "references": [
    {
      "path": "../utils"
    }
  ]
}
其他模块
json
{
  "extends": "../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "target": "ES5",
    "module": "commonjs",
    "baseUrl": ".",
    "outDir": "../dist/utils"
  },
  "include": ["./**/*.ts"]
}

接着在主程序中去引用其他模块的功能

typescript
import { util } from '../utils';
import { core } from '../core';
import { ui } from '../ui';

export const app = () => {
  ui();
  core();
  util();
  console.log('app!');
};

composite

使用引用属性时,必须在被引用的子项目开启此属性,以及declaration属性、includes或者files 得到类型输出和文件关系

extends

复用模块配置文件,类似ESlint中的extends