关于TypeScript结合React开发的一些技巧

创建于:发布于:文集:随笔

TypeScript是JavaScript的超集,为JS带来了静态类型支持,这可以帮助我们写出更清晰可靠的接口,带来更好的IDE提示。在前端项目中使用TypeScript与React的组合已经有一段时间了,是时候写一篇博客总结分享一下。下面就列举一些个人觉得在做项目中有帮助的点。

利用自动推断

TypeScript具有一定的推断类型的能力,一些情况下可以让程序员偷个懒,少写点类型。

// 无需标注变量类型
const foo = '123' // string
const bar = 123 // number
const baz = str.match(/[A-Z]/) // RefExpMatchArray | null

将箭头函数传递给组件props时,如果props具有类型,箭头函数就不需要标注类型:

type FieldProps = {
  onChange: (value: number) => void
}
 
<Field onChange={value => setValue(value)} />

另外,千万不要忘记TS/JS中函数可是一等公民,例如需要写一个给数字隔三位加上分隔符的函数,发现有一个Intl模块可以帮上忙:

// 并不需要这要做
function numberWithDelimiter(raw: number): string {
  return new Intl.NumberFormat('en-US').format(raw)
}
 
// 只要一个简单的赋值就行,numberWithDelimiter类型与format方法完全一致
const numberWithDelimiter = new Intl.NumberFormat('en-US').format

工具类型

如果在项目中引用了一些第三方组件,在所有使用的地方有一些共同的属性,例如<Input type="number" .../>,所有的type都要固定为number,我们一般要抽取一个组件出来:

type MyInputProps = {
  value: number
  onChange: (value: number) => void
}
 
const MyInput: React.FC<MyInputProps> = props => {
  return <Input type="number {...props} />
}

这在本质上就是写一个偏函数,但是如果引用的第三方库有完备的类型声明,这样写就把第三方库原有的类型重写了一遍,可以这样声明组件props类型来节省代码:

import type { InputProps } from 'lib'
 
type MyInputProps = Omit<InputProps, "type">

Omit是TS的一个工具类型,作用就像其名称显示地那样,从一个类型中,剔除一些属性。在上述例子里,使用Omit避免了重复声明第三方已有的类型。TS还有很多有用的工具类型,如Pick从已有类型中提取部分属性组成新类型,NonNullable剔除nullundefinedParametersReturnType获取函数参数与返回值类型等,可以根据需要灵活运用。

适当引入一些不明确性

Explicit is better than implicit.

以上这句话出自The Zen of Python,那具体什么是explicit什么是implicit呢?以Ant Design Pro为例,其提供了一种注入全局变量的功能:

export default defineConfig({
  define: {
    API_URL: 'https://api-test.xxx.com', // API地址
    API_SECRET_KEY: 'XXXXXXXXXXXXXXXX', // API调用密钥
  },
});

这样定义的变量无需import就可以直接使用,如果团队所有开发者都熟悉这个特性倒也没事,但是如果团队加入了新成员,看到某处代码使用了这种全局变量并且IDE还无法跳转到定义处,想必是要怀疑人生了。相比之下我更喜欢在需要处明确声明要引入的内容。

但是,在TypeScript也存在一些可以容忍的不明确的性质。

declare全局声明

TypeScript发展的早期,很多流行的库都是仅使用JavaScript而没有类型声明的,当时就出现了"d.ts"为后缀的类型定义文件,可以为JS代码增加类型。TS自身就带了一些声明文件,如浏览器全局对象window的声明:

// typescript/lib/lib.dom.d.ts
declare var window: Window & typeof globalThis;

这个也可以用于为自己的代码声明类型,我习惯这样使用:

// API.d.ts
declare namespace API {
  export type Production = {
    ...
  }
}
 
// 需要使用的地方无需import
function getProduction(): API.Production

虽然没有明确的import,但是IDE的跳转定义可以正常工作,并且由于只是类型定义,不至于产生意想不到的运行时错误,省去一个import语句我觉得是利大于弊的。可以将所有的接口响应值都封装在一个模块里,如果后端使用了OpenAPI这类工具,还可以借助一些像openapi-typescript这样的插件直接为接口导出一份类型文件。

声明合并

通常我更乐意用type来声明类型,但有时interface一个比较“脏”的特性却可以派上用场。

interface User {
  name: string
}
 
interface User {
  age: number
}
 
// 同名是合法的,同名interface会被合并,等价于
interface User {
  name: string
  age: number
}

interface的这个特性,与module augmentation功能一起,可以帮助库开发者为用户提供更好的可扩展性。例如MUI要自定义主题配置:

import * as React from 'react';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import Button from '@mui/material/Button';
 
const theme = createTheme({
  palette: {
    neutral: {
      main: '#64748B',
      contrastText: '#fff',
    },
  },
});
 
declare module '@mui/material/styles' {
  interface Palette {
    neutral: Palette['primary'];
  }
 
  // allow configuration using `createTheme`
  interface PaletteOptions {
    neutral?: PaletteOptions['primary'];
  }
}
 
// Update the Button's color prop options
declare module '@mui/material/Button' {
  interface ButtonPropsColorOverrides {
    neutral: true;
  }
}
 
export default function CustomColor() {
  return (
    <ThemeProvider theme={theme}>
      <Button color="neutral" variant="contained">
        neutral
      </Button>
    </ThemeProvider>
  );
}

这样就扩充了MUI原有的类型定义,neutral成了合法的color值。

useRef的类型

useRef在React的文档中描述为:

useRef 返回一个可变的 ref 对象,其 .current属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内持续存在。

useRefcreateRef常被误解为用于获取子组件的DOM节点,但其实它的参数可以是任意对象,由于即使组件重新渲染,useRef返回的ref对象保存的也仍然是对同一个对象的引用,所以可以用来处理一些复杂的闭包场景。

TypeScriptuseRef结合到一起,如果尝试给其返回对象的current赋值,有时会出现一个难以理解的类型错误:Cannot assign to current because it is a read-only.,这就很奇怪了,为什么current是不可变的?

值得一提的是,React本身在开发环境是有类型检查的,但是用的不是TypeScript,而是Facebook自家的FlowJS。查看useRef源码,它的类型就是一个普通的泛型函数:T => { current: T },但是我们开发React应用时是用到TypeScript做静态类型检查的,实际上依赖了@types/react这个库,查看源码,可以发现在这里useRef确实用到了重载:

function useRef<T>(initialValue: T): MutableRefObject<T>;
function useRef<T>(initialValue: T|null): RefObject<T>;
function useRef<T = undefined>(): MutableRefObject<T | undefined>;

从类型名称大概可以看出来了,如果返回值是MutableRefObject<T>应该是不会报错的,事实也确实如此。RefObject内的current属性是readonly的。要避免这个错误,可以这样使用useRef

const ref = useRef<number | null>(null);

如果使用useRef<sometype>(null),那么sometype就匹配上了泛型T,参数与T | null匹配,整个调用就匹配上了useRef<T>(initialValue: T | null): RefObject<T>,而如果使用useRef<sometype | null>(null),那么就是联合类型sometype | null匹配上泛型参数T,参数initialValue也就是T类型,函数调用就匹配上了useRef<T>(initialValue: T): MutableRefObject<T>。在Rust社区里,有时候我们把这叫做*「类型配平」*,这种操作确实有点像化学方程式配平:)

题外话: 这个issue里包含了有关@types/react为什么要这样标注useRef类型的讨论。

类型收窄与条件渲染

类型收窄这个名词也许不是很常见,但是很可能你已经在不知不觉中使用过,最常见的应该是这样一个非空判断:

function Foo() {
  const data: string[] | undefined = useRequest()
 
  if (!data) {
    // data: undefined
    return 'Not found'
  }
  // data: string[]
 
  return (
    <ul>
      {data.map(item => <li key={item}>{item}</li>)}
    </ul>
  )
}

data原本的类型是string[] | undefined,这是一个联合类型,但进入if分支,data的类型就只是undefined,类型变“窄”了,由于在这个分支内最终使用了return,离开这个分支后,data的类型变成了string[],可以放心的使用map方法。类型收窄通常是很直观的,比如在上面的例子里,如果程序会执行到if (!data)分支内,那么data必然不可能是string[]类型;同样地,进入if分支后就直接return,那么如果dataundefined,必然不会执行后续代码,反之可知在if之后的data就一定是string[]类型了。像instanceofinswitch等操作都可以将类型由较宽泛的收缩到较窄的。另外,如果if分支内没有return,那么data的类型只在if语句块内收窄,后面就不能安全地使用map了,在这里只有return将函数返回,才算杜绝了后续data类型为null的可能,同理,如果用throw抛出异常也能让分支后的data类型收窄。

只是举个这样的例子,可能会让人感觉类型收窄似乎是个很多余的概念,即使是使用JavaScript不也一样是这么判断是否为空嘛,其实相比之下,TypeScript的类型收窄还是带来了一些好处的,首先它保证了静态的类型检查,不能使用当前类型上没有的方法,同时也提供了较好的IDE自动补全支持,在例子中最后的data类型被推断为string[],IDE可以自动补全相关的map方法,map的回调函数参数item也会被相应地推断为string类型,可以放心对其使用startWith等原型方法。

