从项目近期的两个 ts 类型报错,看背后的原因

背景

现象1

使用 React.FC 定义的组件,在 props 中使用 children 的地方有报错。

报错描述是说组件的 props 类型上,没有声明 children。

现象2

将 i18next 翻译的文案,传入组件的 props 会报错。

报错描述是说,组件接收的是 ReactNode,但是传入的类型不匹配。

原因

这两个报错看起来并不相关,但背后的原因是有关联的。先说一下两个报错的根因:

  1. 现象1 的原因是:React.FC ** 移除了 props 里隐式提供的 children 类型**。因此之前没在 props 中声明 children 的组件,如果有使用 children,现在要报错了。

  2. 现象2 的原因是:ReactNode** 类型中移除了 ****{}**,而 i18next.t 翻译后的结果TFunctionResult里带有 object 类型,因此将其传给 ReactNode 类型的字段时报错了。

他们都是因为升级了 React 18 后,React 类型更新造成的。

那么为什么升级之后会报错呢?

让我们先从一个 create-react-app MR 说起。
create-react-app 是一个快速生成 React 项目的脚手架工具。

一个 create-react-app 的 MR

改动内容

该 MR 创建于 2020 年,改动很简单:

移除了在该脚手架创建 React + TypeScript 项目时,模板代码里 React.FC 的使用。

为什么要移除 React.FC 呢?一起来看看 MR 里的描述。

作者**不推荐使用 React.FC 定义函数组件,**认为 React.FC 有很多弊端甚至没有利。很多初学者在 TypeScript + React 的项目里使用 React.FC ,很可能是因为他们参考了 create-react-app 提供的模板代码来书写,会认为这是一种最佳实践。

后面作者列举了一些原因:

React.FC 的缺点

隐式地提供了 children 类型

使用 React.FC 定义组件会导致它隐式地接收 children,即使这些组件并不需要。

TypeScript 复制代码
const App: React.FC = () => { /*... */ };
const Example = () => {
  <App><div>Unwanted children</div></App>
}

虽然不会导致运行时错误,但它确实传递了多余的 prop 给组件,并且无法被 TS 的类型检查捕获到。

但如果组件的 children 只接收 string 类型而外面传了 number 的话,可能就会导致运行时错误了。

不能与范型一起工作

比如定义一个通用组件,组件的 props 里需要接收一个范型 T:

TypeScript 复制代码
type GenericComponentProps<T> = {
  prop: T
  callback: (t: T) => void
}

const GenericComponent = <T>(props: GenericComponentProps<T>) => {/*...*/}

但是使用 React.FC 没办法定义这样的组件。

TypeScript 复制代码
const GenericComponent: React.FC</* ??? */> = <T>(props: GenericComponentProps<T>) => {/*...*/}

不能很好地与命名空间组件一起工作

将一个组件定义为命名空间,并且将它的相关组件包含在其中,是一个比较常用的模式,比如:

TypeScript 复制代码
<Select>
  <Select.Item />
</Select>

但是使用 React.FC 来定义这样的组件会比较尴尬:

TypeScript 复制代码
const Select: React.FC<SelectProps> & { Item: React.FC<ItemProps> } = (props) => {/* ... */ }
Select.Item = (props) => { /*...*/ }

作为命名空间的 Select,除了定义自身的 SelectProps,还需要定义所有的关联组件。

但是如果不使用 React.FC,定义起来就很简单。

TypeScript 复制代码
const Select = (props: SelectProps) => {/* ... */}
Select.Item = (props: ItemProps) => { /*...*/ }

不能与 defaultProps 一起工作

这个 case 有一定的争议,因为对于 defaultProps,使用 ES6 的默认参数来给 props 加上默认值会更好。我们还是来看看:

TypeScript 复制代码
type ComponentProps = { name: string; }
const Component = ({ name }: ComponentProps) => (
 /* 类型是安全的,因为 name 是必须的 */
 <div>{name.toUpperCase()}</div>
);
Component.defaultProps = { name: "John" };


/* 类型是安全的,因为 Component 定义了 defaultProps */
const Example = () => (<Component />)

这样能编译成功。

但是如果 Component 使用 React.FC 定义,外部使用 Component 的 Example 组件就会有类型报错提示:需要传入必须的参数。

可能的解法是两种:

  1. 外部传入必须的参数 。

  2. 将 Component 的 name prop 定义为可选 name?: string。但是这样一来,组件内部使用的 name.toUpperCase() 就会有类型报错。

没有办法构造出组件期望的情况:外部可选,内部必须

React.FC 的优点

提供了显式地返回类型

React.FC 唯一的好处是指定了返回类型,可以捕捉到下面的错误:

