
简介
随着 React 继续主导 Web 开发领域,开发者们不断寻求构建灵活且可重用用户界面的新方法和高效方式。其中一个获得显著流行的模式是复合组件模式。该模式允许开发者创建高度可组合的组件。
React 的基于组件的架构已经促进了可重用和模块化代码的概念。然而,复合组件模式通过使开发者能够将相关组件封装在一个父组件中,进一步推动了这一概念,从而实现了协调的组合和配置。通过利用这种模式,我们可以构建强大的 UI 组件,这些组件具有灵活性、可维护性和易于扩展性。
在本文中,我们将重构一个示例组件以使用复合组件模式。在这个过程中,我们将涵盖一些概念,如React库自带的Context API 和属性下钻(prop drilling),这些概念对于理解设计是必不可少的。 (译者注: Context API是一套官方提供的状态管理方案,prop drilling是指状态通过props层层传递给子组件)
通过本文的学习,您将对如何有效地设计和实现复合组件有一个深入的理解,赋予您能够创建高度模块化、可定制和用户友好的 UI组件的能力。首先,让我们了解掌握这种设计模式所需的基础知识。
什么是复合组件模式
React 复合组件是一种强大的模式,它允许您通过将相关的子组件封装在一个父组件中来创建可重用和灵活的组件。复合组件背后的想法是为用户提供一个干净而直观的 API,使其与您的组件进行交互,同时隐藏实现细节。
将复合组件视为乐高积木,集合中的每个单独的乐高积木可能具有自己的形状、颜色和用途,但当您以特定的方式将它们组合在一起时,就可以构建出更复杂和令人兴奋的东西。
在 React 的情境中,一个父组件可以充当集合,而子组件则是单个乐高积木。父组件定义了复合组件作为整体的结构和行为,而子组件则表示特定的部分或功能。下图展示了一个典型的 React 组件的结构。
配图1: 典型的React 组件结构
在这种情况下,我们有一个单独的 React 组件(以蓝框标出),具有许多 UI 状态。根据输入 props 和状态逻辑中规定的条件,我们将呈现相应子组件。子组件(C1、C2、C3 和 C4)嵌套在父组件中,不暴露给外部世界。根据输入 props 渲染不同排列的子组件。这种设计模式对于小型组件来说是可以的,但是当组件大小增加时,会有以下缺点:
- 组件最终可能会有大量的输入 props。
- 很难理解组件在做什么,违反了单一职责原则。
- 很难自定义内部组件,毕竟它们没有被暴露出来。
为了解决上述问题,我们可以使用复合组件模式,通过松散耦合而不是嵌套将子组件封装在父组件中。思考下面使用复合组件结构的设计:
配图2: 使用复合组件模式的React组件设计
要使用复合组件,您需要渲染父组件(C1)并将所需的组件(C2、C3、C4)作为其子组件提供。一个设计良好的 React 复合组件应该在父组件中定义状态和有状态逻辑,然后通过 React Context将这些状态提供给子组件消费。通过这样做,不仅可以重用父组件,还可以以有意义的方式排列和自定义子组件以实现所需的功能。这种设计模式带来以下好处:
- 很容易自定义子组件。我们不再需要通过父组件暴露自定义 props,每个子组件都可以单独自定义。
- 使用 React 复合组件时,很容易理解父组件集合的内部结构。
- 一个设计良好的 React 复合组件应该只需要非常少的状态管理。
我希望您现在能够有一个大致的图景。一般来说,复合组件适用于这样的场景:有一个父组件,封装了相关的子组件,并提供了一个灵活和可定制的 API,用于构建复杂的 UI 功能。目前,你已经拥有足够的理论知识了,现在让我们看一个例子:我们将使用复合组件模式,重构一个 React 组件。
一个用于演示的轮播图组件
我已经在一个 React 项目中准备了一个轮播组件,该组件用于演示复合组件模式。您可以在下方找到仓库地址,并将该仓库克隆到本地目录中。一旦您克隆了该仓库,运行以下命令安装依赖项:
bash
cd compound-component-carousel && \
npm install
您需要安装 Node.js 才能使其正常工作。本项目使用的 Node 版本是 v19.9.0。
安装依赖项后,您可以使用以下命令运行该组件:
bash
npm run dev
执行完上述命令后,将会启动一个本地的 React 服务器,并运行在 http://localhost:5173 上。在浏览器中打开此链接,应该会显示下面的组件。

