Typescript学习(二十)配置总结

227 阅读21分钟

我们之前学习的都是Typescript的一些上层用法, 期间也介绍过配置相关的内容, 诸如: noImplicitAny、strictNullChecks等, 但都是简单带过, 现在, 我们就开始系统化熟悉下tsconfig.json中的常见配置

源码相关

experimentalDecorators

experimentalDecorators, 其实看名字就知道了, 就是启用装饰器, 即允许我们在源码中使用装饰器, 前面两节的内容都是基于开启了experimentalDecorators配置进行的, 该配置项默认是关闭的, 所以必须手动开启才能使用!

emitDecoratorMetadata

emitDecoratorMetadata是一个编译选项, 作用在于开启装饰器元数据; 这么说可能不清晰, 还记得上一节中我们利用Reflect.getMetadata('design:type', prototype, propName)来获取属性类型吗? 在那个案例中, 我们利用了内置的元数据design:type获取了成员的类型

function Inject(key?: string): PropertyDecorator {
  return function (prototype: any, propName: string | symbol) {
    Container.regsiterProperty(
      `${prototype.constructor.name}:${String(propName)}`,
      key ?? Reflect.getMetadata('design:type', prototype, propName)
    );
  };
}

@Provide()
class Feul {
  NO: string = '95#';
}

@Provide()
class Car {
  @Inject()
  feul!: Feul;
  constructor() {}
  run() {
    console.log(this.feul.NO);
  }
}

在这个案例中, 如果我们没有给Inject装饰器传递任何参数, 那么, 就会使用design:type, 来获取成员类型, 以此作为从第一个映射表中获取类型值的键, 如果我们关闭emitDecoratorMetadata选项, 即设为false, 那么这个案例在没有给Inject传參的情况下, 会在运行时报错! 因为design:type元数据根本获取不到任何信息!

该选项同样默认关闭, 如需使用必须手动开启;

jsx

jsx配置主要控制的就是jsx语法的编译结果, 其值有: preserve、react-native、react、react-jsx、react-jsxdev;

preserve: 顾名思义, 就是保持jsx的语法不变, 生成.jsx文件

// 源码 .tsx
import React from 'react';
const component = () => <div>123</div>;

// 产物 .jsx
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const react_1 = __importDefault(require("react"));
const component = () => <div>123</div>;

react-native: react-native和preserve产物是一样的, 只是生成的文件是.js文件

// .jsx
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const react_1 = __importDefault(require("react"));
const component = () => <div>123</div>;

react: react则是将jsx代码转为react.createElement的形式

// .js
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const react_1 = __importDefault(require("react"));
const component = () => react_1.default.createElement("div", null, "123");

react-jsx: 启用react/jsx-runtime进行代码编译; react/jsx-runtime是react17新出的代码转换工具包, 在这之前, react中的jsx通常都转为react.createElement的形式, 而jsx-runtime则提供了更多的优化, 例如: 减小编译后的体积、减少运行时开销、更好的类型检查等等;

// .js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const jsx_runtime_1 = require("react/jsx-runtime");
const component = () => (0, jsx_runtime_1.jsx)("div", { children: "123" });

react-jsxdev: 启用react/jsx-dev-runtime进行代码编译, 其实就是jsx-runtime的开发环境版, 提供了一些调试工具

// .js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const jsx_dev_runtime_1 = require("react/jsx-dev-runtime");
const _jsxFileName = "/Users/wangy/IT/eslint-demo/src/component.tsx";
const component = () => (0, jsx_dev_runtime_1.jsxDEV)("div", { children: "123" }, void 0, false, { fileName: _jsxFileName, lineNumber: 2, columnNumber: 24 }, this);
console.log('🚀 ~ file: component.tsx:3 ~ component:', component());

其他的jsx相关的配置

jsxFactory: 其实就是jsx: 'react'时的附属配置项, 决定调用的函数, 默认为React.createElement; 可以将其改为'h', 即启用preact.h方法

jsxFragmentFactory: jsxFactory影响的是普通jsx组件, 则jsxFragmentFactory只针对Fragment组件, 即空白标签组件<></>; 值为处理Fragment组件的方法

jsxImportSource: jsx: 'react-jsx' 或 'react-jsxdev'的附属选项, 其值就是jsx-runtime/jsx-dev-runtime的提供方; 即 决定了jsx-runtime/jsx-dev-runtime从哪里引入;

target

target很好理解, 就是编译后的代码的es版本, 比如es5、es6、es2022这ECMAScript版本号, 如果我们指定target为es5, 那么, 诸如箭头函数、async await 这类较高版本的语法就会被转换;

