TypeScript In React实践指南

2,356 阅读7分钟

TypeScript in React

前言

  • 适用范围

    本文不适合当作 TS 的入门资料,因为本文不会介绍基础的TS语法,而是从项目实践角度出发,讲解TS与React怎样更好的结合使用。
  • 动机

    项目中虽然使用了 TS,也切身体会到了静态类型检查带给项目在可维护性上的优势,但是在实际的项目开发中也遇到了由它带来的种种束缚,对 TS 始终又爱又恨。其次,接触 TS 有半年多的时间了,对自己来说,也是总结与回顾的一个好时机。
  • 问题:

    • 项目开发中浪费在编写 TS 类型注解、消灭各种红波浪报错的时间甚至超过了正式功能的代码编写时间。(一个理想的时间分配大概是,类型注解 20%,功能逻辑 80%)
    • 开发完成后 review 代码,看着代码中处处都是的 any 类型,完全不知从哪着手优化。
    • 系统学习了 TS,在纯 JS 项目中得心应手,但是一到 react 中就各种水土不服。
  • 原因

    • 官方文档,缺乏一些最佳实践的引导。网上的学习资料、讨论也不如其他的前端技术栈来的丰富。
    • react官方并没有阐述 react 与 TS 结合使用的详细指导文档
  • 目的

    尝试解决上述问题,提升读者 TS 实战能力

TS 编写 react 组件

一、从声明一个组件开始

  • 函数式组件声明方式
    1. 方式一(推荐): 将组件用React.FC< P > 这个官方的函数组件泛型接口来声明。 优势(1)props 自带 children 声明 (2)defaultProps、propsTypes、displayName 等的所有react组件静态属性能够获得 IDE 的友好提示。
      interface Props {
        text:string
        theme:Theme
      }
      const Button:React.FC<Props> = (props) => {
          const { text } = props;
          return <div><button>{text}</button></div>;
      };
    
    1. 方式二 : 自行处理 children 声明
    interface Props {
        text:string
        theme:Theme
        children?: ReactNode
      }
      const Button = (props:Props) => {
          const { text, children } = props;
          return <div><button>{text}</button>{children}</div>;
      };
    
    1. 方式三 : 利用 PropsWithChildren< P > 处理 children。效果同方式二一样。
     interface Props {
        text:string
        theme:Theme
      }
      const Button = (props: React.PropsWithChildren<Props>) => {
          const { text, children } = props;
          return <div><button>{text}</button>{children}</div>;
      };
    
    React.FC(推荐)自行处理children属性PropsWithChildren
    自动children声明
    react静态方法声明
  • class 组件声明方式 一般用Component 官方提供的泛型类来定义
      interface Props{
        id: string
      }
      interface State{
          count: number
      }
      class Button extends Component<Props, State> {
          state = {
              count: 0,
          }
          add = () => {
              this.setState({
                  count: this.state.count + 1,
              });
          }
          render() {
              return <div>{this.state.count}<button onClick={this.add}>add</button></div>;
          }
    }
    

