在上一章,我们知道了把组件当做属性传递,可以很好的提升应用的性能。但是,这个模式不仅仅能提升组件的性能,这个模式还有很多不为人知的优点。这种模式所解决的最大用处,是提升组件的灵活性和配置方面的能力。
让我们一起研究React的工作机制。这次,我们要构建一个"有图标的按钮"组件。构建组件的过程可能有点复杂,但是在这个过程中,你将会学到:
- 把组件当做元素传递模式将如何大幅提升组件的可配置性。
- 组件的条件渲染将如何影响组件性能。
- 何时把组件当属性传递,才能正确渲染
- 如何使用cloneElement函数为作为属性传递的组件设置默认属性,以及这样做存在哪些弊端。
问题
想象一下,你要实现一个按钮组件。而其中一个要求是,这个按钮可以在加载资源时展示加载图标。
这是很容易实现的。我们只要实现一个button控件,再传递一个isLoading属性来控制loading图标的展示与隐藏即可。
js
const Button = ({ isLoading }) => {
return (
<button>Submit {isLoading ? <Loading /> : null}</button>
);
};
第二天,这个按钮需要支持展示样式库里面所有的图标,而不仅仅是loading图标。这是不难实现的,为Button组件拓展一个iconName属性即可。没过几天,有需求希望图标的颜色也可以自定义,所以又拓展了一个iconColor属性。后来,又实现了iconSize属性来控制图标的大小,等等。
最终,有一半的属性是用来控制图标的,没有人知道这些属性如何发挥作用,而且每个属性的变化都有可能导致不可预料的后果。
js
const Button = ({
isLoading,
iconLeftName,
iconLeftColor,
iconLeftSize,
isIconLeftAvatar,
...
}) => {
// no one knows what's happening here and how all those props
work
return ...
}
这个场景看起来很熟悉吧?
元素作为属性
好在我们可以用一个简单的方法高效的解决这个问题。我们只要把icon元素当做组件传给Button组件即可:
js
const Button = ({ icon }) => {
return <button>Submit {icon}</button>;
};
剩下的配置工作,让组件传递方按照他们的意愿传递即可:
js
// default Loading icon
<Button icon={<Loading />} />
// red Error icon
<Button icon={<Error color="red" />} />
// yellow large Warning icon
<Button icon={<Warning color="yellow" size="large" />} />
// avatar instead of icon
<Button icon={<Avatar />} />
代码示例: advanced-react.com/examples/03...
当然,对一个按钮做这样的事情(上文提及的相关操作,比如通过某种方式设置属性之类的操作,具体需结合前文确定)有时候是有争议的。这在很大程度上取决于你的设计要求有多严格,以及在实现产品功能时它能允许多大程度的偏差。
想象一下,你需要实现一个由header、content和有一些按钮的footer组成的兑换框。
除非你的设计师要求非常严格且很有话语权,否则很可能你需要在不同的对话框中对那些按钮进行不同的配置:一个、两个、三个按钮的情况,其中一个按钮是链接,一个按钮是 "主要按钮",当然它们上面的文字不同,图标不同,提示信息也不同等等。想象一下要通过配置属性来传递所有这些内容吧!
但如果把元素当做属性来传递,事情就简单很多了,我们为对话框组件拓展一个footer组件即可:
js
const ModalDialog = ({ content, footer }) => {
return (
<div className="modal-dialog">
<div className="content">{content}</div>
<div className="footer">{footer}</div>
</div>
);
};
之后,我们按需传递即可:
js
// just one button in footer
<ModalDialog content={<SomeFormHere />} footer={<SubmitButton />}
/>
// two buttons
<ModalDialog
content={<SomeFormHere />}
footer={<><SubmitButton /><CancelButton /></>}
/>
代码示例: advanced-react.com/examples/03...
又或者,你需要实现一个三行布局的组件。
js
<ThreeColumnsLayout
leftColumn={<Something />}
middleColumn={<OtherThing />}
rightColumn={<SomethingElse />}
/>
代码示例: advanced-react.com/examples/03...
就本质而言,把一个元素当做属性传给一个组件是告诉消费者:给我你想渲染的内容,我不关心这是什么,我只负责把它渲染在正确的地方。剩下的工作,都由属性传递方决定。
当然,上一章讲到的"子组件"作为属性的模式也可以处理这个问题。如果我们想把一些内容当做该组件的"主要"展示内容来传递,例如对话框的"content"内容,或者三列组件的中间列,我们可以使用jsx的嵌套语法。
js
// before
<ModalDialog
content={<SomeFormHere />}
footer={<SubmitButton />}
/>
// after
<ModalDialog
footer={<SubmitButton />}
>
<SomeFormHere />
</ModalDialog
在ModalDialog组件要做的事情,就是把content字段命名为children:
js
const ModalDialog = ({ children, footer }) => {
return (
<div className="dialog">
<div className="content">{children}</div>
<div className="footer">{footer}</div>
</div>
);
};
记住,在这段代码中,children字段不过是基于jsx嵌套语法的语法糖罢了。
条件渲染与性能
在采用这个模式时,有时遇到最大的挑战就是性能问题。这颇具讽刺意味,因为在上一章中,我们还讨论了如何利用它来提升性能。那么,这到底是怎么回事呢?
想象一个场景,有一个组件接收元素作为属性,但是这个属性是条件式渲染的。就像我们的ModalDialog组件,它只会在isDialogOpen为true时被渲染。
js
const App = () => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
// when is this one going to be rendered?
const footer = <Footer />;
return isDialogOpen ? (
<ModalDialog footer={footer} />
) : null;
};
这段代码中困扰开发者的问题是:我们在一个未渲染(也许永远不会被渲染)的对话框中,声明了一个Footer。这是否意味着,Footer会永远被重新渲染,即使对话框并没有展示在屏幕上。这会给应用的性能带来什么影响。它会拖慢应用的运行速度吗?
幸运的是,我们不必为此感到担忧。记住,正如在第二章提及的,footer = <Footer />
所产生的仅仅是一个元素。就React源代码的视角而言,这个元素不过是静静的位于内存中的没有做任何事的对象罢了。而且,生成一个对象的开销是很低的(至少比渲染组件低)。
这个Footer只有在作为一个组件的返回对象时,才会被渲染。至于
是不是在App中被调用的,其实并不重要。真正接收Footer和返回Footer的是ModalDialog组件。
js
const ModalDialog = ({ children, footer }) => {
return (
<div className="dialog">
<div className="content">{children}</div>
{/* Whatever is coming from footer prop is going to be
rendered only when this entire component renders */}
{/* not sooner */}
<div className="footer">{footer}</div>
</div>
);
};
Reac的router接收element属性时,背后也是这个原理。
js
const App = () => {
return (
<>
<Route path="/some/path" element={<Page />} />
<Route path="/other/path" element={<OtherPage />} />
...
</>
);
};
这段代码中并没有任何条件判断,看起来App组件拥有并同时渲染了和。然而,事实上并非如此。 和 所产生的是描述对应页面的描述对象。对应的渲染操作,只有在对应的路由与URL匹配,且Route组件返回了element时,才会发生。
为来自属性的元素配置默认值
任何图标都没有额外的属性了,现在只有由按钮控制的默认属性!然后,如果有人确实需要覆盖默认值,他们仍然可以这样做:像往常一样传递该属性。
js
// override the default black color with red icons
<Button
appearance="secondary"
icon={<Loading color="red" />}
/>
事实上,组件的使用者很有可能不知道Button组件有默认值逻辑。对于他们来说,icon组件的工作原理简直就是魔法。
为什么我不能滥用属性的默认值
说到神奇之处:设置默认值这件事看似神奇地起作用,但也可能存在很大的弊端。这里最大的问题在于,太容易出错并永久性地覆盖属性了。例如,如果我不使用实际的属性去覆盖默认属性,而是直接进行赋值的话:
js
const Button = ({ appearance, size, icon }) => {
// create default props
const defaultIconProps = {
size: size === 'large' ? 'large' : 'medium',
color: appearance === 'primary' ? 'white' : 'black',
};
// clone the icon and assign the default props to it - don't do that!
// it will override all the props that are passed to the icon from the outside!
const clonedIcon = React.cloneElement(
icon,
defaultIconProps,
);
return <button>Submit {clonedIcon}</button>;
};
这样做会摧毁icon属性。无论使用者为icon配置什么尺寸和颜色,都无法渲染出预期效果:
js
// color "red" won't work here - I never passed those props to the
cloned icon
<Button appearance="secondary" icon={<Loading color="red" />} />
// but if I just render this icon outside the button, it will work
<Loading color="red" />
代码示例: advanced-react.com/examples/03...
祝任何试图弄明白为何在按钮外部设置图标的颜色能完美运行,但当图标作为这个属性传递时却行不通的人好运。
所以要格外小心这种模式,确保你总是用实际的属性去覆盖默认属性。如果你对此感到不放心 ------ 别担心。在 React 中,有无数种方法能达成完全相同的结果。有一种模式在这种情况下会非常有用:渲染属性(render props)。如果你需要根据按钮的状态来计算图标的属性,或者只是单纯地将该状态回传给图标,它也会非常有帮助。下一章将全是关于这种模式的内容。
知识概要
在我们前往渲染属性模式前,我们需要记住:
c
js
const Button = ({ icon }) => {
return <button>Submit {icon}</button>;
};
// large red Error icon
<Button icon={<Error color="red" size="large" />} />;
- 如果一组件的元素属性是依赖组件内的状态条件式渲染的,那么即使这个元素的生成独立于这个条件,它们也只会在渲染条件成立时进行渲染。
js
const App = () => {
// footer will be rendered only when the dialog itself renders
// after isDialogOpen is set to "true"
const footer = <Footer />;
return isDialogOpen ? (
<ModalDialog footer={footer} />
) : null;
};
- 如果我们要为元素属性配置默认属性,我们可以使用cloneElement来实现它。
- 但是这个方法是很容易产生问题的,所以这个方法只适用于简单的情况。