Typescript在react项目中的实践
一、理解 Typescript 配置文件
熟悉 Typescript 配置文件是 TS 项目开发的最基本要求。TS 使用 tsconfig.json 作为其配置文件,它主要包含两块内容:
1.指定待编译的文件
2.定义编译选项
我们都知到TS项目的编译命令为tsc,该命令就是使用项目根路径下的tsconfig.json文件,对项目进行编译。
简单的配置示例如下:
{
"compilerOptions": {
"module": "commonjs",
"noImplicitAny": true,
"removeComments": true,
"preserveConstEnums": true,
"sourceMap": true
},
"files": [
"app.ts",
"foo.ts",
]
}
其中,compilerOptions 用来配置编译选项,files 用来指定待编译文件。这里的待编译文件是指入口文件,任何被入口文件依赖的文件都将包括在内。
也可以使用 include 和 exclude 来指定和排除待编译文件:
{
"compilerOptions": {
"module": "commonjs",
"noImplicitAny": true,
"removeComments": true,
"preserveConstEnums": true,
"sourceMap": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"**/*.spec.ts"
]
}
/*************************************
exclude中的通配符
* :匹配 0 或多个字符(注意:不含路径分隔符)
? :匹配任意单个字符(注意:不含路径分隔符)
**/ :递归匹配任何子路径
**************************************/
即指定待编译文件有两种方式:
- 使用 files 属性
- 使用 include 和 exclude 属性
这里进行编译的文件都是TS文件(拓展名为 .ts、.tsx 或 .d.ts 的文件)
- 如果 files 和 include 都未设置,那么除了 exclude 排除的文件,编译器会默认包含路径下的所有 TS 文件。
- 如果同时设置 files 和 include ,那么编译器会把两者指定的文件都引入。
- exclude 只对 include 有效,对 files 无效。即 files 指定的文件如果同时被 exclude 排除,那么该文件仍然会被编译器引入。
常用的编译配置如下:
配置项字段名 | 默认值 | 说明 |
---|---|---|
target | es3 | 生成目标语言的版本 |
allowJs | false | 允许编译 JS 文件 |
noImplicitAny | false | 存在隐式 any 时抛错 |
jsx | Preserve | 在 .tsx 中支持 JSX :React 或 Preserve |
noUnusedLocals | false | 检查只声明、未使用的局部变量(只提示不报错) |
noImplicitThis | false | this 可能为 any 时抛错 |
noImplicitReturns | false | 不存在 return 时抛错 |
types | 默认的,所有位于 node_modules/@types 路径下的模块都会引入到编译器 | 如果指定了types,只有被列出来的包才会被包含进来。 |
对于types 选项,有一个普遍的误解,以为这个选项适用于所有的类型声明文件,包括用户自定义的声明文件,其实不然。这个选项只对通过 npm 安装的声明模块有效,用户自定义的类型声明文件与它没有任何关系。默认的,所有位于 node_modules/@types 路径下的模块都会引入到编译器。如果不希望自动引入node_modules/@types路径下的所有声明模块,那可以使用 types 指定自动引入哪些模块。比如:
{
"compilerOptions": {
"types" : ["node", "lodash", "express"]
}
}
//此时只会引入 node 、 lodash 和 express 三个声明模块,其它的声明模块则不会被自动引入。
配置复用
//建立一个基础的配置文件 configs/base.json
{
"compilerOptions": {
"noImplicitAny": true,
"strictNullChecks": true
}
}
//tsconfig.json 就可以引用这个文件的配置了:
{
"extends": "./configs/base",
"files": [
"main.ts",
"supplemental.ts"
]
}
二、Typescript在React中的应用
1. 无状态组件
无状态组件也被称为展示型组件。在部分时候,它们也是纯函数组件
在@types/react中已经预定义一个类型type SFC,它也是类型interface StatelessComponent的一个别名,此外,它已经有预定义的children和其他(defaultProps、displayName等等…),所以在写无状态组件时我们可以直接使用SFC
import React, { MouseEvent, SFC } from 'react';
type Props = {
onClick(e: MouseEvent<HTMLElement>): void
};
const Button: SFC<Props> = ({
onClick: handleClick,
children
}) => (
<button onClick={handleClick}>{children}</button>
);
2. 有状态组件
我们知道我们在React中不能像下面这样直接更新state:
this.state.clicksCount = 2;
我们应当通过setState来维护状态机,但上述写法,在ts编译时并不会报错。此时我们可以作如下限制:
const initialState = { clicksCount: 0 }
/*使用TypeScript来从我们的实现中推断出State的类型。
好处是:这样我们不需要分开维护我们的类型定义和实现*/
type State = Readonly<typeof initialState>
class ButtonCounter extends Component<object, State> {
/*至此我们定义了类上的state属性,及state其中的各属性均为只读*/
readonly state: State = initialState;
doBadthing(){
this.state.clicksCount = 2; //设置后,该写法编译报错
this.state = { clicksCount: 2 } //设置后,该写法编译报错
}
}
3.处理组件的默认属性
如果使用的typescript是3.x的版本的话,就不用担心这个问题,就直接在jsx中使用defaultProps就可以了。如果使用的是2.x的版本就要关注下述问题了
如果我们想定义默认属性,我们可以在我们的组件中通过以下代码定义
type Props = {
onClick(e: MouseEvent<HTMLElement>): void;
color?: string;
};
const Button: SFC<Props> = (
{
onClick: handleClick,
color,
children
}) => (
<button style={{ color }} onClick={handleClick}>
{children}
</button>
);
Button.defaultProps = {…}
在strict mode模式下,会有这有一个问题,可选的属性color的类型是一个联合类型undefined | string。因此,在对color属性做一些操作时,TS会报错。因为它并不知道它在React创建中通过Component.defaultProps中已经定义了默认属性
在这里我采取的方案是,构建可复用的高阶函数withDefaultProps,统一由他来更新props类型定义和设置默认属性。
export const withDefaultProps =
< P extends object, DP extends Partial<P> = Partial<P> >
(
defaultProps: DP,
Cmp: ComponentType<P>,
) => {
// 提取出必须的属性
type RequiredProps = Omit<P, keyof DP>;
// 重新创建我们的属性定义,通过一个相交类型,将所有的原始属性标记成可选的,必选的属性标记成可选的
type Props = Partial<DP> & Required<RequiredProps>;
Cmp.defaultProps = defaultProps;
// 返回重新的定义的属性类型组件,通过将原始组件的类型检查关闭,然后再设置正确的属性类型
return (Cmp as ComponentType<any>) as ComponentType<Props>;
};
此时,可以使用withDefaultProps高阶函数来定义我们的默认属性
const defaultProps = {
color: 'red',
};
type DefaultProps = typeof defaultProps;
type Props = { onClick(e: MouseEvent<HTMLElement>): void } & DefaultProps;
const Button: SFC<Props> = ({ onClick: handleClick, color, children }) => (
<button style={{ color }} onClick={handleClick}>
{children}
</button>
);
const ButtonWithDefaultProps = withDefaultProps(defaultProps, Button);
组件使用如下
render() {
return (
<ButtonWithDefaultProps
onClick={this.handleIncrement}
>
Increment
</ButtonWithDefaultProps>
)
}
4. 范型组件
范型是指在定义函数、接口或类的时候,不预先指定具体的类型,而在使用的时候再指定类型的一种特性。
interface Props<T> {
content: T;
}
上述代码表明 Props 接口定义了这么一种类型:
- 它是包含一个 content 字段的对象
- 该 content 字段的类型由使用时的泛型 T 决定
泛型函数:
function Foo<T>(props: Props<T>) {
console.log(props);
}
/** 此时 Foo 的完整签名为: function Foo<number>(props: Props<number>): void */
Foo({ content: 42 });
/** 此时 Foo 的完整签名为: function Foo<string>(props: Props<string>): void */
Foo({ content: "hello" });
泛型组件:
将上面的 Foo 函数返回 JSX 元素,就成了一个 React 组件。因为它是泛型函数,它所形成的组件也就成了 泛型组件。当然你很可能会思考泛型组件的用途。
思考下面的实践:
import React, { Fragment, PureComponent } from 'react';
interface IYarn {
...
}
export interface IProps {
total: number;
list: IYarn[];
title: string;
}
class YarnList extends PureComponent<IProps> {
}
上述组件就是用于展示一个列表,其实列表中的分页加载、滚动刷新逻辑等对于所有列表而言都是通用的,当我们想重复利用该容器组件时,你很可能会发现,不同的业务中列表中的属性字段并不通用。
此时,你为了尽可能满足大部分数据类型,你很可能将列表的元素类型做如下定义:
interface IYarn {
[prop: string]: any;
}
interface IProps {
total: number;
list: IYarn[];
title: string;
}
const YarnList: SFC<IProps> = ({
list,total,title
}) => (
<div>
{list.map(
....
)}
</div>
);
在这里已经可以看到类型的丢失了,因为出现了 any,而我们使用 TypeScript 的首要准则是尽量避免 any。
对于复杂类型,类型的丢失就完全享受不到 TypeScript 所带来的类型便利了。
此时,我们就可以使用泛型,把类型传递进来。实现如下:
interface IProps<T> {
total: number;
list: T[];
title: string;
}
const YarnList: SFC<IProps> = ({
list,total,title
}) => (
<div>
<div>title</div>
<div>total</div>
{list.map(
....
)}
</div>
);
改造后,列表元素的类型完全由使用的地方决定,作为列表组件,内部它无须关心,同时对于外部传递的入参,类型也没有丢失。
具体业务调用示例如下:
interface User {
id: number;
name: string;
}
const data: User[] = [
{
id: 1,
name: "xsq"
},
{
id: 2,
name: "tracy"
}
];
const App = () => {
return (
<div className="App">
<YarnList list={data} title="xsq_test" total=2/>
</div>
);
};
5.在数据请求中的应用
假设我们对接口的约定如下:
{
code: 200,
message: "",
data: {}
}
- code代表接口的成功与失败
- message代表接口失败之后的服务端消息输出
- data代表接口成功之后真正的逻辑
因此,我们可以对response定义的类型如下:
export enum StateCode {
error = 400,
ok = 200,
timeout = 408,
serviceError = 500
}
export interface IResponse<T> {
code: StateCode;
message: string;
data: T;
}
接下来我们可以定义具体的一个数据接口类型如下:
export interface ICommodity {
id: string;
img: string;
name: string;
price: number;
unit: string;
}
export interface IFavorites {
id: string;
img: string;
name: string;
url: string;
}
/*列表接口返回的数据格式*/
export interface IList {
commodity: ICommodity[];
groups: IFavorites[];
}
/*登录接口返回的数据格式*/
export interface ISignIn{
Id: string;
name: string;
avatar: string;
permissions: number[];
}
通过开源请求库 axios在项目中编写可复用的请求方法如下:
const ROOT = "https://tracy.me"
interface IRequest<T> {
path: string;
data: T;
}
export function service<T>({ path, data}: IRequest<T>): Promise<IResponse>{
return new Promise((resolve) => {
const request: AxiosRequestConfig = {
url: `${ROOT}/${path}`,
method: "POST",
data: data
}
axios(request).then((response: AxiosResponse<IResponse>) => {
resolve(response.data);
})
});
}
在接口业务调用时:
service({
path: "/list",
data: {
id: "xxx"
}
}).then((response: IResponse<IList>) => {
const { code, data } = response;
if (code === StateCode.ok) {
data.commodity.map((v: ICommodity) => {
});
}
})
此时,我们每一个接口的实现,都可以从约定的类型中得到 TypeScript 工具的支持
ts1.jpg
假设哪一天,后端同学突然要变更之前约定的接口字段,以往我们往往采取全局替换,但是当项目过于庞大时,个别字段的变更也是很棘手的,要准确干净的替换往往不是易事
但此时,由于我们使用的是TypeScript。例如,我们配合后端同学,将前面ISignin接口中的name改成了nickname。
此时,在接口调用的位置,TS编译器将给我们提供准确的定位与提示
ts2.jpg
随着代码量的增加,我们会从Typescript中获取更多的收益,只是往往开始的时候会有些许苦涩,但与你的收益相比,还是值得的