Skip to content

Typescript 应用

作者:Atom
字数统计:12.2k 字
阅读时长:44 分钟

本文主要介绍 TypeScript 的工程能力基础,包括类型指令、类型声明、命名空间这么几个部分

类型检查指令

类型检查指令也就是利用行内注释(Inline Comments) 的能力,用于支持在某一处特定代码使用特殊的配置来覆盖掉全局配置

ts-ignore

直接禁用掉对下一行代码的类型检查

ts
// @ts-ignore
const name: string = 599

ts-expect-error

下一行代码真的存在错误时才能被使用,否则它会给出一个错误

ts
// @ts-expect-error
const name: string = 599
// @ts-expect-error 错误使用此指令,报错
const age: number = 599

ts-check

同下文中的ts-nocheck, 这两个指令可以用在 JS 文件中。TypeScript 并不是只能检查 TS 文件,对于 JS 文件它也可以通过类型推导与 JSDoc 的方式进行不完全的类型检查

js
// @ts-check
let myAge = 18
myAge = '90' // 报错, 与初始值类型不同

/** @type {string} */
let myName
myName = 599 // 报错, 与JSDoc标注类型不同

// 可使webpack具备类型支持
/** @type {import("webpack").Configuration} */
const config = {}
module.exports = config

ts-nocheck

作用于整个文件的 ignore 指令,使用了 ts-nocheck 指令的 TS 文件将不再接受类型检查

ts
// @ts-nocheck 以下代码均不会抛出错误, 作用于整个代码文件
const name: string = 599
const age: number = 'linbudu'

类型声明

声明变量的时候可以给变量加上类型信息,编译阶段就能通过类型检查检测变量使用的对不对

如需适配第三方库以及其他环境, 这种声明变量时添加类型的方式将无法满足场景,因此Typescript中还提供了另一个种添加类型的方式

这就是声明语法declare, 该方式下可以直接单独声明类型, 而不需要在变量声明的时候添加类型

如果将所有单独声明类型的文件提取出来, 就是类型声明文件d.ts

ts
// 这样单独声明了类型,后续使用这些函数及变量的时候也就能做类型检查
interface Animal {
    name: string;
    speed?: number;
}
declare const cat: Animal;
declare function add(num1: number, num2: number): number;

通过declare语法对 Typescript 的类型声明, 它类似于类型标注。 类型声明会存放着特定的类型信息,同时由于它们并不具有实际逻辑,我们可以很方便地使用类型声明来进行类型兼容性的比较、工具类型的声明与测试等等。

ts
// × 不允许在环境上下文中使用初始值
declare let result = foo()
// √ Foo
declare let result: ReturnType<typeof foo>

声明模块的方式

  • namespace:最早的实现模块的方式,编译为声明对象和设置对象的属性的 JS 代码,很容易理解
  • module:和 namespaceAST 没有任何区别,只不过一般用来声明 CommonJS 的模块,在 @types/node 下有很多
  • es modulees 标准的模块语法,ts 额外扩展了 import type

d.ts 的类型声明默认是全局的,除非有 es moduleimportexport 的声明,这时候就要手动 declare global 了。为了避免这种情况,可以用 reference 的编译器指令

如今, 声明模块不推荐用 namespacemodule,尽量用 es module

全局声明

ts
declare let/const // 声明全局变量
declare function  // 声明全局函数
declare class   // 声明全局类
declare enum    // 声明全局枚举类型
declare namespace // 声明(含有子属性的)全局对象
interface/type    // 声明全局类型, 不需要declare关键词

declare namespace里面不能再次使用declare, 而可以使用 export 将内部属性导出, namespace还是有一定的缺陷, 每次使用都需要带上空间名称, 现在已经不推荐使用, 而更推崇的方式是使用模块声明

ts
declare namespace JQuery {
  export function $(ready: () => void): void
  export namespace $ {
    function ajax(url: string, settings?: any): void
  }
}

下面就是模块声明的方式:

ts
declare module 'JQuery' {
  type cssSelector = {
    css: (key: string, value: string) => cssSelector
  }
  export function $(ready: () => void): void
  export namespace $ {
    function ajax(url: string, settings?: any): void
  }
  // 只能兼容ESM的模块导出
  // export default $
  // 兼容性更广泛, 还能兼容AMD和Commonjs的导出
  export = $
  export as namespace $
}

声明文件

类型声明的核心作用:

将类型独立于 .js 文件进行存储。别人在使用你的代码时,就能够获得这些额外的类型信息。同时,如果你在使用别人没有携带类型声明的 .js 文件,也可以通过类型声明进行类型补全

类型声明文件,即 .d.ts 结尾的文件,它会自动地被 TS 加载到环境中,实现对应部分代码的类型补全; 通过额外的类型声明文件,在核心代码文件以外去提供对类型的进一步补全

声明文件中并不包含实际的代码逻辑,它做的事只有一件:为 TypeScript 类型检查与推导提供额外的类型信息,而使用的语法仍然是 TypeScript 的 declare 关键字

在日常 Typescript 开发中常遇到的类型报错问题:

  • Npm 模块缺少类型定义
  • 引入非代码文件
  • 动态注入的全局变量的访问

声明文件来源

TypeScript 有三种存放类型声明的地方:

lib

内置的类型声明,包含 domes 的,可以通过compilerOptions.lib指定

@types/xx

其他环境的 api 类型声明,比如 node,还有 npm 包的类型声明, 可以通过compilerOptions选项下的typeRootstypeRoots配置

开发者写的代码:通过

通过tsconfig.json中的选项includeexclude 还有 files 指定

如果代码本身是用 ts 写的,那编译的时候就可以开启 compilerOptions.declaration,来生成 d.ts 文件

如果要开发一个npm包, npm 包也可以同时存放 ts 类型,通过 packages.jsontypes 字段指定路径即可

注意

tsc 在编译的时候,会分别加载 lib 的,@types 下的,还有 includefiles 的文件,进行类型检查

接下来, 分别通过声明文件来解决这些问题

declare module

declare module 通常用于为没有提供类型定义的库进行类型的补全,以及为非代码文件提供基本类型定义

  • 无类型定义的 npm 包提供声明文件
ts
// 报错 Cannot find module 'pkg' or its corresponding type declarations
import foo from 'pkg'
const res = foo.handler()
ts
// 类型声明文件
declare module 'pkg' {
  type Info = {
    name: string
    age: number
  }
  const handler: () => string

  export const zhangsan: Info
  export default handler
}
  • 为非代码文件(图片、CSS 等)提供声明文件
ts
// scss.d.ts
declare module '*.scss' {
  const content: { [key: string]: any }
  export = content
}

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

DefinitelyTyped

@types/ 开头的这一类 npm 包均属于DefinitelyTyped ,它是 TypeScript 维护的,专用于为社区存在的无类型定义的 JavaScript 库添加类型支持

比如我们需要lodash的声明文件, 直接使用npm i @types/lodash来安装对应的类型声明文件; 安装后 TypeScript 会自动将其加载到环境中(实际上所有 @types/xx 下的包都会自动被加载)

ts
// @types/node
declare module 'fs' {
  export function readFileSync(/** 省略 */): Buffer
}
// @types/react
declare namespace React {
  function useState<S>(): [S, Dispatch<SetStateAction<S>>]
}

扩展已有的类型定义

有时候我们需要通过 window.xxx 的方式访问,而类型声明很显然并不存在; 因此我们需要扩展 window 的类型声明

ts
// 扩展Window的类型定义
interface Window {
  userTracker: (...args: any[]) => Promise<void>
}
// 使用
window.userTracker('click!')

// 扩展@types/node的类型定义
declare module 'fs' {
  export function bump(): void
}
// 使用
import { bump } from 'fs'

类型依赖

如果代码依赖了其他类型声明, 就可以使用 Typescript 的三斜杠指令来引入所依赖的其他类型声明文件; 三斜线指令就像是声明文件中的导入语句一样,它的作用就是声明当前的文件依赖的其他类型声明

包括了 TS 内置类型声明(lib.d.ts)、三方库的类型声明以及你自己提供的类型声明文件

三斜线指令必须被放置在文件的顶部才能生效

ts
/// <reference path="./other.d.ts" />
/// <reference types="node" />
/// <reference lib="dom" />
  • path 属性

path 属性的值为一个相对路径,指向你项目内的其他声明文件。而在编译时,TS 会沿着 path 指定的路径不断深入寻找,最深的那个没有其他依赖的声明文件会被最先加载。

  • types 属性

types 的值是一个包名,也就是你想引入的 @types/ 声明, 如上面的例子中我们实际上是在声明当前文件对 @types/node 的依赖。而如果你的代码文件(.ts)中声明了对某一个包的类型导入,那么在编译产生的声明文件(.d.ts)中会自动包含引用它的指令

  • lib 属性

lib 导入的是 TypeScript 内置的类型声明,如下面的例子我们声明了对 lib.dom.d.ts 的依赖

命名空间

命名空间(namespace)就像一个模块文件一样,将一组强相关的逻辑收拢到一个命名空间内部

命名空间的作用是实现Typescript简单的模块化功能, 使用起来类似于枚举

命名空间与类型又有所区别, 在于它可以运行在内部实际逻辑中

可嵌套

命名空间的内部还可以再嵌套命名空间, 使用名字访问

ts
export namespace VirtualCurrency {
  export class QQCoinPaySDK {}
  export namespace BlockChainCurrency {
    export class BitCoinPaySDK {}
    export class ETHPaySDK {}
  }
}

const ethPaySDK = new VirtualCurrency.BlockChainCurrency.ETHPaySDK()

可合并

类型声明中的同名接口合并,命名空间也可以进行合并,但需要通过三斜线指令来声明导入

ts
// animal.ts
namespace Animal {
  export namespace ProtectedAnimals {}
}

// dog.ts
/// <reference path="animal.ts" />
namespace Animal {
  export namespace Dog {
    export function bark() {}
  }
}

// corgi.ts
/// <reference path="dog.ts" />
namespace Animal {
  export namespace Dog {
    export namespace Corgi {
      export function corgiBark() {}
    }
  }
}

/* 使用时需要将声明全部导入 */
/// <reference path="animal.ts" />
/// <reference path="dog.ts" />
/// <reference path="corgi.ts" />

Animal.Dog.Corgi.corgiBark()

@types 模块

@types/ 系列的包下,想要通过 namespace 进行模块的声明,还需要注意将其导出,然后才会加载到对应的模块下

ts
/* 导出命名空间,我们才能够在从 react 中导入方法时,获得命名空间内部的类型声明 */
export = React
/* 在启用了 --allowUmdGlobalAccess 配置的情况下
 * 允许将这个模块作为全局变量使用(也就是不导入直接使用),
 * 这一特性同样也适用于通过 CDN 资源导入模块时的变量类型声明
 */
export as namespace React

/* declare namespace 省略了不必要的类型标注, 内部的类型我们不再需要使用 declare 关键 */
declare namespace React {
  function useState<S>(initialState): []
}

类型导入

三斜线指令并不是导入类型依赖的唯一方式,也可以使用 import 的方式来导入类型

ts
// vite-env.d.ts
/// <reference types="vite/client" />

// 或者
import * as ViteClientEnv from 'vite/client'
// 或使用 import type
import type * as ViteClientEnv from 'vite/client'

导入类型跟导入模块语法一致, 在编译后会将两者分开

ts
// 不推荐, 无法区分类型和模块
import { Foo, FooType } from './foo'

// 推荐, 将模块与类型区分开来
import { Foo } from './foo'
import type { FooType } from './foo'

// 推荐, 简写类型导入
import { Foo, type FooType } from './foo'

React 应用

组件声明

现在 React 中最常使用的组件声明方式就两种:

  • 函数组件声明

  • 组件泛型声明

函数组件

React 中函数组件类型声明包括: FunctionComponent(FC)StatelessComponent 、SFC

这三个都指函数组件,除开 FC,其他两个已经不推荐使用

FC 并不是在所有场景都能完美胜任的,FC 有着一定优点的,如它还提供了 defaultPropsdisplayName 等一系列合法的 React 属性声明。为了更加灵活, 建议不使用 FC,直接使用简单函数和返回值标注的方式,这样一来你的函数组件就能够完全享受到作为一个函数的额外能力,包括但不限于泛型等等

注意

在 @types/react 18 版本后, FC 内部不再隐式包含 children 属性

tsx
export interface IContainerProps {
  visible: boolean
  controller: () => void
}
// 只能说明它是一个函数,并不能从类型层面上标明它是一个 React 组件,也无法约束它必须返回一个合法的组件
const Container = (props: IContainerProps): JSX.Element => {
  return <p>Hello World!</p>
}

// 更精确的类型声明
import React from 'react'
export interface IContainerProps {
  visible: boolean
  controller: () => void
}
const Container: React.FC<IContainerProps> = ({
  visible = false,
  controller = () => {}
}: IContainerProps) => {
  return <p>Hello World!</p>
}

组件泛型

上文中, FC 的类型定义如下