TypeScript 复制代码
const Component = () => {
  return undefined; // 组件不允许返回 undefined,应该使用 null
}

当使用该组件时,就会得到报错提示:

TypeScript 复制代码
const Example = () => <Component />; // 这里会报错,因为组件返回了错误的内容

但即使不使用 React.FC,手动写返回类型,代码也不会比使用 React.FC 多很多:

TypeScript 复制代码
const Component1 = (props: ComponentProps): JSX.Element => { /*...*/ }
const Component2: FC<ComponentProps> = (props) => { /*...*/ }

因此使用 React.FC 弊大于利,可以说,从该 MR 诞生的那一刻起,业界就已经不推荐使用 React.FC 来定义组件

React 在升级 18 时出手,对类型做了改动

移除了 React.FC 的隐式 children

因此,今年 React 18 在升级时,对类型也做了一定调整,移除了 React.FC 中提供的隐式 children。

  • 确保了React.FC 和普通函数声明之间的行为一致。

  • 能捕获多余的 children prop。

后果就是,"Many packages types crashed because of this change",项目也中枪了。

这就是类型报错现象1 的诞生的背景。

代码调整

在这个类型改动之后,React.FC 已经没有明显的优势了,现在应该怎么声明组件呢?

如何声明函数组件

因为函数组件,本质上就是函数,所以和普通函数一样声明就行,如果需要定义返回值,使用 JSX.Element 或者 React.ReactNode。

TypeScript 复制代码
``const Component = (props: ComponentProps): JSX.Element => { /*...*/ }
``const Component = (props: ComponentProps): React.ReactNode => { /*...*/ }

如果组件需要 children,那么在 props 中显式声明。

如何声明 children

  1. 使用 PropsWithChildren

types/react 中有导出包含 children 的 props 类型 PropsWithChildren:

TypeScript 复制代码
type PropsWithChildren<P = unknown> = P & { children?: ReactNode | undefined };

其中 children 使用 ReactNode 类型,可以保证适当类型检查的同时提供最大的灵活性。

TypeScript 复制代码
type ComponentProps = { name: string; }

const Component = (props: PropsWithChildren<ComponentProps>) => { /* ... */ }
  1. 不使用 PropsWithChildren
TypeScript 复制代码
type ComponentProps = { name: string; children: ReactNode; }

const Component = (props: ComponentProps) => { /* ... */ }

聊完了 React.FC,大家可能有疑问:既然 2020 年就提出了这个观点,为什么 React 要到今年发布 18 的时候才调整呢?

在这个 issue 中,Dan 解释了原因,总结一下,就是:

虽然想在 17 时就解决掉这个事,但是因为 17 是一个升级底层渲染架构的过渡版本,应该尽量保证升级平滑,没有破坏性更新。任何的破坏性更新,都放到 18。

这就现象 1 的前因后果,再来看看造成现象 2 的类型改动。

移除了 ReactNode 的 {} 类型

改动原因

这次类型调整除了改动 React.FC,还移除了 ReactNode 的 {} 类型。

为什么呢,因为如果 ReactNode 包含 {} 时,会导致一种场景下的运行时错误无法被 TS 检查到:

TypeScript 复制代码
const Item = ({ children }: { children: ReactNode }) => {
  return <li>{children}</li>;
}

const App = () => {
  return (
    <ul>
      // 给 children 传递对象,会导致运行时报错
      <Item>{{}}</Item>
    </ul>
  );
}

为什么无法报错呢?

因为在 FC 提供隐式 children 的时候,children 的类型就是 ReactNode,并且{} 也包含在 ReactNode 类型中。

所以这里类型不会报错,但实际上会造成运行时错误。

必须与隐式 children 一起移除的原因

其实官方早就想移除掉这个 {} 类型了,见此MR

因为 children 早在几年前的 React 0.14 版本就不再支持 object 类型了。

但是由于 React.FC 提供了隐式 children ,移除 {} 了的话,如果用户重新定义 children 为对象类型,就会出现类型报错。比如:

TypeScript 复制代码
// children 是一个渲染函数
interface InputProps {
  children: () => ReactNode;
}

const Input: FC<InputProps> = ({
  children,
}) => {
  return children();
};

<Input>{() => <input type="search" />}</Input>;
//     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ "'...' is not assignable to '...'

在这里的 FC,还是有提供隐式 children 的,但是组件的 props 重新定义了 children 类型,现在 children 的类型会被收窄为:children: () => ReactNode & ReactNode,两个类型的交集。

如果移除 ReactNode 的 {},那么这两个类型永远不会有交集,上面的代码就会有类型错误。

所以要移除 {} 就必须移除隐式 children,两者被绑定了。

现象2 的报错和移除{} 有什么关系

