TypeScript在react中的实践

5,330 阅读12分钟

TypeScript 是 JS 类型的超集,并支持了泛型、类型、命名空间、枚举等特性,弥补了 JS 在大型应用开发中的不足,本文主要探索在 TypeScript版本中编写 React 组件的姿势。

在动手将TypeScript融合进现有的React项目之前,先看一下create-react-app是怎么做的。

从create-react-app中一探究竟

首先创建一个叫做my-app的新工程:

create-react-app my-app --scripts-version=react-scripts-ts

react-scripts-ts是一系列适配器,它利用标准的create-react-app工程管道并把TypeScript混入进来。此时的工程结构应如下所示:

my-app/
├─ .gitignore
├─ node_modules/
├─ public/
├─ src/
│  └─ ...
├─ package.json
├─ tsconfig.json
└─ tslint.json

注意:

  • tsconfig.json包含了工程里TypeScript特定的配置选项。
  • tslint.json保存了要使用的代码检查器的设置,TSLint
  • package.json包含了依赖,还有一些命令的快捷方式,如测试命令,预览命令和发布应用的命令。
  • public包含了静态资源如HTML页面或图片。除了index.html文件外,其它的文件都可以删除。
  • src包含了TypeScript和CSS源码。index.tsx是强制使用的入口文件。

@types

打开package.json文件,查看devDependencies,发现一系列@types文件,如下:

"devDependencies": {
    "@types/node": "^12.6.9",
    "@types/react": "^16.8.24",
    "@types/react-dom": "^16.8.5",
    "typescript": "^3.5.3"
}

使用@types/前缀表示我们额外要获取React和React-DOM的声明文件(关于声明文件,参考文章)。 通常当你导入像"react"这样的路径,它会查看react包; 然而,并不是所有的包都包含了声明文件,所以TypeScript还会查看@types/react包。

如果没有这些@types文件,我们在TSX 组件中,引入React 或者ReactDOM 会报错:

Cannot find module 'react'

Cannot find module 'react-dom'

错误原因是由于 ReactReact-dom 并不是使用 TS 进行开发的,所以 TS 不知道 ReactReact-dom 的类型,以及该模块导出了什么,此时需要引入 .d.ts 的声明文件,比较幸运的是在社区中已经发布了这些常用模块的声明文件 DefinitelyTyped

所以如果我们的工程不是使用create-react-app创建的,记得npm install @types/xxx

tsconfig.json

如果一个目录下存在一个tsconfig.json文件,那么它意味着这个目录是TypeScript项目的根目录。tsconfig.json文件中指定了用来编译这个项目的根文件和编译选项。

执行tsc --init生成自己的tsconfig.json配置文件,示例如下。

{
    "compilerOptions": {
        "outDir": "./dist/",
        "sourceMap": true,
        "noImplicitAny": true,
        "module": "commonjs",
        "target": "es5",
        "jsx": "react"
    },
    "include": [
        "./src/**/*"
    ]
}
  • target:默认情况下,编译目标是 es5,如果你只想发布到兼容 es6 的浏览器中,也可以把它配置为 es6。 不过,如果配置为 es6,那么一些老的浏览器(如 IE )中就会抛出 Syntax Error 错误。
  • noImplicitAny :当 noImplicitAny 标志是 false(默认值)时, 如果编译器无法根据变量的用途推断出变量的类型,它就会悄悄的把变量类型默认为 any。这就是隐式 any的含义。当 noImplicitAny 标志是 true 并且 TypeScript 编译器无法推断出类型时,它仍然会生成 JavaScript 文件。 但是它也会报告一个错误

使用eslint进行代码检查

安装 eslint 依赖

npm install eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-plugin-react eslint-config-alloy  babel-eslint --save-dev
  1. @typescript-eslint/parser:将 TypeScript 转换为 ESTree,使 eslint 可以识别
  2. @typescript-eslint/eslint-plugin:只是一个可以打开或关闭的规则列表

创建配置文件.eslintrc.js并写入规则

module.exports = {
    parser: "@typescript-eslint/parser",
 	extends: ["plugin:@typescript-eslint/recommended", "react-app"],
  	plugins: ["@typescript-eslint", "react"],
  	rules: {
        // ...
    }
}

这里使用的是 AlloyTeam ESLint 的 TypeScript 规则

