React中的高阶组件,也被称为HOCs,是React中的一种高级组件模式(仅次于Render Props组件)。高阶组件可以用于多种用例。我想挑出一个用例,即用Higher-Order Components进行条件渲染,作为一个学习者,从这篇文章中得到两个结果:
-
首先,它应该让你了解React的Higher-Order Components与条件渲染的用例。请记住,用高阶组件改变一个组件的外观,特别是在条件渲染的背景下,只是使用HOCs的几种情况之一。例如,你也可以用它们来选择本地状态或改变道具。
-
其次,尽管你可能已经知道了HOCs,但这篇文章通过在React中组成高阶组件和应用函数式编程原则而更进一步。你将会知道如何以优雅的方式使用高阶组件。
为了学习React的高阶组件,文章着重介绍了条件渲染的使用情况。React中的条件渲染可以以多种方式应用。你可以使用if-else语句,三元运算符,或逻辑&&运算符。你可以在另一篇关于React中的条件渲染的文章中阅读更多关于不同的方式。
React 钩子与高阶组件
我在这里写过为什么React Hooks比Higher-Order Components要好。然而,即使在现代React中,我也是React中高阶组件的拥护者。虽然大多数开发者说React Hooks使React更倾向于函数式编程的方向,但我认为这恰恰相反。高阶组件使我们能够通过拥抱组合在组件上应用函数式编程原则。相反,React Hooks将纯粹的(在函数式编程的意义上)函数式组件转变为有状态/副作用的野兽。
无论如何,两者都有其存在的权利。虽然React Hooks是从内部给函数组件添加实现细节(例如状态、副作用)的现状,但React高阶组件是从外部给函数(和类组件)添加味道。HOC是在实际组件内部执行其实现细节(如React Hooks)之前保护组件的完美盾牌。我们将在下面的具体用例中看到这一点是成立的。
高阶组件。用例
我们将从一个问题开始,React中的高阶组件可以作为一个解决方案来使用。让我们有一个列表组件作为React中的功能组件,它只是用来渲染一个项目的列表。列表组件从App组件接收其数据。
import * as React from 'react';
const TODOS = [ { id: '1', task: 'Do this', completed: true }, { id: '2', task: 'Do that', completed: false },];
const App = () => { return <TodoList data={TODOS} />;};
const TodoList = ({ data }) => { return ( <ul> {data.map((item) => ( <TodoItem key={item.id} item={item} /> ))} </ul> );};
const TodoItem = ({ item }) => { return ( <li> {item.task} {item.completed.toString()} </li> );};
export default App;
在现实世界的应用中,这个数据会从一个远程API中获取。下面的函数模拟了这个数据API,以保持这个例子的轻量级。然而,只要把fetchData() 作为一个最终返回数据的黑盒子函数就可以了。
const TODOS = [ { id: '1', task: 'Do this', completed: true }, { id: '2', task: 'Do that', completed: false },];
const fetchData = () => { return { data: TODOS };};
const App = () => { const { data } = fetchData();
return <TodoList data={data} />;};
应用程序将列表中的项目渲染出来。但大多数情况下这是不够的,因为你必须要处理所有的边缘情况。我所说的这些边缘情况是什么呢?
首先,如果你的数据在从API异步获取之前就被null ,会发生什么?你可以应用一个有条件的渲染来选择提前退出你的渲染。
const fetchData = () => { return { data: null };};
const App = () => { const { data } = fetchData();
if (!data) return <div>No data loaded yet.</div>;
return <TodoList data={data} />;};
第二,如果你的数据不是null ,而是空的,会发生什么?你会在有条件的渲染中显示一条信息,给你的用户提供反馈,以改善用户体验(UX)。
const fetchData = () => { return { data: [] };};
const App = () => { const { data } = fetchData();
if (!data) return <div>No data loaded yet.</div>; if (!data.length) return <div>Data is empty.</div>;
return <TodoList data={data} />;};
第三,由于数据是从你的后端异步到达的,你想显示一个加载指示器,以防数据在请求中被等待。因此,你会得到一个更多的属性,如'isLoading',以了解加载状态。
const fetchData = () => { return { data: null, isLoading: true };};
const App = () => { const { data, isLoading } = fetchData();
if (isLoading) return <div>Loading data.</div>; if (!data) return <div>No data loaded yet.</div>; if (!data.length) return <div>Data is empty.</div>;
return <TodoList data={data} />;};
好吧,我不想让这个例子变得更复杂(比如增加另一个错误状态),但你可以得到一个要点,即很多边缘情况可以在一个组件中增加,而这只是一个用例。
虽然这只是一个组件的纵向增加,以覆盖每一个边缘情况,但想象一下其他执行这种数据获取的组件的相同的选择退出条件的渲染。进入高阶组件,因为它们可以作为可重用的功能来屏蔽这些边缘情况。
React的高阶组件
高阶组件(HOC)源于高阶函数(HOF)的概念,只要它以一个函数为参数或以其返回语句返回一个函数,就会被这样称呼。后者在下一个例子中作为速记版本使用JavaScript中的箭头函数表达式进行了说明。
const multiply = (multiplier) => (multiplicand) => multiplicand * multiplier;
const product = multiply(3)(4);
console.log(product);// 12
而如果使用没有HOF的版本,只在一个函数中接受两个参数,也是完全可以的。
const multiply = (multiplier, multiplicand) => multiplicand * multiplier;
const product = multiply(3, 4);
console.log(product);// 12
我们可以看到,使用HOF与函数组合可以导致JavaScript中的函数式编程。
const multiply = (multiplier) => (multiplicand) => multiplicand * multiplier;
const subtract = (minuend) => (subtrahend) => subtrahend - minuend;
const result = compose( subtraction(2), multiply(4),)(3);
console.log(result);// 10
在这里不对JavaScript中的HOF做进一步的详细介绍,让我们在谈论React中的HOC时,对这整个概念进行梳理。在这里,我们将走过正常的函数,以其他函数(函数组件)为参数的函数,以及你在最后一个代码片段中看到的相互组成的函数。
高阶组件将任何React组件作为输入组件,并作为输出组件返回其增强版本。在我们的例子中,我们的目标是在父组件(App)和子组件(TodoList)之间屏蔽所有的条件渲染边缘情况,因为他们都不想被这些情况所困扰。
Component => EnhancedComponent
一个高阶组件的蓝图只是将一个组件作为输入,并将相同(读作:无增强)的组件作为输出,在实际代码中看起来总是这样。
const withHigherOrderComponent = (Component) => (props) => <Component {...props} />;
当创建一个高阶组件时,你总是从它的这个版本开始。一个Higher-Order Component的前缀是with (就像一个React Hook的前缀是use )。现在你可以在任何组件上调用这个HOC的蓝图,而不需要改变应用程序中任何与业务相关的东西。
const withHigherOrderComponent = (Component) => (props) => <Component {...props} />;
const App = () => { const { data, isLoading } = fetchData();
if (isLoading) return <div>Loading data.</div>; if (!data) return <div>No data loaded yet.</div>; if (!data.length) return <div>Data is empty.</div>;
return <TodoList data={data} />;};
const BaseTodoList = ({ data }) => { return ( <ul> {data.map((item) => ( <TodoItem key={item.id} item={item} /> ))} </ul> );};
const TodoList = withHigherOrderComponent(BaseTodoList);
理解最后一个代码片段是本教程中最重要的部分。我们所创建的高阶组件(这里是:withHigherOrderComponent )需要一个组件作为参数。在我们的例子中,我们使用重命名的BaseTodoList 作为输入组件,并从它返回一个新的增强的TodoList 组件。我们得到的基本上是一个包装好的函数组件。
// what we get back when we are calling the HOC(props) => <Component {...props} />;
基本上,它只是另一个函数组件,通过所有的React道具而不接触它们。在其核心部分,这里没有发生任何事情,原始组件只是被包裹在另一个(箭头)函数组件中,该组件并没有添加任何业务逻辑。
因此,返回的组件根本就没有被增强。但这种情况即将改变。让我们通过添加所有的条件渲染作为增强来使这个高阶组件变得有用。
const withConditionalFeedback = (Component) => (props) => { if (props.isLoading) return <div>Loading data.</div>; if (!props.data) return <div>No data loaded yet.</div>; if (!props.data.length) return <div>Data is empty.</div>;
return <Component {...props} />;};
const App = () => { const { data, isLoading } = fetchData();
return <TodoList data={data} isLoading={isLoading} />;};
const BaseTodoList = ({ data }) => { return ( <ul> {data.map((item) => ( <TodoItem key={item.id} item={item} /> ))} </ul> );};
const TodoList = withConditionalFeedback(BaseTodoList);
上一次重构将所有条件渲染的实现逻辑从App组件中移到了Higher-Order Component中。这是一个完美的地方,因为这样一来,App组件和它的子组件都不会被这个细节所困扰。
你可以想象这对React Hooks来说是多么的不合适。首先,通常React Hook不会返回有条件的JSX。其次,React Hook不是从外部保护一个组件,而是在内部增加实现细节。
这就是你需要知道的关于HOCs的基本原理的一切。你可以开始使用它们,或者通过向你的高阶组件添加配置或组合来进一步发展。
高阶组件的配置
如果一个高阶组件只接受一个组件,而不接受其他参数,那么与实现细节有关的一切都由高阶组件自己决定。然而,由于我们在JavaScript中拥有函数,我们可以从外部传递更多的信息作为参数,以获得更多的控制,作为这个高阶组件的用户。
const withHigherOrderComponent = (Component, configuration) => (props) => <Component {...props} />;
不过,只有那些需要从外部进行这种额外配置的高阶组件才应该添加它。为了对函数式编程范式保持友好(见后面的HOCs组成),我们通过一个单独的函数预先选择配置。
const withHigherOrderComponent = (configuration) => (Component) => (props) => <Component {...props} />;
这样一来,配置一个高阶组件本质上只是在它周围增加了一个包装函数。但为什么首先要为这个问题而烦恼呢?让我们回到我们之前的用例,向用户呈现条件性反馈。目前,反馈是非常通用的(例如:"数据是空的。")。通过从外部配置HOC,我们可以决定向我们的用户显示哪些反馈。
const withConditionalFeedback = (dataEmptyFeedback) => (Component) => (props) => { if (props.isLoading) return <div>Loading data.</div>; if (!props.data) return <div>No data loaded yet.</div>;
if (!props.data.length) return <div>{dataEmptyFeedback || 'Data is empty.'}</div>;
return <Component {...props} />; };
...
const TodoList = withConditionalFeedback('Todos are empty.')( BaseTodoList);
请看我们是如何在外部没有提供dataEmptyFeedback 的情况下,仍然使用一个通用的回退。让我们继续为其他可选的反馈信息提供服务。
const withConditionalFeedback = ({ loadingFeedback, noDataFeedback, dataEmptyFeedback }) => (Component) => (props) => { if (props.isLoading) return <div>{loadingFeedback || 'Loading data.'}</div>;
if (!props.data) return <div>{noDataFeedback || 'No data loaded yet.'}</div>;
if (!props.data.length) return <div>{dataEmptyFeedback || 'Data is empty.'}</div>;
return <Component {...props} />; };
...
const TodoList = withConditionalFeedback({ loadingFeedback: 'Loading Todos.', noDataFeedback: 'No Todos loaded yet.', dataEmptyFeedback: 'Todos are empty.',})(BaseTodoList);
为了保持所有这些信息都是可选择的,我们将传递一个配置对象而不是多个参数。这样,如果我们想选入第二个参数而不选入第一个参数,我们就不必处理传递null 作为参数。
毕竟,只要你想从外部配置一个高阶组件,就把高阶组件包裹在另一个函数中,并向它提供一个参数作为配置对象。然后你必须从外部调用高阶组件两次。第一次是为了配置它,第二次是为了用实现细节来增强实际的组件。
高阶组件的组成
高阶组件的伟大之处在于,它们只是一些函数,允许你将功能分成多个函数。以我们之前的高阶组件(还没有配置)为例,将其拆分为多个高阶组件。
const withLoadingFeedback = (Component) => (props) => { if (props.isLoading) return <div>Loading data.</div>; return <Component {...props} />;};
const withNoDataFeedback = (Component) => (props) => { if (!props.data) return <div>No data loaded yet.</div>; return <Component {...props} />;};
const withDataEmptyFeedback = (Component) => (props) => { if (!props.data.length) return <div>Data is empty.</div>; return <Component {...props} />;};
接下来你可以单独应用每个高阶组件。
const TodoList = withLoadingFeedback( withNoDataFeedback( withDataEmptyFeedback(BaseTodoList) ));
在将多个高阶组件应用到一个组件上时,有两个重要的注意点:
- 首先,顺序很重要。如果其中一个(如
withLoadingFeedback)的优先级高于另一个(如withNoDataFeedback),它应该是最外层的HOC,因为你想呈现加载指示器(如果isLoading是true),而不是 "还没有加载数据。"的反馈。 - 其次,HOCs可以相互依赖(这使得它们常常成为一个陷阱)。例如,
withDataEmptyFeedback依赖于它的兄弟姐妹withNoDataFeedback进行!data的空值检查。如果后者不在那里,!props.data.length的空检查就会出现空指针异常。不过,withLoadingFeedbackHOC是独立的。
这些都是一些众所周知的陷阱,我在React Hooks vs Higher-Order Components的文章中描述了使用(多个)HOC时的情况。
不管怎么说,在函数中调用函数似乎很啰嗦。不过,既然我们有了函数,我们可以在这里利用函数式编程的原则,以一种更可读的方式将这些函数相互组合起来。
const compose = (...fns) => fns.reduceRight((prevFn, nextFn) => (...args) => nextFn(prevFn(...args)), value => value );
const TodoList = compose( withLoadingFeedback, withNoDataFeedback, withDataEmptyFeedback)(BaseTodoList);
从本质上讲,compose() 函数将所有传递的参数(必须是函数)作为一个函数数组,并将它们从右到左应用到返回函数的参数中。值得注意的是,compose() 函数也是许多实用程序库(如Lodash)中的函数。然而,所示的实现对于这个用例来说已经足够了。
最后但并非最不重要的是,我们要把之前的高阶组件的配置带回来。首先,调整原子型高阶组件,使其再次使用配置,但这次只是一个字符串,而不是一个对象,因为我们只想用一个反馈信息来配置它(这次不是可选的):
const withLoadingFeedback = (feedback) => (Component) => (props) => { if (props.isLoading) return <div>{feedback}</div>; return <Component {...props} />;};
const withNoDataFeedback = (feedback) => (Component) => (props) => { if (!props.data) return <div>{feedback}</div>; return <Component {...props} />;};
const withDataEmptyFeedback = (feedback) => (Component) => (props) => { if (!props.data.length) return <div>{feedback}</div>; return <Component {...props} />;};
其次,在调用高阶函数时提供这个非可选的配置:
const TodoList = compose( withLoadingFeedback('Loading Todos.'), withNoDataFeedback('No Todos loaded yet.'), withDataEmptyFeedback('Todos are empty.'))(BaseTodoList);
你可以看到,除了使用一个额外的包装函数进行配置外,函数的组成使我们作为开发者在这里能够遵循函数式编程原则。如果其中一个高阶组件不接受配置,它仍然可以在这个组合中使用(只是不像其他接受配置的组件那样调用它)。
希望本教程能帮助你学习React中高阶组件的高级概念,同时对何时使用高阶组件而不是React Hooks有一个明确的立场。我们已经看到了HOCs在条件渲染方面的用例,然而还有很多(例如道具/状态改变,connect ,来自react-redux,它将一个组件连接到全局存储)。
最后但并非最不重要的是,我希望该指南能给你带来灵感,让你了解如何在React中使用高阶组件的函数式编程范式,通过使用高阶函数进行选择配置,保持函数的纯净,以及将函数相互组合。