为什么 React 18 去掉隐式声明的 children 属性

React 18 推出将近一年有余,除了引人注目的 fiber 架构与并发式渲染,官方文档里还提到了一些其他的新特性与新 Hooks,但是这些都不是今天主角,今天本文的主题是想探讨一下关于 React 18 中官方选择去掉 React.FC 中默认声明的子组件类型的原因。

背景

​ 在 React 中,声明函数组件时会使用到 React.FunctionComponent (可缩写为 React.FC)来声明函数类型:

const ReactComp: React.FC = () => { /*... */ };

​ 在 React 17 中,一般这样声明一个函数组件:

// React 17
const Title: React.FC<{ title: string }> = ({
  children,
  title,
}) => <div title={title}>{children}</div>;

​ 你可能会注意到,上述的 children 并没有预先声明,这可能会导致编译器报错,但是实际上在 React 17 中,上述代码可以成功通过 TypeScript 编译器的校验,这是因为 React.FC 隐式声明了 children 这个 Attribute,即默认每个传入的 ReactDOM 都默认含有 children:

interface Props {
    children?: React.ReactNode;
}

隐式声明 Children 属性导致的问题

​ 但是这种声明方式会导致一些问题:

1. 额外传参将会报错行为不一致

  • 如果向一个组件里面传入参数但是没有声明,会导致报错。
import * as React from 'react';

interface InputProps {
    type?: string;
}

const Input = ({ type }: InputProps) => {
    return <input type={type} />;
};

<Input type="search" inputMode="numeric" />;
//                   ^^^^^^^^^  报错的额外参数
  • 这也可能导致捕获传入的参数的拼写错误或是对传参作用的推断错误。
<Input typ="search" />;
//     ^^^ "Type '{ typ: string; }' is not assignable to type
//          'IntrinsicAttributes & InputProps'.
//             Property 'typ' does not exist on type
//             'IntrinsicAttributes & InputProps'. Did you mean 'type'?"

​ 同理,children 也同样是一个额外的传参,此时 TypeScript 编译器也应当与上述例子有相同的错误推断。但是由于已经 children 属性被 React.FC 隐式声明了,所以不会产生类似的报错。但是如果不使用 React.FC 声明函数组件就出现类似的错误。

​ 上述问题如果在开发过程中注意到的话,不会对实际的项目运行产生影响,但是此类报错这并不应该是由开发者来处理的,因为这种现象与软件开发中的 一致性原则最小惊讶原则 (POLA) 相违背。参考上文的例子来说,对于同样为额外传参属性的 inputModechildren ,TypeScript 编译器不会输出同样的结果,甚至当是否使用 React.FC 声明函数组件时,TypeScript 编译器也会有不同的输出结果。如果框架的使用者(即开发者)不提前知道 React.FC 隐式声明了传参的 children 属性,会额外增加开发者的 ‘astonishment

2. Children 类型的范围问题

​ 如前文所示,children 的类型被声明为了 ReactNode 类型,ReactNode 在 React 17 *(注1) 的类型定义如下:

type ReactText = string | number;
type ReactChild = ReactElement | ReactText;

interface ReactNodeArray extends Array<ReactNode> {}
type ReactFragment = {} | ReactNodeArray;

type ReactNode = ReactChild | ReactFragment | ReactPortal | boolean | null | undefined;

​ 基本上大多数类型都可以被默认声明的 ReactNode 的类型所囊括,但是较大的范围并不适用于所有场景。如果想要增加类型提示精度,细化地指明 children 属性更为明智:

interface ContainerProps {
  children: React.ReactElement
}

const ElementContainer: React.FC<ContainerProps> = ({children}) => {
  return <div>{children}</div>
}

<ElementContainer>foo</ElementContainer>
//                ^^^ "'ElementContainer' components don't accept text as child elements. Text in JSX has the type 'string', but the expected type of 'children' is 'ReactElement<any, string | JSXElementConstructor<any>> & ReactNode'"

​ 上述例子中,细化了 children 的类型为 React.ReactElement ,因此当输入的 children 与预期不符时,TypeScript 编译器会更加细致的指出错误。附 ReactElement 的类型定义:

 type Key = string | number

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

3. Children 交叉类型问题

​ 如果对 children 声明额外类型约束,在加上 React.FC 已经默认添加 ReactNode 类型声明,则等同于:

type Props = {
    children?: React.ReactNode & CustomizedType;
}

​ 如果 CustomizedType 不是 ReactNode 的子类型,TypeScript 编译器也会报错。因为传入的 children 不能同时满足两个类型的约束。

interface RenderProps {
    type: string;
}

interface InputProps {
    children: (props: RenderProps) => React.ReactElement;
}

const Input = ({
    children,
}: InputProps & { children?: React.ReactNode }) => {
    return children({ type: 'search' });
};

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

尾声

1. 替代方案

​ 尽管 React 18 已经去掉了隐式声明的 children 属性,但是依旧可以在类似 types.d.ts 的文件中开发时自定义一个类型,满足部分场景的需求:

export type WithChildren<T> = T & { children?: React.ReactNode }
export type FCC<T = {}> = React.FC<WithChildren<T>>

​ 当然,在使用函数组件时也应当仔细思考哪些类型的组件应当含有 children,哪些不应该。

​ 通过上述的一些问题,可以看出 React 官方在设计时做了许多细致的考虑。对一些逻辑进行封装时,不光要从性能与使用场景上考虑,也要考虑到对使用者的一致性的体验。这也是本人在开发中经常容易忽视的一点。


附录

注1:在 React 18 中
  • ReactNode 的定义更改为:
type ReactNode =
        | ReactElement
        | string
        | number
        | Iterable<ReactNode>
        | ReactPortal
        | boolean
        | null
        | undefined
        | DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_REACT_NODES[keyof DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_REACT_NODES];
  • ReactFragment 去掉了空对象类型,并被标注为废弃
/**
  * @deprecated - This type is not relevant when using React. Inline the type instead to make the intent clear.
*/
type ReactFragment = Iterable<ReactNode>;
参考链接:

Remove React.FC from Typescript template #8177

React 18 types #56210

React v18.0

相关阅读:

Consistency

Principle of least astonishment

滚动至顶部