ts
interface FunctionComponent<P = {}> {
  (props: PropsWithChildren<P>, context?: any): ReactElement<any, any> | null
  propTypes?: WeakValidationMap<P> | undefined
  contextTypes?: ValidationMap<any> | undefined
  defaultProps?: Partial<P> | undefined
  displayName?: string | undefined
}

type PropsWithChildren<P> = P & { children?: ReactNode | undefined }

可以看到,代表着属性类型的泛型参数 P 实际上就是直接传给了类型别名 PropsWithChildren ,而它其实就是为 Props 新增了一个 children 属性

如果我们想进一步缩小属性值的范围, 可以使用组件泛型

区别

使用简单函数使用 FC 声明组件的重要差异之一就在于,使用 FC 时你无法再使用组件泛型。组件泛型即指,为你的组件属性再次添加一个泛型

ts
import { PropsWithChildren } from 'react'

interface ICellProps<TData> {
  //
  field: keyof TData
}

const Cell = <T extends Record<string, any>>(
  props: PropsWithChildren<ICellProps<T>>
) => {
  return <p></p>
}

interface IDataStruct {
  name: string
  age: number
}

/* 该组件汇总filed只能为 'name' | 'age' */
const App = () => {
  return (
    <>
      <Cell<IDataStruct> field="name"></Cell>
      <Cell<IDataStruct> field="age"></Cell>
    </>
  )
}

常用 API

常用 Hooks 的 API 类型定义多处使用了泛型参数, 主要分为隐式推导显式提供两种使用方式

useState

useState的类型声明如下

ts
// 提供了默认值
function useState<S>(
  initialState: S | (() => S)
): [S, Dispatch<SetStateAction<S>>]
// 没有提供默认值
function useState<S = undefined>(): [
  S | undefined,
  Dispatch<SetStateAction<S | undefined>>
]

注意

在调用函数时, 泛型参数类型不是必传的, 会根据传入的参数值自动推倒, 只有在类型定义时才会检查泛型参数是否传入

根据声明, 可以确定状态的类型

ts
// 推导为 string 类型
const [state1, setState1] = useState('hello')
// 此时类型为 string | undefined
const [state2, setState2] = useState<string>()

/* 传递空对象方式 */
// 1. 不推荐, 会使类型检测认为, {} 完整实现IData 结构的对象,可能会出现遗漏的未赋值属性
const [data, setData] = useState<IData>({} as IData)
// 2. 更推荐
const [data, setData] = useState<Partial<IData>>({})

/* useState返回值类型 */
type State = ReturnType<typeof useState<number>>

useCallback 与 useMemo

通常情况下,我们不会主动为 useCallback 提供泛型参数,因为其传入的函数往往已经确定。

而为 useMemo 提供泛型参数则要常见一些,因为我们可能希望通过这种方式来约束 useMemo 最后的返回值

ts
const Container = () => {
  // 泛型推导为 (input: number) => boolean
  const handler1 = useCallback((input: number) => {
    return input > 599
  }, [])

  // 显式提供为 (input: number, compare: boolean) => boolean
  const handler2 = useCallback<(input: number, compare: boolean) => boolean>(
    (input: number) => {
      return input > 599
    },
    []
  )

  // 推导为 string
  const result1 = useMemo(() => {
    return 'some-expensive-process'
  }, [])

  // 显式提供
  const result2 = useMemo<{ name?: string }>(() => {
    return {}
  }, [])
}

useReducer

useReducer 有三个泛型参数,分别为 reducer 函数的类型签名、数据的结构以及初始值的计算函数

ts
// useReducer的类型定义
type Reducer<S, A> = (prevState: S, action: A) => S
type ReducerState<R extends Reducer<any, any>> = R extends Reducer<infer S, any>
  ? S
  : never

function useReducer<R extends Reducer<any, any>>(
  reducer: R,
  initialState: ReducerState<R>
): [ReducerState<R>, Dispatch<ReducerAction<R>>]

附示例代码Reducer.tsx

R 被填充为了一整个函数类型,而 ReducerState<R> 实际上就是提取了 reducer 中代表 state 的参数,即状态的类型

useRef

useRef 的常见使用场景主要包括两种,存储一个 DOM 元素引用和持久化保存一个值

ts
const Container = () => {
  const domRef = useRef<HTMLDivElement>(null)
  const valueRef = useRef<number>(599)

  const operateRef = () => {
    domRef.current?.getBoundingClientRect()
    valueRef.current += 1
  }

  return (
    <div ref={domRef}>
      <p>Hello World</p>
    </div>
  )
}

useImperativeHandle

useImperativeHandle 接受一个 ref 、一个函数、一个依赖数组。

这个函数的返回值会被挂载到 ref 上,常见的使用方式是用于实现父组件调用子组件方法:子组件将自己的方法挂载到 ref 后,父组件就可以通过 ref 来调用此方法

  • IRefPayload 描述了我们将会在 ref 上挂载的对象结构。
  • 在函数组件中,接受 ref 的函数组件(子组件)需要被 forwardRef 包裹才能正确接收到 ref 对象,其接受两个泛型参数,分别为 ref 的类型与此组件的属性类型。
  • useImperativeHandle 中传入了 ref 以及一个返回两个方法的函数,它具有两个泛型参数,分别从传入的 ref 以及函数的返回值类型中进行类型推导。在这里我们显式传入了与推导不一致的第二个泛型参数,以此提供了额外的返回值类型检查

附示例代码: useImperativeHandle-Parent

附示例代码: useImperativeHandle-Child

内置类型

内置事件类型定义

在 React 中想要用好 TypeScript 的另一个关键因素就是使用 @types/react 提供的类型定义,最常见的就是事件类型,比如输入框值变化时的 ChangeEvent 和鼠标事件通用的 MouseEvent

先来看一下ChangeEventMouseEvent的事件类型定义(@types/react/index.d.ts)

ts
// @types/react/index.d.ts
interface ChangeEvent<T = Element> extends SyntheticEvent<T> {
  target: EventTarget & T
}
interface MouseEvent<T = Element, E = NativeMouseEvent> extends UIEvent<T, E> {
  altKey: boolean
  button: number
  ctrlKey: boolean
  // ...省略其他属性
}
ts
// 组件中使用事件参数类型定义:
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {}
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {}
/*
 * 事件处理整个函数的类型签名,如 ChangeEventHandler
 * 此时就无需再为 e 声明类型了,它会自动被推导为ChangeEvent<HTMLInputElement>
 */