然后在package.json中增加配置,检查src目录下所有的ts文件。

"scripts": {
	"eslint": "eslint src --ext .ts,.js,.tsx,.jsx"
}

此时执行 npm run eslint 即会检查 src 目录下的所有.ts,.js,.tsx,.jsx后缀的文件

安装 prettier 依赖

npm i prettier eslint-config-prettier eslint-plugin-prettier -D
  1. prettier: 格式化规则程序
  2. eslint-config-prettier: 将禁用任何可能干扰现有 prettier 规则的 linting 规则
  3. eslint-plugin-prettier: 将作为ESlint 的一部分运行 Prettier分析。
module.exports = {
  parser: '@typescript-eslint/parser',
  extends: [
    'plugin:@typescript-eslint/recommended',
    'plugin:prettier/recommended',
  ],
  plugins: ['@typescript-eslint', 'react'],
  rules: {},
};

旧项目中引入Prettier会导致超级多的error,慎用

Visual Studio Code 集成 ESLint 与 Prettier

为了让 vscode 的 eslint 插件启用 typescript 支持,需要添加下面的配置到 .vscode/settings.json 中。

"eslint.validate": [
    "javascript",
    "javascriptreact",
    {
      "language": "typescript",
      "autoFix": true
    },
    {
      "language": "typescriptreact",
      "autoFix": true
    }
]

在webpack中配置loader

修改webpack.config.js文件

module.exports = {
    entry: "./src/index.tsx",
    output: {
        filename: "bundle.js",
        path: __dirname + "/dist"
    },

    devtool: "source-map",

    resolve: {
        extensions: [".ts", ".tsx", ".js", ".json"]
    },

    module: {
        rules: [
            { test: /\.tsx?$/, loader: "awesome-typescript-loader" },
            { enforce: "pre", test: /\.js$/, loader: "source-map-loader" }
        ]
    },
    externals: {
        "react": "React",
        "react-dom": "ReactDOM"
    },
};

awesome-typescript-loader是用来编译ts文件得,也可以使用ts-loader,两者之间得区别,请参考:awesome-typescript-loader & ts-loader

组件开发

有状态组件开发

定义interface

当我们传递props到组件中去的时候,如果想要使props应用interface,那就会强制要求我们传递的props必须遵循interface的结构,确保成员都有被声明,同时也会阻止未期望的props被传递下去。

interface可以定义在组件的外部或是一个独立文件,可以像这样定义一个interface

interface FormProps {
    first_name: string;
    last_name: string;
    age: number;
    agreetoterms?: boolean;
}

这里我们创建了一个FormProps接口,包含一些值。我们也可以给组件的state应用一个interface

interface FormState {
    submitted?: boolean;
    full_name: string;
    age: number;
}

给组件应用interface

我们既可以给类组件也可以给无状态组件应用interface。对于类组件,我们利用尖括号语法去分别应用我们的props和state的interface。

export class MyForm extends React.Component<FormProps, FormState> {
	...
}

注意:在只有state而没有props的情况下,props的位置可以用{}或者object占位,这两个值都表示有效的空对象。

对于纯函数组件,我们可以直接传递props interface

function MyForm(props: FormProps) {
	...
}

引入interface