二、Props 校验及默认值

  • Props 校验 TS 类型约束 vs propsType等第三方库
    // 使用TS interface约束props (推荐)
    interface Props {
      text:string
      theme:Theme
    }
    const Button:React.FC<Props> = (props) => {
        const { text } = props;
        return <div><button>{text}</button></div>;
    };
    
    // 使用专门的js类型库 (不推荐)
    import PropTypes from 'prop-types'
    const Button = (props)=>{
        const { text } = props;
        return <div><button>{text}</button></div>;
    }
    Button.propTypes = {
        text: PropTypes.string,
    };
    
    TS类型约束prop-types等第三方库
    校验时机编译时运行时
    校验规则丰富程度优秀一般
    总结:首选TS类型约束,因为语言级别的类型约束方案,(包含接口、泛型、元组等等的功能)远比api级别的js类型库功能更强大。并且,ts的类型检查在编译阶段,可以更早的发现代码问题(除非特别需要运行时校验)。
  • 设置属性默认值 es6 解构声明默认值 vs defaultProps 静态属性
          // 使用defaultProps静态属性 (不推荐)
          const Button:React.FC<Props> = (props) => {
              const { text } = props;
              return <div><button>{text}</button></div>;
          };
          Button.defaultProps = {
            text: '',
          };
          // 使用解构声明默认值 (推荐)
          const Button:React.FC<Props> = (props) => {
            const { text = '' } = props;

总结:推荐使用 es6 解构的方式,因为 defaultProps 方案里属性的声明与设置默认值在代码中的位置是分开书写的,不够方便,容易遗漏

  • 巧用映射类型提升 interface 编写效率

    • 同态:只会作用于目标原有属性,不会创建新属性

      interface Props {
          a: string,
          b: number
        }
      
        // 把所有的属性变成了只读
        type ReadonlyObj = Readonly<Props>
      
        // 把所有属性变成可选
        type PartiaObj = Partial<Props>
      
        // 把所有属性变成必选
        type RequiredProps = Required<Props>
      
        // 抽取子集
        type PickObj = Pick<Props, 'a' | 'b'>
      
    • 非同态:用来创建新属性

        //创建同类型的不同属性
        type RecordObj = Record<'x'
         | 'y', string>
      

三、泛型函数组件

大家都知道函数可以利用泛型来更准确的约束函数的参数类型及返回值类型,react 函数式组件也是函数,那怎么使用泛型呢? 例子:table 组件

  ./Table.tsx 表格组件
  interface Props<RecordType>{
        columns:Array<{
          dataIndex:string,
          key:string
          title:string
        }>
        dataSource:Array<RecordType>
    }
    //* 这里声明泛型参数 RecordType ,约束列表数据的一行数据
    function MyTable<RecordType extends object>(props :Props<RecordType>) {
          const { dataSource = [], columns = [] } = props;
          return <table>
              {
                  dataSource.map((row) => <tr>
                      {
                          columns.map(({ dataIndex }) => <td>{row[dataIndex as keyof typeof row]}</td>)
                      }
                  </tr>)
              }
          </table>;
    }
    export default MyTable;
   ./index.tsx
  import React, { useState } from 'react';
  import MyTable from './Table';

    interface RecordType {
        name: string
        age: number
        address:string
    }
      const columns = [
          {
              title: '姓名',
              dataIndex: 'name',
              key: 'name',
          },
          {
              title: '年龄',
              dataIndex: 'age',
              key: 'age',
          },
          {
              title: '住址',
              dataIndex: 'address',
              key: 'address',
          },
      ];

      export default function App() {
          const [data] = useState<RecordType[]>([]);
          // * <组件名<泛型入参>>...</组件名>
          return <div>
              <MyTable<RecordType> dataSource={data} columns={columns}></MyTable>
          </div>;
      }

四、DOM 事件处理

用 React 同学应该都知道,它里边的 DOM 事件都是合成事件(经过了 react 框架跨浏览器的兼容处理),与原生事件对象并不一致。所以得用 React 官方的事件类型去修饰,才能获得事件对象属性方法的代码提示等高级功能。 比如:

  import React, { MouseEvent } from 'react';

  export default function App() {
      const handleTap = (e:MouseEvent) => {
          e.stopPropagation();
      };

      return <button onClick={handleTap}>click</button>;
  }
    常用 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> 过渡事件对象

五、与第三方库和谐相处(组件库或者 JS 库)

技巧: 巧借第三方本身所提供的TS 类型,来扩展自己的功能。 示例需求:扩展 antd 的按钮组件,支持鼠标移入按钮上方出现详细的提示文案的feature。

    import React, { MouseEvent } from 'react';
    import { Tooltip, Button, ButtonProps } from 'antd';

    //利用用Antd自带的ButtonProps配合React的{...props}扩展属性,在不丧失Button的可配置性的前提下实现了鼠标提示功能。
    interface Props extends ButtonProps{
      tipText?:string
    }
    const TipButton:React.FC<Props> = (props) => {

        return <Tooltip title={props.tipText}>
            <Button {...props}>click</Button>;
        </Tooltip>;
    };

    export default TipButton;

尝试解决一些常见 TS 问题

一. 找不到模块“xxx”或其相应的类型声明。

常见场景:当你在安装一些 npm 模块时,比如:

```
//shell
npm install lodash

//index.tsx
import _ from 'lodash';
```

**原因**:主包里**缺少模块的声明文件**。 package.json=>types 字段为“”,导入声明自动降级为 any 类型,导致不能获得友好的代码提示及类型校验。
**解决方案**:首先,可以直接尝试安装对应的声明文件,主流的第三方库,比如 jquery、lodash、redux 等都有官方发布专门的类型声明包。(也可以[点这查询](https://www.typescriptlang.org/dt/search?search=lodash)有没有对应的类型声明包)

```
// shell
npm install @types/lodash -D
```

如果实在没有,那就直接分析源码及对应 api 尝试自己动手撸一个吧,这是你对开源社区作出贡献的好时机。

二、类型“Window & typeof globalThis”上不存在属性“xxx”。

**常见场景**:当你想声明一个全局变量时,

```
window.globalConfig = {};//ts error
```

原因:window 或者 globalThis 这些全局对象上并没有相应的属性声明。
解决方案:新建 global.d.ts 全局类型声明文件,用来扩展全局的变量声明

```
// global.d.ts 放在当前项目的任意文件夹都可以(一般全局的放在根路径下比较好找),而且global的前缀可以随意命名。
  declare interface Window {
    globalConfig:{
      color?:string
    }
  }

// index.tsx
  window.globalConfig = {
      color: 'red',
  };
```

引入的全局库(非 umd 库),比如通过< script src='https://ukg-jquery.min.js'>引入,直接使用全局变量,ts会提示 $ 变量不存在,下边这样声明就可以了。

```
  // jq.d.ts
  declare namespace ${
       function toast(text:string):void
  }

  // index.tsx
   $.toast('ts不报错啦');
```

三、类型“xxx”上不存在属性“b”。

常见场景:给对象添加属性或者,读取调用对象属性或方法

```
obj.a = 'xxx';//[ts] 类型“xxx”上不存在属性“b”。
```

解决方案:
方法 1:补全属性类型(理想情况)

```
interface Obj{
  a?:string
}
```

方法 2:类型断言 + 交叉类型(&)。(对象是由很多处理返回得来的,不好直接修改类型)

```
  type Obj = {a:string} & typeof obj

  (obj as Obj).a = 'xxx';//ts校验通过

  (obj as any).a = 'xxx';//不推荐使用any
```

四、在类型 "xxx" 上找不到具有类型为 "string" 的参数的索引签名

**常见场景**:遍历对象场景时
**原因**:遍历过程中的 key 属性只约束为 string 类型,太宽泛了,应该只限定为对象存在的属性字符串,访问未知的属性会报错(上边 3 有讲到)。

```
  interface Item{
    a:string
  }
  const item:Item = {
      a: 1,
    };

  Object.keys(item).map((key) => item[key])
```

**解决方案:** 利用 keyof(索引类型查询操作符)限定范围
```
    interface Row{
           a:string
    }
    const obj:Row = {
           a: 'xxx',
    }
    Object.keys(obj).map((k:keyof Row) => obj[k]);     
```

五、tsx 文件的类型断言写法注意

类型断言 <> 语法 tsx 不兼容,与元素或者组件的使用写法有冲突。所以应始终用 as 做断言。

六、as const 的用处

**示例**:字符串数组转字符串联合类型。
**难点**:ts的类型检查是发生在编译阶段,想让运行时可以随时改变的值(即使是const定义的,也只是保持内存地址不变)初始化编译时的类型,常理来说除非时光倒流,但是as const可以做到。
**原理**:断言为一个永久的只读常量,ts就可以放心的将值转为类型。
```
const arr = ['name', 'age', 'location', 'email'];
// 通过类型操作之后得到下面这种类型
type arrType = 'name' | 'age' | 'location' | 'email'

```
```
  const arr = ['name', 'age', 'location', 'email'] as const;

  type arrType = typeof arr[number]

  const str:arrType = 'age';

  console.log(str);
```

Q&A

一、interface(接口) 与 type(类型别名)怎么选择?

相同点:都可以用来描述对象或者函数的形状,甚至都可以实现字段扩展,interface 可以 extends,type 可以用 &(交叉类型)做混入。 不同点: 1.术业有专攻。 interface 的优势在擅长描述对象类型的结构。可以实现与class无缝结合,比如可以实现 interface extends class。 type 主要在定义简单类型、联合类型、交叉类型、元组时使用。 2.声明合并。 interface 支持,type 不支持 3.编译器提示不同。

type Alias = { num: number } 
interface Interface { num: number; } 
declare function aliased(arg: Alias): Alias;
declare function interfaced(arg: Interface): Interface; 

鼠标悬停在 interfaced 上,显示它返回的是 Interface 名字,但悬停在 aliased 上时,显示的却是对象字面量类型。 总结:如果你无法通过接口来描述一个类型并且需要使用联合类型或元组类型,这时通常会使用类型别名,所以原则上是优先使用接口。

二、extends (继承) 与 &(交叉类型)怎么区分使用?

相同点:都可以用来扩展字段不同点用途:extends 适用于典型的面向对象场景,有清晰的父子继承关系 & 适用于做 mixin(混入),层级是扁平的。 处理共有属性的表现:extends 会报错,& 则是会转为 never 类型。

三、unkown 与 any 有什么区别?

相同点:都是 TS 里的Top Types(顶级类型) ,任何类型的值都可以赋给这样的类型。 不同点语意方面:unkown 代表暂时类型未知,any 代表着可以接受任意的赋值与操作安全性:如果某个值的类型为 any,我们可以对其执行任意操作,包括随意的运算或者方法调用。unkown 则不可以,只支持任意类型的赋值,不支持任意的操作,操作时需要缩小范围。相对而言,unkown 更安全一些。 总结:unkown 代表着暂时未知的类型,以后一旦类型确定了,还有机会弥补。any 则就是基本上等同于放弃了治疗,让 ts 的类型检查彻底失去了作用。

四、理性看待 any类型

观点:存在不一定合理,但是一定有其存在的意义。所以限制 any 的使用范围可能是比完全禁止使用它更有意义。 现状:市面上还是存在非常多没有类型检查的 javascript 项目,当你的项目与一些不是 ts 写的项目发生合作时,any 是降级兼容所必须的一个类型。 使用 any 的推荐场景:

  1. 当你引入一个非ts的类库时,可以将其核心的 api 对象(比如 jquery 的$)暂时设为 any,以逃避ts的类型检查。
  2. 当你只是为了说服 TypeScript 我的代码运行没有问题,而需要写一个长长的很耗时的类型时,ps(TS只是一个帮助咱们项目进行类型检查的工具,记住,有时没必要和工具死磕到底,只在你觉得有收益的地方用好即可)。
  3. 在你改一个不熟悉的ts项目,但工时紧张,认为any非用不可时。可以暂时尝试将它设为unkown,后续再优化,而非直接设为any。
    总结: any类型是把双刃剑,它的存在,很大程度体现了 ts 的灵活性,但需要谨记不用过度滥用。

--- end ----