{
  "compilerOptions": {
   "target": "ES5", 
  }
}
// 源码 .ts
const fn = () => {
  console.log(111)
}
fn()
// --target es5
"use strict";
var fn = function () {
    console.log(111);
};
fn();

相应的, 如果target版本足够高, 已经高于所用的语法了, 那么, 箭头函数就会被保留

"use strict";
const fn = () => {
  console.log(111);
};
fn();

lib

lib 的值是一个数组; 表示开发阶段, 需要引入的库, 我们知道Typescript有许多内置的库, 比如我们之前的很多工具类型, 其实也是源于Typescript的内置库, 其位置就在typescript文件根目录中

通常情况下, lib会被自动导入; 比如, 当我们的target设置为ES2021的时候, lib其实会自动导入ES2021这个库的所有内容; 如果你所需要用到的语法需要用到指定的编译库, 那么就需要特别指明

// 源码 .ts
let str = '1'
str.replaceAll('1', '2')

此时由于replaceAll是es2021的方法, 而我们的target为es6, 所以此时会提示错误;

此时可以修改lib配置项

{
  "compilerOptions": {
    // ...
    "lib": ["ES2021.String"], 
    // ...
  }
}

这样, 就不会报错了;

但是要注意, 如果你此时设置了lib这个配置项, 那么你所设置的这一项将会代替掉默认导入内容! 接上面的案例, 我们现在将target设置为ES2021, 但是, 将lib设置为ES2021.Promise; 注意, 哪怕他们都是同一个版本下的库, 可会造成覆盖:

{
  "compilerOptions": {
    // ...
    "target": "ES2021",                               /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
    "lib": ["ES2021.Promise"]
    // ...
  }
}

此时, 报错又出现了:

当然, 这种情况仅仅针对那些需要引入特殊库的语法, 对于一些相对没那么新的语法, 比如: Promise、startsWith等, 是不怕这种覆盖的

noLib

noLib值为一个boolean类型, 这个选项一旦设为true, 除了一些外部依赖, 比如@types/node所声明的内容, typescript这个包中的一切类型声明的库, 都将被禁止导入! 也就是说, 一旦开启, 你要重新为所有的对象、数组、字符串声明类型!否则你将失去绝大多数类型提示;

构建解析

files、include、exclude

这三个配置之所以放在一起讲解, 是因为他们都是规定本次编译的范围! 只不过具体功能有所不同;

files就是告诉编译器, 本次需要编译那几个文件; 接受一个数组作为参数值

{
 "files": ["./test.ts"],
}

注意: files、include、exclude三个配置均不在compilerOptions之内!

include, 则是告诉编译器, 哪些文件需要被编译, 注意, files毕竟只能指定文件, 而当项目日益变大之后, 配置也会越来越多, 指定特定的文件, 显然是不合适的, 所以还必须要有一种'杀伤范围'更大的‘武器’, 这就是include, 它接受一个数组, 使用glob pattern格式

{
  "include": ["./src/**/*", "./build/*", "util/*.ts"],
}

配置的含义分别为:

  1. 匹配src下的所有文件, 无视层级;
  2. 匹配build下的合法文件, 仅此一层, ./build/types/这种级别的文件不在考虑范围;
  3. 匹配util下的所有.ts文件, 和上面一样, 仅此一层;

上面说的合法文件, 指的是.ts、.tsx、.d.ts、.js、.jsx, 其中js必须开启allowJs配置项

但是有的时候, 我们include匹配过头了, 可能把一些不想要的文件也匹配进来了, 那怎么办? 这些文件也是合法的, 此时, 就需要用到exclude, 从include中去删除个别文件了! 注意, 是从include中; 如果一个文件本就不属于include匹配范围内, 那exclude也是无效的;

{
  "include": ["./src/**/*", "./build/*", "util/*.ts"],
  "exclude": ["./src/**/*.ignore.ts"]
}

baseUrl

baseUrl表示的是, 解析绝对路径时的根目录,例如: import util from 'util/array', 这个util表示在根目录下的util文件夹, 那么哪里是根目录? 此时, 就需要由baseUrl来确定了

{
  "compilerOptions": {
    "baseUrl": "./",  
  }
}

这个配置表示, 根目录就在tsconfig.json所在的目录下, 此时, util文件夹必须和tsconfig.json同级; 而如果我们将baseUrl改为"./src/", 则根目录就会变成src下的目录, util也就必须在src下; 否则就会提示错误; 不过要注意: baseUrl只会影响绝对路径, 对于大多数相对路径的是没有影响的, 除了paths这个配置项, 这个后续会介绍; 另外, baseUrl仅仅只是提供一个提示, 它不会对编译后的内容产生任何的改变, 例如使用如下配置:

{
  "compilerOptions": {
    "baseUrl": "./src",  
  }
}

代码编译后不会发生改变:

// 源码
import util from 'util/array'
// 编译后
import util from 'util/array'

我们会发现, 编译后的代码并没有被转为./src/util/array, 因此, baseUrl仅作为一个提示存在, 它不参与编译;

rootDir

rootDir, 从这个名字就能看出,它也代表根目录, 那这就奇怪了, 有了baseUrl, 还要它作甚? 其他它这个根路径和baseUrl的起的作用不同; rootDir可以说表示的是能够参与编译的根目录; 它的默认值为ts文件的最大公共路径

projectRoot
├── src
│   ├── index.ts
│   ├── utils
│   │   ├── index.ts
├── declare.d.ts
├── tsconfig.json

以上目录结构中的ts的最大公共路径就是src, rootDir默认值也是./src, 因为src外面再无.ts文件了, .d.ts声明文件不算; 而如果我们在src外再加一个.ts文件

projectRoot
├── src
│   ├── index.ts
│   ├── utils
│   │   ├── index.ts
├── declare.d.ts
├── newFile.ts // 新增
├── tsconfig.json

则此时的rootDir的默认值就是这个工程的根目录, 即rootDir: "./"; 那么, 知道这个根路径有啥用呢? 注意, ts编译器会将rootDir表示的根目录中的所有可编译(include或范围内)内容, 都编译并放到目标文件夹内!以上两种文件编译后的结构分别为:

"rootDir": "./"

dist
├── src
│   ├── index.js
│   ├── utils
│   │   ├── index.js
├── newFile.js

"rootDir": "./src"

dist
├── index.js
├── utils
│   ├── index.js

注意了, 上面都是rootDir范围大于include的情况, 那如果反过来呢? 万一include比rootDir还大呢? 当然, 现实中不可能, 因为如果你这么干了, 你只会收到一条报错! 如果你无视报错强行编译, 那么, 内容还是会以rootDir的为准! 即 只编译更小范围的那部分! 当然, 我们最好别这样, 还是要让include的内容, 在rootDir的范围内;

rootDirs

rootDirs其实顾名思义就是多个根目录, 虽然名字和rootDir很像, 但是它和rootDir并不相同, 它指定了多个目录为根路径, 但是它不会影响编译结果, 它的意义在于虚拟目录的合并;假设我们的目录结构如下:

projectRoot
├── root-1
│   ├── index1.ts
├── root-2
│   ├── index2.ts
├── root-3
│   ├── index3.ts
├── tsconfig.json

如果此时我们在root-1/index1.ts中要引入index2.ts,势必要这样写

import xx from '../root-2/index2'

而如果我们增加了如下配置:

{
	"compilerOptions": {
    "rootDirs": ["root-1","root-2","root-3"],
  }
}

我们就可以直接引入, 就好像他们三个index文件在同一个目录下一样

import xx from './index2'

但是要注意, 编译结果不会因这个配置而改变! 这里只是虚拟的一个路径

types 和 typeRoot

默认情况下, ts会自动加载node_modules/@types中的所有文件, 而types, 就是指定加载某几项

{
	"compilerOptions": {
    "types": ["node","react"], // 仅加载@types/node 以及@types/react
  }
}

前面说了, 系统会自动从node_modules/@types下搜索对应的文件, 但是, 如果我们想自定义类型声明文件加载路径, 可以修改typeRoot配置项

{
	"compilerOptions": {
    "typeRoot": ["node_modules/@types","./my-types"], // 仅加载@types/node 以及@types/react
    "skipLibCheck": true
  }
}

加载多个类型声明文件可能会导致冲突, 通过配置skipLibCheck可以避免这个问题的出现

paths

paths其实就是别名, 类似于webpack中的alias

{
	"compilerOptions": {
    "paths": {
      "@src/*": ["./src/*"]
    }
  }
}

这里的含义其实就是定义了./src的路径别名为@src, 当然就像前面说的, 它是受baseUrl影响的, 即使它是相对路径!

resolveJsonModule

此配置项为是否开启解析json文件功能, 开启后, 就能够解析json模块, 并对json内容提供提示以及类型校验

{
	"compilerOptions": {
    "resolveJsonModule": true
  }
}

构建产出

输出相关

outDir & outFile

outDir顾名思义, 其实就是存放构建产物的文件夹, 一半会按照源码的目录结构, 存放在outDir指定的文件夹内; 而outFile则是将所有内容都编译到一个文件中去! 但是要注意这个配置项要求颇为严格, module, 即产物的标准, 必须是amd或system形式!

