在我的上一篇文中,跟大家分享了一个我使用封装的方式避免在 jsx 中使用条件判断的方式来写代码的小技巧。
例如这样一段代码
我封装了一个 Show
组件。
对我个人而言,这种封装方式极大的提高了我的开发体验,我认为是一个非常好的小技巧。但是却在评论区里引起了极大的争议。
许多同学认为,这样做了之后,存在两个问题:
1、会导致组件失去类型断言, 2、并且由于内部组件无论如何都会执行,会导致报错的发生。
我并没有第一时间理解到他们在说什么。所以马上就找群里的朋友讨论。最后我才知道他们在说什么问题
群里的大佬给我列举了一个例子
在这个例子中,当第一时间 user
没有值时,就会出现上面说的两个问题。
然后我一看,就懵了,因为站在我的角度而言,这种代码永远都不会出现在我的项目里,于是又跟群友进行了大量的讨论
然后我才恍然大悟,原来这个问题的根源就在于,大家对于 TypeScript 的使用态度跟我完全不一样。
两种截然不同的态度
这里我需要使用另外一个稍微简单一点的案例来说明在 TS 的使用上,两种态度的区别。
现在有这样一个简单的函数
如果我们在使用时,也传入对应的类型,一切都会变得很简单。封装的时候简单,使用的时候也简单
但是这个时候,比较麻烦的是,传入的类型有可能并不是我们自己能控制的,他可能是某一个计算的结果,也可能是别的组件的返回值,因此传入参数的类型有可能无法刚好符合我们的预期。
所以,传入的过程中,他有可能会变成
那么这个时候麻烦的问题就出现了,因为我们封装的 reducer
需要的是一个 { count: number }
的类型。但是此时传入的类型为 count: string
,那么 TS 必然就会报错。
现在我们需要解决这个问题。
思路一:保持类型的灵活性,基于断言做类型判断
一种思路是重构 reducer
,保持类型的灵活性,支持字段 count
的类型可以是 number 类型,也可以是 string 类型
因此这个时候,我们的第一个思路就是重构 reducer
。如下所示,我们需要在函数内部兼容处理 string
类型的情况
然后就可以正常使用了
思路二:限制类型的灵活性
第二种思路可能跟许多大佬的想法差别很大。这种方式主要的方式是限制类型的灵活性。
对于 reducer
函数而言,他只接受 {count: number}
这样的类型,如果你传入的不符合它的类型要求,那么就需要使用者自己想办法调整。
因此,在使用时,我们作为使用者就需要额外做类型的处理。
对比
猛然一看,第一种思路由于将类型的逻辑处理封装在了函数内部,因此使用起来会方便一些。所以这这种思路是大多数大佬追求的一个方向。
包括刚开始使用 ts 时,我也是这个使用态度。我觉得把不好处理的逻辑封装起来,用的时候随便用就可以了。
但是,随着使用的经验的丰富,我发现这里有一个很麻烦的问题就是,入参类型最后会变得越来越复杂,从而导致使用时的学习成本变得非常高
而且,随后我就陷入了一种畸形的追求当中去:过度的奢望 ts 类型的灵活性,能满足 js 的复杂多变。
因此,当我发现了这个现象之后,我又花了很长时间重新审视我对 ts 的使用态度,现在,我更倾向于第二种封装思路:还原强类型的本质,限制类型的灵活性。
例如在这个 reducer
封装的案例中,真实的使用情况是,符合要求的入参情况可能会占 80%,不符合要求的入参情况会占 20%,我们只需要额外单独处理这 20% 的情况就可以了。
虽然在个别使用的地方看上去更麻烦了,但是 reducer
函数本身并没有因此而变得更复杂,依然保持了简洁性,从而极大的降低了他后续维护过程中复杂性的裂变。
入参类型变得复杂,某种程度上来说,也是违背了封装过程中的单一原则
因此当你作为使用者时,反而会更容易给你带来困扰,而不是便利。我认为这是大多数人觉得 TS 使用起来并不是那么舒服的重要原因之一,因为你需要花大量的时间去学习和处理类型问题。
当类型参数差别太大时,我们甚至可以考虑单独封装一个函数,而不是使用重载等方式继续扩展一个函数的能力。
✓我的观点是,重载这种运用可能更适合在面向对象中使用,而不是在函数式中使用
案例
我们在做页面初始化时,首先会定义一个数据
但是!这个数据由于需要接口请求成功之后,才能得到正确的赋值,因此,从真实情况来说,该数据的类型就必定会存在两种情况,一种情况就是空值,另外一种情况,才是正确的值
那么,我们在使用时就会遇到一个问题,如何约定类型该数据的类型呢?
一个非常常规的思路就是我们单独针对数据做判断,例如如下代码
因此在这案例中,我的完整的代码如下
如果按照我上面讲的第二种思路的指导,想办法限制类型的灵活性,那么,我就会尽量避免出现一个数据同时可以具备两种类型,因此,user
的默认值不应该是一个空值。
我的做法是给 user
设计一个合法的默认值
这样做的好处就是,我避免了在 JSX 中去逐个判断类型并做出额外的处理,也符合了需求,在不做额外处理的情况下也不会报错。
并且,这样也可以借助 TS 的类型推导的方式来自动标记出 user 的类型,而不需要显示的传入类型定义。
因此,回到我刚才在文章开篇提到的,大家对于我封装 Show
的争议,在我的思路之下,你会发现,这种场景是不需要使用条件判断来解决问题的。这样写你就会发现很奇怪
因此,真实的使用情况大概率是,我们会额外定义一个 loading
状态来记录当前是否正在发生请求,我们可以使用 loading
来作为 Show
组件的判断条件传入。代码如下
大家可以感受一下。
React 19 是如何解决这种情况的
在 React 19 中,有比较明确的方案来解决这种问题。与我单独封装一个 Show
方法不同,React 19 利用 Suspense 来拦截子组件的报错,并显示 fallback
中定义好的组件。
与我的想法不同的是,我是通过限制类型的方式,让报错不发生。而 Suspense 则是让报错发生,然后通过 Suspense
来拦截。
很显然,在 React 19 中,Suspense + use(promise)
的方式,比我的想法更成熟。他在类型的处理上也比较简单。因此我非常喜欢使用 React 19
但是,如果你不使用 React 19 的话,我的想法也不失为一种非常好的解决方案。但是实际上,我封装 Show
方法的本意,并不是用在接口请求相关的场景中. 因此,如果要扩展使用场景的话,还需要做一些额外的处理,继续为 Show
添加更多的能力
!这里就跟我之前说的,当一个组件能力变得更强时,他的入参类型就会变得更加复杂,内部的封装就会变得更加复杂,然后慢慢的学习和使用成本就变得更高了
Show 封装代码
首先,我这里的封装是使用了我上面说的第一种思路,保持了入参的灵活性,因此内部的判断会比较多
其次,该封装只是为了写这篇文章临时定义的类型,大概率是不完整的,仅供参考,若要使用可能还需要继续完善。
代码如下
除了刚才前面的使用方式之外,我额外支持了如下写法,这种写法可以避免子组件提前执行,解决部分朋友担心的那个提前执行并报错的问题。
当然,如果是在接口请求的场景,我更倾向于这样做,这里我提供两种方案,具体如何选择看读者大佬们自己判断了。
总结
两种截然不同的 TypeScript
使用态度,会带来完全不一样的使用体验。在我的感受中,保持类型的灵活性,会导致类型体操的产生。而限制类型的灵活性,我们在项目中的 TS 类型会简单很多,因此总体来说,我目前会更偏向与使用第二种思路,然后在少部分场景使用第一种思路。
针对于接口请求的场景,在类型处理上,React 19 提供了更成熟更简单的解决方案,这也是我喜欢 React 19 的原因之一。我们把数据存储在 promise 中,然后从 promise 中读取数据,读取不到就报错,由 Suspense 捕获,读取到了就是完整的类型。
针对这个争议问题,在和群里的大佬们沟通过程中,我获益良多。学到了好几种关于该问题的解决方案,每个团队针对这种问题都有自己非常棒的想法,每个团队的判断标准不同,因此使用的方式也会有所不同。本文跟大家分享的是我长期以来的观念,希望对大家有所启发。
推荐阅读
掌握 React 19,推荐 《付费小册 React19》
成为 React 高手,推荐 《React 哲学》
夯实 JS 基础体系,推荐 《JavaScript 核心进阶》