设计模式
HOC 模式
在我们的应用程序中,我们经常希望在多个组件中使用相同的逻辑。此逻辑可以包括对组件应用特定样式、要求授权或添加全局状态。
能够在多个组件中重用相同逻辑的一种方法是使用 **高阶组件** 模式。此模式允许我们在整个应用程序中重用组件逻辑。
高阶组件 (HOC) 是一个接收另一个组件的组件。HOC 包含我们想要应用于作为参数传递的组件的某些逻辑。在应用该逻辑后,HOC 将返回包含附加逻辑的元素。
假设我们始终希望在应用程序中的多个组件中添加特定样式。与其每次都在本地创建 style
对象,不如简单地创建一个 HOC,将 style
对象添加到传递给它的组件。
function withStyles(Component) {
return props => {
const style = { padding: '0.2rem', margin: '1rem' }
return <Component style={style} {...props} />
}
}
const Button = () = <button>Click me!</button>
const Text = () => <p>Hello World!</p>
const StyledButton = withStyles(Button)
const StyledText = withStyles(Text)
我们刚刚创建了 StyledButton 和 StyledText 组件,它们是 Button 和 Text 组件的修改版本。现在它们都包含在 withStyles
HOC 中添加的样式!
让我们看一下之前在容器/展示模式中使用过的同一个 DogImages 示例!该应用程序只做一件事,就是从 API 获取并渲染一列狗狗图片。
让我们稍微改善一下用户体验。当我们正在获取数据时,我们希望向用户显示一个 “加载中...”
屏幕。与其直接将数据添加到 DogImages
组件中,不如使用一个高阶组件,为我们添加此逻辑。
让我们创建一个名为 withLoader
的 HOC。HOC 应该接收一个组件,并返回该组件。在本例中,withLoader
HOC 应该接收应该显示 Loading…
直到数据被获取的元素。
让我们创建我们想要使用的 withLoader
HOC 的基本版本!
function withLoader(Element) {
return (props) => <Element />;
}
但是,我们不仅希望返回它接收的元素。相反,我们希望此元素包含告诉我们数据是否仍在加载的逻辑。
为了使 withLoader
HOC 非常可重用,我们不会在该组件中硬编码 Dog API 的 URL。相反,我们可以将 URL 作为参数传递给 withLoader
HOC,以便此加载程序可以在需要加载指示器(同时从不同的 API 端点获取数据)的任何组件上使用。
function withLoader(Element, url) {
return (props) => {};
}
HOC 返回一个元素,在本例中是一个函数式组件 props => {}
,我们希望在其中添加允许我们显示带有 Loading…
的文本的逻辑,因为数据仍在获取中。一旦数据被获取,该组件就应该将获取到的数据作为属性传递。
1import React, { useEffect, useState } from "react";23export default function withLoader(Element, url) {4 return (props) => {5 const [data, setData] = useState(null);67 useEffect(() => {8 async function getData() {9 const res = await fetch(url);10 const data = await res.json();11 setData(data);12 }1314 getData();15 }, []);1617 if (!data) {18 return <div>Loading...</div>;19 }2021 return <Element {...props} data={data} />;22 };23}
完美!我们刚刚创建了一个可以接收任何组件和 URL 的 HOC。
- 在
useEffect
挂钩中,withLoader
HOC 从我们作为url
值传递的 API 端点获取数据。在数据尚未返回之前,我们返回包含Loading...
文本的元素。 - 一旦数据被获取,我们就将
data
设置为已获取的数据。由于data
不再为null
,因此我们可以显示传递给 HOC 的元素!
那么,我们如何将此行为添加到应用程序中,以便它实际在 DogImages
列表上显示 Loading...
指示器呢?
在 DogImages.js
中,我们不再希望只导出普通的 DogImages
组件。相反,我们希望导出围绕 DogImages
组件的“包装”withLoading
HOC。
export default withLoading(DogImages);
withLoader
HOC 还期望 URL 知道要从哪个端点获取数据。在本例中,我们希望添加 Dog API 端点。
export default withLoader(
DogImages,
"https://dog.ceo/api/breed/labrador/images/random/6"
);
由于 withLoader
HOC 返回了带有一个额外的 data
属性的元素(在本例中为 DogImages
),因此我们可以在 DogImages
组件中访问 data
属性。
1import React from "react";2import withLoader from "./withLoader";34function DogImages(props) {5 return props.data.message.map((dog, index) => (6 <img src={dog} alt="Dog" key={index} />7 ));8}910export default withLoader(11 DogImages,12 "https://dog.ceo/api/breed/labrador/images/random/6"13);
完美!现在我们会在获取数据时看到一个 Loading...
屏幕。
高阶组件模式允许我们为多个组件提供相同的逻辑,同时将所有逻辑都保留在一个地方。withLoader
HOC 不关心它接收的组件或 URL:只要它是一个有效的组件和一个有效的 API 端点,它就会简单地将来自该 API 端点的数据传递给我们传递的组件。
组合
我们还可以组合多个高阶组件。假设我们还想添加一个功能,当用户将鼠标悬停在 DogImages
列表上时显示一个 Hovering!
文本框。
我们需要创建一个 HOC,它为我们传递的元素提供一个 hovering
属性。根据该属性,我们可以根据用户是否将鼠标悬停在 DogImages
列表上,有条件地渲染文本框。
1import React, { useState } from "react";23export default function withHover(Element) {4 return props => {5 const [hovering, setHover] = useState(false);67 return (8 <Element9 {...props}10 hovering={hovering}11 onMouseEnter={() => setHover(true)}12 onMouseLeave={() => setHover(false)}13 />14 );15 };16}
现在,我们可以将 withHover
HOC 包裹在 withLoader
HOC 周围。
1import React from "react";2import withLoader from "./withLoader";3import withHover from "./withHover";45function DogImages(props) {6 return (7 <div {...props}>8 {props.hovering && <div id="hover">Hovering!</div>}9 <div id="list">10 {props.data.message.map((dog, index) => (11 <img src={dog} alt="Dog" key={index} />12 ))}13 </div>14 </div>15 );16}1718export default withHover(19 withLoader(DogImages, "https://dog.ceo/api/breed/labrador/images/random/6")20);
DogImages
元素现在包含我们从 withHover
和 withLoader
传递的所有属性。现在,我们可以根据 hovering
属性的值是 true
还是 false
有条件地渲染 Hovering!
文本框。
一个用于组合 HOC 的知名库是 recompose。由于 HOC 在很大程度上可以被 React Hooks 替换,因此 recompose 库不再维护,因此本文不会介绍。
Hooks
在某些情况下,我们可以用 React Hooks 替换 HOC 模式。
让我们用 useHover
挂钩替换 withHover
HOC。与其使用高阶组件,不如导出一个挂钩,将 mouseOver
和 mouseLeave
事件监听器添加到元素中。我们不能再像使用 HOC 那样传递元素。相反,我们将从挂钩中返回一个 ref
,该 ref
应该获取 mouseOver
和 mouseLeave
事件。
1import { useState, useRef, useEffect } from "react";23export default function useHover() {4 const [hovering, setHover] = useState(false);5 const ref = useRef(null);67 const handleMouseOver = () => setHover(true);8 const handleMouseOut = () => setHover(false);910 useEffect(() => {11 const node = ref.current;12 if (node) {13 node.addEventListener("mouseover", handleMouseOver);14 node.addEventListener("mouseout", handleMouseOut);1516 return () => {17 node.removeEventListener("mouseover", handleMouseOver);18 node.removeEventListener("mouseout", handleMouseOut);19 };20 }21 }, [ref.current]);2223 return [ref, hovering];24}
useEffect
挂钩向组件添加事件监听器,并将 hovering
值设置为 true
或 false
,具体取决于用户是否当前将鼠标悬停在元素上。ref
和 hovering
值都需要从挂钩中返回:ref
用于向应该接收 mouseOver
和 mouseLeave
事件的组件添加引用,以及 hovering
用于能够有条件地渲染 Hovering!
文本框。
与其用 withHover
HOC 包裹 DogImages
组件,不如直接在 DogImages
组件中使用 useHover
挂钩。
1import React from "react";2import withLoader from "./withLoader";3import useHover from "./useHover";45function DogImages(props) {6 const [hoverRef, hovering] = useHover();78 return (9 <div ref={hoverRef} {...props}>10 {hovering && <div id="hover">Hovering!</div>}11 <div id="list">12 {props.data.message.map((dog, index) => (13 <img src={dog} alt="Dog" key={index} />14 ))}15 </div>16 </div>17 );18}1920export default withLoader(21 DogImages,22 "https://dog.ceo/api/breed/labrador/images/random/6"23);
完美!与其用 withHover
组件包裹 DogImages
组件,不如直接在组件中使用 useHover
挂钩。
一般来说,React Hooks 不会替换 HOC 模式。
“在大多数情况下,Hooks 已经足够了,而且可以帮助减少树中的嵌套。” - React 文档
正如 React 文档告诉我们的那样,使用 Hooks 可以减少组件树的深度。使用 HOC 模式,很容易最终得到一个深度嵌套的组件树。
<withAuth>
<withLayout>
<withLogging>
<Component />
</withLogging>
</withLayout>
</withAuth>
通过直接向组件添加 Hooks,我们不再需要包裹组件。
使用高阶组件可以为多个组件提供相同的逻辑,同时将该逻辑都保留在一个地方。Hooks 允许我们从组件内部添加自定义行为,与 HOC 模式相比,如果多个组件依赖于此行为,则可能会增加引入错误的风险。
HOC 的最佳使用场景:
- 整个应用程序的许多组件都需要使用 相同、未定制的 行为。
- 该组件可以独立工作,无需添加自定义逻辑。
Hooks 的最佳使用场景:
- 该行为必须针对使用它的每个组件进行定制。
- 该行为不会在整个应用程序中传播,只有一个或几个组件使用该行为。
- 该行为向组件添加了许多属性
案例研究
一些依赖于 HOC 模式的库在发布后添加了 Hooks 支持。一个很好的例子是 Apollo Client。
无需了解 Apollo Client 即可理解此示例。
使用 Apollo Client 的一种方法是通过 graphql()
高阶组件。
1import React from "react";2import "./styles.css";34import { graphql } from "react-apollo";5import { ADD_MESSAGE } from "./resolvers";67class Input extends React.Component {8 constructor() {9 super();10 this.state = { message: "" };11 }1213 handleChange = (e) => {14 this.setState({ message: e.target.value });15 };1617 handleClick = () => {18 this.props.mutate({ variables: { message: this.state.message } });19 };2021 render() {22 return (23 <div className="input-row">24 <input25 onChange={this.handleChange}26 type="text"27 placeholder="Type something..."28 />29 <button onClick={this.handleClick}>Add</button>30 </div>31 );32 }33}3435export default graphql(ADD_MESSAGE)(Input);
使用 graphql()
HOC,我们可以将来自客户端的数据提供给被高阶组件包裹的组件!虽然我们目前仍然可以使用 graphql()
HOC,但使用它有一些缺点。
当组件需要访问多个解析器时,我们需要组合多个 graphql()
高阶组件才能做到这一点。组合多个 HOC 会使理解数据如何传递到组件变得困难。在某些情况下,HOC 的顺序很重要,这在重构代码时很容易导致错误。
在 Hooks 发布后,Apollo 向 Apollo Client 库添加了 Hooks 支持。与其使用 graphql()
高阶组件,开发人员现在可以直接通过库提供的挂钩访问数据。
让我们看一个使用与之前在 graphql()
高阶组件示例中看到的数据完全相同的数据的示例。这次,我们将使用 Apollo Client 为我们提供的 useMutation
挂钩向组件提供数据。
1import React, { useState } from "react";2import "./styles.css";34import { useMutation } from "@apollo/react-hooks";5import { ADD_MESSAGE } from "./resolvers";67export default function Input() {8 const [message, setMessage] = useState("");9 const [addMessage] = useMutation(ADD_MESSAGE, {10 variables: { message }11 });1213 return (14 <div className="input-row">15 <input16 onChange={(e) => setMessage(e.target.value)}17 type="text"18 placeholder="Type something..."19 />20 <button onClick={addMessage}>Add</button>21 </div>22 );23}
通过使用 useMutation
挂钩,我们减少了向组件提供数据所需的代码量。
除了减少样板代码,在组件中使用多个解析器的结果也变得更加容易。我们不再需要组合多个高阶组件,只需在组件中编写多个钩子即可。这样,了解数据如何传递到组件变得更加容易,并且在重构组件或将组件分解成更小的部分时,可以改善开发人员体验。
优点
使用高阶组件模式可以让我们将要重用的逻辑都集中在一个地方。这减少了通过反复复制代码而意外将错误传播到整个应用程序中的风险,并且可能在每次复制时引入新的错误。通过将逻辑集中在一个地方,我们可以保持代码DRY
并轻松强制执行关注点分离。
缺点
HOC 可以传递给元素的 prop 的名称可能会导致命名冲突。
function withStyles(Component) {
return props => {
const style = { padding: '0.2rem', margin: '1rem' }
return <Component style={style} {...props} />
}
}
const Button = () = <button style={{ color: 'red' }}>Click me!</button>
const StyledButton = withStyles(Button)
在这种情况下,withStyles
HOC 为我们传递给它的元素添加了一个名为style
的 prop。但是,Button
组件已经有一个名为style
的 prop,它将被覆盖!请确保 HOC 可以处理意外的命名冲突,方法是重命名 prop 或合并 prop。
function withStyles(Component) {
return props => {
const style = {
padding: '0.2rem',
margin: '1rem',
...props.style
}
return <Component style={style} {...props} />
}
}
const Button = () = <button style={{ color: 'red' }}>Click me!</button>
const StyledButton = withStyles(Button)
当使用多个组合的 HOC 时,它们都将 prop 传递给包装在其中的元素,很难确定哪个 HOC 负责哪个 prop。这可能会阻碍调试和轻松扩展应用程序。