让我们检查下文件目录,便于更加深入的了解该轮播图组件:

配图3: React版轮播图组件文件目录
这个React项目的入口文件是App.tsx文件,文件内容如下:
ts
import { ReactNode } from "react";
import Carousel from "./carousel/Carousel";
function App() {
const images: ReactNode[] = [
<img src="https://picsum.photos/800/300/?random" alt="1" />,
<img src="https://picsum.photos/800/301/?random" alt="2" />,
<img src="https://picsum.photos/800/302/?random" alt="3" />,
<img src="https://picsum.photos/800/303/?random" alt="4" />,
<img src="https://picsum.photos/800/304/?random" alt="5" />,
];
return (
<Carousel
images={images}
autoplay
interval={3000}
showArrow
title="Carousel"
showPaging
/>
);
}
export default App;
我们导入了一个 Carousel
组件,并使用它来渲染了五张图片。该组件通过其输入 props 进行配置。例如,如果我们想要轮播自动向右滑动,则需要指定 autoplay
prop。
在 Carousel.tsx
中, 提供了 Carousel
的父组件,其内容如下所示:
ts
// Project dependencies
import { useState, useEffect, useRef, ReactNode } from "react";
import { CarouselProps, Slide } from "./types/Carousel";
// Styling
import "./styles/carousel.scss";
import SingleSlide from "./components/SingleSlide";
import Title from "./components/Title";
import Paging from "./components/Paging";
/**
* Initialise the slides state for the carousel.
* If there are less than 3 images, duplicate the images until there are at least 3 images.
* @param images
* @returns
*/
const initialSlides = (images: ReactNode[]): Slide[] => {
let lessThanThree = images.length < 3;
const slides: Slide[] = [];
while (lessThanThree) {
images = images.concat(images);
lessThanThree = images.length < 3;
}
for (let idx = 0; idx < images.length; idx++) {
let slideClass = "slider-single proactivede";
slides.push({
class: slideClass,
element: images[idx],
});
}
return slides;
};
/**
* Main carousel component. This component is responsible for displaying the slides and the arrows.
* It has a state that keeps track of the current slide index.
* It also has the ability to autoplay the slides at a specified interval.
* @param props
* @returns
*/
const Carousel = (props: CarouselProps) => {
const { images, interval = 1000, autoplay, height = "300px", showArrow, title, showPaging } = props;
const [currentSlideIndex, setCurrentSlideIndex] = useState(0);
const [slides, setSlides] = useState(initialSlides.bind(null, images));
const intervalRef = useRef<number>();
const nextRef = useRef<HTMLDivElement>(null);
// First render will initialise some styling
useEffect(() => {
slideRight();
// Autoplay needs to use a ref or else the sliding function will have sticky states due to closure
if (autoplay) {
clearInterval(intervalRef.current);
intervalRef.current = setInterval(() => {
nextRef.current?.click();
}, interval);
}
return () => {
clearInterval(intervalRef.current);
};
}, [nextRef.current]);
/**
* This function is responsible for sliding the carousel to the right. It is called when the user clicks the right arrow button on the carousel,
* or when the carousel is set to autoplay. The class of the previous slide is set to "slider-single preactive", the class of the active slide is set to activeClass,
* and the class of the next slide is set to "slider-single proactive". The slides array and currentSlideIndex state variables are updated, and if autoplay is enabled,
* the intervalRef timer is cleared and a new timer is set.
*/
const slideRight = () => {
const activeClass = "slider-single active";
const updatedSlides = [...slides];
const numSlides = updatedSlides.length;
const lastSlideIndex = numSlides - 1;
let nextSlideIndex = currentSlideIndex + 1;
if (currentSlideIndex === lastSlideIndex) {
nextSlideIndex = 0;
}
const previousSlide = updatedSlides[currentSlideIndex];
const activeSlide = updatedSlides[nextSlideIndex];
// If next slide index overflows, set to first slide
const nextSlide = updatedSlides[nextSlideIndex + 1 > lastSlideIndex ? 0 : nextSlideIndex + 1];
updatedSlides.forEach((slide) => {
if (slide.class.split(" ").includes("preactivede")) {
slide.class = "slider-single proactivede";
}
if (slide.class.split(" ").includes("preactive")) {
slide.class = "slider-single preactivede";
}
});
previousSlide.class = "slider-single preactive";
activeSlide.class = activeClass;
nextSlide.class = "slider-single proactive";
setSlides(updatedSlides);
setCurrentSlideIndex(nextSlideIndex);
if (autoplay) {
clearInterval(intervalRef.current);
intervalRef.current = setInterval(() => {
nextRef.current?.click();
}, interval);
}
};
/**
* The logic is very much the same as the slideRight function, except that the slide classes are different and specified in reverse order.
*/
const slideLeft = () => {
const numSlides = slides.length;
let nextSlideIndex = currentSlideIndex - 1;
const lastSlideIndex = numSlides - 1;
const updatedSlides = [...slides];
if (currentSlideIndex === 0) {
nextSlideIndex = lastSlideIndex;
}
// If prev slide index overflows, set to last slide
const previousSlide = updatedSlides[nextSlideIndex - 1 < 0 ? lastSlideIndex : nextSlideIndex - 1];
const activeSlide = updatedSlides[nextSlideIndex];
const nextSlide = updatedSlides[currentSlideIndex];
slides.forEach((slide) => {
if (slide.class.split(" ").includes("proactivede")) {
slide.class = "slider-single preactivede";
}
if (slide.class.split(" ").includes("proactive")) {
slide.class = "slider-single proactivede";
}
});
previousSlide.class = "slider-single preactive";
activeSlide.class = "slider-single active";
nextSlide.class = "slider-single proactive";
setSlides(updatedSlides);
setCurrentSlideIndex(nextSlideIndex);
};
let content = null;
const hasSlides = slides && slides.length > 0;
if (hasSlides) {
const slideComponents = slides.map((slide, index: number) => (
<SingleSlide
slide={slide}
onSlideLeft={slideLeft}
onSlideRight={slideRight}
key={index}
ref={nextRef}
showArrow={showArrow}
/>
));
content = (
<div className="slider-container">
{title && <Title text={title} />}
<div className="slider-content">{slideComponents}</div>
{showPaging && <Paging numSlides={slides.length} currentSlideIndex={currentSlideIndex} />}
</div>
);
}
return (
<div className="react-3d-carousel" style={{ height }}>
{content}
</div>
);
};
export default Carousel;
Carousel
组件通过 slides
状态显示图片。它通过 currentSlideIndex
状态管理当前幻灯片索引,并提供自动播放、箭头、标题和分页等功能。该组件初始化幻灯片,处理幻灯片转换并应用样式。SingleSlide
、Title
和 Paging
等子组件则基于提供的 props,决定要不要被渲染。slideRight
和 slideLeft
函数将使用适当的样式更新幻灯片和 currentSlideIndex
。此外,useEffect 钩子用于设置一个 interval ref 对象,如果 autoplay
设置为 true,则该对象将以 interval 指定的固定频率自动模拟单击事件,并触发在 SingleSlide
组件上。单击事件通过将 React 的ref 对象即nextRef 附加到 SingleSlide
子组件来模拟。轮播的样式相当复杂,出于本文的目的,我不会详细介绍它们,但如果您感兴趣,可以查看 carousel.scss
。
在 Carousel
组件中,有四个不同的子组件。它们分别如下所示,注释会描述它们的具体作用:
Title
ts
/**
* Component that displays the title of the slide.
*/
const Title = (props: { text: string }) => {
const { text } = props;
return <p className="slide-title">{text}</p>;
};
export default Title;
SlideArrow
ts
import { forwardRef } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowLeft, faArrowRight } from "@fortawesome/free-solid-svg-icons";
type SliderArrowProps = { direction: "left" | "right"; onSlide: VoidFunction; show: boolean };
/**
* This component is used to display the arrows that allow the user to move back and forth between slides in the slider.
*/
const SliderArrow = forwardRef<HTMLDivElement, SliderArrowProps>((props, ref) => {
const { direction, onSlide, show } = props;
return (
<div className={`slider-${direction}`} onClick={onSlide} ref={ref} style={{ display: show ? "" : "none" }}>
<div>
<FontAwesomeIcon size="lg" icon={direction === "left" ? faArrowLeft : faArrowRight} />
</div>
</div>
);
});
export default SliderArrow;
SingleSlide
ts
import { forwardRef } from "react";
import { SingleSlideProps } from "../types/Carousel";
import SliderArrow from "./SlideArrow";
/**
* This component creates a single slide in a slide pack with the ability to slide left and right.
*/
const SingleSlide = forwardRef<HTMLDivElement, SingleSlideProps>((props, ref) => {
const { slide, onSlideLeft, onSlideRight, showArrow = false } = props;
return (
<div className={slide.class}>
<SliderArrow direction="left" onSlide={onSlideLeft} show={showArrow} />
<SliderArrow direction="right" onSlide={onSlideRight} ref={ref} show={showArrow} />
<div className="slider-single-content">{slide.element}</div>
</div>
);
});
export default SingleSlide;
Paging
ts
/**
* Component that displays the current slide index and the total number of slides.
*/
const Paging = (props: { numSlides: number; currentSlideIndex: number }) => {
const { numSlides, currentSlideIndex } = props;
const pagingElements = Array.from({ length: numSlides }, (_, idx) => {
const isActive = idx === currentSlideIndex;
return <span className={`slide-paging-element ${isActive ? "active" : ""}`} />;
});
return <div className="slide-paging">{pagingElements}</div>;
};
export default Paging;
现在我们已经了解了基本的 React 项目以及 Carousel 组件,让我们看看如何使用复合组件模式重构它。
重构轮播图组件
最终重构的组件在这里。您可以下载重构后代码,但我建议您在这样做之前先阅读本节。 react-compound-component-refactor
要实现复合组件模式,我们需要设置一个 React Context对象,以便将在父级管理的状态提供给所有子组件。我们将重构 Carousel.tsx
中的原始 Carousel
组件,内容如下:
ts
// Project dependencies
import { createContext } from "react";
import { CarouselProps, Slide } from "./types/Carousel";
// Styling
import "./styles/carousel.scss";
import useSlideRotation from "./hooks/useSlideRotation";
// Children components
import Title from "./components/Title";
import Paging from "./components/Paging";
import Slides from "./components/Slides";
import SlidesContainer from "./components/SlidesContainer";
type CarouselContext = {
currentSlideIndex: number;
slides: Slide[];
slideRight: () => void;
slideLeft: () => void;
nextRef: React.RefObject<HTMLDivElement> | null;
};
const defaultContext: CarouselContext = {
currentSlideIndex: 0,
slides: [],
slideRight: () => {},
slideLeft: () => {},
nextRef: null,
};
export const CarouselContext = createContext<CarouselContext>(defaultContext);
/**
* Main carousel component. This component is responsible for displaying the slides and the arrows.
* It has a state that keeps track of the current slide index.
* It also has the ability to autoplay the slides at a specified interval.
* @param props
* @returns
*/
const Carousel = (props: CarouselProps) => {
const { images, interval = 1000, autoplay, children } = props;
if (images && images.length <= 0) {
throw new Error("Carousel requires at least one image");
}
const { currentSlideIndex, slides, slideLeft, slideRight, nextRef } = useSlideRotation({
images,
interval,
autoplay,
});
return (
<CarouselContext.Provider value={{ slideLeft, slideRight, nextRef, slides, currentSlideIndex }}>
{children}
</CarouselContext.Provider>
);
};
// Set children components to be memeber of the Carousel component
Carousel.Title = Title;
Carousel.Paging = Paging;
Carousel.Slides = Slides;
Carousel.SlidesContainer = SlidesContainer;
export default Carousel;
有一些明显的变化:
- 我们从 Carousel 父组件中删除了一些 props。这些包括
height
、showArrow
、title
和showPaging
,这些 props 都与底层组件相关,与父组件无关。 - 所有状态和有状态逻辑都被移动到自定义 Hook useSlideRotation 中,以提高模块化程度。状态逻辑保持不变。
currentSlideIndex
和slides
状态与slideLeft
和slideRight
函数一起返回,这些函数控制这些状态。此外,用于跟踪轮播滑块箭头的nextRef
对象也从自定义 Hook 中返回。 - 我们创建了一个名为
CarouselContext
的 React Context,用于向子组件提供currentSlideIndex
、slides
、slideLeft
、slideRight
和nextRef
。 - 所有子组件都作为
Carousel
父组件的成员导出。
原始 Carousel
组件的演示部分进行了调整,我们创建了一个 SlidesContainer
组件,如下所示,注意 Carousel
组件中的 height
prop 被移动到了这里:
ts
import { SlidesContainerProps } from "../types/Carousel";
/**
* This is the container compoennt that holds the slides.
* @param props
* @returns
*/
const SlidesContainer = (props: SlidesContainerProps) => {
const { children, height = "300px" } = props;
return (
<div className="react-3d-carousel" style={{ height }}>
<div className="slider-container">{children}</div>
</div>
);
};
export default SlidesContainer;
在 SlidesContainer
组件中,将渲染 Title
、Paging
和 Slides
组件。Slides
组件只是原始 SingleSlide
组件的映射版本。让我们在下面看看这些子组件的实际效果。
Slides
ts
import { useContext } from "react";
import { SlidesProps } from "../types/Carousel";
import SlideArrow from "./SlideArrow";
import { CarouselContext } from "../Carousel";
/**
* This component creates a slide pack where individual slides have the ability to slide left and right.
*/
const Slides = (props: SlidesProps) => {
const { showArrow = true } = props;
const { slides, slideLeft, slideRight, nextRef } = useContext(CarouselContext);
const slideComponents = slides.map((slide, index: number) => (
<div className={slide.class} key={`slide-${index}`}>
<SlideArrow direction="left" onSlide={slideLeft} show={showArrow} />
<SlideArrow direction="right" onSlide={slideRight} ref={nextRef} show={showArrow} />
<div className="slider-single-content">{slide.element}</div>
</div>
));
return <div className="slider-content">{slideComponents}</div>;
};
export default Slides;
请注意,我们现在将 showArrow
prop 从 Carousel
移动到了这里。更重要的是,我们通过使用 useContext Hook,通过 CarouselContext
访问 slides
、slideLeft
、slideRight
和 nextRef
。这些都是在 Carousel 父级管理的状态,在子组件中被消费。您将在其他依赖于这些全局Context的子组件中看到这一点。
Title
ts
/**
* Component that displays the title of the slide.
*/
const Title = (props: { text: string }) => {
const { text } = props;
return <p className="slide-title">{text}</p>;
};
export default Title;
Title组件保持不变
Paging
ts
import { useContext } from "react";
import { CarouselContext } from "../Carousel";
/**
* Component that displays the current slide index and the total number of slides.
*/
const Paging = () => {
const { slides, currentSlideIndex } = useContext(CarouselContext);
const pagingElements = Array.from({ length: slides.length }, (_, idx) => {
const isActive = idx === currentSlideIndex;
return <span className={`slide-paging-element ${isActive ? "active" : ""}`} />;
});
return <div className="slide-paging">{pagingElements}</div>;
};
export default Paging;
Paging
组件现在从全局的CarouselContext
组件中获取currentSlideIndex
和slides
的长度,这些参数之前是通过props drilling层层传递到当前组件。
在App.tsx
文件中,我们可以使用Carousel
组件以及其子属性将其初始化。
ts
import { ReactNode } from "react";
import "./App.css";
import Carousel from "./carousel/Carousel";
function App() {
const images: ReactNode[] = [
<img src="https://picsum.photos/800/300/?random" alt="1" />,
<img src="https://picsum.photos/800/301/?random" alt="2" />,
<img src="https://picsum.photos/800/302/?random" alt="3" />,
<img src="https://picsum.photos/800/303/?random" alt="4" />,
<img src="https://picsum.photos/800/304/?random" alt="5" />,
];
return (
<div className="main">
<Carousel images={images} autoplay interval={3000}>
<Carousel.SlidesContainer>
<Carousel.Title text="Carousel" />
<Carousel.Slides />
<Carousel.Paging />
</Carousel.SlidesContainer>
</Carousel>
</div>
);
}
export default App;
父级 Carousel
组件包裹了所有底层子组件,并通过 React Context提供相关状态。请注意,我们不再需要将特定于子组件的 props 传递给父级轮播图组件,以更改子组件的 UI 行为。我们使用复合组件模式进行的组件重构到此结束。
复合组件模式的威力
你可能会争论这种模式实际上增加了代码量并且没有让逻辑变得简单。这是事实,但是让我们考虑一个场景:假设我们希望Paging
组件显示的是一个方形而不是一个圆形,可以做如下的改动:
ts
import { useContext } from "react";
import { CarouselContext } from "../Carousel";
type PagingProps = {
elementStyle?: string;
};
/**
* Component that displays the current slide index and the total number of slides.
*/
const Paging = (props: PagingProps) => {
const { elementStyle } = props;
const {slides, currentSlideIndex} = useContext(CarouselContext);
const pagingElements = Array.from({ length: slides.length }, (_, idx) => {
const isActive = idx === currentSlideIndex;
return <span className={`${elementStyle ? elementStyle : 'slide-paging-element'} ${isActive ? "active" : ""}`} />;
});
return <div className="slide-paging">{pagingElements}</div>;
};
export default Paging;
我们通过 elementStyle
prop 暴露了 Paging
底层 span
元素的样式。这似乎是一件很明显的事情,然而,让我们想想如果我们没有使用复合组件模式会怎样。我们将不得不将 elementStyle
暴露为父组件的 prop。对于目前的场景,这不是一个问题,但如果我们有许多需要灵活定制的子组件,那么我们将很快得到一个带有大量的 props,且难以维护的组件。
当您需要构建由一组逻辑相关的底层组件组成的 UI 组件时,复合组件的优势真正显现。特别是当我们想要在必要时重用父组件,同时允许对底层子组件进行定制时。
如果您试图创建低级可重用的 UI 组件,则不应考虑使用此模式。依赖于使用 React Context来管理和提供状态意味着组件仅在嵌套在父级复合组件中时才能正常工作。
结论
在本文中,我们探讨了 React 中的复合组件模式,它允许我们创建灵活和可重用的 UI 组件。通过将相关的子组件封装在父组件中,我们可以实现协调的组合和配置,同时隐藏实现细节。
复合组件模式提供了几个好处。首先,它使得定制单个子组件变得容易,提高了对父组件内部组成的理解,其次,减少了对广泛的状态管理的需求。利用 React Context,我们可以向子组件提供必要的状态和函数,而无需通过 props 暴露它们,从而使代码库更简洁
和易于维护
。
重构示例轮播图组件展示了复合组件模式的威力。我们将状态管理和逻辑移动到自定义 hook
中,创建了React Context 以向子组件提供状态,并以更模块化和可重用的方式组织了组件。这种方法允许轻松定制轮播图组件,并促进对其内部结构的更清晰理解。
总的来说,复合组件模式是在 React 中构建高度模块化、可定制和用户友好的 UI 组件的有价值工具。通过拥抱这种模式,开发人员可以创建更高效和可维护的代码,同时赋予用户轻松创建复杂 UI 功能的能力。
原文链接,如侵则删。