按照约定,我们一般会创建一个 **src/types/**目录来将你的所有interface分组:

// src/types/index.tsx
export interface FormProps {
    first_name: string;
    last_name: string;
    age: number;
    agreetoterms?: boolean;
}

然后引入组件所需要的interface

// src/components/MyForm.tsx
import React from 'react';
import { StoreState } from '../types/index';
...

无状态组件开发

无状态组件也被称为展示组件,如果一个展示组件没有内部的state可以被写为纯函数组件。 如果写的是函数组件,在@types/react中定义了一个类型type SFC<P = {}> = StatelessComponent<P>;。我们写函数组件的时候,能指定我们的组件为SFC或者StatelessComponent。这个里面已经预定义了children等,所以我们每次就不用指定类型children的类型了。

实现源码 node_modules/@types/react/index.d.ts

type SFC<P = {}> = StatelessComponent<P>;
interface StatelessComponent<P = {}> {
    (props: P & { children?: ReactNode }, context?: any): ReactElement<any> | null;
    propTypes?: ValidationMap<P>;
    contextTypes?: ValidationMap<any>;
    defaultProps?: Partial<P>;
    displayName?: string;
}

使用 SFC 进行无状态组件开发。

import React, { ReactNode, SFC } from 'react';
import style from './step-complete.less';

export interface IProps  {
  title: string | ReactNode;
  description: string | ReactNode;
}
const StepComplete:SFC<IProps> = ({ title, description, children }) => {
  return (
    <div className={style.complete}>
      <div className={style.completeTitle}>
        {title}
      </div>
      <div className={style.completeSubTitle}>
        {description}
      </div>
      <div>
        {children}
      </div>
    </div>
  );
};
export default StepComplete;

事件处理

我们在进行事件注册时经常会在事件处理函数中使用 event 事件对象,例如当使用鼠标事件时我们通过 clientXclientY 去获取指针的坐标。

大家可以想到直接把 event 设置为 any 类型,但是这样就失去了我们对代码进行静态检查的意义。

function handleEvent (event: any) {
  console.log(event.clientY)
}

试想下当我们注册一个 Touch 事件,然后错误的通过事件处理函数中的 event 对象去获取其 clientY 属性的值,在这里我们已经将 event 设置为 any 类型,导致 TypeScript 在编译时并不会提示我们错误, 当我们通过 event.clientY 访问时就有问题了,因为 Touch 事件的 event 对象并没有 clientY 这个属性。

通过 interfaceevent 对象进行类型声明编写的话又十分浪费时间,幸运的是 React 的声明文件提供了 Event 对象的类型声明。

Event 事件对象类型

常用 Event 事件对象类型:

  • ClipboardEvent<T = Element> 剪贴板事件对象
  • DragEvent<T = Element> 拖拽事件对象
  • ChangeEvent<T = Element> Change 事件对象
  • KeyboardEvent<T = Element> 键盘事件对象
  • MouseEvent<T = Element> 鼠标事件对象
  • TouchEvent<T = Element> 触摸事件对象
  • WheelEvent<T = Element> 滚轮事件对象
  • AnimationEvent<T = Element> 动画事件对象
  • TransitionEvent<T = Element> 过渡事件对象

实例:

import { MouseEvent } from 'react';

interface IProps {
  onClick (event: MouseEvent<HTMLDivElement>): void,
}

Promise 类型

在做异步操作时我们经常使用 async 函数,函数调用时会 return 一个 Promise 对象,可以使用 then 方法添加回调函数。

Promise<T> 是一个泛型类型,T 泛型变量用于确定使用 then 方法时接收的第一个回调函数(onfulfilled)的参数类型。

interface IResponse<T> {
  message: string,
  result: T,
  success: boolean,
}
async function getResponse (): Promise<IResponse<number[]>> {
  return {
    message: '获取成功',
    result: [1, 2, 3],
    success: true,
  }
}

getResponse()
  .then(response => {
    console.log(response.result)
  })

我们首先声明 IResponse 的泛型接口用于定义 response 的类型,通过 T 泛型变量来确定 result 的类型。

然后声明了一个 异步函数 getResponse 并且将函数返回值的类型定义为 Promise<IResponse<number[]>>

最后调用 getResponse 方法会返回一个 promise 类型,通过 then 调用,此时 then 方法接收的第一个回调函数的参数 response 的类型为,{ message: string, result: number[], success: boolean}

泛型组件

工具泛型使用技巧

typeof

一般我们都是先定义类型,再去赋值使用,但是使用 typeof 我们可以把使用顺序倒过来。

const options = {
  a: 1
}
type Options = typeof options

使用字符串字面量类型限制值为固定的字符串参数

限制 props.color 的值只可以是字符串 redblueyellow

interface IProps {
  color: 'red' | 'blue' | 'yellow',
}

使用数字字面量类型限制值为固定的数值参数

限制 props.index 的值只可以是数字 012

interface IProps {
 index: 0 | 1 | 2,
}

使用 Partial 将所有的 props 属性都变为可选值

Partial` 实现源码 `node_modules/typescript/lib/lib.es5.d.ts
type Partial<T> = { [P in keyof T]?: T[P] };

上面代码的意思是 keyof T 拿到 T 所有属性名, 然后 in 进行遍历, 将值赋给 P , 最后 T[P] 取得相应属性的值,中间的 ? 用来进行设置为可选值。

如果 props 所有的属性值都是可选的我们可以借助 Partial 这样实现。

import { MouseEvent } from 'react'
import * as React from 'react'
interface IProps {
  color: 'red' | 'blue' | 'yellow',
  onClick (event: MouseEvent<HTMLDivElement>): void,
}
const Button: SFC<Partial<IProps>> = ({onClick, children, color}) => {
  return (
    <div onClick={onClick}>
      { children }
    </div>
  )

使用 Required 将所有 props 属性都设为必填项

Required 实现源码 node_modules/typescript/lib/lib.es5.d.ts

type Required<T> = { [P in keyof T]-?: T[P] };

看到这里,小伙伴们可能有些疑惑, -? 是做什么的,其实 -? 的功能就是把可选属性的 ? 去掉使该属性变成必选项,对应的还有 +? ,作用与 -? 相反,是把属性变为可选项。

条件类型

TypeScript2.8引入了条件类型,条件类型可以根据其他类型的特性做出类型的判断。

T extends U ? X : Y

原先

interface Id { id: number, /* other fields */ }
interface Name { name: string, /* other fields */ }
declare function createLabel(id: number): Id;
declare function createLabel(name: string): Name;
declare function createLabel(name: string | number): Id | Name;

