TSConfig 配置详解

INFO

本篇内容是学习小册《TypeScript 全面进阶指南》的学习笔记。

TSConfig作为 TypeScript 工程层面的重要部分,有必要了解一下各个配置项都代表着什么。

TSConfig 其实分为3个部分:构建相关类型检查相关及工程相关

构建相关

构建源码相关

先整体看一下构建相关的这一部分的 json 文件配置

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "jsx": "react",
    "jsxFactory": "React.createElement",
    "jsxFragmentFactory": "React.Fragment",
    "jsxImportSource": "react",
    "target": "es2018",
    "lib": ["es2018"],
    "noLib": true
  }
}

特殊语法相关

{
  "experimentalDecorators": true,
  "emitDecoratorMetadata": true,
}

上面这两个选项都与装饰器有关,experimentalDecorators用于开启装饰器的 @ 语法,emitDecoratorMetadata则影响装饰器实际运行时的元数据相关逻辑。

jsx/tsx语法相关

jsx/tsx 多数情况下多用于 Reactjsx/tsx转换为 React.createElement这样的调用,并生成*.js文件。

{
  "jsx": "react", /* preserve or react or react-jsx or react-jsxdev or react-native */
  "jsxFactory": "React.createElement", /* React.createElement or h */   
  "jsxFragmentFactory": "React.Fragment", /* React.Fragment or Fragment */
  "jsxImportSource": "react",
}
  • jsx 配置将直接影响 JSX 组件的构建表现,常见的主要有 react (将 JSX 组件转换为对 React.createElement 调用,生成 .js 文件)、preserve(原样保留 JSX 组件,生成 .jsx 文件
  • jsxFactory: 影响负责最终处理转换完毕 JSX 组件的方法,默认值为 React.createElement
  • jsxFragmentFactory: 类似 jsxFactory, 只影响 Fragment组件,默认值为React.Fragment
  • jsxImportSource: 当你的 jsx 设置为 react-jsx / react-jsxdev 时,指定你的 jsx-runtime / jsx-dev-runtime 从何处导入。在另一个类 React 框架 Solid 中,也将此配置修改为了自己的实现: "jsxImportSource": "solid-js"

target、lib 和 noLib

{
  "target": "es2018",
  "lib": ["es2018"],
  "noLib": true
}
  • target: 构建代码使用的语法,常用的配置项:es5, es6, es2018, esnext等,其中 exnext 代表着基于目前的 TypeScript 版本所支持的最新版本。
  • lib: 指定一组描述目标运行时环境的捆绑库声明文件,其中 target 选项的修改会影响到 lib 选项的默认值,而 lib 决定了你是否能使用某些来自于更新版本的 ECMAScript 语法。比如配置了 lib: ["es2021.Stirng"],那么声明文件 lib.es2021.string.d.ts就会被自动引入。
  • noLib: 如果不想使用 ts 提供的 lib 文件,可以开启这个选项

构建解析相关

这部分配置主要控制源码解析,包括从何处开始收集要构建的文件,如何解析别名路径等等

files、include 和 exclude

这三个选项决定了将被编译的代码文件

{
  "compilerOptions": {...},
  "files": [
    "src/index.ts",
    "src/handler.ts"
  ],
  "includes": [
    "src/**/*",
    "generated/*.ts",
    "internal/*"
  ],
  "excludes": "node_modules"
}
  • flies: files 可以用来描述本次包含的所有文件,但不能使用 src或者 src/*这种方式,files 中的每个值必须是完整的文件路径。如果你项目中文件数量比较多,又分散到各个文件夹,那么请使用 includeexclude进行配置。
  • include: 功能和 files 相似,但比 files 更灵活。include 中可以使用 global pattern的方式进行路径匹配。例如:src/**/*表示匹配 src下所有的合法文件,而无视目录层级。internal/*则只会匹配 internal 下的文件,不会匹配internal/utils/下的文件。这里的合法文件指的是,在不包括文件扩展名 *.ts的情况下只会匹配 .ts/ .tsx/ .d.ts /.js/.jsx 文件(js 和 jsx 文件需要启用 allowJs 配置时才会被包括)。
  • exclude: 用法基本同 include,只不过是排除文件,同样可以使用 global pattern规则。需要注意的是:exclude 只能剔除已经被 include 包含的文件。

baseUrl

baseUrl可以定义文件进行解析的根目录,它通常会是一个相对路径,然后配合 tsconfig.json 所在的路径来确定根目录的位置

project
├── out.ts
├── src
├──── core.ts
└── tsconfig.json
{
  "complierOptions": {
    "baseUrl": "./"
  }
}

如果将 baseUrl配置为 ./则文件的根目录就是 project。基于这个配置,你就可以在 out.ts中直接使用基于根目录的绝对路径导入文件。

import 'src/core' // TS 会自动解析到对应的文件,即 "./src/core.ts"

rootDir

{
  "compilerOptions": {
    "rootDir": "."
  }
}

rootDir 决定了项目文件的根目录,默认不需要配置,TS 会自动推算。举个🌰:

project
├── src
│   ├── index.ts
│   ├── app.ts
│   ├── utils
│   │   ├── helpers.ts
├── declare.d.ts
├── tsconfig.json

上面这个工程目录下,rootDir会被推断为 src

rootDirs

rootDirs 就是复数版本的 rootDir,它接收一组值,并且会将这些值均视为平级的根目录:

{
  "compilerOptions": {
    "rootDirs": ["src/zh", "src/en", "src/jp"]
  }
}

rootDirs主要用于实现多个虚拟目录的合并解析。

project
├── src
│   ├── zh
│   │   ├── locale.ts
│   ├── en
│   │   ├── locale.ts
│   ├── jp
│   │   ├── locale.ts
│   ├── index.ts
├── tsconfig.json

types 与 typeRoots

默认情况,TypeScript 回默认加载 node_modules/@types/下的所有声明文件,包括嵌套的 ../../node_modules/@types 路径,这么做可以方便使用第三方库的类型。如果你只想加载实际使用到的类型定义包,可以通过 types 配置:

{
	"compilerOpitons": {
		"types": ["node", "jest", "react"]
	}
}

以上配置只会加载 @types/node@types/jest@types/react 的类型定义。 如果开发中需要改变加载 @types/ 下文件的行为,就可以使用 typeRoots 选项,其默认行为是加载 @types, 即指定的 node_modules/@types 先的所有文件(包括嵌套文件)。

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

以上配置回尝试加载 ./node_modules/@types/react./node_modules/@team-types/react./typings/react 的声明文件,需要注意的是这些路径都是相对于 baseUrl 的。
以上配置中用到了 skipLibCheck 的配置,如果加载多个类型声明造成冲突的时候,可以选择开启这个选项来禁用掉加载类型声明的检查。

moduleResolution

这个配置指定了模块的解析策略,可以配置为 node 或者 classic,node 为默认值,classic 主要作向后兼容用,基本不推荐使用。
首先来看 node 解析模式,从名字也能看出来它其实就是与 node 一致的解析模式。假设我们有个 src/index.js,其中存在基于相对路径 const foo = require("./foo") 的导入,则会依次按照以下顺序解析:

  • /<root>/<project>/src/foo.js 文件是否存在?
  • /<root>/<project>/src/foo 是否是一个文件夹?
    • 此文件夹内部是否包含 package.json,且其中使用 main 属性描述了这个文件夹的入口文件?
    • 假设 main 指向 dist/index.js,那这里会尝试寻找 /<root>/<project>/src/foo/dist/index.js 文件
    • 否则的话,说明这个文件不是一个模块或者没有定义模块入口,我们走默认的 /foo/index.js 。

而对于绝对路径,即 const foo = require("foo"),其只会在 node_modules 中寻找,从 /<root>/<project>/src/node_modules 开始,到 /<root>/<project>/node_modules ,再逐级向上直到根目录。

TypeScript 在这基础上增加了对 .ts .tsx 和 .d.ts (优先级按照这一顺序)扩展名的文件解析,以及对 package.json 中 types 字段的加载。

而对于 classic 模式,其解析逻辑可能不太符合直觉,其相对路径导入与绝对路径导入均不会解析 node_modules 中的文件。对于相对路径导入 import foo from "./foo",它只会尝试 /<root>/<project>/src/foo.ts 和 /<root>/<project>/src/foo.d.ts

而对于绝对路径导入 import foo from "foo",它会按照以下顺序来解析:

  • /<root>/<project>/src/foo.ts(.d.ts)
  • /<root>/<project>/foo.ts(.d.ts)
  • /<root>/foo.ts(.d.ts)
  • /foo.ts(.d.ts)

moduleSuffixes

此配置在 TypeScript v4.7 之后被引入,类似 moduleResolution,但是解析的策略是模块后缀名。

{
	"compilerOpitons": {
		"moduleSuffixes": [".ios", ".native", ""],
	}
}

开启了此配置, ts 在解析文件的时候优先去查找 index.ios.ts, 然后是 index.native.ts,最后才是 index.ts

noResolve

默认情况下,TypeScript 会将你代码中导入的文件也解析为程序的一部分,包括 import 导入和三斜线导入,可以通过开启这个配置来阻止解析。 注意:虽然导入过程被禁用了,但是仍然要保证导入的模块是一个合法模块

// 开启此配置后,这个指令指向的声明文件将不会被加载! 
/// <reference path="./foo.d.ts" />

paths

paths 类似 webpack 配置中的 alias 别名,允许你自定义 @/utils 这种方式来简化导入路径,具体配置是:

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

注意:paths 的解析是基于 baseUrl 作为相对路径的,因此需要确定了 baseUrl 配置才可以让ts 解析正确的模块路径,配置项可以传入一个数组,ts 会一次解析这些路径,直到找到一个真实存在的路径

resolveJsonModule

开启此配置后可以直接导入 JSON 文件,并根据导入的内容获得完整的基于实际 JSON 内容的类型推导。

构建产物相关

构建输出相关

outDir 和 outFile

这两个配置决定了构建产物的输出文件。outDir 配置包括所有的构建产物,通常情况下会按照原有的目录结构构建:

dist 
├── core 
├──── handler.js 
├──── handler.d.ts 
├── index.js 
├── index.d.ts 
src 
├── core 
├──── handler.ts 
└── index.ts

outFile 类似 Rollup 或者 ESBuild 中的 bundle 选项,会将所有的产物打包为单个文件,但仅能在 module 选项为 None/System/AMD 的时候使用。

preserveConstEnums

由于常量枚举会在编译时被抹除,此配置可以阻止这一编译行为,让常量枚举可以想普通枚举编译为一个运行时存在的对象。

noEmit 与 noEmitOnError

这两个选项主要控制最终是否将构建产物实际写入文件系统中,其中 noEmit 开启时将不会写入,但仍然会执行构建过程,因此也就包括了类型检查、语法检查与实际构建过程。而 noEmitOnError 则仅会在构建过程中有错误产生才会阻止写入。 比如我们通过 ESBuild 或者 SWC 进行构建的时候,可以使用 tsc --noEmit 进行类型检查。

module

这一配置控制最终 JavaScript 产物使用的模块标准,常见的包括 CommonJs、ES6、ESNext 以及 NodeNext 等(实际的值也可以是全小写的形式)。另外也支持 AMD、UMD、System 等模块标准。

importHelpers 与 noEmitHelpers

类似 babel 语法降级,bable 在语法降级的过程中会使用到 corejs 或者 @babel/polyfill 提供的辅助函数。同样在 ts 编译的过程中,由于需要抹除类型系统,同时还要基于 target 配置对语法进行降级,所以也需要一些辅助函数。ts 中的辅助函数都被封装在了 tslib 中,通过开启 importHelpers 配置,就可以将这些辅助函数从 tslib 中导出而不是在源码中定义,能够有效的帮助减少构建产物体系。 如果你不想使用 tslib 中的辅助函数,而是采用自己实现的,可以开启 noEmitHelpers 选项,开启此选项后,源码仍使用辅助函数,但是不会从 tslib 中导入。 注意:开启了 noEmitHelpers 后,你需要在全局命名空间下提供同名的辅助函数的实现。

downlevelIteration

此配置主要用于解决当 for... of 语法被降级到 ES5 或者更低版本时,for 循环行为不一致的问题。开启这一配置,意味着 ts 在构建的过程中会引入辅助函数来解决。如果无法解决,则需要手动引入 polyfill

importsNotUsedAsValues 与 preserveValueImports

默认情况下,TypeScript 就在编译时去抹除仅类型导入(import type),但如果你希望保留这些类型导入语句,可以通过更改 importsNotUsedAsValues 配置的值来改变其行为。默认情况下,此配置的值为 remove,即对仅类型导入进行抹除。你也可以将其更改为 preserve,这样所有的导入语句都会被导入(但是类型变量仍然会被抹除)。或者是 error,在这种情况下首先所有导入语句仍然会被保留,但会在值导入仅被用于类型时产生一个错误。

举例来说,以下代码中的仅类型导入会在 preserve 或 error 时保留:

// foo.ts
export type FooType = any;

init();

// index.ts
import type { FooType } from "./foo";
import {} from "./foo";

这样 foo 文件中的 init()也就是副作用,仍然能够得到执行。

类似的,还有一个控制导入语句构建产物的配置,preserveValueImports。它主要针对的是值导入(即非类型导入或混合导入),这是因为在某些时候我们的值导入可能是通过一些奇怪的方式使用的:

import { Animal } from "./animal";

eval("console.log(new Animal().isDangerous())");

preserveValueImports 配置会将所有的值导入都保留下来,

如果你使用 Babel 等无法处理类型的编译器来构建 TS 代码(即启用了 isolatedModules 配置),由于它们并不知道这里到底是值导入还是类型导入,所以此时你必须将类型导入显式标记出来:

import { Animal, type AnimalKind } from "./animal;

// 或使用两条导入
import { Animal } from "./animal;
import type { AnimalKind } from "./animal;

当你同时启用了 isolatedModules 与 preserveValueImports 配置时,编辑器会严格约束你必须这么做。

声明文件相关

declaration、declarationDir

这两个选项主要控制声明文件的输出,其中 declaration 接受一个布尔值,即是否产生声明文件。而 declarationDir 控制写入声明文件的路径,默认情况下声明文件会和构建代码文件在一个位置,比如 src/index.ts 会构建出 dist/index.js 与 dist/index.d.ts,但使用 declarationDir 你可以将这些类型声明文件输出到一个独立的文件夹下,如 dist/types/index.d.ts dist/types/utils.d.ts 这样。

declarationMap

declarationMap 选项会为声明文件也生成 source map,这样你就可以从 .d.ts 直接映射回原本的 .ts 文件了。

emitDeclarationOnly

此配置会让最终构建结果只包含构建出的声明文件(.d.ts),而不会包含 .js 文件。类似于 noEmit 选项,你可以使用其他构建器比如 swc 来构建代码文件,而只使用 tsc 来生成类型文件。

Source Map 相关
  • sourceMap 与 inlineSourceMap 有些类似于 Webpack 中的 devtool 配置,控制是生成 .map.js 这样独立的 source map 文件,还是直接将其附加在生成的 .js 文件中。这两个选项是互斥的。
  • inlineSources 这一选项类似于 source map,只不过它是映射到原本的 .ts 文件,也就是你可以从压缩过的代码直接定位到原本的 .ts 文件。
  • sourceRoot 与 mapRoot,这两个选项通常供 debugger 消费,分别用于定义我们的源文件与 source map 文件的根目录。

构建产物代码格式化配置

  • newLine,指定文件的结尾使用 CRLF 还是 LF 换行风格。其中 CRLF 其实就是 Carriage Return Line Feed ,是 Windows(DOS)系统下的换行符(相当于 \r\n),而 LF 则是 Line Feed,为 Unix 下的换行符(相当于 \n)。
  • removeComments,移除所有 TS 文件的注释,默认启用。
  • stripInternal 这一选项会阻止为被标记为 internal 的代码语句生成对应的类型,即被 JSDoc 标记为 @internal。推荐的做法是为仅在内部使用而没有导出的变量或方法进行标记,来减少生成代码的体积。
/**
  * @internal
  */
const SECRET_KEY = "Shinji";

以上这段代码不会生成对应的类型声明。

类型检查相关

这部分的配置主要控制对源码中语法与类型检查的严格程度。

允许类

这一类通常名称以 allowXXX 的形式出现,这些配置关注的语法一般都是有害的,默认情况下都是禁用或者给出警告的,需要手动开启。

allowUmdGlobalAccess

这一配置会允许你直接使用 UMD 格式的模块而不需要先导入,比如通过 CDN 引入或者是任何方式确保全局一定有这个变量。

allowUnreachableCode

Unreachable Code 通常指的是无法执行到的代码,也称 Dead Code,常见的 Unreachable Code 包括 return 语句、throw 语句以及 process.exit 后的代码:

function foo() {
  return 599;
  console.log('Dead Code'); // Dead Code
}

function bar() {
  throw new Error('Oops!');
  console.log('Dead Code'); // Dead Code
}

allowUnreachableCode 配置的默认值为 undefined,表现为在编译过程中并不会抛出阻止过程的错误,而只是一个警告。它也可以配置为 true(完全允许)与 false (抛出一个错误)。

allowUnusedLabels

这个不常见,Label 的语法为:

someLabel: 
	statement

statement 语句会被标记为 someLabel ,然后在别的地方你就可以用 someLabel 来引用这段语句。此配置禁止了声明但没有被实际使用的 label 标记。

禁止类

这部分配置的关注点其实除了类型,也包括实际的代码逻辑,它们主要关注未被妥善处理的逻辑代码与无类型信息(手动标注与自动推导均无)的部分,这部分配置的值通常只有 true 或者 false。

类型检查

noImplicitAny

在你没有为变量或参数指定类型,同时 TypeScript 也无法自动推导其类型时,这里变量的类型就会被推导为 any,此选项禁止类型被自动推断为 any,需要手动声明类型。但你仍然可以手动声明一个类型为 any。

useUnknownInCatchVariables

启用此配置后,try/catch 语句中 catch 的 error 类型会被更改为 unknown (否则是 any 类型)。这样可以在类型层面确保在 catch 语句中对 error 进行更妥当的处理:

try {
  // ...
  // 一个自定义的错误类
  throw new NetworkError();
} catch (err) {
  if (err instanceof NetworkError) {}
  if (err instanceof AuthError) {}
  if (err instanceof CustomError) {}
}

逻辑检查

noFallthroughCasesInSwitch

这一配置确保在你的 switch case 语句中不会存在连续执行多个 case 语句的情况。主要原因是在 JS 中如果不手动 break 或者 return,程序就会继续执行下一个 case 语句。

noImplicitOverride

noImplicitOverride 这一配置的作用,就是避免你在不使用 override 关键字的情况下就覆盖了基类方法。原因是在派生类继承于基类时,通常我们不希望去覆盖基类已有的方法(SOLID 原则),这样可以确保在任何需要基类的地方,我们都可以放心地传入一个派生类。在真的需要覆盖基类方法时,推荐的方式是使用 override 关键字,标明此方法覆盖了基类中的方法。

class Base {
	print() { }
}
class Derived1 extends Base {
	override print() {
		// ...
	}
}
class Derived2 extends Base {
	// 错误!没有使用 override 关键字
	print() {
		// ...
	}
}
noImplicitReturns

这一配置会确保所有返回值类型中不包含 undefined 的函数,在其内部所有的执行路径上都需要有 return 语句。

noImplicitThis

在 ts 中,比如函数与 Class 的方法中,实际上第一个参数是 this:

function foo(this: any, name: string) {}

这个 this 参数实际上就是函数执行时指向的 this,你可以在实际情况中灵活地指定 this 为具体类型。如果你并不声明 this 类型而是直接访问,就会得到一个错误:

function foo(name: string) { 
	// "this" 隐式具有类型 "any",因为它没有类型注释。 
	this.name = name; 
}
noPropertyAccessFromIndexSignature 与 noUncheckedIndexedAccess

这两条配置的功能是让对基于索引签名类型声明的结构属性访问更安全一些,其中 noPropertyAccessFromIndexSignature 配置禁止了对未知属性(如 'unknownProp')的访问,即使它们已在索引类型签名中被隐式声明。 而 noUncheckedIndexedAccess 配置则宽松一些,它会将一个 undefined 类型附加到对未知属性访问的类型结果上,比如 PropType1 的类型会是 string | undefined,这样能够提醒你在对这个属性进行读写时进行一次空检查。

interface AllStringTypes {
	name: string;
	[key: string]: string;
}

type PropType1 = AllStringTypes['unknownProp']; // string
type PropType2 = AllStringTypes['name']; // string
noUnusedLocals 与 noUnusedParameters

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

严格检查

exactOptionalPropertyTypes

当你声明一个类型为可选属性的时候,比如:

interface Theme {
	color?: 'dark' | 'light'
}

此类型会被推导为:"dark" | "light" | undefined,开启了此选项后,会移除 undefined 类型。

// 类型 “undefined” 不能分配给“exactOptionalPropertyTypes: true”的类型 “"dark" | "light"”。请考虑将 “undefined” 添加到目标类型。 
theme.color = undefined
// 你需要这么做
interface Theme {
	color: 'dark' | 'light' | undefined
}

strict

一组规则的开关,开启 strict 会默认开启全部规则:

  • alwaysStrictuseUnknownInCatchVariables
  • noFallthroughCasesInSwitchnoImplicitAnynoImplicitThis
  • strictNullChecksstrictBindCallApplystrictFunctionTypesstrictPropertyInitialization

alwaysStrict

此配置是ES5 中严格模式在 TS 中的体现,开启此配置会使 TS 对所有文件开启严格检查,具体表现可能会禁用掉部分语法。同时,编译后的 js 产物开头也会自动注入 use strict 标记。

strictBindCallApply

JavaScript 中可以通过 bind、call、apply 来改变一个函数的 this 指向,绝大部分情况下即使改变了 this 指向,函数的入参也应当是不变的。这条配置会确保在使用 bind、call、apply 方法时,其第二个入参(即将用于调用原函数的入参)需要与原函数入参类型保持一致:

function fn(x: string) {
  return parseInt(x);
}

const n1 = fn.call(undefined, '10');

// 类型“boolean”的参数不能赋给类型“string”的参数。
const n2 = fn.call(undefined, false);

strictFunctionTypes

此配置对函数类型启用更严格的检查,对参数类型启用逆变检查。

function fn(x: string) {
  console.log('Hello, ' + x.toLowerCase());
}

type StringOrNumberFunc = (ns: string | number) => void;

// 不能将类型“string | number”分配给类型“string”。
let func: StringOrNumberFunc = fn;

需要注意的是,对于接口中的函数类型,只有通过 property 形式声明才会接受严格检查,即以下代码不会被检查出错误:

type Methodish = {
  func(x: string | number): void;
};
 
function fn(x: string) {
  console.log("Hello, " + x.toLowerCase());
}
 
const m: Methodish = {
  // 没有对函数参数类型进行逆变检查
  func: fn,
};

// 实际运行将会报错
m.func(10);
// [ERR]: x.toLowerCase is not a function

strictNullChecks

这是在任何规模项目内都应该开启的一条规则。在这条规则关闭的情况下,null 和 undefined 会被隐式地视为任何类型的子类型,在某些可能产生 string | undefined 类型的方法中,如果关闭了 strictNullChecks 检查,就意味着很可能下面会遇到一个 cannot read property 'xxx' of undefined 的错误:

const matcher: string = "ikari";

const list = ['ikari', 'shinji'];

// 为 string 类型
const target = list.find((u) => u.includes(matcher));

console.log(target.replace('ikari', 'rei')); // 'target' is possibly 'undefined'.

此时 target 的类型被推导为 string | undefined ,所以会报错。

strictPropertyInitialization

这一配置要求 Class 中的所有属性都需要存在一个初始值,无论是在声明时就提供还是在构造函数中初始化。

class Foo {
  prop1: number = 599;
  prop2: number;
  // 属性“prop3”没有初始化表达式,且未在构造函数中明确赋值。
  prop3: number;

  constructor(public prop4: number) {
    this.prop2 = prop4;
  }
}

这条配置有时候也不完全合理,如我们将初始化逻辑放在一个单独函数中:

class Foo {
  prop1: number = 599;
  prop2: number;
  // 属性“prop3”没有初始化表达式,且未在构造函数中明确赋值。
  prop3: number;

  constructor(public prop4: number) {
    this.prop2 = prop4;
    this.init();
  }

  init() {
    this.prop3 = 599;
  }
}

此时报错仍然存在,但我们其实已经确保了有初始值的存在。这种情况下可以依据实际需要使用非空断言或可选修饰:

class Foo {
  prop3!: number;
  _prop3?: number;
}

skipLibCheck 与 skipDefaultLibCheck

默认情况下,TypeScript 会对加载的类型声明文件也进行检查,包括内置的 lib.d.ts 系列与 @types/ 下的声明文件。在某些时候,这些声明文件可能存在冲突,比如两个不同来源的声明文件使用不同的类型声明了一个全局变量。此时,你就可以使用 skipLibCheck 跳过对这些类型声明文件的检查,这也能进一步加快编译速度。 skipDefaultLibCheck 类似于 skipLibCheck ,但它只会跳过那些使用了 /// <reference no-default-lib="true"/> 指令的声明文件(如内置的 lib.d.ts),这一三斜线指令的作用即是将此文件标记为默认库声明,因此开启这一配置后,编译器在处理其文件时不会再尝试引入默认库声明。

工程相关

Project References

Project References 这一配置使得你可以将整个工程拆分成多个部分,比如你的 UI 部分、Hooks 部分以及主应用等等。这一功能和 Monorepo 非常相似,但它并不需要各个子项目拥有自己独立的 package.json、独立安装依赖、独立构建等。通过 Project References ,我们可以定义这些部分的引用关系,为它们使用独立的 tsconfig 配置。

{
    "compilerOptions": {},
    "references": [
        { "path": "../ui-components" },
        { "path": "../hooks" },
      	{ "path": "../utils" },
    ]
}

composite

composite 属于 compilerOptions 内部的配置,在 Project References 的被引用子项目 tsconfig.json 中必须为启用状态,它通过一系列额外的配置项,确保你的子项目能被 Project References 引用,而在子项目中必须启用 declaration ,必须通过 files 或 includes 声明子项目内需要包含的文件等。

兼容性

isolatedModules

构建过程会使用 TypeScript 配合其他构建器,如 ESBuild、SWC、Babel 等。通常在这个过程中,类型相关的检查会完全交由 TypeScript 处理,因为这些构建器只能执行语法降级与打包。

由于这些构建器通常是独立地处理每个文件,这也就意味着如果存在如类型导入、namespace 等特殊语法时,它们无法像 tsc 那样去全面分析这些关系后再进行处理。此时我们可以启用 isolatedModules 配置,它会确保每个文件都能被视为一个独立模块,因此也就能够被这些构建器处理。

启用 isolatedModules 后,所有代码文件(不包括声明文件)都需要至少存在一个导入或导出语句(比如最简单的情况下可以使用 export {}),无法导出类型(ESBuild 并不知道这个值是类型还是值)以及无法使用 namespace 与常量枚举(常量枚举在构建后会被内联到产物中)。

除了这些构建器以外,isolatedModules 配置也适用于使用 TS Compiler API 中的 transpileModule 方法,这个方法类似于 Babel,不会生成声明文件,只会进行单纯的语法降级。

JavaScript 相关

allowJs

只有在开启此配置后,你才能在 .ts 文件中去导入 .js / .jsx 文件。

checkJs

checkJs 通常用于配合 allowJs 使用,为 .js 文件提供尽可能全面的类型检查。

如果你希望禁用对部分 JavaScript 文件的检查,或者仅对部分 JavaScript 文件进行检查,可以对应地使用 @ts-nocheck 和 @ts-check

模块相关

esModuleInterop 与 allowSyntheticDefaultImports

这两个配置主要还是为了解决 ES Module 和 CommonJS 之间的兼容性问题。 通常情况下,ESM 调用 ESM,CJS 调用 CJS,都不会有问题。但如果是 ESM 调用 CJS ,就可能遇到奇怪的问题。比如 React 中的源码中是这样导出的:

// react/cjs/react.development.js
exports.Children = Children;
exports.useState = useState;
exports.memo = memo;
exports.useEffect = useEffect;

假设我们分别使用具名导入、默认导入和命名空间导入来导入 React:

import { useRef } from "react"; // 具名导入(named import)
import React from "react"; // 默认导入(default import)
import * as ReactCopy from "react"; // 命名空间导入(namespace import)

console.log(useRef);
console.log(React.useState)
console.log(ReactCopy.useEffect)

这样的代码在默认情况下(即没有启用 esModuleInterop)会被编译为:

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const react_1 = require("react");
const react_2 = require("react");
const ReactCopy = require("react");
console.log(react_1.useRef);
console.log(react_2.default.useState);
console.log(ReactCopy.useEffect);

可以看到,默认导入的调用被转换为了 react_2.default,而具名导入和命名空间则不变,三种导入语句都被转换为了 CJS。

这是因为 TypeScript 默认将 CommonJs 也视为 ES Module 一样,对于具名导入,可以直接将 module.exports.useRef = useRef 和 export const useRef = useRef 等价。但是由于 CommonJs 中并没有这个“默认导出”这个概念, 只能将 ES Module 中的默认导出 export default 强行等价于 module.exports.default,如上面的编译结果中的 react_2.default。这里的 default 就是一个属性名,和 module.exports.foo 是一个概念。

但 CommonJs 下存在着类似“命名空间导出”的概念,即 const react = require("react") 可以等价于 import * as React from "react"

很明显,对于默认导出的情况,由于 React 中并没有使用 module.exports.default 提供(模拟)一个默认导出,因此 react_2.default 只可能是 undefined。

为了解决这种情况,TypeScript 中支持通过 esModuleInterop 配置来在 ESM 导入 CJS 这种情况时引入额外的辅助函数,进一步对兼容性进行支持,如上面的代码在开启配置后的构建产物会是这样的:

var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { //... }));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { //... });
var __importStar = (this && this.__importStar) || function (mod) { //... };
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
  
Object.defineProperty(exports, "__esModule", { value: true });
const react_1 = require("react");
const react_2 = __importDefault(require("react"));
const ReactCopy = __importStar(require("react"));
console.log(react_1.useRef);
console.log(react_2.default.useState);
console.log(ReactCopy.useEffect);

这些辅助函数会确保 ESM 的默认导入(__importDefault) 与命名空间导入 (__importStar)能正确地对应到 CJS 中的导出,如 __importDefault 会检查目标模块的使用规范,对 ESM 模块直接返回,否则将其挂载在一个对象的 default 属性上:

const react_2 = __importDefault(require("react"));

// 转换结果等价于以下
const react_2 = { default: { useState: {} } }

而 __importStar (即命名空间导入的辅助函数)的实现则要复杂一些:

var __importStar = (this && this.__importStar) || function (mod) {
    if (mod && mod.__esModule) return mod;
    var result = {};
    if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
    __setModuleDefault(result, mod);
    return result;
};

它会在目标模块不是 ESM 规范时,将模块中除了 default 属性以外的导出都挂载到返回对象上(__createBinding),然后将这个对象的 default 属性设置为原本的模块信息(__setModuleDefault)。这样你既可以 ReactCopy.useEffect 访问某个值,也可以 ReactCopy.default 访问原本的模块。

这些辅助方法也属于 importHelpers 中的 helper,因此你也可以通过启用 importHelpers 配置来从 tslib 导入这些辅助方法。

实际上,由于 React 本身是通过 CommonJs 导出的,在你使用默认导入时, TS 也会提醒你此模块只能在启用了 esModuleInterop 的情况下使用默认导入。

启用 esModuleInterop 配置的同时,也会启用 allowSyntheticDefaultImports 配置,这一配置会为没有默认导出的 CJS 模块“模拟”出默认的导出,以提供更好的类型提示。如以下代码:

// handlers.js
module.exports = {
  errorHandler: () => {}
}

// index.js
import handlers from "./handlers";

window.onerror = handlers.errorHandler;

虽然这段代码转换后的实际逻辑没有问题,但由于这里并不存在 module.exports.default 导出,会导致在类型上出现一个错误。

启用 allowSyntheticDefaultImports 配置会在这种情况下将 handlers 中的代码模拟为以下的形式:

const allHandlers = {
  errorHandler: () => {}
}

module.exports = allHandlers;
module.exports.default = allHandlers;

然后在导入方就能够获得正确的类型提示了,实际上这也是 Babel 实际的构建效果,但需要注意的是在 TypeScript 中 allowSyntheticDefaultImports 配置并不会影响最终的代码生成(不像 esModuleInterop 那样),只会对类型检查有帮助。

编译器相关

incremental

incremental 配置将启用增量构建,在每次编译时首先 diff 出发生变更的文件,仅对这些文件进行构建,然后将新的编译信息通过 .tsbuildinfo 存储起来。你可以使用 tsBuildInfoFile 配置项来控制这些编译信息文件的输出位置。

watch 相关

我们可以通过 tsc --watch 来启动一个监听模式的 tsc,它会在代码文件发生变化(同样会对 node_modules 文件夹的变化进行监听,但只到文件夹级别)时重新进行编译。通常我们会搭配 incremental 选项。

你可以通过与 compilerOptions 同级的 watchOptions 来配置监听行为:

{
  "compilerOptions": {
    "target": "es2020",
    "moduleResolution": "node"
    // ...
  },
  "watchOptions": {
    // 如何监听文件
    "watchFile": "useFsEvents",
    // 如何监听目录
    "watchDirectory": "useFsEvents",
    "fallbackPolling": "dynamicPriority",
    "synchronousWatchDirectory": true,
    "excludeDirectories": ["**/node_modules", "_build"],
    "excludeFiles": ["build/fileWhichChangesOften.ts"]
  }
}

对于 watchFile 与 watchDirectory 选项,TS 提供了 useFsEvents(使用操作系统的原生事件来进行监听)、fixedPollingInterval(不进行具体监听,而只是在每秒以固定的时间间隔后去检查发生变更的文件)、priorityPollingInterval(类似 fixedPollingInterval ,但对某些特殊类型文件的检查频率会降低)、dynamicPriorityPolling(对变更不频繁的文件,检查频率降低)、useFsEventsOnParentDirectory(对文件/目录的父文件夹使用原生事件监听) 等数个监听方式选择。

其他常用的选项则主要是用于减小监听范围的 excludeDirectories 与 excludeFiles 。

编译器检查

这里的配置主要用于检查编译器的工作情况,或者在你需要进行编译器性能优化时使用,它们会生成编译器工作的分析报告,包括本次编译包含了哪些文件,以及各个编译阶段(I/O、Type Checking 等)的耗时。

  • diagnostics 与 extendedDiagnostics,输出诊断信息,其中 diagnostics 会生成可读性更好的版本。
  • generateCpuProfile,生成 CPU 的耗时报告,用于了解构建缓慢的可能原因。
  • listFiles 与 listEmittedFiles,其中 listFiles 会罗列所有被纳入本次编译过程的文件,可以用于检查是否携带了非预期的文件。而 listEmittedFiles 则会罗列输出的文件,你可以利用这些文件信息进行额外处理,比如拷贝文件。
  • traceResolution,输出一份跟踪模块解析策略与路径的信息,比如这样:
    ======== Resolving module 'typescript' from 'src/app.ts'. ========
    Module resolution kind is not specified, using 'NodeJs'.
    Loading module 'typescript' from 'node_modules' folder.
    File 'src/node_modules/typescript.ts' does not exist.
    File 'src/node_modules/typescript.tsx' does not exist.
    File 'src/node_modules/typescript.d.ts' does not exist.
    File 'src/node_modules/typescript/package.json' does not exist.
    File 'node_modules/typescript.ts' does not exist.
    File 'node_modules/typescript.tsx' does not exist.
    File 'node_modules/typescript.d.ts' does not exist.
    Found 'package.json' at 'node_modules/typescript/package.json'.
    'package.json' has 'types' field './lib/typescript.d.ts' that references 'node_modules/typescript/lib/typescript.d.ts'.
    File 'node_modules/typescript/lib/typescript.d.ts' exist - use it as a module resolution result.
    ======== Module name 'typescript' was successfully resolved to 'node_modules/typescript/lib/typescript.d.ts'. ========
    

其它工程相关

extends

这一配置可以类比到 ESLint 配置中的 extends,作用就是复用已有的文件,在这里即是一个已存在的 TSConfig 配置文件。其作用包括在 Monorepo 下统一各个子项目的基础配置:

// <root>/packages/pkg/tsconfig.json
{
  "extends": "../../tsconfig.base.json"
}

// <root>/tsconfig.base.json
{
  "compilerOptions": { }
}

或者在团队的所有项目间使用基本统一的配置:

{
  "extends": "team-config/tsconfig.json"
}

其中 team-config 是一个 npm 包。

完整配置文件

你可以使用 tsc --init来创建一个默认的 TSConfig 文件, 或者点我 阅读更多关于 TSConfig 的配置详解