了解了这个背景,再来看看现象 2 的报错原因,就很容易理解了。

因为 i18next.t 的返回值里包含对象,而现在 ReactNode 不支持对象,传给组件 prop 类型为 ReactNode 的参数,就报错了。

i18next 在今年 10 月移除了 object 类型,该次更新包含在了 v22 版本中。

代码调整

现在的 i18next 版本在 v17,更新到 v22 有点太冒险了。目前简单处理了一下,就是在代码里重新声明 i18next.t 的返回值类型,移除其中的 object。

总结

导致代码有很多类型报错的根本原因是 React 18 升级后破坏性地更新了一波类型:

  • 移除了 React.FC 隐式提供的 children 类型

  • 移除了 ReactNode 包含的 {} 类型

今天我们了解了一下这些修改背后的原因,对今后的开发会给我们带来这些变化:

  • 推荐像声明普通函数一样的去声明函数组件

  • 如果有 children 需要显示定义在 props 中

  • 如果需要定义返回值,使用 JSX.Element 或者 React.ReactNode

加餐1:JSX.Element vs React.ReactNode vs React.ReactElement

这三种类型常常会让 React 开发者感到困惑。它们好像是同一个东西,只是名字不同而已。

但这并不完全正确,它们有些什么区别呢?

ReactElement

我们知道,React 代码里写的 JSX 最终会被编译成 React.createElement (React 17 以后会被编译成jsx)。

ReactElement 就是该函数调用后返回的结果,它是一个具有 type 和 props 的对象。

JavaScript 复制代码
type Key = string | number

 interface ReactElement<P = any, T extends string | JSXElementConstructor<any> = string | JSXElementConstructor<any>> {
    type: T;
    props: P;
    key: Key | null;
}

JSX.Element

JSX.Element 基本也就是 ReactElement,不过它的 type 和 props 为 any 类型,类型更通用,更宽松。

由于各种库可以实现自己的 JSX,所以 JSX 是一个 namespace,由库来设置具体类型,React 对其的设置如下:

TypeScript 复制代码
declare global {
  namespace JSX {
    interface Element extends React.ReactElement<any, any> { }
  }
}

在 preact 中,JSX.Element 就有 preact 另外的类型定义。

ReactNode

而 ReactNode 是另一个东西,它不是 createElement 或 jsx 的返回值。

React 节点是虚拟 DOM 的表示方式,ReactNode 是 class 组件 render 函数和函数组件的返回值,因此它是组件所有可能返回值的集合。它除了可以是 ReactElement,还可以是:

  • string

  • number

  • ReactFragment

  • ReactNode 数组

  • boolean

  • null

  • undefined

TypeScript 复制代码
interface ReactNodeArray extends ReadonlyArray<ReactNode> {}
type ReactFragment = Iterable<ReactNode>;

type ReactNode = 
  | ReactElement 
  | string 
  | number 
  | ReactFragment 
  | ReactPortal 
  | boolean 
  | null 
  | undefined;

例子

TypeScript 复制代码
const Component = () => 
  return (
    <div> // 这里的是 ReactElement = JSX.Element
      <Custom> // 这里的是 ReactElement = JSX.Element
        Hello world! // 这里的是 ReactNode
      </Custom>
    </div>
  )
}

const Example = Component(); // 这里是 ReactNode

加餐2:Object vs object vs {}

object 是一个对象类型,不包括 string,number 等基础类型。

Object 表示除 undefined 和 null 以外所有类型。

{} 与 Object 一样,表示除 undefined 和 null 以外所有类型。

相关推荐
RAY_CHEN.几秒前
vue3 pinia 中actions修改状态不生效
vue.js·typescript·npm
酷酷的威朗普几秒前
医院绩效考核系统
javascript·css·vue.js·typescript·node.js·echarts·html5
小张不爱写代码1 分钟前
CocosCreator 音效管理器
typescript
小刺猬_9851 分钟前
(超详细)数组方法 ——— splice( )
前端·javascript·typescript
契机再现2 分钟前
babel与AST
javascript·webpack·typescript
渊兮兮3 分钟前
Vue3 + TypeScript +动画,实现动态登陆页面
前端·javascript·css·typescript·动画
鑫宝Code3 分钟前
【TS】TypeScript中的接口(Interface):对象类型的强大工具
前端·javascript·typescript
和你一起去月球4 分钟前
TypeScript - 函数(下)
javascript·git·typescript
疯狂的沙粒1 小时前
如何在 React 项目中应用 TypeScript?应该注意那些点?结合实际项目示例及代码进行讲解!
react.js·typescript
J总裁的小芒果1 小时前
Vue3 el-table 默认选中 传入的数组
前端·javascript·elementui·typescript