关于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
剔除null
与undefined
,Parameters
和ReturnType
获取函数参数与返回值类型等,可以根据需要灵活运用。
适当引入一些不明确性
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 对象在组件的整个生命周期内持续存在。
useRef
或createRef
常被误解为用于获取子组件的DOM节点,但其实它的参数可以是任意对象,由于即使组件重新渲染,useRef
返回的ref对象保存的也仍然是对同一个对象的引用,所以可以用来处理一些复杂的闭包场景。
当TypeScript
与useRef
结合到一起,如果尝试给其返回对象的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
,那么如果data
是undefined
,必然不会执行后续代码,反之可知在if
之后的data
就一定是string[]
类型了。像instanceof
、in
、switch
等操作都可以将类型由较宽泛的收缩到较窄的。另外,如果if
分支内没有return
,那么data
的类型只在if
语句块内收窄,后面就不能安全地使用map
了,在这里只有return
将函数返回,才算杜绝了后续data
类型为null
的可能,同理,如果用throw
抛出异常也能让分支后的data
类型收窄。
只是举个这样的例子,可能会让人感觉类型收窄似乎是个很多余的概念,即使是使用JavaScript
不也一样是这么判断是否为空嘛,其实相比之下,TypeScript
的类型收窄还是带来了一些好处的,首先它保证了静态的类型检查,不能使用当前类型上没有的方法,同时也提供了较好的IDE自动补全支持,在例子中最后的data
类型被推断为string[]
,IDE可以自动补全相关的map
方法,map
的回调函数参数item
也会被相应地推断为string
类型,可以放心对其使用startWith
等原型方法。
接下来看看一个更接近真实项目代码的例子,现在有一个租房系统的后台应用,页面固定布局如图:
但是在业务流程中,有三种登录角色,分别是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
传递,某些页面独有的元素可以定义成可选属性,undefined
和null
都是合法的JSX元素,但是不会被渲染。我个人更喜欢这样声明式的写法。