const handleChange: ChangeEventHandler<HTMLInputElement> = (e) => {}
// InputEvent 并非在所有浏览器都得到了支持,因此并不存在对应的类型定义,你可以使用 KeyboardEvent 来代替
const handleKeyboard = (event: KeyboardEvent<HTMLInputElement>) => {}

附示例代码: Event.tsx

内置样式类型定义

声明组件属性中的样式时, 会常用到内置样式类型CSSProperties ,它描述了所有的 CSS 属性及对应的属性值类型,你也可以直接用它来检查 CSS 样式时的值

附示例代码: Style.tsx

内置 ComponentProps 系列

  • 扩展原生属性

原生 HTML 元素的所有 HTML 属性都保留下来作为组件的属性的场景下,肯定不能一个个声明所有属性,那么就可以使用 ComponentProps 来提取出一个元素上所有的属性

ts
import type { ComponentProps } from 'react'
interface IButtonProps extends ComponentProps<'button'> {
  size?: 'small' | 'large'
  link?: boolean
}
const Button = (props: IButtonProps) => {
  return <button {...props}>{props.children}</button>
}
  • 提取组件属性

假如某组件库只导出了这个组件而没有导出这个组件的属性类型定义,而我们又需要基于这个组件进行定制封装,此时就可以使用 ComponentProps 去提取它的属性类型

ts
import { Button } from 'ui-lib'
import type { ComponentProps } from 'react'
interface IButtonProps extends ComponentProps<Button> {
  display: boolean
}
const EnhancedButton = (props: IButtonProps) => {
  return <Button {...props}>{props.children}</Button>
}
// 内置类型 ComponentPropsWithRef 或 ComponentPropsWithoutRef 用于判断组件内部是否使用了ref
interface WithRefProps extends ComponentPropsWithRef<Button> {}
const EnhancedButton = (props: WithRefProps) => {
  return <Button {...props}>{props.children}</Button>
}

内置 ReactElement & ReactNode

ReactElementcreateElementcloneElement 等 factory 方法的返回值类型,它本质上指的是一个有效的 JSX 元素,即 JSX.Element。

ReactNode 可以认为包含了 ReactElement ,它还包含 nullundefined 以及 ReactFragment 等一些特殊的部分,其类型定义如下:

ts
type ReactText = string | number
type ReactChild = ReactElement | ReactText
type ReactNode =
  | ReactChild
  | ReactFragment
  | ReactPortal
  | boolean
  | null
  | unde

type PropsWithChildren<P> = P & { children?: ReactNode | undefined }
interface FunctionComponent<P = {}> {
  (props: PropsWithChildren<P>, context?: any): ReactElement<any, any> | null
}

声明规范

结构划分

大型Typescript项目如何存放这些类型代码,需要预先有一个明确的规范。

目前推荐一种方式,在项目中使用一个专门的文件夹存放类型代码,其中又按照这些类型的作用进行了划分,其分布大致是这样的

md
PROJECT
├── src
│ ├── types
│ │ ├── shared.ts
│ │ ├── [biz].ts
│ │ ├── request.ts
│ │ ├── tool.ts
│ ├── typings.d.ts
└── tsconfig.json
  • shared.ts,被其他类型定义所使用的类型,如简单的联合类型封装、简单的结构工具类型等。

  • [biz].ts,与业务逻辑对应的类型定义,比如 user.ts module.ts 等,推荐的方式是在中大型项目中尽可能按照业务模型来进行细粒度的拆分。

  • request.ts,请求相关的类型定义,推荐的方式是定义响应结构体,然后使用 biz 中的业务逻辑类型定义进行填充:

  • tool.ts,工具类型定义,一般是推荐把比较通用的工具类型抽离到专门的工具类型库中,这里只存放使用场景特殊的部分。

  • typings.d.ts,全局的类型声明,包括非代码文件的导入、无类型 npm 包的类型声明、全局变量的类型定义等等,你也可以进一步拆分为 env.d.tsruntime.d.tsmodule.d.ts 等数个各司其职的声明文件。

实际使用:

ts
import type { Status } from "./shared";
export interface IRequestStruct<TData = never> {
    status: Status;
    code: number;
    data: TData;
}
export interface IPaginationRequestStruct<TData = never> {
    status: Status;
    curPage: number;
    totalCount: number;
    hasNextPage: boolean;
    data: TData[];
}
import type { IPaginationRequestStruct } from "@/types/request";
import type { IUserProfile } from "@/types/user";

export function fetchUserList: Promise<IPaginationRequestStruct<IUserProfile>> {}

父子组件声明

父组件的类型, 被多个子组件共享和依赖, 这种场景尽量将该声明放置在父组件中, 而不是全局声明中

ts
// Parent.tsx
import { ChildA } from './ChildA'
import { ChildB } from './ChildB'
import { ChildC } from './ChildC'

//  被多个子组件消费的类型
export interface ISpecialDataStruct {}
const Parent = () => {
  const data: ISpecialDataStruct = {}
  return (
    <>
      <ChildA inputA={data} />
      <ChildB inputB={data} />
      <ChildC inputC={data} />
    </>
  )
}

// ChildA.tsx
import type { ISpecialDataStruct } from './parent'
interface IAProp {
  inputA: ISpecialDataStruct
}
export const ChildA: FC<IAProp> = (props) => <></>

工程化配置

本段来记录 Typescript 工程化中项目配置:

  • Babel
  • ESLint
  • Prettier
  • Git Hooks

Babel 配置

tsc 的编译流程

  • Scanner 进行词法分析, 生成 Token
  • Parser 进行语法分析, 组成 AST
  • Binder 进行作用域分析
  • Checker 进行类型检查
  • Transformer 进行 AST 增删改查
  • Emmiter 生成目标代码、类型声明文件、Sourcemap 文件

tsc 生成的代码没有做 polyfill 的处理,需要全量引入 core-js

babel 的编译流程

  • Parser 做词法分析和语法分析,生成 token 和 AST
  • Transformer 进行 AST 的转换
  • Generator 把 AST 打印成目标代码并生成 sourcemap

babel 也可以通过插件来支持编译typescript代码, 但不支持 const enum(会作为 enum 处理),不支持 namespace 的跨文件合并,导出非 const 的值,不支持过时的 export = import = 的模块语法

如何配置

在我们的实际开发中, 是结合使用二者的。利用tsc单独做类型检查, 利用babel 编译 typescript 代码来生成体积更小的代码

js
module.exports = {
  presets: [
    ['@babel/preset-typescript'],
    [
      '@babel/preset-env',
      {
        targets: '目标环境',
        useBuiltIns: 'usage' // ‘entry’
      }
    ]
  ],
  plugins: ['@babel/plugin-transform-runtime']
}