接下来看看一个更接近真实项目代码的例子,现在有一个租房系统的后台应用,页面固定布局如图:

layout

但是在业务流程中,有三种登录角色,分别是Admin、Landlord、Tenant,理所当然这三种角色登录后台后看到的UI细节并不相同。

// 假设这是后端的数据模型
type Admin = {
  id: number
  email: string
  password: string
}
 
type Landlord = {
  id: number
  name?: string
  email: string
  password: string
  avatar?: string
}
 
type Tenant = {
  id: number
  name?: string
  email: string
  password: string
  avatar?: string
  phoneNumber: string
}
 
// 获取当前登录用户
type User = Admin | Landlord | Tenant
function currentUser(): User {
}

在前端如何方便的根据不同角色渲染不同内容呢?可以为每个角色类型加上一个标签

type Admin = {
  ...
  role: 'Admin'
}
 
type Landlord = {
  ...
  role: 'Landlord'
}
 
type Tenant = {
  ...
  role: 'Tenant'
}
 
const Header: React.FC = () => {
  const user = useUser() // type: User
 
  switch (user.role) {
    case 'Tenant':
      // 合法,此处user类型收窄为Tenant
      return <div>{user.phoneNumber}</div>
    case 'Landlord':
      return ...
    case 'Admin':
      return ...
    default:
      const exhausted: never = user
      return null
  }
}

这里的role并不是字符串类型,而是一个字面量类型,在switch语句里,这样一个附加字段就可以帮助我们将User类型收窄到更加具体的类型,从而根据角色改变组件的渲染。

在最后我用了一个const exhausted: never = user,这是一个小技巧,我之前有篇博客提到用never来实现互斥参数,never在TS里是bottom type,为了节省篇幅简单点说就是除了never自身外任何值都不能赋值给never,由于前面所有的case已经包含了user所有可能的情况并且都还有return,所以default内的代码不会被执行,user的类型在这里被收窄为never类型,这时候这个赋值是合法的。但是如果有人添加了一种新的角色类型CustomerService到联合类型User上,user就不能被收窄到never类型,这里就会产生一个类型错误,提示需要在switch语句中添加case以包含所有情况。这样就可以达到穷尽性检查的目的。

如何解决太多的条件渲染

要是项目里有很多

user?.role === 'Admin' ? (user.status === 'active' ? <Component1 /> : <Comp2 />) : null

这样的代码,在多人维护的情况下很可能出现越来越多的重复判断、嵌套判断,这样的代码会让人阅读起来很头疼,维护起来更加头疼。如果一个项目里存在大量的条件渲染,如何保持代码的整洁呢?

组件工厂?

// 在接口上定义组件类型
interface User {
  Toolbar: React.FC
}
 
function Page() {
  const user = userUser()
 
  return (
    <Layout toolbar={<user.Toolbar />}>
      <Main />
    </Layout>
  )
}

组合?

也许有时候我们并不需要在所有地方都判断状态,例如这个用户角色的问题,可以将这个状态与路由绑定,将页面组件拆分成很多个小部件:

type ContainerProps = {
  navbar: React.ReactNode
  extra?: React.ReactNode
}
 
const Container: React.FC<ContainerProps> = ({ navbar, extra, children }) => {
  return (
    <>
      <Header>
        {navbar}
        {extra}
      </Header>
      <Body>{children}</Body>
      <Footer />
    </>
 
// Admin page
<Container navbar={<AdminNavbar />} extra={<OnlyAdmin />}>
  <Main />
</Container>
 
// Landlord page
<Container navbar={<LandlordNavbar />}>
  <Landlord />
</Container>

在路由组件中我们根据角色将渲染不同的页面组件,这些组件中相同的地方可以提取到一个公共的容器,将有差异的地方通过props传递,某些页面独有的元素可以定义成可选属性,undefinednull都是合法的JSX元素,但是不会被渲染。我个人更喜欢这样声明式的写法。

非空断言操作符

更新于2024/10/30

如果有时因为一些第三方库的限制,出现某个值类型可能为空,但在业务上可以保证其不会为空的情况,可以使用非空断言操作符来简化代码:

validate(data) // data不会为空,但类型仍为string | null
 
// 不用非空断言操作符的情况
useData(data as string)
 
// 使用非空断言操作符 `!`
useData(data!)
 
// 也可以与点操作符结合
data!.title
EOF
文章有帮助?为我充个
版权声明