你好,我是王沛。今天我们来聊聊函数组件中的设计模式。
所谓设计模式,就是针对特定场景,提供一种公认的最佳实践。在前面的课程中,我们已经提到了不少模式,比如保证状态的唯一数据源,语义化的拆分复杂组件,等等。熟练掌握这些模式,可以让我们的代码更加简洁直观。
那么今天这节课我会介绍另外两个模式:
第2讲我们介绍了 Hooks 的一个重要规则,即:Hooks 必须在顶层作用域调用,而不能放在条件判断、循环等语句中,同时也不能在可能的 return 语句之后执行。换句话说,Hooks 必须按顺序被执行到。
这个规则存在的原因就在于,React 需要在函数组件内部维护所用到的 Hooks 的状态,所以我们无法在条件语句中使用 Hooks,这因而会给我们实现业务逻辑带来一定的局限。
比如说,对于一个对话框组件,通过 visible 属性来控制是否显示。那么在 visible 为 false 的时候,其实不应该执行任何对话框内部的逻辑,因为还没展示在 UI 上。
需要注意,只有在 visible 为true 的时候才应该去执行业务逻辑,展现数据。那么我们期望的代码可能是下面的方式:
import { Modal } from "antd";
import useUser from "../09/useUser";
function UserInfoModal({ visible, userId, ...rest }) {
// 当 visible 为 false 时,不渲染任何内容
if (!visible) return null;
// 这一行 Hook 在可能的 return 之后,会报错!
const { data, loading, error } = useUser(userId);
return (
<Modal visible={visible} {...rest}>
{/* 对话框的内容 */}
</Modal>
);
};
可以看到,我们期望在对话框隐藏时通过返回 null 不去渲染任何内容,这个逻辑看上去非常自然直观。
但是呢,它却通不过编译,因为在 return 语句之后使用了 useUser 这个 Hook。所以在你的编辑器配置了 React Hooks 的 ESLint 插件之后,会给出下面的错误提示:
可以看到,因为 Hooks 使用规则的存在,使得有时某些逻辑无法直观地实现。换句话说,Hooks 在带来众多好处的同时,也或多或少带来了一些局限。因此,我们需要用一个间接的模式来实现这样的逻辑,可以称之为容器模式。
具体做法就是把条件判断的结果放到两个组件之中,确保真正 render UI 的组件收到的所有属性都是有值的。
针对刚才我们讲的例子,就可以在 UserInfoModal 外层加一个容器,这样就能实现条件渲染了。实现的代码如下:
// 定义一个容器组件用于封装真正的 UserInfoModal
export default function UserInfoModalWrapper({
visible,
...rest, // 使用 rest 获取除了 visible 之外的属性
}) {
// 如果对话框不显示,则不 render 任何内容
if (!visible) return null;
// 否则真正执行对话框的组件逻辑
return <UserInfoModal visible {...rest} />;
}
这样的话,我们就间接实现了按条件去执行 Hooks 的逻辑。
在实际的使用场景中,可能判断条件不止 visible 一个属性,而会是一些属性的组合,来具体决定 render 什么内容。虽然这样的做法不够直观,但其实也能带来一些好处。比如说,在函数组件你会少写一些条件判断语句,并且确保每个组件尽量短小,这样反而更加易读和维护。
在容器模式中我们其实也可以看到,条件的隔离对象是多个子组件,这就意味着它通常用于一些比较大块逻辑的隔离。所以对于一些比较细节的控制,其实还有一种做法,就是把判断条件放到 Hooks 中去。
比如上节课的例子,我们需要先发送请求,获得文章信息,从而知道作者的 ID 是什么,这样才能用 useUser 这个 Hook 去获取用户数据。
那么直观的写法是下面这样的:
const ArticleView = ({ id }) => {
const { data: article, loading } = useArticle(id);
let user = null;
if (article?.userId) user = useUser(article?.userId).data;
// 组件其它逻辑
}
可以看到,我们需要的 article 这个对象获取到之后,才能去用 useUser 这个 Hook 再去获取用户信息。那么同样的,既然 Hook 不能放到条件语句中,那我们应该如何做呢?
事实上,上一讲的例子已经给出了答案,那就是把条件语句自包含在 Hook 之中。这样当没有传递 userId 给 useUser 这个 Hook 的时候,副作用里实际上什么也不做,比如:
function useUser(id) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
// 当 id 不存在,直接返回,不发送请求
if (!id) return
// 获取用户信息的逻辑
});
}
可以看到,在 useEffect 中我们会判断 ID 是否存在。如果不存在,就不发送请求。这样的话,这个 Hook 就可以在组件中无条件使用了。
总体来说,通过这样一个容器模式,我们把原来需要条件运行的 Hooks 拆分成子组件,然后通过一个容器组件来进行实际的条件判断,从而渲染不同的组件,实现按条件渲染的目的。这在一些复杂的场景之下,也能达到拆分复杂度,让每个组件更加精简的目的。
对于 React 开发而言,如果要挑选一个最重要的设计模式,那一定是 render props。因为它解决了 UI 逻辑的重用问题,不仅适用于 Class 组件,在函数组件的场景下也不可或缺。
鉴于大家日常交流都习惯用这个英文的名字,所以这里我也就不翻译成中文了。顾名思义,render props 就是把一个 render 函数作为属性传递给某个组件,由这个组件去执行这个函数从而 render 实际的内容。
在 Class 组件时期,render props 和 HOC(高阶组件)两种模式可以说是进行逻辑重用的两把利器,但是实际上,HOC 的所有场景几乎都可以用 render props 来实现。可以说,Hooks是逻辑重用的第一选择。
不过在如今的函数组件情况下,Hooks 有一个局限,那就是只能用作数据逻辑的重用,而一旦涉及 UI 表现逻辑的重用,就有些力不从心了,而这正是 render props 擅长的地方。所以,即使有了 Hooks,我们也要掌握 render props 这个设计模式的用法。
为了方便你理解 render props 这个模式,我先给你举一个数据逻辑重用的简单例子。这个例子仍然是我们熟悉的计数器。有两个按钮,加一和减一,并将当前值显示在界面上。执行的效果如下图所示:
如果不考虑 UI 的展现,这里要抽象的业务逻辑就是计数逻辑,包括三个部分:
如果用 render props 模式把这部分逻辑封装起来,那就可以在不同的组件中使用,由使用的组件自行决定 UI 如何展现。下面的代码就是这个计数器的 render props 的实现:
import { useState, useCallback } from "react";
function CounterRenderProps({ children }) {
const [count, setCount] = useState(0);
const increment = useCallback(() => {
setCount(count + 1);
}, [count]);
const decrement = useCallback(() => {
setCount(count - 1);
}, [count]);
return children({ count, increment, decrement });
}
可以看到,我们要把计数逻辑封装到一个自己不 render 任何 UI 的组件中,那么在使用的时候可以用如下的代码:
function CounterRenderPropsExample() {
return (
<CounterRenderProps>
{({ count, increment, decrement }) => {
return (
<div>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
);
}}
</CounterRenderProps>
);
}
这里利用了 children 这个特殊属性。也就是组件开始 tag 和结束tag 之间的内容,其实是会作为 children 属性传递给组件。那么在使用的时候,是直接传递了一个函数过去,由实现计数逻辑的组件去调用这个函数,并把相关的三个参数count,increase 和 decrease 传递给这个函数。
当然,我们完全也可以使用其它的属性名字,而不是 children。我们只需要把这个 render 函数作为属性传递给组件就可以了,这也正是 render props 这个名字的由来。
这个例子演示了纯数据逻辑的重用,也就是重用的业务逻辑自己不产生任何 UI。那么在这种场景下,其实用 Hooks 是更方便的,在第6讲中,我其实已经给过这么计数器的 Hooks 实现的例子,代码如下:
import { useState, useCallback }from 'react';
function useCounter() {
// 定义 count 这个 state 用于保存当前数值
const [count, setCount] = useState(0);
// 实现加 1 的操作
const increment = useCallback(() => setCount(count + 1), [count]);
// 实现减 1 的操作
const decrement = useCallback(() => setCount(count - 1), [count]);
// 将业务逻辑的操作 export 出去供调用者使用
return { count, increment, decrement };
}
很显然,使用 Hooks 的方式是更简洁的。这也是为什么我们经常说 Hooks 能够替代 render props 这个设计模式。但是,需要注意的是,Hooks 仅能替代纯数据逻辑的 render props。如果有 UI 展示的逻辑需要重用,那么我们还是必须借助于 render props 的逻辑,这就是我一再强调必须要掌握 render props 这种设计模式的原因。
为了解释这个用法,我给你举一个例子。比如,我们需要显示一个列表,如果超过一定数量,则把多余的部分折叠起来,通过一个弹出框去显示。下面这张图可以比较直观地展示这个需求的实际运行效果:
可以看到,这里展示了两个列表。一个只显示用户名,这在一些社交软件的界面上很常见,只显示几个点赞的用户,多余的用一个数字来表示,鼠标移上去则跳转或者显示完整列表。
另外一个是表格,但是也只显示前面5个,多余的折叠到 “更多...” 里面。比如说,对于第一个,鼠标移上去后的效果如下图所示:
我们来分析一下。对于这一类场景,功能相同的部分是:数据超过一定数量时,显示一个 “更多...”的文字;鼠标移上去则弹出一个框,用于显示其它的数据。
功能不同的部分是:每一个列表项如何渲染,是在使用的时候决定的。
因此,对于这一类具有 UI 逻辑重用需求的场景,我们就无法通过 Hooks 实现,而是需要通过 render props 这个设计模式。
下面这段代码展示了如何实现这个包含了 render props 的 ListWithMore 组件:
import { Popover } from "antd";
function ListWithMore({ renderItem, data = [], max }) {
const elements = data.map((item, index) => renderItem(item, index, data));
const show = elements.slice(0, max);
const hide = elements.slice(max);
return (
<span className="exp-10-list-with-more">
{show}
{hide.length > 0 && (
<Popover content={<div style={{ maxWidth: 500 }}>{hide}</div>}>
<span className="more-items-wrapper">
and{" "}
<span className="more-items-trigger"> {hide.length} more...</span>
</span>
</Popover>
)}
</span>
);
}
可以看到,这个组件接收了三个参数,分别是:
这样,任何有类似需求的场景就都可以用这个组件去实现了。下面这段代码展示了上面示意图中两个场景的实现代码,你可以体会一下:
// 这里用一个示例数据
import data from './data';
function ListWithMoreExample () => {
return (
<div className="exp-10-list-with-more">
<h1>User Names</h1>
<div className="user-names">
Liked by:{" "}
<ListWithMore
renderItem={(user) => {
return <span className="user-name">{user.name}</span>;
}}
data={data}
max={3}
/>
</div>
<br />
<br />
<h1>User List</h1>
<div className="user-list">
<div className="user-list-row user-list-row-head">
<span className="user-name-cell">Name</span>
<span>City</span>
<span>Job Title</span>
</div>
<ListWithMore
renderItem={(user) => {
return (
<div className="user-list-row">
<span className="user-name-cell">{user.name}</span>
<span>{user.city}</span>
<span>{user.job}</span>
</div>
);
}}
data={data}
max={5}
/>
</div>
</div>
);
};
可以看到,代码里使用了两个 ListWithMore 组件,通过 renderItem 这个属性,我们可以自主决定该如何渲染每一个列表项,从而把一部分 UI 逻辑抽象出来,形成一个可复用的逻辑,以简化不同场景的使用。
在今天这节课,我们介绍了两个设计模式。
一个是容器模式,可以实现类似于按条件执行 Hooks 的功能。虽然这是一个间接的方式,但是能够帮助我们更好地做组件逻辑的分离。
第二个则是经典的 render props 模式,虽然它和 Hooks 没有任何关系,但它可以作为 Hooks 的一个补充。在我们需要重用某些 UI 逻辑的时候,提供一个实现方案。
课程中的代码,以及在线运行效果的链接在这里: https://codesandbox.io/s/react-hooks-course-20vzg 。你可以 fork 后自己动手尝试。
思考一下你做过的项目,你能想到哪些 render props 模式的使用场景呢?欢迎交流分享。