化合物组分 - 反应
什么是化合物成分?
复合组件是指一组彼此关联且协同工作的组件。
它们还具有极高的灵活性和可扩展性。
在本教程中,我将重点介绍一个非常简单的卡片组件示例,希望它能解释清楚并展现复合组件模式的简单之处。
我不会着重讲解样式/CSS,所以如果您要跟着教程自己测试代码,则必须添加自己的CSS(内联样式、SASS/SCSS、外部样式表、CSS模块、styled components等等)。例如,在本文后面的代码示例中,我切换了一个CSS类(BEM修饰符),这表明导入了一个外部样式表,其中定义了样式。我的意思是,下面的代码示例本身无法正常工作,需要正确的样式才能使用户界面看起来正确。
更新:本文底部添加了完整的可运行代码,包括一些非常基本的样式。
如果你想了解更多关于复合组件的信息,你可以在互联网上找到大量的教程/视频,以下是我最喜欢的一些,它们让我开始使用复合组件模式:
Kent C. Dodds - React Hooks:复合组件
- 他使用了带有钩子的函数组件,并且很好地解释了复合组件,但是虽然他举了一个很棒的用例示例,但我认为这对初学者来说有点难以理解,因为他将 useCallback 和 useMemo 与自定义钩子和上下文一起使用(我也使用上下文和自定义钩子,但不使用 useCallback 和 useMemo,我认为这样更容易理解复合组件的概念)。
- 这个人很幽默,而且对复合组件的讲解也很到位。他使用的是类组件,这只是创建组件的另一种(或许是比较老旧的方法),而我的教程则侧重于函数组件/钩子,请记住这一点。
示例 - 卡片组件作为复合组件
基础知识
我们先来看一个例子,它最终只是一个接收 children 属性的 div 元素:
function Card({children}){
return (
<div className="Card">
{children}
</div>
);
}
export default Card;
用法如下:
<Card>
// Content goes here
</Card>
目前这只是一个“普通”的组件,没什么特别的。
我们来添加一个标题,比如说一个 h2 标题:
function Card({children}){
...
}
function Heading({children}){
return (
<h2 className="Card__heading">
{children}
</h2>
);
}
export Heading;
export default Card;
或许您之前已经见过这种定义组件的方式(在同一个文件中定义多个组件),或者您只是知道可以这样做。理论上,这几乎就是组合组件的全部内容了。就是这么简单,因为现在您可以这样做:
<Card>
<Heading>My title</Heading>
</Card>
Heading 组件“属于”Card 组件这一点并不那么明显,因为你可以在 Card 组件之外直接使用 Heading 组件:
<Heading>My title</Heading>
<Card>
// Oh no, I want my Heading to only be in here!
</Card>
让我向您展示一种略有不同的组件导出方法:
function Card({children}){
...
}
function Heading({children}){
...
}
Card.Heading = Heading;
export default Card;
注意我是如何将 Heading 组件作为属性添加到 Card 组件中的,所以 Heading 现在成了 Card 对象的一个方法。这是因为你创建的每个组件都会被添加到 React 的虚拟 DOM 中,而虚拟 DOM 本身就是一个对象(一个巨大的对象),所以既然 Card 组件只是虚拟 DOM 对象中的一个属性,为什么不直接将你想要的内容添加到这个 Card 属性中呢?
为了更清楚地说明,下面是它的使用方法:
<Card>
<Card.Heading>My title</Card.Heading>
</Card>
我认为这更清楚地表明了标题“属于”卡片组件,但请记住,它只是一个组件,因此您仍然可以在卡片组件之外使用标题组件:
<Card.Heading>My title</Card.Heading>
<Card>
// Oh no, I want my Heading to only be in here!
</Card>
以上只是复合组件的基础知识,你可以就此止步,告诉自己你知道如何创建复合组件了,但复合组件的功能远不止于此,这使得它们非常强大和实用,尤其是在大型项目或非常复杂的组件中。
我将在这里一一介绍:
使用上下文创建作用域
如果我们真的希望子组件只在 Card 组件内部运行(我称之为作用域),那么显然我们需要做一些额外的工作。这里我们可以利用 Context API(如果您不完全理解 Context 的概念,请不要担心,跟着步骤操作,应该就能明白了。您也可以根据需要阅读更多关于Context API 的内容)。
首先,我们从 React 导入 createContext hook 来创建上下文,并创建一个名为 CardContext 的变量来使用此 hook(您可以随意命名该变量,但我认为 CardContext 是一个很好且具有描述性的名称):
import { createContext } from "react";
var CardContext = createContext();
function Card({children}){
...
}
function Heading({children}){
...
...
我们还需要一个上下文提供程序,但由于我们没有任何要通过上下文共享的状态或值,所以我们只需在提供程序的 value 属性中使用一个空对象作为值:
import { createContext } from "react";
var CardContext = createContext();
function Card({children}){
return (
<CardContext.Provider value={{}}>
<div className="Card">
{children}
</div>
</CardContext.Provider>
);
}
function Heading({children}){
...
...
简单来说,CardContext.Provider 是一个容器,它保存任何值value={// whatever you want},然后所有嵌套的子对象都可以访问该值。
要访问这些值(如果有的话),我们只需在需要访问这些值的子组件中使用 useContext hook 即可:
import { createContext, useContext } from "react";
...
function Heading({children}){
var context = useContext(CardContext);
return (
<h2 className="Card__heading">
{children}
</h2>
);
}
现在,该context变量保存了我们在提供者的 value 属性中定义的任何值value={// whatever you want},在本例中,这只是一个空对象value={{}}。
我们目前所创造的美妙之处在于,如果我们渲染到<Card.Heading>外部<Card>(即提供程序),则context内部变量<Card.Heading>将为真undefined,而如果渲染到内部,则会包含空对象{}。
由于这部分内容是关于作用域,而不是关于子组件通过上下文访问的值,所以让我们利用上面描述的知识来创建这个作用域,以便进行条件检查:
子组件内部的条件检查
...
function Heading({children}){
var context = useContext(CardContext);
if (!context) {
return (
<p className="Card__scopeError>
I want to be inside the Card component!
</p>
)
}
return (
<h2 className="Card__heading">
{children}
</h2>
);
}
如果我们现在尝试在<Card.Heading>外部渲染<Card>,则会渲染一个包含“错误消息”的 p 标签,而不是 h2 标签,这迫使我们只能在内部使用它<Card>。太好了!
虽然如果我们创建很多子组件,就必须把上下文和条件检查复制粘贴到每个子组件里。我不太喜欢这样。虽然这样也能运行,但代码会显得过于冗杂,不够简洁!
将条件检查和上下文与自定义钩子结合起来
return使用自定义钩子,可以将语句内部的所有代码<Card.Heading>简化为一行,这样可以更简洁、更容易地创建新的子组件。
自定义钩子只是一个普通的函数,它的好处是可以访问其他钩子,无论是 React 内置的钩子(如 useState、useEffect、useRef 等),还是其他自定义钩子。
创建自定义钩子函数有一条重要的规则,那就是函数名必须以“use”开头:
function useObjectState(initialValue){
var [state, setState] = useState(initialValue);
return {state, setState};
}
如果你这样做:
function objectState(initialValue){
var [state, setState] = useState(initialValue);
return {state, setState};
}
您将收到以下错误信息:
React Hook "useState" is called in function "objectState" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter react-hooks/rules-of-hooks
好的,那么我们来创建这个自定义钩子(这个钩子直接从 Kent C. Dodds 的代码复制过来的。链接在顶部,或者点击这里):
import { createContext, useContext } from "react";
...
function useCardContext(){
var context = useContext(CardContext);
if (!context) {
throw new Error("Child components of Card cannot be rendered outside the Card component!");
}
return context;
}
function Card({children}){
...
现在最棒的是,每个子组件只需要使用这个自定义钩子,作用域和上下文仍然可以正常工作:
...
function useCardContext(){
...
}
function Heading({children}){
var context = useCardContext();
return (
<h2 className="Card__heading">
{children}
</h2>
);
}
...
就是这样!
嗯,差不多吧,我们还没用到上下文中的任何值,但相信我,它会奏效的。不信?好吧,那我们接下来就试试:
国家管理
假设我们想要在卡片中添加一个简单的按钮,点击该按钮后,可以切换整个卡片的边框颜色,也许还可以切换标题的文本颜色(为什么,因为某些原因!)。
我们该怎么做呢?
我们先来创建按钮组件:
...
function Heading({children}){
var context = useCardContext();
...
}
function Button({children}){
var context = useCardContext();
return (
<button className="Card__button">
{children}
</button>
);
}
Card.Button = Button;
...
并使用它:
<Card>
<Card.Heading>My title</Card.Heading>
<Card.Button>Toggle</Card.Button>
</Card>
按钮需要一些状态处理,但一般来说,当需要在父组件或子组件之间共享状态时,应该在父组件(最外层组件)级别声明状态,<Card>然后通过上下文将该状态共享给其他子组件。由于我们已经创建了上下文,共享就非常简单,所以让我们添加状态和上下文值(提供程序值):
import { createContext, useContext, useState } from "react";
...
function Card({children}){
var [toggled, setToggled] = useState(false);
return (
<CardContext.Provider value={{toggled, setToggled}}>
...
</CardContext.Provider>
);
}
...
我们刚才所做的就是在顶级组件中使用 useState 创建了一个状态(<Card>),并向其提供者的值属性添加了toggled和。setToggled<CardContext.Provider value={{toggled, setToggled}}>
你注意到我是如何将解构数组“更改”成一个带有 ` toggledand` 和 ` setToggledor` 属性的对象,并将该对象作为提供程序的值传递进去的吗?我希望能够只“获取”子组件内部需要的值,例如,在 `onClick` 事件中<Card.Button>我们需要setToggled切换状态,所以我们只需setToggled从上下文中“获取”即可:
...
function Button({children}){
var {setToggled} = useCardContext();
return (
<button
className="Card__button"
onClick={() => setToggled(prev => !prev)}
>
{children}
</button>
);
}
Card.Button = Button;
...
我喜欢解构语法,它允许我们只“提取”需要的部分var {setToggled} = useCardContext();。
如果我们直接使用数组作为值,就必须这样做:`{{ array("...")} var [toggled, setToggled] = useCardContext();`,这样就会留下toggled一个未使用的变量。
你也可以使用context之前的变量,但要注意点语法,这时你需要使用 `( onClick={() => context.setToggled(prev => !prev)})`。
要切换边框的显示/隐藏状态,<Card>我们只需使用已定义的toggled状态来切换 CSS 类即可div:
...
function Card({children}){
var [toggled, setToggled] = useState(false);
return (
<CardContext.Provider value={{toggled, setToggled}}>
<div className={toggled ? "Card Card--highlight" : "Card"}>
{children}
</div>
</CardContext.Provider>
);
}
...
最后,我们需要让标题也能切换颜色,但这里我们需要toggled从上下文中“获取”信息:
...
function Heading({children}){
var {toggled} = useCardContext();
return (
<h2 className={
toggled
? "Card__heading Card__heading--highlight"
: "Card__heading"}
>
{children}
</h2>
);
}
...
就是这样。现在你可以在组件内部管理状态,并将其与其余子组件共享,而无需将其暴露给外部。正如 Ryan Florence 在他的演讲中所说(链接在顶部或点击此处观看视频):
这个系统内部存在着某种状态。
它不是应用程序状态,也不是我们需要用 Redux 处理的状态。它
也不是组件状态,因为我的组件本身就有自己的状态。
这是一个独立的小系统,一个由组件构成的小世界,其中包含一些我们需要调整的状态。
因此,在复合组件系统中,你可以创建只存在于该系统内部的状态,我认为这非常强大。
复合成分的力量
复合组件功能非常强大,如果你读过或正在读本教程,你会发现我经常提到这一点,这是因为它们既灵活又可扩展,而且一旦你理解了这种模式,它们就很容易创建、使用和操作。
灵活性
你注意到每个子组件(`<div>`<Card.Heading>和<Card.Button>`<span>`)都只包含一个 HTML(JSX)元素<Card>吗?这正是复合组件模式如此强大的原因之一,因为现在你的组件变得非常灵活,例如,你可以这样做:
<Card>
// Who says the button should'nt be above the title?
// Well you do...! You decide where it should go.
<Card.Button>Toggle</Card.Button>
<Card.Heading>My title</Card.Heading>
</Card>
div您还可以自由地为每个组件定义 props/属性,如果您有一个组件有多个's(或其他元素类型),而每个 's' 都需要一些属性,那么这样做就比较困难了。
我承认,如果不使用复合组件模式,组件看起来会简洁得多:
<Card title="My title" button={true} />
但现在谁来决定标题和按钮的渲染顺序呢?我们如何为标题和按钮添加内联样式?是否需要灵活的样式设置className?我们是否应该添加一个属性来将按钮置于标题上方?类似这样:
<Card
style={{border: "2px solid blue"}}
className="MyCard"
title="My title"
titleClass="MyTitle"
titleStyle={{color: "blue"}}
button={true}
buttonAbove={true}
buttonClass="MyButton"
buttonStyle={{border: "1px dotted blue"}}
/>
这简直太糟糕了,而且,事情已经没那么简单了!
想象一下,如果元素远不止标题和按钮,该如何控制它们的顺序呢?内联样式className等等呢?大量的属性和数不清的 if 语句……谢了!
复合组件能极大地帮助解决这个问题。
它不仅让用户在使用组件时更容易自定义组件的外观、风格和行为,而且这种简单而结构化的模式也大大简化了组件的创建过程。
这引出了我想谈的下一个重要话题:
可扩展性
那么,向我们的复合组件添加新功能有多难呢?
简而言之:超级简单!
我们来看一个例子:
假设我们想要一张灵活的图片。我们可以决定它是一张普通的图片,可以直接插入到需要的地方,还是可以设置不同的样式,例如用作头像,或者可以选择将图片插入为背景图片,总之,随我们想要。
我们来试试:
...
function Image({src, alt, type}){
useCardContext();
return (
<img
className={`Card__image${type
? " Card__image--" + type
: ""}`}
src={src}
alt={alt}
/>
);
}
Card.Image = Image;
...
用法:
<Card>
<Card.Heading>My title</Card.Heading>
<Card.Image
src="/path/to/image.jpg"
alt="Our trip to the beach"
/>
<Card.Button>Toggle</Card.Button>
</Card>
或者:
<Card>
<Card.Image
src="/path/to/avatar-image.jpg"
alt="This is me"
type="avatar"
/>
<Card.Heading>My title</Card.Heading>
<Card.Button>Toggle</Card.Button>
</Card>
当然,你需要为你的Card__image--avatar任何其他车辆type进行适当的造型设计。
所以,每当您需要添加新功能时,只需将其作为子组件添加即可,就这么简单。
如果您需要作用域,只需使用自定义上下文钩子。
如果您需要状态,只需在顶层组件中创建状态并将其传递给上下文即可。
请记住,通过上下文以对象形式传递值本身就非常灵活,因为您可以根据需要添加新属性:
...
function Card({children}){
var [toggled, setToggled] = useState(false);
var [something, setSomething] = useState(null);
return (
<CardContext.Provider
value={{
toggled,
setToggled,
something,
setSomething
}}
>
...
</CardContext.Provider>
);
}
...
以上就是全部内容。希望大家对复合组件的强大功能以及它的使用和创建方式有了更深入的了解……
CodeSandbox
在这个沙盒环境中尝试修改代码:
完整代码
如果您感兴趣,以下是完整的(可运行的)代码:
创建两个文件Card.js,Card.css并将以下代码分别粘贴到每个文件中:
Card.js:
import { createContext, useContext, useState } from "react";
import "./Card.css";
// Context (Scope)
var CardContext = createContext();
function useCardContext(){
var context = useContext(CardContext);
if (!context) {
throw new Error("Child components of Card cannot be rendered outside the Card component!");
}
return context;
}
// Card component (main/parent component)
function Card({children}){
var [toggled, setToggled] = useState(false);
return (
<CardContext.Provider value={{toggled, setToggled}}>
<div className={toggled ? "Card Card--highlight" : "Card"}>
{children}
</div>
</CardContext.Provider>
);
}
// Heading component (sub component)
function Heading({children}){
var {toggled} = useCardContext();
return (
<h2 className={
toggled
? "Card__heading Card__heading--highlight"
: "Card__heading"}
>
{children}
</h2>
);
}
Card.Heading = Heading;
// Button component (sub component)
function Button({children}){
var {setToggled} = useCardContext();
return (
<button
className="Card__button"
onClick={() => setToggled(prev => !prev)}
>
{children}
</button>
);
}
Card.Button = Button;
// Image component (sub component)
function Image({src, alt, type}){
useCardContext();
return (
<img
className={`Card__image${type
? " Card__image--" + type
: ""}`}
src={src}
alt={alt}
/>
);
}
Card.Image = Image;
export default Card;
对于一些(非常)基本的样式设置,您可以使用以下方法:
Card.css:
/* Card */
.Card{
border: 1px solid lightgray;
}
.Card--highlight{
border-color: hotpink;
}
/* Heading */
.Card__heading{
margin: 20px;
}
.Card__heading--highlight{
color: hotpink;
}
/* Button */
.Card__button{
border: none;
background-color: hotpink;
padding: 10px 20px;
margin: 20px;
}
/* Image */
.Card__image{
width: 100%;
}
.Card__image--avatar{
width: 48px;
height: 48px;
border-radius: 50%;
margin: 13px 20px 0;
float: left;
}
最后,在需要的地方导入Card组件,例如App.js:
// Remember to update the path to point to the
// correct location of your Card component:
import Card from "./components/Card";
import "./App.css"
function App(){
return (
<div className="App">
{/* First example from the tutorial */}
<Card>
<Card.Heading>My title</Card.Heading>
<Card.Button>Toggle</Card.Button>
</Card>
{/* Example with button and heading flipped */}
<Card>
<Card.Button>Toggle</Card.Button>
<Card.Heading>My title</Card.Heading>
</Card>
{/* Example with image */}
<Card>
<Card.Heading>My title</Card.Heading>
<Card.Image
src="https://picsum.photos/300/100?random=0"
alt="Our trip to the beach"
/>
<Card.Button>Toggle</Card.Button>
</Card>
{/* Example with an avatar-image (type="avatar") */}
<Card>
<Card.Image
src="https://picsum.photos/48?random=1"
alt="This is me"
type="avatar"
/>
<Card.Heading>My title</Card.Heading>
<Card.Button>Toggle</Card.Button>
</Card>
</div>
);
}
export default App;