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中获取更多的收益,只是往往开始的时候会有些许苦涩,但与你的收益相比,还是值得的

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,445评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,889评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,047评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,760评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,745评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,638评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,011评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,669评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,923评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,655评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,740评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,406评论 4 320
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,995评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,961评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,023评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,483评论 2 342

推荐阅读更多精彩内容