背景
现象1
使用 React.FC 定义的组件,在 props 中使用 children 的地方有报错。
报错描述是说组件的 props 类型上,没有声明 children。
现象2
将 i18next 翻译的文案,传入组件的 props 会报错。
报错描述是说,组件接收的是 ReactNode,但是传入的类型不匹配。
原因
这两个报错看起来并不相关,但背后的原因是有关联的。先说一下两个报错的根因:
-
现象1 的原因是:React.FC ** 移除了 props 里隐式提供的 children 类型**。因此之前没在 props 中声明 children 的组件,如果有使用 children,现在要报错了。
-
现象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 组件就会有类型报错提示:需要传入必须的参数。
可能的解法是两种:
-
外部传入必须的参数 。
-
将 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
- 使用 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>) => { /* ... */ }
- 不使用 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 以外所有类型。