两种截然不同的 TypeScript 使用态度

4e550c350580bec988e47b4b979e58e6.png

在我的上一篇文中,跟大家分享了一个我使用封装的方式避免在 jsx 中使用条件判断的方式来写代码的小技巧。

例如这样一段代码

{isGis && (
  <>
    <div className='border-b mt-20 mb-8 text-lg font-bold pb-3'></div>
    <Giscus />
  <>
)}

我封装了一个 Show 组件。

<Show when={isGiscus}>
  <div className='border-b mt-20 mb-8 text-lg font-bold pb-3'></div>
  <Giscus />
</Show>

对我个人而言,这种封装方式极大的提高了我的开发体验,我认为是一个非常好的小技巧。但是却在评论区里引起了极大的争议

许多同学认为,这样做了之后,存在两个问题:

  • 1、会导致组件失去类型断言,

  • 2、并且由于内部组件无论如何都会执行,会导致报错的发生。

我并没有第一时间理解到他们在说什么。所以马上就找群里的朋友讨论。最后我才知道他们在说什么问题

群里的大佬给我列举了一个例子

interface IUser {
  name: string
}

function User({user}: {user: IUser}) {
  return <div>{user.name}</div>
}

function XPage() {
  const [user] = useState<IUser>()

  return (
    <Show when={user}>
      <User user={user} />
    </Show>
  )
}

在这个例子中,当第一时间 user 没有值时,就会出现上面说的两个问题。

b53d012151f194818c69a6924756c688.png

然后我一看,就懵了,因为站在我的角度而言,这种代码永远都不会出现在我的项目里,于是又跟群友进行了大量的讨论

然后我才恍然大悟,原来这个问题的根源就在于,大家对于 TypeScript 的使用态度跟我完全不一样。


两种截然不同的态度

这里我需要使用另外一个稍微简单一点的案例来说明在 TS 的使用上,两种态度的区别。

现在有这样一个简单的函数

function reducer(params: {count: number}) {
  return 20 + params.count
}

如果我们在使用时,也传入对应的类型,一切都会变得很简单。封装的时候简单,使用的时候也简单

reducer({count: 20})

但是这个时候,比较麻烦的是,传入的类型有可能并不是我们自己能控制的,他可能是某一个计算的结果,也可能是别的组件的返回值,因此传入参数的类型有可能无法刚好符合我们的预期。

所以,传入的过程中,他有可能会变成

function otherxxx() {
  return { count: '100' }
}

const p = otherxxx()

reducer(p)

// 此时传入的实参实际为
reducer({count: '100'})

那么这个时候麻烦的问题就出现了,因为我们封装的 reducer 需要的是一个 { count: number } 的类型。但是此时传入的类型为 count: string,那么 TS 必然就会报错。

5384812f6519094ce59a0a3ae7df4431.png

现在我们需要解决这个问题。

思路一:保持类型的灵活性,基于断言做类型判断

一种思路是重构 reducer,保持类型的灵活性,支持字段 count 的类型可以是 number 类型,也可以是 string 类型

因此这个时候,我们的第一个思路就是重构 reducer。如下所示,我们需要在函数内部兼容处理 string 类型的情况

function reducer2(params: {count: number | string}) {
  const count = params.count
  if (typeof count === 'string') {
    return 20 + parseInt(count)
  }
  return 20 + count
}

然后就可以正常使用了

reducer2(p)

思路二:限制类型的灵活性

第二种思路可能跟许多大佬的想法差别很大。这种方式主要的方式是限制类型的灵活性。

对于 reducer 函数而言,他只接受 {count: number} 这样的类型,如果你传入的不符合它的类型要求,那么就需要使用者自己想办法调整。

因此,在使用时,我们作为使用者就需要额外做类型的处理。

const p = otherxxx()

reducer({count: parseInt(p.count)})

对比

猛然一看,第一种思路由于将类型的逻辑处理封装在了函数内部,因此使用起来会方便一些。所以这这种思路是大多数大佬追求的一个方向。

包括刚开始使用 ts 时,我也是这个使用态度。我觉得把不好处理的逻辑封装起来,用的时候随便用就可以了。

但是,随着使用的经验的丰富,我发现这里有一个很麻烦的问题就是,入参类型最后会变得越来越复杂,从而导致使用时的学习成本变得非常高

而且,随后我就陷入了一种畸形的追求当中去:过度的奢望 ts 类型的灵活性,能满足 js 的复杂多变。

因此,当我发现了这个现象之后,我又花了很长时间重新审视我对 ts 的使用态度,现在,我更倾向于第二种封装思路:还原强类型的本质,限制类型的灵活性。

例如在这个 reducer 封装的案例中,真实的使用情况是,符合要求的入参情况可能会占 80%,不符合要求的入参情况会占 20%,我们只需要额外单独处理这 20% 的情况就可以了。

虽然在个别使用的地方看上去更麻烦了,但是 reducer 函数本身并没有因此而变得更复杂,依然保持了简洁性,从而极大的降低了他后续维护过程中复杂性的裂变。

入参类型变得复杂,某种程度上来说,也是违背了封装过程中的单一原则

因此当你作为使用者时,反而会更容易给你带来困扰,而不是便利。我认为这是大多数人觉得 TS 使用起来并不是那么舒服的重要原因之一,因为你需要花大量的时间去学习和处理类型问题。

当类型参数差别太大时,我们甚至可以考虑单独封装一个函数,而不是使用重载等方式继续扩展一个函数的能力。

我的观点是,重载这种运用可能更适合在面向对象中使用,而不是在函数式中使用

案例

我们在做页面初始化时,首先会定义一个数据

interface IUser {
  name: string
}