preserveConstEnums

preserveConstEnums, 看名字就知道, 就是保留常量枚举, 我们的常量枚举默认编译后是不会保留的, 而开启本配置后, 编译的结果仍会保留它;

noEmit & noEmitOnError

noEmit表示, 只构建, 但是, 不把产出物写入文件系统, 适合的场景就是, 不需要产出物, 只是想检查下类型和语法; noEmitOnError则是指, 出错的时候, 才阻止写入系统;

module

module表示构建产物的模块标准, 比如: CommonJs(默认)、amd、umd、esxx、node16、nodenext等等; 要注意这个选项和其他配置项的关联关系, 例如前面的, 如果我们设置了outFile配置项, 则module必须, 也只能是amd和system

importHelpers 与 noEmitHelpers

importHelppers有点类似于babel中的polyfill概念, 即垫片, 或者说辅助函数都行; 就是我们对语法进行降级的时候(target设置成低版本), 有些语法需要辅助函数转换, 此时, 如果importHelpers设置成了true, 那么编译的结果就会自动引入tslib, 这是一个非常重要的typescript库, 可以导出辅助函数而不是直接将辅助的逻辑全部写在编译的结果中, 可以减少编译产物体积, 但是有几点需要注意:

  1. 你的源码文件必须是一个模块, 即至少有export等导出的操作;
  2. 你必须安装了tslib;
  3. 你的module不能为none;
  4. 不可设置noLib为true;
// 源码:
let arr:any[] = [11,2,3]

export let newArr:any[] = ['new', ...arr]
// 编译后
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.newArr = void 0;
var tslib_1 = require("tslib");
var arr = [11, 2, 3];
exports.newArr = tslib_1.__spreadArray(['new'], arr, true);

noEmitHelpers 则是从tslib中导入辅助函数, 要不自动实现辅助函数, 而是必须由用户去实现这些辅助函数,怎么理解呢? 如果你正常啥也不设置, 只是规定target为低版本语法, 那么编译后的代码其实也会生成辅助函数

// 源码:
let arr:any[] = [11,2,3]

export let newArr:any[] = ['new', ...arr]

// 编译产物:

"use strict";
var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
    if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
        if (ar || !(i in from)) {
            if (!ar) ar = Array.prototype.slice.call(from, 0, i);
            ar[i] = from[i];
        }
    }
    return to.concat(ar || Array.prototype.slice.call(from));
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.newArr = void 0;
var arr = [11, 2, 3];
exports.newArr = __spreadArray(['new'], arr, true);

如果你设置了importHelppers为true, 那结果就是刚才说的那样, 引入了tslib, 不会写那么一大堆东西, 减少了体积; 而如果你设置了noEmitHelpers为true, 那编译结果就会变成这样:

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.newArr = void 0;
var arr = [11, 2, 3];
exports.newArr = __spreadArray(['new'], arr, true);

我们可以看到, __spreadArray辅助方法压根没实现, 只能由用户自己在源码中重新实现!

downlevelIteration

在target低于es6的时候, 如果我们源码中使用了for...of, 那么, 在默认的编译中, for...of将被编译为普通for循环

// 源码:
let arr:any[] = [11,2,3]
for (let item of arr) {
  console.log(item)
}

// 编译产物:
"use strict";
var arr = [11, 2, 3];
for (var _i = 0, arr_1 = arr; _i < arr_1.length; _i++) {
  var item = arr_1[_i];
  console.log(item);
}

但是有时候, for..of可能会和for循环出现不一致的情况, 例如: emoji字符在for...of中遍历一次; 而在for循环中则会有两次; 所以, 为了保持for...of不被降级, 可以设置downlevelIteration为true; 但是要注意了, 这里所谓的不降级, 只是在[Symbol.iterator]接口存在的情况下, 用for循环去模拟for...of! 而不是真的不降级;

声明相关

declaration

declaration很好理解, 就是是否要生成类型声明文件, 即 .d.ts文件, 默认不生成, 开启后, 声明文件会跟编译后的文件走, 比如: outDir: './dist', 则声明文件也会出现在dist内

declarationDir

如果我们想改变声明文件生成的位置, 就可以设置declarationDir, 它和outDir一样, 都是指定一个输出文件路径; 比如, 可以设置声明文件全部输出到types文件夹内, "declarationDir": "./types"

declarationMap

我们都知道, sourcemap是源码和编译后代码的映射表, 我们通过sourcemap可以定位到源码的位置, 同样的, 声明文件也有sourcemap, 可以映射声明文件和源码的位置关系, 最典型的就是, 我们使用一些npm第三方库的时候, 点击类型, 往往会跳转到声明文件, 而如果作者设置了declarationMap并且在npm包内提供了ts源码, 则可以直接跳转到对应的ts文件中;