ESlint 配置

ESLint 的作用其实可以划分为两个部分:风格统一与代码优化

  • 对于新项目, 使用ESlint的命令来初始化配置信息
sh
npx eslint --init
# 或者
npm init @eslint/config
  • 对于已有项目, 新增 ts 配置支持

先安装相关依赖

sh
# 首推
pnpm i @typescript-eslint/eslint-plugin @typescript-eslint/parser --save-dev
# 或者
yarn add @typescript-eslint/eslint-plugin @typescript-eslint/parser --save-dev
# 或者
npm i @typescript-eslint/eslint-plugin @typescript-eslint/parser --save-dev

再修改 ESlint 配置

js
module.exports = {
  root: true,
  parser: '@typescript-eslint/parser',
  plugins: ['@typescript-eslint'],
  extends: [
    // ...其他已有的配置
    'plugin:@typescript-eslint/recommended'
  ]
}

Prettier 配置

它同样是代码格式化工具,但和 ESLint 并不完全等价。除 JS/TS 代码文件以外,Prettier 也支持 CSSLess 这样的样式文件,DSL 声明如 HTMLGraphQL 等等。推荐将除核心代码文件以外的部分,如 JSONHTMLMarkDown 等全交给 Prettier 进行格式化

PrettierESLint 的核心差异在于它并不包括 no-xxx(不允许某些语法),prefer-xxx(对于多种功能一致的语法,推荐使用其中某一种)这些涉及具体代码逻辑的规则,而是专注于 indent、quote、comma(逗号)、printWidth(每行允许的字符串长度) 等规则

  • 首先安装Prettier, 如果想要让 Prettier 也参与格式化代码文件,还需要安装 eslint-config-prettier ,这一配置包禁用了部分 ESLint 中会与 Prettier 产生冲突的规则
sh
# 首推
pnpm install prettier eslint-config-prettier --save-dev
# 或者
npm install prettier eslint-config-prettier --save-dev
# 或者
yarn add prettier eslint-config-prettier --save-dev
  • 创建 Prettier 配置文件 .prettierrc.js
js
module.exports = {
  // 单行最多 80 字符
  printWidth: 80,
  // 一个 Tab 缩进 2 个空格
  tabWidth: 2,
  // 每一行结尾需要有分号
  semi: true,
  // 使用单引号
  singleQuote: true,
  // 在对象属性中,仅在必要时才使用引号,如 "prop-foo"
  quoteProps: 'as-needed',
  // 在 jsx 中使用双引号
  jsxSingleQuote: false,
  // 使用 es5 风格的尾缀逗号,即数组和对象的最后一项成员后也需要逗号
  trailingComma: 'es5',
  // 大括号内首尾需要空格
  bracketSpacing: true,
  // HTML 标签(以及 JSX,Vue 模板等)的反尖括号 > 需要换行
  bracketSameLine: false,
  // 箭头函数仅有一个参数时也需要括号,如 (arg) => {}
  // 使用 crlf 作为换行符
  endOfLine: 'crlf'
}
  • 修改 ESlint 兼容 prettier, 通过 eslint-config-prettier 禁用掉部分 ESLint 规则
js
module.exports = {
  extends: [
    'plugin:react/recommended',
    'plugin:@typescript-eslint/recommended',
    'prettier' // 新增这一行
  ]
}
  • 修改 Vscode 的编辑器配置
json
{
  /* React项目Vscode配置 */
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "eslint.probe": ["javascript", "javascriptreact", "typescriptreact", "vue"],
  "editor.formatOnSave": true,
  // Runs Prettier, then ESLint
  "editor.codeActionsOnSave": ["source.formatDocument", "source.fixAll.eslint"],
  "vetur.validation.template": false,
  "[javascriptreact]": {
    "editor.formatOnSave": false,
    "editor.defaultFormatter": "vscode.typescript-language-features"
  },
  "[typescriptreact]": {
    "editor.formatOnSave": false,
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  }
}

GitHooks 配置

husky 初始化

husky能在git操作的某个时机执行的额外逻辑

sh
# 安装
npx husky-init && npm install       # npm
npx husky-init && yarn              # Yarn 1
yarn dlx husky-init --yarn2 && yarn # Yarn 2+
pnpm dlx husky-init && pnpm install # pnpm

# 初始化
npx husky add .husky/pre-commit './node_modules/.bin/lint-staged'

lint-staged 初始化

lint-staged 的作用即是找出你添加到暂存区(git add)的文件,然后执行对应的 lint

sh
pnpm install --save-dev lint-staged

然后修改package.json配置, 对于暂存区的核心代码文件,先使用 ESLint 格式化,再使用 Prettier 格式化,而对于其他文件,统一使用 Prettier 进行格式化

json
{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": [
      "eslint --cache --fix",
      "prettier --write --list-different"
    ],
    "*.{json,md,html,css,scss,sass,less,styl}": [
      "prettier --write --list-different"
    ]
  }
}

TS 执行器

如何在命令行上执行 TypeScript 代码文件呢? 随着生态的发展, 如今的Typescript主要有以下几个执行器

tsc

使用tsc命令, 需要配置好tsconfig.json中的outDir选项, 在.ts被转换前, 还会检查 TypeScript 类型错误

ts-node

ts-node工作方式与 tsc 非常相似,执行过程中也会进行类型检查,然后自动将 .js 文件转储到磁盘,并使用node 运行它。当不需要检查类型时, 可以使用参数--transpileOnly提升运行速度

sh
ts-node --transpileOnly src/index.ts

esno 及 @digitak/esrun

@digitak/esrunesno 是对 ts-node 的改进, 但二者都不会检查类型错误。所以如果需要类型检查, 还需要搭配 tsc --noEmit 使用

它俩的优势主要在效率比ts-node --transpileOnly 还要高一部分

bun

bun并非一个Typescript执行器, 而是一个新的JS运行时, 它的优势在于, 旨在替代 Node.js。它是用 Zig 编写的,并由 JavaScriptCore 提供支持,大大减少了启动时间和内存使用量

以下是一个它们几个执行器的运行效率对比图, 可以看到bun的执行效率确实一枝独秀。而esno@digitak/esrun以及ts-node --transpileOnly几个差距不是特别明显

TS 配置项

在 Typescript 项目中, 通过tsconfig.json来完成项目配置, 其中的配置项又可以分为三类:

  • 构建配置项
  • 检查配置项
  • 工程配置项

构建配置项

主要包含构建源码、构建解析、构建产物、声明文件等部分

