- 原文地址:Compound Components In React
- 原文作者:Ichoku Chinonso
- 译文出自:掘金翻译计划
速览↬ 复合组件是 React 中的一种高级模式,它通过一种巧妙的方法实现 UI 组件之间通信,并通过显式的父子关系共享隐含的状态数据。
复合组件帮助开发人员构建更形象、灵活的 APIs(Application Interfaces 应用程序接口),以在组件间共享数据和逻辑。本文则将阐述如何基于 Context API 和 React 实现(复合组件),并如何使用这种高级模式搭建组件。
注意: 为了能跟得上后边的内容,你需要对 React 和其中的 Context API 有基本的了解。
复合组件的概念
复合组件可以称之为一种模式,它封装了多个组件的状态(state)和行为(behavior),而仍将可变部分的渲染控制权交给外部用户。
上述定义中,请注意关键词:状态和行为。它帮助我们理解复合组件对状态的处理(比如,已经被外部用户用父组件包裹的组件内部状态如何流转)。
复合组件目的是为父子组件提供了更加形象和灵活的 API。
例如 HTML 中的 <select> 和 <option> 标签:
<select>
<option value="volvo">Volvo</option>
<option value="mercedes">Mercedes</option>
<option value="audi">Audi</option>
</select>
上述代码中的 select 标签和 option 标签配合,可以用来在 HTML 的下拉(drop-down)菜单中选择条目。其中,<select> 管理着 UI 的状态,然后根据 <select> 运行方式配置 <option> 元素。React 中的复合组件用来构建一个声明式的 UI 组件,从而避免 prop drilling 出现。
Prop drilling 是多个子组件向下传递属性的现象,是我们称为的代码味道(code smell)。“props drilling” 最糟糕的地方在于——当父组件重新渲染,子组件也会重新重新渲染,从而导致组件的”多米诺效应“(一层层组件的重新渲染),其中比较好的解决方法,就是使用 React Context API,我们稍后也会对此进行详细探讨。
prop drilling 直译为 属性钻孔,但是尤其意指在 react 组件中,层级过深时,需要组件逐层传递相同的属性。其实过深也在于开发者自己的权衡,通常 >2 层,我们就应该警惕这种 prop drilling 问题的出现了。
在 React 中应用复合组件
本节将介绍可以在我们的应用中使用的依赖包,该包采用了 React 中复合组件的模式来构建组件,如下,该例是一个来自 @reach UI 包的Menu组件。
import {
Menu,
MenuList,
MenuButton,
MenuItem,
MenuItems,
MenuPopover,
MenuLink,
} from "@reach/menu-button";
import "@reach/menu-button/styles.css";
如下是我们使用 Menu 的方法。
function Example() {
return (
<Menu>
<MenuButton>Actions</MenuButton>
<MenuList>
<MenuItem>Download</MenuItem>
<MenuLink to="view">View</MenuLink>
</MenuList>
</Menu>
);
}
上述示例代码是复合组件的一种实践,在其中我们可以看到诸如:Menu, MenuButton,MenuList, MenuItem and MenuLink 由 @reach/menu-button 中引出。与导出一个单独的组件相反,ReachUI 导出了一个名叫 Menu 的父组件,这个父组件(以属性方式)携带了 MenuButton, MenuList, MenuItem 和 MenuLink 的子组件。
什么时候使用复合组件
作为一个 React 开发人员,我们应在如下场景下使用复合组件:
- 解决与构建复用性组件相关的需求时(例如开发组件库)
- “高内聚,低耦合”地开发
- 更好地实现组件间逻辑的共享
复合组件的优点和问题
复合组件是一种非常赞的 React 模式,并能够加入到你的 React 开发者工具包内。本节将阐述使用复合组件时的优缺点,以及在使用这种开发模式搭建组件时,我们将学习到的内容。
优点
-
概念独立 让所有的 UI 状态逻辑放在父组件中,并让所有子组件内部进行通信,使组件的响应性有更清晰的划分。
-
减少复杂度 与“props drilling”向下传递属性给他们指定的组件相反,使用复合组件模式的子属性会传递给它对应的子组件。
问题
React 中使用复合组件的模式搭建组件最大的一个问题是:只有 direct children(直接子组件)才能访问到 props,这意味着我们不能在其他组件中嵌套任何其他组件,如下的 div 元素阻断了这样的访问关系。
export default function FlyoutMenu() {
return (
<FlyOut>
{/* This breaks */}
<div>
<FlyOut.Toggle />
<FlyOut.List>
<FlyOut.Item>Edit</FlyOut.Item>
<FlyOut.Item>Delete</FlyOut.Item>
</FlyOut.List>
</div>
</FlyOut>
);
}
解决这个问题的其中一种方法就是借助灵活的复合组件模式,并使用 React.createContext 隐式共享状态。
当使用复合组件模式时,Context API 使 React 中前套组件之间的状态传递成为可能,这是由于 Context 提供了一种从组件树向下传递数据的方法,而不用在每一级组件手动传递。使用 Context API 为最终的用户(组件的使用方)侧提供了巨大的灵活性。
在 React 中组件维护复合组件
复合组件为 React 应用内提供了一种非常灵活的方式实现状态共享,因而在 React 应用使用复合组件,会使维持状态共享以及实际应用的调试变得更加便利。
构建一个示例demo
本文中,我们将在 React 中使用复合组件的模式,搭建一个 accordion(手风琴)组件。这个教程中我们将要搭建的组件是一个灵活的自定义 accordion 组件,它使用 Context API 在组件间共享状态。
让我们开始!
首先,我们执行如下命令创建一个 React 应用
npx create-react-app accordionComponent
cd accordionComponent
npm start
或者
yarn create react-app accordionComponent
cd accordionComponent
yarn start
上述命令创建了一个 React 应用,改变了 React 项目的路径,并启动了部署服务。
也就是初始化整个应用,并且启动整个应用。
注意: 在本教程中,我们会使用 styled-components 定义我们组件的样式。
使用如下命令安装 styled-components:
yarn add styled-components
或者
npm install --save styled-components
在 src 目录,创建一个名为components的目录,来存放我们的组件。在components内部,新建两个文件:accordion.js 和 accordion.styles.js。
accordion.styles.js 文件包含了 Accordion 组件的样式(我们组件的样式基于styled-components实现)。
import styled from "styled-components";
export const Container = styled.div`
display: flex;
border-bottom: 8px solid #222;
`;
如上,是一个样式化的组件,这个组件使用了称为 styled-components 的 css-in-js 库。
在 accordion.styles.js 内部,添加如下的样式:
export const Frame = styled.div`
margin-bottom: 40px;
`;
export const Inner = styled.div`
display: flex;
padding: 70px 45px;
flex-direction: column;
max-width: 815px;
margin: auto;
`;
export const Title = styled.h1`
font-size: 40px;
line-height: 1.1;
margin-top: 0;
margin-bottom: 8px;
color: black;
text-align: center;
`;
export const Item = styled.div`
color: white;
margin: auto;
margin-bottom: 10px;
max-width: 728px;
width: 100%;
&:first-of-type {
margin-top: 3em;
}
&:last-of-type {
margin-bottom: 0;
}
`;
export const Header = styled.div`
display: flex;
flex-direction: space-between;
cursor: pointer;
margin-bottom: 1px;
font-size: 26px;
font-weight: normal;
background: #303030;
padding: 0.8em 1.2em 0.8em 1.2em;
user-select: none;
align-items: center;
img {
filter: brightness(0) invert(1);
width: 24px;
user-select: none;
@media (max-width: 600px) {
width: 16px;
}
}
`;
export const Body = styled.div`
font-size: 26px;
font-weight: normal;
line-height: normal;
background: #303030;
white-space: pre-wrap;
user-select: none;
overflow: hidden;
&.closed {
max-height: 0;
overflow: hidden;
transition: max-height 0.25ms cubic-bezier(0.5, 0, 0.1, 1);
}
&.open {
max-height: 0px;
transition: max-height 0.25ms cubic-bezier(0.5, 0, 0.1, 1);
}
span {
display: block;
padding: 0.8em 2.2em 0.8em 1.2em;
}
`;
让我们开始构建我们的 accordion(手风琴)组件,其中在 accordion.js 文件中,写入如下的代码:
import React, { useState, useContext, createContext } from "react";
import {
Container,
Inner,
Item,
Body,
Frame,
Title,
Header
} from "./accordion.styles";
如上,我们引入了 useState、useContext 和 createContext 的 hooks,这些 hooks 会帮助我们使用复合组件搭建手风琴(Accordion)组件。
React documentation中描述了,context提供了一种组件间传递数据的方法,从而避免了在每层组件手动向下传递属性。
接着,我们为需要这些共享数据的组件创建 context。
const ToggleContext = createContext();
export default function Accordion({ children, ...restProps }) {
return (
<Container {...restProps}>
<Inner>{children}</Inner>
</Container>
);
}
上述代码片段的 Container 和 Inner 组件来自我们的 ./accordion.styles.js 文件,其中使用 styled-components (来自css-in-js 工具库)创建组件的样式。Container 组件包含了我们使用复合组件构建的整个 Accordion (手风琴组件)。
通过 createContext() 方法创建 context 对象,因此当 React 渲染一个订阅了这个 Context 对象的组件,组件会读取当前匹配到的最近的 context 的 Provider 提供的值。
也就是从当前组件往外,会读取到最先匹配到的 Context.Provider 组件提供的值。
然后我们创建 Accordion 的基础组件,它接受 children 和任意的 restProps 属性。这是包含了 Accordion (手风琴)下所有组件的父组件。
让我们创建 accordion.js 中的其他子组件。
Accordion.Title = function AccordionTitle({ children, ...restProps }) {
return <Title {...restProps}>{children}</Title>;
};
Accordion.Frame = function AccordionFrame({ children, ...restProps }) {
return <Frame {...restProps}>{children}</Frame>;
};
注意,Accordion 父组件后的 . 是用来建立父子组件之间的联系。
接下来,添加如下代码到 accordion.js 文件中:
Accordion.Item = function AccordionItem({ children, ...restProps }) {
const [toggleShow, setToggleShow] = useState(true);
return (
<ToggleContext.Provider value={{ toggleShow, setToggleShow }}>
<Item {...restProps}>{children}</Item>
</ToggleContext.Provider>
);
};
Accordion.ItemHeader = function AccordionHeader({ children, ...restProps }) {
const { isShown, toggleIsShown } = useContext(ToggleContext);
return (
<Header onClick={() => toggleIsShown(!isShown)} {...restProps}>
{children}
</Header>
);
};
Accordion.Body = function AccordionHeader({ children, ...restProps }) {
const { isShown } = useContext(ToggleContext);
return (
<Body className={isShown ? "open" : "close"}>
<span>{children}</span>
</Body>
);
};
这里我们创建了一个 Body、 Header 和 Item 组件,并且都是父组件 Accordion 的子组件,这里正是开始变得奇妙的地方。此外需要注意的是,每个在此创建的子组件都接受一个 children 属性 和 restprops 属性。
Item 子组件中,我们使用 useState 初始状态并设置为 true,然后留意,我们在 accordion.js 的顶层已经创建一个 Context 对象——ToggleContext,而且当 React 渲染一个订阅了这个 Context 对象的组件,组件会在这个组件树中,读取匹配到的最近的 context 的 Provider 提供的值。
每一个 Context 对象有一个 Provider 组件,并能让消费组件订阅这个 context 对象的变化。
Provider 组件接受 value 属性,该属性则传递给了 provider 组件后代中消费 value 的组件,此外,我们传递的是状态 toggleShow 和设置该状态的方法 setToggleShow,它们便是决定 context 对象如何在没有 props drilling 下实现组件间状态共享的值。
然后,在我们的 header 子组件中,我们解构了 context 对象的值,并通过监听 click 事件更改当前的 toggleShow 状态。所以,我们要实现的便是:当 Header 被点击之后,手风琴组件能够来回切换展示。
在我们的 Accordion.Body 组件中,我们同样解构了 context 对象中的 toggleShow,用来标识组件的当前状态,而且,通过 toggleShow 值,我们可以展示 Accordion.Body 组件的内容,同样也可以隐藏。
由此,我们可以得到 accordion.js 的全部代码。
而这就是我们将从 Context 和 Compound components 学到的东西整合起来的过程,但是在此之前,先让我们创建一个名为 data.json 的文件,并且通过如下方法解析里边内容:
[
{
"id": 1,
"header": "What is Netflix?",
"body": "Netflix is a streaming service that offers a wide variety of award-winning TV programs, films, anime, documentaries and more – on thousands of internet-connected devices.\n\nYou can watch as much as you want, whenever you want, without a single advert – all for one low monthly price. There’s always something new to discover, and new TV programs and films are added every week!"
},
{
"id": 2,
"header": "How much does Netflix cost?",
"body": "Watch Netflix on your smartphone, tablet, smart TV, laptop or streaming device, all for one low fixed monthly fee. Plans start from £5.99 a month. No extra costs or contracts."
},
{
"id": 3,
"header": "Where can I watch?",
"body": "Watch anywhere, anytime, on an unlimited number of devices. Sign in with your Netflix account to watch instantly on the web at netflix.com from your personal computer or on any internet-connected device that offers the Netflix app, including smart TVs, smartphones, tablets, streaming media players and game consoles.\n\nYou can also download your favorite programs with the iOS, Android, or Windows 10 app. Use downloads to watch while you’re on the go and without an internet connection. Take Netflix with you anywhere."
},
{
"id": 4,
"header": "How do I cancel?",
"body": "Netflix is flexible. There are no annoying contracts and no commitments. You can easily cancel your account online with two clicks. There are no cancellation fees – start or stop your account at any time."
},
{
"id": 5,
"header": "What can I watch on Netflix?",
"body": "Netflix has an extensive library of feature films, documentaries, TV programs, anime, award-winning Netflix originals, and more. Watch as much as you want, any time you want."
}
]
这是为了测试我们的 Accordion 组件的数据。
我们已经整体过了一遍(如何利用复合组件的模式实现 Accordion 组件的构建),相信你一定从这篇文章中学到了很多。
在这部分,我们会将已经开发和学习到的复合组件的所有内容整合到一起,并在 App.js 中使用 Array.map 函数展示上文定义好的数据,此外,注意 App.js 中没有用到任何的 state,我只用将数据传给特定的组件,Context API 已经帮我们把其他的所有事情已经处理好了(状态管理和组件之间的共享)。
最后,在 App.js 中:
import React from "react";
import Accordion from "./components/Accordion";
import faqData from "./data";
export default function App() {
return (
<Accordion>
<Accordion.Title>Frequently Asked Questions</Accordion.Title>
<Accordion.Frame>
{faqData.map((item) => (
<Accordion.Item key={item.id}>
<Accordion.Header>{item.header}</Accordion.Header>
<Accordion.Body>{item.body}</Accordion.Body>
</Accordion.Item>
))}
</Accordion.Frame>
</Accordion>
);
}
在 App.js 文件中,我们通过文件路径导入复合的 Accordion 组件,并且引入我们的虚拟数据,并通过 map 遍历以访问到数据文件中的每个单独的元素,然后通过对应的(Accordion.Item)组件展示。此外,我们注意到我们所要做的仅仅是传递 children 属性给对应的组件,Context API 确保它们能够放在正确的组件下,并且没有 props drilling 现象。
这是我们最终成品的样子:

Compound Components 的可替代方案
使用复合组件的缺点是使用 render props 的 API,React 中 Render Prop 术语指的是:通过设置函数属性使 React 组件间复用代码的技术。这个带有 render prop 组件接受一个返回 React 元素的组件,并且(在所需要的地方)调用这个函数,而不是执行它本来的渲染逻辑。
当组件互相嵌套时,为从一个组件向所需要的子组件传递数据,可能会造成 props drilling 现象,相比使用 render props 方法,这是使用 context 共享数据的优势。
总结
在本文,我们学习到了 React 重的一个高级模式——复合组件模式,它是一种非常精妙搭建可复用组件的方法,而且通过复合组件的模式构建你的组件给你组件带来了非常大的灵活性。当然,如果灵活性不是当前所需的,你仍然可以选择使用 Render Prop 。
复合组件在构建 design systems 时也非常有用,我们也梳理了使用 Context API 在组件间进行状态共享的过程。
本教程的代码可以在Codesandbox找到。
译者注
组件互相嵌套的情况可以想象 Menu 下的 MenuItem 再嵌套一个 Menu,那么如果如果用 render props 时,需要传递多层,而且比较麻烦,采用这种复合组件的方法非常好用。举例:
- antd 的 select 分组 ant.design/components/…
- antd 的 menu 分组 ant.design/components/…
Context 虽然可以隐藏一些状态逻辑的传递,但是因为它将数据的 Provider 方和 Consumer 方耦合在一起,这样组件的复用代价就提高了,因为你要兼顾两个地方。而 render props 可以处理这样的场景,并且能保证复用性,但是如果存在嵌套或者层级比较深 render props 就不太合适,因为也会出现 render props 的现象。
所以其实可以有一个不成熟的总结:
- 如果你要开发的组件是一个比较独立的(高内聚,比如 Select Menu 以及本文的 Accordion 组件,父子组件通常一起出现),那么建议采用 Context + 复合组件的方法实现。
- 如果你要开发的组件不太独立,其内部的子组件可能会用到其他地方,那么不太建议使用 Context + 复合组件的方法,
render props是一个比较合适的手段。 - 如果
render props层级太深,我觉得其实可以将其与 Context API 结合。 - 对于嵌套情况,我觉得已经算是比较高内聚的情况,个人建议优先选择本文提到的 Context + 复合组件的方法。