emitDeclarationOnly

emitDeclarationOnly顾名思义, 就是指生成声明文件! 很想noEmit, 都是限制产出物输出的, 一般场景就是, 和其他构建工具配合的时候, tsc只负责生成声明文件, 而其他部分, 则由其他构建工具代为处理;

检查相关

这部分的检查相关的内容, 将在开发阶段对代码进行检查, 并提出警告或者报错, 来规范我们的代码, 其功能也和eslint颇为相似

允许类

允许类的配置会降低代码校验的严格程度, 名字通常都是allowxx, 它可以减少开发阶段代码报错的几率, 但是, 正因为如此, 它也增加了代码出错的几率; 因此, 请谨慎开启!

allowUmdGlobalAccess

我们知道, 代码的模版规范主要有ES Module、CommonJs、AMD、UMD等, 其中UMD最为特别, 它兼容了其他的模版规范, 同时, 会将变量挂载到全局对象之上, 比如: window、global这类; 通常我们可以直接访问window、global上的对象

console.log(a)
// 等价于
console.log(window.a)

不错, 我们可以不需要导入而直接使用它, 如果我们关闭该项, 则以上访问方式会报错

allowUnreachableCode

我们日常开发中, return语句、throw Error语句、process.ext等语句都代表了其所在作用域内的代码执行终结, 这意味着, 该作用域内, 这些语句后面的代码, 都是不会被执行的! 如果我们设置allowUnreachableCode为true, 就会导致编译器忽略这个错误

开启效果:

关闭效果:

allowUnusedLabels

在javascript中, 如果有多个for循环嵌套, 而我们又想在得到结果后就终止整个循环, 此时我们通常会使用label

outerLoop:
for (let i = 0; i < 5; i++) {
  innerLoop:
  for (let j = 0; j < 3; j++) {
    if (i === 2 && j === 1) {
      break outerLoop; // 跳出外部循环
    }
    console.log(`i: ${i}, j: ${j}`);
  }
}

以上案例中, 我们给内外两个循环分别设置了一个label: outerLoop 和 innerLoop, 当符合条件的时候, 我们会直接终止外层循环, 而我们并没有使用到内层循环的label , 即innerLoop; 如果我们设置allowUnusedLabels为true, 代码不会有任何提示; 而如果将其改为false, 则会产生警告提示

禁止类

这部分内容会对代码的类型甚至逻辑进行校验, 名字很多都是noxx开启它们有助于保障我们代码的质量, 但同时也会增加一些工作量, 所以, 是否开启, 得视情况而定;

noImplicitAny

这个配置项可以说是很常见的, 即禁止隐式转换any, 我们都知道, any虽然给我们开发提供了便利, 但是也会造成一定的风险; 来看看一下案例

function fn (params) {
  params.name = 'jack'
  params.age = 18
  params.age = '19'
  return params
}

以上代码在关闭noImplicitAny时, 不会有任何报错, 但是我们看看, 这里明显是有问题的! 首先, 谁能保证params在运行时一定存在? 或者说不会是null?再者params.age一会是数字, 一会是字符串, 这样随便来, 基本也就丧失了类型支持了; 原因就在于, 我们没有给params设置任何类型, 而此时, 由于我们又允许Typescript将其隐式转为any, 所以, params就自然而然地变成了any, 也就导致了一系列错误; 防止这类错误的办法有2个: 第一就是每次都记住要加类型, 显然不现实, 你记住人家不一定记得住; 第二就是, 乖乖打开noImplicitAny配置项!这样, 如果我们不设置类型, 就会得到一个提示:

当然, 此时如果你将params设为any也没问题; 不过最好被这么做

useUnknownInCatchVariables

当我们使用try...catch的时候, 通常会拿到catch的参数, 然后打印出错误的原因; 如果关闭本选项, catch的参数会变成any类型, 而非unknown! 而如果转为any, 则意味着它可以赋值给任何类型! 比如以下案例, 就不会提示错误, 但显然错得很离谱:

let str:string = ''
function fn () {
  try {
    // ...
  }catch (e) {
    str = e
  }
}
if (str.startsWith('new-')) {}

noFallthroughCasesInSwitch

如果说以上两种禁止类都是禁止any或者说就是针对类型的, 那么, 接下来我们要介绍的禁止类配置都跟逻辑有关, 更加深入代码的细节中; 我们使用switch...case的时候可能会这样写:

function fn(width, height) {
  switch (width * height) {
    case 1:
        console.log('one');
    case 2:
        console.log('two');
    case 3:
        console.log('three');
  }
}
fn(1, 1);

// 打印结果:
/** 
	one
  two
  three
*/

我们其实可以说我们的case就是出现了Fallthrough问题, 即从上到下贯穿了! 原因就在于没有写break; 所以, 当我们开启noFallthroughCaseInSwitch之后, Typescript就会在开发阶段就提示错误, 从而避免引发更大的问题!

noImplicitOverride

现在只要看到noImplicitxx我们应该就能很快反应过来, 是为禁止某种隐式行为, 就像前面的noImplicitAny一样, 这里的noImplicitOverride其实就是禁止子类隐式覆盖父类的的方法, 我们知道, 子类如果实在必须要覆盖父类的方法, 就必须显性使用override修饰符! 以此来提示, 这里发生了覆盖;

依照里氏替换原则: 子类应该是扩展父类的方法, 而非直接覆盖!

而如果我们的子类覆盖了父类的方法, 而又没有使用override, 就会给代码的维护造成一定的困难

class Animal {
  eat() {
    console.log('I can eat')
  }
}

class Dog extends Animal {
  override eat () {
    console.log('I will bark!')
  }
}

因此, 我们需要开启noImplicitOverride配置项, 来提醒我们要注意在覆盖处添加override修饰符!

noImplicitReturns

设想一个场景: 如果一个函数中有很多的判断条件, 而且我们预先设想是每个判断条件都需要返回值, 而且返回值是允许undefined; 那么, 当我们忘记在某个分支上返回内容的时候, Typescript是否也会无法检测到:

type returnType = string|number|undefined
function fn (num:number):returnType {
  if (num > 0) {
    return 'this is too much'
  } else {
    
  }
}

以上场景中, 我们的fn能够接收string|number|undefined, 可能开发人员只是习惯性拿undefined来兜底, 本质上是要有number类型返回的, 但是由于粗心, 在else分支没有返回内容, 恰好函数又能够接收undefined, 此时, 错误就会被我们忽视; 因此, 我们需要开启noImplicitReturns, 也就是说, 我们必须明确return一个值, 哪怕真的是undefined! 开启之后:

noImplicitThis

我们在javascript开发中, 经常会这样使用this

function fn (name) {
  this.name = name
}

咋一看没问题呀, 我这个fn后续会拿去实例化成一个对象, 这么写没问题呀, 但是, 别忘了, this并不是只为实例化服务的, 它指向的是调用它的对象, 那如果调用它的对象里没有name那怎么办?

let obj = {
  age: 19,
  fn
}
obj.fn()

可能你会觉得也没什么问题, 大不了赋一个值呗! 那如果this后面是一个对象呢? 或者说, 查询的层次很深呢?

function fn () {
  this.info.name = 'jack'
}

此时如果info不存在, 就会报出空指针错误; 所以, Typescript提供了noImplicitThis配置项, 当开启的时候, 我们在函数中调用this的时候, 必须为this声明类型! 那怎么声明呢? 在Typescript中, 可以这样为函数的this声明类型:

function fn (this: {name:string}) {
  this.name = 'jack'
}

不错, 第一个参数就是this! 如果我们开启了noImplicitThis, 而且在函数中调用了this上的属性/方法, 而且没有声明this的类型, 那么就会提示错误;

这样, 当我们为this声明类型后, 这个方法如果被赋给一个对象的某个属性, 那么这个对象就必须拥有这个方法中, this所拥有的属性/方法, 否则一样报错

noUnusedLocals和noUnusedParameters

开启这两项之后, 在局部作用域内, 变量声明以及参数声明后必须有地方使用, 而不是只声明不用, 否则会报出警告

严格检查

exactOptionalPropertyTypes

我们声明一个对象的时候, 有时候会声明一个可选属性, 但是这个属性使用的时候会出现一定的困惑, 就是这个属性到底是没有呢, 还是为undefined; 显然存在不确定的地方; 开启exactOptionalPropertyTypes之后, 就是明确了这个问题, 即 这个值是不存在或者有非undefined的值, 总之, 要么有非undefined的值, 要么干脆这个属性都没了, 就不可能存在undefined!

alwaysStrict

这个就是开启es5的严格模式

strictBindCallApply

bind、call、apply都可以改变一个方法的this, 同时, 这个方法的参数, 都能由这三个方法代为接收, 开启strictBindCallApply之后, 就是要求这三个方法代收参数的时候, 必须符合原方法的参数类型

strictFunctionTypes

