# 在 React 中使用 TypeScript
在 React 中使用 TypeScript 主要关注三个方面:
- 组件声明
- 声明一个 React 组件的方式
- 泛型坑位
- React API 中预留出的泛型坑位
- 可以通过输入一个值来隐式推导,也可以直接显式声明来约束后续的值输入
- 内置类型定义
- 事件信息的类型定义及内置工具类型
# 项目初始化
代码语言:javascript复制npx create-vite
# 模板选择 react-ts
# or
npm i create-react-app -g
create-react-app my-app --template typescript
# 项目配置
在 devDependencies
中包含了 @types/react
与 @types/react-dom
等,用于自动加载 node_modules/@types
下的类型声明文件。
在项目内的 vite-env.d.ts
,包含对于非实际代码文件导入的类型定义,如 CSS、Modules、图片、视频等。
# 组件声明
代码语言:javascript复制const Container = () => {
return <div>Cellinlab</div>
};
对于组件的 props
类型,可以像在函数中标注参数类型一样:
export interface IContainerProps {
visible: boolean;
controller: () => void;
}
const Container = (props: IContainerProps) => {
return <div>Cellinlab</div>
};
属性默认值可以通过参数默认值的形式进行声明:
代码语言:javascript复制const Container = ({
visible = false,
controller = () => {},
}: IContainerProps) => {
return <div>Cellinlab</div>
};
可以显式声明组件的返回值类型:
代码语言:javascript复制const Container = ({
visible = false,
controller = () => {},
}: IContainerProps): JSX.Element => {
return <div>Cellinlab</div>
};
React 还提供了 FC
类型来支持更精确的类型声明:
import React from 'react';
export interface IContainerProps {
visible: boolean;
controller: () => void;
}
const Container: React.FC<IContainerProps> = ({
visible = false,
controller = () => {},
}: IContainerProps) => {
return <div>Cellinlab</div>
};
FC 即 Function Component,作为一个类型被导出,其用法是接受的唯一泛型参数为这个组件的属性类型。
# 组件泛型
使用简单函数和使用 FC
的重要差异之一是,使用 FC
时无法再使用组件泛型。组件泛型指,为组件属性再次添加一个泛型:
import { PropsWithChildren } from 'react';
interface ICellProps<TData> {
field: keyof TData;
}
const Cell = <T extends Record<string, any>>(
props: PropsWithChildren<ICellProps<T>>,
) => {
return <div></div>;
};
interface IDataStruce {
name: string;
age: number;
}
const App = () => {
return (
<>
<Cell<IDataStruce> field="name" />
<Cell<IDataStruce> field="age" />
</>
);
};
# FC 并不完美
# 泛型坑位
常见的泛型坑位主要来自于 Hooks:
# useState
可以由输入值隐式推导或显式传入类型:
代码语言:javascript复制const Container = () => {
const [state1, SetState1] = useState('Cell');
// 隐式推导 state1 为 string 类型
const [state2, SetState2] = useState<string>('Cell');
// 显式传入 state2 为 string 类型
const [state3, SetState3] = useState<string>();
// 显式传入 state3 为 string | undefined 类型
};
在显式传入泛型时,如果没有提供初始值,类型实际会是 string | undefined
。因为 useState
声明中对是否提供初始值的两种情况做了区分重载:
function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];
function useState<S = undefined>(): [S | undefined, Dispatch<SetStateAction<S | undefined>>];
另一个常见场景是对于在初始化阶段是一个空对象的状态,可以使用断言:
代码语言:javascript复制const [data, setData] = useState<IData>({} as IData);
// 为了避免遗漏赋值的情况,可以将其标记为可选
const [data, setData] = useState<Partial<IData>>({});
如果要消费 useState
的返回值的类型,可以使用 ReturnType
:
type StateType = ReturnType<typeof useState<number>>;
# useCallback 和 useMemo
useCallback
和 useMemo
,它们的泛型参数分别表示包裹的函数和计算产物,使用方式类型,也分为隐式推导和显式提供:
const Container = () => {
// 泛型推导为 (input: number) => boolean
const handler1 = useCallback((input: number) => {
return input > 2022;
}, []);
// 显式提供 (input: number, compare: boolean) => boolean
const handler2 = useCallback<(input: number, compare: boolean) => boolean>(
(input: number) => {
return input > 2022;
},
[],
);
// 推导为 string
const result = useMemo(() => {
return 'Cellinlab';
}, []);
// 显式提供 string
const result2 = useMemo<{ name?: string }>(() => {
return {};
}, []);
};
通常,不会主动给 useCallback
提供泛型参数,因为其传入的函数往往已经确定。而为 useMemo
提供泛型参数较为常见,希望通过这种方法来约束 useMemo
最后的返回值。
# useReducer
useReducer
可以看做更复杂一些的 useState
,它们关注的都是数据的变化。不一样的是 useReducer
中只能由 reducer
安照特定的 action
来修改数据,但 useState
可以随意修改。
useReducer
有三个泛型坑位,分别为 reducer
函数的类型签名,数据的结构,及初始值的计算函数:
import { useReducer } from 'react';
const initialState = {
count: 0,
};
type Action =
| {
type: 'inc';
payload: {
count: number;
max?: number;
};
}
| {
type: 'dec';
payload: {
count: number;
min?: number;
};
};
function reducer(state: typeof initialState, action: Action) {
switch (action.type) {
case 'inc':
return {
count: action.payload.max
? Math.min(state.count action.payload.count, action.payload.max)
: state.count action.payload.count,
};
case 'dec':
return {
count: action.payload.min
? Math.max(state.count - action.payload.count, action.payload.min)
: state.count - action.payload.count,
};
default:
throw new Error('Unexpected action type: ' action.type);
}
}
function Container () {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
Count: {state.count}
<button
onClick={() =>
dispatch({
type: 'dec',
payload: {
count: 599,
min: 0,
},
})
}
>
-(min:0)
</button>
<button
onClick={() =>
dispatch({
type: 'inc',
payload: {
count: 599,
max: 599,
},
})
}
> (max:599)</button>
</>
);
}
# useRef 与 useImperativeHandle
useRef
的常见使用场景主要包括两种,存储一个 DOM 元素引用和持久化保存一个值。
const Container = () => {
const domRef = useRef<HTMLDivElement>(null);
const valueRef = useRef<number>(0);
const operateRef = () => {
domRef.current?.getBoundingClientRect();
valueRef.current = 1;
};
return (
<div ref={domRef}>
<button onClick={operateRef}>operate</button>
</div>
);
};
useImperativeHandle
接受一个 ref
、一个函数、一个依赖数组。这个函数的返回值会被挂载到 ref
上,常见的使用方式是用于实现父组件调用子组件方法:子组件将自己的方法挂载到 ref
上,父组件可以通过 ref
来调用此方法。
import {
useRef,
useImperativeHandle,
forwardRef,
ForwardRefRenderFunction,
} from 'react';
interface IRefPayload {
controller: () => void;
}
const Parent = () => {
const childRef = useRef<IRefPayload>(null);
const invokeController = () => {
childRef.current?.controller();
};
return (
<>
<Child ref={childRef} />
<button onClick={invokeController}>invoke controller</button>
</>
);
};
interface IChildPropStruct {}
interface IExtendedRefPayload extends IRefPayload {
disposer: () => void;
}
const Child = forwardRef<IRefPayload, IChildPropStruct>((props, ref) => {
const internalController = () => {
console.log('Internal controller');
};
useImperativeHandle<IRefPayload, IExtendedRefPayload>(
ref,
() => {
return {
controller: internalController,
disposer: () => {
console.log('Disposer');
},
}
},
[]
);
return <div>Child</div>;
});
# 内置类型定义
在 React 中想要用好 TypeScript 的另一个关键因素就是使用 @types/react
提供的类型定义:
import { useState } from 'react';
import type { ChangeEvent, MouseEvent } from 'react';
const Container = () => {
const [v, setV] = useState('Cell');
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setV(e.target.value);
};
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
console.log(e.currentTarget);
};
return (
<>
<input value={v} onChange={handleChange} />
<button onClick={handleClick}>Click</button>
</>
);
};
注意,ChangeEvent
和 MouseEvent
上还有一个泛型坑位,用于指定发生此事件的元素类型,传入更精确的元素类型获得更严格的类型检查。
除了事件类型外,在声明组件样式属性时会用到 CSSProperties
,描述了所有的 CSS 属性及对应的属性值类型,可以直接用它来检查 CSS 样式值:
import type { CSSProperties } from 'react';
export interface IContainerProps {
style: CSSProperties;
}
const css: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
};
const Container = (props: IContainerProps) => {
return <div style={props.style}>Container</div>;
};
在基于原生 HTML 元素去封装组件时,通常会需要将这个原生元素的所有 HTML 属性都保留下来作为组件的属性,此时可以用 ComponentProps
来提取一个元素上的所有属性:
import type { ComponentProps } from 'react';
interface IButtonProps extends ComponentProps<'button'> {
size?: 'small' | 'medium' | 'large';
link?: boolean;
}
const Button = (props: IButtonProps) => {
return <button {...props}>{props.children}</button>
};
除了对原生 DOM 元素使用外,也可以在使用在组件库中提取组件属性类型定义。
# 其他工程实践
# 项目中的类型声明文件
代码语言:javascript复制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.ts
runtime.d.ts
module.d.ts
等数个各司其职的声明文件
# 组件与组件类型
父组件导入各个子组件,传递属性时会进行额外的数据处理,其结果的类型被这多个子组件共享,而这个类型仅仅被父子组件消费,此时将该类型定义在父组件中即可,没必要放到全局类型定义中:
代码语言:javascript复制// Parent.tsx
import { ChildA } from './ChildA';
import { ChildB } from './ChildB';
import { ChildC } from './ChildC';
export interface ISpecialDataStruct {}
const Parent = () => {
const data: ISpecialDataStruct = {};
return (
<>
<ChildA data={data} />
<ChildB data={data} />
<ChildC data={data} />
</>
);
};
// ChildA.tsx
import type { ISpecialDataStruct } from './Parent';
interface IAProp {
inputA: ISpecialDataStruct;
}
export const ChildA: FC<IAProp> = (props) => {
return <div>ChildA</div>;
};
# 全链路 TypeScript 工具库
# 开发阶段
- 项目开发
- ts-node 与 ts-node-dev:用于直接执行 .ts 文件
- tsc-watch:它类似于 ts-node-dev,主要功能也是监听文件变化然后重新执行
- esno,核心能力同样是执行 .ts 文件,但底层是 ESBuild 而非 tsc,因此速度上会明显更快
- typed-install,在安装包时自动去判断这个包是否有额外的类型定义包,并为你自动地进行安装
- suppress-ts-error,自动为项目中所有的类型报错添加
@ts-expect-error
或@ts-ignore
注释,重构项目时很有帮助 - ts-error-translator,将 TS 报错翻译成更接地气的版本,并且会根据代码所在的上下文来详细说明报错原因
- 代码生成
- TypeStat,能够将 JavaScript 文件转化为 TypeScript 文件,并在这个过程中去尝试提取类型
- ts-auto-guard,自动基于接口生成类型守卫
- typescript-json-schema,从 TypeScript 代码生成 JSON Schema
- json-schema-to-typescript,从 JSON Schema 生成 TypeScript 代码
# 类型相关
- type-fest,工具类型库
- utility-types,工具类型库
- ts-essentials
- type-zoo
- ts-toolbelt,目前包含工具类型数量最多的,基本上能满足所有需要。
- tsd,用于进行类型层面的单元测试,即验证工具类型计算结果是否是符合预期的类型
- conditional-type-checks,类似于 tsd,也是用于对类型进行单元测试
# 校验阶段
- 逻辑校验
- zod,核心优势在于与 TypeScript 的集成,如能从 Schema 中直接提取出类型
- class-validator,基于装饰器来进行校验
- superstruct,功能与使用方式类似于 zod
- ow,用于函数参数的校验,通常在 CLI 工具里使用
- runtypes,类似于 Zod
- 类型覆盖检查
- typescript-coverage-report
- type-coverage,前者的底层依赖,可以用来定制更复杂的场景
# 构建阶段
- ESBuild
- swc,目的是替代 Babel,因此它是可以直接支持装饰器等特性的
- fork-ts-checker-webpack-plugin,Webpack 插件,使用额外的子进程来进行 TypeScript 的类型检查
- esbuild-loader,基于 ESBuild 的 Webpack Loader,基本可以完全替代 ts-loader 来编译 ts 文件
- rollup-plugin-dts,能够将你项目内定义与编译生成的类型声明文件重新进行打包
- Parcel,一个 Bundler,与 Webpack、Rollup 的核心差异是零配置,不需要任何 loader 或者 plugin 配置就能对常见基本所有的样式方案、语言方案、框架方案进行打包