json
{
  "compilerOptions": {
    /* ... */
  }
}

compilerOptions.experimentalDecorators

启用装饰器的 @ 语法

compilerOptions.emitDecoratorMetadata

影响装饰器实际运行时的元数据相关逻辑

compilerOptions.jsx

配置将直接影响 JSX 组件的构建表现

  • react: 将 JSX 组件转换为对 React.createElement 调用,生成 .js 文件
  • preserve: 原样保留 JSX 组件,生成 .jsx 文件,你可以接着让其他的编译器进行处理
  • react-native: 类似于 preserve,但会生成 .js 文件
  • react-jsx / react-jsxdev: JSX 组件会被转换为对 __jsx 方法的调用与生成 .js 文件,此方法来自于 react/jsx-runtime

compilerOptions.jsxFactory

影响负责最终处理转换完毕 JSX 组件的方法,默认即为 React.createElement。如果你想使用 preact.h 作为处理方法,可以将其配置为 h

compilerOptions.jsxFragmentFactory

类似 jsxFactory,只不过它影响的是 Fragment 组件(<></>)的提供方。jsxFactoryjsxFragmentFactory 均是 TS 4.1 版本以前用于实现自定义 JSX 转换的配置项

compilerOptions.jsxImportSource

当你的 jsx 设置为 react-jsx / react-jsxdev 时,指定你的 jsx-runtime / jsx-dev-runtime 从何处导入。

如设置为 preact 时,会从 preact/jsx-runtime 导入 _jsx 函数,用于进行 JSX 组件的转换。

类似的,在另一个类 React 框架 Solid 中,也将此配置修改为了自己的实现: "jsxImportSource": "solid-js"

compilerOptions.target

target 配置决定了当前构建代码使用的语法,常用值包括 es5、es6、es2018、es2021、esnext(基于目前的 TypeScript 版本所支持的最新版本) 等等

与 Babel 的targets有所不同, 在 Babel 中也有 targets 的概念。但通常指的是预期运行的浏览器,如 chrome 89,然后基于 browserlist 获取浏览器信息,基于 caniuse 或者 compat-table 获取各个浏览器版本支持的特性,最后再进行语法的降级

compilerOptions.lib

当你的 target 为较低的版本时,就需要手动引入对应的声明; target 为 "es2021" 时,你不需要添加 "es2021" 到 lib 中也能使用 ECMAScript2021 的新方法(例如replaceAll)

compilerOptions.noLib

如果你希望使用自己提供的 lib 声明定义,可以启用 noLib 配置,这样 TypeScript 将不会去加载内置的类型定义

files

与上文的compilerOptions处于同一层级, 用于来包含需要编译的文件, 每个值都需要是完整的文件路径,适合在小型项目时使用, 不支持通配符*

json
{
  "files": ["src/index.ts", "src/handler.ts"]
}

include

如果你的文件数量较多,或者分散在各个文件夹,此时可以使用 include 来包含, 支持通配符*

json
{
  "include": ["src/**/*", "generated/*.ts", "internal/*"]
}

src/*/ 表示匹配 src 下所有的合法文件,而无视目录层级。

而 internal/*则只会匹配 internal 下的文件,不会匹配 internal/utils/ 下的文件。

这里的合法文件指的是,在不包括文件扩展名(*.ts)的情况下只会匹配 .ts / .tsx / .d.ts / .js / .jsx 文件(js 和 jsx 文件需要启用 allowJs 配置时才会被包括)

exclude

include 与 exclude

tsconfig.json自动包含不会嵌入模块解析。如果编译器将某个文件标识为模块导入的目标,则无论该文件是否在前面的步骤中被排除,它都会被包含在编译中。

include和属性exclude采用类似于 glob 的文件模式列表。支持的 glob 通配符包括:

  • * 匹配零个或多个字符(不包括目录分隔符)
  • ? 匹配任意一个字符(不包括目录分隔符)
  • **/ 递归匹配任意子目录

使用 glob pattern 来一次性匹配许多文件, 其中有部分文件我们需要排除掉, 所以就使用exclude

注意

exclude 只能剔除已经被 include 包含的文件

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

compilerOptions.baseUrl

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

当解析根目录确定后, 在 import 引入语句中, 可以省略路径符; 例如import "src/core"就会被解析为baseUrl + src/core

json
{
  "compilerOptions": {
    "baseUrl": "./" // 根目录是tsconfig.json一级
  }
}

compilerOptions.rootDir

rootDir 配置决定了项目文件的虚拟目录, 这个目录必须包含项目, 所有需要被编译的 ts 文件( includefiles 中包括的 .ts 文件), 除开声明文件(.d.ts)

如果显式指定 rootDir ,需要确保其包含了所有 “被包括” 的文件,因为 TypeScript 需要确保这所有的文件都被生成在 outDir 内

假设项目目录结构如下:

md
├── dest
├── node_modules
├── package.json
├── src
│ ├── index.ts
├── tsconfig.json
└── yarn.lock

"outDir": "./dest" 配置情况下,src/index.ts 编译后输出位置为 dest/src/index.js

输出目录带上了的 src 这一层,显然不是那么合理。

解决办法是指定 rootDir: “src”。 这样,根目录变成了 src,编译后输出则没有了 src 这一层。

compilerOptions.rootDirs

有时多个目录下的工程源文件在编译时会进行合并放在某个输出目录下。 这可以看做一些源目录创建了一个“虚拟”目录。

利用rootDirs,可以告诉编译器生成这个虚拟目录的 roots; 因此编译器可以在“虚拟”目录下解析相对模块导入,就 好像它们被合并在了一起一样

构建中的一步会将 /src/views/generated/templates/views的输出拷贝到同一个目录下。 在运行时,视图可以假设它的模版与它同在一个目录下,因此可以使用相对导入 "./template"

md
src
└── views
└── view1.ts (imports './template1')
└── view2.ts
generated
└── templates
└── views
└── template1.ts (imports './view2')

compilerOptions.types

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

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

compilerOptions.typeRoots

如果你希望改变加载 @types/ 下文件的行为,可以使用 typeRoots 选项,其默认为 @types,即指定 node_modules/@types 下的所有文件(仍然包括嵌套的)

json
{
  "compilerOptions": {
    // 注意我们需要使用相对于 baseUrl 的相对路径
    // 结合types配置, 会尝试加载 node_modules/@types/react 以及 ./node_modules/@team-types/react 、./typings/react 中的声明文件
    "typeRoots": [
      "./node_modules/@types",
      "./node_modules/@team-types",
      "./typings"
    ],
    "types": ["react"],
    // 加载多个声明文件可能会导致内部的声明冲突
    // 禁用加载类型声明的检查
    "skipLibCheck": true
  }
}