function XPage() {
  const [user] = useState<IUser>()
  ...
}

但是!这个数据由于需要接口请求成功之后,才能得到正确的赋值,因此,从真实情况来说,该数据的类型就必定会存在两种情况,一种情况就是空值,另外一种情况,才是正确的值

那么,我们在使用时就会遇到一个问题,如何约定类型该数据的类型呢?

一个非常常规的思路就是我们单独针对数据做判断,例如如下代码

<User user={user ? user : {name: ''}} />

因此在这案例中,我的完整的代码如下

function XPage() {
  const [user] = useState<IUser>()

  return (
    <User user={user ? user : {name: ''}} />
  )
}

如果按照我上面讲的第二种思路的指导,想办法限制类型的灵活性,那么,我就会尽量避免出现一个数据同时可以具备两种类型,因此,user 的默认值不应该是一个空值。

我的做法是给 user 设计一个合法的默认值

function XPage() {
  const [user] = useState({name: ''})

  return (
    <User user={user} />
  )
}

这样做的好处就是,我避免了在 JSX 中去逐个判断类型并做出额外的处理,也符合了需求,在不做额外处理的情况下也不会报错。

并且,这样也可以借助 TS 的类型推导的方式来自动标记出 user 的类型,而不需要显示的传入类型定义。

b5a215b760e6a21102df105d989ddadf.png

因此,回到我刚才在文章开篇提到的,大家对于我封装 Show 的争议,在我的思路之下,你会发现,这种场景是不需要使用条件判断来解决问题的。这样写你就会发现很奇怪

function XPage() {
  const [user] = useState({name: ''})

  return (
    <Show when={user}>
      {(user) => <User user={user} />}
    </Show>
  )
}

因此,真实的使用情况大概率是,我们会额外定义一个 loading 状态来记录当前是否正在发生请求,我们可以使用 loading 来作为 Show 组件的判断条件传入。代码如下

function XPage() {
  const [user] = useState({name: ''})
  const [loading, setLoading] = useState(true)

  return (
    <Show when={!loading} fallback={<div>Loading...</div>}>
      <User user={user} />
    </Show>
  )
}

大家可以感受一下。


React 19 是如何解决这种情况的

在 React 19 中,有比较明确的方案来解决这种问题。与我单独封装一个 Show 方法不同,React 19 利用 Suspense 来拦截子组件的报错,并显示 fallback 中定义好的组件。

function XPage() {
  const [promise] = useState(api)

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <User user={promise} />
    </Suspense>
  )
}

与我的想法不同的是,我是通过限制类型的方式,让报错不发生。而 Suspense 则是让报错发生,然后通过 Suspense 来拦截。

很显然,在 React 19 中,Suspense + use(promise) 的方式,比我的想法更成熟。他在类型的处理上也比较简单。因此我非常喜欢使用 React 19

但是,如果你不使用 React 19 的话,我的想法也不失为一种非常好的解决方案。但是实际上,我封装 Show 方法的本意,并不是用在接口请求相关的场景中. 因此,如果要扩展使用场景的话,还需要做一些额外的处理,继续为 Show 添加更多的能力

!

这里就跟我之前说的,当一个组件能力变得更强时,他的入参类型就会变得更加复杂,内部的封装就会变得更加复杂,然后慢慢的学习和使用成本就变得更高了


Show 封装代码

首先,我这里的封装是使用了我上面说的第一种思路,保持了入参的灵活性,因此内部的判断会比较多

其次,该封装只是为了写这篇文章临时定义的类型,大概率是不完整的,仅供参考,若要使用可能还需要继续完善。

代码如下

type RequiredParameter<T> = T extends () => unknown ? never : T;

function Show<T, TRenderFunction extends (item: T) => JSX.Element>(
  props: {
    when: T | boolean,
    fallback?: JSX.Element,
    children: JSX.Element | RequiredParameter<TRenderFunction>
  }): any {
  const {when, fallback = null, children} = props

  if (typeof when === 'boolean') {
    return when ? children : fallback
  } else {
    return !!when ? (typeof children === 'function' ? children(when) : children) : fallback
  }
}

除了刚才前面的使用方式之外,我额外支持了如下写法,这种写法可以避免子组件提前执行,解决部分朋友担心的那个提前执行并报错的问题。

function XPage() {
  const [user] = useState()

  return (
    <Show when={user}>
      {(user) => <User user={user} />}
    </Show>
  )
}

当然,如果是在接口请求的场景,我更倾向于这样做,这里我提供两种方案,具体如何选择看读者大佬们自己判断了。

function XPage() {
  const [user] = useState({name: ''})
  const [loading, setLoading] = useState(true)

  return (
    <Show when={!loading} fallback={<div>Loading...</div>}>
      <User user={user} />
    </Show>
  )
}

总结

两种截然不同的 TypeScript 使用态度,会带来完全不一样的使用体验。在我的感受中,保持类型的灵活性,会导致类型体操的产生。而限制类型的灵活性,我们在项目中的 TS 类型会简单很多,因此总体来说,我目前会更偏向与使用第二种思路,然后在少部分场景使用第一种思路。

针对于接口请求的场景,在类型处理上,React 19 提供了更成熟更简单的解决方案,这也是我喜欢 React 19 的原因之一。我们把数据存储在 promise 中,然后从 promise 中读取数据,读取不到就报错,由 Suspense 捕获,读取到了就是完整的类型。

针对这个争议问题,在和群里的大佬们沟通过程中,我获益良多。学到了好几种关于该问题的解决方案,每个团队针对这种问题都有自己非常棒的想法,每个团队的判断标准不同,因此使用的方式也会有所不同。本文跟大家分享的是我长期以来的观念,希望对大家有所启发。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值