⚛️🚀 React 组件模式
编码愉快🚀
更新:
概述
本文档将帮助您确定不同 React 模式之间的利弊,以及每种模式何时最合适。以下模式将通过遵循关注点分离、DRY 和代码复用等设计原则,实现更实用、更可复用的代码。其中一些模式将有助于解决大型 React 应用程序中出现的问题,例如prop 钻取或状态管理。每个主要模式都包含一个托管在CodeSandBox上的示例。
💡以下示例并不复杂,以免读者对与每个组件模式的概念无关的实现细节感到困惑。
📚 目录
⬆️复合组件
概述
复合组件是一种组件组合使用的模式,组件之间共享一个隐式状态,从而允许它们在后台相互通信。复合组件由一组子组件组成,这些子组件协同工作以产生某些功能。
想象一下 HTML 中的复合组件,例如
<select>
和<option>
元素。它们单独使用时功能不多,但组合在一起就能创造出完整的体验。—— Kent C. Dodds
❓ 为什么要使用复合组件?它们能提供什么价值?
作为可复用组件的创建者,您应该时刻牢记组件的使用者:其他将使用您组件的工程师。此模式为组件的使用者提供了灵活性。它允许您抽象组件的内部工作原理;可复用组件背后的逻辑无需用户关注。它提供了一个用户友好的界面,让组件的使用者只需关心组合元素的布局,同时提供整体的体验。
例子
让我们深入研究一个示例,创建一个单选按钮图片表单。我们将创建一个单选按钮组表单,但与常规的单选按钮输入不同,我们将渲染一个图片列表供用户选择。您可以在CodeSandBox中查看最终结果。
我们将创建一个父组件RadioImageForm
,用于处理表单的逻辑;以及一个子组件RadioInput
,用于渲染图片单选框。它们将共同创建一个复合组件。
{/* The parent component that handles the onChange events
and managing the state of the currently selected value. */}
<RadioImageForm>
{/* The child, sub-components.
Each sub-component is an radio input displayed as an image
where the user is able to click an image to select a value. */}
<RadioImageForm.RadioInput />
<RadioImageForm.RadioInput />
<RadioImageForm.RadioInput />
</RadioImageForm>
文件中src/components/RadioImageForm.tsx
有 1 个主要组件:
RadioImageForm
- 首先,我们创建一个父组件,用于管理表单的状态并处理表单的更改事件。组件的使用者(其他使用该组件的工程师)可以通过传递一个回调函数 prop 来订阅单选框的当前选中值。onStateChange
每次表单更改时,该组件都会更新单选框,并将当前值提供给使用者。
在组件内RadioImageForm
我们有一个静态组件或子组件:
RadioInput
- 接下来,我们将创建一个静态组件,即 组件的子集RadioImageForm
。RadioInput
是一个可以通过点语法符号访问的静态组件,例如<RadioImageForm.RadioInput/>
。这使得组件的使用者可以轻松访问我们的子组件,并让他们能够控制 如何RadioInput
在表单中呈现。
💡组件
RadioInput
是类的静态属性RadioImageForm
。复合组件由父组件RadioImageForm
和静态组件组成RadioInput
。从现在开始,我将静态组件称为“子组件”。
让我们迈出创建组件的第一步RadioImageForm
。
export class RadioImageForm extends React.Component<Props, State> {
static RadioInput = ({
currentValue,
onChange,
label,
value,
name,
imgSrc,
key,
}: RadioInputProps): React.ReactElement => (
//...
);
onChange = (): void => {
// ...
};
state = {
currentValue: '',
onChange: this.onChange,
defaultValue: this.props.defaultValue || '',
};
render(): React.ReactElement {
return (
<RadioImageFormWrapper>
<form>
{/* .... */}
</form>
</RadioImageFormWrapper>
)
}
}
在创建可复用组件时,我们希望提供一个组件,让用户可以控制元素在其代码中渲染的位置。但是,为了获得良好的用户体验,RadioInput
组件需要访问内部状态、内部onChange
函数以及用户的 props。那么,如何将这些数据传递给子组件呢?这就是React.Children.map
和React.cloneElement
发挥作用的地方。有关这两者工作原理的深入解释,您可以深入研究 React 文档:
render 方法的最终结果RadioImageForm
如下所示:
render(): React.ReactElement {
const { currentValue, onChange, defaultValue } = this.state;
return (
<RadioImageFormWrapper>
<form>
{
React.Children.map(this.props.children,
(child: React.ReactElement) =>
React.cloneElement(child, {
currentValue,
onChange,
defaultValue,
}),
)
}
</form>
</RadioImageFormWrapper>
)
}
在此实现中值得注意的是:
RadioImageFormWrapper
- 我们的组件样式使用了 styled-components。我们可以忽略这一点,因为 CSS 样式与组件模式无关。React.Children.map
- 它遍历组件的直接子组件,允许我们操作每个直接子组件。React.cloneElement
- 来自 React 文档:
克隆并返回一个新的 React 元素,以某个元素为起点。克隆后的元素将包含原始元素的 props,并将新的 props 浅层合并。新的子元素将替换现有的子元素。
使用 和React.Children.map
,React.cloneElement
我们可以迭代和操作每个子组件。因此,我们可以传递在此转换过程中明确定义的额外 props。在这种情况下,我们可以将RadioImageForm
内部状态传递给每个RadioInput
子组件。由于React.cloneElement
执行的是浅合并,因此用户定义的任何 props 都RadioInput
将传递给组件。
最后,我们可以RadioInput
在RadioImageForm
类中声明静态属性 component。这样一来,用户就可以使用点语法RadioInput
直接调用我们的子集组件RadioImageForm
。这有助于提高代码可读性,并显式地声明子组件。通过这个接口,我们创建了一个可复用且用户友好的组件。以下是我们的RadioInput
静态组件:
static RadioInput = ({
currentValue,
onChange,
label,
value,
name,
imgSrc,
key,
}: RadioInputProps) => (
<label className="radio-button-group" key={key}>
<input
type="radio"
name={name}
value={value}
aria-label={label}
onChange={onChange}
checked={currentValue === value}
aria-checked={currentValue === value}
/>
<img alt="" src={imgSrc} />
<div className="overlay">
{/* .... */}
</div>
</label>
);
💡需要注意的一点是,我们明确定义了
RadioInputProps
用户可以传递给RadioInput
子组件的道具的模型契约。
然后组件的使用者可以RadioInput
在他们的代码中使用点语法符号来引用(RadioImageForm.RadioInput
):
// src/index.tsx
<RadioImageForm onStateChange={onChange}>
{DATA.map(
({ label, value, imgSrc }): React.ReactElement => (
<RadioImageForm.RadioInput
label={label}
value={value}
name={label}
imgSrc={imgSrc}
key={imgSrc}
/>
),
)}
</RadioImageForm>
🚧由于
RadioInput
是静态属性,它无法访问RadioImageForm
实例。因此,您无法直接引用类中定义的状态或方法RadioImageForm
。例如this.onChange
,在以下示例中将不起作用:static RadioInput = () => <input onChange={this.onChange} //...
结论
基于这种灵活的理念,我们抽象了单选按钮图片表单的实现细节。尽管组件的内部逻辑可能很简单,但对于更复杂的组件,我们可以将内部工作原理从用户那里抽象出来。父组件RadioImageForm
负责处理变化事件操作并更新当前选中的单选按钮输入。子RadioInput
组件能够确定当前选中的输入。我们为单选按钮图片表单提供了基本样式。此外,我们还为组件添加了可访问性。组件RadioImageForm
管理表单状态、应用当前选中的单选按钮输入以及应用表单样式的内部逻辑,这些实现细节无需使用我们组件的工程师关注。
缺点
虽然我们为组件用户创建了用户友好的界面,但我们的设计中存在一个漏洞。如果组件<RadioImageForm.RadioInput/>
被一堆 div 覆盖怎么办?如果组件的使用者想要重新排列布局,又会发生什么?组件仍然会渲染,但 radio 输入框将无法从RadioImageForm
state 中获取当前值,从而破坏用户体验。这种组件模式不够灵活,这就引出了下一个组件模式。
⬆️ 复合组件 CodeSandBox
🚀具有功能组件和 React hooks 的复合组件示例:
⬆️ 复合组件与功能组件 CodeSandBox
⬆️灵活的复合组件
概述
在之前的例子中,我们使用了复合组件模式,但是当我们将子组件包裹在一堆 div 中时会发生什么?它会崩溃。它不灵活。复合组件的问题在于它只能克隆并将 props 传递给直接 子组件。
❓ 为什么要使用灵活的复合组件?它们能提供什么价值?
使用灵活复合组件,我们可以隐式访问类组件的内部状态,无论它们在组件树中的渲染位置如何。使用灵活复合组件的另一个原因是,当多个组件需要共享状态时,无论它们在组件树中的位置如何。组件的使用者应该能够灵活地选择在何处渲染复合组件。为了实现这一点,我们将使用 React 的 Context API。
💡但首先我们应该通过阅读官方React 文档来了解有关 React 的 Context API 的一些背景信息。
例子
我们将继续使用单选按钮图片表单的示例,并重构组件以使用灵活的复合组件模式。您可以在CodeSandBoxRadioImageForm
中查看最终结果。
让我们为RadioImageForm
组件创建一些上下文,以便我们可以将数据传递给父组件树中任何位置的子组件(例如RadioInput
)。希望你已经熟悉了 React 的 Context,以下是来自 React 文档的简要概述:
Context 提供了一种通过组件树传递数据的方法,而无需在每个级别手动传递 props。
首先,我们调用React.createContext
方法,为上下文提供默认值。接下来,我们将为上下文对象分配一个显示名称。我们将此添加到文件顶部RadioImageForm.tsx
。
const RadioImageFormContext = React.createContext({
currentValue: '',
defaultValue: undefined,
onChange: () => { },
});
RadioImageFormContext.displayName = 'RadioImageForm';
- 通过调用,
React.createContext
我们创建了一个包含Provider
和Consumer
对的上下文对象。前者将向后者提供数据;在我们的示例中,Provider
会将我们的内部状态暴露给子组件。 - 通过将 赋值
displayName
给 context 对象,我们可以轻松地在 React Dev Tool 中区分不同的 context 组件。因此,我们可以使用Context.Provider
and而不是or 。如果我们在调试时有多个使用 Context 的组件,这将有助于提高可读性。Context.Consumer
RadioImageForm.Provider
RadioImageForm.Consumer
接下来我们可以重构RadioImageForm
组件的渲染功能并删除单调React.Children.map
的React.cloneElement
功能并渲染子属性。
render(): React.ReactElement {
const { children } = this.props;
return (
<RadioImageFormWrapper>
<RadioImageFormContext.Provider value={this.state}>
{children}
</RadioImageFormContext.Provider>
</RadioImageFormWrapper>
);
}
接受RadioImageFormContext.Provider
一个名为 的 prop value
。传递给该 prop 的数据value
是我们想要提供给此 Provider 后代的上下文。子组件需要访问我们的内部状态以及内部onChange
函数。通过将onChange
方法currentValue
和defaultValue
赋值给state
对象,我们就可以传递this.state
上下文值。
🚧每当 this
value
更改为其他值时,它都会重新渲染自身及其所有消费者。React 会持续渲染,因此将对象传递给value
prop 会重新渲染所有子组件,因为该对象在每次渲染时都会重新分配(每次渲染都会创建一个新对象)。这不可避免地会导致性能问题,因为value
即使对象中的值没有改变,每次子组件重新渲染时,传递给 prop 的对象都会重新创建。不要这样做:<RadioImageFormContext.Provider value={{ currentValue: this.state.currentValue, onChange: this.onChange }}>
。相反,应该传递this.state
,以防止任何子组件不必要的重新渲染。
最后,我们的子组件可以使用我们之前创建的上下文,也就是内部数据。由于我们的子组件都是组件内部的RadioImageForm
,因此我们可以将 定义Consumer
为 的静态属性RadioImageForm
。
export class RadioImageForm extends React.Component<Props, State> {
static Consumer = RadioImageFormContext.Consumer;
//...
💡或者,如果您有需要订阅上下文的外部组件,则可以
RadioImageFormContext.Consumer
在文件内导出,例如export const RadioImageFormConsumer = RadioImageFormContext.Consumer
。
对于我们的每个子组件,我们可以Consumer
通过将消费者呈现为根元素来使用点语法符号进行声明。
为了举例说明,我们将创建一个提交按钮,用户可以在其中提供一个回调函数,以便我们能够传递currentValue
上下文值。RadioImageForm
我们将在下方创建SubmitButton
组件。
static SubmitButton = ({ onSubmit }: SubmitButtonProps) => (
<RadioImageForm.Consumer>
{({ currentValue }) => (
<button
type="button"
className="btn btn-primary"
onClick={() => onSubmit(currentValue)}
disabled={!currentValue}
aria-disabled={!currentValue}
>
Submit
</button>
)}
</RadioImageForm.Consumer>
);
需要注意的是,Consumer
需要一个函数作为子函数;它使用了渲染属性模式。例如({ currentValue }) => (// Render content))
。此函数接收当前上下文值,并订阅内部状态变化。这使我们能够明确声明需要从 中获取哪些数据Provider
。例如,SubmitButton
需要currentValue
属性,该属性是类的引用RadioImageForm
。但现在它可以通过 Context 直接访问这些值。
💡为了更好地理解 render prop 的工作原理(函数作为子概念),您可以访问React Docs。
通过这些更改,我们组件的用户可以在组件树中的任何位置使用我们的复合组件。在src/index.tsx
文件中,您可以查看组件的使用者如何使用它。
结论
通过这种模式,我们能够设计可复用的组件,并让组件使用者能够在不同的环境中灵活使用。我们提供了一个组件友好的接口,组件使用者无需了解内部逻辑。借助 Context API,我们可以将组件的隐式状态传递给子组件,无论其在层级结构中的深度如何。这赋予用户控制权,从而增强组件的样式。这就是灵活复合组件的魅力所在:它们有助于将呈现与内部逻辑分离。使用 Context API 实现复合组件更具优势,这也是我推荐从灵活复合组件模式入手,而非复合组件模式的原因。
⬆️ 灵活的复合组件 CodeSandBox
🚀具有功能组件和 React hooks 的灵活复合组件示例:
⬆️ 灵活的复合组件与功能组件 CodeSandBox
⬆️提供者模式
概述
提供者模式是一种在 React 组件树中共享数据的优雅解决方案。提供者模式利用了我们之前学到的概念,其中两个主要概念是 React 的 context API 和 render props。
💡如需了解更多见解,请访问有关Context API和Render Props的 React 文档。
上下文 API:
Context 提供了一种通过组件树传递数据的方法,而无需在每个级别手动传递 props。
渲染道具:
术语“render prop”是指使用值为函数的 prop 在 React 组件之间共享代码的技术。
❓ 为什么要使用提供者模式?它们能提供什么价值?
提供者模式是一个强大的概念,它在设计复杂的应用程序时非常有用,因为它解决了许多问题。在 React 中,我们必须处理单向数据流,并且在组合多个组件时,我们必须将共享状态从父级传递到子级组件。这可能会导致难看的意大利面条式代码。
在页面上加载和显示共享数据的挑战在于如何将共享状态提供给需要访问它的子组件。利用 React 的 Context API,我们可以创建一个数据提供器组件,用于获取数据并将共享状态提供给整个组件树。这样,多个子组件(无论嵌套深度如何)都可以访问相同的数据。获取数据和显示数据是两个独立的部分。理想情况下,单个组件应该只负责一个职责。父组件,即数据包装器(提供器),主要关注的是数据获取和共享状态的处理,而子组件则可以专注于如何渲染这些数据。提供器组件还可以处理响应数据的业务逻辑,例如规范化和数据处理,这样即使 API 端点更新且响应数据模型发生变化,子组件也能始终接收相同的模型。这种关注点分离在构建大型应用程序时非常有用,因为它有助于提高可维护性并简化开发。其他开发人员可以轻松确定每个组件的职责。
有些人可能会问,为什么不使用像 Redux、MobX、Recoil、Rematch、Unstated、Easy Peasy 或其他一些状态管理库呢?虽然这些库可以帮助解决状态管理问题,但没有必要过度设计问题。引入状态管理库会产生大量重复的样板代码、其他开发者需要学习的复杂流程,以及增加应用程序占用空间的臃肿。现在,我并不是说状态管理库毫无用处,不应该使用它,而是说,重要的是要了解它提供的价值,并证明导入新库的合理性。当我使用 React 初始化我的应用程序时,我选择不使用状态管理库,尽管似乎其他所有 React 项目都在这样做。虽然我这样做的需求可能与其他人不同,但我认为没有必要用一个未来开发者可能需要学习的状态管理工具来使我们的代码库变得复杂。因此,我选择了使用提供程序模式的解决方案。
例子
冗长的介绍过后,让我们深入探讨一个示例。这次我们将创建一个非常简单的应用程序,以演示如何在组件甚至页面之间轻松共享状态,同时遵循关注点分离和 DRY 等设计原则。您可以在CodeSandBox中查看最终结果。在我们的示例中,我们将创建一个狗狗社交应用,用户可以在其中查看自己的个人资料以及狗狗好友列表。
首先,让我们利用 React 的 Context API 创建数据提供者组件,DogDataProvider
它将负责获取我们的数据并将其提供给子组件,无论它们在组件树中的位置如何。
// src/components/DogDataProvider.tsx
interface State {
data: IDog;
status: Status;
error: Error;
}
const initState: State = { status: Status.loading, data: null, error: null };
const DogDataProviderContext = React.createContext(undefined);
DogDataProviderContext.displayName = 'DogDataProvider';
const DogDataProvider: React.FC = ({ children }): React.ReactElement => {
const [state, setState] = React.useState<State>(initState);
React.useEffect(() => {
setState(initState);
(async (): Promise<void> => {
try {
// MOCK API CALL
const asyncMockApiFn = async (): Promise<IDog> =>
await new Promise(resolve => setTimeout(() => resolve(DATA), 1000));
const data = await asyncMockApiFn();
setState({
data,
status: Status.loaded,
error: null
});
} catch (error) {
setState({
error,
status: Status.error,
data: null
});
}
})();
}, []);
return (
<DogDataProviderContext.Provider value={state}>
{children}
</DogDataProviderContext.Provider>
);
};
在此实现中值得注意的是:
- 首先,我们
DogDataProviderContext
通过 React 的 Context API创建一个上下文对象React.createContext
。这将用于通过我们稍后实现的自定义 React hook 向使用组件提供状态。 - 通过为 context 对象赋值
displayName
,我们可以轻松地在 React Dev Tool 中区分不同的 context 组件。这样一来, React Dev Tools 中的Context.Provider
context 组件就不用再使用 了DogDataProvider.Provider
。如果我们在调试过程中有多个组件使用了 context,这可以提高可读性。 - 在我们的
useEffect
钩子中,我们将获取和管理将被多个子组件使用的相同共享数据。 - 我们的状态模型包含我们富有创意的 data 属性、status 属性和 error 属性。通过这三个属性,子组件可以决定渲染哪些状态:1. 加载状态;2. 已渲染数据的加载状态;3. 错误状态。
- 由于我们已经将数据的加载和管理与关注显示数据的 UI 组件分离,因此在安装和卸载 UI 组件时我们不会有不必要的数据提取。
接下来,我们将在创建组件的同一文件中创建自定义 React hook 。自定义 hook 将向使用组件DogDataProvider
提供组件的上下文状态。DogDataProvider
// src/components/DogDataProvider.tsx
export function useDogProviderState() {
const context = React.useContext(DogDataProviderContext);
if (context === undefined) {
throw new Error('useDogProviderState must be used within DogDataProvider.');
}
return context;
}
自定义钩子用于[React.useContext](https://reactjs.org/docs/hooks-reference.html#usecontext)
从组件获取提供的上下文值DogDataProvider
,并在调用它时返回上下文状态。通过暴露自定义钩子,消费者组件可以订阅提供者数据组件中管理的状态。
此外,我们还添加了错误处理功能,当钩子函数在非数据提供者组件的子组件中调用时,可以进行错误处理。这将确保钩子函数在被误用时能够快速失败,并提供有价值的错误消息。
最后,我们在消费组件中加载数据并显示。我们将重点介绍Profile
在主路径中加载的组件,但您也可以在DogFriends
和Nav
组件中看到消费组件的示例。
首先,index.tsx
我们必须在文件DogDataProvider
中将组件包装在根级别:
// src/index.tsx
function App() {
return (
<Router>
<div className="App">
{/* The data provder component responsible
for fetching and managing the data for the child components.
This needs to be at the top level of our component tree.*/}
<DogDataProvider>
<Nav />
<main className="py-5 md:py-20 max-w-screen-xl mx-auto text-center text-white w-full">
<Banner
title={'React Component Patterns:'}
subtitle={'Provider Pattern'}
/>
<Switch>
<Route exact path="/">
{/* A child component that will consume the data from
the data provider component, DogDataProvider. */}
<Profile />
</Route>
<Route path="/friends">
{/* A child component that will consume the data from
the data provider component, DogDataProvider. */}
<DogFriends />
</Route>
</Switch>
</main>
</DogDataProvider>
</div>
</Router>
);
}
然后在Profile
组件中我们可以使用自定义钩子useDogProviderState
:
const Profile = () => {
// Our custom hook that "subscirbes" to the state changes in
// the data provider component, DogDataProvider.
const { data, status, error } = useDogProviderState();
return (
<div>
<h1 className="//...">Profile</h1>
<div className="mt-10">
{/* If the API call returns an error we will show an error message */}
{error ? (
<Error errorMessage={error.message} />
// Show a loading state when we are fetching the data
) : status === Status.loading ? (
<Loader isInherit={true} />
) : (
// Display the content with the data
// provided via the custom hook, useDogProviderState.
<ProfileCard data={data} />
)}
</div>
</div>
);
};
在此实现中值得注意的是:
- 当获取数据时,我们将显示加载状态。
- 如果 API 调用返回错误,我们将显示一条错误消息。
- 最后,一旦通过自定义钩子获取并提供数据,
useDogProviderState
我们将呈现ProfileCard
组件。
结论
这是一个故意简化的示例,旨在展示提供程序模式的强大概念。但我们为如何在 React 应用程序中完成数据获取、状态管理和数据显示奠定了优雅的基础。
⬆️ 带有自定义示例的提供程序模式
💡由于 React hooks 是在 React v16.8 中引入的,如果您需要支持低于 v16.8 的版本,这里是没有 hooks 的相同示例:CodeSandBox。
编码愉快🚀
博客文章:https://alexitaylor.com/blog/08-react-component-patterns
如果您喜欢此内容,请在 Twitter 上关注我@alexi_be3 💙
更新:
2020年9月2日:感谢Dmitry指出,对于提供程序模式,您需要将undefined
默认值传递给React.useContext()
;否则,自定义消费者钩子useDogProviderState
将永远不会抛出错误。我已根据此更改更新了示例。另外,感谢您提供包含函数式组件的灵活复合组件示例。我已添加 CodeSandBox 中复合组件和包含函数式组件的灵活复合组件的示例。