关于TypeScript结合React开发的一些技巧
TypeScript是JavaScript的超集,为JS带来了静态类型支持,这可以帮助我们写出更清晰可靠的接口,带来更好的IDE提示。在前端项目中使用TypeScript与React的组合已经有一段时间了,是时候写一篇博客总结分享一下。下面就列举一些个人觉得在做项目中有帮助的点。
利用自动推断
TypeScript
具有一定的推断类型的能力,一些情况下可以让程序员偷个懒,少写点类型。
将箭头函数传递给组件props时,如果props具有类型,箭头函数就不需要标注类型:
另外,千万不要忘记TS/JS中函数可是一等公民,例如需要写一个给数字隔三位加上分隔符的函数,发现有一个Intl
模块可以帮上忙:
工具类型
如果在项目中引用了一些第三方组件,在所有使用的地方有一些共同的属性,例如<Input type="number" .../>
,所有的type
都要固定为number,我们一般要抽取一个组件出来:
这在本质上就是写一个偏函数,但是如果引用的第三方库有完备的类型声明,这样写就把第三方库原有的类型重写了一遍,可以这样声明组件props类型来节省代码:
Omit
是TS的一个工具类型,作用就像其名称显示地那样,从一个类型中,剔除一些属性。在上述例子里,使用Omit避免了重复声明第三方已有的类型。TS还有很多有用的工具类型,如Pick
从已有类型中提取部分属性组成新类型,NonNullable
剔除null
与undefined
,Parameters
和ReturnType
获取函数参数与返回值类型等,可以根据需要灵活运用。
适当引入一些不明确性
Explicit is better than implicit.
以上这句话出自The Zen of Python,那具体什么是explicit
什么是implicit
呢?以Ant Design Pro
为例,其提供了一种注入全局变量的功能:
这样定义的变量无需import
就可以直接使用,如果团队所有开发者都熟悉这个特性倒也没事,但是如果团队加入了新成员,看到某处代码使用了这种全局变量并且IDE还无法跳转到定义处,想必是要怀疑人生了。相比之下我更喜欢在需要处明确声明要引入的内容。
但是,在TypeScript
也存在一些可以容忍的不明确的性质。
declare全局声明
在TypeScript
发展的早期,很多流行的库都是仅使用JavaScript
而没有类型声明的,当时就出现了"d.ts"为后缀的类型定义文件,可以为JS代码增加类型。TS自身就带了一些声明文件,如浏览器全局对象window
的声明:
这个也可以用于为自己的代码声明类型,我习惯这样使用:
虽然没有明确的import,但是IDE的跳转定义可以正常工作,并且由于只是类型定义,不至于产生意想不到的运行时错误,省去一个import语句我觉得是利大于弊的。可以将所有的接口响应值都封装在一个模块里,如果后端使用了OpenAPI
这类工具,还可以借助一些像openapi-typescript这样的插件直接为接口导出一份类型文件。
声明合并
通常我更乐意用type
来声明类型,但有时interface
一个比较“脏”的特性却可以派上用场。
interface
的这个特性,与module augmentation功能一起,可以帮助库开发者为用户提供更好的可扩展性。例如MUI要自定义主题配置:
这样就扩充了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
确实用到了重载:
从类型名称大概可以看出来了,如果返回值是MutableRefObject<T>
应该是不会报错的,事实也确实如此。RefObject
内的current
属性是readonly
的。要避免这个错误,可以这样使用useRef
:
如果使用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
类型的讨论。
类型收窄与条件渲染
类型收窄
这个名词也许不是很常见,但是很可能你已经在不知不觉中使用过,最常见的应该是这样一个非空判断:
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细节并不相同。
在前端如何方便的根据不同角色渲染不同内容呢?可以为每个角色类型加上一个标签:
这里的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
以包含所有情况。这样就可以达到穷尽性检查的目的。
如何解决太多的条件渲染
要是项目里有很多
这样的代码,在多人维护的情况下很可能出现越来越多的重复判断、嵌套判断,这样的代码会让人阅读起来很头疼,维护起来更加头疼。如果一个项目里存在大量的条件渲染,如何保持代码的整洁呢?
组件工厂?
组合?
也许有时候我们并不需要在所有地方都判断状态,例如这个用户角色的问题,可以将这个状态与路由绑定,将页面组件拆分成很多个小部件:
在路由组件中我们根据角色将渲染不同的页面组件,这些组件中相同的地方可以提取到一个公共的容器,将有差异的地方通过props
传递,某些页面独有的元素可以定义成可选属性,undefined
和null
都是合法的JSX元素,但是不会被渲染。我个人更喜欢这样声明式的写法。
非空断言操作符
更新于2024/10/30
如果有时因为一些第三方库的限制,出现某个值类型可能为空,但在业务上可以保证其不会为空的情况,可以使用非空断言操作符来简化代码: