本文首发微信公众号:前端徐徐。
前言
在 React 中,一件事往往有上百万种不同的实现方式。如果需要将一个组件作为属性传递给另一个组件,我们该怎么做呢?如果我们在一些流行的开源库中寻找答案,会发现:
- 我们可以像 Material UI 库在按钮的
startIcon
属性中那样,把它们作为元素传递。 - 我们可以像
react-select
库对其components
属性那样,把它们作为组件本身传递。 - 我们可以像 Material UI Data Grid 组件对其
renderCell
属性那样,把它们作为函数传递。
一点都不混乱 😅。
那么哪种方式是最好的,哪种方式应该避免呢?哪种方式应该被列入“React 最佳实践”列表中,为什么呢?让我们一起来探讨一下吧!
或者,如果你喜欢剧透,可以直接滚动到文章的总结部分。那里有这些问题的明确答案 😉
为什么我们需要将组件作为属性传递
在开始编写代码之前,我们先来理解一下为什么我们需要将组件作为属性传递。简短的回答是:为了灵活性以及简化组件之间的数据共享。
假设我们在实现一个带图标的按钮。我们当然可以这样实现:
const Button = ({ children }: { children: ReactNode }) => { return ( <button> <SomeIcon size="small" color="red" /> {children} </button> ); };
但是,如果我们需要让用户更换图标呢?我们可以为此引入一个 iconName
属性:
type Icons = 'cross' | 'warning' | ... // 所有支持的图标 const getIconFromName = (iconName: Icons) => { switch (iconName) { case 'cross': return <CrossIcon size="small" color="red" />; ... // 所有其他支持的图标 } } const Button = ({ children, iconName }: { children: ReactNode, iconName: Icons }) => { const icon = getIconFromName(name); return <button> {icon} {children} </button> }
如果需要让用户更改图标的外观呢?例如更改其大小和颜色?我们还需要为此引入一些属性:
type Icons = 'cross' | 'warning' | ... // 所有支持的图标 type IconProps = { size: 'small' | 'medium' | 'large', color: string }; const getIconFromName = (iconName: Icons, iconProps: IconProps) => { switch (iconName) { case 'cross': return <CrossIcon {...iconProps} />; ... // 所有其他支持的图标 } } const Button = ({ children, iconName, iconProps }: { children: ReactNode, iconName: Icons, iconProps: IconProps }) => { const icon = getIconFromName(name, iconProps); return <button> {icon} {children} </button> }
如果用户希望在按钮发生变化时更改图标,例如当按钮被悬停时更改图标的颜色。为了实现这个功能,我们需要暴露 onHover
回调,在每个父组件中引入状态管理,在按钮被悬停时设置状态等等。
这不仅是一个非常有限和复杂的 API。我们还强制我们的 Button 组件知道它可以渲染的每一个图标,这意味着这个 Button 的捆绑 JS 不仅包括它自己的代码,还包括列表上的每一个图标。这将是一个非常重的按钮 🙂
这时候将组件作为属性传递的方式就派上用场了。我们无需将图标的详细描述(如名称和属性)传递给 Button,Button 可以直接说:“给我一个图标,我不在乎是哪一个,由你选择,我会在合适的位置渲染它”。
让我们看看可以通过三种模式实现这一点的方法:
- 作为元素传递
- 作为组件传递
- 作为函数传递
构建带图标的按钮
准确地说,我们构建三个按钮,分别使用三种不同的 API 来传递图标,然后进行比较。希望最后能明显看出哪种方式更好。
我们将使用 material UI 组件库中的一个图标作为例子。让我们从基础开始,先构建 API。
第一个:作为 React 元素传递图标
我们只需要将一个元素传递给按钮的 icon
属性,然后像其他元素一样渲染它。
type ButtonProps = { children: ReactNode; icon: ReactElement<IconProps>; }; export const ButtonWithIconElement = ({ children, icon }: ButtonProps) => { return ( <button> {icon} {children} </button> ); };
然后可以像这样使用它:
<ButtonWithIconElement icon={<AccessAlarmIconGoogle />}>button here</ButtonWithIconElement>
第二个:作为组件传递图标
我们需要创建一个以大写字母开头的属性来表示它是一个组件,然后像其他组件一样从属性中渲染该组件。
type ButtonProps = { children: ReactNode; Icon: ComponentType<IconProps>; }; export const ButtonWithIconComponent = ({ children, Icon }: ButtonProps) => { return ( <button> <Icon /> {children} </button> ); };
然后可以像这样使用它:
import AccessAlarmIconGoogle from '@mui/icons-material/AccessAlarm'; <ButtonWithIconComponent Icon={AccessAlarmIconGoogle}>button here</ButtonWithIconComponent>;
第三个:作为函数传递图标
我们需要创建一个以 render
开头的属性来表示它是一个渲染函数,即一个返回元素的函数,调用该函数并将结果添加到组件的渲染函数中。
type ButtonProps = { children: ReactNode; renderIcon: () => ReactElement<IconProps>; }; export const ButtonWithIconRenderFunc = ({ children, renderIcon }: ButtonProps) => { const icon = renderIcon(); return ( <button> {icon} {children} </button> ); };
然后可以像这样使用它:
<ButtonWithIconRenderFunc renderIcon={() => <AccessAlarmIconGoogle />}>button here</ButtonWithIconRenderFunc>
这很容易!现在我们的按钮可以在那个特殊的图标插槽中渲染任何图标,而不需要知道是什么。请参阅代码示例中的实际工作示例。
调整图标的大小和颜色
首先看看我们是否可以根据需要调整图标,而不会影响按钮。毕竟,这些模式的主要承诺不就是为了这个吗?
第一个:作为 React 元素传递图标
非常简单:我们只需要将一些属性传递给图标。我们使用 material UI 图标,它们提供 fontSize
和 color
属性。
<ButtonWithIconElement icon={<AccessAlarmIconGoogle fontSize="small" color="warning" />}>button here</ButtonWithIconElement>
第二个:作为组件传递图标
同样简单:我们需要将图标提取为一个组件,并在返回元素中传递属性。
const AccessAlarmIcon = () => <AccessAlarmIconGoogle fontSize="small" color="error" />; const Page = () => { return <ButtonWithIconComponent Icon={AccessAlarmIcon}>button here</ButtonWithIconComponent>; };
重要提示:AccessAlarmIcon
组件应始终定义在 Page
组件之外,否则每次 Page
重新渲染时都会重新创建该组件,这对性能非常不利且容易出现错误。如果你不熟悉这种情况,这篇文章可以帮助你了解如何编写高性能的 React 代码:如何编写高性能的 React 代码:规则、模式、注意事项和禁忌。
第三个:作为函数传递图标
几乎和第一个一样:只需将属性传递给元素。
<ButtonWithIconRenderFunc renderIcon={() => ( <AccessAlarmIconGoogle fontSize="small" color="success" /> )} > button here </ButtonWithIconRenderFunc>
所有三种方式都轻松完成,我们可以无限制地修改图标,而不需要触碰按钮。相比于最初的 iconName
和 iconProps
,这种方式更具灵活性。
按钮默认图标大小
你可能已经注意到,我在所有三个示例中使用了相同的图标大小。当实现一个通用的按钮组件时,更可能会有一些属性来控制按钮的大小。无限制的灵活性是好的,但对于设计系统来说,你会希望有一些预定义的按钮类型。而对于不同大小的按钮,你希望按钮来控制图标的大小,而不是由消费者来决定,以免不小心把小图标放在大按钮中或反之亦然。
现在变得有趣了:是否可以让按钮控制图标的某一方面,同时保持灵活性?
第一个:作为React 元素传递图标
这里我们会遇到麻烦。考虑一下 ButtonWithIconElement
:
type ButtonProps = { children: ReactNode; icon: ReactElement<IconProps>; size: 'small' | 'medium' | 'large'; }; const ButtonWithIconElement = ({ children, icon, size }: ButtonProps) => { return ( <button className={`btn-${size}`}> {icon} {children} </button> ); };
icon
是一个 React 元素。fontSize
属性已经嵌入其中。我们的按钮组件无法覆盖它。可能的解决方案是创建图标的克隆:
import { cloneElement } from 'react'; type ButtonProps = { children: ReactNode; icon: ReactElement<IconProps>; size: 'small' | 'medium' | 'large'; }; const ButtonWithIconElement = ({ children, icon, size }: ButtonProps) => { const newIcon = cloneElement(icon, { fontSize: size }); return ( <button className={`btn-${size}`}> {newIcon} {children} </button> ); };
非常简单有效,但有时会带来不必要的复杂性。此实现允许将 fontSize
覆盖成另一个值:
<ButtonWithIconElement icon={<AccessAlarmIconGoogle fontSize="small" />} size="large">button here</ButtonWithIconElement>
第二个:作为组件传递图标
我们有更多的灵活性:我们可以将属性传递给组件:
type ButtonProps = { children: ReactNode; Icon: ComponentType<IconProps>; size: 'small' | 'medium' | 'large'; }; const ButtonWithIconComponent = ({ children, Icon, size }: ButtonProps) => { return ( <button className={`btn-${size}`}> <Icon fontSize={size} /> {children} </button> ); };
然后在 Page
中渲染:
const Page = () => { return <ButtonWithIconComponent Icon={AccessAlarmIconGoogle} size="large">button here</ButtonWithIconComponent>; };
这也有效:AccessAlarmIconGoogle
获取到 fontSize
属性并根据需要渲染。我们保留了覆盖的能力:按钮不在乎如何设置图标大小。
第三个:作为函数传递图标
这同样简单:只需将属性传递给 renderIcon
函数。
type ButtonProps = { children: ReactNode; renderIcon: (props: IconProps) => ReactElement; size: 'small' | 'medium' | 'large'; }; const ButtonWithIconRenderFunc = ({ children, renderIcon, size }: ButtonProps) => { const icon = renderIcon({ fontSize: size }); return ( <button className={`btn-${size}`}> {icon} {children} </button> ); };
然后渲染:
<ButtonWithIconRenderFunc renderIcon={props => <AccessAlarmIconGoogle {...props} />} size="large">button here</ButtonWithIconRenderFunc>
你选择哪种方式?
这三个模式都有效,展示了我们想要的灵活性。但是,是否有更好的选择呢?
如果是我,我会选择将组件作为函数传递。原因有几个:
- 更好的可控性:我们可以覆盖任何属性,无需担心实现的复杂性。
- 更好的灵活性:允许传递除图标外的任何属性,而不需要担心属性被覆盖。
- 更好的可扩展性:我们可以轻松添加新的属性和方法,而无需修改现有代码。
最后,希望这些模式能够帮助你更好地理解如何在 React 中传递组件作为属性,并帮助你选择适合你的最佳实现方式。