使用条件类型

type IdOrName<T extends number | string> = T extends number ? Id : Name;
declare function createLabel<T extends number | string>(idOrName: T): T extends number ? Id : Name;

Exclude<T,U>

T 中排除那些可以赋值给 U 的类型。

Exclude 实现源码 node_modules/typescript/lib/lib.es5.d.ts

type Exclude<T, U> = T extends U ? never : T;

实例:

type T = Exclude<1|2|3|4|5, 3|4>  // T = 1|2|5 

此时 T 类型的值只可以为 125 ,当使用其他值是 TS 会进行错误提示。

Error:(8, 5) TS2322: Type '3' is not assignable to type '1 | 2 | 5'.

Extract<T,U>

T 中提取那些可以赋值给 U 的类型。

Extract实现源码 node_modules/typescript/lib/lib.es5.d.ts

type Extract<T, U> = T extends U ? T : never;

实例:

type T = Extract<1|2|3|4|5, 3|4>  // T = 3|4

此时T类型的值只可以为 34 ,当使用其他值时 TS 会进行错误提示:

Error:(8, 5) TS2322: Type '5' is not assignable to type '3 | 4'.

Pick<T,K>

T 中取出一系列 K 的属性。

Pick 实现源码 node_modules/typescript/lib/lib.es5.d.ts

type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};

实例:

假如我们现在有一个类型其拥有 nameagesex 属性,当我们想生成一个新的类型只支持 nameage 时可以像下面这样:

interface Person {
  name: string,
  age: number,
  sex: string,
}
let person: Pick<Person, 'name' | 'age'> = {
  name: '小王',
  age: 21,
}

Record<K,T>

K 中所有的属性的值转化为 T 类型。

Record 实现源码 node_modules/typescript/lib/lib.es5.d.ts

type Record<K extends keyof any, T> = {
    [P in K]: T;
};

实例:

nameage 属性全部设为 string 类型。

let person: Record<'name' | 'age', string> = {
  name: '小王',
  age: '12',
}

Omit<T,K>(没有内置)

从对象 T 中排除 keyK 的属性。

由于 TS 中没有内置,所以需要我们使用 PickExclude 进行实现。

type Omit<T, K> = Pick<T, Exclude<keyof T, K>>

实例:

排除 name 属性。

interface Person {
  name: string,
  age: number,
  sex: string,
}


let person: Omit<Person, 'name'> = {
  age: 1,
  sex: '男'
}

NonNullable<T>

排除 Tnullundefined

NonNullable 实现源码 node_modules/typescript/lib/lib.es5.d.ts

type NonNullable<T> = T extends null | undefined ? never : T;

实例:

type T = NonNullable<string | string[] | null | undefined>; // string | string[]

ReturnType<T>

获取函数 T 返回值的类型。。

ReturnType 实现源码 node_modules/typescript/lib/lib.es5.d.ts

type ReturnType<T extends (...args: any[]) => any> = T extends (...args: any[]) => infer R ? R : any;

infer R 相当于声明一个变量,接收传入函数的返回值类型。

实例:

type T1 = ReturnType<() => string>; // string
type T2 = ReturnType<(s: string) => void>; // void