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

1,074 阅读9分钟

背景

现象1

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

image

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

image

现象2

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

image

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

image

原因

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

  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 的使用。

image

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

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

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

React.FC 的缺点

隐式地提供了 children 类型

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

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

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

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

不能与范型一起工作

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

type GenericComponentProps<T> = {
  prop: T
  callback: (t: T) => void
}

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

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

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

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

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

<Select>
  <Select.Item />
</Select>

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

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

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

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

const Select = (props: SelectProps) => {/* ... */}
Select.Item = (props: ItemProps) => { /*...*/ }

不能与 defaultProps 一起工作

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

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 唯一的好处是指定了返回类型,可以捕捉到下面的错误:

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

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

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

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

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。

image

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

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

代码调整

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

如何声明函数组件

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

``const Component = (props: ComponentProps): JSX.Element => { /*...*/ }
``const Component = (props: ComponentProps): React.ReactNode => { /*...*/ }

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

如何声明 children

  1. 使用 PropsWithChildren

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

type PropsWithChildren<P = unknown> = P & { children?: ReactNode | undefined };

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

type ComponentProps = { name: string; }

const Component = (props: PropsWithChildren<ComponentProps>) => { /* ... */ }
  1. 不使用 PropsWithChildren
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 的 {} 类型。

image

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

const Item = ({ children }: { children: ReactNode }) => {
  return <li>{children}</li>;
}

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

为什么无法报错呢?

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

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

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

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

image

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

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

// 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 的参数,就报错了。

image

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 的对象。

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 对其的设置如下:

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

interface ReactNodeArray extends ReadonlyArray<ReactNode> {}
type ReactFragment = Iterable<ReactNode>;

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

例子

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 以外所有类型。

image