开启逆变, 我们知道, 当A函数的参数是B函数的子类型; 同时, B函数的返回值是A函数的子类型, 则说明B是A的子类型! 这就是逆变(参数父子关系与函数父子关系相反)和协变(返回值父子关系和函数父子关系相同)! 而要开启逆变, 就必须设置strictFunctionTypes为true;

如果我们关闭本选项, 那么只会进行协变检查:

此时, A的参数是B参数的父类型, 或者说, A的参数包含了B, 按照逆变角度来讲, A 是B的子类型, 那么B肯定是不能赋值给A的! 但是由于我们关闭了逆变检查, 而协变的角度来看, B是A的子类型; 所以没有报错, 当我们打开之后

协变通过, 但逆变不通过, 一样报错!

注意, 对象中的逆变, 必须是property的形式的函数类型才会触发:

strictNullChecks

如果关闭此配置项, 那么, null和undefined可以视作任何类型的子类型! 注意, 它和前面的

exactOptionalPropertyTypes配置项是相互关联的, 因为exactOptionalPropertyTypes开启后是不允许可选参数被赋值undefined, 如果关闭strictNullChecks就意味着可以给任何变量赋值undefined, 这是矛盾的!

let str:string = null
let num:number = undefined

而且, 还会导致一些xx|undefined的问题被忽略

let arr:string[] = ['jack', 'rose', 'boat']

let result = arr.filter(item => item === 'dog')

// 开启strictNullChecks的时候, data类型为string|undefined
// 关闭后, data类型为string
let data = result[0] 

// 如果data被误判为string, 则有可能导致这行不会提示错误, 而运行时出错!
data.replace(/a/, 'b')

strictPropertyInitialization

本选项要求, 类中的属性必须被初始化, 哪怕你在构造函数中初始化也行

class Foo {
  name:string; // 报错
  age:number;
  constructor () {
    this.age = 12
  }
}

skipLibCheck 与 skipDefaultLibCheck

skipLibCheck, 来自不同的声明文件可能会存在冲突, 而开启skipLibCheck的目的就是跳过这些类型检查; skipDefaultLibCheck则是专门针对三斜线引入的默认库, 开启之后, 将不会再引入这些库;

工程类

references

随着我们的项目越来越庞大, 代码越来越多, 我们可能会需要将公共代码拆分出来, 并将业务代码拆分为多个模块; 业内也有很多类似的解决方案, 例如: monorepo, monorepo的好处就是, 可以提升代码的可维护性, 可以提升代码的复用率, 提升开发效率等; 有兴趣的可以去看看相关的资料并学习, 这里只将Typescript中, 类似方案的实现, 这里需要用到一个很重要的配置项: references, 说白了就是关联某个独立的Typescript模块, 假如我们现在有多个独立的Typescript工程, 在同一个仓库中维护:

我们拥有一个main包, 假设就是我们的一个实际运行的工程; 又有assets、hooks、utils等资产或工具, 我们的main会引用它们, 而这些工具之间又有引用关系, 比如: assets就引用了utils; 而且这些工具包拥有独立的tsconfig.json文件, 而且配置也不同, utils的module是commonjs, 其他的均为esxx; 但它们又有公共的ts配置在根目录, 即 tsconfig.base.json;

现在的需求是:

  1. 这些包互相时,不会因为模块标准的不同而报错;
  2. 我只构建主包main, 其他的工具包必须跟着一起构建到指定目录;
  3. 必须保持自己独立配置同时, 能够读取公共配置tsconfig.base.json;

我们来着手处理吧:

首先, 我们在main下的tsconfig.json文件中做如下调整:

{
  "extends": "../tsconfig.base.json",
  "references": [{
      "path": "../utils"
    },{
      "path": "../hooks"
    },{
      "path": "../assets"
    }],
  "compilerOptions": {
    "module": "CommonJS",
    "target": "ES6",
    "declaration": true,
    "outDir": "../dist/main",
    "rootDir": "./",
    "baseUrl": ".",
    "composite": true
  },
}

使用extends继承基础公共配置; references引用另外三个工具类包的配置; 设置outDir为../dist/main

其次, 另外一些工具类的配置修改如下:

utils:
{
  "extends": "../tsconfig.base.json",
  "compilerOptions": {
    "module": "CommonJS",
    "target": "ES6",
    "declaration": true,
    "outDir": "../dist/utils",
    "rootDir": "./",
    "baseUrl": ".",
    "composite": true  此选项必须开启, 声明这是一个子模块, 并能被引用!
  },
}
hooks:
{
  "extends": "../tsconfig.base.json",
  "compilerOptions": {
    "module": "ESNext",
    "target": "ES6",
    "declaration": true,
    "outDir": "../dist/hooks",
    "rootDir": "./",
    "baseUrl": ".",
    "composite": true   此选项必须开启, 声明这是一个子模块, 并能被引用!
  },
}
assets:
{
  "extends": "../tsconfig.base.json",
  "references": [
    {
      "path": "../utils" assets引用了utils, 所以这里也要加上references
    }
  ],
  "compilerOptions": {
    "module": "ESNext",
    "target": "ES2017",
    "declaration": true,
    "outDir": "../dist/assets",
    "rootDir": "./",
    "baseUrl": ".",
    "composite": true  此选项必须开启, 声明这是一个子模块, 并能被引用!
  },
}

然后, 当我们要构建的时候, 就应该执行 tsc --build main了, 构建出来的结果如下:

esModuleInterop

通常在开发当中通常都是ESM引入ESM, 而CommonJs引入CommonJs, 但是 总会有混用的时候, 先看下以下这段代码:

// index.ts
import react from 'react'
import { useEffect } from 'react'
import * as all from 'react'

console.log(react)
console.log(useEffect)
console.log(all)

通过编译后, 结果为:

// index.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var react_1 = require("react");
var react_2 = require("react");
var all = require("react");
console.log(react_1.default);
console.log(react_2.useEffect);
console.log(all);

执行node index.js, 之后我们会发现react_1.default这部分显示为undefined; 说白了, 就是CommonJs没有所谓的默认导出这种概念, 所以export default会被转为module.exports.default; 问题是实际上React并没导出这个default属性! 所以, 我们需要使用esModuleInterop, 它可以在代码层面上模拟出module.exports.default:

"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
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;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
var react_1 = __importDefault(require("react"));
var react_2 = require("react");
var all = __importStar(require("react"));
console.log(react_1.default);
console.log(react_2.useEffect);
console.log(all);

可以看到, 默认导入被加上了一个辅助函数__importDefault, 命名空间导入也被加上了辅助函数__importStar, __importDefault是将所有导出的内容都挂到default属性之上; 而__importStar则是将除了default属性之外的所有属性都挂到导出对象上; 我们再来执行node index.js, 会发现不再是undefined了

以上虽然解决了ESM引入CommonJs的一些兼容问题, 但所有辅助函数都直接写在源码里了, 可以再加上之前介绍过的importHelpers来使用tsLib, 这样编译后的结果就变为:

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var tslib_1 = require("tslib");
var react_1 = require("react");
var all = tslib_1.__importStar(require("react"));
console.log(react_1.useEffect);
console.log(all);

allowSyntheticDefaultImports

如果说esModuleInterop是在实际代码层面上为导出对象增加了.default属性, 那么allowSyntheticDefaultImports, 则是在类型层面上模拟出了这个default属性, 让我们默认导入CommonJs模块的内容的时候, 不至于出现类型报错, 如果设置该配置项为false 那么刚才的案例就会出现一个缺少默认导出的错误提示

编译器优化

incremental

增量编译, 设置之后会生成一个.tsbuildinfo, 后续编译, 只会编译修改的部分, 有利于提升编译速度;

watchOptions

watchOptions顾名思义就是当我们使用监听模式--watch的时候的配置项, 这里介绍几个常见的配置项

{
  "watchOptions": {
    "watchFile": "usefsevents",
    "watchDirectory": "usefsevents",
    "excludeFiles": ["./utils/**/*"],
    "excludeDirectories": ["assets"]
  }
}

注意, 这个配置项和我们高频使用的compilerOptions是平级的, 不要把它写在compilerOptions内! watchFile/watchDirectory就是规定对监听范围内的文件/文件夹的监听方式:

  • usefsevents: 使用原生方法监听;
  • fixedpollinginterval: 以固定频率轮询文件/文件夹
  • dynamicpriporitypolling: 对频繁修改的文件高频轮询;
  • prioritypollinginterval: 固定间隔的检查每个文件是否发生变化,但使用启发式监听的文件的检查频率要低于非启发式监听的文件。
  • useFsEventsOnParentDirectory:采用系统的文件系统的原生事件机制监听修改文件所在的目录,这样修改一个文件实际上监听的是此文件所在的目录都被监听了,如此整个项目的文件监听器将显著减少,但可能导致监听并不准确。

excludeFiles和excludeDirectories则是排除不需要监听的文件/文件夹, 类似于exclude, 此处不再赘述

编译日志类

diagnostics

生成本次编译的诊断信息,比如触发文,处理的所消耗的时间等等

listFiles

罗列出纳入本次编译的文件:

listEmittedFiles项, 则仅仅列出本次输出文件

traceResolution

追踪模块解析路径