使用 Render Props 模式分离逻辑与 UI

profile image

什么是 React 的 Render Props 模式?在什么时候、如何使用它?它与使用 Hooks 有什么区别?让我们通过示例来了解一下。

本帖由 Jetbrains's Coding Agent Junie junie logo翻译。如有任何翻译错误,请告知我们!

在 React 中,有多种复用组件、数据和逻辑的技术,例如 HOC(高阶组件)和 Custom Hook(自定义 Hook)。这一次,让我们来了解一下其中的 render props 模式。

什么是 Render Props 模式?

简单来说,Render Props 是一种模式,通过将返回 JSX 的函数作为 prop 传递给组件,从而将要渲染的内容委托给父组件(外部)。下面是一个简单的示例。(顺便说一下,prop 的名称不一定非得是 render。)

typescript
// (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

让我们再进一步。例如,子组件管理着某些特定数据,但根据父组件的不同,可能需要以不同的方式显示这些数据。

typescript
// (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 更为常见。使用这种方式,它会像普通组件一样包裹子元素,从而提高可读性。

typescript
// (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 可能会更简洁、更简单。

typescript
// 转换为 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)来实现。

typescript
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 方式修改

typescript
// (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 模式的库集合,可以从中获取灵感!


参考