compilerOptions.moduleResolution

指定了模块的解析策略,可以配置为 node 或者 classic ,其中 node 为默认值,而 classic 主要作向后兼容用

Node 解析策略:

  • 假设有代码: 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 ,再逐级向上直到根目录。

compilerOptions.moduleSuffixes

影响对模块的解析策略,但仅影响模块的后缀名部分

json
{
  "compilerOptions": {
    // 首先尝试查找 foo.ios.ts,然后是 foo.native.ts,最后才是 foo.ts
    "moduleSuffixes": [".ios", ".native", ""]
  }
}

compilerOptions.noResolve

TypeScript 会将你代码中导入的文件也解析为程序的一部分,包括 import 导入和三斜线指令的导入,你可以通过禁用这一配置来阻止这个解析过程

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

compilerOptions.paths

paths 类似于 Webpack 中的 alias。 用来定义路径别名, 但是它只能用于导入语句中, 不能用于其他地方

json
{
  "compilerOptions": {
    "baseUrl": "./",
    // paths 的解析是基于 baseUrl 作为相对路径的,因此需要确保指定了 baseUrl
    "paths": {
      "@/utils/*": ["src/utils/*", "src/other/utils/*"]
    }
  }
}

与inlude、exclude、files、types、typesRoot、lib的区别

  • paths只能用作import语句的别名解析, 最终结果为实际配置的路径拼接上额外路径
  • includeexcludefiles则是用于确定需要编译的代码文件
  • typestypesRoot则是用于确定需要加载的声明文件
  • lib则是用于确定需要加载的内置声明文件

总而言之: pathsimport解析路径有关。 includeexcludefiles它们与编译文件有关。typestypesRootlib它们则与声明文件有关

compilerOptions.resolveJsonModule

JSON文件导入支持, 对导入内容获得完整的基于实际 JSON 内容的类型推导

compilerOptions.outDir

outDir 配置的值将包括所有的构建产物,通常情况下会按照原本的目录结构存放

compilerOptions.outFile

outFile 类似于 Rollup 或 ESBuild 中的 bundle 选项,它会将所有的产物(其中非模块的文件)打包为单个文件,但仅能在 module 选项为 None / System / AMD 时使用

compilerOptions.preserveConstEnums

通常情况下, 常量枚举会在编译时被抹除, 该配置项让常量枚举也像普通枚举那样被编译为一个运行时存在的对象

compilerOptions.noEmit

noEmit 开启时将不会写入,但仍然会执行构建过程,因此也就包括了类型检查、语法检查与实际构建过程

compilerOptions.noEmitOnError

noEmitOnError 则仅会在构建过程中有错误产生才会阻止写入

compilerOptions.module

控制最终 JavaScript 产物使用的模块标准(CommonJsES6ESNextNodeNextAMDUMDSystem )

compilerOptions.importHelpers

Typescript编译时除了抹除类型,还需要基于 target 进行语法降级,这一功能往往需要一些辅助函数,将新语法转换为旧语法的实现

通过启用 importHelpers 配置,这些辅助函数就将从 tslib 中导出而不是在源码中定义,能够有效地帮助减少构建产物体系

compilerOptions.noEmitHelpers

如果你希望使用自己的实现辅助函数,而非完全从 tslib 中导出,就可以使用 noEmitHelpers 配置,在开启时源码中仍然会使用这些辅助函数,不会存在从 tslib 中导入的过程。因此,此时需要你在全局命名空间下来提供同名的实现

compilerOptions.downlevelIteration

用于降级遍历器的实现(es3/5)

开启该配置后, TS 会在构建产物中引入辅助函数用于判断[Symbol.iterator] 接口存在时保留 for...of 循环, 否则降级为普通的基于索引的 for 循环; 因此如果是保留for...of, 请引入该语法的polyfill

compilerOptions.importsNotUsedAsValues

默认情况下,TypeScript 就在编译时去抹除仅类型导入(import type),但如果你希望保留这些类型导入语句,可以通过更改 importsNotUsedAsValues 配置的值来改变其行为

  • remove(默认值): 仅对类型导入进行抹除
  • preserve: 所有的导入语句都会被导入(但是类型变量仍然会被抹除)
  • error: 在这种情况下首先所有导入语句仍然会被保留,但会在值导入仅被用于类型时产生一个错误

compilerOptions.preserveValueImports

主要针对的是值导入(即非类型导入或混合导入), 会将所有的值导入都保留下来

compilerOptions.declaration

控制声明文件的输出,其中 declaration 接受一个布尔值,即是否产生声明文件

compilerOptions.declarationDir

declarationDir 控制写入声明文件的路径,默认情况下声明文件会和构建代码文件在一个位置,比如 src/index.ts 会构建出 dist/index.jsdist/index.d.ts,但使用 declarationDir 你可以将这些类型声明文件输出到一个独立的文件夹下

compilerOptions.declarationMap

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

在使用第三方库时,如果你点击一个来自第三方库的变量,会发现跳转的是其声明文件。如果这些库提供了 declarationMap 与原本的 .ts 文件,那就可以直接跳转到变量对应的原始 ts 文件

compilerOptions.emitDeclarationOnly

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

compilerOptions.newLine

指定文件的结尾使用 CRLF 还是 LF 换行风格。其中 CRLF 其实就是 Carriage Return Line Feed ,是 Windows(DOS)系统下的换行符(相当于 \r\n),而 LF 则是 Line Feed,为 Unix 下的换行符(相当于 \n)

compilerOptions.removeComments

移除所有 TS 文件的注释,默认启用

compilerOptions.stripInternal

这一选项会阻止为被标记为 internal 的代码语句生成对应的类型,即被 JSDoc 标记为 @internal

ts
// 这段代码不会生成对应的类型声明
/**
 * @internal
 */
const SECRET_KEY = 'LINBUDU'

类型检查配置项

这部分的配置主要控制对源码中语法与类型检查的严格程度,这也是导致 TypeScript 项目下限与上限差异巨大的主要原因,检查全开与全关下的 TypeScript 简直就是两门不同的语言。但并不是说检查越严格越好,更好的方式是依据实际需要来调整检查的严格程度

compilerOptions.allowUmdGlobalAccess

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

compilerOptions.allowUnreachableCode

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

  • undefined(默认) 不会抛出阻止过程的错误, 仅警告
  • true 完全允许
  • false 抛出一个错误

compilerOptions.allowUnusedLabels

