在 React 中,有多种复用组件、数据和逻辑的技术,例如 HOC(高阶组件)和 Custom Hook(自定义 Hook)。这一次,让我们来了解一下其中的 render props 模式。
什么是 Render Props 模式?
简单来说,Render Props 是一种模式,通过将返回 JSX 的函数作为 prop 传递给组件,从而将要渲染的内容委托给父组件(外部)。下面是一个简单的示例。(顺便说一下,prop 的名称不一定非得是 render。)
// (1) 提供外壳的组件
const Box = ({ render }) => {
return (
<div style={{ border: '2px solid blue', padding: '20px', borderRadius: '8px' }}>
{render()}
</div>
);
};
// (2) 使用部分:“我想放入文本”
<Box render={() => <span>这是一个简单的文本。</span>} />
// (3) 使用部分:“我想放入图片”
<Box render={() => <img src="logo.png" alt="Logo" />} />正如你所看到的,这是一种接收返回 JSX 的函数作为 prop 并调用它的模式。因为它非常简单,目前可能还不清楚为什么这个模式有用,或者该如何使用它。
传递数据的 Render Props
让我们再进一步。例如,子组件管理着某些特定数据,但根据父组件的不同,可能需要以不同的方式显示这些数据。
// (1) 持有数据的组件
const UserProvider = ({ render }) => {
const user = { name: "Sanghyeon", role: "Developer" };
// 将持有的数据放入 render 函数中发送
return <div>{render(user)}</div>;
};
// (2) 使用部分 A:只想放大显示名称时
<UserProvider render={(user) => <h1>{user.name}</h1>} />
// (3) 使用部分 B:想同时查看角色和名称时
<UserProvider render={(user) => <p>{user.name} ({user.role})</p>} />通过这种方式,我们可以从外部(父组件)自由地处理和渲染组件持有的数据。
利用 children prop
在上面的示例中,我们使用了名为 render 的 prop,但利用 children prop 更为常见。使用这种方式,它会像普通组件一样包裹子元素,从而提高可读性。
// (1) 运行 children 作为函数,而不是 render prop
const UserProvider = ({ children }) => {
const user = { name: "Sanghyeon", role: "Developer" };
return <div>{children(user)}</div>;
};
// (2) 使用部分:结构变得更加直观。
<UserProvider>
{(user) => (
<div>
<h1>{user.name}</h1>
<p>{user.role}</p>
</div>
)}
</UserProvider>使用 Hooks 替代
但是,当前的代码也可以完全用 Hook 替代。如果只是单纯处理数据,使用 Hook 可能会更简洁、更简单。
// 转换为 Hook 的代码
const useUser = () => {
const user = { name: "Sanghyeon", role: "Developer" };
return user;
};
// (1) 使用部分 A:大号名称
function LargeName() {
const user = useUser();
return <h1>{user.name}</h1>;
}
// (2) 使用部分 B:角色和名称
function NameAndRole() {
const user = useUser();
return <p>{user.name} ({user.role})</p>;
}在现代 React 开发中,许多 Render Props 模式已被 Hook 取代。然而,有一些领域是 Hook 难以解决的。那就是 “当逻辑必须与特定的 DOM 结构结合时”。
实际应用案例
让我们来实现无限滚动。实现无限滚动有多种方法,但假设我们使用 IntersectionObserver 和一个用于检测滚动终点的哑组件(dummy component)来实现。
function PostListContainer() {
const { items, fetchNextPage, hasMore, isLoading } = useInfiniteScrollHook();
const observerRef = useRef(null); // 由开发者直接管理
return (
<div>
<PostList items={items} />
// (1) 不便之处:每次都必须在这里手动根据数据 (isLoading) 处理 UI
{isLoading && <MyCustomSpinner />}
// (2) 不便之处:必须记得手动插入用于逻辑的“哑 div”
{hasMore && <div ref={observerRef} style={{ height: '20px' }} />}
</div>
);
}Hook 只提供逻辑,ref 和 DOM 的连接必须由开发者手动完成。如果这种逻辑重复出现,将会是一项相当繁琐的工作。
使用 Render Props 方式修改
// (1) Render Props 组件
function InfiniteScroll({ onLoadMore, hasMore, children }) {
const [isLoading, setIsLoading] = useState(false);
const observerRef = useRef(null);
useEffect(() => {
if (!hasMore || isLoading) return;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setIsLoading(true);
onLoadMore().finally(() => setIsLoading(false));
}
});
if (observerRef.current) observer.observe(observerRef.current);
return () => observer.disconnect();
}, [hasMore, onLoadMore, isLoading]);
return (
<div>
// 将当前加载状态传递给子函数
{children(isLoading)}
// 组件负责必不可少的哑元素
{hasMore && <div ref={observerRef} style={{ height: '20px' }} />}
</div>
);
}
// (2) 使用部分:接收内部状态 isLoading 并决定 UI
<InfiniteScroll hasMore={hasMore} onLoadMore={fetchNextPage}>
{(isLoading) => (
<>
<PostList items={items} />
// 由父组件决定是将加载条放在列表正下方,还是在右上角显示一个小 loading
{isLoading && <MyCustomSpinner />}
</>
)}
</InfiniteScroll>使用 Render Props 模式,可以将逻辑和必不可少的 DOM 结构作为一套方案提供。使用者不再需要关心 observerRef,并且可以通过 children prop 传递 isLoading 来从外部控制如何显示。
优点和缺点
优点
- 逻辑与结构的完全封装: 正如
InfiniteScroll示例,当逻辑运行必须依赖特定的 DOM 元素(如哑 div)时,可以将其隐藏在组件内部,从而最大化复用性。 - 控制权委派: 数据由组件管理,但“如何显示”完全由使用的父组件决定。因此可以实现非常灵活的 UI 构成。
缺点
- Render Prop Hell(嵌套问题): 如果嵌套使用多个 Render Props,代码深度会增加,可读性可能会急剧下降。
- 相比 Hook 代码较冗长: 如果只是简单的逻辑共享,编写的代码会比使用 Hook 更多,结构看起来也更复杂。
总结
对于单纯复用数据或逻辑,使用 Hook 就足够了。但如果你想封装逻辑和 DOM 结构,不妨尝试使用 Render Props 模式。
如果仍然不知道如何应用,这里有一个 使用 Render Props 模式的库集合,可以从中获取灵感!
参考