ts
// 代码块中的label
{
  L: function F() {}
}

可选值域上面的allowUnreachableCode一致

compilerOptions.noImplicitAny

启用 noImplicitAny 配置,代码中无类型标注导致的隐式 any 类型推导就会抛出一个错误

compilerOptions.useUnknownInCatchVariables

启用此配置后,try/catch 语句中 catch 的 error 类型会被更改为 unknown (否则是 any 类型)

compilerOptions.noFallthroughCasesInSwitch

启动该配置后, 将确保在你的 switch...case 语句中不会存在连续执行多个 case 语句的情况

compilerOptions.noImplicitOverride

启用noImplicitOverride后,将避免你在不使用 override 关键字的情况下就覆盖了基类方法

compilerOptions.noImplicitReturns

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

ts
// 函数缺少结束 return 语句,返回类型不包括 "undefined"。
function handle(color: 'blue' | 'black'): string {
  if (color === 'blue') {
    return 'beats'
  } else {
    ;('bose')
  }
}

compilerOptions.noImplicitThis

注意

TypeScript 的函数与 Class 的方法中,第一个参数是 this

启用 noImplicitThis 配置,它将确保你在使用 this 语法时一定能够确定 this 的具体类型

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

compilerOptions.noPropertyAccessFromIndexSignature

ts
nterface AllStringTypes {
  name: string;
  [key: string]: string;
}
type PropType1 = AllStringTypes['unknownProp']; // string
type PropType2 = AllStringTypes['name']; // string

配置的功能就是让对基于索引签名类型声明的结构属性访问更安全一些

其中 noPropertyAccessFromIndexSignature 配置禁止了对未知属性(如 'unknownProp')的访问,即使它们已在索引类型签名中被隐式声明

compilerOptions.noUncheckedIndexedAccess

启用后, 它会将一个 undefined 类型附加到对未知属性访问的类型结果上,比如 上文PropType1 的类型会是 string | undefined,这样能够提醒你在对这个属性进行读写时进行一次空检查

compilerOptions.noUnusedLocals

是否允许存在声明但未使用的变量,就像 ESLint 一样

compilerOptions.noUnusedParameters

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

compilerOptions.exactOptionalPropertyTypes

ts
interface ITheme {
  prefer?: 'dark' | 'light'
}
declare const theme: ITheme
theme.prefer = 'dark'
theme.prefer = 'light'
theme.prefer = undefined

这一配置会使得 TypeScript 对可选属性(即使用 ? 修饰的属性)启用更严格检查

启用 exactOptionalPropertyTypes 配置后,undefined 不会再被允许作为可选属性的值,除非你显式添加一个 undefined 类型

compilerOptions.alwaysStrict

alwaysStrict 配置会使得 TS 对所有文件使用严格模式进行检查(表现在会禁用掉一部分语法),同时生成的 js 文件开头也会有 'use strict' 标记

compilerOptions.strict

开启所有严格类型检查选项, 包括alwaysStrictuseUnknownInCatchVariablesnoFallthroughCasesInSwitchnoImplicitAnynoImplicitThisstrictNullChecksstrictBindCallApplystrictFunctionTypesstrictPropertyInitialization

compilerOptions.strictBindCallApply

这条配置会确保在使用 bind、call、apply 方法时,其第二个入参(即将用于调用原函数的入参)需要与原函数入参类型保持一致

compilerOptions.strictFunctionTypes

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

compilerOptions.strictNullChecks

这是在任何规模项目内都应该开启的一条规则。在这条规则关闭的情况下,null 和 undefined 会被隐式地视为任何类型的子类型

compilerOptions.strictPropertyInitialization

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

compilerOptions.skipLibCheck

默认情况下,TypeScript 会对加载的类型声明文件也进行检查,包括内置的 lib.d.ts 系列与 @types/ 下的声明文件。在某些时候,这些声明文件可能存在冲突,比如两个不同来源的声明文件使用不同的类型声明了一个全局变量

skipLibCheck 跳过对这些类型声明文件的检查,这也能进一步加快编译速度

compilerOptions.skipDefaultLibCheck

只会跳过那些使用了 /// <reference no-default-lib="true"/> 指令的声明文件(如内置的 lib.d.ts),这一三斜线指令的作用即是将此文件标记为默认库声明

工程配置项

Typescript 配置支持你可以将整个工程拆分成多个部分,比如你的 UI 部分、Hooks 部分以及主应用等等; 而每个部分使用独立的tsconfig.json

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

references

为了达到上述的效果, 将项目拆分, 需要配置 references 选项

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

征对这种包含多个部分的项目, 构建命令只能使用

sh
# 不能使用 --project 选项
tsc --build app

compilerOptions.isolatedModules

通常项目中, 类型相关的检查会完全交由 TypeScript 处理, 而构建过程使用其他ESBuildSWCBabel 等执行语法降级与打包

启用 isolatedModules 配置,它会确保每个文件都能被视为一个独立模块,因此也就能够被上述构建器处理

compilerOptions.allowJs

开启此配置后,允许在 .ts 文件中去导入 .js / .jsx 文件

compilerOptions.checkJs

checkJs 通常用于配合 allowJs 使用, 为 .js 文件提供尽可能全面的类型检查, 该配置就相当于为所有 JavaScript 文件标注了 @ts-check

compilerOptions.esModuleInterop

TypeScript 中支持通过 esModuleInterop 配置来在 ESM 导入 CJS 这种情况时引入额外的辅助函数,进一步对兼容性进行支持

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

compilerOptions.allowSyntheticDefaultImports

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

compilerOptions.incremental

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

watchOptions.fixedPollingInterval

不进行具体监听,而只是在每秒以固定的时间间隔后去检查发生变更的文件

watchOptions.priorityPollingInterval

类似 fixedPollingInterval ,但对某些特殊类型文件的检查频率会降低

watchOptions.dynamicPriorityPolling

对变更不频繁的文件,检查频率降低

watchOptions.useFsEventsOnParentDirectory

对文件/目录的父文件夹使用原生事件监听)

extends

作用就是复用已有的 TSconfig 配置文件

json
{
  // team-config 是一个 npm 包
  "extends": "team-config/tsconfig.json"
}

工程化一些坑记录

坑点列表

  • 在打包命令的目录, scripts或者commands里面也应该配置到tsconfig.jsoninclude里面, 否则会出现node的类型解析不出来的情况

  • rollup配置完@rollup/plugin-babel@rollup/plugin-typescript后, 打包typescript文件依旧会报错, 无法识别typescript的语法, 提示Unexpected token, 此时需要给tsconfig.json里面配置rootDir